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
+1
View File
@@ -1,6 +1,7 @@
import { ErrorCodes, type ErrorCode } from '../constants';
export { ErrorCodes, type ErrorCode };
export { getPostgresError, isUniqueConstraintViolation, type PostgresErrorDetails } from './postgres-error';
/**
* Standard HTTP status code value. Kept as a structural `number` so this
@@ -0,0 +1,45 @@
export type PostgresErrorDetails = {
code: string;
constraint?: string;
detail?: string;
table?: string;
};
function readPostgresField(error: object, key: string): string | undefined {
if (!(key in error)) {
return undefined;
}
const value = (error as Record<string, unknown>)[key];
return typeof value === 'string' ? value : undefined;
}
/**
* Walks an error's `cause` chain and returns the first Postgres error payload
* (postgres.js / Drizzle wrap the driver error as `cause`).
*/
export function getPostgresError(error: unknown): PostgresErrorDetails | null {
let current: unknown = error;
while (current && typeof current === 'object') {
const code = readPostgresField(current, 'code');
if (code && /^[0-9A-Z]{5}$/.test(code)) {
return {
code,
constraint: readPostgresField(current, 'constraint_name'),
detail: readPostgresField(current, 'detail'),
table: readPostgresField(current, 'table_name')
};
}
current = 'cause' in current ? (current as { cause: unknown }).cause : null;
}
return null;
}
export function isUniqueConstraintViolation(error: unknown): boolean {
return getPostgresError(error)?.code === '23505';
}