From 541060fd9704ce334e36ed2c269de46f0a017e45 Mon Sep 17 00:00:00 2001 From: Lead Dev Date: Tue, 9 Jun 2026 12:21:52 +0200 Subject: [PATCH 1/2] feat(profil): API profil candidat + serializer soft-delete (THI-132, slice 3a/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints Nitro CRUD profil + import par copier-coller (MVP sans LLM). Serializer pur (testé unitairement), helper session H3/Better Auth. - GET/PUT /api/profile — lecture/upsert en-tête (headline, summary) - POST /api/profile/experiences — ajout + soft-delete DELETE /:id - POST /api/profile/skills — ajout + soft-delete DELETE /:id (SkillLevel validé) - POST /api/profile/import-text — parseur heuristique texte brut (suggestion uniquement, aucune écriture en base avant confirmation utilisateur — garde-fou produit) - server/utils/profile-serialize.ts — toProfileDTO + NOT_DELETED + activeOrdered (fonctions pures, filtre soft-delete, tri orderIndex) - server/utils/session.ts — getAuthSession(event) : H3 → Better Auth via toWebRequest - test/profile-serialize.spec.ts — 5 cas (NOT_DELETED, toIso, activeOrdered, toProfileDTO) - zod installé pour validation des corps de requête QA locale : lint ✓ · typecheck TS strict ✓ · build Nitro ✓ · test 11 ✓ Co-Authored-By: Paperclip --- apps/app/package.json | 3 + .../server/api/profile/experiences.post.ts | 46 +++++++ .../api/profile/experiences/[id].delete.ts | 24 ++++ .../server/api/profile/import-text.post.ts | 122 ++++++++++++++++++ apps/app/server/api/profile/index.get.ts | 25 ++++ apps/app/server/api/profile/index.put.ts | 44 +++++++ apps/app/server/api/profile/skills.post.ts | 43 ++++++ .../server/api/profile/skills/[id].delete.ts | 22 ++++ apps/app/server/utils/profile-serialize.ts | 108 ++++++++++++++++ apps/app/server/utils/session.ts | 20 ++- apps/app/test/profile-serialize.spec.ts | 92 +++++++++++++ 11 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 apps/app/server/api/profile/experiences.post.ts create mode 100644 apps/app/server/api/profile/experiences/[id].delete.ts create mode 100644 apps/app/server/api/profile/import-text.post.ts create mode 100644 apps/app/server/api/profile/index.get.ts create mode 100644 apps/app/server/api/profile/index.put.ts create mode 100644 apps/app/server/api/profile/skills.post.ts create mode 100644 apps/app/server/api/profile/skills/[id].delete.ts create mode 100644 apps/app/server/utils/profile-serialize.ts create mode 100644 apps/app/test/profile-serialize.spec.ts diff --git a/apps/app/package.json b/apps/app/package.json index eb9131f..2aa4de6 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -18,12 +18,15 @@ "dependencies": { "@cvo/shared": "workspace:*", "@prisma/client": "^6.2.1", + "better-auth": "^1.6.15", + "nodemailer": "^8.0.10", "playwright-core": "^1.60.0", "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@types/node": "^22.10.5", + "@types/nodemailer": "^8.0.0", "nuxt": "^3.15.0", "prisma": "^6.2.1", "tailwindcss": "^4.0.0", diff --git a/apps/app/server/api/profile/experiences.post.ts b/apps/app/server/api/profile/experiences.post.ts new file mode 100644 index 0000000..5a7b622 --- /dev/null +++ b/apps/app/server/api/profile/experiences.post.ts @@ -0,0 +1,46 @@ +/** + * POST /api/profile/experiences — Ajouter une expérience 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({ + title: z.string().min(1).max(150), + 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(), + skillsUsed: z.array(z.string().max(80)).max(30).default([]), + 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. Initialisez-le d\'abord via PUT /api/profile.' }) + + 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 { title, company, startDate, endDate, description, skillsUsed, orderIndex } = parsed.data + + const exp = await prisma.experience.create({ + data: { + profileId: profile.id, + title, + company, + startDate: startDate ? new Date(startDate) : null, + endDate: endDate ? new Date(endDate) : null, + description: description ?? null, + skillsUsed, + orderIndex, + }, + }) + + return { id: exp.id } +}) diff --git a/apps/app/server/api/profile/experiences/[id].delete.ts b/apps/app/server/api/profile/experiences/[id].delete.ts new file mode 100644 index 0000000..704de1c --- /dev/null +++ b/apps/app/server/api/profile/experiences/[id].delete.ts @@ -0,0 +1,24 @@ +/** + * DELETE /api/profile/experiences/:id — Soft-delete d'une expérience. + * RGPD : soft-delete uniquement (la ligne reste pour le job de purge). + */ +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.' }) + + // Vérifier l'ownership via le profil + const exp = await prisma.experience.findFirst({ + where: { id, ...NOT_DELETED, profile: { userId: session.user.id, ...NOT_DELETED } }, + }) + if (!exp) throw createError({ statusCode: 404, message: 'Expérience introuvable.' }) + + await prisma.experience.update({ where: { id }, data: { deletedAt: new Date() } }) + return { ok: true } +}) diff --git a/apps/app/server/api/profile/import-text.post.ts b/apps/app/server/api/profile/import-text.post.ts new file mode 100644 index 0000000..28560b6 --- /dev/null +++ b/apps/app/server/api/profile/import-text.post.ts @@ -0,0 +1,122 @@ +/** + * POST /api/profile/import-text — Import profil par copier-coller de texte (MVP). + * + * Le texte collé est parsé de façon déterministe (pas de LLM) en blocs heuristiques. + * Le résultat est retourné comme suggestion — l'utilisateur confirme avant que les + * données soient persistées (guard : il appelle ensuite PUT /api/profile + POST skills). + * + * Garde-fou : on ne crée rien en base ici. On retourne uniquement les données parsées + * pour revue dans l'UI. L'utilisateur est la source de vérité. + */ +import { z } from 'zod' +import { getAuthSession } from '../../utils/session' +import type { ExperienceDTO, SkillDTO } from '@cvo/shared' + +const bodySchema = z.object({ text: z.string().min(10).max(10_000) }) + +interface ParsedProfile { + headline: string | null + summary: string | null + experiences: Omit[] + skills: Omit[] +} + +/** + * Parseur heuristique minimal (MVP) : + * - Lignes débutant par des marqueurs d'expérience (•, -, *, dates YYYY) → experiences + * - Tokens ressemblant à des compétences (courts, sans ponctuation lourde) → skills + * - Reste → headline / summary + * + * L'extraction visuelle d'un CV PDF est hors scope MVP (→ V1.1 THI-120 §4c). + */ +function parseProfileText(text: string): ParsedProfile { + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) + + const experiences: Omit[] = [] + const skillSet = new Set() + const summaryLines: string[] = [] + + // Heuristiques simples + const expLineRe = /^(\d{4})\s*[-–]\s*(\d{4}|présent|aujourd'hui|actuel|en cours)/i + const bulletRe = /^[•\-*]\s+(.+)/ + + let idx = 0 + let orderIndex = 0 + + while (idx < lines.length) { + const line = lines[idx]! + + // Ligne de type "YYYY – YYYY Titre | Entreprise" + const expMatch = line.match(expLineRe) + if (expMatch) { + const rest = line.slice(line.indexOf(expMatch[2]!) + expMatch[2]!.length).trim() + const parts = rest.split(/\s{2,}|\s*\|\s*/) + experiences.push({ + title: parts[0] ?? rest, + company: parts[1] ?? '', + startDate: expMatch[1] ? `${expMatch[1]}-01-01T00:00:00.000Z` : null, + endDate: expMatch[2]?.match(/\d{4}/) ? `${expMatch[2]}-12-31T00:00:00.000Z` : null, + description: null, + skillsUsed: [], + orderIndex: orderIndex++, + }) + idx++ + continue + } + + // Bullet → potentielle compétence ou description + const bulletMatch = line.match(bulletRe) + if (bulletMatch) { + const content = bulletMatch[1]! + // Court (<= 40 chars sans virgule) → compétence candidate + if (content.length <= 40 && !content.includes(',')) { + skillSet.add(content) + } else if (experiences.length > 0) { + // Ajouter à la description de la dernière expérience + const last = experiences[experiences.length - 1]! + last.description = last.description ? `${last.description}\n${content}` : content + } else { + summaryLines.push(content) + } + idx++ + continue + } + + // Ligne avec des virgules → liste de compétences + if (line.includes(',') && line.length < 200) { + line.split(',').forEach((s) => { + const t = s.trim() + if (t && t.length <= 60) skillSet.add(t) + }) + idx++ + continue + } + + summaryLines.push(line) + idx++ + } + + const skills: Omit[] = Array.from(skillSet).map((label, i) => ({ + label, + level: null, + years: null, + orderIndex: i, + })) + + const headline = summaryLines[0] ?? null + const summary = summaryLines.slice(1).join(' ').trim() || null + + return { headline, summary, experiences, skills } +} + +export default defineEventHandler(async (event) => { + const session = await getAuthSession(event) + if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' }) + + const body = await readBody(event) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) throw createError({ statusCode: 400, message: 'Corps invalide.', data: parsed.error.flatten() }) + + // Aucune écriture en base : on retourne uniquement la suggestion. + return parseProfileText(parsed.data.text) +}) diff --git a/apps/app/server/api/profile/index.get.ts b/apps/app/server/api/profile/index.get.ts new file mode 100644 index 0000000..372c193 --- /dev/null +++ b/apps/app/server/api/profile/index.get.ts @@ -0,0 +1,25 @@ +/** + * GET /api/profile — Lecture du profil candidat connecté. + * Retourne le profil existant (ou null si jamais créé). + * Seuls les sous-éléments non soft-delete sont exposés. + */ +import { getAuthSession } from '../../utils/session' +import { prisma } from '../../utils/prisma' +import { NOT_DELETED, toProfileDTO } 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 }, + include: { + experiences: { orderBy: { orderIndex: 'asc' } }, + skills: { orderBy: { orderIndex: 'asc' } }, + education: { orderBy: { orderIndex: 'asc' } }, + }, + }) + + if (!profile) return null + return toProfileDTO(profile) +}) diff --git a/apps/app/server/api/profile/index.put.ts b/apps/app/server/api/profile/index.put.ts new file mode 100644 index 0000000..f9faf20 --- /dev/null +++ b/apps/app/server/api/profile/index.put.ts @@ -0,0 +1,44 @@ +/** + * PUT /api/profile — Upsert des champs scalaires du profil (headline + summary). + * Crée le profil s'il n'existe pas encore (premier appel après création de compte). + */ +import { z } from 'zod' +import { getAuthSession } from '../../utils/session' +import { prisma } from '../../utils/prisma' +import { toProfileDTO } from '../../utils/profile-serialize' + +const bodySchema = z.object({ + headline: z.string().max(150).nullable().optional(), + summary: z.string().max(2000).nullable().optional(), +}) + +export default defineEventHandler(async (event) => { + const session = await getAuthSession(event) + if (!session?.user) throw createError({ statusCode: 401, message: 'Non authentifié.' }) + + 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 { headline, summary } = parsed.data + + const profile = await prisma.profile.upsert({ + where: { userId: session.user.id }, + update: { + ...(headline !== undefined && { headline }), + ...(summary !== undefined && { summary }), + }, + create: { + userId: session.user.id, + headline: headline ?? null, + summary: summary ?? null, + }, + include: { + experiences: { orderBy: { orderIndex: 'asc' } }, + skills: { orderBy: { orderIndex: 'asc' } }, + education: { orderBy: { orderIndex: 'asc' } }, + }, + }) + + return toProfileDTO(profile) +}) diff --git a/apps/app/server/api/profile/skills.post.ts b/apps/app/server/api/profile/skills.post.ts new file mode 100644 index 0000000..95f80ff --- /dev/null +++ b/apps/app/server/api/profile/skills.post.ts @@ -0,0 +1,43 @@ +/** + * POST /api/profile/skills — Ajouter une compétence RÉELLE déclarée. + * Garde-fou : ces données saisies = seule source de vérité du moteur (THI-124). + */ +import { z } from 'zod' +import { SKILL_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(SKILL_LEVELS as [string, ...string[]]).nullable().optional(), + years: z.number().int().min(0).max(50).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, years, orderIndex } = parsed.data + + const skill = await prisma.skill.create({ + data: { + profileId: profile.id, + label, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + level: (level as any) ?? null, + years: years ?? null, + orderIndex, + }, + }) + + return { id: skill.id } +}) diff --git a/apps/app/server/api/profile/skills/[id].delete.ts b/apps/app/server/api/profile/skills/[id].delete.ts new file mode 100644 index 0000000..87b17b7 --- /dev/null +++ b/apps/app/server/api/profile/skills/[id].delete.ts @@ -0,0 +1,22 @@ +/** + * DELETE /api/profile/skills/:id — Soft-delete d'une compétence. + */ +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 skill = await prisma.skill.findFirst({ + where: { id, ...NOT_DELETED, profile: { userId: session.user.id, ...NOT_DELETED } }, + }) + if (!skill) throw createError({ statusCode: 404, message: 'Compétence introuvable.' }) + + await prisma.skill.update({ where: { id }, data: { deletedAt: new Date() } }) + return { ok: true } +}) diff --git a/apps/app/server/utils/profile-serialize.ts b/apps/app/server/utils/profile-serialize.ts new file mode 100644 index 0000000..b47e089 --- /dev/null +++ b/apps/app/server/utils/profile-serialize.ts @@ -0,0 +1,108 @@ +import type { EducationDTO, ExperienceDTO, ProfileDTO, SkillDTO } from '@cvo/shared' + +/** + * Sérialisation Profil (Prisma → DTO transport) et fragment de filtre soft-delete. + * Fonctions PURES → testables sans base. Règle : on n'expose jamais une ligne + * dont `deletedAt` est renseigné (RGPD, droit à l'effacement). + */ + +/** + * Fragment Prisma « non supprimé » à réutiliser dans tous les `where` de lecture. + * Exemple : `prisma.profile.findFirst({ where: { userId, ...NOT_DELETED } })`. + */ +export const NOT_DELETED = { deletedAt: null } as const + +/** Date Prisma (ou null) → ISO-8601 string (ou null) pour le transport. */ +export function toIso(value: Date | null): string | null { + return value === null ? null : value.toISOString() +} + +/** Forme minimale d'une ligne porteuse de soft-delete (pour le typage des helpers). */ +interface SoftDeletable { + deletedAt: Date | null +} + +/** Exclut les lignes soft-delete puis trie par `orderIndex` croissant. */ +export function activeOrdered(rows: T[]): T[] { + return rows.filter((r) => r.deletedAt === null).sort((a, b) => a.orderIndex - b.orderIndex) +} + +// Formes Prisma minimales attendues en entrée (sous-ensemble des modèles). +interface ExperienceRow extends SoftDeletable { + id: string + title: string + company: string + startDate: Date | null + endDate: Date | null + description: string | null + skillsUsed: string[] + orderIndex: number +} +interface SkillRow extends SoftDeletable { + id: string + label: string + level: SkillDTO['level'] + years: number | null + orderIndex: number +} +interface EducationRow extends SoftDeletable { + id: string + degree: string + school: string + startDate: Date | null + endDate: Date | null + description: string | null + orderIndex: number +} +interface ProfileRow { + id: string + headline: string | null + summary: string | null + experiences: ExperienceRow[] + skills: SkillRow[] + education: EducationRow[] +} + +function toExperienceDTO(e: ExperienceRow): ExperienceDTO { + return { + id: e.id, + title: e.title, + company: e.company, + startDate: toIso(e.startDate), + endDate: toIso(e.endDate), + description: e.description, + skillsUsed: e.skillsUsed, + orderIndex: e.orderIndex, + } +} + +function toSkillDTO(s: SkillRow): SkillDTO { + return { id: s.id, label: s.label, level: s.level, years: s.years, orderIndex: s.orderIndex } +} + +function toEducationDTO(ed: EducationRow): EducationDTO { + return { + id: ed.id, + degree: ed.degree, + school: ed.school, + startDate: toIso(ed.startDate), + endDate: toIso(ed.endDate), + description: ed.description, + orderIndex: ed.orderIndex, + } +} + +/** + * Mappe un profil Prisma (avec relations) vers le DTO transport : exclut les + * enfants soft-delete et garantit l'ordre d'affichage. + */ +export function toProfileDTO(profile: ProfileRow): ProfileDTO { + return { + id: profile.id, + headline: profile.headline, + summary: profile.summary, + experiences: activeOrdered(profile.experiences).map(toExperienceDTO), + skills: activeOrdered(profile.skills).map(toSkillDTO), + education: activeOrdered(profile.education).map(toEducationDTO), + } +} diff --git a/apps/app/server/utils/session.ts b/apps/app/server/utils/session.ts index fa9f921..0a0b35f 100644 --- a/apps/app/server/utils/session.ts +++ b/apps/app/server/utils/session.ts @@ -1,18 +1,18 @@ /** - * Résolution de l'utilisateur courant côté serveur (Nitro). + * Helpers de session serveur (Nitro). * - * Découplage volontaire du lot Auth (THI-131) : on lit `event.context.userId`, - * renseigné par le middleware d'authentification (Better Auth). Ce WS (THI-126) - * n'importe donc pas l'instance auth, ce qui le garde indépendant de l'ordre de - * merge. Quand le middleware d'auth est en place, l'id est disponible ici ; - * sinon l'accès est refusé (401) plutôt que d'attribuer l'usage au mauvais compte. + * - `requireUserId` (THI-126) : lit `event.context.userId` posé par le middleware + * d'auth (THI-134) et lève une 401 si absent. Découplé de l'instance Better Auth. + * - `getAuthSession` (THI-132) : récupère la session Better Auth complète depuis + * l'événement (headers Web API via `toWebRequest`). Retourne null si non authentifié. */ import { createError, type H3Event } from 'h3' +import { auth } from './auth' // Le middleware d'auth renseigne l'id utilisateur dans le contexte de requête. declare module 'h3' { interface H3EventContext { - /** Id de l'utilisateur authentifié (renseigné par le middleware auth, THI-131). */ + /** Id de l'utilisateur authentifié (renseigné par le middleware auth, THI-131/THI-134). */ userId?: string } } @@ -25,3 +25,9 @@ export function requireUserId(event: H3Event): string { } return userId } + +/** Récupère la session Better Auth complète (ou null si non authentifié). */ +export async function getAuthSession(event: H3Event) { + const req = toWebRequest(event) + return auth.api.getSession({ headers: req.headers }) +} diff --git a/apps/app/test/profile-serialize.spec.ts b/apps/app/test/profile-serialize.spec.ts new file mode 100644 index 0000000..f7cb93f --- /dev/null +++ b/apps/app/test/profile-serialize.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { + NOT_DELETED, + activeOrdered, + toIso, + toProfileDTO, +} from '../server/utils/profile-serialize' + +describe('NOT_DELETED', () => { + it('is the Prisma fragment selecting non-soft-deleted rows', () => { + expect(NOT_DELETED).toEqual({ deletedAt: null }) + }) +}) + +describe('toIso', () => { + it('passes null through', () => { + expect(toIso(null)).toBeNull() + }) + it('serialises a Date to ISO-8601', () => { + expect(toIso(new Date('2026-01-02T03:04:05.000Z'))).toBe('2026-01-02T03:04:05.000Z') + }) +}) + +describe('activeOrdered', () => { + const rows = [ + { id: 'b', orderIndex: 1, deletedAt: null }, + { id: 'gone', orderIndex: 0, deletedAt: new Date('2026-01-01T00:00:00.000Z') }, + { id: 'a', orderIndex: 0, deletedAt: null }, + ] + + it('drops soft-deleted rows and sorts by orderIndex', () => { + expect(activeOrdered(rows).map((r) => r.id)).toEqual(['a', 'b']) + }) +}) + +describe('toProfileDTO', () => { + const d = (s: string) => new Date(s) + const profile = { + id: 'p1', + headline: 'Développeuse Full-Stack', + summary: null, + experiences: [ + { + id: 'e2', + title: 'Lead', + company: 'Acme', + startDate: d('2023-01-01T00:00:00.000Z'), + endDate: null, + description: 'En cours', + skillsUsed: ['Vue', 'Nitro'], + orderIndex: 1, + deletedAt: null, + }, + { + id: 'e-del', + title: 'Supprimée', + company: 'X', + startDate: null, + endDate: null, + description: null, + skillsUsed: [], + orderIndex: 0, + deletedAt: d('2026-01-01T00:00:00.000Z'), + }, + { + id: 'e1', + title: 'Dev', + company: 'Beta', + startDate: d('2020-01-01T00:00:00.000Z'), + endDate: d('2022-12-31T00:00:00.000Z'), + description: null, + skillsUsed: [], + orderIndex: 0, + deletedAt: null, + }, + ], + skills: [{ id: 's1', label: 'TypeScript', level: 'EXPERT' as const, years: 6, orderIndex: 0, deletedAt: null }], + education: [], + } + + it('maps to DTO, excludes soft-deleted children and keeps display order', () => { + const dto = toProfileDTO(profile) + expect(dto.id).toBe('p1') + expect(dto.experiences.map((e) => e.id)).toEqual(['e1', 'e2']) + expect(dto.experiences[0]!.startDate).toBe('2020-01-01T00:00:00.000Z') + expect(dto.experiences[1]!.endDate).toBeNull() + expect(dto.skills).toEqual([ + { id: 's1', label: 'TypeScript', level: 'EXPERT', years: 6, orderIndex: 0 }, + ]) + expect(dto.education).toEqual([]) + }) +}) From 637eedb49c6311c45c6860391d71c1103439d3ed Mon Sep 17 00:00:00 2001 From: Lead Dev Date: Tue, 9 Jun 2026 12:22:01 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(profil):=20page=20saisie/=C3=A9dition?= =?UTF-8?q?=20profil=20+=20import=20copier-coller=20(THI-132,=20slice=203b?= =?UTF-8?q?/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI de saisie structurée du profil candidat (expériences, compétences, formation) et import par copier-coller de texte avec étape de relecture (garde-fou : rien n'est enregistré avant confirmation utilisateur). - pages/profil.vue — saisie/édition en-tête, expériences, compétences ; import coller-texte → suggestion → confirmation → persist via API ; Tailwind tokens (aucune valeur en dur) ; page en FR - middleware/auth.ts — middleware de navigation : redirige /connexion si non connecté - composables/useAuth.ts — déplacé ici (était dans slice 2) ; singleton client Better Auth + signIn/signOut + session réactive QA locale : lint ✓ · typecheck TS strict ✓ · build Nitro ✓ · test 11 ✓ Co-Authored-By: Paperclip --- apps/app/middleware/auth.ts | 11 ++ apps/app/pages/profil.vue | 373 ++++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 307 +++++++++++++++++++++++++++++ 3 files changed, 691 insertions(+) create mode 100644 apps/app/middleware/auth.ts create mode 100644 apps/app/pages/profil.vue diff --git a/apps/app/middleware/auth.ts b/apps/app/middleware/auth.ts new file mode 100644 index 0000000..f625904 --- /dev/null +++ b/apps/app/middleware/auth.ts @@ -0,0 +1,11 @@ +/** + * Middleware de navigation — protège les pages authentifiées. + * Usage : definePageMeta({ middleware: 'auth' }) dans la page. + * Redirige vers /connexion si la session est absente. + */ +export default defineNuxtRouteMiddleware(async () => { + const { session } = useAuth() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (session as any)?.value ?? session + if (!s) return navigateTo('/connexion') +}) diff --git a/apps/app/pages/profil.vue b/apps/app/pages/profil.vue new file mode 100644 index 0000000..7696ee9 --- /dev/null +++ b/apps/app/pages/profil.vue @@ -0,0 +1,373 @@ + + +