Skip to content

Commit 62c1b6f

Browse files
feat(billing): add access-cutoff reminder ladder, admin banner and blast-radius docs (#517)
#494 shipped the access cutoff as a tenant-wide hard block announced by a single email at scheduling time, with no in-app signal for admins and no written statement of how wide the block reaches. This closes all three gaps. Notification ladder. `decideAccessCutoffAction()` returns 'schedule' exactly once per cutoff, so the school got exactly one message, 14 days out, and nothing at T-7, T-1 or on the day access actually stopped. A new `access_cutoff_notifications` ledger — unique on (tenant_id, cutoff_at, stage) — lets the existing daily sweep send whichever rung is due without repeating itself. A row is written only when at least one admin address actually received the mail, so an undelivered rung is retried by the next sweep; `dueCutoffNotificationStage()` returns at most one rung (the most urgent reached-but-unsent one) so a stale warning can never land beside "access is now paused". The cron response now reports per-stage sends and failures. Delivery is judged on sendEmail's boolean rather than on whether it threw: lib/email/send.ts swallows both a missing Mailgun config and an API error and returns false, which made #494's try/catch around the send near-decorative. Admin banner. <AccessCutoffBanner /> renders in the dashboard shell for admins on every admin page, not just billing, in a scheduled state (days remaining) and an active state (access paused). Not dismissible: a dismissed countdown to losing all student access is indistinguishable from no countdown. The student half of this gap was already closed by #509 (/dashboard/student/access-suspended), verified as a regression check. Blast radius. docs/MONETIZATION.md now states explicitly that the cutoff is tenant-wide and all-or-nothing (a free-plan school's 51st student costs all 51 their access), what it does not touch (enrollments, entitlements, purchases, staff and author access, preview lessons), and why per-student narrowing was considered and rejected. Also fixes a locale bug found during verification: both the new banner and the #509 student page formatted dates with toLocaleDateString(undefined), which resolves to the Node process locale on the server, so /es pages rendered English dates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168MCSb3b3iWM5TSb25Rp3v
1 parent b87b9da commit 62c1b6f

12 files changed

Lines changed: 771 additions & 40 deletions

File tree

app/[locale]/dashboard/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { UserNav } from "@/components/user-nav"
88
import { LanguageSwitcher } from "@/components/language-switcher"
99
import { GamificationHeaderCard } from "@/components/gamification/gamification-header-card"
1010
import { VerifyEmailBanner } from "@/components/shared/verify-email-banner"
11+
import { AccessCutoffBanner } from "@/components/shared/access-cutoff-banner"
1112
import type { Metadata } from "next"
1213

1314
export const metadata: Metadata = {
@@ -43,6 +44,10 @@ export default async function DashboardLayout({
4344
{user?.email && !user.email_confirmed_at && (
4445
<VerifyEmailBanner email={user.email} />
4546
)}
47+
{/* #517: admins are the only ones who can act on a plan-limit
48+
cutoff, and before this they only saw it if they happened to
49+
open the billing page. */}
50+
{role === 'admin' && <AccessCutoffBanner />}
4651
<div className="flex flex-1 flex-col">
4752
{children}
4853
</div>

app/[locale]/dashboard/student/access-suspended/page.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createAdminClient } from '@/lib/supabase/admin'
22
import { redirect } from 'next/navigation'
3-
import { getTranslations } from 'next-intl/server'
3+
import { getLocale, getTranslations } from 'next-intl/server'
44
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
55
import { Button } from '@/components/ui/button'
66
import { Card, CardContent } from '@/components/ui/card'
@@ -14,7 +14,10 @@ import Link from 'next/link'
1414
* real cause and points at the person who can fix it.
1515
*/
1616
export default async function AccessSuspendedPage() {
17-
const t = await getTranslations('dashboard.student.accessSuspended')
17+
const [t, locale] = await Promise.all([
18+
getTranslations('dashboard.student.accessSuspended'),
19+
getLocale(),
20+
])
1821

1922
const userId = await getCurrentUserId()
2023
if (!userId) redirect('/auth/login')
@@ -56,7 +59,9 @@ export default async function AccessSuspendedPage() {
5659
<p className="text-muted-foreground text-sm text-pretty">{t('whatNowBody')}</p>
5760
<p className="text-muted-foreground text-sm">
5861
{t('since', {
59-
date: new Date(cutoffAt).toLocaleDateString(undefined, { dateStyle: 'long' }),
62+
// Explicit locale — `undefined` on the server resolves to the
63+
// Node process locale, not the request's (#517).
64+
date: new Date(cutoffAt).toLocaleDateString(locale, { dateStyle: 'long' }),
6065
})}
6166
</p>
6267
</CardContent>

app/api/cron/enforce-plan-limits/route.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ export const runtime = 'nodejs'
1616
* `tenants.access_cutoff_at` against its current plan/usage, regardless of
1717
* plan (a free-plan tenant needs this as much as a paid one).
1818
*
19+
* It is also the only thing that runs repeatedly while a cutoff is pending,
20+
* which makes it the right home for the #517 reminder ladder: `notifyDueStages`
21+
* tells the reconciler to send whichever rung (T-7, T-1, or the day access
22+
* actually stops) is due and not yet in the `access_cutoff_notifications`
23+
* ledger. That is why no separate cron entry was added — the sweep that
24+
* already visits every tenant daily is exactly the cadence the ladder needs.
25+
*
26+
* `notified` / `notifyFailures` in the response make send health visible:
27+
* before #517 a failing mail provider left the schedule in place and said
28+
* nothing, so the school's first signal was students losing access.
29+
*
1930
* Secured by CRON_SECRET env var (set the same value in the cron scheduler).
2031
*/
2132

@@ -37,14 +48,28 @@ export async function GET(req: NextRequest) {
3748

3849
const { data: tenants } = await supabase.from('tenants').select('id')
3950

40-
const result = { scheduled: 0, cleared: 0, none: 0, errors: 0 }
51+
const result = {
52+
scheduled: 0,
53+
cleared: 0,
54+
none: 0,
55+
errors: 0,
56+
/** Reminder-ladder rungs delivered this run, keyed by stage (#517). */
57+
notified: {} as Record<string, number>,
58+
/** Tenants where a rung was due but nobody received it; retried tomorrow. */
59+
notifyFailures: 0,
60+
}
4161

4262
for (const tenant of tenants || []) {
4363
try {
44-
const decision = await reconcileAccessCutoff(supabase, tenant.id)
64+
const decision = await reconcileAccessCutoff(supabase, tenant.id, { notifyDueStages: true })
4565
if (decision.action === 'schedule') result.scheduled++
4666
else if (decision.action === 'clear') result.cleared++
4767
else result.none++
68+
69+
if (decision.notifiedStage) {
70+
result.notified[decision.notifiedStage] = (result.notified[decision.notifiedStage] ?? 0) + 1
71+
}
72+
if (decision.notifyFailed) result.notifyFailures++
4873
} catch (err) {
4974
console.error('enforce-plan-limits: reconcile failed for tenant', tenant.id, err)
5075
result.errors++
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Link from 'next/link'
2+
import { getLocale, getTranslations } from 'next-intl/server'
3+
import { IconAlertTriangle, IconLock } from '@tabler/icons-react'
4+
import { getCurrentTenantId } from '@/lib/supabase/tenant'
5+
import { getAccessCutoffNotice } from '@/lib/billing/access-cutoff-notice'
6+
7+
/**
8+
* Persistent in-app warning shown to tenant admins while an access cutoff is
9+
* scheduled or in force (issue #517).
10+
*
11+
* Deliberately not dismissible: the thing it is counting down to is every
12+
* student at the school losing access to every course, and a dismissed
13+
* countdown is indistinguishable from no countdown. It also states the blast
14+
* radius in the same words as the email, so an admin who reads only one of
15+
* the two still learns that this is school-wide rather than per-student.
16+
*
17+
* Renders nothing when there is no cutoff, which is the overwhelmingly common
18+
* case — one indexed single-row read on the admin dashboard path.
19+
*/
20+
export async function AccessCutoffBanner() {
21+
const tenantId = await getCurrentTenantId()
22+
const notice = await getAccessCutoffNotice(tenantId)
23+
if (!notice) return null
24+
25+
const [t, locale] = await Promise.all([
26+
getTranslations('components.accessCutoffBanner'),
27+
getLocale(),
28+
])
29+
30+
// Explicit locale: on the server `undefined` resolves to the Node process
31+
// locale, not the request's, so an /es page would render an English date.
32+
const cutoffDate = new Date(notice.cutoffAt).toLocaleDateString(locale, { dateStyle: 'long' })
33+
34+
return (
35+
<div
36+
role="status"
37+
className={
38+
notice.active
39+
? 'flex flex-wrap items-center gap-3 border-b border-destructive/50 bg-destructive/10 px-4 py-2.5 text-destructive'
40+
: 'flex flex-wrap items-center gap-3 border-b border-amber-500/50 bg-amber-50 px-4 py-2.5 text-amber-900 dark:bg-amber-950/20 dark:text-amber-100'
41+
}
42+
>
43+
{notice.active ? (
44+
<IconLock className="h-5 w-5 shrink-0" aria-hidden="true" />
45+
) : (
46+
<IconAlertTriangle className="h-5 w-5 shrink-0" aria-hidden="true" />
47+
)}
48+
49+
<p className="min-w-0 flex-1 text-sm text-pretty">
50+
<span className="font-medium">
51+
{notice.active
52+
? t('activeTitle')
53+
: notice.daysRemaining <= 1
54+
? t('scheduledTitleImminent')
55+
: t('scheduledTitle', { days: notice.daysRemaining })}
56+
</span>{' '}
57+
{notice.active ? t('activeBody', { date: cutoffDate }) : t('scheduledBody', { date: cutoffDate })}
58+
</p>
59+
60+
<Link
61+
href="/dashboard/admin/billing"
62+
className="shrink-0 rounded-md border border-current px-3 py-1.5 text-sm font-medium hover:opacity-80"
63+
>
64+
{notice.active ? t('restoreAccess') : t('manageBilling')}
65+
</Link>
66+
</div>
67+
)
68+
}

docs/MONETIZATION.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,39 @@ The two sections above are **creation-time soft caps only** — they block a *ne
246246
- **Page/route gates**: `requireCourseAccess()` (`lib/services/course-access-guard.ts`) on every student course route — course detail, lessons, exercises (list + detail), exams (list, taker, result, review), community — plus `hasCourseAccess()` in the certificate, AI tutor and exercise-submission APIs. A refusal caused by the tenant cutoff (rather than by a missing entitlement) sends the student to `/dashboard/student/access-suspended`, which explains that the school is over its limits and that their enrollment is intact.
247247
- This is a deliberate decision, not an oversight: with no production users yet, real enforcement was shipped directly rather than grandfathering pre-existing over-limit usage.
248248

249+
#### Blast radius — the cutoff is tenant-wide and all-or-nothing (issue #517)
250+
251+
This is the single most important property of the mechanism and the easiest one to misread from the description above, so it is stated here explicitly.
252+
253+
`has_course_access()` gates on `t.access_cutoff_at IS NULL OR t.access_cutoff_at > now()` with **no per-student predicate**. Once the cutoff passes, access is denied for **every student in the tenant, across every course**, regardless of who or what caused the tenant to go over its limit:
254+
255+
- A free-plan school (50-student limit) that enrols its 51st student loses course access for **all 51** — not for the one student over the line, and not only for new enrolments.
256+
- A school over its *course* limit also loses **student** access; the two limits share one cutoff.
257+
- Restoration is equally all-or-nothing: the moment usage is back under the limit or the plan is upgraded, access returns for everyone at once. There is no partial or staged restoration.
258+
259+
What the cutoff does **not** touch:
260+
261+
- `enrollments`, `entitlements`, `transactions` and progress records are untouched — nothing is deleted, revoked or refunded. The cutoff is a gate in front of the data, not a mutation of it.
262+
- Teachers, admins and course authors keep access, via the `is_tenant_staff()` / author escape hatches in the RLS policies — so a school can still fix its content while cut off.
263+
- Published preview lessons (`is_preview`) stay public, so the school's funnel keeps working.
264+
- Other tenants are unaffected; `access_cutoff_at` lives on the `tenants` row.
265+
266+
**Why all-or-nothing.** #493 §1.1 offered two options — "read-only mode past N days over limit" or "a hard student-count cutoff" — and the hard cutoff is the one that shipped. Narrowing it to only the students over the limit (e.g. the most recently enrolled) was considered and rejected: it would make one student's access depend on the join order of their peers, produce a support burden that is impossible to explain to the student affected, and require a per-student ordering rule inside a `STABLE SECURITY DEFINER` function on the hot path of every content read. The blunt version is legible to the school admin — who is the person who can actually fix it — and is reversible in one action. If per-student narrowing is ever revisited it should be its own issue, not a change smuggled into the enforcement function.
267+
268+
#### Notification ladder (issue #517)
269+
270+
Because the blast radius is this wide, the school has to be told more than once. #494 sent exactly one email at scheduling time — a design consequence of `decideAccessCutoffAction()` returning `'schedule'` only once per cutoff — with no retry if it failed and no message on the day access actually stopped.
271+
272+
- **Ladder:** `scheduled` (immediately, T-14) → `reminder_7d``reminder_1d``enforced` (the first sweep after access actually stops). `accessCutoffWarningTemplate({ stage, ... })` gives each rung its own subject and copy; the `enforced` notice is written in the past tense.
273+
- **Ledger:** `access_cutoff_notifications` (migration `20260725100000_access_cutoff_notifications.sql`), one row per `(tenant_id, cutoff_at, stage)` under a unique constraint. Keyed on `cutoff_at` as well as tenant, so a cleared-then-rescheduled cutoff correctly starts a fresh ladder for its new deadline. Service-role only: RLS is enabled with no policies.
274+
- **Retry:** a ledger row is written **only** when at least one admin address actually received the mail. A rung nobody received stays unrecorded and is therefore still due on the next daily sweep. `dueCutoffNotificationStage()` returns at most one rung — always the most urgent reached-but-unsent one — so an undelivered early rung is superseded rather than queued behind an urgent one (no "you have 7 days" landing beside "access is now paused").
275+
- **Where it runs:** `app/api/cron/enforce-plan-limits/route.ts` passes `notifyDueStages: true`. No new cron entry was added — the sweep that already visits every tenant daily is exactly the cadence the ladder needs. Its response reports `notified` (per stage) and `notifyFailures`, so a failing mail provider is visible in the cron log instead of silent. Event-driven call sites (join-school, course creation, plan changes) do **not** pass the flag, so user-facing actions never pay email latency for a reminder the cron will send anyway.
276+
277+
#### In-app signals (issue #517)
278+
279+
- **Tenant admins**: `<AccessCutoffBanner />` (`components/shared/access-cutoff-banner.tsx`) renders in the dashboard shell (`app/[locale]/dashboard/layout.tsx`) for `role === 'admin'`, on every admin page rather than only on billing. Two states — scheduled (days remaining) and active (access paused) — both naming the school-wide blast radius in the same words as the email. Deliberately **not** dismissible: a dismissed countdown to losing all student access is indistinguishable from no countdown. Backed by `describeAccessCutoff()` / `getAccessCutoffNotice()` (`lib/billing/access-cutoff-notice.ts`), a single indexed single-row read gated to admins.
280+
- **Students**: `/dashboard/student/access-suspended` (shipped in #509, above) is the specific "your school's account needs attention" state — it names the school's plan limits as the cause and states that the enrolment is intact.
281+
249282
---
250283

251284
## Currency Support
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* The tenant-admin view of an access cutoff (issue #517).
3+
*
4+
* #494 stored `tenants.access_cutoff_at` and mailed it once. The only place
5+
* that ever read it back for a human was `/dashboard/admin/billing`, so an
6+
* admin who never opened that page had no in-app signal at all — the first
7+
* they'd know was students reporting they'd lost access.
8+
*
9+
* This module answers one question for the dashboard shell: is there a cutoff
10+
* an admin needs to see right now, and how urgent is it. The day arithmetic is
11+
* pure and `now`-injectable so it can be reasoned about without a clock.
12+
*/
13+
14+
import { createAdminClient } from '@/lib/supabase/admin'
15+
16+
const DAY_MS = 24 * 60 * 60 * 1000
17+
18+
export interface AccessCutoffNotice {
19+
cutoffAt: string
20+
/** True once the cutoff has passed — students have already lost access. */
21+
active: boolean
22+
/**
23+
* Whole days until access stops, rounded up, floored at 0. `1` means
24+
* "tomorrow"; `0` alongside `active: false` means "later today".
25+
*/
26+
daysRemaining: number
27+
}
28+
29+
/**
30+
* Pure: turn a stored cutoff timestamp into what the banner needs.
31+
* Returns null when there is nothing to show (no cutoff, unparseable value).
32+
*/
33+
export function describeAccessCutoff(input: {
34+
cutoffAt: string | null | undefined
35+
now: Date
36+
}): AccessCutoffNotice | null {
37+
const { cutoffAt, now } = input
38+
if (!cutoffAt) return null
39+
40+
const cutoffMs = new Date(cutoffAt).getTime()
41+
if (Number.isNaN(cutoffMs)) return null
42+
43+
const msRemaining = cutoffMs - now.getTime()
44+
45+
return {
46+
cutoffAt,
47+
active: msRemaining <= 0,
48+
daysRemaining: msRemaining <= 0 ? 0 : Math.ceil(msRemaining / DAY_MS),
49+
}
50+
}
51+
52+
/**
53+
* Read the current tenant's cutoff state. Uses the admin client because
54+
* `tenants` is not readable in full by an authenticated member, and this runs
55+
* on every admin dashboard render — a single indexed single-row read, gated by
56+
* the caller to admins only.
57+
*/
58+
export async function getAccessCutoffNotice(
59+
tenantId: string,
60+
now: Date = new Date()
61+
): Promise<AccessCutoffNotice | null> {
62+
if (!tenantId) return null
63+
64+
const supabase = createAdminClient()
65+
const { data: tenant } = await supabase
66+
.from('tenants')
67+
.select('access_cutoff_at')
68+
.eq('id', tenantId)
69+
.maybeSingle()
70+
71+
return describeAccessCutoff({ cutoffAt: tenant?.access_cutoff_at, now })
72+
}

0 commit comments

Comments
 (0)