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
@@ -50,7 +50,7 @@ import { Hono } from '@api/utils/hono';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { TOrganization } from '@db/types';
|
||||
import { assetsRouter } from '@api/routes/organization/assets';
|
||||
import { autoEnrollStudent } from '@api/services/organization/auto-enroll';
|
||||
import { autoJoinOrg } from '@api/services/organization/auto-join';
|
||||
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
@@ -125,13 +125,13 @@ export const organizationRouter = new Hono()
|
||||
* Requires authentication
|
||||
*/
|
||||
/**
|
||||
* POST /organization/auto-enroll-student
|
||||
* Idempotently enrolls the authenticated user as a STUDENT in the org
|
||||
* identified by the `cio-org-id` header. Called by the dashboard's
|
||||
* `setupApp` after a user authenticates on a tenant site they aren't a
|
||||
* member of yet. Respects `disableSignup` and `inviteOnly` policies.
|
||||
* POST /organization/auto-join
|
||||
* Idempotently joins the authenticated user to the org identified by the
|
||||
* `cio-org-id` header. Called by the dashboard's `setupApp` after a user
|
||||
* authenticates on a tenant site they aren't a member of yet. Respects
|
||||
* `disableSignup` and `inviteOnly` policies.
|
||||
*/
|
||||
.post('/auto-enroll-student', authMiddleware, async (c) => {
|
||||
.post('/auto-join', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
@@ -140,10 +140,10 @@ export const organizationRouter = new Hono()
|
||||
return c.json({ success: false, error: 'Organization ID is required', code: 'ORG_ID_REQUIRED' }, 400);
|
||||
}
|
||||
|
||||
const result = await autoEnrollStudent(user.id, orgId);
|
||||
const result = await autoJoinOrg(user.id, orgId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to auto-enroll student');
|
||||
return handleError(c, error, 'Failed to auto-join organization');
|
||||
}
|
||||
})
|
||||
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createOrganizationMember,
|
||||
getOrganizationById,
|
||||
getOrganizationMemberIdByOrgAndProfile
|
||||
} from '@cio/db/queries/organization';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||
|
||||
interface OrgSignupSettings {
|
||||
signup?: {
|
||||
inviteOnly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutoEnrollResult {
|
||||
alreadyMember: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-enroll the authenticated user as a STUDENT in the given org. Used by
|
||||
* the dashboard when a newly-signed-in user lands on a tenant site they
|
||||
* aren't yet a member of (free-tier `<org>.myclassroomio.com` or a verified
|
||||
* BYOD domain).
|
||||
*
|
||||
* Idempotent: existing members get an `alreadyMember: true` no-op so invited
|
||||
* admins/tutors don't get downgraded.
|
||||
*
|
||||
* Respects the same org policies as `signupGuard` does for email/password
|
||||
* signups: `disableSignup` and `settings.signup.inviteOnly` both block the
|
||||
* insert.
|
||||
*/
|
||||
export async function autoEnrollStudent(userId: string, orgId: string): Promise<AutoEnrollResult> {
|
||||
const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(orgId, userId);
|
||||
if (existingMemberId) {
|
||||
return { alreadyMember: true };
|
||||
}
|
||||
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (organization.disableSignup) {
|
||||
throw new AppError('Signup is disabled for this organization', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const settings = organization.settings as OrgSignupSettings | null;
|
||||
if (settings?.signup?.inviteOnly) {
|
||||
throw new AppError('This organization requires an invitation to join', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const profile = await getProfileById(userId);
|
||||
if (!profile?.email) {
|
||||
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await assertStudentCapacityOrThrow(orgId, 1);
|
||||
|
||||
await createOrganizationMember({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: profile.email,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: false };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createOrganizationMember,
|
||||
getOrganizationById,
|
||||
getOrganizationMemberIdByOrgAndProfile
|
||||
} from '@cio/db/queries/organization';
|
||||
import {
|
||||
getActivePendingOrgInviteForEmail,
|
||||
selectOrganizationMemberByOrgAndNormalizedEmail,
|
||||
updateOrganizationMemberById
|
||||
} from '@cio/db/queries/organization/invite';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||
|
||||
interface OrgSignupSettings {
|
||||
signup?: {
|
||||
inviteOnly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutoJoinResult {
|
||||
alreadyMember: boolean;
|
||||
/** True when an email-only roster row was linked to the signing-in profile (session refresh needed). */
|
||||
linkedExistingMember: boolean;
|
||||
/** True when the user has an active org invite — caller should surface accept UI instead of joining. */
|
||||
pendingInvite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a pre-existing org roster row (audience import, team invite, tutor add)
|
||||
* that was created with email but no profile_id yet. Preserves the existing role
|
||||
* (TUTOR, ADMIN, etc.) — does not downgrade to STUDENT.
|
||||
*/
|
||||
async function linkExistingMemberByEmail(
|
||||
userId: string,
|
||||
orgId: string,
|
||||
normalizedEmail: string
|
||||
): Promise<AutoJoinResult | null> {
|
||||
const existingMemberByEmail = await selectOrganizationMemberByOrgAndNormalizedEmail(db, orgId, normalizedEmail);
|
||||
|
||||
if (!existingMemberByEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingMemberByEmail.profileId && existingMemberByEmail.profileId !== userId) {
|
||||
throw new AppError('Email is already linked to another profile', ErrorCodes.CONFLICT, 409);
|
||||
}
|
||||
|
||||
if (!existingMemberByEmail.profileId) {
|
||||
await updateOrganizationMemberById(db, existingMemberByEmail.id, {
|
||||
profileId: userId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: true, linkedExistingMember: true };
|
||||
}
|
||||
|
||||
return { alreadyMember: true, linkedExistingMember: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-join the authenticated user to the given org. Used by the dashboard when
|
||||
* a newly-signed-in user lands on a tenant site they aren't yet a member of
|
||||
* (free-tier `<org>.myclassroomio.com` or a verified BYOD domain).
|
||||
*
|
||||
* New members are created as STUDENT when no roster row or invite applies.
|
||||
* Idempotent: existing members get an `alreadyMember: true` no-op so invited
|
||||
* admins/tutors don't get downgraded.
|
||||
*
|
||||
* Pre-existing roster rows matched by email (e.g. tutors added with
|
||||
* `profile_id` null) are linked before signup policy checks — invite-only and
|
||||
* disableSignup only apply to net-new student signups.
|
||||
*/
|
||||
export async function autoJoinOrg(userId: string, orgId: string): Promise<AutoJoinResult> {
|
||||
const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(orgId, userId);
|
||||
if (existingMemberId) {
|
||||
return { alreadyMember: true, linkedExistingMember: false };
|
||||
}
|
||||
|
||||
const profile = await getProfileById(userId);
|
||||
if (!profile?.email) {
|
||||
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const normalizedEmail = profile.email.toLowerCase().trim();
|
||||
|
||||
const pendingInvite = await getActivePendingOrgInviteForEmail(orgId, normalizedEmail);
|
||||
if (pendingInvite) {
|
||||
return { alreadyMember: false, linkedExistingMember: false, pendingInvite: true };
|
||||
}
|
||||
|
||||
const linked = await linkExistingMemberByEmail(userId, orgId, normalizedEmail);
|
||||
if (linked) {
|
||||
return linked;
|
||||
}
|
||||
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (organization.disableSignup) {
|
||||
throw new AppError('Signup is disabled for this organization', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const settings = organization.settings as OrgSignupSettings | null;
|
||||
if (settings?.signup?.inviteOnly) {
|
||||
throw new AppError('This organization requires an invitation to join', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
await assertStudentCapacityOrThrow(orgId, 1);
|
||||
|
||||
await createOrganizationMember({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: normalizedEmail,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: false, linkedExistingMember: false };
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user