feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
+272 -28
View File
@@ -6,11 +6,13 @@ import type {
} from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import {
createOrganizationInvite,
createOrganizationInviteAudits,
createOrganizationInvites,
createOrganizationMembers,
getOrganizationAudienceMember,
getLatestOrganizationInviteRowByOrgAndEmail,
getOrgMembersByProfileIds,
getOrganizationById,
@@ -20,6 +22,7 @@ import {
revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization';
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
@@ -113,16 +116,33 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
let courseNames: string[] = [];
if (data.allCourses) {
const courses = await getOrgCourses({ orgId });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
courseIds = courses.items.map((c) => c.id);
courseNames = courses.items.map((c) => c.title).filter(Boolean);
} else if (data.courseIds && data.courseIds.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
courseIds = courses.items.map((c) => c.id);
courseNames = courses.items.map((c) => c.title).filter(Boolean);
}
return { courseIds, courseNames };
}
async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let programIds: string[] = [];
let programNames: string[] = [];
if (data.allPrograms) {
const programs = await getProgramsByOrg(orgId);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
} else if (data.programIds && data.programIds.length > 0) {
const programs = await getProgramsByOrg(orgId, data.programIds);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
}
return { programIds, programNames };
}
async function enrollAudienceStudentProfilesInCourses(
orgId: string,
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
@@ -202,16 +222,98 @@ async function enrollAudienceStudentProfilesInCourses(
};
}
async function enrollAudienceStudentProfilesInPrograms(
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
orgId: string,
profileIds: string[],
programIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (programIds.length === 0 || profileIds.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const uniqueProfileIds = [...new Set(profileIds)];
const orgMembers = await getOrgMembersByProfileIds(orgId, uniqueProfileIds);
const studentMembers = orgMembers.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
const validProfileIds = new Set(studentMembers.map((member) => member.profileId!));
const profileEmailMap = new Map(
studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? ''])
);
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
const validProgramIds = programs.map((program) => program.id);
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
const existingSet = await getExistingProgramMembers(pairs);
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.programId}:${pair.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await Promise.all(
toInsert.map((pair) =>
addProgramMember({
programId: pair.programId,
roleId: ROLE.STUDENT,
profileId: pair.profileId,
email: profileEmailMap.get(pair.profileId) || undefined
})
)
);
}
let emailsSent = 0;
if (shouldSendEmail && toInsert.length > 0) {
await Promise.all(
toInsert
.filter((pair) => profileEmailMap.get(pair.profileId))
.map(async (pair) => {
const email = profileEmailMap.get(pair.profileId)!;
try {
await sendEmail('studentProgramWelcome', {
to: email,
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
}
})
);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
async function createStudentOrgInvitesAndSendEmails(input: {
orgId: string;
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
emails: string[];
courseIds: string[];
courseNamesLabel: string | undefined;
programIds: string[];
accessNamesLabel: string | undefined;
invitedByProfileId: string;
shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, courseNamesLabel, invitedByProfileId, shouldSendEmail } = input;
const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
input;
if (emails.length === 0) {
return { created: 0, emailsSent: 0, emailsFailed: 0 };
@@ -236,7 +338,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
isRevoked: false,
metadata: {
source: 'AUDIENCE_IMPORT',
courseIds: courseIds.length > 0 ? courseIds : undefined
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
}
}
};
@@ -261,7 +364,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds
courseIds,
programIds
}
};
})
@@ -283,7 +387,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
@@ -388,15 +492,24 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
}
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const assignResult = await enrollAudienceStudentProfilesInCourses(
const assignedToCourses = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
existingStudentProfileIds,
courseIds,
data.sendEmail
);
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
existingStudentProfileIds,
programIds,
data.sendEmail
);
let imported = 0;
let importEmailsSent = 0;
@@ -425,12 +538,26 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
}
}
if (programIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
profiles.map((profile) => profile.id),
programIds,
false
);
}
}
const inviteOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: newEmails,
courseIds,
courseNamesLabel,
programIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
@@ -447,7 +574,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
organization,
emails: pendingStudentEmails,
courseIds,
courseNamesLabel,
programIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
@@ -457,11 +585,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
return {
imported,
assigned: assignResult.assigned,
alreadyEnrolledInCourses: assignResult.alreadyEnrolled,
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed
};
}
@@ -483,22 +612,32 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
const emailToUse = member.email.toLowerCase().trim();
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined;
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
let courseIds: string[] = [];
let courseNames: string[] = [];
if (courseIdsFromMetadata.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
courseIds = courses.items.map((c) => c.id);
courseNames = courses.items.map((c) => c.title).filter(Boolean);
}
let programIds: string[] = [];
let programNames: string[] = [];
if (programIdsFromMetadata.length > 0) {
const programs = await getProgramsByOrg(orgId, programIdsFromMetadata);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
}
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const token = generateToken();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const invite = await createOrganizationInvite({
organizationId: orgId,
@@ -510,7 +649,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
isRevoked: false,
metadata: {
source: 'AUDIENCE_RESEND',
courseIds: courseIds.length > 0 ? courseIds : undefined
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
}
});
@@ -527,7 +667,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds
courseIds,
programIds
}
}
]);
@@ -542,7 +683,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
@@ -633,10 +774,113 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
const courseIds = data.courseIds ?? [];
const programIds = data.programIds ?? [];
let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
if (courseIds.length > 0) {
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
}
assignedToCourses = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
data.profileIds,
courseIds,
data.sendEmail
);
}
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail);
if (programIds.length > 0) {
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
throw new AppError('No valid programs found', ErrorCodes.VALIDATION_ERROR, 400, 'programIds');
}
assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
data.profileIds,
programIds,
data.sendEmail
);
}
return {
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent
};
}
export async function updatePendingAudienceMemberEmail(
orgId: string,
memberId: number,
data: { email: string; sendEmail: boolean },
invitedByProfileId: string
) {
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const existingMember = await getOrganizationAudienceMember(orgId, memberId);
if (!existingMember) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
if (existingMember.profileId) {
throw new AppError(
'Only pending audience members can be updated through this endpoint',
ErrorCodes.VALIDATION_ERROR,
400,
'memberId'
);
}
const normalizedEmail = data.email.toLowerCase().trim();
const currentEmail = existingMember.email.toLowerCase().trim();
if (!normalizedEmail) {
throw new AppError('Email is required', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
if (normalizedEmail !== currentEmail) {
const matchingMembers = await getOrganizationMembersByNormalizedEmails(orgId, [normalizedEmail]);
if (matchingMembers.length > 0) {
throw new AppError('An audience member with this email already exists', ErrorCodes.CONFLICT, 409, 'email');
}
}
await revokeActiveOrganizationInvitesByEmails(orgId, [currentEmail, normalizedEmail], invitedByProfileId);
const updatedMember = await updateOrganizationAudienceMember(orgId, memberId, {
email: normalizedEmail,
verified: false
});
if (!updatedMember) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail);
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: [normalizedEmail],
courseIds: meta.courseIds?.filter(Boolean) ?? [],
programIds: meta.programIds?.filter(Boolean) ?? [],
accessNamesLabel: undefined,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
return updatedMember;
}
@@ -16,6 +16,7 @@ import {
MCP_TOOL_CREDIT_COST,
type TMcpToolName
} from '@cio/utils/plans';
import { PLAN } from '@cio/utils/plans/constants';
export type OrganizationAutomationUsageSummary = {
type: TOrganizationApiKeyType;
@@ -56,11 +57,16 @@ export async function assertOrganizationAutomationKeyCreationAllowed(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<void> {
const planName = await getOrganizationPlanName(organizationId);
if (type === AUTOMATION_TYPE.API && planName === PLAN.BASIC) {
throw new AppError('Public API keys require an Early Adopter or Enterprise plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
if (type !== AUTOMATION_TYPE.MCP) {
return;
}
const planName = await getOrganizationPlanName(organizationId);
const limits = getMcpAutomationLimits(planName);
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
+235 -1
View File
@@ -7,18 +7,20 @@ import {
createOrganizationInvite,
createOrganizationInviteAudit,
createOrganizationMembers,
getActivePendingOrgInviteForEmail,
getOrganizationById,
getOrganizationInviteByTokenHash,
revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
import { db } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email';
@@ -426,6 +428,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
if (invite) {
const metadata = invite.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
@@ -441,6 +444,30 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
console.error('acceptOrganizationInvite course enrollment error:', error);
}
}
if (programIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({
programId,
roleId: invite.invite.roleId,
profileId: user.id,
email: normalizedEmail
})
)
);
} catch (error) {
console.error('acceptOrganizationInvite program enrollment error:', error);
}
}
}
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
@@ -452,3 +479,210 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
redirectTo
};
}
/**
* 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 tx
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.id, inviteId))
.limit(1);
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);
}
if (row.invite.email.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 };
}
const [orgMemberByEmail] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
if (orgMemberByEmail) {
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
}
await tx
.update(schema.organizationmember)
.set({ profileId: user.id, roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
} else {
const [orgMemberByProfile] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.profileId, user.id)
)
)
.limit(1);
if (orgMemberByProfile) {
await tx
.update(schema.organizationmember)
.set({ roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
} else {
await tx.insert(schema.organizationmember).values({
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
profileId: user.id,
email: normalizedEmail,
verified: true
});
}
}
await markUserAndProfileEmailVerified(user.id, tx);
const [acceptedInvite] = await tx
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId: user.id,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, row.invite.id),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({ id: schema.organizationInvite.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 programIds = parseProgramIdsFromInviteMetadata(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
);
} catch (error) {
console.error('acceptOrganizationInviteById course enrollment error:', error);
}
}
if (programIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
)
);
} catch (error) {
console.error('acceptOrganizationInviteById program 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
};
}