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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
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,9 +1,17 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { currentOrg, getOrgPublicUrl, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
import {
currentOrg,
getAppOrigin,
getOrgPublicUrl,
isOrgManagerRole,
mergeAccountOrgFromServer,
orgs
} from '$lib/utils/store/org';
import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
import type { AccountResponse } from './types';
import { resolveAppOrgParams, type AppOrgParams } from './resolve-app-org-params';
import type { AccountResponse, AccountSuccess } from './types';
import { type AppOrgParams } from './resolve-app-org-params';
import type { PendingOrgInvite } from '$features/lms/utils/types';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import type { TUser } from '@cio/db/types';
import { authClient } from '$lib/utils/services/auth/client';
@@ -12,7 +20,7 @@ import { goto } from '$app/navigation';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { identifyPosthogUser } from '$lib/utils/services/posthog';
import { identifyUserJotUser } from '$lib/utils/services/userjot';
import { isOrgStudent } from '$lib/utils/store/app';
import { isOrgStudent, globalStore } from '$lib/utils/store/app';
import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { licenseApi } from '$features/license/api/license.svelte';
import { logout } from '$lib/utils/functions/logout';
@@ -44,6 +52,8 @@ function tenantSyncKey(params: AppSetupParams): string | null {
class AppInitApi extends BaseApi {
data = $state<AccountResponse>(null);
session = $state<App.Locals | null>(null);
pendingOrgInvite = $state<PendingOrgInvite | null>(null);
showPendingInviteModal = $state(false);
private syncedTenantKey: string | null = null;
get loading() {
@@ -51,57 +61,178 @@ class AppInitApi extends BaseApi {
}
async setupApp(locals: App.Locals, params: AppSetupParams): Promise<boolean | undefined> {
if (!locals.user) {
if (!params.isOrgSite) {
goto(resolve('/login', {}));
return false;
}
console.log('No user found in locals');
if (this.isLoading || this.isInitializedAndReady) {
return;
}
this.session = locals;
this.isLoading = true;
// Auto-enroll on tenant sites for first-time signups. Idempotent on the
// API side (no-ops for existing members so invited admins/tutors keep
// their roles). Runs BEFORE the account fetch so the returned org list
// already reflects the new membership.
if (params.isOrgSite && params.orgId) {
await this.autoEnrollOnTenantSite(params.orgId);
}
try {
if (!locals.user) {
if (!params.isOrgSite) {
goto(resolve('/login', {}));
return false;
}
await this.execute<typeof classroomio.account.$get>({
requestFn: () => classroomio.account.$get(),
logContext: 'fetching account',
onSuccess: (data) => {
this.data = data;
this.setupStores(params);
licenseApi.syncFromAccount(data.licenseFeatures, get(currentOrg));
setupAnalyticsBasedOnLicense(
data.profile?.id ? { id: data.profile.id, email: data.profile.email, name: data.profile.fullname } : undefined
);
this.setUserAnalytics();
this.routeUserToNextPage(params);
},
onError: () => {
logout();
console.log('No user found in locals');
return;
}
});
this.session = locals;
const needsTenantMembership = params.isOrgSite && params.orgId && !this.isMemberOfOrg(locals, params.orgId);
if (needsTenantMembership && params.orgId) {
const orgId = params.orgId;
const [pendingInvite, accountData] = await Promise.all([
this.fetchPendingOrgInvite(orgId),
this.fetchAccountData()
]);
if (!accountData) {
return false;
}
if (pendingInvite) {
this.showPendingOrgInvite(pendingInvite);
this.applyAccountData(accountData, params);
return;
}
const joined = await this.autoJoinOnTenantSite(orgId);
if (joined) {
const refreshedAccount = await this.fetchAccountData();
if (refreshedAccount) {
this.applyAccountData(refreshedAccount, params);
return;
}
}
this.applyAccountData(accountData, params);
return;
}
const accountData = await this.fetchAccountData();
if (accountData) {
this.applyAccountData(accountData, params);
}
} finally {
if (!this.isInitializedAndReady) {
this.isLoading = false;
}
}
}
private async autoEnrollOnTenantSite(orgId: string): Promise<boolean> {
/**
* Whether the current session already reflects membership in `orgId`.
* Better Auth's `customSession` attaches an `orgRoles` map ({ orgId: roleId })
* that RBAC middlewares also use, so a present key means the user is a member.
*/
private isMemberOfOrg(locals: App.Locals, orgId: string): boolean {
const orgRoles = (locals as { orgRoles?: Record<string, number> }).orgRoles;
return !!orgRoles && orgId in orgRoles;
}
private applyAccountData(accountData: AccountSuccess, params: AppSetupParams) {
this.resetErrors();
this.success = true;
this.data = accountData;
this.setupStores(params);
licenseApi.syncFromAccount(accountData.licenseFeatures, get(currentOrg));
setupAnalyticsBasedOnLicense(
accountData.profile?.id
? { id: accountData.profile.id, email: accountData.profile.email, name: accountData.profile.fullname }
: undefined
);
this.setUserAnalytics();
this.routeUserToNextPage(params);
}
private async fetchAccountData(): Promise<AccountSuccess | undefined> {
try {
const response = await classroomio.organization['auto-enroll-student'].$post(
const response = await classroomio.account.$get();
const result = (await response.json()) as AccountResponse;
if (!result?.success) {
this.error = JSON.stringify(result);
logout();
return undefined;
}
return result;
} catch (error) {
console.error('Error fetching account:', error);
this.error = error instanceof Error ? error.message : 'Error fetching account';
logout();
return undefined;
}
}
private showPendingOrgInvite(pendingInvite: PendingOrgInvite) {
this.pendingOrgInvite = pendingInvite;
this.showPendingInviteModal = true;
}
/**
* On tenant sites, link an existing roster row or auto-join the org as STUDENT —
* unless the user has a pending org invite, in which case we surface accept UI.
* Returns true when a new membership was created or an email-only row was linked.
*/
private async ensureTenantMembership(
orgId: string,
options?: { knownPendingInvite?: PendingOrgInvite | null }
): Promise<boolean> {
const pendingInvite =
options && 'knownPendingInvite' in options
? (options.knownPendingInvite ?? null)
: await this.fetchPendingOrgInvite(orgId);
if (pendingInvite) {
this.showPendingOrgInvite(pendingInvite);
return false;
}
return this.autoJoinOnTenantSite(orgId);
}
private async fetchPendingOrgInvite(orgId: string): Promise<PendingOrgInvite | null> {
try {
const response = await classroomio.invite.organization.pending.$get({}, { headers: { 'cio-org-id': orgId } });
const result = await response.json();
if (result.success && result.data) {
return result.data;
}
} catch (error) {
console.warn('pending org invite check failed', error);
}
return null;
}
private async autoJoinOnTenantSite(orgId: string): Promise<boolean> {
try {
const response = await classroomio.organization['auto-join'].$post(
{},
{ headers: { 'cio-org-id': orgId } }
);
if (!response.ok) {
// 403 is expected for invite-only / disabled-signup orgs — the user
// visited a tenant site but isn't allowed to enroll. Other failures
// visited a tenant site but isn't allowed to join. Other failures
// shouldn't block the rest of setupApp.
console.warn('auto-enroll-student failed', response.status, await response.text().catch(() => ''));
console.warn('auto-join failed', response.status, await response.text().catch(() => ''));
return false;
}
const result = await response.json();
if (result.success && result.data?.pendingInvite) {
const pendingInvite = await this.fetchPendingOrgInvite(orgId);
if (pendingInvite) {
this.showPendingOrgInvite(pendingInvite);
}
return false;
}
@@ -111,7 +242,7 @@ class AppInitApi extends BaseApi {
await authClient.getSession({ query: { disableCookieCache: true } });
return true;
} catch (error) {
console.warn('auto-enroll-student threw', error);
console.warn('auto-join threw', error);
return false;
}
}
@@ -121,7 +252,7 @@ class AppInitApi extends BaseApi {
*
* setupApp only runs once per session; tenant subdomains and `/org/[slug]`
* routes can change without another /account fetch. This keeps `currentOrg`
* aligned with the URL and attempts auto-enroll on org sites when needed.
* aligned with the URL and attempts auto-join on org sites when needed.
*/
async syncOrgContext(params: AppSetupParams): Promise<void> {
if (!this.data?.success || !params.orgSiteName) {
@@ -135,11 +266,11 @@ class AppInitApi extends BaseApi {
const isMember = this.data.organizations.some((org) => org.siteName === params.orgSiteName);
if (params.isOrgSite && params.orgId && !isMember) {
const enrolled = await this.autoEnrollOnTenantSite(params.orgId);
const pendingInvite = await this.fetchPendingOrgInvite(params.orgId);
const enrolled = await this.ensureTenantMembership(params.orgId, { knownPendingInvite: pendingInvite });
if (enrolled) {
const response = await classroomio.account.$get();
const accountData = (await response.json()) as AccountResponse;
if (accountData?.success) {
const accountData = await this.fetchAccountData();
if (accountData) {
this.data = accountData;
}
}
@@ -229,8 +360,14 @@ class AppInitApi extends BaseApi {
return undefined;
}
const managedOrganizations = organizations.filter((org) => isOrgManagerRole(org.roleId));
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
return organizations.find((org) => org.siteName === lastOrgSiteName) ?? organizations[0];
return (
managedOrganizations.find((org) => org.siteName === lastOrgSiteName) ??
managedOrganizations[0] ??
organizations[0]
);
}
routeUserToNextPage({ isOrgSite }: AppSetupParams) {
@@ -341,10 +478,39 @@ class AppInitApi extends BaseApi {
});
}
async handlePendingInviteAccepted(redirectTo?: string): Promise<void> {
this.showPendingInviteModal = false;
this.pendingOrgInvite = null;
await authClient.getSession({ query: { disableCookieCache: true } });
const response = await classroomio.account.$get();
const accountData = (await response.json()) as AccountResponse;
if (accountData?.success) {
this.data = accountData;
this.setupStores();
}
const isOnOrgSite = get(globalStore).isOrgSite;
if (redirectTo?.startsWith('/org/') && isOnOrgSite) {
window.location.href = `${getAppOrigin()}${redirectTo}`;
return;
}
if (redirectTo) {
window.location.href = redirectTo;
return;
}
window.location.reload();
}
reset() {
super.reset();
this.data = null;
this.syncedTenantKey = null;
this.pendingOrgInvite = null;
this.showPendingInviteModal = false;
licenseApi.reset();
user.set(defaultUserState);
@@ -1,7 +1,7 @@
export type AppOrgParams = {
isOrgSite: boolean;
orgSiteName: string;
/** Tenant org id from `getOrgSiteInfo`; used to auto-enroll on org sites. */
/** Tenant org id from `getOrgSiteInfo`; used to auto-join on org sites. */
orgId?: string | null;
};
@@ -10,7 +10,7 @@
interface Props {
open?: boolean;
invite: PendingOrgInvite;
onAccepted?: () => void;
onAccepted?: (redirectTo?: string) => void;
}
let { open = $bindable(false), invite, onAccepted }: Props = $props();
@@ -52,7 +52,7 @@
snackbar.success('invite.organization.messages.joined');
open = false;
onAccepted?.();
onAccepted?.(result.data.redirectTo);
} catch (error) {
console.error('Failed to accept organization invite by id', error);
snackbar.error('invite.organization.messages.join_failed');
@@ -12,7 +12,7 @@
import type { AccountOrg } from '$features/app/types';
import { setTheme } from '$lib/utils/functions/theme';
import { currentOrg, currentOrgPath, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
import { currentOrg, currentOrgPath, managedOrgs, mergeAccountOrgFromServer } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import ComingSoon from '$features/ui/coming-soon.svelte';
@@ -33,8 +33,9 @@
const accountRootId = $derived(rootIdFor($currentOrg));
const accountWorkspaces = $derived($orgs.filter((org) => rootIdFor(org) === accountRootId));
const otherOrgs = $derived($orgs.filter((org) => rootIdFor(org) !== accountRootId));
const switchableOrgs = $derived($managedOrgs);
const accountWorkspaces = $derived(switchableOrgs.filter((org) => rootIdFor(org) === accountRootId));
const otherOrgs = $derived(switchableOrgs.filter((org) => rootIdFor(org) !== accountRootId));
function onClick(org: AccountOrg) {
if (org.id === $currentOrg.id) return;