feat(app) : phase 2 — endpoint /api/alerts, inbox, flows Node-RED
- POST /api/alerts : token Bearer (comparaison temps constant, 503 si non configuré), schéma d'alerte PRD §4.4 validé Zod, exempté du SSO dans les hooks - Persistance alertes.yaml + commits git (réception, « traité ») - Inbox /alertes : filtres par profil impacté et statut, actions « Marquer traité » et « Créer/MAJ fiche » avec pré-remplissage du formulaire registre depuis l'alerte (aucun champ licence/capacité inventé) - nodered-flows/ : 4 flows importables (rss-ingest, license-watch, classifier avec le prompt figé du PRD §6 embarqué tel quel, notify) — nœuds core uniquement, configuration par variables d'env, README de câblage - Vérifié : check/lint/test (27) /build verts, autofixer propre, JSON des flows et fonctions embarquées validés, test fumée HTTP (201/400/401 sur l'endpoint, inbox, traité + commit, pré-remplissage MAJ fiche)
This commit is contained in:
@@ -9,6 +9,7 @@ import { estRoutePublique, resoudreUtilisateur } from '$lib/server/sso';
|
||||
* Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy
|
||||
* YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ;
|
||||
* toute autre route (pages et endpoints API) renvoie 401.
|
||||
* Exception : POST /api/alerts, protégé par token Bearer au lieu du SSO (webhook Node-RED).
|
||||
*/
|
||||
const authHandle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = resoudreUtilisateur(event.request.headers, {
|
||||
@@ -17,7 +18,8 @@ const authHandle: Handle = async ({ event, resolve }) => {
|
||||
dev
|
||||
});
|
||||
|
||||
if (!event.locals.user && !estRoutePublique(event.url.pathname, base)) {
|
||||
const estWebhookAlertes = event.url.pathname === `${base}/api/alerts`;
|
||||
if (!event.locals.user && !estRoutePublique(event.url.pathname, base) && !estWebhookAlertes) {
|
||||
error(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,32 @@ export const fr = {
|
||||
genere_le: 'Généré le',
|
||||
tokens_aide: 'Tokens disponibles : {{date}}, {{tableau_registre}}, {{tableau_classements}}'
|
||||
},
|
||||
alertes: {
|
||||
titre: 'Inbox alertes',
|
||||
intro: 'Événements classifiés reçus depuis Node-RED (POST /api/alerts).',
|
||||
filtre_profil: 'Profil impacté',
|
||||
filtre_statut: 'Statut',
|
||||
tous: 'tous',
|
||||
toutes: 'toutes',
|
||||
a_traiter: 'à traiter',
|
||||
traite: 'traitées',
|
||||
marquer_traite: 'Marquer traité',
|
||||
creer_fiche: 'Créer la fiche',
|
||||
maj_fiche: 'Mettre à jour la fiche',
|
||||
aucune: 'Aucune alerte pour ces filtres.',
|
||||
recue_le: 'Reçue le',
|
||||
urgence: {
|
||||
haute: 'urgence haute',
|
||||
moyenne: 'urgence moyenne',
|
||||
faible: 'urgence faible'
|
||||
},
|
||||
types: {
|
||||
nouvelle_sortie: 'nouvelle sortie',
|
||||
changement_licence: 'changement de licence',
|
||||
changement_classement: 'changement de classement',
|
||||
comfyui_support: 'support ComfyUI'
|
||||
}
|
||||
},
|
||||
pied: {
|
||||
ligne: 'veille-ia — application AGPL-3.0, auto-hébergée.',
|
||||
registre_vide: 'Registre et profils initialisés depuis le PRD au premier démarrage.'
|
||||
|
||||
@@ -87,3 +87,37 @@ export const profilSchema = z.object({
|
||||
contraintes_specifiques: z.string().nullable().default(null)
|
||||
});
|
||||
export type Profil = z.infer<typeof profilSchema>;
|
||||
|
||||
// ── Alertes (PRD §4.4) ──
|
||||
|
||||
export const typeAlerteSchema = z.enum([
|
||||
'nouvelle_sortie',
|
||||
'changement_licence',
|
||||
'changement_classement',
|
||||
'comfyui_support'
|
||||
]);
|
||||
export type TypeAlerte = z.infer<typeof typeAlerteSchema>;
|
||||
|
||||
export const urgenceSchema = z.enum(['haute', 'moyenne', 'faible']);
|
||||
export type Urgence = z.infer<typeof urgenceSchema>;
|
||||
|
||||
/** Charge utile reçue sur POST /api/alerts (émise par le classifieur Node-RED, PRD §6). */
|
||||
export const alerteEntranteSchema = z.object({
|
||||
type: typeAlerteSchema,
|
||||
modele_id: slugSchema.nullable().default(null),
|
||||
titre: z.string().min(1),
|
||||
resume: z.string().min(1),
|
||||
impact_profils: z.array(z.string()).default([]),
|
||||
urgence: urgenceSchema.default('moyenne'),
|
||||
sources: z.array(z.string().url()).min(1, 'au moins une source est obligatoire'),
|
||||
date: z.string().min(1)
|
||||
});
|
||||
export type AlerteEntrante = z.infer<typeof alerteEntranteSchema>;
|
||||
|
||||
/** Alerte persistée : charge utile + métadonnées ajoutées par le serveur. */
|
||||
export const alerteSchema = alerteEntranteSchema.extend({
|
||||
id: z.string().min(1),
|
||||
recue_le: z.string().min(1),
|
||||
statut: z.enum(['a_traiter', 'traite']).default('a_traiter')
|
||||
});
|
||||
export type Alerte = z.infer<typeof alerteSchema>;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { tokenValide } from './alertes';
|
||||
|
||||
describe('tokenValide', () => {
|
||||
it('accepte le token exact', () => {
|
||||
expect(tokenValide('secret-123', 'secret-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejette un token différent', () => {
|
||||
expect(tokenValide('secret-124', 'secret-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette un token de longueur différente', () => {
|
||||
expect(tokenValide('secret-12', 'secret-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette null des deux côtés', () => {
|
||||
expect(tokenValide(null, 'secret-123')).toBe(false);
|
||||
expect(tokenValide('secret-123', null)).toBe(false);
|
||||
expect(tokenValide(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejette une chaîne vide', () => {
|
||||
expect(tokenValide('', 'secret-123')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { alerteSchema, type Alerte, type AlerteEntrante } from '../schemas';
|
||||
import { committer } from './git';
|
||||
import { initialiserDonnees } from './stockage';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { load as loadYaml, dump as dumpYaml } from 'js-yaml';
|
||||
import { config } from './config';
|
||||
|
||||
/**
|
||||
* Vérification du token Bearer de POST /api/alerts — fonction pure, testable.
|
||||
* Comparaison en temps constant (jamais de === sur des secrets).
|
||||
*/
|
||||
export function tokenValide(tokenRecu: string | null, tokenAttendu: string | null): boolean {
|
||||
if (!tokenRecu || !tokenAttendu) return false;
|
||||
const a = Buffer.from(tokenRecu);
|
||||
const b = Buffer.from(tokenAttendu);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
const FICHIER_ALERTES = 'alertes.yaml';
|
||||
const alertesFichierSchema = z.array(alerteSchema);
|
||||
|
||||
function cheminAlertes(): string {
|
||||
return join(config.dataDir, FICHIER_ALERTES);
|
||||
}
|
||||
|
||||
export async function lireAlertes(): Promise<Alerte[]> {
|
||||
await initialiserDonnees();
|
||||
if (!existsSync(cheminAlertes())) return [];
|
||||
const brut = await readFile(cheminAlertes(), 'utf8');
|
||||
const charge = loadYaml(brut);
|
||||
if (charge === null || charge === undefined) return [];
|
||||
return alertesFichierSchema.parse(charge);
|
||||
}
|
||||
|
||||
async function ecrireAlertes(alertes: Alerte[], messageCommit: string): Promise<void> {
|
||||
const valides = alertesFichierSchema.parse(alertes);
|
||||
await writeFile(cheminAlertes(), dumpYaml(valides, { lineWidth: 100, noRefs: true }), 'utf8');
|
||||
await committer([FICHIER_ALERTES], messageCommit);
|
||||
}
|
||||
|
||||
/** Ajoute une alerte en tête d'inbox (plus récente d'abord). */
|
||||
export async function ajouterAlerte(alerte: AlerteEntrante): Promise<Alerte> {
|
||||
const alertes = await lireAlertes();
|
||||
const complete: Alerte = alerteSchema.parse({
|
||||
...alerte,
|
||||
id: crypto.randomUUID(),
|
||||
recue_le: new Date().toISOString(),
|
||||
statut: 'a_traiter'
|
||||
});
|
||||
await ecrireAlertes([complete, ...alertes], `feat : alerte reçue « ${complete.titre} »`);
|
||||
return complete;
|
||||
}
|
||||
|
||||
export async function marquerAlerteTraitee(id: string): Promise<boolean> {
|
||||
const alertes = await lireAlertes();
|
||||
const index = alertes.findIndex((a) => a.id === id);
|
||||
if (index === -1) return false;
|
||||
alertes[index] = { ...alertes[index], statut: 'traite' };
|
||||
await ecrireAlertes(alertes, `feat : alerte « ${alertes[index].titre} » marquée traitée`);
|
||||
return true;
|
||||
}
|
||||
@@ -9,14 +9,13 @@
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
// Navigation privée : visible uniquement avec un utilisateur SSO.
|
||||
// L'entrée "bientot" correspond à la phase 2 du PRD.
|
||||
const entrees = [
|
||||
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
|
||||
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false },
|
||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
|
||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
|
||||
{ href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false },
|
||||
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
|
||||
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: false }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
titre: fr.accueil.carte_alertes_titre,
|
||||
texte: fr.accueil.carte_alertes_texte,
|
||||
icone: 'alerte',
|
||||
href: null
|
||||
href: '/alertes'
|
||||
}
|
||||
] as const;
|
||||
</script>
|
||||
@@ -48,16 +48,9 @@
|
||||
<article class="carte">
|
||||
<h2>
|
||||
<Icon nom={carte.icone} taille={22} />
|
||||
{#if carte.href}
|
||||
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
|
||||
{:else}
|
||||
{carte.titre}
|
||||
{/if}
|
||||
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
|
||||
</h2>
|
||||
<p>{carte.texte}</p>
|
||||
{#if !carte.href}
|
||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { lireAlertes, marquerAlerteTraitee } from '$lib/server/alertes';
|
||||
import { lireProfils } from '$lib/server/stockage';
|
||||
import { chaineOuNull } from '$lib/server/formulaires';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [alertes, profils] = await Promise.all([lireAlertes(), lireProfils()]);
|
||||
return { alertes, profils };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
traiter: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const id = chaineOuNull(data.get('id'));
|
||||
if (!id || !(await marquerAlerteTraitee(id))) {
|
||||
return fail(404, { erreur: 'Alerte introuvable.' });
|
||||
}
|
||||
return { traitee: id };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let filtreProfil = $state('');
|
||||
let filtreStatut = $state<'a_traiter' | 'traite' | ''>('a_traiter');
|
||||
|
||||
const alertesFiltrees = $derived(
|
||||
data.alertes.filter(
|
||||
(a) =>
|
||||
(filtreStatut === '' || a.statut === filtreStatut) &&
|
||||
(filtreProfil === '' || a.impact_profils.includes(filtreProfil))
|
||||
)
|
||||
);
|
||||
|
||||
function nomProfil(id: string): string {
|
||||
return data.profils.find((p) => p.id === id)?.nom ?? id;
|
||||
}
|
||||
|
||||
function dateCourte(iso: string): string {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.alertes.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.alertes.titre}</h1>
|
||||
<p class="intro">{fr.alertes.intro}</p>
|
||||
|
||||
{#if form && 'erreur' in form}
|
||||
<p class="erreur" role="alert">{form.erreur}</p>
|
||||
{/if}
|
||||
|
||||
<div class="filtres" role="search">
|
||||
<label class="champ">
|
||||
<span>{fr.alertes.filtre_statut}</span>
|
||||
<select bind:value={filtreStatut}>
|
||||
<option value="a_traiter">{fr.alertes.a_traiter}</option>
|
||||
<option value="traite">{fr.alertes.traite}</option>
|
||||
<option value="">{fr.alertes.toutes}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.alertes.filtre_profil}</span>
|
||||
<select bind:value={filtreProfil}>
|
||||
<option value="">{fr.alertes.tous}</option>
|
||||
{#each data.profils as profil (profil.id)}
|
||||
<option value={profil.id}>{profil.nom}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if alertesFiltrees.length === 0}
|
||||
<p>{fr.alertes.aucune}</p>
|
||||
{:else}
|
||||
<div class="liste">
|
||||
{#each alertesFiltrees as alerte (alerte.id)}
|
||||
<article class="carte alerte" class:traitee={alerte.statut === 'traite'}>
|
||||
<header>
|
||||
<span class="tag" class:tag-signal={alerte.urgence === 'haute'}>
|
||||
{fr.alertes.types[alerte.type]} · {fr.alertes.urgence[alerte.urgence]}
|
||||
</span>
|
||||
<time datetime={alerte.recue_le}>
|
||||
{fr.alertes.recue_le}
|
||||
{dateCourte(alerte.recue_le)}
|
||||
</time>
|
||||
</header>
|
||||
<h2>{alerte.titre}</h2>
|
||||
<p class="resume">{alerte.resume}</p>
|
||||
|
||||
{#if alerte.impact_profils.length > 0}
|
||||
<p class="impacts">
|
||||
{#each alerte.impact_profils as profilId (profilId)}
|
||||
<span class="tag">{nomProfil(profilId)}</span>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="sources">
|
||||
{#each alerte.sources as source (source)}
|
||||
<a href={source} rel="external noopener noreferrer" target="_blank">{source}</a>
|
||||
{/each}
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if alerte.modele_id}
|
||||
<a
|
||||
class="bouton"
|
||||
href={resolve(`/registre/${alerte.modele_id}?depuis_alerte=${alerte.id}`)}
|
||||
>
|
||||
<Icon nom="registre" taille={16} />
|
||||
{fr.alertes.maj_fiche}
|
||||
</a>
|
||||
{:else}
|
||||
<a class="bouton" href={resolve(`/registre/nouveau?depuis_alerte=${alerte.id}`)}>
|
||||
<Icon nom="registre" taille={16} />
|
||||
{fr.alertes.creer_fiche}
|
||||
</a>
|
||||
{/if}
|
||||
{#if alerte.statut === 'a_traiter'}
|
||||
<form method="post" action="?/traiter" use:enhance>
|
||||
<input type="hidden" name="id" value={alerte.id} />
|
||||
<button class="bouton bouton-secondaire" type="submit">
|
||||
<Icon nom="verifie" taille={16} />
|
||||
{fr.alertes.marquer_traite}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.intro {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.filtres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.champ > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
padding: 0.5em 0.75em;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.alerte {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 180px;
|
||||
}
|
||||
|
||||
.alerte.traitee {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.alerte header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.alerte time {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alerte h2 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.resume {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.impacts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions .bouton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.bouton-secondaire {
|
||||
color: var(--succes);
|
||||
border-color: var(--succes);
|
||||
}
|
||||
|
||||
.bouton-secondaire:hover {
|
||||
color: var(--succes);
|
||||
border-color: var(--succes);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: var(--signal);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { alerteEntranteSchema } from '$lib/schemas';
|
||||
import { ajouterAlerte, tokenValide } from '$lib/server/alertes';
|
||||
import { config } from '$lib/server/config';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/alerts (PRD §4.4) — reçoit les événements classifiés depuis Node-RED.
|
||||
* Protégé par token Bearer (ALERTS_TOKEN, généré à l'install YunoHost) — pas par le SSO
|
||||
* (cf. hooks.server : cette route est exemptée du 401 SSO, le token fait foi).
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!config.alertsToken) {
|
||||
error(503, 'ALERTS_TOKEN non configuré sur ce serveur');
|
||||
}
|
||||
const autorisation = request.headers.get('authorization');
|
||||
const token = autorisation?.startsWith('Bearer ') ? autorisation.slice(7).trim() : null;
|
||||
if (!tokenValide(token, config.alertsToken)) {
|
||||
error(401, 'Token invalide');
|
||||
}
|
||||
|
||||
let corpsBrut: unknown;
|
||||
try {
|
||||
corpsBrut = await request.json();
|
||||
} catch {
|
||||
error(400, 'Corps JSON invalide');
|
||||
}
|
||||
|
||||
const corps = alerteEntranteSchema.safeParse(corpsBrut);
|
||||
if (!corps.success) {
|
||||
error(
|
||||
400,
|
||||
`Alerte invalide : ${corps.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const alerte = await ajouterAlerte(corps.data);
|
||||
return json({ id: alerte.id }, { status: 201 });
|
||||
};
|
||||
@@ -1,14 +1,32 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { modeleSchema } from '$lib/schemas';
|
||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { lireAlertes } from '$lib/server/alertes';
|
||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const registre = await lireRegistre();
|
||||
const modele = registre.find((m) => m.id === params.id);
|
||||
if (!modele) error(404, 'Modèle introuvable');
|
||||
return { modele };
|
||||
|
||||
// Pré-remplissage depuis une alerte (action « Mettre à jour la fiche » de l'inbox) :
|
||||
// fusionne les sources, préfixe les notes du résumé — sans toucher aux champs vérifiés.
|
||||
const alerteId = url.searchParams.get('depuis_alerte');
|
||||
let brut: Record<string, unknown> | null = null;
|
||||
if (alerteId) {
|
||||
const alertes = await lireAlertes();
|
||||
const alerte = alertes.find((a) => a.id === alerteId);
|
||||
if (alerte) {
|
||||
brut = {
|
||||
...modele,
|
||||
sources: [...new Set([...modele.sources, ...alerte.sources])],
|
||||
notes: `${alerte.resume}\n\n${modele.notes ?? ''}`.trim()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { modele, brut };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
edition
|
||||
actionSupprimer="?/supprimer"
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { modeleSchema } from '$lib/schemas';
|
||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { lireAlertes } from '$lib/server/alertes';
|
||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||
import type { Actions } from './$types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Pré-remplissage depuis une alerte (action « Créer la fiche » de l'inbox) :
|
||||
* reprend les sources et le résumé — sans inventer aucun champ licence/capacité.
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const alerteId = url.searchParams.get('depuis_alerte');
|
||||
if (!alerteId) return { brut: null };
|
||||
|
||||
const alertes = await lireAlertes();
|
||||
const alerte = alertes.find((a) => a.id === alerteId);
|
||||
if (!alerte) return { brut: null };
|
||||
|
||||
return {
|
||||
brut: {
|
||||
id: alerte.modele_id ?? '',
|
||||
nom: alerte.modele_id ? '' : alerte.titre,
|
||||
sources: alerte.sources,
|
||||
notes: `${alerte.resume}\n\n(alerte du ${alerte.date}, type : ${alerte.type})`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ModeleForm from '$lib/components/ModeleForm.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -14,5 +14,5 @@
|
||||
|
||||
<ModeleForm
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user