diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 0b1733c4..eb568def 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -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) => { diff --git a/apps/api/src/services/organization/auto-enroll.ts b/apps/api/src/services/organization/auto-enroll.ts deleted file mode 100644 index a8a6e928..00000000 --- a/apps/api/src/services/organization/auto-enroll.ts +++ /dev/null @@ -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 `.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 { - 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 }; -} diff --git a/apps/api/src/services/organization/auto-join.ts b/apps/api/src/services/organization/auto-join.ts new file mode 100644 index 00000000..c98be3d8 --- /dev/null +++ b/apps/api/src/services/organization/auto-join.ts @@ -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 { + 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 `.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 { + 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 }; +} diff --git a/apps/api/src/utils/__tests__/errors.test.ts b/apps/api/src/utils/__tests__/errors.test.ts index cfdaa3ec..5b9f63ab 100644 --- a/apps/api/src/utils/__tests__/errors.test.ts +++ b/apps/api/src/utils/__tests__/errors.test.ts @@ -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 + }); + }); +}); diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index 98bd5cea..c720601a 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -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( + { + success: false, + error: 'Resource already exists', + code: ErrorCodes.CONFLICT + }, + 409 + ); + } + return c.json( { success: false, diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts index bf4a2e57..af1dbd01 100644 --- a/apps/dashboard/src/lib/features/app/init.svelte.ts +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -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(null); session = $state(null); + pendingOrgInvite = $state(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 { - 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({ - 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 { + /** + * 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 }).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 { 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 { + 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 { + 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 { + 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 { 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 { + 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); diff --git a/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts b/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts index 351fa92d..cb025e63 100644 --- a/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts +++ b/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts @@ -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; }; diff --git a/apps/dashboard/src/lib/features/lms/components/pending-invite-modal.svelte b/apps/dashboard/src/lib/features/lms/components/pending-invite-modal.svelte index 31eb29de..4a09e57e 100644 --- a/apps/dashboard/src/lib/features/lms/components/pending-invite-modal.svelte +++ b/apps/dashboard/src/lib/features/lms/components/pending-invite-modal.svelte @@ -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'); diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte index 2b2e52b2..937fe533 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte @@ -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; diff --git a/apps/dashboard/src/lib/utils/store/org.ts b/apps/dashboard/src/lib/utils/store/org.ts index 7477e9fe..65418d6b 100644 --- a/apps/dashboard/src/lib/utils/store/org.ts +++ b/apps/dashboard/src/lib/utils/store/org.ts @@ -67,6 +67,21 @@ export const isOrgAdmin = derived(currentOrg, ($currentOrg) => { return $currentOrg.roleId === ROLE.ADMIN; }); + +/** True when the user can manage an org (admin dashboard), not a student-only member. */ +export function isOrgManagerRole(roleId: number): boolean { + return roleId === ROLE.ADMIN || roleId === ROLE.TUTOR; +} + +/** Orgs where the user is ADMIN or TUTOR — eligible for the admin org switcher. */ +export const managedOrgs = derived(orgs, ($orgs) => $orgs.filter((org) => isOrgManagerRole(org.roleId))); + +export const isOrgTeamMember = derived(currentOrg, ($currentOrg) => { + if ($currentOrg.roleId === 0) return null; + + return isOrgManagerRole($currentOrg.roleId); +}); + const getActivePlan = (org: AccountOrg) => { return org.plans.find((p) => p.isActive); }; diff --git a/apps/dashboard/src/routes/(app)/lms/+page.svelte b/apps/dashboard/src/routes/(app)/lms/+page.svelte index e2f1073d..1c024733 100644 --- a/apps/dashboard/src/routes/(app)/lms/+page.svelte +++ b/apps/dashboard/src/routes/(app)/lms/+page.svelte @@ -1,18 +1,10 @@ @@ -91,7 +63,3 @@ {/snippet} - -{#if pendingInvite} - -{/if} diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte index e657d934..575ad9ec 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte @@ -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', {})); } }); diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index bd6f5ed9..3412a093 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -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 @@ + {#if appInitApi.pendingOrgInvite} + appInitApi.handlePendingInviteAccepted(redirectTo)} + /> + {/if} + {@render children?.()} diff --git a/packages/utils/src/errors/index.ts b/packages/utils/src/errors/index.ts index 3d607f76..0f6caf45 100644 --- a/packages/utils/src/errors/index.ts +++ b/packages/utils/src/errors/index.ts @@ -1,6 +1,7 @@ import { ErrorCodes, type ErrorCode } from '../constants'; export { ErrorCodes, type ErrorCode }; +export { getPostgresError, isUniqueConstraintViolation, type PostgresErrorDetails } from './postgres-error'; /** * Standard HTTP status code value. Kept as a structural `number` so this diff --git a/packages/utils/src/errors/postgres-error.ts b/packages/utils/src/errors/postgres-error.ts new file mode 100644 index 00000000..8ab51fea --- /dev/null +++ b/packages/utils/src/errors/postgres-error.ts @@ -0,0 +1,45 @@ +export type PostgresErrorDetails = { + code: string; + constraint?: string; + detail?: string; + table?: string; +}; + +function readPostgresField(error: object, key: string): string | undefined { + if (!(key in error)) { + return undefined; + } + + const value = (error as Record)[key]; + + return typeof value === 'string' ? value : undefined; +} + +/** + * Walks an error's `cause` chain and returns the first Postgres error payload + * (postgres.js / Drizzle wrap the driver error as `cause`). + */ +export function getPostgresError(error: unknown): PostgresErrorDetails | null { + let current: unknown = error; + + while (current && typeof current === 'object') { + const code = readPostgresField(current, 'code'); + + if (code && /^[0-9A-Z]{5}$/.test(code)) { + return { + code, + constraint: readPostgresField(current, 'constraint_name'), + detail: readPostgresField(current, 'detail'), + table: readPostgresField(current, 'table_name') + }; + } + + current = 'cause' in current ? (current as { cause: unknown }).cause : null; + } + + return null; +} + +export function isUniqueConstraintViolation(error: unknown): boolean { + return getPostgresError(error)?.code === '23505'; +}