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:
co-authored by
Rotimi Best
Cursor Agent
parent
7eaff0040c
commit
8bd07a524c
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user