feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -0,0 +1,73 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseById, getOrganizationById, getProfileById, getCourseOrganizationId } from '@cio/db/queries';
import { resolveCertificateDesign, type CertificateRenderInput } from '@api/utils/certificate';
import type { TCertificateDownloadRequest } from '@cio/utils/validation/course';
/**
* Loads the design + render data for a given course/student pair so the API
* can hand it to `generateCertificatePdf` / `generateCertificatePng`.
*
* The client only sends `studentName` (+ optional studentId/issuedAt). Everything
* else comes from the database: title, description, org name + logo, design.
*/
export async function assembleCertificateRender(
courseId: string,
body: TCertificateDownloadRequest
): Promise<CertificateRenderInput> {
const [courseRow] = await getCourseById(courseId);
if (!courseRow) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const organizationId = await getCourseOrganizationId(courseId);
const organization = organizationId ? await getOrganizationById(organizationId) : null;
const design = resolveCertificateDesign(courseRow.certificate);
const issuedAtIso = body.issuedAt ?? new Date().toISOString();
const issuedAtDate = new Date(issuedAtIso);
const date = Number.isNaN(issuedAtDate.getTime())
? new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' })
: issuedAtDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' });
const certificateId = body.studentId
? formatCertificateId(design.idFormat, body.studentId, issuedAtDate)
: formatCertificateId(design.idFormat, fallbackSequence(issuedAtDate), issuedAtDate);
return {
design,
data: {
recipientName: body.studentName,
courseName: courseRow.title,
courseDescription: courseRow.description ?? '',
orgName: organization?.name ?? '',
orgLogoUrl: organization?.avatarUrl ?? undefined,
date,
certificateId
}
};
}
function formatCertificateId(format: string | undefined, seq: string, issuedAt: Date): string {
const year = issuedAt.getFullYear();
const month = String(issuedAt.getMonth() + 1).padStart(2, '0');
const tail = seq.replace(/-/g, '').slice(-4).toUpperCase() || '0001';
return (format ?? 'N° {seq}').replace('{seq}', tail).replace('{year}', String(year)).replace('{month}', month);
}
function fallbackSequence(issuedAt: Date): string {
return String(issuedAt.getTime()).slice(-6);
}
export async function assembleOwnerPreviewRender(
courseId: string,
userId: string,
body: TCertificateDownloadRequest
): Promise<CertificateRenderInput> {
const studentName = body.studentName.trim().length
? body.studentName
: (await getProfileById(userId))?.fullname || 'Preview Recipient';
return assembleCertificateRender(courseId, { ...body, studentName });
}
@@ -16,6 +16,7 @@ import {
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { ROLE } from '@cio/utils/constants';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export type CertificationBlocker = {
code:
@@ -240,6 +241,22 @@ export async function evaluateCourseCertification(
courseRow,
earnedAt
});
trackServerEvent({
eventType: SERVER_EVENTS.COURSE_COMPLETED,
userId: profileId,
courseId,
props: { path: 'standard', earnedAt }
});
if (courseRow.certificate?.isDownloadable) {
trackServerEvent({
eventType: SERVER_EVENTS.CERTIFICATE_ISSUED,
userId: profileId,
courseId,
props: { path: 'standard', earnedAt }
});
}
}
return { ...evaluation, certificateEarnedAt, isNewCompletion };
+108 -1
View File
@@ -6,11 +6,13 @@ import {
getCourseComplianceHistoryRows,
getCourseCurrentComplianceRows,
getLatestComplianceRecordsByProfiles,
getOrgComplianceLearnerRows,
getStudentCourseMembersForCompliance,
listComplianceRecordsReadyForExpiry,
listLatestComplianceRecordsForReminderScan,
updateCourseCertificateIssueStatusByRecordId,
updateCourseCompletionRecord
updateCourseCompletionRecord,
type OrgComplianceLearnerRow
} from '@cio/db/queries/course/compliance';
import { db } from '@cio/db/drizzle';
import { getCourseById } from '@cio/db/queries/course';
@@ -712,3 +714,108 @@ export async function runComplianceReminderScan() {
reminderEventCount
};
}
type OrgComplianceStatus = ComplianceStatus | 'no_record';
const STATUS_KEYS: OrgComplianceStatus[] = [
'compliant',
'expiring_soon',
'in_grace_period',
'non_compliant',
'waived',
'in_progress',
'not_started',
'no_record'
];
function emptyStatusCounts() {
const counts = {} as Record<OrgComplianceStatus, number>;
for (const status of STATUS_KEYS) counts[status] = 0;
return counts;
}
export type OrgComplianceOverview = {
summary: {
totalLearners: number;
totalCourses: number;
counts: Record<OrgComplianceStatus, number>;
};
courses: Array<{
courseId: string;
courseTitle: string;
learnerCount: number;
counts: Record<OrgComplianceStatus, number>;
}>;
learners: Array<{
groupMemberId: string;
profileId: string | null;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
courseId: string;
courseTitle: string;
status: OrgComplianceStatus;
cycleNumber: number | null;
dueDate: string | null;
completedAt: string | null;
validUntil: string | null;
}>;
};
/**
* Aggregates compliance state across every COMPLIANCE course in an
* organization. Returns status tallies, per-course breakdown, and the
* flat learner-by-course list for the dashboard table.
*/
export async function getOrgComplianceOverview(orgId: string): Promise<OrgComplianceOverview> {
const rows = await getOrgComplianceLearnerRows(orgId);
const summaryCounts = emptyStatusCounts();
const courseMap = new Map<
string,
{ title: string; learnerCount: number; counts: Record<OrgComplianceStatus, number> }
>();
const uniqueLearners = new Set<string>();
for (const row of rows) {
const status = row.status as OrgComplianceStatus;
summaryCounts[status] = (summaryCounts[status] ?? 0) + 1;
if (row.profileId) uniqueLearners.add(row.profileId);
let courseAcc = courseMap.get(row.courseId);
if (!courseAcc) {
courseAcc = { title: row.courseTitle, learnerCount: 0, counts: emptyStatusCounts() };
courseMap.set(row.courseId, courseAcc);
}
courseAcc.learnerCount += 1;
courseAcc.counts[status] = (courseAcc.counts[status] ?? 0) + 1;
}
return {
summary: {
totalLearners: uniqueLearners.size,
totalCourses: courseMap.size,
counts: summaryCounts
},
courses: Array.from(courseMap.entries()).map(([courseId, c]) => ({
courseId,
courseTitle: c.title,
learnerCount: c.learnerCount,
counts: c.counts
})),
learners: rows.map((row: OrgComplianceLearnerRow) => ({
groupMemberId: row.groupMemberId,
profileId: row.profileId,
fullname: row.profile?.fullname ?? null,
email: row.email,
avatarUrl: row.profile?.avatarUrl ?? null,
courseId: row.courseId,
courseTitle: row.courseTitle,
status: row.status as OrgComplianceStatus,
cycleNumber: row.cycleNumber,
dueDate: row.dueDate,
completedAt: row.completedAt,
validUntil: row.validUntil
}))
};
}
+41 -45
View File
@@ -29,11 +29,13 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -350,17 +352,18 @@ async function sendStudentJoinEmails(input: {
}) {
try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
orgName: input.orgName,
courseName: input.courseName,
loginUrl
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
});
} catch (error) {
console.error('Failed to send student welcome email:', error);
console.error('Failed to enqueue student welcome email:', error);
}
try {
@@ -372,26 +375,22 @@ async function sendStudentJoinEmails(input: {
const studentProfile = await getProfileById(input.studentId);
const studentName = studentProfile?.fullname || input.studentEmail;
await Promise.all(
teachers
.map((teacher) => teacher.email)
.filter((email): email is string => !!email)
.map((teacherEmail) =>
sendEmail('teacherStudentJoined', {
to: teacherEmail,
fields: {
courseName: input.courseName,
studentName,
studentEmail: input.studentEmail
},
from: buildEmailFromName('ClassroomIO')
}).catch((error) => {
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, error);
})
)
);
const teacherEmails = teachers.map((teacher) => teacher.email).filter((email): email is string => !!email);
if (teacherEmails.length === 0) return;
await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmails,
fields: {
courseName: input.courseName,
studentName,
studentEmail: input.studentEmail
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
});
} catch (error) {
console.error('Failed to send teacher join notification emails:', error);
console.error('Failed to enqueue teacher join notification emails:', error);
}
}
@@ -434,7 +433,7 @@ async function createEmailInviteAndSend(input: {
}
try {
const responses = await sendEmail('studentCourseInvite', {
await enqueueTransactionalEmail('studentCourseInvite', {
to: input.recipientEmail,
fields: {
orgName: input.orgName,
@@ -442,29 +441,11 @@ async function createEmailInviteAndSend(input: {
inviteLink: createdInvite.inviteLink,
expiresAt: getExpiryLabel(createdInvite.expiresAt)
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-invite-email:${createdInvite.id}`
});
const allSuccessful = responses.every((response) => response.success);
if (!allSuccessful) {
const errorMessage = responses
.filter((response) => !response.success)
.map((response) => response.error || 'Unknown email error')
.join('; ');
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_FAILED', {
actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail,
metadata: { error: errorMessage }
});
return {
invite: createdInvite,
sent: false,
error: errorMessage
};
}
// Optimistic EMAIL_SENT — see comment in services/organization/invite.ts.
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', {
actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail
@@ -683,6 +664,14 @@ export async function enrollInCourse(
studentEmail: normalizedEmail
});
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
orgId: org.id,
userId: user.id,
courseId,
props: { path: 'free-enrollment' }
});
return {
success: true,
alreadyJoined: false,
@@ -1036,6 +1025,13 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
studentId: user.id,
studentEmail: normalizedEmail
});
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
userId: user.id,
courseId: result.courseId,
props: { path: 'invite' }
});
}
return {
+17 -11
View File
@@ -2,7 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export interface PaymentRequestData {
courseId: string;
@@ -40,25 +42,23 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const teacherEmail = teacherResult[0].email;
// Send email to teacher about student buy request
try {
await sendEmail('teacherStudentBuyRequest', {
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail,
fields: {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname
},
from: buildEmailFromName('ClassroomIO')
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to send teacher buy request email:', emailError);
// Don't fail the request if email fails
console.error('Failed to enqueue teacher buy request email:', emailError);
}
// Send email to student with payment instructions
try {
await sendEmail('studentProvePayment', {
await enqueueTransactionalEmail('studentProvePayment', {
to: data.studentEmail,
fields: {
courseName,
@@ -67,13 +67,19 @@ export async function createPaymentRequest(data: PaymentRequestData) {
orgName
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail
replyTo: teacherEmail,
idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to send student payment proof email:', emailError);
// Don't fail the request if email fails
console.error('Failed to enqueue student payment proof email:', emailError);
}
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_STARTED,
courseId: data.courseId,
props: { path: 'payment-request', orgName }
});
return {
success: true,
courseName,
+22 -24
View File
@@ -14,7 +14,8 @@ import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
/**
@@ -85,45 +86,44 @@ export async function addMember(
studentName = data.name || null;
}
// Send welcome email to student
if (studentEmail) {
try {
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: studentEmail,
fields: {
orgName,
courseName,
loginUrl
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`
});
} catch (emailError) {
console.error('Failed to send student welcome email:', emailError);
console.error('Failed to enqueue student welcome email:', emailError);
}
}
// Get all teachers (ADMIN or TUTOR) in the course
const teachersResult = await getCourseTeachers({ courseId });
// Send notification to all teachers
if (teachersResult.length > 0 && studentEmail && studentName) {
const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null);
await Promise.all(
teacherEmails.map((teacherEmail) =>
sendEmail('teacherStudentJoined', {
to: teacherEmail,
if (teacherEmails.length > 0) {
try {
await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmails,
fields: {
courseName,
studentName,
studentEmail
},
from: buildEmailFromName('ClassroomIO')
}).catch((emailError) => {
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, emailError);
})
)
);
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
});
} catch (emailError) {
console.error('Failed to enqueue teacher notification email:', emailError);
}
}
}
}
} catch (emailError) {
@@ -193,19 +193,17 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
const baseUrl = getDashboardBaseUrl(orgSiteName);
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
// Send welcome emails to teachers
const teacherEmailPromises: Promise<unknown>[] = [];
addedMembers.forEach((member, index) => {
const memberData = members[index];
// Only send emails to teachers (TUTOR or ADMIN role) who have email
const roleId = memberData.roleId;
const email = memberData.email;
const name = memberData.name;
if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) {
teacherEmailPromises.push(
sendEmail('teacherCourseWelcome', {
enqueueTransactionalEmail('teacherCourseWelcome', {
to: email,
fields: {
name,
@@ -213,9 +211,10 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
courseName,
inviteLink
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
}).catch((emailError) => {
console.error(`Failed to send welcome email to ${email}:`, emailError);
console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
return [];
})
);
@@ -226,8 +225,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
try {
await Promise.all(teacherEmailPromises);
} catch (emailError) {
// Log but don't fail the request if email fails
console.error('Failed to send welcome emails:', emailError);
console.error('Failed to enqueue welcome emails:', emailError);
}
}
+72 -3
View File
@@ -1,4 +1,12 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
generateTranscriptVttPresignedUrl,
generateVideoDownloadPresignedUrls,
TRANSCRIPT_VTT_PRESIGN_SECONDS
} from '@api/utils/s3';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { getAssetsByIds } from '@cio/db/queries/assets';
import {
getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery,
@@ -8,7 +16,6 @@ import {
} from '@cio/db/queries/course';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
/**
* Anonymous-safe: fetch the full tree for a published public course by slug.
@@ -24,7 +31,7 @@ export async function getPublicCourseTreeService(courseSlug: string): Promise<Pu
}
export type PublicItemResponse =
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
| (Omit<Extract<PublicCourseItemContent, { kind: 'lesson' }>, 'courseOrganizationId'> & { kind: 'lesson' })
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
kind: 'exercise';
questions: PublicExercisePayload;
@@ -74,7 +81,69 @@ export async function getPublicCourseItemService(
if (!baseItem) return null;
if (baseItem.kind === 'lesson') {
return baseItem;
const { courseOrganizationId, ...lesson } = baseItem;
let video = lesson.video;
if (video?.type === 'upload') {
let storageKey: string | undefined;
if (video.assetId) {
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
storageKey = assets[0]?.storageKey ?? undefined;
}
if (!storageKey && video.key) {
storageKey = video.key;
}
if (storageKey) {
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
const signedLink = urls[storageKey];
if (signedLink) {
const { key: _omitKey, ...rest } = video;
video = { ...rest, link: signedLink };
}
}
}
if (video?.assetId && courseOrganizationId) {
const transcriptRow = await getMediaTranscriptByAsset(video.assetId, courseOrganizationId);
let transcript: {
vttUrl: string;
vttUrlExpiresAt: string;
language: string;
} | null = null;
if (transcriptRow) {
const vttUrl = await generateTranscriptVttPresignedUrl(transcriptRow.vttStorageKey, transcriptRow.vttBucket);
transcript = {
vttUrl,
vttUrlExpiresAt: new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(),
language: transcriptRow.language
};
}
video = { ...video, transcript };
}
if (video?.key !== undefined) {
const { key: _omitS3Key, ...withoutKey } = video;
video = withoutKey;
}
return { ...lesson, video };
}
if (!baseItem.isUnlocked) {
return {
...baseItem,
questions: {
unsectioned: [],
sections: []
}
};
}
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);