Pending course, rest ready for launch
This commit is contained in:
197
lib/certificates.ts
Normal file
197
lib/certificates.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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}`,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user