Skip to content

Commit 4b3ea30

Browse files
fix(ops): schedule /api/cron/* from GitHub Actions and reconcile cutoff on usage change (#513)
Production runs on Dokploy, which does not read `vercel.json`. Querying the Dokploy API for schedules on the LMS app, the MCP app, and at server scope all return empty, and there is no separate host holding a crontab — so none of the seven `/api/cron/*` routes have ever run in production. Access-cutoff enforcement (#494), platform-subscription expiry (#462), the daily digest, league rollover and both payment reconcilers were all inert. Add `.github/workflows/cron.yml`, which schedules all seven routes using the `Authorization: Bearer $CRON_SECRET` header each route already implements, one schedule entry per cadence declared in `vercel.json`, plus a `workflow_dispatch` route picker for on-demand runs. It requires a `CRON_SECRET` repository secret and a `CRON_BASE_URL` repository variable, and fails loudly when either is missing rather than silently no-opping. `vercel.json` is left in place. Also call `reconcileAccessCutoff()` from `joinCurrentSchool()` and `createCourse()` immediately after their inserts succeed, wrapped in try/catch so a reconciliation or email failure can never fail a user action that already succeeded. This is defense-in-depth rather than a live bug fix: both pre-flight checks block at `>=` while `computePlanLimitViolations` flags at `>`, so a legitimate join or course creation stops at the limit. The value is in catching drift between the three independent limit implementations, and in clearing a stale cutoff as soon as a tenant drops back under its limit. Docs: `OPERATIONS_GUIDE.md` no longer tells the operator to configure Vercel Cron, and `DEPLOYMENT.md` §3.5 now lists all seven routes across three mutually-exclusive scheduling options instead of four routes in one. `solana-pull` remains deliberately unscheduled — it submits on-chain USDC transfers and belongs in its own reviewed change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xmiu65sTJVVgFjdn9xRKK2
1 parent b87b9da commit 4b3ea30

6 files changed

Lines changed: 522 additions & 5 deletions

File tree

.github/workflows/cron.yml

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Scheduled invocation of the app's /api/cron/* routes (issue #513).
2+
#
3+
# Production runs on Dokploy, which does not read `vercel.json` — so the seven
4+
# cron routes declared there had no scheduler at all and every non-payment
5+
# lifecycle job (access-cutoff enforcement, platform-subscription expiry, daily
6+
# digest, league rollover, payment reconciliation) was inert in production.
7+
#
8+
# Each route authenticates with the same `Authorization: Bearer $CRON_SECRET`
9+
# header it already implements, so no application code changed to support this.
10+
#
11+
# Required repository settings (Settings → Secrets and variables → Actions):
12+
# secret CRON_SECRET — must match the CRON_SECRET env var on the Dokploy app
13+
# variable CRON_BASE_URL — the production origin, e.g. https://preciopana.com
14+
# Both are checked at runtime; a missing one fails the run loudly rather than
15+
# silently doing nothing, which is the failure mode this workflow exists to fix.
16+
#
17+
# Caveats worth knowing before relying on this:
18+
# * GitHub delays scheduled runs under load, and drops high-frequency ones
19+
# first — the */10 reconcilers are the most affected. If exact cadence
20+
# matters for those, prefer a Dokploy scheduled task (see docs/DEPLOYMENT.md).
21+
# * GitHub disables scheduled workflows after 60 days with no repository
22+
# activity. Re-enable from the Actions tab if that happens.
23+
# * Run ONE mechanism. If a Dokploy schedule is added later, remove the
24+
# corresponding schedule here so routes don't fire twice.
25+
26+
name: Scheduled cron routes
27+
28+
on:
29+
schedule:
30+
# Keep these in sync with vercel.json and with the case block below.
31+
- cron: '*/10 * * * *' # solana-reconcile + binance-personal-reconcile
32+
- cron: '0 * * * *' # daily-digest — hourly by design (each tenant sends at its own local hour)
33+
- cron: '0 0 * * *' # expire-subscriptions
34+
- cron: '0 1 * * 1' # league-rollover (pg_cron also runs it Mondays 00:05; this is the fallback)
35+
- cron: '0 2 * * *' # expire-platform-subscriptions
36+
- cron: '0 3 * * *' # enforce-plan-limits
37+
workflow_dispatch:
38+
inputs:
39+
route:
40+
description: 'Cron route to run now'
41+
required: true
42+
type: choice
43+
options:
44+
- enforce-plan-limits
45+
- expire-platform-subscriptions
46+
- expire-subscriptions
47+
- daily-digest
48+
- league-rollover
49+
- solana-reconcile
50+
- binance-personal-reconcile
51+
# Never scheduled: solana-pull submits on-chain USDC transfers and has
52+
# never had a scheduler in vercel.json either. Available here for
53+
# manual runs only — putting it on a timer needs its own review.
54+
- solana-pull
55+
56+
permissions:
57+
contents: read
58+
59+
concurrency:
60+
# Serialize per trigger so a slow run can't overlap the next tick.
61+
group: cron-${{ github.event.schedule || inputs.route }}
62+
cancel-in-progress: false
63+
64+
jobs:
65+
invoke:
66+
name: Invoke cron route(s)
67+
runs-on: ubuntu-latest
68+
timeout-minutes: 15
69+
steps:
70+
- name: Resolve routes for this trigger
71+
id: resolve
72+
env:
73+
SCHEDULE: ${{ github.event.schedule }}
74+
DISPATCH_ROUTE: ${{ inputs.route }}
75+
run: |
76+
set -euo pipefail
77+
if [ -n "${DISPATCH_ROUTE:-}" ]; then
78+
routes="$DISPATCH_ROUTE"
79+
else
80+
case "${SCHEDULE:-}" in
81+
'*/10 * * * *') routes='solana-reconcile binance-personal-reconcile' ;;
82+
'0 * * * *') routes='daily-digest' ;;
83+
'0 0 * * *') routes='expire-subscriptions' ;;
84+
'0 1 * * 1') routes='league-rollover' ;;
85+
'0 2 * * *') routes='expire-platform-subscriptions' ;;
86+
'0 3 * * *') routes='enforce-plan-limits' ;;
87+
*)
88+
echo "::error::Schedule '${SCHEDULE:-}' has no route mapping. Add it to the case block in .github/workflows/cron.yml."
89+
exit 1
90+
;;
91+
esac
92+
fi
93+
echo "Resolved routes: $routes"
94+
echo "routes=$routes" >> "$GITHUB_OUTPUT"
95+
96+
- name: Invoke
97+
env:
98+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
99+
CRON_BASE_URL: ${{ vars.CRON_BASE_URL }}
100+
ROUTES: ${{ steps.resolve.outputs.routes }}
101+
run: |
102+
set -euo pipefail
103+
104+
if [ -z "${CRON_BASE_URL:-}" ]; then
105+
echo "::error::Repository variable CRON_BASE_URL is not set (expected e.g. https://preciopana.com)."
106+
exit 1
107+
fi
108+
if [ -z "${CRON_SECRET:-}" ]; then
109+
echo "::error::Repository secret CRON_SECRET is not set. It must match the CRON_SECRET env var on the Dokploy app."
110+
exit 1
111+
fi
112+
113+
base="${CRON_BASE_URL%/}"
114+
failed=0
115+
116+
for route in $ROUTES; do
117+
url="$base/api/cron/$route"
118+
echo "--- GET $url"
119+
# The secret is passed via env into the header and never printed;
120+
# GitHub also masks it in logs. Body is capped so a large digest
121+
# response can't flood the log.
122+
code="$(curl -sS -o /tmp/cron-body.txt -w '%{http_code}' \
123+
--max-time 600 --retry 2 --retry-delay 15 --retry-connrefused \
124+
-H "Authorization: Bearer $CRON_SECRET" \
125+
"$url")" || code='000'
126+
127+
echo "HTTP $code"
128+
head -c 2000 /tmp/cron-body.txt || true
129+
echo
130+
131+
if [ "$code" != '200' ]; then
132+
echo "::error::$route returned HTTP $code"
133+
failed=1
134+
fi
135+
done
136+
137+
exit "$failed"

