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

887 lines
29 KiB
TypeScript

import { AppError, ErrorCodes } from '@api/utils/errors';
import type {
TAssignAudienceCourses,
TAudienceInviteByEmail,
TImportAudienceMembers
} 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,
getOrganizationMembersByNormalizedEmails,
getStudentOrganizationMemberByOrgAndEmail,
hasActiveOrganizationInviteForEmail,
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';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
/** Parallel outbound invite emails; avoids sequential SMTP/API latency per recipient. */
const EMAIL_SEND_CONCURRENCY = 5;
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
if (items.length === 0) {
return [];
}
const results: R[] = new Array(items.length);
let next = 0;
const workerCount = Math.min(concurrency, items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const i = next++;
if (i >= items.length) break;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
function generateToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
function buildInviteLink(token: string): string {
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
}
function getExpiryLabel(expiresAtIso: string): string {
return new Date(expiresAtIso).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC'
});
}
function parseRecipientCsv(recipientCsv: string): string[] {
return recipientCsv
.split(/[\n,;\t ]+/g)
.map((value) => value.trim())
.filter(Boolean);
}
function getNormalizedRecipients(recipientCsv: string): {
valid: string[];
invalid: string[];
duplicates: string[];
} {
const raw = parseRecipientCsv(recipientCsv);
const seen = new Set<string>();
const valid: string[] = [];
const invalid: string[] = [];
const duplicates: string[] = [];
for (const recipient of raw) {
const normalized = recipient.toLowerCase().trim();
if (!normalized) continue;
if (!EMAIL_REGEX.test(normalized)) {
invalid.push(normalized);
continue;
}
if (seen.has(normalized)) {
duplicates.push(normalized);
continue;
}
seen.add(normalized);
valid.push(normalized);
}
return { valid, invalid, duplicates };
}
async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let courseIds: string[] = [];
let courseNames: string[] = [];
if (data.allCourses) {
const courses = await getOrgCourses({ orgId });
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.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>>>,
profileIds: string[],
courseIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (courseIds.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((m) => m.profileId && m.roleId === ROLE.STUDENT);
const validProfileIds = new Set(studentMembers.map((m) => m.profileId!));
const profileEmailMap = new Map(studentMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
if (courseGroups.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await addGroupMembers(
toInsert.map((p) => ({
groupId: p.groupId,
roleId: ROLE.STUDENT,
profileId: p.profileId,
email: profileEmailMap.get(p.profileId) || undefined
}))
);
}
let emailsSent = 0;
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
if (shouldSendEmail && toInsert.length > 0) {
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError);
}
});
await Promise.all(emailPromises);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
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[];
programIds: string[];
accessNamesLabel: string | undefined;
invitedByProfileId: string;
shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
input;
if (emails.length === 0) {
return { created: 0, emailsSent: 0, emailsFailed: 0 };
}
await revokeActiveOrganizationInvitesByEmails(orgId, emails, invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const inviteInputs = emails.map((email) => {
const token = generateToken();
return {
email,
token,
row: {
organizationId: orgId,
roleId: ROLE.STUDENT,
email,
tokenHash: hashToken(token),
createdByProfileId: invitedByProfileId,
expiresAt,
isRevoked: false,
metadata: {
source: 'AUDIENCE_IMPORT',
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
}
}
};
});
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
await createOrganizationInviteAudits(
emails.map((email) => {
const invite = inviteByEmail.get(email)!;
return {
inviteId: invite.id,
organizationId: orgId,
eventType: 'CREATED' as const,
actorProfileId: invitedByProfileId,
targetEmail: email,
ipAddress: null,
userAgent: null,
metadata: {
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds,
programIds
}
};
})
);
let emailsSent = 0;
let emailsFailed = 0;
if (shouldSendEmail) {
const emailOutcomes = await mapWithConcurrency(emails, EMAIL_SEND_CONCURRENCY, async (email) => {
const invite = inviteByEmail.get(email)!;
const token = tokenByEmail.get(email)!;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
to: email,
fields: {
email,
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
const allSuccessful = responses.every((r) => r.success);
if (allSuccessful) {
return {
inviteId: invite.id,
email,
success: true as const,
error: undefined as string | undefined
};
}
return {
inviteId: invite.id,
email,
success: false as const,
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
};
} catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
return { inviteId: invite.id, email, success: false as const, error: message };
}
});
emailsSent = emailOutcomes.filter((o) => o.success).length;
emailsFailed = emailOutcomes.length - emailsSent;
await createOrganizationInviteAudits(
emailOutcomes.map((o) => ({
inviteId: o.inviteId,
organizationId: orgId,
eventType: o.success ? ('EMAIL_SENT' as const) : ('EMAIL_FAILED' as const),
actorProfileId: invitedByProfileId,
targetEmail: o.email,
ipAddress: null,
userAgent: null,
metadata: o.success ? {} : { error: o.error ?? 'Unknown' }
}))
);
}
return { created: emails.length, emailsSent, emailsFailed };
}
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const recipients = getNormalizedRecipients(data.recipientCsv);
if (recipients.invalid.length > 0) {
throw new AppError(
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
if (recipients.valid.length === 0) {
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
}
const memberRows = await getOrganizationMembersByNormalizedEmails(orgId, recipients.valid);
const memberByEmail = new Map(memberRows.map((m) => [m.normalizedEmail, m]));
const newEmails: string[] = [];
const existingStudentProfileIds: string[] = [];
const pendingStudentEmails: string[] = [];
const teamEmails: string[] = [];
for (const email of recipients.valid) {
const m = memberByEmail.get(email);
if (!m) {
newEmails.push(email);
continue;
}
if (m.roleId !== ROLE.STUDENT) {
teamEmails.push(email);
continue;
}
if (!m.profileId) {
pendingStudentEmails.push(email);
continue;
}
existingStudentProfileIds.push(m.profileId);
}
if (teamEmails.length > 0) {
throw new AppError(
`These emails belong to organization staff, not students: ${teamEmails.slice(0, 8).join(', ')}${teamEmails.length > 8 ? '…' : ''}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
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;
let importEmailsFailed = 0;
if (newEmails.length > 0) {
await createOrganizationMembers(
newEmails.map((email) => ({
organizationId: orgId,
email,
roleId: ROLE.STUDENT,
verified: false
}))
);
if (courseIds.length > 0) {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
if (validGroupIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
}
}
}
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,
programIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
imported = newEmails.length;
importEmailsSent = inviteOutcome.emailsSent;
importEmailsFailed = inviteOutcome.emailsFailed;
}
let pendingEmailsSent = 0;
let pendingEmailsFailed = 0;
if (pendingStudentEmails.length > 0) {
const pendingOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: pendingStudentEmails,
courseIds,
programIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
pendingEmailsSent = pendingOutcome.emailsSent;
pendingEmailsFailed = pendingOutcome.emailsFailed;
}
return {
imported,
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed
};
}
export async function resendAudienceInvite(orgId: string, data: TAudienceInviteByEmail, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
if (!member) {
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
}
if (member.profileId) {
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
const emailToUse = member.email.toLowerCase().trim();
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
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.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 accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const invite = await createOrganizationInvite({
organizationId: orgId,
roleId: ROLE.STUDENT,
email: emailToUse,
tokenHash: hashToken(token),
createdByProfileId: invitedByProfileId,
expiresAt,
isRevoked: false,
metadata: {
source: 'AUDIENCE_RESEND',
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
}
});
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: 'CREATED',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: {
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds,
programIds
}
}
]);
let emailSent = false;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
to: emailToUse,
fields: {
email: emailToUse,
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
const allSuccessful = responses.every((r) => r.success);
emailSent = allSuccessful;
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: allSuccessful
? {}
: {
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
}
}
]);
} catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: 'EMAIL_FAILED',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: { error: message }
}
]);
}
return { emailSent };
}
export async function revokeAudiencePendingInvite(
orgId: string,
data: TAudienceInviteByEmail,
revokedByProfileId: string
) {
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
if (!member) {
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
}
if (member.profileId) {
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
const emailToUse = member.email.toLowerCase().trim();
const hasActive = await hasActiveOrganizationInviteForEmail(orgId, emailToUse);
if (!hasActive) {
throw new AppError('No active pending invite to revoke', ErrorCodes.VALIDATION_ERROR, 400);
}
const revoked = await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], revokedByProfileId);
if (revoked.length === 0) {
throw new AppError('No invite was revoked', ErrorCodes.VALIDATION_ERROR, 400);
}
await createOrganizationInviteAudits(
revoked.map((inv) => ({
inviteId: inv.id,
organizationId: orgId,
eventType: 'REVOKED' as const,
actorProfileId: revokedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: { reason: 'audience_revoked_by_admin' }
}))
);
return { revoked: true };
}
export async function assignAudienceToCourses(orgId: string, data: TAssignAudienceCourses) {
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
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
);
}
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;
}