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
+30 -1
View File
@@ -3,7 +3,7 @@ import { z, ZodError } from 'zod';
import { Hono } from 'hono';
import { ZCourseType } from '@cio/utils/validation/course';
import { AppError, ErrorCodes, formatZodErrorMessage, handlePublicApiError } from '@api/utils/errors';
import { AppError, ErrorCodes, formatZodErrorMessage, handleError, handlePublicApiError } from '@api/utils/errors';
describe('formatZodErrorMessage', () => {
it('formats issue paths and messages', () => {
@@ -65,3 +65,32 @@ describe('handlePublicApiError', () => {
});
});
});
describe('handleError', () => {
it('maps postgres unique violations to 409 CONFLICT', async () => {
const app = new Hono().get('/test', async (c) => {
try {
throw {
name: 'DrizzleQueryError',
message: 'Failed query',
cause: {
code: '23505',
constraint_name: 'organizationmember_org_email_unique'
}
};
} catch (error) {
return handleError(c, error, 'Failed to create member');
}
});
const response = await app.request('/test');
const body = await response.json();
expect(response.status).toBe(409);
expect(body).toEqual({
success: false,
error: 'Resource already exists',
code: ErrorCodes.CONFLICT
});
});
});
+12 -1
View File
@@ -2,7 +2,7 @@ import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { ZodError } from 'zod';
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
import { AppError, ErrorCodes, type ErrorCode, isUniqueConstraintViolation } from '@cio/utils/errors';
// AppError moved to `@cio/utils/errors` so it can be thrown from
// non-api workspace packages (notably `@cio/core`). Re-exported
@@ -81,6 +81,17 @@ export const handleError = (
);
}
if (isUniqueConstraintViolation(error)) {
return c.json<ErrorResponse>(
{
success: false,
error: 'Resource already exists',
code: ErrorCodes.CONFLICT
},
409
);
}
return c.json<ErrorResponse>(
{
success: false,