Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/app/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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')
})
3 changes: 3 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
373 changes: 373 additions & 0 deletions apps/app/pages/profil.vue

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions apps/app/server/api/profile/experiences.post.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
24 changes: 24 additions & 0 deletions apps/app/server/api/profile/experiences/[id].delete.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
122 changes: 122 additions & 0 deletions apps/app/server/api/profile/import-text.post.ts
Original file line number Diff line number Diff line change
@@ -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<ExperienceDTO, 'id'>[]
skills: Omit<SkillDTO, 'id'>[]
}

/**
* 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<ExperienceDTO, 'id'>[] = []
const skillSet = new Set<string>()
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<SkillDTO, 'id'>[] = 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)
})
25 changes: 25 additions & 0 deletions apps/app/server/api/profile/index.get.ts
Original file line number Diff line number Diff line change
@@ -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)
})
44 changes: 44 additions & 0 deletions apps/app/server/api/profile/index.put.ts
Original file line number Diff line number Diff line change
@@ -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)
})
43 changes: 43 additions & 0 deletions apps/app/server/api/profile/skills.post.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
22 changes: 22 additions & 0 deletions apps/app/server/api/profile/skills/[id].delete.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
Loading