feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user