Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/workflows/cron.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Scheduled invocation of the app's /api/cron/* routes (issue #513).
#
# Production runs on Dokploy, which does not read `vercel.json` — so the seven
# cron routes declared there had no scheduler at all and every non-payment
# lifecycle job (access-cutoff enforcement, platform-subscription expiry, daily
# digest, league rollover, payment reconciliation) was inert in production.
#
# Each route authenticates with the same `Authorization: Bearer $CRON_SECRET`
# header it already implements, so no application code changed to support this.
#
# Required repository settings (Settings → Secrets and variables → Actions):
# secret CRON_SECRET — must match the CRON_SECRET env var on the Dokploy app
# variable CRON_BASE_URL — the production origin, e.g. https://preciopana.com
# Both are checked at runtime; a missing one fails the run loudly rather than
# silently doing nothing, which is the failure mode this workflow exists to fix.
#
# Caveats worth knowing before relying on this:
# * GitHub delays scheduled runs under load, and drops high-frequency ones
# first — the */10 reconcilers are the most affected. If exact cadence
# matters for those, prefer a Dokploy scheduled task (see docs/DEPLOYMENT.md).
# * GitHub disables scheduled workflows after 60 days with no repository
# activity. Re-enable from the Actions tab if that happens.
# * Run ONE mechanism. If a Dokploy schedule is added later, remove the
# corresponding schedule here so routes don't fire twice.

name: Scheduled cron routes

on:
schedule:
# Keep these in sync with vercel.json and with the case block below.
- cron: '*/10 * * * *' # solana-reconcile + binance-personal-reconcile
- cron: '0 * * * *' # daily-digest — hourly by design (each tenant sends at its own local hour)
- cron: '0 0 * * *' # expire-subscriptions
- cron: '0 1 * * 1' # league-rollover (pg_cron also runs it Mondays 00:05; this is the fallback)
- cron: '0 2 * * *' # expire-platform-subscriptions
- cron: '0 3 * * *' # enforce-plan-limits
workflow_dispatch:
inputs:
route:
description: 'Cron route to run now'
required: true
type: choice
options:
- enforce-plan-limits
- expire-platform-subscriptions
- expire-subscriptions
- daily-digest
- league-rollover
- solana-reconcile
- binance-personal-reconcile
# Never scheduled: solana-pull submits on-chain USDC transfers and has
# never had a scheduler in vercel.json either. Available here for
# manual runs only — putting it on a timer needs its own review.
- solana-pull

permissions:
contents: read

concurrency:
# Serialize per trigger so a slow run can't overlap the next tick.
group: cron-${{ github.event.schedule || inputs.route }}
cancel-in-progress: false

jobs:
invoke:
name: Invoke cron route(s)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Resolve routes for this trigger
id: resolve
env:
SCHEDULE: ${{ github.event.schedule }}
DISPATCH_ROUTE: ${{ inputs.route }}
run: |
set -euo pipefail
if [ -n "${DISPATCH_ROUTE:-}" ]; then
routes="$DISPATCH_ROUTE"
else
case "${SCHEDULE:-}" in
'*/10 * * * *') routes='solana-reconcile binance-personal-reconcile' ;;
'0 * * * *') routes='daily-digest' ;;
'0 0 * * *') routes='expire-subscriptions' ;;
'0 1 * * 1') routes='league-rollover' ;;
'0 2 * * *') routes='expire-platform-subscriptions' ;;
'0 3 * * *') routes='enforce-plan-limits' ;;
*)
echo "::error::Schedule '${SCHEDULE:-}' has no route mapping. Add it to the case block in .github/workflows/cron.yml."
exit 1
;;
esac
fi
echo "Resolved routes: $routes"
echo "routes=$routes" >> "$GITHUB_OUTPUT"

- name: Invoke
env:
CRON_SECRET: ${{ secrets.CRON_SECRET }}
CRON_BASE_URL: ${{ vars.CRON_BASE_URL }}
ROUTES: ${{ steps.resolve.outputs.routes }}
run: |
set -euo pipefail

if [ -z "${CRON_BASE_URL:-}" ]; then
echo "::error::Repository variable CRON_BASE_URL is not set (expected e.g. https://preciopana.com)."
exit 1
fi
if [ -z "${CRON_SECRET:-}" ]; then
echo "::error::Repository secret CRON_SECRET is not set. It must match the CRON_SECRET env var on the Dokploy app."
exit 1
fi

base="${CRON_BASE_URL%/}"
failed=0

for route in $ROUTES; do
url="$base/api/cron/$route"
echo "--- GET $url"
# The secret is passed via env into the header and never printed;
# GitHub also masks it in logs. Body is capped so a large digest
# response can't flood the log.
code="$(curl -sS -o /tmp/cron-body.txt -w '%{http_code}' \
--max-time 600 --retry 2 --retry-delay 15 --retry-connrefused \
-H "Authorization: Bearer $CRON_SECRET" \
"$url")" || code='000'

echo "HTTP $code"
head -c 2000 /tmp/cron-body.txt || true
echo

if [ "$code" != '200' ]; then
echo "::error::$route returned HTTP $code"
failed=1
fi
done

