198 lines
5.7 KiB
TypeScript
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}`,
|
|
]);
|
|
}
|