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
+56 -104
View File
@@ -1,114 +1,66 @@
import type { TCertificateDownload } from '@cio/utils/validation/course';
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
import {
DEFAULT_CERTIFICATE_DESIGN,
renderCertificate,
resolveTemplateId,
type CertificateDesign,
type CertificateRenderData
} from '@cio/certificates';
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
error?: string;
import { getCloudflarePdfBuffer, getCloudflarePngBuffer } from '@api/utils/cloudflare';
export interface CertificateRenderInput {
design: CertificateDesign;
data: CertificateRenderData;
}
export const generateCertificate = async (data: TCertificateDownload) => {
const { html, styles } = generateCertificateHtml(data);
return getCloudflarePdfBuffer(html, styles);
};
/**
* Coerces a stored `course.certificate` JSONB blob (legacy or current) into a
* complete `CertificateDesign` suitable for `renderCertificate`.
*/
export function resolveCertificateDesign(stored: unknown): CertificateDesign {
const blob = stored && typeof stored === 'object' ? (stored as Record<string, unknown>) : {};
const legacyTheme = typeof blob.theme === 'string' ? (blob.theme as string) : undefined;
const storedDesign =
blob.design && typeof blob.design === 'object' ? (blob.design as Partial<CertificateDesign>) : undefined;
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const templateId = resolveTemplateId(storedDesign?.templateId ?? legacyTheme);
const html = `
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
const accentColor =
storedDesign?.accentColor && /^#[0-9a-fA-F]{6}$/.test(storedDesign.accentColor)
? storedDesign.accentColor
: DEFAULT_CERTIFICATE_DESIGN.accentColor;
const styles = `
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
const storedSignatories = Array.isArray(storedDesign?.signatories) ? storedDesign?.signatories : undefined;
const signatories: CertificateDesign['signatories'] = [
{
name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
},
{
name: storedSignatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
`;
const pdf = {
html: html,
styles: styles
];
return {
templateId,
accentColor,
subtitle: storedDesign?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
descriptionOverride: storedDesign?.descriptionOverride,
signatories,
idFormat: storedDesign?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
};
}
return pdf;
export async function generateCertificatePdf(input: CertificateRenderInput) {
const { html, styles } = renderCertificate(input.design, input.data);
return getCloudflarePdfBuffer(html, styles);
}
export async function generateCertificatePng(input: CertificateRenderInput) {
const { html, styles } = renderCertificate(input.design, input.data);
return getCloudflarePngBuffer(html, styles);
}
+34
View File
@@ -26,3 +26,37 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
}
};
/**
* Renders HTML to a PNG via Cloudflare Browser Rendering's `/screenshot` endpoint.
* The viewport is fixed to 1100x780 to match the certificate canvas.
*/
export const getCloudflarePngBuffer = async (html: string, styles?: string) => {
console.log('Generating PNG with Cloudflare API...');
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/screenshot`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
},
body: JSON.stringify({
html,
addStyleTag: styles ? [{ content: styles }] : undefined,
viewport: { width: 1100, height: 780, deviceScaleFactor: 2 },
screenshotOptions: { type: 'png', omitBackground: false, fullPage: false }
})
}
);
console.log('PNG response status:', response.status);
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('Error generating PNG:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to generate PNG');
}
};
+5 -3
View File
@@ -5,7 +5,8 @@ import {
} from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } 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';
export function calcCourseProgressPercent(params: {
lessonsCompleted: number;
@@ -69,7 +70,7 @@ export function scheduleCertificationCompletionWork(params: {
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await sendEmail('studentCourseCompletion', {
await enqueueTransactionalEmail('studentCourseCompletion', {
to: studentEmail,
fields: {
orgName: courseRow.orgName,
@@ -78,7 +79,8 @@ export function scheduleCertificationCompletionWork(params: {
certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-completion:${groupMemberId}`
});
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
@@ -132,3 +132,15 @@ export const agentUploadKeyGenerator = (c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:agent:upload`;
};
/**
* Redis keys for engagement analytics read endpoints (landing-stats, funnel,
* country breakdown, time-to-enrollment). Value: JSON.
*/
export function dashAnalyticsKey(route: string, orgId: string, days: number, extra?: string): string {
const suffix = extra ? `:${extra}` : '';
return `dash:analytics:${route}:${orgId}:${days}${suffix}`;
}
/** TTL for engagement analytics caches (10 min). */
export const DASH_ANALYTICS_TTL_SECONDS = 600;
+16
View File
@@ -156,6 +156,22 @@ export async function generateUploadPresignedUrl(
return presignedUrl;
}
/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */
export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600;
export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise<string> {
const client = getPresignS3Client();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key
}) as GetSignedUrlParameters[1];
return getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS
});
}
/**
* Generates presigned URLs for video download
* @param keys Array of video keys