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,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 };
}