Skip to content

Commit ee5bdfb

Browse files
fix(billing): reconcile the admin course actions and unblock re-join (#550)
Both found by driving the flows in a browser rather than by reading code. `archiveCourse` exists twice. `app/actions/teacher/courses.ts` backs the teacher course card; `app/actions/admin/courses.ts` backs the admin course-status screen, and that is the one the admin UI actually calls. Only the first was reconciling, so archiving from the admin screen — the more likely path for an admin racing a cutoff — left the cutoff in place and the recovery loop open on exactly the half a reviewer would try first. `restoreCourse` is the mirror image: it raises the active course count, so it now reconciles too and schedules the cutoff at the moment the admin causes it, with the full grace period, instead of whenever a sweep next notices. The `/join-school` page gated on a `tenant_users` row existing at all, so a removed member — redirected there by proxy.ts precisely so they could re-join — was met with "You're Already a Member!" and no way forward. The server action already handled `status`; the page did not. Both membership reads on that page now require `status = 'active'`, matching `joinCurrentSchool()`. Verified end to end against local Supabase, no cron running: admin-screen archive clears `access_cutoff_at` synchronously and the student's /access-suspended page redirects them back into the course; Remove from School clears it on the student limit; re-joining while at the limit is still refused with "This school has reached its student limit", and re-joining under it updates the surviving row in place (one row, `active`) rather than inserting a duplicate. Re-check limits reports "Still over the plan limit" while over and "Access restored" once under. Also fixes six pre-existing lint errors in the join-school page, which the whole-file pre-commit hook makes blocking as soon as the file is touched: five unescaped apostrophes, and an `any` on the memberships map. Removing that `any` surfaced a real mismatch — the generated types model the `tenants` embed as an array while PostgREST returns a bare object — so the row now handles both shapes instead of casting the discrepancy away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015V35rmrQiZCxDJDiXas9GV
1 parent a126e98 commit ee5bdfb

2 files changed

Lines changed: 45 additions & 12 deletions

File tree

app/[locale]/join-school/page.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export default async function JoinSchoolPage() {
3434
<CardHeader>
3535
<CardTitle className="text-red-900">School Not Found</CardTitle>
3636
<CardDescription className="text-red-700">
37-
The school you're trying to join doesn't exist or is no longer available.
37+
The school you&apos;re trying to join doesn&apos;t exist or is no longer available.
3838
</CardDescription>
3939
</CardHeader>
4040
<CardContent>
@@ -47,13 +47,19 @@ export default async function JoinSchoolPage() {
4747
)
4848
}
4949

50-
// Check if user is already a member of this tenant
50+
// Check if user is already a member of this tenant. Only an ACTIVE row
51+
// counts (#550): a member removed by an admin keeps their row with
52+
// `status = 'removed'`, and gating on mere existence would show them
53+
// "You're Already a Member!" on the very page they were redirected to in
54+
// order to re-join, with no way forward. `joinCurrentSchool()` applies the
55+
// same rule and reinstates the row through the normal student-limit check.
5156
const { data: membership } = await supabase
5257
.from('tenant_users')
5358
.select('*')
5459
.eq('user_id', userId)
5560
.eq('tenant_id', tenantId)
56-
.single()
61+
.eq('status', 'active')
62+
.maybeSingle()
5763

5864
if (membership) {
5965
return (
@@ -62,10 +68,10 @@ export default async function JoinSchoolPage() {
6268
<CardHeader>
6369
<div className="flex items-center gap-2">
6470
<CheckCircle className="h-6 w-6 text-green-600" />
65-
<CardTitle className="text-green-900">You're Already a Member!</CardTitle>
71+
<CardTitle className="text-green-900">You&apos;re Already a Member!</CardTitle>
6672
</div>
6773
<CardDescription className="text-green-700">
68-
You're already enrolled in {tenant.name}
74+
You&apos;re already enrolled in {tenant.name}
6975
</CardDescription>
7076
</CardHeader>
7177
<CardContent className="space-y-4">
@@ -86,11 +92,14 @@ export default async function JoinSchoolPage() {
8692
)
8793
}
8894

89-
// Get user's other school memberships
95+
// Get user's other school memberships — active ones only, for the same
96+
// reason as above: a school the user was removed from is not somewhere they
97+
// can still switch back into.
9098
const { data: otherMemberships } = await supabase
9199
.from('tenant_users')
92100
.select('tenant_id, tenants(name, slug)')
93101
.eq('user_id', userId)
102+
.eq('status', 'active')
94103
.neq('tenant_id', tenantId)
95104

96105
return (
@@ -111,16 +120,25 @@ export default async function JoinSchoolPage() {
111120
<Card className="mb-6 border-blue-200 bg-blue-50">
112121
<CardHeader>
113122
<CardTitle className="text-sm text-blue-900">
114-
You're already a member of:
123+
You&apos;re already a member of:
115124
</CardTitle>
116125
</CardHeader>
117126
<CardContent>
118127
<ul className="space-y-2">
119-
{otherMemberships.map((membership: any) => (
120-
<li key={membership.tenant_id} className="text-sm text-blue-800">
121-
{membership.tenants?.name || 'Unknown School'}
122-
</li>
123-
))}
128+
{otherMemberships.map((membership) => {
129+
// The generated types model this to-one embed as an array while
130+
// PostgREST returns a bare object; the `any` this replaces was
131+
// hiding the mismatch rather than resolving it. Handle both so
132+
// the row renders whichever shape actually arrives.
133+
const school = Array.isArray(membership.tenants)
134+
? membership.tenants[0]
135+
: membership.tenants
136+
return (
137+
<li key={membership.tenant_id} className="text-sm text-blue-800">
138+
{school?.name || 'Unknown School'}
139+
</li>
140+
)
141+
})}
124142
</ul>
125143
<p className="text-xs text-blue-700 mt-3">
126144
You can switch between schools anytime from your dashboard.

app/actions/admin/courses.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
44
import { verifyAdminAccess, createAdminClient, type ActionResult } from '@/lib/supabase/admin'
55
import { getCurrentTenantId } from '@/lib/supabase/tenant'
66
import { isSuperAdmin } from '@/lib/supabase/get-user-role'
7+
import { reconcileAccessCutoffSafely } from '@/lib/billing/access-cutoff'
78

89
/**
910
* Approves a course (moves from draft to published)
@@ -132,9 +133,16 @@ export async function archiveCourse(
132133
})
133134
}
134135

136+
// This is the *admin* archive path, distinct from `archiveCourse` in
137+
// `app/actions/teacher/courses.ts` — the admin course-status screen calls
138+
// this one. Both drop the active course count, so both must reconcile, or
139+
// the recovery loop stays open on whichever half was missed (#550).
140+
await reconcileAccessCutoffSafely(adminClient, tenantId)
141+
135142
revalidatePath('/dashboard/admin/courses')
136143
revalidatePath(`/dashboard/teacher/courses/${courseId}`)
137144
revalidatePath('/dashboard/student')
145+
revalidatePath('/dashboard/admin/billing')
138146

139147
return { success: true }
140148
} catch (error) {
@@ -199,9 +207,16 @@ export async function restoreCourse(courseId: number): Promise<ActionResult> {
199207
})
200208
}
201209

210+
// Restoring *raises* the active course count, so it can put the school
211+
// back over its limit. Reconciling here schedules the cutoff at the moment
212+
// the admin causes it, with the full 14-day grace period, rather than
213+
// whenever a sweep next happens to notice.
214+
await reconcileAccessCutoffSafely(adminClient, tenantId)
215+
202216
revalidatePath('/dashboard/admin/courses')
203217
revalidatePath(`/dashboard/teacher/courses/${courseId}`)
204218
revalidatePath('/dashboard/student')
219+
revalidatePath('/dashboard/admin/billing')
205220

206221
return { success: true }
207222
} catch (error) {

0 commit comments

Comments
 (0)