Files
ACVE/lib/certificates.ts
2026-03-15 13:52:11 +00:00

198 lines
5.7 KiB
TypeScript

import { db } from "@/lib/prisma";
import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
type CertificatePrismaClient = {
certificate: {
create: (args: object) => Promise<{ id: string; certificateNumber?: string }>;
findFirst: (args: object) => Promise<{ id: string; certificateNumber?: string } | null>;
};
};
export type CertificateIssueResult = {
certificateId: string | null;
certificateNumber: string | null;
newlyIssued: boolean;
};
function getText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
const record = value as Record<string, unknown>;
if (typeof record.en === "string") return record.en;
if (typeof record.es === "string") return record.es;
}
return "";
}
function escapePdfText(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
function buildMinimalPdf(lines: string[]): Uint8Array {
const contentLines = lines
.map((line, index) => `BT /F1 14 Tf 72 ${730 - index * 24} Td (${escapePdfText(line)}) Tj ET`)
.join("\n");
const stream = `${contentLines}\n`;
const objects = [
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj",
"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj",
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj",
"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj",
`5 0 obj << /Length ${stream.length} >> stream\n${stream}endstream endobj`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
for (const object of objects) {
offsets.push(pdf.length);
pdf += `${object}\n`;
}
const xrefStart = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (let i = 1; i <= objects.length; i += 1) {
pdf += `${offsets[i].toString().padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
return new TextEncoder().encode(pdf);
}
export async function issueCertificateIfEligible(userId: string, courseId: string): Promise<CertificateIssueResult> {
const prismaAny = db as unknown as CertificatePrismaClient;
try {
const existing = await prismaAny.certificate.findFirst({
where: { userId, courseId },
select: { id: true, certificateNumber: true },
});
if (existing) {
return {
certificateId: existing.id,
certificateNumber: existing.certificateNumber ?? null,
newlyIssued: false,
};
}
const [course, completedCount, lessons] = await Promise.all([
db.course.findUnique({
where: { id: courseId },
select: { id: true, title: true, slug: true },
}),
db.userProgress.count({
where: {
userId,
isCompleted: true,
lesson: {
module: {
courseId,
},
},
},
}),
db.lesson.findMany({
where: {
module: {
courseId,
},
},
select: {
id: true,
description: true,
},
}),
]);
const totalCount = lessons.length;
if (!course || totalCount === 0 || completedCount < totalCount) {
return { certificateId: null, certificateNumber: null, newlyIssued: false };
}
const finalExamLessonIds = lessons
.filter((lesson) => isFinalExam(parseLessonDescriptionMeta(lesson.description).contentType))
.map((lesson) => lesson.id);
if (finalExamLessonIds.length > 0) {
const completedFinalExams = await db.userProgress.count({
where: {
userId,
isCompleted: true,
lessonId: {
in: finalExamLessonIds,
},
},
});
if (completedFinalExams < finalExamLessonIds.length) {
return { certificateId: null, certificateNumber: null, newlyIssued: false };
}
}
const membership = await db.membership.findFirst({
where: { userId, isActive: true },
select: { companyId: true },
});
const certificateNumber = `ACVE-${new Date().getFullYear()}-${Math.floor(
100000 + Math.random() * 900000,
)}`;
const certificate = await prismaAny.certificate.create({
data: {
userId,
courseId,
companyId: membership?.companyId ?? null,
certificateNumber,
pdfVersion: 1,
metadataSnapshot: {
courseId: course.id,
courseSlug: course.slug,
courseTitle: getText(course.title) || "Untitled course",
certificateNumber,
completionPercent: 100,
issuedAt: new Date().toISOString(),
brandingVersion: "ACVE-2026-01",
},
},
select: { id: true },
});
return {
certificateId: certificate.id,
certificateNumber: certificate.certificateNumber ?? certificateNumber,
newlyIssued: true,
};
} catch {
return { certificateId: null, certificateNumber: null, newlyIssued: false };
}
}
export function buildCertificatePdf(input: {
certificateNumber: string;
learnerName: string;
learnerEmail: string;
courseTitle: string;
issuedAt: Date;
}): Uint8Array {
const date = new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(input.issuedAt);
return buildMinimalPdf([
"ACVE - Certificate of Completion",
"",
`Certificate No: ${input.certificateNumber}`,
"",
"This certifies that",
input.learnerName,
`(${input.learnerEmail})`,
"",
"has successfully completed the course",
input.courseTitle,
"",
`Issued on ${date}`,
]);
}