Files
classroomio/apps/api/src/services/organization/invite.ts
T

753 lines
24 KiB
TypeScript
Raw Normal View History

2026-03-09 11:18:22 +01:00
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
checkEmailsExistInOrg,
claimPendingOrganizationInvite,
2026-03-09 11:18:22 +01:00
createOrganizationInvite,
createOrganizationInviteAudit,
createOrganizationMember,
createOrganizationMembers,
createLinkInvite,
getActivePendingOrgInviteForEmail,
2026-03-09 11:18:22 +01:00
getOrganizationById,
getOrganizationInviteByTokenHash,
getOrgLinkInvite,
getOrgLinkInviteWithOrg,
revokeActiveOrganizationInvitesByEmails,
selectOrganizationInviteWithOrgByInviteId,
selectOrganizationInviteWithOrgByTokenHash,
selectOrganizationMemberByOrgAndNormalizedEmail,
selectOrganizationMemberByOrgAndProfile,
setLinkInviteRevoked,
updateOrganizationMemberById
2026-03-09 11:18:22 +01:00
} 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';
2026-04-27 11:40:38 +01:00
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
2026-03-09 11:18:22 +01:00
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)}`;
2026-03-09 11:18:22 +01:00
}
function getRoleLabel(roleId: number): string {
if (roleId === ROLE.ADMIN) return 'Admin';
if (roleId === ROLE.TUTOR) return 'Tutor';
if (roleId === ROLE.STUDENT) return 'Student';
2026-03-09 11:18:22 +01:00
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<void> {
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
);
}
2026-03-09 11:18:22 +01:00
async function recordOrganizationInviteAudit(
inviteId: string,
organizationId: string,
eventType: TNewOrganizationInviteAudit['eventType'],
2026-03-09 11:18:22 +01:00
context: {
actorProfileId?: string | null;
targetEmail?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
metadata?: Record<string, unknown>;
} = {}
) {
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;
2026-03-09 11:18:22 +01:00
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);
2026-03-09 11:18:22 +01:00
try {
await enqueueTransactionalEmail('inviteTeacher', {
2026-03-09 11:18:22 +01:00
to: email,
fields: {
email,
orgName: organization.name,
orgSiteName: organization.siteName,
roleName,
inviterName,
2026-03-09 11:18:22 +01:00
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}`
2026-03-09 11:18:22 +01:00
});
// 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).
2026-03-09 11:18:22 +01:00
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);
2026-03-09 11:18:22 +01:00
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) {
2026-03-09 11:18:22 +01:00
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
if (status === 'ACCEPTED') {
await markUserAndProfileEmailVerified(user.id, tx);
2026-03-09 11:18:22 +01:00
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
});
2026-03-09 11:18:22 +01:00
await markUserAndProfileEmailVerified(user.id, tx);
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
2026-03-09 11:18:22 +01:00
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);
2026-03-09 11:18:22 +01:00
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
);
2026-04-27 11:40:38 +01:00
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';
2026-03-09 11:18:22 +01:00
return {
organizationId: result.organization.id,
roleId: result.roleId,
alreadyAccepted: result.alreadyAccepted,
redirectTo
2026-03-09 11:18:22 +01:00
};
}
// ─── 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<string, unknown>)?.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<string, unknown>)?.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<string, unknown>)?.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
);
2026-04-27 11:40:38 +01:00
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
};
}