diff --git a/apps/app/package.json b/apps/app/package.json index d29cd57..eb9131f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -17,7 +17,9 @@ }, "dependencies": { "@cvo/shared": "workspace:*", - "@prisma/client": "^6.2.1" + "@prisma/client": "^6.2.1", + "playwright-core": "^1.60.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/apps/app/server/api/cv/export-pdf.post.ts b/apps/app/server/api/cv/export-pdf.post.ts new file mode 100644 index 0000000..9d2af04 --- /dev/null +++ b/apps/app/server/api/cv/export-pdf.post.ts @@ -0,0 +1,69 @@ +/** + * POST /api/cv/export-pdf + * Corps : RenderableCv (JSON). + * Réponse : PDF binaire (application/pdf), Content-Disposition: attachment. + * + * Flux : parse Zod → assertValidCv → buildCvHtml → renderHtmlToPdf. + * Sécurité : données non loggées (RGPD) ; HTML échappé par buildCvHtml. + */ + +import { z } from 'zod' +import { assertValidCv } from '@cvo/shared' +import type { RenderableCv } from '@cvo/shared' +import { buildCvHtml } from '../../utils/cv-html' +import { renderHtmlToPdf } from '../../utils/pdf' + +const Provenance = z.object({ profileItemId: z.string(), reformulated: z.boolean() }) +const Contact = z.object({ kind: z.enum(['email', 'phone', 'location', 'link']), label: z.string(), value: z.string() }) +const Bullet = z.object({ id: z.string(), text: z.string(), provenance: Provenance }) +const BaseEntry = z.object({ id: z.string(), provenance: Provenance }) + +const Section = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('summary'), title: z.string(), text: z.string(), provenance: Provenance }), + z.object({ kind: z.literal('experience'), title: z.string(), entries: z.array(BaseEntry.extend({ role: z.string(), organization: z.string(), period: z.string(), location: z.string().optional(), bullets: z.array(Bullet) })) }), + z.object({ kind: z.literal('skills'), title: z.string(), entries: z.array(BaseEntry.extend({ label: z.string() })) }), + z.object({ kind: z.literal('education'), title: z.string(), entries: z.array(BaseEntry.extend({ degree: z.string(), institution: z.string(), period: z.string() })) }), +]) + +const RenderableCvSchema = z.object({ + header: z.object({ fullName: z.string().min(1), headline: z.string(), contacts: z.array(Contact), provenance: Provenance }), + sections: z.array(Section), + locale: z.literal('fr'), +}) + +export default defineEventHandler(async (event) => { + const raw = await readBody(event) + const parsed = RenderableCvSchema.safeParse(raw) + if (!parsed.success) { + throw createError({ statusCode: 400, message: 'Corps invalide : ' + parsed.error.message }) + } + const cv = parsed.data as RenderableCv + + // Garde-fou provenance. Au MVP : les profileItemIds déclarés dans le CV lui-même + // servent de proxy du profile_snapshot (THI-123 fournira les vrais ids). + try { + assertValidCv(cv, extractDeclaredIds(cv)) + } catch (err) { + throw createError({ statusCode: 422, message: (err as Error).message }) + } + + const pdfBuffer = await renderHtmlToPdf(buildCvHtml(cv)) + + setHeader(event, 'Content-Type', 'application/pdf') + setHeader(event, 'Content-Disposition', 'attachment; filename="cv.pdf"') + return new Uint8Array(pdfBuffer) +}) + +/** Extrait tous les profileItemIds déclarés dans le CV (proxy de profile_snapshot au MVP). */ +function extractDeclaredIds(cv: RenderableCv): Set { + const ids = new Set() + const add = (p: { profileItemId: string } | undefined) => { if (p?.profileItemId) ids.add(p.profileItemId) } + + add(cv.header?.provenance) + for (const s of cv.sections ?? []) { + if (s.kind === 'summary') { add(s.provenance) } + else if (s.kind === 'experience') { for (const e of s.entries) { add(e.provenance); for (const b of e.bullets) add(b.provenance) } } + else if (s.kind === 'skills' || s.kind === 'education') { for (const e of s.entries) add(e.provenance) } + } + return ids +} diff --git a/apps/app/server/utils/cv-html.ts b/apps/app/server/utils/cv-html.ts new file mode 100644 index 0000000..6e23ae0 --- /dev/null +++ b/apps/app/server/utils/cv-html.ts @@ -0,0 +1,128 @@ +/** + * Rendu côté serveur (Nitro) d'un RenderableCv en HTML complet auto-suffisant. + * Aucune dépendance Vue côté serveur : la structure HTML est générée directement + * à partir des données, pour être passée à Chromium (export PDF). + * + * Les classes Tailwind utilisées ici sont un sous-ensemble des tokens @theme + * définis dans main.css. Elles sont inlinées en CSS natif dans la balise + + +
+
+

${esc(cv.header.fullName)}

+

${esc(cv.header.headline)}

+ ${renderContacts(cv)} +
+
+${sectionsHtml} +
+
+ +` +} diff --git a/apps/app/server/utils/pdf.ts b/apps/app/server/utils/pdf.ts new file mode 100644 index 0000000..6f00e72 --- /dev/null +++ b/apps/app/server/utils/pdf.ts @@ -0,0 +1,51 @@ +/** + * Utilitaire serveur (Nitro) : rendu HTML → PDF via Chromium headless. + * + * Approche : on injecte le HTML du CV dans une page Chromium et on utilise + * l'API print-to-PDF native. Le CSS @media print (main.css) gère la mise en + * page A4, les marges et le break-inside-avoid. + * + * Chromium path : CHROMIUM_EXECUTABLE_PATH (env, pour Docker / CI) ou Chrome + * installé localement sur macOS (dev). En production on attend un container + * Chromium ou l'option Gotenberg (alternative évoquée dans THI-120 §4b). + * + * Sécurité : le HTML est généré côté serveur uniquement depuis un RenderableCv + * validé par le garde-fou (assertValidCv). Il n'est jamais stocké en clair ni + * loggé (RGPD). + */ + +import { chromium } from 'playwright-core' + +const MAC_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + +function resolveChromiumPath(): string { + const env = process.env.CHROMIUM_EXECUTABLE_PATH + if (env) return env + // Fallback local macOS dev : Chrome installé. + return MAC_CHROME +} + +/** + * Convertit un fragment HTML en PDF A4. + * @param html Fragment HTML complet (head + body) rendu par le template Vue. + * @returns ArrayBuffer du PDF. + */ +export async function renderHtmlToPdf(html: string): Promise { + const executablePath = resolveChromiumPath() + const browser = await chromium.launch({ + executablePath, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }) + try { + const page = await browser.newPage() + await page.setContent(html, { waitUntil: 'networkidle' }) + const pdfBuffer = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '12mm', right: '14mm', bottom: '12mm', left: '14mm' }, + }) + return pdfBuffer.buffer as ArrayBuffer + } finally { + await browser.close() + } +} diff --git a/apps/app/test/cv-html.spec.ts b/apps/app/test/cv-html.spec.ts new file mode 100644 index 0000000..dc185d5 --- /dev/null +++ b/apps/app/test/cv-html.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { buildCvHtml } from '../server/utils/cv-html' +import type { RenderableCv } from '@cvo/shared' + +const demoCv: RenderableCv = { + locale: 'fr', + header: { + fullName: 'Camille Martin', + headline: 'Développeuse full-stack TypeScript', + contacts: [{ kind: 'email', label: 'Email', value: 'camille@exemple.fr' }], + provenance: { profileItemId: 'identity-1', reformulated: true }, + }, + sections: [ + { + kind: 'summary', + title: 'Profil', + text: 'Développeuse orientée qualité.', + provenance: { profileItemId: 'summary-1', reformulated: true }, + }, + { + kind: 'experience', + title: 'Expériences', + entries: [ + { + id: 'exp-1', + role: 'Développeuse full-stack', + organization: 'Studio Web SAS', + period: '2021 – 2024', + provenance: { profileItemId: 'exp-1', reformulated: true }, + bullets: [ + { + id: 'b1', + text: "Conception d'une app Vue 3.", + provenance: { profileItemId: 'exp-1-b1', reformulated: true }, + }, + ], + }, + ], + }, + { + kind: 'skills', + title: 'Compétences', + entries: [{ id: 'sk-1', label: 'TypeScript', provenance: { profileItemId: 'skill-ts', reformulated: false } }], + }, + { + kind: 'education', + title: 'Formation', + entries: [ + { + id: 'edu-1', + degree: 'Master Informatique', + institution: 'Université de Lyon', + period: '2019', + provenance: { profileItemId: 'edu-1', reformulated: false }, + }, + ], + }, + ], +} + +describe('buildCvHtml', () => { + it('produit un document HTML valide', () => { + const html = buildCvHtml(demoCv) + expect(html).toMatch(/^/) + expect(html).toContain('') + }) + + it("inclut le nom et l'accroche", () => { + const html = buildCvHtml(demoCv) + expect(html).toContain('Camille Martin') + expect(html).toContain('Développeuse full-stack TypeScript') + }) + + it('inclut les contacts', () => { + const html = buildCvHtml(demoCv) + expect(html).toContain('camille@exemple.fr') + }) + + it('inclut toutes les sections', () => { + const html = buildCvHtml(demoCv) + expect(html).toContain('Profil') + expect(html).toContain('Expériences') + expect(html).toContain('Compétences') + expect(html).toContain('Formation') + }) + + it("inclut les puces d'expérience", () => { + const html = buildCvHtml(demoCv) + expect(html).toContain("Conception d'une app Vue 3.") + }) + + it('échappe les caractères HTML potentiellement dangereux', () => { + const maliciousCv: RenderableCv = { + ...demoCv, + header: { + ...demoCv.header, + fullName: '', + }, + sections: [], + } + const html = buildCvHtml(maliciousCv) + expect(html).not.toContain('