feat(app) : phase 1 — registre, profils, moteur de recommandation, export markdown

- Schémas Zod partagés ($lib/schemas) : modèle, profil (PRD §4), null = « à vérifier »,
  sources[] obligatoires à chaque écriture
- Persistance YAML dans DATA_DIR + commits git automatiques (simple-git) ;
  seeds PRD au premier démarrage (1 modèle §4.1, 5 profils, 2 templates)
- CRUD registre et profils : pages liste/édition/création, form actions,
  champs tri-état, messages d'erreur Zod, commit par écriture
- Moteur de recommandation pur et testé (12 cas : nominal, licence null,
  seuil, statut mort, vram, modalité, fraîcheur, pénalités) : filtrage dur
  (requiert/exclut/obligatoire/mort) puis scoring pondéré (préférences,
  rang arena, fraîcheur, pénalités statut et champs null), justification
  et warnings (attribution, seuil, garde-fous, déclarations plateformes)
- POST /api/recommander (SSO requis) + page /recommander
- Exporteur markdown /exports : templates éditables, génération depuis le
  registre (tableaux registre + classements), commit par document
- Vérifié : check/lint/test (22) /build verts, test fumée HTTP complet
  (seed + git init, CRUD par form action, exports générés, filtres API,
  401/404), svelte-autofixer propre sur les 11 composants
This commit is contained in:
cyber-mawonaj
2026-08-01 10:19:42 -04:00
parent 4a108c1fda
commit 0c9f83d3f5
36 changed files with 2814 additions and 34 deletions
+7
View File
@@ -0,0 +1,7 @@
import { lireProfils } from '$lib/server/stockage';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const profils = await lireProfils();
return { profils };
};
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>{fr.profils.titre}{fr.app.nom}</title>
</svelte:head>
<div class="titre-page">
<h1>{fr.profils.titre}</h1>
<a class="bouton" href={resolve('/profils/nouveau')}>{fr.profils.nouveau}</a>
</div>
{#if data.profils.length === 0}
<p>{fr.profils.aucun}</p>
{:else}
<div class="grille">
{#each data.profils as profil (profil.id)}
<article class="carte">
<h2>{profil.nom}</h2>
{#if profil.requiert.length > 0}
<p>
<strong>{fr.profils.legende_requiert} :</strong>
{profil.requiert.map((c) => fr.formulaires.cles[c]).join(', ')}
</p>
{/if}
{#if profil.exclut.length > 0}
<p>
<strong>{fr.profils.legende_exclut} :</strong>
{profil.exclut.map((c) => fr.formulaires.cles[c]).join(', ')}
</p>
{/if}
{#if profil.contraintes_specifiques}
<p class="contraintes">{profil.contraintes_specifiques}</p>
{/if}
<a href={resolve(`/profils/${profil.id}`)}>{fr.profils.modifier}</a>
</article>
{/each}
</div>
{/if}
<style>
.titre-page {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.carte h2 {
font-size: 1.05rem;
margin-bottom: var(--space-1);
}
.carte p {
color: var(--muted);
margin-bottom: var(--space-1);
}
.contraintes {
font-style: italic;
}
</style>
@@ -0,0 +1,41 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { profilSchema } from '$lib/schemas';
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const profils = await lireProfils();
const profil = profils.find((p) => p.id === params.id);
if (!profil) error(404, 'Profil introuvable');
return { profil };
};
export const actions: Actions = {
enregistrer: async ({ request, params }) => {
const data = await request.formData();
const brut = profilDepuisFormulaire(data);
// L'identifiant d'un profil existant n'est pas modifiable (champ readonly).
const resultat = profilSchema.safeParse({ ...(brut as object), id: params.id });
if (!resultat.success) {
return fail(400, { erreurs: erreursZod(resultat.error), brut });
}
const profils = await lireProfils();
const index = profils.findIndex((p) => p.id === params.id);
if (index === -1) error(404, 'Profil introuvable');
profils[index] = resultat.data;
await ecrireProfils(profils, `feat : mise à jour du profil ${params.id}`);
redirect(303, '/profils');
},
supprimer: async ({ params }) => {
const profils = await lireProfils();
const restant = profils.filter((p) => p.id !== params.id);
if (restant.length === profils.length) error(404, 'Profil introuvable');
await ecrireProfils(restant, `feat : suppression du profil ${params.id}`);
redirect(303, '/profils');
}
};
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ProfilForm from '$lib/components/ProfilForm.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
<title>{fr.profils.titre_edition} : {data.profil.nom}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.profils.titre_edition} : {data.profil.nom}</h1>
<ProfilForm
profil={data.profil}
edition
actionSupprimer="?/supprimer"
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
/>
@@ -0,0 +1,27 @@
import { fail, redirect } from '@sveltejs/kit';
import { profilSchema } from '$lib/schemas';
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const brut = profilDepuisFormulaire(data);
const resultat = profilSchema.safeParse(brut);
if (!resultat.success) {
return fail(400, { erreurs: erreursZod(resultat.error), brut });
}
const profils = await lireProfils();
if (profils.some((p) => p.id === resultat.data.id)) {
return fail(409, {
erreurs: [`Un profil avec l'identifiant « ${resultat.data.id} » existe déjà.`],
brut
});
}
await ecrireProfils([...profils, resultat.data], `feat : ajout du profil ${resultat.data.id}`);
redirect(303, '/profils');
}
};
@@ -0,0 +1,18 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ProfilForm from '$lib/components/ProfilForm.svelte';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<svelte:head>
<title>{fr.profils.titre_nouveau}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.profils.titre_nouveau}</h1>
<ProfilForm
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
/>