From 2a2eb5d9de5966eb934f139af3e0faa98ea0f7be Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Sun, 21 Jun 2026 20:45:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(candidatures):=20=C3=A9diteur=20de=20CV=20?= =?UTF-8?q?in-app,=20suivi,=20design=20par-CV=20&=20comp=C3=A9tences=20cl?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parcours complet : import/profil → offre → match → génération → édition → export, avec persistance des candidatures (plus de perte au changement d'onglet). Persistance & suivi - Modèle Candidature (statuts brouillon→postulé→entretien→refusé/accepté), endpoints list/detail/patch/delete, page de suivi /candidatures. - La génération persiste le CV et redirige vers l'éditeur. Éditeur in-app (/candidatures/:id) - Édition contenu (sections, textes, ordre, ajout/suppression) provenance-safe, aperçu live débouncé, sauvegarde, statut, export PDF, suppression. - Apparence : disposition, couleurs, police, et tokens photo (position, taille en px, marge, padding, rayon sidebar). - Recadrage interactif de la photo (déplacer + zoomer), sortie nette. Compétences clés (phrases verbe d'action) - Stockées au profil, extraites du CV importé, injectées au-dessus des expériences et ADAPTÉES à l'offre sans rien inventer (repli verbatim). Profil - Identité complète, langues, formations, « CV de base » (design + contenu) extrait d'un PDF ; import dédupliqué (plus de doublons au ré-import). Rendu - Gabarit maîtrisé + tokens (cv-html), export forcé sur 1 page (cut), élagage des sections vides, validation/bornage des tokens côté serveur. Tests : 118 (app) + 6 (shared) ; typecheck clean. Page /ui supprimée. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + apps/app/.env.example | 4 + apps/app/components/cv/CvDesignPanel.vue | 104 ++ apps/app/components/cv/CvPhotoInput.vue | 249 ++++ apps/app/components/cv/CvTemplate.vue | 34 + apps/app/layouts/default.vue | 17 +- apps/app/pages/candidature.vue | 114 +- apps/app/pages/candidatures/[id].vue | 611 ++++++++++ apps/app/pages/candidatures/index.vue | 106 ++ apps/app/pages/profil.vue | 1004 ++++++++++++++++- apps/app/pages/ui.vue | 373 ------ .../migration.sql | 6 + .../migration.sql | 25 + .../migration.sql | 2 + .../20260619122800_candidatures/migration.sql | 26 + .../migration.sql | 2 + apps/app/prisma/schema.prisma | 75 ++ .../server/api/candidature/analyze.post.ts | 1 + .../server/api/candidature/generate.post.ts | 117 +- .../server/api/candidatures/[id].delete.ts | 21 + apps/app/server/api/candidatures/[id].get.ts | 19 + .../app/server/api/candidatures/[id].patch.ts | 54 + apps/app/server/api/candidatures/index.get.ts | 18 + apps/app/server/api/cv/export-pdf.post.ts | 60 +- apps/app/server/api/cv/preview.post.ts | 19 + .../server/api/profile/cv-design.delete.ts | 20 + apps/app/server/api/profile/cv-design.put.ts | 27 + .../api/profile/cv-design/extract.post.ts | 72 ++ apps/app/server/api/profile/education.post.ts | 44 + .../api/profile/education/[id].delete.ts | 22 + .../server/api/profile/experiences.post.ts | 2 +- apps/app/server/api/profile/index.get.ts | 1 + apps/app/server/api/profile/index.put.ts | 30 +- apps/app/server/api/profile/languages.post.ts | 41 + .../api/profile/languages/[id].delete.ts | 22 + apps/app/server/api/usage/current.get.ts | 10 +- .../app/server/services/cv-content-extract.ts | 162 +++ apps/app/server/services/cv-design-extract.ts | 60 + apps/app/server/services/keyskills-adapt.ts | 64 ++ apps/app/server/services/matching.ts | 194 +++- apps/app/server/utils/anthropic.ts | 34 +- .../app/server/utils/candidature-serialize.ts | 54 + apps/app/server/utils/cv-design-tokens.ts | 90 ++ apps/app/server/utils/cv-design.ts | 26 + apps/app/server/utils/cv-html.ts | 282 +++-- apps/app/server/utils/cv-render-input.ts | 78 ++ apps/app/server/utils/pdf.ts | 21 +- apps/app/server/utils/profile-serialize.ts | 30 +- apps/app/test/candidature-serialize.spec.ts | 46 + apps/app/test/cv-content-extract.spec.ts | 50 + apps/app/test/cv-design-extract.spec.ts | 46 + apps/app/test/cv-design-tokens.spec.ts | 71 ++ apps/app/test/cv-html.spec.ts | 158 ++- apps/app/test/cv-render-input.spec.ts | 65 ++ apps/app/test/keyskills-adapt.spec.ts | 40 + apps/app/test/match-contracts.spec.ts | 8 + apps/app/test/match-report.spec.ts | 8 + apps/app/test/matching.spec.ts | 8 + apps/app/test/photo-crop.spec.ts | 44 + apps/app/test/profile-serialize.spec.ts | 17 + apps/app/utils/photo-crop.ts | 48 + packages/shared/src/candidature.ts | 56 + packages/shared/src/cv.ts | 59 + packages/shared/src/fixtures/sample-cv.ts | 2 +- packages/shared/src/index.ts | 15 +- packages/shared/src/match.ts | 14 +- packages/shared/src/offer.ts | 7 +- packages/shared/src/profile.ts | 70 ++ packages/shared/src/provenance.ts | 4 +- 69 files changed, 4604 insertions(+), 680 deletions(-) create mode 100644 apps/app/components/cv/CvDesignPanel.vue create mode 100644 apps/app/components/cv/CvPhotoInput.vue create mode 100644 apps/app/pages/candidatures/[id].vue create mode 100644 apps/app/pages/candidatures/index.vue delete mode 100644 apps/app/pages/ui.vue create mode 100644 apps/app/prisma/migrations/20260619075016_profile_identity/migration.sql create mode 100644 apps/app/prisma/migrations/20260619082630_profile_languages/migration.sql create mode 100644 apps/app/prisma/migrations/20260619083557_profile_base_cv_design/migration.sql create mode 100644 apps/app/prisma/migrations/20260619122800_candidatures/migration.sql create mode 100644 apps/app/prisma/migrations/20260619133422_profile_key_skills/migration.sql create mode 100644 apps/app/server/api/candidatures/[id].delete.ts create mode 100644 apps/app/server/api/candidatures/[id].get.ts create mode 100644 apps/app/server/api/candidatures/[id].patch.ts create mode 100644 apps/app/server/api/candidatures/index.get.ts create mode 100644 apps/app/server/api/cv/preview.post.ts create mode 100644 apps/app/server/api/profile/cv-design.delete.ts create mode 100644 apps/app/server/api/profile/cv-design.put.ts create mode 100644 apps/app/server/api/profile/cv-design/extract.post.ts create mode 100644 apps/app/server/api/profile/education.post.ts create mode 100644 apps/app/server/api/profile/education/[id].delete.ts create mode 100644 apps/app/server/api/profile/languages.post.ts create mode 100644 apps/app/server/api/profile/languages/[id].delete.ts create mode 100644 apps/app/server/services/cv-content-extract.ts create mode 100644 apps/app/server/services/cv-design-extract.ts create mode 100644 apps/app/server/services/keyskills-adapt.ts create mode 100644 apps/app/server/utils/candidature-serialize.ts create mode 100644 apps/app/server/utils/cv-design-tokens.ts create mode 100644 apps/app/server/utils/cv-design.ts create mode 100644 apps/app/server/utils/cv-render-input.ts create mode 100644 apps/app/test/candidature-serialize.spec.ts create mode 100644 apps/app/test/cv-content-extract.spec.ts create mode 100644 apps/app/test/cv-design-extract.spec.ts create mode 100644 apps/app/test/cv-design-tokens.spec.ts create mode 100644 apps/app/test/cv-render-input.spec.ts create mode 100644 apps/app/test/keyskills-adapt.spec.ts create mode 100644 apps/app/test/photo-crop.spec.ts create mode 100644 apps/app/utils/photo-crop.ts create mode 100644 packages/shared/src/candidature.ts diff --git a/.gitignore b/.gitignore index acef788..394abb2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .pnpm-store/ # Nuxt +.nuxt/ apps/app/.nuxt/ apps/app/.output/ apps/app/.data/ diff --git a/apps/app/.env.example b/apps/app/.env.example index df25a2f..f232577 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -14,6 +14,10 @@ APP_URL=http://localhost:3000 # (THI-124 / flux candidature). Sans cette clé, /api/candidature/* répond 502. ANTHROPIC_API_KEY=sk-ant-changeme +# Dev uniquement : si "true", désactive le quota de générations gratuites +# (le modèle freemium reste codé ; à ne PAS activer en prod). +# DISABLE_GENERATION_LIMIT=true + # SMTP (optionnel en dev — si absent, le magic-link est loggé dans la console). # SMTP_HOST=smtp.example.fr # SMTP_PORT=587 diff --git a/apps/app/components/cv/CvDesignPanel.vue b/apps/app/components/cv/CvDesignPanel.vue new file mode 100644 index 0000000..2d37b9e --- /dev/null +++ b/apps/app/components/cv/CvDesignPanel.vue @@ -0,0 +1,104 @@ + + + diff --git a/apps/app/components/cv/CvPhotoInput.vue b/apps/app/components/cv/CvPhotoInput.vue new file mode 100644 index 0000000..327217d --- /dev/null +++ b/apps/app/components/cv/CvPhotoInput.vue @@ -0,0 +1,249 @@ + + + diff --git a/apps/app/components/cv/CvTemplate.vue b/apps/app/components/cv/CvTemplate.vue index 0624804..50f7be9 100644 --- a/apps/app/components/cv/CvTemplate.vue +++ b/apps/app/components/cv/CvTemplate.vue @@ -18,6 +18,12 @@ function isEducation(s: CvSection): s is Extract { return s.kind === 'summary' } +function isKeySkills(s: CvSection): s is Extract { + return s.kind === 'keyskills' +} +function isLanguages(s: CvSection): s is Extract { + return s.kind === 'languages' +} - - - Design system - diff --git a/apps/app/pages/candidature.vue b/apps/app/pages/candidature.vue index 96d44bb..39e95ef 100644 --- a/apps/app/pages/candidature.vue +++ b/apps/app/pages/candidature.vue @@ -3,8 +3,8 @@ import type { AnalyzeCandidatureRequest, AnalyzeCandidatureResponse, GenerateCandidatureRequest, + GenerateCandidatureResponse, ProfileDTO, - RenderableCv, UsageSummary, } from '@cvo/shared' import { @@ -15,7 +15,7 @@ import { matchVerdict, } from '@cvo/shared' import type { MatchVerdict } from '@cvo/shared' -import { ChevronRight, Download, RotateCcw, ShieldCheck, TriangleAlert } from '@lucide/vue' +import { ChevronRight, TriangleAlert } from '@lucide/vue' definePageMeta({ middleware: 'auth' }) @@ -23,13 +23,14 @@ const toast = useToast() // ─── Machine à états du parcours ───────────────────────────────────────────── -type Step = 'input' | 'analyzing' | 'scored' | 'generating' | 'done' +// Le parcours s'arrête à `generating` : au succès on redirige vers l'éditeur +// (/candidatures/:id) — le CV est persisté, donc plus de perte au changement d'onglet. +type Step = 'input' | 'analyzing' | 'scored' | 'generating' const step = ref('input') const offerText = ref('') const offerError = ref('') const analysis = ref(null) -const generatedCv = ref(null) // ─── Quota de générations (badge + gating des boutons) ─────────────────────── @@ -149,15 +150,18 @@ async function generate() { if (!analysis.value || quotaExhausted.value || step.value === 'generating') return step.value = 'generating' try { - const body: GenerateCandidatureRequest = { offer: analysis.value.offer } - const res = await $fetch<{ cv: RenderableCv }>(CANDIDATURE_GENERATE_PATH, { + const body: GenerateCandidatureRequest = { + offer: analysis.value.offer, + match: analysis.value.match, + } + const res = await $fetch(CANDIDATURE_GENERATE_PATH, { method: 'POST', body, }) - generatedCv.value = res.cv - step.value = 'done' - toast.success('CV généré', "Ton CV adapté à l'offre est prêt.") await refreshUsage() + toast.success('CV généré', 'Ton CV est prêt — tu peux le personnaliser.') + // Persisté en base : on ouvre l'éditeur (le CV survit aux changements d'onglet). + await navigateTo(`/candidatures/${res.candidatureId}`) } catch (err) { step.value = 'scored' const status = errorStatus(err) @@ -167,66 +171,29 @@ async function generate() { toast.error('Quota atteint', 'Tu as épuisé tes générations offertes.') } else if (status === 422) { toast.error('Génération impossible', errorMessage(err, 'Réessaie dans quelques instants.')) + } else if (status === 409) { + // Profil incomplet (vide ou nom manquant) : on relaie le message serveur, + // qui guide vers la page profil. Aucun crédit consommé. + toast.error('Profil à compléter', errorMessage(err, 'Complète ton profil puis réessaie.')) } else { toast.error('La génération a échoué', "Ton crédit n'a pas été consommé.") } } } -// ─── Étape 3 : export PDF + nouvelle candidature ───────────────────────────── - -const exporting = ref(false) - -async function exportPdf() { - if (!generatedCv.value) return - exporting.value = true - try { - const res = await $fetch('/api/cv/export-pdf', { - method: 'POST', - body: generatedCv.value, - responseType: 'arrayBuffer', - }) - const blob = new Blob([res], { type: 'application/pdf' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'cv-teven.pdf' - a.click() - URL.revokeObjectURL(url) - } catch { - toast.error( - 'Export impossible', - "Le PDF n'a pas pu être généré. Réessaie dans quelques instants.", - ) - } finally { - exporting.value = false - } -} - -async function resetFlow() { - offerText.value = '' - offerError.value = '' - analysis.value = null - generatedCv.value = null - step.value = 'input' - await refreshUsage() -} - -// ─── A11y : focus déplacé sur le titre du résultat à chaque étape clé ──────── +// ─── A11y : focus déplacé sur le titre du résultat à l'étape « scored » ────── const scoredHeadingRef = ref(null) -const doneHeadingRef = ref(null) watch(step, async (s) => { - if (s !== 'scored' && s !== 'done') return + if (s !== 'scored') return await nextTick() - const target = s === 'scored' ? scoredHeadingRef.value : doneHeadingRef.value - target?.focus() + scoredHeadingRef.value?.focus() })