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