import { AppError, ErrorCodes } from '@api/utils/errors'; import { checkEmailsExistInOrg, claimPendingOrganizationInvite, createOrganizationInvite, createOrganizationInviteAudit, createOrganizationMember, createOrganizationMembers, createLinkInvite, getActivePendingOrgInviteForEmail, getOrganizationById, getOrganizationInviteByTokenHash, getOrgLinkInvite, getOrgLinkInviteWithOrg, revokeActiveOrganizationInvitesByEmails, selectOrganizationInviteWithOrgByInviteId, selectOrganizationInviteWithOrgByTokenHash, selectOrganizationMemberByOrgAndNormalizedEmail, selectOrganizationMemberByOrgAndProfile, setLinkInviteRevoked, updateOrganizationMemberById } from '@cio/db/queries/organization'; import { getCourseGroupIds } from '@cio/db/queries/course'; import { enrollUsersInCourseGroups } from '@cio/db/queries/group'; import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort'; import { ROLE } from '@cio/utils/constants'; import type { TNewOrganizationInviteAudit } from '@db/types'; import crypto from 'node:crypto'; import { db, type DbOrTxClient } from '@cio/db/drizzle'; import { assertStudentCapacityOrThrow } from './student-limit'; import { getAppBaseUrl } from '@cio/core/config/dashboard-url'; import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org'; import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { buildEmailBranding, buildEmailFromName, sanitizeEmailSubject } from '@cio/email'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED'; const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; interface TInviteRequestContext { ipAddress?: string | null; userAgent?: string | null; } interface TAuthUser { id: string; email?: string | null; } function hashToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } function generateToken(): string { return crypto.randomBytes(32).toString('base64url'); } function normalizeEmails(emails: string[]): string[] { return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))]; } function buildTeamInviteLink(token: string): string { return `${getAppBaseUrl()}/invite/${encodeURIComponent(token)}`; } function getRoleLabel(roleId: number): string { if (roleId === ROLE.ADMIN) return 'Admin'; if (roleId === ROLE.TUTOR) return 'Tutor'; if (roleId === ROLE.STUDENT) return 'Student'; return `Role ${roleId}`; } function getInviteStatus(invite: { isRevoked: boolean; expiresAt: string; acceptedAt: string | null; }): OrganizationInviteStatus { if (invite.isRevoked) { return 'REVOKED'; } if (invite.acceptedAt) { return 'ACCEPTED'; } if (new Date(invite.expiresAt).getTime() <= Date.now()) { return 'EXPIRED'; } return 'ACTIVE'; } function getExpiryLabel(expiresAtIso: string): string { return new Date(expiresAtIso).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }); } async function syncOrgMemberForOrgInvite( tx: DbOrTxClient, params: { organizationId: string; roleId: number; normalizedEmail: string; userId: string } ): Promise { const orgMemberByEmail = await selectOrganizationMemberByOrgAndNormalizedEmail( tx, params.organizationId, params.normalizedEmail ); if (orgMemberByEmail) { if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== params.userId) { throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403); } await updateOrganizationMemberById(tx, orgMemberByEmail.id, { profileId: params.userId, roleId: params.roleId, email: params.normalizedEmail, verified: true }); return; } const orgMemberByProfile = await selectOrganizationMemberByOrgAndProfile(tx, params.organizationId, params.userId); if (orgMemberByProfile) { await updateOrganizationMemberById(tx, orgMemberByProfile.id, { roleId: params.roleId, email: params.normalizedEmail, verified: true }); return; } if (params.roleId === ROLE.STUDENT) { await assertStudentCapacityOrThrow(params.organizationId, 1); } await createOrganizationMember( { organizationId: params.organizationId, roleId: params.roleId, profileId: params.userId, email: params.normalizedEmail, verified: true }, tx ); } async function recordOrganizationInviteAudit( inviteId: string, organizationId: string, eventType: TNewOrganizationInviteAudit['eventType'], context: { actorProfileId?: string | null; targetEmail?: string | null; ipAddress?: string | null; userAgent?: string | null; metadata?: Record; } = {} ) { await createOrganizationInviteAudit({ inviteId, organizationId, eventType, actorProfileId: context.actorProfileId ?? null, targetEmail: context.targetEmail ?? null, ipAddress: context.ipAddress ?? null, userAgent: context.userAgent ?? null, metadata: context.metadata ?? {} }); } /** * Creates secure organization role invites from org settings. * Invites are role-aware and tokenized; legacy payload links are not used. */ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: number, invitedByProfileId: string) { if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) { throw new AppError('Invalid organization role for invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId'); } const organization = await getOrganizationById(orgId); if (!organization || !organization.siteName) { throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404); } const normalizedEmails = normalizeEmails(emails); const existingEmails = await checkEmailsExistInOrg(orgId, normalizedEmails); const emailsToInvite = normalizedEmails.filter((email) => !existingEmails.includes(email)); if (emailsToInvite.length === 0) { return []; } const members = await createOrganizationMembers( emailsToInvite.map((email) => ({ organizationId: orgId, email, roleId, verified: false })) ); const revokedInvites = await revokeActiveOrganizationInvitesByEmails(orgId, emailsToInvite, invitedByProfileId); await Promise.all( revokedInvites.map((invite) => recordOrganizationInviteAudit(invite.id, orgId, 'REVOKED', { actorProfileId: invitedByProfileId, targetEmail: invite.email, metadata: { reason: 'superseded_by_new_invite' } }) ) ); const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString(); const roleName = getRoleLabel(roleId); const inviterProfile = invitedByProfileId ? await getProfileById(invitedByProfileId) : null; const inviterName = inviterProfile?.fullname?.trim() || undefined; for (const email of emailsToInvite) { try { const token = generateToken(); const tokenHash = hashToken(token); const invite = await createOrganizationInvite({ organizationId: orgId, roleId, email, tokenHash, createdByProfileId: invitedByProfileId, expiresAt, isRevoked: false, metadata: { source: 'ORG_SETTINGS_TEAM_INVITE' } }); await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', { actorProfileId: invitedByProfileId, targetEmail: email, metadata: { roleId, roleName, expiresAt } }); const inviteLink = buildTeamInviteLink(token); try { await enqueueTransactionalEmail('inviteTeacher', { to: email, fields: { email, orgName: organization.name, orgSiteName: organization.siteName, roleName, inviterName, expiresAt: getExpiryLabel(expiresAt), inviteLink, branding: buildEmailBranding(organization) }, from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), subject: sanitizeEmailSubject(`You have been invited to join ${organization.name} on ClassroomIO`), idempotencyKey: `org-invite-teacher:${invite.id}` }); // Record EMAIL_SENT optimistically — the worker retries up to 5 times, // and a final failure flips the email_delivery row to `failed` for // operator follow-up. EMAIL_FAILED here only means the enqueue itself // failed (DB outbox write or transient validation error). await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', { actorProfileId: invitedByProfileId, targetEmail: email }); } catch (emailError) { const message = emailError instanceof Error ? emailError.message : 'Unknown email error'; await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', { actorProfileId: invitedByProfileId, targetEmail: email, metadata: { error: message } }); } } catch (error) { console.error(`Failed to create org invite for ${email}:`, error); } } return members; } /** * Server-only invite preview by token (API-key protected at route layer). */ export async function previewOrganizationInvite(token: string, context: TInviteRequestContext = {}) { const tokenHash = hashToken(token); const data = await getOrganizationInviteByTokenHash(tokenHash); if (!data) { throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404); } await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', { targetEmail: data.invite.email, ipAddress: context.ipAddress, userAgent: context.userAgent }); return { invite: { id: data.invite.id, roleId: data.invite.roleId, roleLabel: getRoleLabel(data.invite.roleId), email: data.invite.email, expiresAt: data.invite.expiresAt, status: getInviteStatus(data.invite) }, organization: data.organization }; } /** * Authenticated acceptance for organization role invites. */ export async function acceptOrganizationInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) { if (!user.id || !user.email) { throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401); } const normalizedEmail = user.email.toLowerCase().trim(); const tokenHash = hashToken(token); const result = await db.transaction(async (tx) => { const row = await selectOrganizationInviteWithOrgByTokenHash(tx, tokenHash); if (!row) { throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404); } const status = getInviteStatus(row.invite); if (status === 'REVOKED') { throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403); } if (status === 'EXPIRED') { throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400); } const inviteEmail = row.invite.email ?? ''; if (inviteEmail.toLowerCase().trim() !== normalizedEmail) { throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403); } if (status === 'ACCEPTED') { await markUserAndProfileEmailVerified(user.id, tx); return { organization: row.organization, roleId: row.invite.roleId, alreadyAccepted: true }; } await syncOrgMemberForOrgInvite(tx, { organizationId: row.invite.organizationId, roleId: row.invite.roleId, normalizedEmail, userId: user.id }); await markUserAndProfileEmailVerified(user.id, tx); const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id); if (!acceptedInvite) { throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409); } return { organization: row.organization, roleId: row.invite.roleId, alreadyAccepted: false }; }); const siteName = result.organization.siteName || ''; const invite = await getOrganizationInviteByTokenHash(tokenHash); if (invite) { await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', { actorProfileId: user.id, targetEmail: normalizedEmail, ipAddress: context.ipAddress, userAgent: context.userAgent, metadata: { alreadyAccepted: result.alreadyAccepted } }); } // Auto-enroll in courses if invite metadata contains courseIds (from audience import). // Run regardless of alreadyAccepted — enrollment is idempotent and the user may // have accepted the org invite previously without getting course access. if (invite) { const metadata = invite.invite.metadata; const courseIds = parseCourseIdsFromInviteMetadata(metadata); const cohortIds = parseCohortIdsFromInviteMetadata(metadata); if (courseIds.length > 0) { try { // Same as importAudienceMembers after course ids are known: resolve groups from course rows only. const courseGroupMappings = await getCourseGroupIds(courseIds); const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[]; await enrollUsersInCourseGroups( validGroupIds, [{ profileId: user.id, email: normalizedEmail }], invite.invite.roleId ); await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]); } catch (error) { console.error('acceptOrganizationInvite course enrollment error:', error); } } if (cohortIds.length > 0) { try { const existingCohortMemberships = await getExistingCohortMembers( cohortIds.map((cohortId) => ({ cohortId, profileId: user.id })) ); const cohortIdsToInsert = cohortIds.filter( (cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`) ); await Promise.all( cohortIdsToInsert.map((cohortId) => addCohortMember({ cohortId, roleId: invite.invite.roleId, profileId: user.id, email: normalizedEmail }) ) ); } catch (error) { console.error('acceptOrganizationInvite cohort enrollment error:', error); } } } const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org'; return { organizationId: result.organization.id, roleId: result.roleId, alreadyAccepted: result.alreadyAccepted, redirectTo }; } // ─── Link Invite ──────────────────────────────────────────────────────────── const LINK_INVITE_FAR_FUTURE_MS = 100 * 365 * 24 * 60 * 60 * 1000; /** * Returns the current org link invite if one already exists, otherwise creates one. * One link invite per org — subsequent calls return the same record. */ export async function getOrCreateLinkInvite(orgId: string, roleId: number, profileId: string) { if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) { throw new AppError('Invalid organization role for link invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId'); } const existing = await getOrgLinkInvite(orgId); if (existing) { const token = (existing.metadata as Record)?.token as string | undefined; return { id: existing.id, token: token ?? '', roleId: existing.roleId, isRevoked: existing.isRevoked }; } const token = generateToken(); const tokenHash = hashToken(token); const expiresAt = new Date(Date.now() + LINK_INVITE_FAR_FUTURE_MS).toISOString(); const invite = await createLinkInvite({ organizationId: orgId, roleId, tokenHash, createdByProfileId: profileId, expiresAt, metadata: { token, source: 'ORG_SETTINGS_LINK_INVITE' } }); await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', { actorProfileId: profileId, metadata: { roleId, type: 'LINK' } }); return { id: invite.id, token, roleId: invite.roleId, isRevoked: invite.isRevoked }; } /** * Returns current link invite for the org (for GET endpoint — returns null if none created yet). */ export async function fetchOrgLinkInvite(orgId: string) { const row = await getOrgLinkInvite(orgId); if (!row) return null; const token = (row.metadata as Record)?.token as string | undefined; return { id: row.id, token: token ?? '', roleId: row.roleId, isRevoked: row.isRevoked }; } /** * Enables or disables the org link invite (toggle isRevoked). */ export async function toggleOrgLinkInvite(orgId: string, isRevoked: boolean, profileId: string) { const updated = await setLinkInviteRevoked(orgId, isRevoked, profileId); if (!updated) { throw new AppError('Link invite not found', ErrorCodes.NOT_FOUND, 404); } const token = (updated.metadata as Record)?.token as string | undefined; return { id: updated.id, token: token ?? '', roleId: updated.roleId, isRevoked: updated.isRevoked }; } /** * Server-only preview for a link invite (API-key protected at route layer). */ export async function previewLinkInvite(token: string, context: TInviteRequestContext = {}) { const tokenHash = hashToken(token); const data = await getOrgLinkInviteWithOrg(db, tokenHash); if (!data) { throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404); } await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', { ipAddress: context.ipAddress, userAgent: context.userAgent }); return { invite: { id: data.invite.id, roleId: data.invite.roleId, roleLabel: getRoleLabel(data.invite.roleId), isRevoked: data.invite.isRevoked }, organization: data.organization }; } /** * Accepts a link invite — adds the authenticated user to the org. * The invite is NOT consumed (reusable). Anyone with the link can join. */ export async function acceptLinkInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) { if (!user.id || !user.email) { throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401); } const normalizedEmail = user.email.toLowerCase().trim(); const tokenHash = hashToken(token); const result = await db.transaction(async (tx) => { const row = await getOrgLinkInviteWithOrg(tx, tokenHash); if (!row) { throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404); } if (row.invite.isRevoked) { throw new AppError('This invite link has been disabled', ErrorCodes.UNAUTHORIZED, 403); } await syncOrgMemberForOrgInvite(tx, { organizationId: row.invite.organizationId, roleId: row.invite.roleId, normalizedEmail, userId: user.id }); await markUserAndProfileEmailVerified(user.id, tx); return { organization: row.organization, roleId: row.invite.roleId, inviteId: row.invite.id }; }); await recordOrganizationInviteAudit(result.inviteId, result.organization.id, 'ACCEPTED', { actorProfileId: user.id, targetEmail: normalizedEmail, ipAddress: context.ipAddress, userAgent: context.userAgent, metadata: { source: 'LINK_INVITE' } }); const siteName = result.organization.siteName || ''; const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org'; return { organizationId: result.organization.id, roleId: result.roleId, redirectTo }; } // ─── End Link Invite ───────────────────────────────────────────────────────── /** * Returns the pending org invite for the currently logged-in user, if one exists. * Used by the LMS dashboard to prompt the student to accept on first load. */ export async function getPendingOrgInviteForUser(orgId: string, email: string) { if (!orgId || !email) { return null; } const data = await getActivePendingOrgInviteForEmail(orgId, email); if (!data) { return null; } return { id: data.invite.id, email: data.invite.email, roleId: data.invite.roleId, roleLabel: getRoleLabel(data.invite.roleId), expiresAt: data.invite.expiresAt, organization: data.organization }; } /** * Accepts an organization invite by invite ID (for logged-in students with a pending invite). * Replicates the acceptance logic of acceptOrganizationInvite but looks up by invite ID instead of token. */ export async function acceptOrganizationInviteById( inviteId: string, user: TAuthUser, context: TInviteRequestContext = {} ) { if (!user.id || !user.email) { throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401); } const normalizedEmail = user.email.toLowerCase().trim(); const result = await db.transaction(async (tx) => { const row = await selectOrganizationInviteWithOrgByInviteId(tx, inviteId); if (!row) { throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404); } const status = getInviteStatus(row.invite); if (status === 'REVOKED') { throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403); } if (status === 'EXPIRED') { throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400); } const inviteEmailById = row.invite.email ?? ''; if (inviteEmailById.toLowerCase().trim() !== normalizedEmail) { throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403); } if (status === 'ACCEPTED') { await markUserAndProfileEmailVerified(user.id, tx); return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true }; } await syncOrgMemberForOrgInvite(tx, { organizationId: row.invite.organizationId, roleId: row.invite.roleId, normalizedEmail, userId: user.id }); await markUserAndProfileEmailVerified(user.id, tx); const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id); if (!acceptedInvite) { throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409); } return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: false }; }); await recordOrganizationInviteAudit(result.invite.id, result.invite.organizationId, 'ACCEPTED', { actorProfileId: user.id, targetEmail: normalizedEmail, ipAddress: context.ipAddress, userAgent: context.userAgent, metadata: { alreadyAccepted: result.alreadyAccepted } }); const metadata = result.invite.metadata; const courseIds = parseCourseIdsFromInviteMetadata(metadata); const cohortIds = parseCohortIdsFromInviteMetadata(metadata); if (courseIds.length > 0) { try { const courseGroupMappings = await getCourseGroupIds(courseIds); const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[]; await enrollUsersInCourseGroups( validGroupIds, [{ profileId: user.id, email: normalizedEmail }], result.invite.roleId ); await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]); } catch (error) { console.error('acceptOrganizationInviteById course enrollment error:', error); } } if (cohortIds.length > 0) { try { const existingCohortMemberships = await getExistingCohortMembers( cohortIds.map((cohortId) => ({ cohortId, profileId: user.id })) ); const cohortIdsToInsert = cohortIds.filter( (cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`) ); await Promise.all( cohortIdsToInsert.map((cohortId) => addCohortMember({ cohortId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail }) ) ); } catch (error) { console.error('acceptOrganizationInviteById cohort enrollment error:', error); } } const siteName = result.organization.siteName || ''; const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org'; return { organizationId: result.organization.id, roleId: result.roleId, alreadyAccepted: result.alreadyAccepted, redirectTo }; }