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,18 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { DashboardPage } from '$features/lms/pages';
import PendingInviteModal from '$features/lms/components/pending-invite-modal.svelte';
import { getGreeting } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { classroomio } from '$lib/utils/services/api';
import * as Page from '@cio/ui/base/page';
import { coursesApi } from '$features/course/api';
import type { PendingOrgInvite } from '$features/lms/utils/types';
let pendingInvite = $state<PendingOrgInvite | null>(null);
let showInviteModal = $state(false);
import * as Page from '@cio/ui/base/page';
const todayLabel = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
@@ -41,26 +33,6 @@
);
let progressPercentage = $derived(totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0);
onMount(async () => {
if (!$profile.id || !$currentOrg.id) return;
try {
const response = await classroomio.invite.organization.pending.$get();
const result = await response.json();
if (result.success && result.data) {
pendingInvite = result.data;
showInviteModal = true;
}
} catch (error) {
console.error('Failed to check pending org invite', error);
}
});
function handleInviteAccepted() {
window.location.href = '/lms';
}
</script>
<svelte:head>
@@ -91,7 +63,3 @@
{/snippet}
</Page.Body>
</Page.Root>
{#if pendingInvite}
<PendingInviteModal bind:open={showInviteModal} invite={pendingInvite} onAccepted={handleInviteAccepted} />
{/if}
@@ -5,7 +5,7 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton';
import { currentOrg } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { isOrgStudent } from '$lib/utils/store/app';
import { appInitApi } from '$features/app/init.svelte';
import { AppHeader } from '$features/ui';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
@@ -27,9 +27,9 @@
});
$effect(() => {
// Decide only once the account/role has loaded — until then the student flag
// defaults to false and a student is indistinguishable from an admin.
if (appInitApi.isInitializedAndReady && $isStudentExperience) {
// Students must not use the admin org dashboard on app.* — send them to LMS.
// isStudentExperience is false on the app host in cloud mode even for students.
if (appInitApi.isInitializedAndReady && $isOrgStudent) {
goto(resolve('/lms', {}));
}
});
+9
View File
@@ -4,6 +4,7 @@
import { Snackbar } from '$features/ui';
import { appInitApi } from '$features/app/init.svelte';
import PendingInviteModal from '$features/lms/components/pending-invite-modal.svelte';
import { resolveAppOrgParams } from '$features/app/resolve-app-org-params';
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
import { globalStore } from '$lib/utils/store/app';
@@ -109,6 +110,14 @@
<Snackbar />
{#if appInitApi.pendingOrgInvite}
<PendingInviteModal
bind:open={appInitApi.showPendingInviteModal}
invite={appInitApi.pendingOrgInvite}
onAccepted={(redirectTo) => appInitApi.handlePendingInviteAccepted(redirectTo)}
/>
{/if}
{@render children?.()}
</div>