app/actions/join-school.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getCurrentTenantId } from '@/lib/supabase/tenant'
66
import { revalidatePath } from 'next/cache'
77
import { sendEmail } from '@/lib/email/send'
88
import { joinedSchoolTemplate } from '@/lib/email/templates/joined-school'
9+
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
910

1011
/**
1112
* Join the current tenant as a student
@@ -109,6 +110,21 @@ export async function joinCurrentSchool() {
109110
return { success: false, error: 'Failed to join school. Please try again.' }
110111
}
111112

113+
// The membership row now exists, so the tenant's student count has changed —
114+
// reconcile the access cutoff at the moment usage moves rather than waiting up
115+
// to 24h for the nightly sweep (issue #513). The pre-flight check above blocks
116+
// at `>=` while `computePlanLimitViolations` flags at `>`, so a legitimate join
117+
// stops *at* the limit and normally produces no violation; this call exists to
118+
// catch the cases where the two independent limit computations drift (the
119+
// hardcoded 50-student fallback above vs. `platform_plans.limits`), and to
120+
// clear a stale cutoff once a tenant drops back under its limit.
121+
// Non-blocking: reconciliation must never fail a join that already succeeded.
122+
try {
123+
await reconcileAccessCutoff(adminClient, tenantId)
124+
} catch (cutoffErr) {
125+
console.error('Failed to reconcile access cutoff after join:', cutoffErr)
126+
}
127+
112128
// Create gamification profile for this tenant (ignore if already exists)
113129
const { error: gamificationError } = await adminClient
114130
.from('gamification_profiles')

app/actions/teacher/courses.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getUserRole } from '@/lib/supabase/get-user-role'
66
import { revalidatePath } from 'next/cache'
77
import { sendEmail } from '@/lib/email/send'
88
import { createAdminClient } from '@/lib/supabase/admin'
9+
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
910

1011
// Fallback plan limits (used if platform_plans table query fails)
1112
const PLAN_LIMITS_FALLBACK: Record<string, number> = {
@@ -187,6 +188,21 @@ export async function createCourse(courseData: CourseFormData) {
187188
throw new Error(`Failed to create course: ${error.message}`)
188189
}
189190

191+
// The course row now exists, so the tenant's course count has changed —
192+
// reconcile the access cutoff at the moment usage moves rather than waiting up
193+
// to 24h for the nightly sweep (issue #513). `checkCourseLimit` above blocks at
194+
// `currentCount < limit` and counts *all* courses, while
195+
// `computePlanLimitViolations` flags at `>` and counts only non-archived ones,
196+
// so a legitimate creation normally produces no violation; this call exists to
197+
// catch the cases where those two independent limit computations drift, and to
198+
// clear a stale cutoff once a tenant drops back under its limit.
199+
// Non-blocking: reconciliation must never fail a course that was already created.
200+
try {
201+
await reconcileAccessCutoff(adminClient, tenantId)
202+
} catch (cutoffErr) {
203+
console.error('Failed to reconcile access cutoff after course creation:', cutoffErr)
204+
}
205+
190206
revalidatePath('/dashboard/teacher/courses')
191207
return course
192208
}

docs/DEPLOYMENT.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,19 +258,76 @@ After saving, **redeploy** the app. Allow 1-2 minutes for Let's Encrypt to issue
258258

259259
### 3.5 Cron Jobs
260260

261-
Since there's no Vercel cron, set up a system cron on the server:
261+
`vercel.json` declares these schedules, but **Dokploy does not read `vercel.json`** —
262+
something on this side has to call the routes or they never run at all. Every
263+
`/api/cron/*` route authenticates with `Authorization: Bearer $CRON_SECRET`
264+
(§3.3), so any scheduler that can issue an HTTP GET will do.
265+
266+
**Pick exactly one of the three mechanisms below.** Running two means every route
267+
fires twice; the routes are written to tolerate that, but it doubles the load and
268+
makes logs hard to read.
269+
270+
#### Option A — GitHub Actions (default, and what the repo ships)
271+
272+
`.github/workflows/cron.yml` runs all seven schedules. It needs two repository
273+
settings under **Settings → Secrets and variables → Actions**:
274+
275+
| Kind | Name | Value |
276+
|---|---|---|
277+
| Secret | `CRON_SECRET` | Same value as the `CRON_SECRET` env var on the Dokploy app |
278+
| Variable | `CRON_BASE_URL` | Production origin, e.g. `https://lmsplatform.com` |
279+
280+
A missing setting fails the run loudly rather than silently no-opping. Verify it
281+
end to end with a manual run:
282+
283+
```bash
284+
gh workflow run cron.yml -f route=enforce-plan-limits
285+
gh run watch
286+
```
287+
288+
Two caveats: GitHub delays scheduled runs under load (dropping high-frequency
289+
ones first, so the `*/10` reconcilers are the most affected), and it disables
290+
scheduled workflows after 60 days with no repository activity. If either matters
291+
for your deployment, use Option B instead.
292+
293+
#### Option B — Dokploy scheduled task
294+
295+
In the Dokploy dashboard, open the LMS application → **Schedules** → create one
296+
per line below, shell `bash`, command:
297+
298+
```bash
299+
curl -sS -f -H "Authorization: Bearer $CRON_SECRET" https://lmsplatform.com/api/cron/<route>
300+
```
301+
302+
Most reliable of the three (it runs on the host, on time), but it is invisible to
303+
the repository — nothing in code review will tell you it exists or that it broke.
304+
If you choose this, disable the schedules in `.github/workflows/cron.yml`.
305+
306+
#### Option C — host crontab
262307

263308
```bash
264309
# crontab -e
310+
# Expire lapsed student subscriptions
265311
0 0 * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/expire-subscriptions
266312
# Daily digest + streak nudge — must run HOURLY (each tenant sends at its own local hour)
267313
0 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/daily-digest
268314
# Weekly league rollover (Mondays 01:00). pg_cron inside the database also runs it (Mondays 00:05), so this entry is a fallback.
269315
0 1 * * 1 curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/league-rollover
270316
# Expire lapsed manual-transfer platform (school billing) subscriptions: reminders, grace, downgrade to free. Replaces the retired pg_cron job.
271317
0 2 * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/expire-platform-subscriptions
318+
# Reconcile access cutoffs for tenants that grew past their plan limits with no plan-change event
319+
0 3 * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/enforce-plan-limits
320+
# Confirm on-chain Solana payments
321+
*/10 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/solana-reconcile
322+
# Confirm Binance personal-wallet payments
323+
*/10 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/binance-personal-reconcile
272324
```
273325

326+
> `/api/cron/solana-pull` exists but is deliberately not on any schedule here or
327+
> in `vercel.json`. It submits on-chain USDC transfers for native Solana
328+
> subscriptions; enabling it is a separate decision. Run it manually via
329+
> `gh workflow run cron.yml -f route=solana-pull` if needed.
330+
274331
---
275332

276333
## 4. How Subdomain Routing Works

docs/OPERATIONS_GUIDE.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,35 @@ Events: checkout.session.completed, invoice.payment_failed, customer.subscriptio
215215
```
216216
→ Copy the signing secret to `STRIPE_PLATFORM_WEBHOOK_SECRET`
217217

