Skip to content
Merged
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
5 changes: 5 additions & 0 deletions app/[locale]/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { UserNav } from "@/components/user-nav"
import { LanguageSwitcher } from "@/components/language-switcher"
import { GamificationHeaderCard } from "@/components/gamification/gamification-header-card"
import { VerifyEmailBanner } from "@/components/shared/verify-email-banner"
import { AccessCutoffBanner } from "@/components/shared/access-cutoff-banner"
import type { Metadata } from "next"

export const metadata: Metadata = {
Expand Down Expand Up @@ -43,6 +44,10 @@ export default async function DashboardLayout({
{user?.email && !user.email_confirmed_at && (
<VerifyEmailBanner email={user.email} />
)}
{/* #517: admins are the only ones who can act on a plan-limit
cutoff, and before this they only saw it if they happened to
open the billing page. */}
{role === 'admin' && <AccessCutoffBanner />}
<div className="flex flex-1 flex-col">
{children}
</div>
Expand Down
11 changes: 8 additions & 3 deletions app/[locale]/dashboard/student/access-suspended/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createAdminClient } from '@/lib/supabase/admin'
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { getLocale, getTranslations } from 'next-intl/server'
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
Expand All @@ -14,7 +14,10 @@ import Link from 'next/link'
* real cause and points at the person who can fix it.
*/
export default async function AccessSuspendedPage() {
const t = await getTranslations('dashboard.student.accessSuspended')
const [t, locale] = await Promise.all([
getTranslations('dashboard.student.accessSuspended'),
getLocale(),
])

const userId = await getCurrentUserId()
if (!userId) redirect('/auth/login')
Expand Down Expand Up @@ -56,7 +59,9 @@ export default async function AccessSuspendedPage() {
<p className="text-muted-foreground text-sm text-pretty">{t('whatNowBody')}</p>
<p className="text-muted-foreground text-sm">
{t('since', {
date: new Date(cutoffAt).toLocaleDateString(undefined, { dateStyle: 'long' }),
// Explicit locale — `undefined` on the server resolves to the
// Node process locale, not the request's (#517).
date: new Date(cutoffAt).toLocaleDateString(locale, { dateStyle: 'long' }),
})}
</p>
</CardContent>
Expand Down
29 changes: 27 additions & 2 deletions app/api/cron/enforce-plan-limits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ export const runtime = 'nodejs'
* `tenants.access_cutoff_at` against its current plan/usage, regardless of
* plan (a free-plan tenant needs this as much as a paid one).
*
* It is also the only thing that runs repeatedly while a cutoff is pending,
* which makes it the right home for the #517 reminder ladder: `notifyDueStages`
* tells the reconciler to send whichever rung (T-7, T-1, or the day access
* actually stops) is due and not yet in the `access_cutoff_notifications`
* ledger. That is why no separate cron entry was added — the sweep that
* already visits every tenant daily is exactly the cadence the ladder needs.
*
* `notified` / `notifyFailures` in the response make send health visible:
* before #517 a failing mail provider left the schedule in place and said
* nothing, so the school's first signal was students losing access.
*
* Secured by CRON_SECRET env var (set the same value in the cron scheduler).
*/

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

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

const result = { scheduled: 0, cleared: 0, none: 0, errors: 0 }
const result = {
scheduled: 0,
cleared: 0,
none: 0,
errors: 0,
/** Reminder-ladder rungs delivered this run, keyed by stage (#517). */
notified: {} as Record<string, number>,
/** Tenants where a rung was due but nobody received it; retried tomorrow. */
notifyFailures: 0,
}

for (const tenant of tenants || []) {
try {
const decision = await reconcileAccessCutoff(supabase, tenant.id)
const decision = await reconcileAccessCutoff(supabase, tenant.id, { notifyDueStages: true })
if (decision.action === 'schedule') result.scheduled++
else if (decision.action === 'clear') result.cleared++
else result.none++

if (decision.notifiedStage) {
result.notified[decision.notifiedStage] = (result.notified[decision.notifiedStage] ?? 0) + 1
}
if (decision.notifyFailed) result.notifyFailures++
} catch (err) {
console.error('enforce-plan-limits: reconcile failed for tenant', tenant.id, err)
result.errors++
Expand Down
68 changes: 68 additions & 0 deletions components/shared/access-cutoff-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Link from 'next/link'
import { getLocale, getTranslations } from 'next-intl/server'
import { IconAlertTriangle, IconLock } from '@tabler/icons-react'
import { getCurrentTenantId } from '@/lib/supabase/tenant'
import { getAccessCutoffNotice } from '@/lib/billing/access-cutoff-notice'

/**
* Persistent in-app warning shown to tenant admins while an access cutoff is
* scheduled or in force (issue #517).
*
* Deliberately not dismissible: the thing it is counting down to is every
* student at the school losing access to every course, and a dismissed
* countdown is indistinguishable from no countdown. It also states the blast
* radius in the same words as the email, so an admin who reads only one of
* the two still learns that this is school-wide rather than per-student.
*
* Renders nothing when there is no cutoff, which is the overwhelmingly common
* case — one indexed single-row read on the admin dashboard path.
*/
export async function AccessCutoffBanner() {
const tenantId = await getCurrentTenantId()
const notice = await getAccessCutoffNotice(tenantId)
if (!notice) return null

const [t, locale] = await Promise.all([
getTranslations('components.accessCutoffBanner'),
getLocale(),
])

// Explicit locale: on the server `undefined` resolves to the Node process
// locale, not the request's, so an /es page would render an English date.
const cutoffDate = new Date(notice.cutoffAt).toLocaleDateString(locale, { dateStyle: 'long' })

return (
<div
role="status"
className={
notice.active
? 'flex flex-wrap items-center gap-3 border-b border-destructive/50 bg-destructive/10 px-4 py-2.5 text-destructive'
: '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'
}
>
{notice.active ? (
<IconLock className="h-5 w-5 shrink-0" aria-hidden="true" />
) : (
<IconAlertTriangle className="h-5 w-5 shrink-0" aria-hidden="true" />
)}

<p className="min-w-0 flex-1 text-sm text-pretty">
<span className="font-medium">
{notice.active
? t('activeTitle')
: notice.daysRemaining <= 1
? t('scheduledTitleImminent')
: t('scheduledTitle', { days: notice.daysRemaining })}
</span>{' '}
{notice.active ? t('activeBody', { date: cutoffDate }) : t('scheduledBody', { date: cutoffDate })}
</p>

<Link
href="/dashboard/admin/billing"
className="shrink-0 rounded-md border border-current px-3 py-1.5 text-sm font-medium hover:opacity-80"
>
{notice.active ? t('restoreAccess') : t('manageBilling')}
</Link>
</div>
)
}
33 changes: 33 additions & 0 deletions docs/MONETIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,39 @@ The two sections above are **creation-time soft caps only** — they block a *ne
- **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.
- 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.

#### Blast radius — the cutoff is tenant-wide and all-or-nothing (issue #517)

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.

`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:

- 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.
- A school over its *course* limit also loses **student** access; the two limits share one cutoff.
- 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.

What the cutoff does **not** touch:

- `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.
- 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.
- Published preview lessons (`is_preview`) stay public, so the school's funnel keeps working.
- Other tenants are unaffected; `access_cutoff_at` lives on the `tenants` row.

**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.

#### Notification ladder (issue #517)

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.

- **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.
- **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.
- **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").
- **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.

#### In-app signals (issue #517)

- **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.
- **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.

---

## Currency Support
Expand Down
72 changes: 72 additions & 0 deletions lib/billing/access-cutoff-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* The tenant-admin view of an access cutoff (issue #517).
*
* #494 stored `tenants.access_cutoff_at` and mailed it once. The only place
* that ever read it back for a human was `/dashboard/admin/billing`, so an
* admin who never opened that page had no in-app signal at all — the first
* they'd know was students reporting they'd lost access.
*
* This module answers one question for the dashboard shell: is there a cutoff
* an admin needs to see right now, and how urgent is it. The day arithmetic is
* pure and `now`-injectable so it can be reasoned about without a clock.
*/

import { createAdminClient } from '@/lib/supabase/admin'

const DAY_MS = 24 * 60 * 60 * 1000

export interface AccessCutoffNotice {
cutoffAt: string
/** True once the cutoff has passed — students have already lost access. */
active: boolean
/**
* Whole days until access stops, rounded up, floored at 0. `1` means
* "tomorrow"; `0` alongside `active: false` means "later today".
*/
daysRemaining: number
}

/**
* Pure: turn a stored cutoff timestamp into what the banner needs.
* Returns null when there is nothing to show (no cutoff, unparseable value).
*/
export function describeAccessCutoff(input: {
cutoffAt: string | null | undefined
now: Date
}): AccessCutoffNotice | null {
const { cutoffAt, now } = input
if (!cutoffAt) return null

const cutoffMs = new Date(cutoffAt).getTime()
if (Number.isNaN(cutoffMs)) return null

const msRemaining = cutoffMs - now.getTime()

return {
cutoffAt,
active: msRemaining <= 0,
daysRemaining: msRemaining <= 0 ? 0 : Math.ceil(msRemaining / DAY_MS),
}
}

/**
* Read the current tenant's cutoff state. Uses the admin client because
* `tenants` is not readable in full by an authenticated member, and this runs
* on every admin dashboard render — a single indexed single-row read, gated by
* the caller to admins only.
*/
export async function getAccessCutoffNotice(
tenantId: string,
now: Date = new Date()
): Promise<AccessCutoffNotice | null> {
if (!tenantId) return null

const supabase = createAdminClient()
const { data: tenant } = await supabase
.from('tenants')
.select('access_cutoff_at')
.eq('id', tenantId)
.maybeSingle()

return describeAccessCutoff({ cutoffAt: tenant?.access_cutoff_at, now })
}
Loading
Loading