- Ton intitulé et ton accroche, repris en haut de tes CV.
+ Ton identité, ton intitulé et ton accroche, repris en haut de tes CV.
@@ -601,6 +1161,103 @@ async function applyImport() {
+
+
+
+
+
+
+
+
+
Langues
+
Les langues que tu parles, avec ton niveau.
+
+
+
+
+ Ajouter une langue
+
+
+
+
+
+
+ {{ lang.label }}
+
+ · {{ LANGUAGE_LEVEL_LABELS[lang.level] }}
+
+
+
+
+
+
+
+
+
+
+ Aucune langue pour l'instant. Ajoute celles que tu parles.
+
+
+
+
+
+
+
+
@@ -708,6 +1365,297 @@ async function applyImport() {
+
+
+
+
+
+
+
+
+
CV de base
+
+ Upload ton CV actuel (PDF) : on en capture la mise en forme (couleurs, polices,
+ agencement) ET on pré-remplit ton profil (expériences, compétences, langues…) à
+ partir de son contenu. Tu relis avant d'enregistrer. Ton PDF n'est pas conservé.
+
+
+
+
+
+
+
+
+
+
+
Design actif
+
{{ baseDesign.summary }}
+
+
+ Retirer
+
+
+
+ Remplacer
+
+
+
+
+
+
+
+
Relecture
+
+ Détecté — rien n'est encore enregistré.
+
+
+
+ Design : {{ designPreview.summary }}
+
+
+
+
+ Accent
+
+
+
+ Sidebar
+
+ {{
+ designPreview.layout === 'sidebar-left' ? '2 colonnes' : '1 colonne'
+ }}
+ Police : {{ designPreview.font }}
+
+
+
+
+
+ Infos détectées — relis et corrige avant d'enregistrer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Compétences clés ({{ parsedPreview.keySkills.length }})
+
+
+
+
+
+
+
+ Expériences ({{ parsedPreview.experiences.length }})
+
+
+
+
+
+
+
+ Formations ({{ parsedPreview.education.length }})
+
+
+
+
+
+
+
+ Compétences ({{ parsedPreview.skills.length }})
+
+
+
+
+ Niveau ?
+
+ {{ SKILL_LEVEL_LABELS[l] }}
+
+
+
+
+
+
+
+
+
+
+
+ Langues ({{ parsedPreview.languages.length }})
+
+
+
+
+ Niveau ?
+
+ {{ LANGUAGE_LEVEL_LABELS[l] }}
+
+
+
+
+
+
+
+
+
+ Expériences, compétences et langues seront ajoutées. Identité et résumé ne sont
+ remplis que s'ils sont encore vides dans ton profil.
+
+
+
+
+
+ Annuler
+
+
+ Utiliser et compléter le profil
+
+
+
+
+
+
+
+
+ Aucun design importé. Tes CV utilisent le modèle par défaut.
+
+
+
+ Importer un PDF
+
+
+
+
@@ -729,6 +1677,24 @@ async function applyImport() {
{{ skillCount }}
+
+
+
+ Langues
+
+ {{ languageCount }}
+
+
+
+
+ Formations
+
+ {{ educationCount }}
+
Plus ton profil est complet, plus tes CV générés seront pertinents.
diff --git a/apps/app/pages/ui.vue b/apps/app/pages/ui.vue
deleted file mode 100644
index 00d7087..0000000
--- a/apps/app/pages/ui.vue
+++ /dev/null
@@ -1,373 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
brand — indigo profond
-
-
-
-
ink — neutres chauds (stone)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Primaire
- Secondaire
- Ghost
- Supprimer
- Désactivé
- Générer
-
-
- Small
- Medium
- Large
-
-
- Avec icône
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Recommandé
- Validé
- À retravailler
- Manquant
- Brouillon
-
-
-
-
-
-
- Toujours via @lucide/vue, stroke-width 1.75–2. Jamais
- d'émojis ni de SVG inline ad hoc.
-
-
-
-
- {{ item.name }}
-
-
-
-
-
-
-
-
-
- Réessayer
-
-
- Ajouter
-
-
-
-
-
-
-
- Plusieurs petits skeletons qui épousent la forme du contenu final, plutôt qu'un grand
- bloc.
-
-
-
-
-
-
-
- Feedback non bloquant après une action — via useToast().
-
-
-
- Toast succès
-
-
- Toast erreur
-
-
- Toast info
-
-
-
-
-
-
-
- Pour les actions irréversibles — focus initial sur « Annuler », piège de focus, fermeture
- avec Échap.
-
- Supprimer une offre…
-
-
-
-
-
diff --git a/apps/app/prisma/migrations/20260619075016_profile_identity/migration.sql b/apps/app/prisma/migrations/20260619075016_profile_identity/migration.sql
new file mode 100644
index 0000000..f323ece
--- /dev/null
+++ b/apps/app/prisma/migrations/20260619075016_profile_identity/migration.sql
@@ -0,0 +1,6 @@
+-- AlterTable
+ALTER TABLE "profiles" ADD COLUMN "email" TEXT,
+ADD COLUMN "fullName" TEXT,
+ADD COLUMN "links" TEXT[],
+ADD COLUMN "location" TEXT,
+ADD COLUMN "phone" TEXT;
diff --git a/apps/app/prisma/migrations/20260619082630_profile_languages/migration.sql b/apps/app/prisma/migrations/20260619082630_profile_languages/migration.sql
new file mode 100644
index 0000000..fede5ff
--- /dev/null
+++ b/apps/app/prisma/migrations/20260619082630_profile_languages/migration.sql
@@ -0,0 +1,25 @@
+-- CreateEnum
+CREATE TYPE "LanguageLevel" AS ENUM ('A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'NATIVE');
+
+-- CreateTable
+CREATE TABLE "languages" (
+ "id" TEXT NOT NULL,
+ "profileId" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "level" "LanguageLevel",
+ "orderIndex" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "deletedAt" TIMESTAMP(3),
+
+ CONSTRAINT "languages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "languages_profileId_idx" ON "languages"("profileId");
+
+-- CreateIndex
+CREATE INDEX "languages_deletedAt_idx" ON "languages"("deletedAt");
+
+-- AddForeignKey
+ALTER TABLE "languages" ADD CONSTRAINT "languages_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/app/prisma/migrations/20260619083557_profile_base_cv_design/migration.sql b/apps/app/prisma/migrations/20260619083557_profile_base_cv_design/migration.sql
new file mode 100644
index 0000000..fbced7c
--- /dev/null
+++ b/apps/app/prisma/migrations/20260619083557_profile_base_cv_design/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "profiles" ADD COLUMN "baseCvDesign" JSONB;
diff --git a/apps/app/prisma/migrations/20260619122800_candidatures/migration.sql b/apps/app/prisma/migrations/20260619122800_candidatures/migration.sql
new file mode 100644
index 0000000..3a30e08
--- /dev/null
+++ b/apps/app/prisma/migrations/20260619122800_candidatures/migration.sql
@@ -0,0 +1,26 @@
+-- CreateEnum
+CREATE TYPE "candidature_status" AS ENUM ('DRAFT', 'SUBMITTED', 'INTERVIEW', 'REJECTED', 'ACCEPTED');
+
+-- CreateTable
+CREATE TABLE "candidatures" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "status" "candidature_status" NOT NULL DEFAULT 'DRAFT',
+ "offerSnapshot" JSONB NOT NULL,
+ "matchScore" INTEGER NOT NULL,
+ "matchReport" JSONB NOT NULL,
+ "generatedCv" JSONB NOT NULL,
+ "design" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "deletedAt" TIMESTAMP(3),
+
+ CONSTRAINT "candidatures_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "candidatures_userId_deletedAt_idx" ON "candidatures"("userId", "deletedAt");
+
+-- CreateIndex
+CREATE INDEX "candidatures_deletedAt_idx" ON "candidatures"("deletedAt");
diff --git a/apps/app/prisma/migrations/20260619133422_profile_key_skills/migration.sql b/apps/app/prisma/migrations/20260619133422_profile_key_skills/migration.sql
new file mode 100644
index 0000000..0ee102c
--- /dev/null
+++ b/apps/app/prisma/migrations/20260619133422_profile_key_skills/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "profiles" ADD COLUMN "keySkills" TEXT[];
diff --git a/apps/app/prisma/schema.prisma b/apps/app/prisma/schema.prisma
index f475aa3..7cb3942 100644
--- a/apps/app/prisma/schema.prisma
+++ b/apps/app/prisma/schema.prisma
@@ -59,6 +59,17 @@ enum SkillLevel {
EXPERT
}
+// Niveau de maîtrise d'une langue — cadre européen (CEFR) + langue maternelle.
+enum LanguageLevel {
+ A1
+ A2
+ B1
+ B2
+ C1
+ C2
+ NATIVE
+}
+
// Compte utilisateur — ancre d'authentification (le WS Auth y greffera Better
// Auth + ses tables satellites). Données auth hébergées en FR/UE (RGPD).
model User {
@@ -85,8 +96,19 @@ model Profile {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ // Identité affichée en tête de CV — FACTUELLE, jamais reformulée par le moteur
+ // (cf. @cvo/shared cv.ts). Injectée déterministe-ment dans le header généré.
+ fullName String? // nom complet du candidat (ex. « Camille Martin »)
+ email String? // email de contact affiché (défaut : email du compte)
+ phone String? // téléphone de contact
+ location String? // localisation (ex. « Paris, France »)
+ links String[] // liens (portfolio, LinkedIn, GitHub…)
+ keySkills String[] // compétences clés : phrases (verbe d'action), section au-dessus des expériences
headline String? // intitulé court (ex. « Développeur Full-Stack »)
summary String? // résumé / accroche
+ // Thème « CV de base » capturé depuis un PDF (style uniquement, jamais de
+ // contenu) : { css, googleFont, summary }. Le PDF source n'est JAMAIS stocké.
+ baseCvDesign Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@ -94,6 +116,7 @@ model Profile {
experiences Experience[]
skills Skill[]
education Education[]
+ languages Language[]
@@index([deletedAt])
@@map("profiles")
@@ -138,6 +161,23 @@ model Skill {
@@map("skills")
}
+// Langue déclarée par le candidat (n par profil).
+model Language {
+ id String @id @default(uuid())
+ profileId String
+ profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
+ label String // libellé libre (ex. « Français », « Anglais »)
+ level LanguageLevel?
+ orderIndex Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ deletedAt DateTime?
+
+ @@index([profileId])
+ @@index([deletedAt])
+ @@map("languages")
+}
+
// Formation / diplôme (n par profil).
model Education {
id String @id @default(uuid())
@@ -158,6 +198,41 @@ model Education {
@@map("education")
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Candidature — une candidature persistée = offre analysée + CV généré (éditable)
+// + suivi. Rend le CV durable (plus de perte au changement d'onglet) et alimente
+// la liste de suivi. `userId` opaque (même pattern que UsageEvent, pas de FK).
+// Json : offre/CV/design (jamais loggés ; RGPD = données du candidat, pas de tiers).
+// ─────────────────────────────────────────────────────────────────────────────
+enum CandidatureStatus {
+ DRAFT // brouillon
+ SUBMITTED // postulé
+ INTERVIEW // entretien
+ REJECTED // refusé
+ ACCEPTED // accepté
+
+ @@map("candidature_status")
+}
+
+model Candidature {
+ id String @id @default(uuid())
+ userId String
+ label String // titre dénormalisé pour la liste (= offre.title à la création)
+ status CandidatureStatus @default(DRAFT)
+ offerSnapshot Json // AnalyzedOffer figée
+ matchScore Int // dénormalisé depuis MatchReport.score (tri/badge)
+ matchReport Json // MatchReport complet
+ generatedCv Json // RenderableCv — copie de travail éditable
+ design Json? // CvDesign par-candidature (null = fallback profil/défaut)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ deletedAt DateTime?
+
+ @@index([userId, deletedAt])
+ @@index([deletedAt])
+ @@map("candidatures")
+}
+
// Metering — usage_events + usage_counters (THI-120 §2 · THI-126)
//
// On mesure l'usage DÈS le MVP (même gratuit) pour préparer le billing freemium.
diff --git a/apps/app/server/api/candidature/analyze.post.ts b/apps/app/server/api/candidature/analyze.post.ts
index 5d1cbfb..a81ff6f 100644
--- a/apps/app/server/api/candidature/analyze.post.ts
+++ b/apps/app/server/api/candidature/analyze.post.ts
@@ -44,6 +44,7 @@ export default defineEventHandler(async (event): Promise => {
+export default defineEventHandler(async (event): Promise => {
const userId = requireUserId(event)
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
- message: 'Corps invalide : { offer: AnalyzedOffer } attendu.',
+ message: 'Corps invalide : { offer: AnalyzedOffer, match: MatchReport } attendu.',
})
}
const offer: AnalyzedOffer = parsed.data.offer
+ const match: MatchReport = parsed.data.match
// Gate quota AVANT tout appel LLM (aucun token consommé si quota atteint).
+ // Bypass via env DISABLE_GENERATION_LIMIT=true (dev/temporaire — le modèle éco
+ // freemium reste en place, ce n'est PAS un illimité de prod).
const now = new Date()
- const allowed = await isUsageAllowed(prisma, userId, 'generation', FREE_TIER_QUOTAS, now)
+ const limitDisabled = process.env.DISABLE_GENERATION_LIMIT === 'true'
+ const allowed = limitDisabled || (await isUsageAllowed(prisma, userId, 'generation', FREE_TIER_QUOTAS, now))
if (!allowed) {
throw createError({
statusCode: 403,
@@ -63,6 +79,7 @@ export default defineEventHandler(async (event): Promise<{ cv: RenderableCv }> =
experiences: { orderBy: { orderIndex: 'asc' } },
skills: { orderBy: { orderIndex: 'asc' } },
education: { orderBy: { orderIndex: 'asc' } },
+ languages: { orderBy: { orderIndex: 'asc' } },
},
})
const profile = profileRow ? toProfileDTO(profileRow) : null
@@ -74,6 +91,23 @@ export default defineEventHandler(async (event): Promise<{ cv: RenderableCv }> =
})
}
+ // Identité du candidat (nom + email par défaut) : factuelle, posée côté serveur
+ // — jamais inventée par le LLM. Le compte fournit l'email de repli.
+ const account = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { name: true, email: true },
+ })
+ const fullName = (profile.fullName ?? '').trim() || (account?.name ?? '').trim()
+ if (!fullName) {
+ // Sans nom, le header du CV serait vide et l'export PDF échouerait (400).
+ // On guide vers le profil AVANT de consommer un appel LLM.
+ throw createError({
+ statusCode: 409,
+ message: 'Ajoute ton nom complet à ton profil',
+ data: { code: 'profile_name_missing' },
+ })
+ }
+
// Mesure des tokens via onUsage (jamais de contenu).
let tokensIn = 0
let tokensOut = 0
@@ -89,13 +123,60 @@ export default defineEventHandler(async (event): Promise<{ cv: RenderableCv }> =
try {
const cv = await matchProfileToOffer(profile, offer, { complete })
+ // Identité = données factuelles : on écrase ce que le LLM a pu produire dans
+ // le header (nom + contacts) par les valeurs réelles du profil/compte. La
+ // provenance et l'accroche reformulée par le moteur sont conservées.
+ cv.header.fullName = fullName
+ cv.header.contacts = buildContacts(profile, account?.email ?? null)
+
+ // Compétences clés (phrases verbe d'action) : on PART des phrases réelles du
+ // profil et on les fait ADAPTER à l'offre (reformulation/priorisation), borné
+ // aux phrases sources — jamais d'ajout. Repli verbatim si l'adaptation échoue.
+ // Posées au-dessus des expériences ; provenance = profil (garde-fou satisfait).
+ if (profile.keySkills.length) {
+ let phrases = profile.keySkills
+ try {
+ phrases = await adaptKeySkills(profile.keySkills, offer, { complete })
+ } catch {
+ phrases = profile.keySkills
+ }
+ const keySection = {
+ kind: 'keyskills' as const,
+ title: 'Compétences clés',
+ entries: phrases.map((text, i) => ({
+ id: `ks-${i}`,
+ text,
+ provenance: { profileItemId: profile.id, reformulated: true },
+ })),
+ }
+ const expIdx = cv.sections.findIndex((s) => s.kind === 'experience')
+ cv.sections.splice(expIdx >= 0 ? expIdx : cv.sections.length, 0, keySection)
+ }
+
await recordUsageEvent(
prisma,
{ userId, type: 'generation', tokensIn, tokensOut, billable: false },
new Date(),
)
- return { cv }
+ // Persiste la candidature (CV durable + suivi). Design seedé depuis le « CV de
+ // base » du profil (ou null → fallback au rendu). Statut initial = brouillon.
+ const candidature = await prisma.candidature.create({
+ data: {
+ userId,
+ label: offer.title,
+ offerSnapshot: offer as unknown as Prisma.InputJsonValue,
+ matchScore: Math.round(match.score),
+ matchReport: match as unknown as Prisma.InputJsonValue,
+ generatedCv: cv as unknown as Prisma.InputJsonValue,
+ design: profile.baseCvDesign
+ ? (profile.baseCvDesign as unknown as Prisma.InputJsonValue)
+ : Prisma.DbNull,
+ },
+ select: { id: true },
+ })
+
+ return { cv, candidatureId: candidature.id }
} catch (err) {
// Garde-fou de provenance : le LLM a produit un élément non sourcé — rejeté.
if (err instanceof ProvenanceError) {
@@ -114,3 +195,29 @@ export default defineEventHandler(async (event): Promise<{ cv: RenderableCv }> =
throw err
}
})
+
+/**
+ * Construit les contacts factuels du header à partir du profil (email du compte
+ * en repli). Aucune reformulation : valeurs reprises telles quelles.
+ */
+function buildContacts(profile: ProfileDTO, accountEmail: string | null): CvContact[] {
+ const contacts: CvContact[] = []
+ const email = (profile.email ?? '').trim() || (accountEmail ?? '').trim()
+ if (email) contacts.push({ kind: 'email', label: 'Email', value: email })
+ if (profile.phone?.trim()) contacts.push({ kind: 'phone', label: 'Téléphone', value: profile.phone.trim() })
+ if (profile.location?.trim()) contacts.push({ kind: 'location', label: 'Localisation', value: profile.location.trim() })
+ for (const raw of profile.links ?? []) {
+ const value = raw.trim()
+ if (value) contacts.push({ kind: 'link', label: linkLabel(value), value })
+ }
+ return contacts
+}
+
+/** Libellé lisible d'un lien (hôte sans `www.`), avec repli sur « Lien ». */
+function linkLabel(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, '')
+ } catch {
+ return 'Lien'
+ }
+}
diff --git a/apps/app/server/api/candidatures/[id].delete.ts b/apps/app/server/api/candidatures/[id].delete.ts
new file mode 100644
index 0000000..e232cff
--- /dev/null
+++ b/apps/app/server/api/candidatures/[id].delete.ts
@@ -0,0 +1,21 @@
+/**
+ * DELETE /api/candidatures/:id — soft-delete d'une candidature.
+ */
+import { requireUserId } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+
+export default defineEventHandler(async (event) => {
+ const userId = requireUserId(event)
+ const id = getRouterParam(event, 'id')
+ if (!id) throw createError({ statusCode: 400, message: 'id requis.' })
+
+ const existing = await prisma.candidature.findFirst({
+ where: { id, userId, ...NOT_DELETED },
+ select: { id: true },
+ })
+ if (!existing) throw createError({ statusCode: 404, message: 'Candidature introuvable.' })
+
+ await prisma.candidature.update({ where: { id }, data: { deletedAt: new Date() } })
+ return { ok: true }
+})
diff --git a/apps/app/server/api/candidatures/[id].get.ts b/apps/app/server/api/candidatures/[id].get.ts
new file mode 100644
index 0000000..aa34df1
--- /dev/null
+++ b/apps/app/server/api/candidatures/[id].get.ts
@@ -0,0 +1,19 @@
+/**
+ * GET /api/candidatures/:id — candidature complète (offre + match + CV + design).
+ * Pour l'éditeur. 404 si absente / pas au propriétaire.
+ */
+import { requireUserId } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+import { toCandidatureDTO } from '../../utils/candidature-serialize'
+
+export default defineEventHandler(async (event) => {
+ const userId = requireUserId(event)
+ const id = getRouterParam(event, 'id')
+ if (!id) throw createError({ statusCode: 400, message: 'id requis.' })
+
+ const row = await prisma.candidature.findFirst({ where: { id, userId, ...NOT_DELETED } })
+ if (!row) throw createError({ statusCode: 404, message: 'Candidature introuvable.' })
+
+ return toCandidatureDTO(row)
+})
diff --git a/apps/app/server/api/candidatures/[id].patch.ts b/apps/app/server/api/candidatures/[id].patch.ts
new file mode 100644
index 0000000..58a5569
--- /dev/null
+++ b/apps/app/server/api/candidatures/[id].patch.ts
@@ -0,0 +1,54 @@
+/**
+ * PATCH /api/candidatures/:id — met à jour une candidature (champs optionnels :
+ * cv, design, status, label). Le `cv` repasse le garde-fou de provenance avant
+ * persistance ; le `design` est re-normalisé. 404 si pas au propriétaire.
+ */
+import { Prisma } from '@prisma/client'
+import { CANDIDATURE_STATUSES, type CandidatureStatus } from '@cvo/shared'
+import { requireUserId } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+import { parseRenderableCvBody } from '../../utils/cv-render-input'
+import { normalizeDesign } from '../../utils/cv-design-tokens'
+import { toCandidatureDTO } from '../../utils/candidature-serialize'
+
+export default defineEventHandler(async (event) => {
+ const userId = requireUserId(event)
+ const id = getRouterParam(event, 'id')
+ if (!id) throw createError({ statusCode: 400, message: 'id requis.' })
+
+ const existing = await prisma.candidature.findFirst({
+ where: { id, userId, ...NOT_DELETED },
+ select: { id: true },
+ })
+ if (!existing) throw createError({ statusCode: 404, message: 'Candidature introuvable.' })
+
+ const body = (await readBody(event)) as Record
+ const data: Prisma.CandidatureUpdateInput = {}
+
+ if (body.cv !== undefined) {
+ // Re-applique la validation + garde-fou provenance (400/422 si invalide).
+ const cv = parseRenderableCvBody(body.cv)
+ data.generatedCv = cv as unknown as Prisma.InputJsonValue
+ }
+ if (body.design !== undefined) {
+ data.design =
+ body.design === null
+ ? Prisma.DbNull
+ : (normalizeDesign(body.design) as unknown as Prisma.InputJsonValue)
+ }
+ if (body.status !== undefined) {
+ if (!CANDIDATURE_STATUSES.includes(body.status as CandidatureStatus)) {
+ throw createError({ statusCode: 400, message: 'Statut invalide.' })
+ }
+ data.status = body.status as CandidatureStatus
+ }
+ if (body.label !== undefined) {
+ const label = String(body.label).trim().slice(0, 150)
+ if (!label) throw createError({ statusCode: 400, message: 'Libellé vide.' })
+ data.label = label
+ }
+
+ const row = await prisma.candidature.update({ where: { id }, data })
+ return toCandidatureDTO(row)
+})
diff --git a/apps/app/server/api/candidatures/index.get.ts b/apps/app/server/api/candidatures/index.get.ts
new file mode 100644
index 0000000..187b0ff
--- /dev/null
+++ b/apps/app/server/api/candidatures/index.get.ts
@@ -0,0 +1,18 @@
+/**
+ * GET /api/candidatures — liste des candidatures de l'utilisateur (suivi).
+ * Léger : on ne sélectionne pas les gros JSON (offre/CV/design).
+ */
+import { requireUserId } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+import { toCandidatureListItemDTO } from '../../utils/candidature-serialize'
+
+export default defineEventHandler(async (event) => {
+ const userId = requireUserId(event)
+ const rows = await prisma.candidature.findMany({
+ where: { userId, ...NOT_DELETED },
+ orderBy: { updatedAt: 'desc' },
+ select: { id: true, label: true, status: true, matchScore: true, createdAt: true, updatedAt: true },
+ })
+ return rows.map(toCandidatureListItemDTO)
+})
diff --git a/apps/app/server/api/cv/export-pdf.post.ts b/apps/app/server/api/cv/export-pdf.post.ts
index 9d2af04..1ff9923 100644
--- a/apps/app/server/api/cv/export-pdf.post.ts
+++ b/apps/app/server/api/cv/export-pdf.post.ts
@@ -3,67 +3,27 @@
* Corps : RenderableCv (JSON).
* Réponse : PDF binaire (application/pdf), Content-Disposition: attachment.
*
- * Flux : parse Zod → assertValidCv → buildCvHtml → renderHtmlToPdf.
+ * Flux : parse Zod + garde-fou provenance → buildCvHtml (thème « CV de base » si
+ * configuré) → 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 type { CvDesign } 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'),
-})
+import { loadBaseCvDesign } from '../../utils/cv-design'
+import { parseRenderInput } from '../../utils/cv-render-input'
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
+ const { cv, design: bodyDesign } = parseRenderInput(await readBody(event))
- // 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 })
- }
+ // Design par-candidature (body) > thème de profil > défaut. Avec un design, on
+ // rend en pleine page (marges nulles) ; le gabarit gère ses paddings.
+ const design: CvDesign | null = bodyDesign ?? (await loadBaseCvDesign(event))
- const pdfBuffer = await renderHtmlToPdf(buildCvHtml(cv))
+ const pdfBuffer = await renderHtmlToPdf(buildCvHtml(cv, design), { fullBleed: !!design })
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/api/cv/preview.post.ts b/apps/app/server/api/cv/preview.post.ts
new file mode 100644
index 0000000..5da6cd8
--- /dev/null
+++ b/apps/app/server/api/cv/preview.post.ts
@@ -0,0 +1,19 @@
+/**
+ * POST /api/cv/preview
+ * Corps : `RenderableCv` nu OU `{ cv, design? }`. Réponse : document HTML complet.
+ *
+ * Sert l'aperçu écran (iframe sandbox), même HTML que l'export PDF. Le design
+ * provient du body (éditeur, design par-candidature) sinon du profil sinon défaut.
+ */
+import type { CvDesign } from '@cvo/shared'
+import { buildCvHtml } from '../../utils/cv-html'
+import { loadBaseCvDesign } from '../../utils/cv-design'
+import { parseRenderInput } from '../../utils/cv-render-input'
+
+export default defineEventHandler(async (event) => {
+ const { cv, design } = parseRenderInput(await readBody(event))
+ const effective: CvDesign | null = design ?? (await loadBaseCvDesign(event))
+
+ setHeader(event, 'Content-Type', 'text/html; charset=utf-8')
+ return buildCvHtml(cv, effective)
+})
diff --git a/apps/app/server/api/profile/cv-design.delete.ts b/apps/app/server/api/profile/cv-design.delete.ts
new file mode 100644
index 0000000..80b31eb
--- /dev/null
+++ b/apps/app/server/api/profile/cv-design.delete.ts
@@ -0,0 +1,20 @@
+/**
+ * DELETE /api/profile/cv-design — Retire le thème « CV de base » (revient au
+ * template par défaut pour les futures générations).
+ */
+import { Prisma } from '@prisma/client'
+import { getAuthSession } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+
+export default defineEventHandler(async (event) => {
+ const session = await getAuthSession(event)
+ if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' })
+
+ const profile = await prisma.profile.findFirst({ where: { userId: session.user.id, ...NOT_DELETED } })
+ if (!profile) throw createError({ statusCode: 404, message: 'Profil introuvable.' })
+
+ // `DbNull` = NULL SQL (le champ Json nullable est vidé).
+ await prisma.profile.update({ where: { id: profile.id }, data: { baseCvDesign: Prisma.DbNull } })
+ return { ok: true }
+})
diff --git a/apps/app/server/api/profile/cv-design.put.ts b/apps/app/server/api/profile/cv-design.put.ts
new file mode 100644
index 0000000..048e237
--- /dev/null
+++ b/apps/app/server/api/profile/cv-design.put.ts
@@ -0,0 +1,27 @@
+/**
+ * PUT /api/profile/cv-design — Persiste les tokens de design « CV de base »
+ * (après relecture). Les tokens sont validés/normalisés (couleurs hex, police
+ * bornée, layout dans l'enum). Aucun CSS brut n'est stocké.
+ */
+import { Prisma } from '@prisma/client'
+import type { CvDesign } from '@cvo/shared'
+import { getAuthSession } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+import { normalizeDesign } from '../../utils/cv-design-tokens'
+
+export default defineEventHandler(async (event) => {
+ const session = await getAuthSession(event)
+ if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' })
+
+ const profile = await prisma.profile.findFirst({ where: { userId: session.user.id, ...NOT_DELETED } })
+ if (!profile) throw createError({ statusCode: 404, message: 'Profil introuvable.' })
+
+ const design: CvDesign = normalizeDesign(await readBody(event))
+
+ await prisma.profile.update({
+ where: { id: profile.id },
+ data: { baseCvDesign: design as unknown as Prisma.InputJsonValue },
+ })
+ return design
+})
diff --git a/apps/app/server/api/profile/cv-design/extract.post.ts b/apps/app/server/api/profile/cv-design/extract.post.ts
new file mode 100644
index 0000000..db9f40f
--- /dev/null
+++ b/apps/app/server/api/profile/cv-design/extract.post.ts
@@ -0,0 +1,72 @@
+/**
+ * POST /api/profile/cv-design/extract — étape 1 du « CV de base ».
+ *
+ * Corps : multipart, champ fichier = PDF du CV existant. Réponse : { design }
+ * (thème extrait, sanitisé) AVANT toute persistance — l'utilisateur relit puis
+ * confirme (cf. flux import-text). Le PDF n'est JAMAIS stocké ni loggé (RGPD) :
+ * seuls les tokens sont mesurés.
+ */
+import { requireUserId } from '../../../utils/session'
+import { prisma } from '../../../utils/prisma'
+import { recordUsageEvent } from '../../../utils/metering'
+import { anthropicComplete, LlmError, type LlmComplete } from '../../../utils/anthropic'
+import { extractCvDesign } from '../../../services/cv-design-extract'
+import { extractCvContent } from '../../../services/cv-content-extract'
+
+/** Borne de garde : marge confortable sous la limite API (32 Mo). */
+const MAX_PDF_BYTES = 8 * 1024 * 1024
+
+export default defineEventHandler(async (event) => {
+ const userId = requireUserId(event)
+
+ const parts = await readMultipartFormData(event)
+ const file = parts?.find((p) => p.filename && p.data?.length)
+ if (!file) {
+ throw createError({ statusCode: 400, message: 'Aucun fichier reçu.' })
+ }
+ if (file.type !== 'application/pdf') {
+ throw createError({ statusCode: 400, message: 'Le fichier doit être un PDF.' })
+ }
+ if (file.data.length > MAX_PDF_BYTES) {
+ throw createError({ statusCode: 413, message: 'PDF trop volumineux (8 Mo maximum).' })
+ }
+
+ // Mesure des tokens via onUsage (jamais de contenu).
+ let tokensIn = 0
+ let tokensOut = 0
+ const complete: LlmComplete = (req) =>
+ anthropicComplete({
+ ...req,
+ onUsage: (u) => {
+ tokensIn += u.inputTokens
+ tokensOut += u.outputTokens
+ },
+ })
+
+ const pdfBase64 = file.data.toString('base64')
+
+ try {
+ // Deux extractions indépendantes sur le même PDF : le STYLE (thème) et le
+ // CONTENU (à relire pour pré-remplir le profil). En parallèle.
+ const [design, parsed] = await Promise.all([
+ extractCvDesign(pdfBase64, { complete }),
+ extractCvContent(pdfBase64, { complete }),
+ ])
+
+ await recordUsageEvent(
+ prisma,
+ { userId, type: 'extraction', tokensIn, tokensOut, billable: false },
+ new Date(),
+ )
+
+ return { design, parsed }
+ } catch (err) {
+ if (err instanceof LlmError) {
+ throw createError({
+ statusCode: 502,
+ message: "L'analyse du CV a échoué, réessaie dans un instant",
+ })
+ }
+ throw err
+ }
+})
diff --git a/apps/app/server/api/profile/education.post.ts b/apps/app/server/api/profile/education.post.ts
new file mode 100644
index 0000000..7088d72
--- /dev/null
+++ b/apps/app/server/api/profile/education.post.ts
@@ -0,0 +1,44 @@
+/**
+ * POST /api/profile/education — Ajouter une formation au profil.
+ */
+import { z } from 'zod'
+import { getAuthSession } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+
+const bodySchema = z.object({
+ degree: z.string().min(1).max(150),
+ school: z.string().min(1).max(150),
+ startDate: z.string().datetime({ offset: true }).nullable().optional(),
+ endDate: z.string().datetime({ offset: true }).nullable().optional(),
+ description: z.string().max(8000).nullable().optional(),
+ orderIndex: z.number().int().min(0).default(0),
+})
+
+export default defineEventHandler(async (event) => {
+ const session = await getAuthSession(event)
+ if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' })
+
+ const profile = await prisma.profile.findFirst({ where: { userId: session.user.id, ...NOT_DELETED } })
+ if (!profile) throw createError({ statusCode: 404, message: 'Profil introuvable.' })
+
+ const body = await readBody(event)
+ const parsed = bodySchema.safeParse(body)
+ if (!parsed.success) throw createError({ statusCode: 400, message: 'Corps invalide.', data: parsed.error.flatten() })
+
+ const { degree, school, startDate, endDate, description, orderIndex } = parsed.data
+
+ const edu = await prisma.education.create({
+ data: {
+ profileId: profile.id,
+ degree,
+ school,
+ startDate: startDate ? new Date(startDate) : null,
+ endDate: endDate ? new Date(endDate) : null,
+ description: description ?? null,
+ orderIndex,
+ },
+ })
+
+ return { id: edu.id }
+})
diff --git a/apps/app/server/api/profile/education/[id].delete.ts b/apps/app/server/api/profile/education/[id].delete.ts
new file mode 100644
index 0000000..a478df9
--- /dev/null
+++ b/apps/app/server/api/profile/education/[id].delete.ts
@@ -0,0 +1,22 @@
+/**
+ * DELETE /api/profile/education/:id — Soft-delete d'une formation.
+ */
+import { getAuthSession } from '../../../utils/session'
+import { prisma } from '../../../utils/prisma'
+import { NOT_DELETED } from '../../../utils/profile-serialize'
+
+export default defineEventHandler(async (event) => {
+ const session = await getAuthSession(event)
+ if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' })
+
+ const id = getRouterParam(event, 'id')
+ if (!id) throw createError({ statusCode: 400, message: 'id requis.' })
+
+ const edu = await prisma.education.findFirst({
+ where: { id, ...NOT_DELETED, profile: { userId: session.user.id, ...NOT_DELETED } },
+ })
+ if (!edu) throw createError({ statusCode: 404, message: 'Formation introuvable.' })
+
+ await prisma.education.update({ where: { id }, data: { deletedAt: new Date() } })
+ return { ok: true }
+})
diff --git a/apps/app/server/api/profile/experiences.post.ts b/apps/app/server/api/profile/experiences.post.ts
index 5a7b622..9d15234 100644
--- a/apps/app/server/api/profile/experiences.post.ts
+++ b/apps/app/server/api/profile/experiences.post.ts
@@ -11,7 +11,7 @@ const bodySchema = z.object({
company: z.string().min(1).max(150),
startDate: z.string().datetime({ offset: true }).nullable().optional(),
endDate: z.string().datetime({ offset: true }).nullable().optional(),
- description: z.string().max(2000).nullable().optional(),
+ description: z.string().max(8000).nullable().optional(),
skillsUsed: z.array(z.string().max(80)).max(30).default([]),
orderIndex: z.number().int().min(0).default(0),
})
diff --git a/apps/app/server/api/profile/index.get.ts b/apps/app/server/api/profile/index.get.ts
index 372c193..68c2099 100644
--- a/apps/app/server/api/profile/index.get.ts
+++ b/apps/app/server/api/profile/index.get.ts
@@ -17,6 +17,7 @@ export default defineEventHandler(async (event) => {
experiences: { orderBy: { orderIndex: 'asc' } },
skills: { orderBy: { orderIndex: 'asc' } },
education: { orderBy: { orderIndex: 'asc' } },
+ languages: { orderBy: { orderIndex: 'asc' } },
},
})
diff --git a/apps/app/server/api/profile/index.put.ts b/apps/app/server/api/profile/index.put.ts
index f9faf20..8a21b6e 100644
--- a/apps/app/server/api/profile/index.put.ts
+++ b/apps/app/server/api/profile/index.put.ts
@@ -8,8 +8,16 @@ import { prisma } from '../../utils/prisma'
import { toProfileDTO } from '../../utils/profile-serialize'
const bodySchema = z.object({
+ fullName: z.string().max(150).nullable().optional(),
+ email: z.string().email().max(254).nullable().optional(),
+ phone: z.string().max(40).nullable().optional(),
+ location: z.string().max(150).nullable().optional(),
+ links: z.array(z.string().url().max(500)).max(10).optional(),
+ // Borne large anti-abus ; le nettoyage réel (trim/vides/plafond 8 × 300 car.) se
+ // fait côté handler pour ne JAMAIS faire échouer toute la sauvegarde d'en-tête.
+ keySkills: z.array(z.string().max(2000)).max(100).optional(),
headline: z.string().max(150).nullable().optional(),
- summary: z.string().max(2000).nullable().optional(),
+ summary: z.string().max(8000).nullable().optional(),
})
export default defineEventHandler(async (event) => {
@@ -20,16 +28,33 @@ export default defineEventHandler(async (event) => {
const parsed = bodySchema.safeParse(body)
if (!parsed.success) throw createError({ statusCode: 400, message: 'Corps invalide.', data: parsed.error.flatten() })
- const { headline, summary } = parsed.data
+ const { fullName, email, phone, location, links, keySkills, headline, summary } = parsed.data
+
+ // Nettoyage tolérant : trim, retrait des vides, plafond 8 phrases × 300 car. On
+ // tronque plutôt que rejeter → un excès ne fait pas échouer toute la sauvegarde.
+ const cleanedKeySkills =
+ keySkills?.map((s) => s.trim().slice(0, 300)).filter(Boolean).slice(0, 8)
const profile = await prisma.profile.upsert({
where: { userId: session.user.id },
update: {
+ ...(fullName !== undefined && { fullName }),
+ ...(email !== undefined && { email }),
+ ...(phone !== undefined && { phone }),
+ ...(location !== undefined && { location }),
+ ...(links !== undefined && { links }),
+ ...(cleanedKeySkills !== undefined && { keySkills: cleanedKeySkills }),
...(headline !== undefined && { headline }),
...(summary !== undefined && { summary }),
},
create: {
userId: session.user.id,
+ fullName: fullName ?? null,
+ email: email ?? null,
+ phone: phone ?? null,
+ location: location ?? null,
+ links: links ?? [],
+ keySkills: cleanedKeySkills ?? [],
headline: headline ?? null,
summary: summary ?? null,
},
@@ -37,6 +62,7 @@ export default defineEventHandler(async (event) => {
experiences: { orderBy: { orderIndex: 'asc' } },
skills: { orderBy: { orderIndex: 'asc' } },
education: { orderBy: { orderIndex: 'asc' } },
+ languages: { orderBy: { orderIndex: 'asc' } },
},
})
diff --git a/apps/app/server/api/profile/languages.post.ts b/apps/app/server/api/profile/languages.post.ts
new file mode 100644
index 0000000..e7ef484
--- /dev/null
+++ b/apps/app/server/api/profile/languages.post.ts
@@ -0,0 +1,41 @@
+/**
+ * POST /api/profile/languages — Ajouter une langue déclarée.
+ * Garde-fou : ces données saisies = seule source de vérité du moteur (THI-124).
+ */
+import { z } from 'zod'
+import { LANGUAGE_LEVELS } from '@cvo/shared'
+import { getAuthSession } from '../../utils/session'
+import { prisma } from '../../utils/prisma'
+import { NOT_DELETED } from '../../utils/profile-serialize'
+
+const bodySchema = z.object({
+ label: z.string().min(1).max(80),
+ level: z.enum(LANGUAGE_LEVELS as [string, ...string[]]).nullable().optional(),
+ orderIndex: z.number().int().min(0).default(0),
+})
+
+export default defineEventHandler(async (event) => {
+ const session = await getAuthSession(event)
+ if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' })
+
+ const profile = await prisma.profile.findFirst({ where: { userId: session.user.id, ...NOT_DELETED } })
+ if (!profile) throw createError({ statusCode: 404, message: 'Profil introuvable.' })
+
+ const body = await readBody(event)
+ const parsed = bodySchema.safeParse(body)
+ if (!parsed.success) throw createError({ statusCode: 400, message: 'Corps invalide.', data: parsed.error.flatten() })
+
+ const { label, level, orderIndex } = parsed.data
+
+ const language = await prisma.language.create({
+ data: {
+ profileId: profile.id,
+ label,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ level: (level as any) ?? null,
+ orderIndex,
+ },
+ })
+
+ return { id: language.id }
+})
diff --git a/apps/app/server/api/profile/languages/[id].delete.ts b/apps/app/server/api/profile/languages/[id].delete.ts
new file mode 100644
index 0000000..713d88a
--- /dev/null
+++ b/apps/app/server/api/profile/languages/[id].delete.ts
@@ -0,0 +1,22 @@
+/**
+ * DELETE /api/profile/languages/:id — Soft-delete d'une langue.
+ */
+import { getAuthSession } from '../../../utils/session'
+import { prisma } from '../../../utils/prisma'
+import { NOT_DELETED } from '../../../utils/profile-serialize'
+
+export default defineEventHandler(async (event) => {
+ const session = await getAuthSession(event)
+ if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' })
+
+ const id = getRouterParam(event, 'id')
+ if (!id) throw createError({ statusCode: 400, message: 'id requis.' })
+
+ const language = await prisma.language.findFirst({
+ where: { id, ...NOT_DELETED, profile: { userId: session.user.id, ...NOT_DELETED } },
+ })
+ if (!language) throw createError({ statusCode: 404, message: 'Langue introuvable.' })
+
+ await prisma.language.update({ where: { id }, data: { deletedAt: new Date() } })
+ return { ok: true }
+})
diff --git a/apps/app/server/api/usage/current.get.ts b/apps/app/server/api/usage/current.get.ts
index bbb3cab..9a98939 100644
--- a/apps/app/server/api/usage/current.get.ts
+++ b/apps/app/server/api/usage/current.get.ts
@@ -1,4 +1,4 @@
-import type { UsageSummary } from '@cvo/shared'
+import type { UsageQuotas, UsageSummary } from '@cvo/shared'
import { FREE_TIER_QUOTAS } from '@cvo/shared'
import { prisma } from '../../utils/prisma'
import { readUsageSummary } from '../../utils/metering'
@@ -8,5 +8,11 @@ import { requireUserId } from '../../utils/session'
// l'utilisateur authentifié. Aucune donnée de contenu : uniquement des compteurs.
export default defineEventHandler(async (event): Promise => {
const userId = requireUserId(event)
- return readUsageSummary(prisma, userId, FREE_TIER_QUOTAS, new Date())
+ // Cohérent avec le gate de /generate : si la limite est désactivée (dev), la
+ // génération est reportée comme illimitée (l'UI ne grise plus le bouton).
+ const quotas: UsageQuotas =
+ process.env.DISABLE_GENERATION_LIMIT === 'true'
+ ? { ...FREE_TIER_QUOTAS, generation: undefined }
+ : FREE_TIER_QUOTAS
+ return readUsageSummary(prisma, userId, quotas, new Date())
})
diff --git a/apps/app/server/services/cv-content-extract.ts b/apps/app/server/services/cv-content-extract.ts
new file mode 100644
index 0000000..387afdb
--- /dev/null
+++ b/apps/app/server/services/cv-content-extract.ts
@@ -0,0 +1,162 @@
+/**
+ * Extraction de CONTENU — analyse le PDF d'un CV et en extrait les données
+ * factuelles pour pré-remplir le profil (identité, expériences, compétences,
+ * langues). N'invente rien : un champ absent vaut null / [].
+ *
+ * Distinct de cv-design-extract (qui ne capture que le STYLE). Le résultat est
+ * une PROPOSITION : l'utilisateur relit puis confirme avant toute écriture.
+ */
+import { SKILL_LEVELS, LANGUAGE_LEVELS, type ParsedCvProfile } from '@cvo/shared'
+import { anthropicComplete, LlmError, type LlmComplete } from '../utils/anthropic'
+
+const SYSTEM = `Tu extrais les informations factuelles d'un CV (fourni en PDF) pour pré-remplir un profil.
+RÈGLES :
+- N'invente RIEN. Si une information est absente du CV, mets null (ou [] pour les listes).
+- Recopie fidèlement : intitulés de poste, entreprises, écoles, libellés de compétences/langues.
+- Dates au format "YYYY-MM" (ou "YYYY-MM-DD"), ou null si absente/illisible. endDate null = poste en cours.
+- skillsUsed = compétences citées dans une expérience donnée (libellés courts).
+- level de compétence : un de ${SKILL_LEVELS.join('/')} si déductible, sinon null.
+- level de langue : un de ${LANGUAGE_LEVELS.join('/')} (CEFR) ou "NATIVE", sinon null.
+- education = formations/diplômes : degree (intitulé du diplôme/titre), school (établissement), dates, description (projets, mentions…).
+- keySkills = « compétences clés » : 2 à 5 phrases d'accroche commençant par un VERBE D'ACTION (ex. « Concevoir des applications web… », « Maîtriser l'écosystème Vue 3… »), reprises si le CV a une section de ce type ; sinon [].
+- headline = intitulé/accroche principal du candidat ; summary = résumé/à-propos s'il existe.`
+
+/** JSON Schema (structured outputs) — anyOf pour les nullable (cf. règles Anthropic). */
+const nullableString = { anyOf: [{ type: 'string' }, { type: 'null' }] }
+const nullableEnum = (values: readonly string[]) => ({
+ anyOf: [{ type: 'string', enum: [...values] }, { type: 'null' }],
+})
+
+const CV_CONTENT_SCHEMA = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ fullName: nullableString,
+ email: nullableString,
+ phone: nullableString,
+ location: nullableString,
+ links: { type: 'array', items: { type: 'string' } },
+ keySkills: { type: 'array', items: { type: 'string' } },
+ headline: nullableString,
+ summary: nullableString,
+ experiences: {
+ type: 'array',
+ items: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ title: { type: 'string' },
+ company: { type: 'string' },
+ startDate: nullableString,
+ endDate: nullableString,
+ description: nullableString,
+ skillsUsed: { type: 'array', items: { type: 'string' } },
+ },
+ required: ['title', 'company', 'startDate', 'endDate', 'description', 'skillsUsed'],
+ },
+ },
+ education: {
+ type: 'array',
+ items: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ degree: { type: 'string' },
+ school: { type: 'string' },
+ startDate: nullableString,
+ endDate: nullableString,
+ description: nullableString,
+ },
+ required: ['degree', 'school', 'startDate', 'endDate', 'description'],
+ },
+ },
+ skills: {
+ type: 'array',
+ items: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ label: { type: 'string' },
+ level: nullableEnum(SKILL_LEVELS),
+ years: { anyOf: [{ type: 'integer' }, { type: 'null' }] },
+ },
+ required: ['label', 'level', 'years'],
+ },
+ },
+ languages: {
+ type: 'array',
+ items: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ label: { type: 'string' },
+ level: nullableEnum(LANGUAGE_LEVELS),
+ },
+ required: ['label', 'level'],
+ },
+ },
+ },
+ required: ['fullName', 'email', 'phone', 'location', 'links', 'keySkills', 'headline', 'summary', 'experiences', 'education', 'skills', 'languages'],
+} as const
+
+export interface ExtractCvContentDeps {
+ complete: LlmComplete
+}
+
+/** Analyse le PDF (base64) et renvoie le contenu de profil proposé. */
+export async function extractCvContent(
+ pdfBase64: string,
+ deps: ExtractCvContentDeps = { complete: anthropicComplete },
+): Promise {
+ const result = (await deps.complete({
+ system: SYSTEM,
+ user: [
+ { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: pdfBase64 } },
+ { type: 'text', text: 'Extrais les informations de ce CV pour pré-remplir un profil.' },
+ ],
+ schema: CV_CONTENT_SCHEMA as unknown as Record,
+ costLabel: 'cv-content-extract',
+ maxTokens: 4096,
+ })) as Partial
+
+ // Normalisation : null → '' pour les champs texte (éditables côté UI) ; les
+ // niveaux restent nullable. Le schéma garantit déjà la forme globale.
+ const raw = result as Record
+ return {
+ fullName: (raw.fullName as string) ?? '',
+ email: (raw.email as string) ?? '',
+ phone: (raw.phone as string) ?? '',
+ location: (raw.location as string) ?? '',
+ links: (raw.links as string[]) ?? [],
+ keySkills: ((raw.keySkills as string[]) ?? [])
+ .filter((s) => typeof s === 'string' && s.trim())
+ .map((s) => s.trim().slice(0, 300))
+ .slice(0, 8),
+ headline: (raw.headline as string) ?? '',
+ summary: (raw.summary as string) ?? '',
+ experiences: ((raw.experiences as ParsedCvProfile['experiences']) ?? []).map((e) => ({
+ title: e.title ?? '',
+ company: e.company ?? '',
+ startDate: e.startDate ?? '',
+ endDate: e.endDate ?? '',
+ description: e.description ?? '',
+ skillsUsed: e.skillsUsed ?? [],
+ })),
+ education: ((raw.education as ParsedCvProfile['education']) ?? []).map((e) => ({
+ degree: e.degree ?? '',
+ school: e.school ?? '',
+ startDate: e.startDate ?? '',
+ endDate: e.endDate ?? '',
+ description: e.description ?? '',
+ })),
+ skills: ((raw.skills as ParsedCvProfile['skills']) ?? []).map((s) => ({
+ label: s.label ?? '',
+ level: s.level ?? null,
+ years: s.years ?? null,
+ })),
+ languages: ((raw.languages as ParsedCvProfile['languages']) ?? []).map((l) => ({
+ label: l.label ?? '',
+ level: l.level ?? null,
+ })),
+ }
+}
diff --git a/apps/app/server/services/cv-design-extract.ts b/apps/app/server/services/cv-design-extract.ts
new file mode 100644
index 0000000..7dbce07
--- /dev/null
+++ b/apps/app/server/services/cv-design-extract.ts
@@ -0,0 +1,60 @@
+/**
+ * Capture de DA — observe le PDF d'un CV et en extrait des TOKENS de design
+ * (disposition, couleurs, police) qui paramètrent le gabarit codé (cv-html.ts).
+ *
+ * On ne génère PAS de CSS : juste des paramètres validés → rendu toujours propre.
+ * Le contenu du CV reste fourni par le moteur (provenance garantie).
+ *
+ * `deps.complete` est injecté (réel en prod, faux en test).
+ */
+import type { CvDesign } from '@cvo/shared'
+import { anthropicComplete, type LlmComplete } from '../utils/anthropic'
+import { normalizeDesign } from '../utils/cv-design-tokens'
+
+const SYSTEM = `Tu observes le PDF d'un CV et tu en déduis des TOKENS de design (pas de CSS) :
+- layout : "sidebar-left" si le CV a une colonne latérale (bandeau de couleur sur un côté), sinon "single".
+- accent : la couleur d'accent dominante du CV (titres, filets, liens) en hex (#rrggbb).
+- sidebarBg : couleur de fond de la sidebar en hex (si pas de sidebar, propose une couleur sombre cohérente avec l'accent).
+- sidebarFg : couleur du texte sur la sidebar en hex (clair si sidebar sombre).
+- font : nom d'une police Google proche de celle du CV (ex. "Inter", "Poppins", "Lato"), ou null.
+- summary : 1 phrase en français décrivant le style (couleurs + disposition).
+N'invente pas de contenu. Donne des couleurs hex valides. Sois fidèle aux couleurs réelles du CV.`
+
+/** JSON Schema (structured outputs) — anyOf pour le nullable (cf. règles Anthropic). */
+const CV_DESIGN_SCHEMA = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ layout: { type: 'string', enum: ['single', 'sidebar-left'] },
+ accent: { type: 'string' },
+ sidebarBg: { type: 'string' },
+ sidebarFg: { type: 'string' },
+ font: { anyOf: [{ type: 'string' }, { type: 'null' }] },
+ summary: { type: 'string' },
+ },
+ required: ['layout', 'accent', 'sidebarBg', 'sidebarFg', 'font', 'summary'],
+} as const
+
+export interface ExtractCvDesignDeps {
+ complete: LlmComplete
+}
+
+/** Analyse le PDF (base64) et renvoie des tokens `CvDesign` validés/normalisés. */
+export async function extractCvDesign(
+ pdfBase64: string,
+ deps: ExtractCvDesignDeps = { complete: anthropicComplete },
+): Promise {
+ const result = await deps.complete({
+ system: SYSTEM,
+ user: [
+ { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: pdfBase64 } },
+ { type: 'text', text: 'Déduis les tokens de design (disposition, couleurs, police) de ce CV.' },
+ ],
+ schema: CV_DESIGN_SCHEMA as unknown as Record,
+ costLabel: 'cv-design-extract',
+ maxTokens: 512,
+ })
+
+ // Validation stricte : couleurs hex, police bornée, layout dans l'enum, défauts sinon.
+ return normalizeDesign(result)
+}
diff --git a/apps/app/server/services/keyskills-adapt.ts b/apps/app/server/services/keyskills-adapt.ts
new file mode 100644
index 0000000..8b1c556
--- /dev/null
+++ b/apps/app/server/services/keyskills-adapt.ts
@@ -0,0 +1,64 @@
+/**
+ * Adapte les « compétences clés » du profil à une offre — SANS inventer.
+ *
+ * Étape de génération : on part des phrases RÉELLES du profil (déjà rédigées par le
+ * candidat) et on les fait reformuler/réordonner/sélectionner par le LLM pour mettre
+ * en avant ce qui compte pour l'offre. Garde-fou anti-invention : le résultat est
+ * borné au NOMBRE de phrases sources (jamais d'ajout) ; en cas d'échec/vide, on
+ * retombe sur les phrases d'origine (verbatim). RGPD : aucun contenu loggé (tokens
+ * mesurés par anthropicComplete).
+ */
+import { type AnalyzedOffer } from '@cvo/shared'
+import { anthropicComplete, type LlmComplete } from '../utils/anthropic'
+
+const SYSTEM = `Tu adaptes les « compétences clés » d'un candidat à une offre, SANS mentir ni inventer.
+On te donne les compétences clés ACTUELLES du candidat (phrases factuelles, déjà rédigées) et l'offre analysée.
+Tu rends "keySkills" : ces mêmes compétences RÉÉCRITES / RÉORDONNÉES / SÉLECTIONNÉES pour mettre en avant
+ce qui compte le plus pour l'offre.
+RÈGLES ABSOLUES :
+- N'ajoute AUCUNE compétence, technologie, chiffre ou outil que les phrases sources n'attestent pas déjà.
+- Tu peux reformuler le style et changer l'ordre, retirer les moins pertinentes, mais JAMAIS en créer de nouvelles non fondées.
+- Chaque phrase reste fidèle au candidat et commence par un verbe d'action.
+- Tu rends AU PLUS autant de phrases que reçues (tu peux en rendre moins), jamais plus.`
+
+const SCHEMA = {
+ type: 'object',
+ additionalProperties: false,
+ properties: { keySkills: { type: 'array', items: { type: 'string' } } },
+ required: ['keySkills'],
+} as const
+
+export interface AdaptKeySkillsDeps {
+ complete: LlmComplete
+}
+
+/**
+ * Renvoie les compétences clés adaptées à l'offre (≤ nombre de phrases fournies,
+ * trim, sans vides). Repli sur `keySkills` d'origine si le LLM ne renvoie rien.
+ */
+export async function adaptKeySkills(
+ keySkills: string[],
+ offer: AnalyzedOffer,
+ deps: AdaptKeySkillsDeps = { complete: anthropicComplete },
+): Promise {
+ if (keySkills.length === 0) return []
+
+ const result = (await deps.complete({
+ system: SYSTEM,
+ user: JSON.stringify({ offreAnalysee: offer, competencesClesActuelles: keySkills }),
+ schema: SCHEMA as unknown as Record,
+ costLabel: 'keyskills-adapt',
+ effort: 'low',
+ maxTokens: 1024,
+ })) as { keySkills?: unknown }
+
+ const adapted = Array.isArray(result.keySkills)
+ ? result.keySkills
+ .filter((s): s is string => typeof s === 'string')
+ .map((s) => s.trim().slice(0, 300))
+ .filter(Boolean)
+ .slice(0, keySkills.length) // jamais plus de phrases que fournies (anti-invention)
+ : []
+
+ return adapted.length ? adapted : keySkills
+}
diff --git a/apps/app/server/services/matching.ts b/apps/app/server/services/matching.ts
index 3f9a1ac..d72e38d 100644
--- a/apps/app/server/services/matching.ts
+++ b/apps/app/server/services/matching.ts
@@ -25,23 +25,155 @@ RÈGLES ABSOLUES :
- Le CONTENU vient EXCLUSIVEMENT du profil candidat fourni. N'invente JAMAIS une compétence, une expérience ou une réalisation absente du profil.
- L'offre sert uniquement à TRIER et REFORMULER : réordonne les sections/éléments par pertinence et reformule les libellés, sans rien ajouter.
- Chaque élément produit DOIT porter "provenance.profileItemId" = l'id EXACT de l'élément profil source. Mets "reformulated": true si tu as reformulé le libellé.
-- N'utilise QUE les id présents dans le profil. Aucun id inventé.`
+- N'utilise QUE les id présents dans le profil. Aucun id inventé.
+- Les langues déclarées (le cas échéant) vont dans une section "languages" ; chaque entrée porte son libellé et son niveau (ex. « Courant », « C1 », « Natif »), sourcée par l'id de la langue.
-/** Provenance attendue sur chaque nœud du CV. */
-const PROVENANCE_SCHEMA = {
- type: 'object',
- additionalProperties: false,
- properties: {
- profileItemId: { type: 'string' },
- reformulated: { type: 'boolean' },
- },
- required: ['profileItemId', 'reformulated'],
-}
+CONCISION (le CV doit tenir sur UNE page A4) :
+- Résumé : 3 phrases maximum (≈ 350 caractères).
+- Par expérience : 2 à 4 puces MAXIMUM, les plus pertinentes pour l'offre. Chaque puce = 1 ligne (≈ 140 caractères max), commence par un verbe d'action, va à l'essentiel (résultat/impact). Pas de phrases à rallonge.
+- Priorise : mets en avant les expériences/compétences pertinentes pour l'offre ; reste synthétique. Mieux vaut court et percutant qu'exhaustif.`
+
+/**
+ * JSON Schema imposé à la sortie du matching (forme de `RenderableCv`).
+ *
+ * ⚠️ Le validateur de sortie structurée d'Anthropic est STRICT : tout objet doit
+ * porter `additionalProperties: false` explicite (cf. mémoire projet). De plus, la
+ * grammaire COMPILÉE a une taille limite : inliner les mêmes sous-schémas partout
+ * (provenance × chaque entrée × 5 variantes de section) fait dépasser cette limite
+ * → « compiled grammar is too large » (400 → 502). On factorise donc via `$defs` +
+ * `$ref` : le compilateur partage les productions et la grammaire reste compacte.
+ *
+ * `prov` = `#/$defs/prov` (référencé partout). Union de sections via `anyOf`.
+ */
+const REF = (name: string) => ({ $ref: `#/$defs/${name}` })
-/** JSON Schema imposé à la sortie du matching (forme de `RenderableCv`). */
const RENDERABLE_CV_SCHEMA = {
type: 'object',
additionalProperties: false,
+ $defs: {
+ prov: {
+ type: 'object',
+ additionalProperties: false,
+ properties: { profileItemId: { type: 'string' }, reformulated: { type: 'boolean' } },
+ required: ['profileItemId', 'reformulated'],
+ },
+ contact: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['email', 'phone', 'location', 'link'] },
+ label: { type: 'string' },
+ value: { type: 'string' },
+ },
+ required: ['kind', 'label', 'value'],
+ },
+ bullet: {
+ type: 'object',
+ additionalProperties: false,
+ properties: { id: { type: 'string' }, text: { type: 'string' }, provenance: REF('prov') },
+ required: ['id', 'text', 'provenance'],
+ },
+ exp: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ id: { type: 'string' },
+ role: { type: 'string' },
+ organization: { type: 'string' },
+ period: { type: 'string' },
+ // `location` optionnel (CvExperienceEntry.location?) → hors `required`.
+ location: { type: 'string' },
+ bullets: { type: 'array', items: REF('bullet') },
+ provenance: REF('prov'),
+ },
+ required: ['id', 'role', 'organization', 'period', 'bullets', 'provenance'],
+ },
+ skill: {
+ type: 'object',
+ additionalProperties: false,
+ properties: { id: { type: 'string' }, label: { type: 'string' }, provenance: REF('prov') },
+ required: ['id', 'label', 'provenance'],
+ },
+ edu: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ id: { type: 'string' },
+ degree: { type: 'string' },
+ institution: { type: 'string' },
+ period: { type: 'string' },
+ provenance: REF('prov'),
+ },
+ required: ['id', 'degree', 'institution', 'period', 'provenance'],
+ },
+ lang: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ id: { type: 'string' },
+ label: { type: 'string' },
+ level: { type: 'string' }, // libellé affiché (ex. « C1 », « Natif »)
+ provenance: REF('prov'),
+ },
+ required: ['id', 'label', 'level', 'provenance'],
+ },
+ // Union discriminée par `kind` (CvSection) — une variante par type de section.
+ section: {
+ anyOf: [
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['summary'] },
+ title: { type: 'string' },
+ text: { type: 'string' },
+ provenance: REF('prov'),
+ },
+ required: ['kind', 'title', 'text', 'provenance'],
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['experience'] },
+ title: { type: 'string' },
+ entries: { type: 'array', items: REF('exp') },
+ },
+ required: ['kind', 'title', 'entries'],
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['skills'] },
+ title: { type: 'string' },
+ entries: { type: 'array', items: REF('skill') },
+ },
+ required: ['kind', 'title', 'entries'],
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['education'] },
+ title: { type: 'string' },
+ entries: { type: 'array', items: REF('edu') },
+ },
+ required: ['kind', 'title', 'entries'],
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['languages'] },
+ title: { type: 'string' },
+ entries: { type: 'array', items: REF('lang') },
+ },
+ required: ['kind', 'title', 'entries'],
+ },
+ ],
+ },
+ },
properties: {
locale: { type: 'string', enum: ['fr'] },
header: {
@@ -50,12 +182,12 @@ const RENDERABLE_CV_SCHEMA = {
properties: {
fullName: { type: 'string' },
headline: { type: 'string' },
- contacts: { type: 'array', items: { type: 'object' } },
- provenance: PROVENANCE_SCHEMA,
+ contacts: { type: 'array', items: REF('contact') },
+ provenance: REF('prov'),
},
required: ['fullName', 'headline', 'contacts', 'provenance'],
},
- sections: { type: 'array', items: { type: 'object' } },
+ sections: { type: 'array', items: REF('section') },
},
required: ['locale', 'header', 'sections'],
} as const
@@ -64,12 +196,13 @@ export interface MatchDeps {
complete: LlmComplete
}
-/** Ids réels du profil : header + chaque expérience / compétence / formation. */
+/** Ids réels du profil : header + chaque expérience / compétence / formation / langue. */
export function collectProfileItemIds(profile: ProfileDTO): Set {
const ids = new Set([profile.id])
for (const e of profile.experiences) ids.add(e.id)
for (const s of profile.skills) ids.add(s.id)
for (const ed of profile.education) ids.add(ed.id)
+ for (const l of profile.languages) ids.add(l.id)
return ids
}
@@ -82,9 +215,13 @@ export async function matchProfileToOffer(
offer: AnalyzedOffer,
deps: MatchDeps = { complete: anthropicComplete },
): Promise {
+ // keySkills (phrases) est injecté DÉTERMINISTE-ment côté generate.post (au-dessus
+ // des expériences) → on le retire du payload pour que le LLM ne les recopie pas
+ // dans skills/summary (doublon sémantique).
+ const { keySkills: _keySkills, ...profilReel } = profile
const user = JSON.stringify({
instructionsDeTri: offer,
- profilReel: profile,
+ profilReel,
})
const cv = (await deps.complete({
@@ -93,10 +230,31 @@ export async function matchProfileToOffer(
schema: RENDERABLE_CV_SCHEMA as unknown as Record,
costLabel: 'matching',
effort: 'medium',
- maxTokens: 8192,
+ // Profils riches (nombreuses expériences/compétences/langues) → sortie longue.
+ maxTokens: 16000,
})) as RenderableCv
+ // Plafond déterministe (garantie « 1 page », indépendant du LLM) : on borne le
+ // nombre d'expériences et de puces. On supprime, jamais on n'invente → provenance
+ // intacte sur ce qui reste.
+ capCvLength(cv)
+
// §3 — garde-fou déterministe : rejette tout élément non sourcé (hors LLM).
assertValidCv(cv, collectProfileItemIds(profile))
return cv
}
+
+/** Bornes de longueur pour viser un CV sur une seule page A4. */
+const MAX_EXPERIENCES = 6
+const MAX_BULLETS_PER_EXPERIENCE = 4
+
+/** Tronque (en place) expériences et puces aux bornes ci-dessus. Ne fait que retirer. */
+function capCvLength(cv: RenderableCv): void {
+ for (const section of cv.sections) {
+ if (section.kind !== 'experience') continue
+ section.entries = section.entries.slice(0, MAX_EXPERIENCES)
+ for (const entry of section.entries) {
+ entry.bullets = entry.bullets.slice(0, MAX_BULLETS_PER_EXPERIENCE)
+ }
+ }
+}
diff --git a/apps/app/server/utils/anthropic.ts b/apps/app/server/utils/anthropic.ts
index 1bd03ef..9f766f8 100644
--- a/apps/app/server/utils/anthropic.ts
+++ b/apps/app/server/utils/anthropic.ts
@@ -21,11 +21,24 @@ const PRICING: Record = {
'claude-opus-4-8': { in: 5, out: 25 },
}
+/**
+ * Bloc de contenu d'un message utilisateur. Permet d'envoyer un document (PDF)
+ * ou une image en plus du texte. Forme API Messages : le bloc `document`/`image`
+ * doit précéder le bloc texte. PDF en base64 : pas de beta header requis.
+ */
+export type LlmContentBlock =
+ | { type: 'text'; text: string }
+ | { type: 'document'; source: { type: 'base64'; media_type: 'application/pdf'; data: string } }
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
+
export interface LlmRequest {
/** Consignes système (rôle/tâche). Pas de contenu CV au-delà du strict nécessaire. */
system: string
- /** Message utilisateur : données structurées (profil réel + offre analysée). */
- user: string
+ /**
+ * Message utilisateur : soit une string (cas courant : données structurées),
+ * soit une liste de blocs (texte + document PDF / image) pour la vision.
+ */
+ user: string | LlmContentBlock[]
/** JSON Schema imposé à la sortie. */
schema: Record
/** Étiquette de coût pour le log (ex. `offer-analysis`). JAMAIS de contenu. */
@@ -100,7 +113,22 @@ export const anthropicComplete: LlmComplete = async (req) => {
},
body: JSON.stringify(body),
})
- if (!res.ok) throw new LlmError(`Claude API ${res.status}`)
+ if (!res.ok) {
+ // Le corps d'erreur de l'API décrit le problème (schéma invalide, grammaire
+ // trop grande, etc.) — RGPD-safe (aucun contenu utilisateur). On le logge et
+ // on l'inclut dans l'erreur pour rendre les 502 diagnosticables.
+ let detail = ''
+ try {
+ const errBody = (await res.json()) as { error?: { message?: string } }
+ detail = errBody?.error?.message ?? ''
+ } catch {
+ /* corps non-JSON : on garde juste le statut */
+ }
+ console.warn(
+ JSON.stringify({ event: 'llm_error', label: req.costLabel, model, status: res.status, detail }),
+ )
+ throw new LlmError(`Claude API ${res.status}${detail ? `: ${detail}` : ''}`)
+ }
const data = (await res.json()) as {
stop_reason?: string
diff --git a/apps/app/server/utils/candidature-serialize.ts b/apps/app/server/utils/candidature-serialize.ts
new file mode 100644
index 0000000..33fc3e9
--- /dev/null
+++ b/apps/app/server/utils/candidature-serialize.ts
@@ -0,0 +1,54 @@
+/**
+ * Sérialisation Candidature (Prisma → DTO transport). Fonctions pures → testables
+ * sans base. Dates en ISO ; design re-normalisé ; gros JSON castés vers les types
+ * partagés. Les lignes soft-delete ne sont jamais exposées (filtre dans le where).
+ */
+import type {
+ AnalyzedOffer,
+ CandidatureDTO,
+ CandidatureListItemDTO,
+ CandidatureStatus,
+ MatchReport,
+ RenderableCv,
+} from '@cvo/shared'
+import { toIso } from './profile-serialize'
+import { normalizeDesign } from './cv-design-tokens'
+
+/** Forme Prisma minimale (sous-ensemble du modèle Candidature). */
+export interface CandidatureRow {
+ id: string
+ label: string
+ status: CandidatureStatus
+ matchScore: number
+ offerSnapshot: unknown
+ matchReport: unknown
+ generatedCv: unknown
+ design: unknown
+ createdAt: Date
+ updatedAt: Date
+}
+
+/** Vue liste (légère) — pas besoin des gros JSON. */
+export function toCandidatureListItemDTO(
+ row: Pick,
+): CandidatureListItemDTO {
+ return {
+ id: row.id,
+ label: row.label,
+ status: row.status,
+ matchScore: row.matchScore,
+ createdAt: toIso(row.createdAt)!,
+ updatedAt: toIso(row.updatedAt)!,
+ }
+}
+
+/** Vue complète (éditeur). */
+export function toCandidatureDTO(row: CandidatureRow): CandidatureDTO {
+ return {
+ ...toCandidatureListItemDTO(row),
+ offer: row.offerSnapshot as AnalyzedOffer,
+ match: row.matchReport as MatchReport,
+ cv: row.generatedCv as RenderableCv,
+ design: row.design ? normalizeDesign(row.design) : null,
+ }
+}
diff --git a/apps/app/server/utils/cv-design-tokens.ts b/apps/app/server/utils/cv-design-tokens.ts
new file mode 100644
index 0000000..acd7529
--- /dev/null
+++ b/apps/app/server/utils/cv-design-tokens.ts
@@ -0,0 +1,90 @@
+/**
+ * Validation/normalisation des tokens de design « CV de base ».
+ *
+ * Les valeurs viennent d'une extraction LLM (donnée non fiable) ou d'anciens
+ * designs stockés (ancienne forme `{ css }`). On normalise toujours vers un
+ * `CvDesign` sûr : couleurs hex validées, police bornée, layout dans l'enum,
+ * sinon valeurs par défaut. Aucun CSS brut n'est jamais accepté.
+ */
+import type { CvDesign, CvLayout } from '@cvo/shared'
+
+/** Design par défaut (marque Teven) — utilisé sans « CV de base » configuré. */
+export const DEFAULT_DESIGN: CvDesign = {
+ layout: 'single',
+ accent: '#4f46e5',
+ sidebarBg: '#1f2937',
+ sidebarFg: '#f9fafb',
+ sidebarRadius: 0,
+ photoPosition: 'header-right',
+ photoSize: 128, // px
+ photoMargin: 14,
+ photoPadding: 0,
+ font: null,
+ photo: null,
+ summary: '',
+}
+
+/** Positions de photo autorisées. */
+const PHOTO_POSITIONS = ['header-right', 'header-left', 'sidebar'] as const
+
+/** Borne un nombre dans [min, max] (fallback si non numérique). */
+function sanitizeNumber(value: unknown, min: number, max: number, fallback: number): number {
+ const n = typeof value === 'number' ? value : Number(value)
+ if (!Number.isFinite(n)) return fallback
+ return Math.min(max, Math.max(min, n))
+}
+
+/** Plafond de la photo (data-URL) — ~700 Ko de base64 ≈ ~500 Ko décodés. */
+const MAX_PHOTO_CHARS = 700_000
+/** N'autorise que des images raster en data-URL base64 (pas de SVG → pas de script). */
+const DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp);base64,[A-Za-z0-9+/=]+$/
+
+/**
+ * Renvoie une photo en data-URL raster valide et bornée, ou null. Rejette les
+ * URL distantes, le SVG (vecteur de script) et tout dépassement de taille.
+ */
+export function sanitizeDataImage(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const v = value.trim()
+ if (v.length === 0 || v.length > MAX_PHOTO_CHARS) return null
+ return DATA_IMAGE.test(v) ? v : null
+}
+
+const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
+
+/** Renvoie une couleur hex valide, ou le fallback. */
+export function sanitizeColor(value: unknown, fallback: string): string {
+ return typeof value === 'string' && HEX.test(value.trim()) ? value.trim() : fallback
+}
+
+/** Renvoie un nom de police « propre » (lettres/chiffres/espaces/-), ou null. */
+export function sanitizeFont(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const cleaned = value.trim().slice(0, 40)
+ return /^[A-Za-z0-9 \-]+$/.test(cleaned) && cleaned.length > 0 ? cleaned : null
+}
+
+function sanitizeLayout(value: unknown): CvLayout {
+ return value === 'sidebar-left' ? 'sidebar-left' : 'single'
+}
+
+/** Normalise un objet quelconque (extraction LLM ou ancien stockage) en CvDesign sûr. */
+export function normalizeDesign(raw: unknown): CvDesign {
+ const r = (raw && typeof raw === 'object' ? raw : {}) as Record
+ return {
+ layout: sanitizeLayout(r.layout),
+ accent: sanitizeColor(r.accent, DEFAULT_DESIGN.accent),
+ sidebarBg: sanitizeColor(r.sidebarBg, DEFAULT_DESIGN.sidebarBg),
+ sidebarFg: sanitizeColor(r.sidebarFg, DEFAULT_DESIGN.sidebarFg),
+ sidebarRadius: sanitizeNumber(r.sidebarRadius, 0, 12, 0),
+ photoPosition: PHOTO_POSITIONS.includes(r.photoPosition as (typeof PHOTO_POSITIONS)[number])
+ ? (r.photoPosition as CvDesign['photoPosition'])
+ : 'header-right',
+ photoSize: sanitizeNumber(r.photoSize, 64, 360, 128), // px
+ photoMargin: sanitizeNumber(r.photoMargin, 0, 16, 14),
+ photoPadding: sanitizeNumber(r.photoPadding, 0, 8, 0),
+ font: sanitizeFont(r.font),
+ photo: sanitizeDataImage(r.photo),
+ summary: typeof r.summary === 'string' ? r.summary.trim().slice(0, 500) : '',
+ }
+}
diff --git a/apps/app/server/utils/cv-design.ts b/apps/app/server/utils/cv-design.ts
new file mode 100644
index 0000000..27da0ce
--- /dev/null
+++ b/apps/app/server/utils/cv-design.ts
@@ -0,0 +1,26 @@
+/**
+ * Charge le thème « CV de base » de l'utilisateur courant (ou null).
+ * Mutualisé entre l'export PDF et l'aperçu pour garantir un rendu identique.
+ */
+import type { H3Event } from 'h3'
+import type { CvDesign } from '@cvo/shared'
+import { prisma } from './prisma'
+import { NOT_DELETED } from './profile-serialize'
+import { normalizeDesign } from './cv-design-tokens'
+
+/**
+ * Tokens de design « CV de base » de l'utilisateur courant, ou null (→ défaut).
+ *
+ * NON bloquant : si la requête n'est pas authentifiée (ex. page démo publique),
+ * on renvoie null → gabarit par défaut. Les tokens stockés sont re-normalisés
+ * (tolère un ancien format) avant rendu.
+ */
+export async function loadBaseCvDesign(event: H3Event): Promise {
+ const userId = event.context.userId
+ if (!userId) return null
+ const profile = await prisma.profile.findFirst({
+ where: { userId, ...NOT_DELETED },
+ select: { baseCvDesign: true },
+ })
+ return profile?.baseCvDesign ? normalizeDesign(profile.baseCvDesign) : null
+}
diff --git a/apps/app/server/utils/cv-html.ts b/apps/app/server/utils/cv-html.ts
index 6e23ae0..f26bee4 100644
--- a/apps/app/server/utils/cv-html.ts
+++ b/apps/app/server/utils/cv-html.ts
@@ -1,53 +1,85 @@
/**
- * 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).
+ * Rendu serveur d'un RenderableCv en document HTML A4 auto-suffisant (export PDF
+ * + aperçu iframe). Le STYLE est un GABARIT CODÉ et testé à l'impression — pas du
+ * CSS généré par un LLM. On l'adapte au CV de l'utilisateur via des TOKENS
+ * (`CvDesign` : layout, accent, couleurs sidebar, police) injectés en variables
+ * CSS. Résultat : toujours propre, 1 page si le contenu le permet, proportions
+ * maîtrisées. Le contenu reste fourni par le moteur (provenance garantie).
*
- * 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
+ ${googleFontLink(d.font)}
+
-
-
-
-${sectionsHtml}
-
+
+
+ ${mainHtml}