218-
### 4d. Configure Vercel Cron
218+
### 4d. Configure the cron scheduler
219219

220-
The `vercel.json` file already configures a daily cron job that expires lapsed
221-
school subscriptions. Vercel runs this automatically on Pro+ plans.
220+
The app has seven scheduled jobs under `/api/cron/*` — student and school
221+
subscription expiry, the daily digest, weekly league rollover, plan-limit
222+
enforcement, and two payment reconcilers. They are declared in `vercel.json`, but
223+
**that file only does anything on Vercel.** On any other host (this project
224+
deploys to Dokploy) something has to call the routes or none of them ever run,
225+
and the failures are silent: no cutoffs get scheduled, no lapsed subscriptions
226+
expire, no digests go out.
222227

223-
The cron calls `/api/cron/expire-subscriptions` with your `CRON_SECRET` header.
228+
The repo ships `.github/workflows/cron.yml` to cover this. Set two repository
229+
settings under **Settings → Secrets and variables → Actions**:
230+
231+
- Secret `CRON_SECRET` — same value as the `CRON_SECRET` env var on your host
232+
- Variable `CRON_BASE_URL` — your production origin, e.g. `https://yourdomain.com`
233+
234+
Then confirm it works end to end:
235+
236+
```bash
237+
gh workflow run cron.yml -f route=enforce-plan-limits
238+
gh run watch
239+
```
240+
241+
A `200` with a JSON body means the chain is wired correctly. A `401` means the
242+
`CRON_SECRET` in Actions doesn't match the one on your host.
243+
244+
If you'd rather not depend on GitHub Actions, `docs/DEPLOYMENT.md` §3.5 documents
245+
a Dokploy scheduled task and a host crontab as alternatives — **use only one of
246+
the three**, or every job fires more than once.
224247

225248
---
226249

0 commit comments

Comments
 (0)