exit "$failed"
16 changes: 16 additions & 0 deletions app/actions/join-school.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getCurrentTenantId } from '@/lib/supabase/tenant'
import { revalidatePath } from 'next/cache'
import { sendEmail } from '@/lib/email/send'
import { joinedSchoolTemplate } from '@/lib/email/templates/joined-school'
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'

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

// The membership row now exists, so the tenant's student count has changed —
// reconcile the access cutoff at the moment usage moves rather than waiting up
// to 24h for the nightly sweep (issue #513). The pre-flight check above blocks
// at `>=` while `computePlanLimitViolations` flags at `>`, so a legitimate join
// stops *at* the limit and normally produces no violation; this call exists to
// catch the cases where the two independent limit computations drift (the
// hardcoded 50-student fallback above vs. `platform_plans.limits`), and to
// clear a stale cutoff once a tenant drops back under its limit.
// Non-blocking: reconciliation must never fail a join that already succeeded.
try {
await reconcileAccessCutoff(adminClient, tenantId)
} catch (cutoffErr) {
console.error('Failed to reconcile access cutoff after join:', cutoffErr)
}

// Create gamification profile for this tenant (ignore if already exists)
const { error: gamificationError } = await adminClient
.from('gamification_profiles')
Expand Down
16 changes: 16 additions & 0 deletions app/actions/teacher/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getUserRole } from '@/lib/supabase/get-user-role'
import { revalidatePath } from 'next/cache'
import { sendEmail } from '@/lib/email/send'
import { createAdminClient } from '@/lib/supabase/admin'
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'

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

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

revalidatePath('/dashboard/teacher/courses')
return course
}
Expand Down
59 changes: 58 additions & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,76 @@ After saving, **redeploy** the app. Allow 1-2 minutes for Let's Encrypt to issue

### 3.5 Cron Jobs

Since there's no Vercel cron, set up a system cron on the server:
`vercel.json` declares these schedules, but **Dokploy does not read `vercel.json`** —
something on this side has to call the routes or they never run at all. Every
`/api/cron/*` route authenticates with `Authorization: Bearer $CRON_SECRET`
(§3.3), so any scheduler that can issue an HTTP GET will do.

**Pick exactly one of the three mechanisms below.** Running two means every route
fires twice; the routes are written to tolerate that, but it doubles the load and
makes logs hard to read.

#### Option A — GitHub Actions (default, and what the repo ships)

`.github/workflows/cron.yml` runs all seven schedules. It needs two repository
settings under **Settings → Secrets and variables → Actions**:

| Kind | Name | Value |
|---|---|---|
| Secret | `CRON_SECRET` | Same value as the `CRON_SECRET` env var on the Dokploy app |
| Variable | `CRON_BASE_URL` | Production origin, e.g. `https://lmsplatform.com` |

A missing setting fails the run loudly rather than silently no-opping. Verify it
end to end with a manual run:

```bash
gh workflow run cron.yml -f route=enforce-plan-limits
gh run watch
```

Two caveats: GitHub delays scheduled runs under load (dropping high-frequency
ones first, so the `*/10` reconcilers are the most affected), and it disables
scheduled workflows after 60 days with no repository activity. If either matters
for your deployment, use Option B instead.

#### Option B — Dokploy scheduled task

In the Dokploy dashboard, open the LMS application → **Schedules** → create one
per line below, shell `bash`, command:

```bash
curl -sS -f -H "Authorization: Bearer $CRON_SECRET" https://lmsplatform.com/api/cron/<route>
```

Most reliable of the three (it runs on the host, on time), but it is invisible to
the repository — nothing in code review will tell you it exists or that it broke.
If you choose this, disable the schedules in `.github/workflows/cron.yml`.

#### Option C — host crontab

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

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

---

## 4. How Subdomain Routing Works
Expand Down
31 changes: 27 additions & 4 deletions docs/OPERATIONS_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,35 @@ Events: checkout.session.completed, invoice.payment_failed, customer.subscriptio
```
→ Copy the signing secret to `STRIPE_PLATFORM_WEBHOOK_SECRET`

### 4d. Configure Vercel Cron
### 4d. Configure the cron scheduler

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

The cron calls `/api/cron/expire-subscriptions` with your `CRON_SECRET` header.
The repo ships `.github/workflows/cron.yml` to cover this. Set two repository
settings under **Settings → Secrets and variables → Actions**:

- Secret `CRON_SECRET` — same value as the `CRON_SECRET` env var on your host
- Variable `CRON_BASE_URL` — your production origin, e.g. `https://yourdomain.com`

Then confirm it works end to end:

```bash
gh workflow run cron.yml -f route=enforce-plan-limits
gh run watch
```

A `200` with a JSON body means the chain is wired correctly. A `401` means the
`CRON_SECRET` in Actions doesn't match the one on your host.

If you'd rather not depend on GitHub Actions, `docs/DEPLOYMENT.md` §3.5 documents
a Dokploy scheduled task and a host crontab as alternatives — **use only one of
the three**, or every job fires more than once.

---

Expand Down
Loading
Loading