fix(org): auto-enroll idempotency, signup enrollment, and pending invite gate (#729)

* fix(org): auto-enroll idempotency, pending invites, and tenant enrollment

- Map Postgres unique violations to 409 CONFLICT instead of 500
- Link email-only roster rows before signup policy gates (preserve role)
- Return pendingInvite when an active org invite exists; surface modal globally
- Enroll at signup via create-profile hook when cio-org-id header is present
- Gate client auto-enroll on session orgRoles and pending invite check
- Throw 409 when org email row is linked to a different profile (Greptile)
- Enforce free-plan student cap via assertStudentCapacityOrThrow on new enrolls
- Hide student-only orgs from admin org switcher; redirect students off /org/[slug]
- Route tutor/admin pending-invite accepts on tenant sites to app host

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* perf(dashboard): parallelize account and pending invite fetch on tenant bootstrap

Start GET /account and GET /invite/organization/pending together when a
logged-in user visits a tenant site without session membership. Auto-enroll
still runs sequentially after both complete when no invite is found, then
refetches /account if enrollment succeeds.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix: address PR review — restore AppModeWatcher and drop signup ctx enrollment

Better Auth database hooks do not receive frontend cio-org-id headers in ctx,
so enrollFromSignupOrgContext could never work. Revert to client-side auto-enroll
fallback and restore main's AppModeWatcher in the root layout.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* refactor(org): rename auto-enroll-student endpoint to auto-join

POST /organization/auto-join replaces /auto-enroll-student to clarify this
joins org membership, not course enrollment. Renamed service to autoJoinOrg.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-12 22:46:44 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 7eaff0040c
commit 8bd07a524c
15 changed files with 471 additions and 171 deletions
@@ -10,7 +10,7 @@
interface Props {
open?: boolean;
invite: PendingOrgInvite;
onAccepted?: () => void;
onAccepted?: (redirectTo?: string) => void;
}
let { open = $bindable(false), invite, onAccepted }: Props = $props();
@@ -52,7 +52,7 @@
snackbar.success('invite.organization.messages.joined');
open = false;
onAccepted?.();
onAccepted?.(result.data.redirectTo);
} catch (error) {
console.error('Failed to accept organization invite by id', error);
snackbar.error('invite.organization.messages.join_failed');