Initial commit — specs pré-projet SatPRO + site de présentation (viewer, annotations, gestion des accès)
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# === Secrets / configuration locale ===
|
||||||
|
# Jamais versionner le .env du site (identifiants admin, secret JWT).
|
||||||
|
site/.env
|
||||||
|
**/.env
|
||||||
|
|
||||||
|
# === Dépendances ===
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# === Artefacts de build Angular ===
|
||||||
|
site/frontend/dist/
|
||||||
|
site/frontend/.angular/
|
||||||
|
|
||||||
|
# === Données d'exécution (persistées sur l'hôte, non versionnées) ===
|
||||||
|
# Annotations et comptes gérés : un JSON par doc / users.json (mots de passe hachés).
|
||||||
|
# On garde les dossiers via leurs .gitkeep, mais pas leur contenu runtime.
|
||||||
|
site/annotations/*.json
|
||||||
|
site/users/*.json
|
||||||
|
|
||||||
|
# === Archives de déploiement ===
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# === Divers ===
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
inclusion: always
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design system — KiwUI for web app (DEKRA)
|
||||||
|
|
||||||
|
Toute interface, maquette ou composant produit pour SatPRO doit respecter le design
|
||||||
|
system **KiwUI DEKRA**. Référence détaillée (palette complète, typographie, boutons) :
|
||||||
|
`docs/design-system-kiwui.md`. Source : Figma `EIWttg1O7Lt8y8QzMW5ygs`.
|
||||||
|
|
||||||
|
## Règles essentielles
|
||||||
|
|
||||||
|
- **IMPORTANT : ne jamais coder en dur une couleur hex.** Utiliser les variables CSS de
|
||||||
|
tokens (voir `site/frontend/src/styles.scss`, préfixe `--ds-`) ou le token KiwUI nommé.
|
||||||
|
- **Police : Noto Sans** partout. Pas d'autre famille sans décision explicite.
|
||||||
|
- **Vert d'interface principal : Dark green 500 `#006b52`** (surfaces/accents).
|
||||||
|
Vert logo `#008143` réservé aux usages identitaires ponctuels.
|
||||||
|
- Couleurs **sémantiques** pour les états : succès (light green), info (dark blue),
|
||||||
|
avertissement (orange), danger (red). Voir la référence pour les échelles.
|
||||||
|
- **IMPORTANT : ne pas utiliser les tokens préfixés `❌`** (dépréciés dans Figma),
|
||||||
|
notamment l'ancienne échelle `❌ Blue` et les `*_alt`. Utiliser les sémantiques.
|
||||||
|
|
||||||
|
## Boutons (KiwUI)
|
||||||
|
|
||||||
|
- Axes : **Importance** (Primary / Secondary / Tertiary), **Status** (Neutral / Negative /
|
||||||
|
Disabled), **State** (Default / Hover / Focus / Pressed), icône/texte/les deux.
|
||||||
|
- Primary/Neutral : fond `#006b52`, texte blanc. Secondary/Neutral : texte `#006b52`,
|
||||||
|
fond clair/bordure. Negative : palette Red. Disabled : gris neutres.
|
||||||
|
|
||||||
|
## Flux Figma → code (quand on maquettera)
|
||||||
|
|
||||||
|
Quand on implémentera un écran depuis Figma :
|
||||||
|
1. `get_design_context` sur le(s) nœud(s) ciblé(s) ; si trop volumineux, `get_metadata`
|
||||||
|
puis re-cibler. Ajouter `get_screenshot` pour la fidélité visuelle.
|
||||||
|
2. Traduire la sortie Figma (souvent React + Tailwind) vers la **stack réelle du projet**
|
||||||
|
(le site des specs est en **Angular + SCSS**), en réutilisant les tokens `--ds-`.
|
||||||
|
3. Utiliser les sources d'assets `localhost` fournies par le serveur Figma telles quelles ;
|
||||||
|
ne pas installer de nouveaux packages d'icônes ni créer de placeholders.
|
||||||
|
4. Viser une parité visuelle 1:1 et valider contre le screenshot Figma.
|
||||||
|
|
||||||
|
> Note : nous sommes en phase « specs pré-projet ». Les **règles de design system
|
||||||
|
> techniques** complètes (`create_design_system_rules`) seront générées quand la stack de
|
||||||
|
> maquettage sera figée. Ce fichier fixe déjà le cadre visuel à respecter.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
tar czf satpro-site.tar.gz \
|
||||||
|
--exclude='site/frontend/node_modules' \
|
||||||
|
--exclude='site/backend/node_modules' \
|
||||||
|
--exclude='site/frontend/dist' \
|
||||||
|
--exclude='site/frontend/.angular' \
|
||||||
|
--exclude='site/.env' \
|
||||||
|
--exclude='site/annotations/*.json' \
|
||||||
|
--exclude='site/users/*.json' \
|
||||||
|
site docs
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Référence design system — KiwUI for web app (DEKRA)
|
||||||
|
|
||||||
|
> Source : Figma « KiwUI for web app – Design System DEKRA » (fichier `EIWttg1O7Lt8y8QzMW5ygs`).
|
||||||
|
> Tokens extraits via l'intégration Figma. Document de cadrage **visuel** pour la phase maquettage.
|
||||||
|
> ⚠️ Les tokens préfixés **❌** dans Figma sont **dépréciés** : ne pas les utiliser (listés en fin de document).
|
||||||
|
|
||||||
|
## 1. Principes
|
||||||
|
|
||||||
|
- **Design system** officiel DEKRA pour les applications web (équipe KiwUI).
|
||||||
|
- Police : **Noto Sans**.
|
||||||
|
- Identité : dominante **verte DEKRA** (vert logo + verts foncés), gris neutres, et couleurs **sémantiques** (succès / info / avertissement / danger).
|
||||||
|
- ~120 composants / 1 200+ variantes dans la bibliothèque (boutons, etc.).
|
||||||
|
|
||||||
|
## 2. Couleurs
|
||||||
|
|
||||||
|
### 2.1 Vert logo (identité)
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| Logo green 500 | `#008143` |
|
||||||
|
|
||||||
|
### 2.2 Vert foncé (Dark green) — couleur primaire d'interface
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| Dark green 50 | `#f2f8f6` |
|
||||||
|
| Dark green 100 | `#cce1dc` |
|
||||||
|
| Dark green 200 | `#b3d3cb` |
|
||||||
|
| Dark green 300 | `#80b5a9` |
|
||||||
|
| Dark green 400 | `#4d9786` |
|
||||||
|
| **Dark green 500** | `#006b52` |
|
||||||
|
| Dark green 600 | `#01604a` |
|
||||||
|
| Dark green 700 | `#015642` |
|
||||||
|
| Dark green 800 | `#014031` |
|
||||||
|
| Dark green 900 | `#002019` |
|
||||||
|
|
||||||
|
### 2.3 Vert vif (Bright green) — accent
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| Bright green 50 | `#efffe4` |
|
||||||
|
| Bright green 100 | `#c8fea1` |
|
||||||
|
| Bright green 200 | `#b0fd78` |
|
||||||
|
| Bright green 300 | `#9ee46c` |
|
||||||
|
| Bright green 400 | `#7bb154` |
|
||||||
|
| Bright green 500 | `#527638` |
|
||||||
|
| Bright green 600 | `#466530` |
|
||||||
|
|
||||||
|
### 2.4 Jaune (secondaire)
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| Yellow 50 | `#fff9e0` |
|
||||||
|
| Yellow 100 | `#ffe066` |
|
||||||
|
| Yellow 200 | `#ffcc00` |
|
||||||
|
| Yellow 300 | `#e6b800` |
|
||||||
|
| Yellow 400 | `#bf9900` |
|
||||||
|
| Yellow 500 | `#876c00` |
|
||||||
|
| Yellow 600 | `#735c00` |
|
||||||
|
| secondary/yellow WCAG 700 | `#594700` |
|
||||||
|
|
||||||
|
### 2.5 Gris (neutres)
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| Gray 50 | `#f8f8f8` |
|
||||||
|
| Gray 100 | `#ededed` |
|
||||||
|
| Gray 200 | `#d2d2d2` |
|
||||||
|
| Gray 300 | `#b4b4b4` |
|
||||||
|
| Gray 400 | `#9d9d9d` |
|
||||||
|
| Gray 500 | `#696969` |
|
||||||
|
| Gray 600 | `#595959` |
|
||||||
|
| Gray 700 | `#444444` |
|
||||||
|
| Gray 800 | `#353535` |
|
||||||
|
| Gray 1000 | `#1e1e1e` |
|
||||||
|
| Neutral 50 | `#fafafa` |
|
||||||
|
|
||||||
|
### 2.6 Couleurs sémantiques
|
||||||
|
|
||||||
|
**Succès (Light green)**
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| 50 | `#f0f7ea` |
|
||||||
|
| 100 | `#d1e8c0` |
|
||||||
|
| 200 | `#b2d996` |
|
||||||
|
| 300 | `#93ca6b` |
|
||||||
|
| 400 | `#64b32c` |
|
||||||
|
| 500 | `#467d1f` |
|
||||||
|
| 600 | `#386519` |
|
||||||
|
| 700 | `#2b4d13` |
|
||||||
|
| 800 | `#1e360d` |
|
||||||
|
| 900 | `#101d07` |
|
||||||
|
|
||||||
|
**Info (Dark blue)**
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| 50 | `#f5f7f8` |
|
||||||
|
| 100 | `#e6eaee` |
|
||||||
|
| 200 | `#ccd5dd` |
|
||||||
|
| 300 | `#b3c0cc` |
|
||||||
|
| 400 | `#8097aa` |
|
||||||
|
| 500 | `#4d6d88` |
|
||||||
|
| 600 | `#335877` |
|
||||||
|
| 800 | `#002e55` |
|
||||||
|
| 900 | `#001c33` |
|
||||||
|
|
||||||
|
**Avertissement (Orange)**
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| 50 | `#fef6ed` |
|
||||||
|
| 100 | `#fbdebf` |
|
||||||
|
| 200 | `#f8c48c` |
|
||||||
|
| 300 | `#f4a34c` |
|
||||||
|
| 400 | `#ef7d02` |
|
||||||
|
| 500 | `#af5b00` |
|
||||||
|
| 600 | `#944d00` |
|
||||||
|
| 700 | `#733c00` |
|
||||||
|
| 800 | `#542b00` |
|
||||||
|
|
||||||
|
**Danger (Red)**
|
||||||
|
| Token | Hex |
|
||||||
|
|-------|-----|
|
||||||
|
| 50 | `#fdf2f2` |
|
||||||
|
| 100 | `#f8d9d9` |
|
||||||
|
| 200 | `#f3bfbf` |
|
||||||
|
| 300 | `#ed9999` |
|
||||||
|
| 400 | `#e36666` |
|
||||||
|
| 500 | `#d10000` |
|
||||||
|
| 600 | `#bc0000` |
|
||||||
|
| 700 | `#9d0000` |
|
||||||
|
| 800 | `#7d0000` |
|
||||||
|
| 900 | `#3f0000` |
|
||||||
|
|
||||||
|
### 2.7 Tokens sémantiques d'usage (rôles)
|
||||||
|
| Rôle | Valeur |
|
||||||
|
|------|--------|
|
||||||
|
| text-primary | `#1e1e1e` |
|
||||||
|
| text-btn-primary | `#ffffff` |
|
||||||
|
| text-btn-secondary-neutral | `#006b52` |
|
||||||
|
| surface-primary | `#ffffff` |
|
||||||
|
| surface-secondary-bold | `#006b52` |
|
||||||
|
| surface-btn-overlay-dark | `#353535b2` |
|
||||||
|
| border-primary | `#696969` |
|
||||||
|
| border-secondary | `#d2d2d2` |
|
||||||
|
| border-icon-primary | `#ffffff` |
|
||||||
|
| border-icon-tertiary | `#353535` |
|
||||||
|
| White | `#ffffff` |
|
||||||
|
|
||||||
|
### 2.8 Ombres / effets
|
||||||
|
| Token | Définition |
|
||||||
|
|-------|-----------|
|
||||||
|
| shadow-soft | `#1e1e1e26` (noir ~15 % alpha) |
|
||||||
|
| Card / Modal drop shadow | drop-shadow offset (0,0), **radius 10**, spread 0, couleur shadow-soft |
|
||||||
|
|
||||||
|
## 3. Typographie
|
||||||
|
|
||||||
|
Police unique : **Noto Sans**. Styles nommés relevés :
|
||||||
|
|
||||||
|
| Style | Famille | Poids | Taille | Interligne |
|
||||||
|
|-------|---------|-------|--------|------------|
|
||||||
|
| 22px Semibold – Big text | Noto Sans | 600 | 22 | 32 |
|
||||||
|
| 14px Regular – Text | Noto Sans | 400 | 14 | 18 |
|
||||||
|
| 13px Regular – Text | Noto Sans | 400 | 13 | 18 |
|
||||||
|
| 13px Regular – Button | Noto Sans | 400 | 13 | 18 |
|
||||||
|
|
||||||
|
> Letter-spacing : 0 sur tous les styles relevés.
|
||||||
|
> Taille de base relevée : `Size/18px | 1rem` = 18.
|
||||||
|
|
||||||
|
## 4. Boutons (taxonomie des variantes)
|
||||||
|
|
||||||
|
Le composant **Button** se décline selon plusieurs axes (relevés dans la bibliothèque) :
|
||||||
|
|
||||||
|
- **Importance** : `Primary`, `Secondary`, `Tertiary`.
|
||||||
|
- **Status** : `Neutral`, `Negative` (danger), `Disabled` (+ variantes dépréciées `❌ Neutral_alt`).
|
||||||
|
- **State** : `Default`, `Hover`, `Focus`, `Pressed step 1`, `Pressed step 2`, `Disabled`.
|
||||||
|
- **Contenu** : icône seule / texte seul / icône + texte.
|
||||||
|
- Variantes composées : `Button Group`, `isbreadcrumb`.
|
||||||
|
|
||||||
|
Repères de couleur boutons :
|
||||||
|
- **Primary / Neutral** : fond vert foncé `#006b52` (surface-secondary-bold), texte blanc.
|
||||||
|
- **Secondary / Neutral** : texte vert `#006b52` (text-btn-secondary-neutral), fond clair / bordure.
|
||||||
|
- **Negative** : palette **Red (Danger)** (`#d10000` et dérivés).
|
||||||
|
- **Disabled** : neutres gris.
|
||||||
|
|
||||||
|
## 5. ⚠️ Tokens dépréciés (ne pas utiliser)
|
||||||
|
|
||||||
|
Préfixés `❌` dans Figma — remplacés par les sémantiques ci-dessus :
|
||||||
|
|
||||||
|
- `❌ Blue` (toute l'échelle 50→900) → utiliser **Info / Dark blue**.
|
||||||
|
- `❌ text-secondary_alt`, `❌ text-btn-secondary-neutral_alt`, `❌ surface-secondary_alt` (`#0f508a`).
|
||||||
|
- `❌ Neutral_alt` (états de boutons).
|
||||||
|
|
||||||
|
## 6. Correspondance avec le site des specs
|
||||||
|
|
||||||
|
Le site de présentation des specs applique un sous-ensemble de ces tokens (voir
|
||||||
|
`site/frontend/src/styles.scss`). Correspondance retenue :
|
||||||
|
|
||||||
|
| Usage site | Token KiwUI |
|
||||||
|
|------------|-------------|
|
||||||
|
| Vert principal (en-tête, accents) | Dark green 500 `#006b52` |
|
||||||
|
| Vert logo (ponctuel) | Logo green 500 `#008143` |
|
||||||
|
| Vert foncé (titres, hover) | Dark green 700 `#015642` |
|
||||||
|
| Fond clair de section | Dark green 50 `#f2f8f6` |
|
||||||
|
| Texte principal | `#1e1e1e` |
|
||||||
|
| Bordures | Gray 200 `#d2d2d2` |
|
||||||
|
| Fond gris | Gray 50 `#f8f8f8` |
|
||||||
|
| Succès / Info / Warning / Danger | Sémantiques §2.6 |
|
||||||
|
| Police | Noto Sans |
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# SatPRO — Specs pré-projet (métier)
|
||||||
|
|
||||||
|
> **Phase : Specs pré-projet.** Ces documents décrivent **le besoin métier** de la réécriture de SatPRO.
|
||||||
|
> Ils ne contiennent **aucune spécification technique**, **aucun choix d'architecture** et **aucune conception IT**.
|
||||||
|
> Objectif : servir de base de discussion pour les ateliers avec les opérationnels, puis de référentiel métier pour la suite du projet.
|
||||||
|
|
||||||
|
## De quoi parle-t-on
|
||||||
|
|
||||||
|
SatPRO est l'outil de **gestion de l'activité** d'une entreprise de services de contrôle. Il pilote le cycle de vie commercial et opérationnel d'un **contrat** client, de sa mise en place jusqu'à la facturation des interventions produites.
|
||||||
|
|
||||||
|
La présente phase vise à **réécrire et moderniser** l'outil existant :
|
||||||
|
- modernisation **visuelle** de l'interface,
|
||||||
|
- amélioration de l'**expérience utilisateur**,
|
||||||
|
- reprise, ajustement, enrichissement ou suppression des processus actuels (à arbitrer en atelier).
|
||||||
|
|
||||||
|
## Guide de lecture
|
||||||
|
|
||||||
|
| # | Document | Contenu |
|
||||||
|
|---|----------|---------|
|
||||||
|
| 00 | [Sommaire](00-sommaire.md) | Ce document : index et guide de lecture |
|
||||||
|
| 01 | [Vision & périmètre](01-vision-et-perimetre.md) | Objectif de la réécriture, scope / hors-scope, systèmes externes |
|
||||||
|
| 02 | [Glossaire](02-glossaire.md) | Vocabulaire métier et sigles |
|
||||||
|
| 03 | [Acteurs & rôles](03-acteurs-et-roles.md) | Qui fait quoi dans SatPRO |
|
||||||
|
| 04 | [Processus global](04-processus-global.md) | Vue d'ensemble du cycle de vie |
|
||||||
|
| 05 | [Initialisation du contrat](05-initialisation-contrat.md) | Sous-process |
|
||||||
|
| 06 | [Gestion des accords](06-accords.md) | Sous-process **optionnel** (fonctionnalité existante) |
|
||||||
|
| 07 | [Prestations](07-prestations.md) | Sous-process |
|
||||||
|
| 08 | [Interventions](08-interventions.md) | Sous-process |
|
||||||
|
| 09 | [RFI — Revue de Fin d'Intervention](09-rfi.md) | Sous-process |
|
||||||
|
| 10 | [Facturation](10-facturation.md) | Sous-process |
|
||||||
|
| 11 | [Sous-traitance (STI / STE / intra-groupe)](11-sous-traitance.md) | Sous-process |
|
||||||
|
| 12 | [Fonctions transverses](12-fonctions-transverses.md) | Historisation, recherche, paramétrage, workflows, MAJ en masse |
|
||||||
|
| 13 | [Pilotage](13-pilotage.md) | Sous-process (à définir) |
|
||||||
|
| 14 | [Questions & points ouverts](14-questions-ateliers.md) | Décisions attendues en atelier |
|
||||||
|
| 15 | [Journal des décisions](15-journal-decisions.md) | Retours d'atelier et décisions appliquées (suivi) |
|
||||||
|
|
||||||
|
## Trame des fiches de sous-process
|
||||||
|
|
||||||
|
Chaque fiche de sous-process suit la même structure pour faciliter la lecture et l'animation des ateliers :
|
||||||
|
|
||||||
|
1. **Objectif** du sous-process
|
||||||
|
2. **Acteurs** concernés
|
||||||
|
3. **Déclencheur / entrée** et **sortie**
|
||||||
|
4. **Étapes** du sous-process
|
||||||
|
5. **Règles de gestion**
|
||||||
|
6. **Données** : reçues / paramétrables dans SatPRO / envoyées vers les systèmes externes
|
||||||
|
7. **Cas particuliers**
|
||||||
|
8. **Points ouverts** (à trancher en atelier)
|
||||||
|
|
||||||
|
## Convention de statut des points ouverts
|
||||||
|
|
||||||
|
Dans les fiches, les éléments non arbitrés sont marqués :
|
||||||
|
|
||||||
|
- 🟢 **Conserver** — fonctionnement actuel à reconduire
|
||||||
|
- 🟡 **À ajuster / enrichir** — à retravailler en atelier
|
||||||
|
- 🔴 **À définir** — non spécifié aujourd'hui, décision attendue
|
||||||
|
- ⚪ **À confirmer** — hypothèse de rédaction à valider par le métier
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 01 — Vision & périmètre
|
||||||
|
|
||||||
|
## 1. Contexte
|
||||||
|
|
||||||
|
SatPRO est un outil **existant** de gestion de l'activité d'une entreprise de services de contrôle. Il orchestre le cycle de vie d'un contrat client : mise en place contractuelle, découpage en prestations, production des interventions, contrôle de conformité (RFI) et déclenchement de la facturation.
|
||||||
|
|
||||||
|
Le projet consiste à **réécrire** cet outil, pas à ajouter une fonction isolée. La réécriture poursuit trois intentions :
|
||||||
|
|
||||||
|
- **Moderniser l'interface** (aspect visuel).
|
||||||
|
- **Améliorer l'expérience utilisateur** des acteurs internes (commerce, administration, management, ordonnancement).
|
||||||
|
- **Capitaliser sur l'existant** : reprendre ce qui fonctionne, ajuster ou enrichir ce qui doit l'être, supprimer ce qui n'a plus lieu d'être. Ces arbitrages se font en atelier avec les opérationnels.
|
||||||
|
|
||||||
|
## 2. Objectifs de la phase « Specs pré-projet »
|
||||||
|
|
||||||
|
Cette phase produit **le besoin métier**, pas la solution technique. Concrètement, pour chaque étape du cycle de vie du contrat :
|
||||||
|
|
||||||
|
- suivre le **cycle de vie** d'un contrat dans SatPRO ;
|
||||||
|
- reprendre les **sous-process** de chaque étape ;
|
||||||
|
- reprendre, ajuster, enrichir, revoir ou supprimer **l'existant** ;
|
||||||
|
- définir les **données attendues** à chaque étape.
|
||||||
|
|
||||||
|
## 3. Périmètre fonctionnel (dans le scope)
|
||||||
|
|
||||||
|
Le périmètre couvert par SatPRO va de l'**initialisation du contrat** à la **préparation / déclenchement de la facturation**. Les blocs suivants sont dans le scope de la réécriture :
|
||||||
|
|
||||||
|
| Bloc | Objet |
|
||||||
|
|------|-------|
|
||||||
|
| Gestion des accords | Gestion des accords de référencement (BPU, règles d'indexation, périmètre autorisé) auxquels des contrats sont rattachés — **fonctionnalité existante de SatPRO** |
|
||||||
|
| Initialisation du contrat | Création du contrat depuis l'offre (ou une commande directe rattachée à un accord), paramétrage administratif, de production, de facturation et d'indexation, gestion des destinataires |
|
||||||
|
| Prestations | Découpage des missions vendues en prestations (canevas), héritage et propagation, regroupement / dégroupement |
|
||||||
|
| Interventions | Production concrète chez le client : affectation, planification, RDV, suivi des statuts |
|
||||||
|
| RFI | Revue de fin d'intervention : contrôle de conformité production vs contrat, gestion des écarts |
|
||||||
|
| Facturation | Préparation et déclenchement de la facture, cas particuliers, refacturation |
|
||||||
|
| Sous-traitance | Gestion STI (interne à la filiale), STE (externe) et sous-traitance intra-groupe |
|
||||||
|
| Fonctions transverses | Historisation, recherche, paramétrage par défaut, workflows de validation, mises à jour en masse |
|
||||||
|
| Pilotage | Suivi de l'activité (à définir) |
|
||||||
|
|
||||||
|
## 4. Hors périmètre (hors scope)
|
||||||
|
|
||||||
|
Les éléments suivants ne sont **pas** réalisés dans SatPRO ; SatPRO échange avec eux mais ne les remplace pas :
|
||||||
|
|
||||||
|
- Le bloc **Contact & opportunités** (amont commercial).
|
||||||
|
- Le bloc **Offre** (élaboration et validation du devis) — en amont, alimente SatPRO.
|
||||||
|
- L'outil de **production terrain** (saisie par le technicien sur le terrain) — en aval, renvoie les données de production à SatPRO.
|
||||||
|
- L'outil de **facturation** proprement dit — SatPRO prépare et transmet, la facture est émise par cet outil.
|
||||||
|
- La **planification maître** lorsque le dossier est géré hors SatPRO (voir systèmes externes).
|
||||||
|
|
||||||
|
> 🔴 **Fonctionnalités existantes à supprimer (décision atelier).**
|
||||||
|
> - **C2I** : la fonctionnalité existante C2I **n'est pas reconduite** dans la réécriture.
|
||||||
|
> Le besoin qu'elle couvrait (notamment la gestion du **déplacement infructueux** en « mode
|
||||||
|
> échec ») doit être **repensé** dans le nouvel outil (voir [10 — Facturation](10-facturation.md)).
|
||||||
|
|
||||||
|
### 4.1 Règle de création d'un contrat
|
||||||
|
|
||||||
|
> 🟢 **Décision atelier.** Un contrat ne peut être créé **que** :
|
||||||
|
> - à partir d'une **offre** (cas général, y compris offre d'avenant) ; **ou**
|
||||||
|
> - à partir d'une **commande directe rattachée à un accord** de référencement
|
||||||
|
> (création du contrat puis de ses prestations).
|
||||||
|
>
|
||||||
|
> Une **commande directe** rattachée à un **contrat existant** ne crée pas de contrat :
|
||||||
|
> elle **déclenche la part de prestation** déjà présente au contrat relative à cette commande
|
||||||
|
> (complément de prestation). Voir [05 — Initialisation du contrat](05-initialisation-contrat.md).
|
||||||
|
|
||||||
|
> Dans cette phase, aucune intégration technique n'est décrite. Les systèmes externes sont nommés uniquement pour identifier **les données échangées** avec SatPRO.
|
||||||
|
|
||||||
|
## 5. Systèmes externes en interface
|
||||||
|
|
||||||
|
| Système | Rôle vis-à-vis de SatPRO | Sens principal |
|
||||||
|
|---------|--------------------------|----------------|
|
||||||
|
| **Bingo Pro** | Offre / devis approuvé — source de création automatique du contrat | → vers SatPRO |
|
||||||
|
| **CRM** | Identité client (raison sociale, SIREN, adresse, GPS) redescendue automatiquement | → vers SatPRO |
|
||||||
|
| **PlanUp / 7Opteam** | Affectation et planification des techniciens ; peut être maître de la planification | ↔ |
|
||||||
|
| **Saisie Terrain** (outil de production) | Données de production remontées en fin d'intervention (temps réel, quantités, observations, photos, écarts, rapport) | → vers SatPRO |
|
||||||
|
| **DekraBill / DBill** | Outil de facturation : reçoit les paramètres et les lignes de facture préparés par SatPRO | ← depuis SatPRO |
|
||||||
|
| **Controlling** | Tarifs de sous-traitance par code matériel (mise à jour annuelle) | → vers SatPRO |
|
||||||
|
| **Cubes** | Restitution décisionnelle (CA facturé, coûts de sous-traitance, etc.) | ← depuis SatPRO |
|
||||||
|
|
||||||
|
> ⚪ **À confirmer** : la liste et le périmètre exact de chaque interface sont à valider en atelier. Les noms d'outils sont repris du support d'atelier.
|
||||||
|
|
||||||
|
> 🟢 **Précision atelier.** La **gestion des accords** (de référencement) n'est **pas** un système externe : c'est une **fonctionnalité existante de SatPRO**, intégrée au périmètre de la réécriture (voir §3 et la fiche [06 — Gestion des accords](06-accords.md)). Un contrat peut être rattaché à un accord et en hériter (BPU, indexation, périmètre autorisé).
|
||||||
|
|
||||||
|
## 6. Principes métier structurants
|
||||||
|
|
||||||
|
Ces principes traversent tout l'outil et doivent être tenus dans la réécriture :
|
||||||
|
|
||||||
|
- **Hiérarchie Contrat → Prestation → Intervention.** La prestation est le canevas ; l'intervention est la production concrète d'une part du contrat chez le client.
|
||||||
|
- **Héritage descendant.** Les prestations héritent du contrat ; les interventions héritent de la prestation.
|
||||||
|
- **Propagation vers le non-produit uniquement.** Toute évolution d'un paramètre héritable redescend sur les interventions **non encore produites**. Les interventions **déjà produites conservent** le paramétrage figé au moment de leur production (historique).
|
||||||
|
- **Priorité du niveau le plus bas.** Une donnée saisie au niveau prestation (ou intervention) devient « prioritaire » sur la valeur héritée.
|
||||||
|
- **Contrôle avant facturation.** Aucune facturation ne part tant que la conformité de la production n'est pas validée (RFI) : automatique si aucun écart, manuelle (acte administratif) en cas d'écart.
|
||||||
|
- **Traçabilité.** Les modifications sur contrat / prestation / intervention sont historisées (qui, quoi, quand, valeur d'origine).
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 02 — Glossaire
|
||||||
|
|
||||||
|
> Vocabulaire métier et sigles utilisés dans les specs pré-projet SatPRO.
|
||||||
|
> ⚪ Les définitions marquées sont à confirmer / préciser en atelier.
|
||||||
|
|
||||||
|
## Objets du cycle de vie
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **Contrat** | Engagement qui lie le client à l'entreprise. Porte l'ensemble des paramètres : parties, dates, reconduction, missions vendues, sites, périodicité, prix, destinataires des avis / rapports / factures, règles de facturation et d'indexation. Point de départ du cycle de vie dans SatPRO. |
|
||||||
|
| **Prestation** | « Chapeau » / canevas issu du découpage des missions vendues. Regroupe les missions de **même périodicité**, **même domaine technique** et **même lieu d'intervention**. Hérite du contrat et sert de modèle à toutes les interventions qui en découlent sur la durée du contrat. |
|
||||||
|
| **Intervention (ITV)** | Production concrète d'une part du contrat chez le client. Hérite de la prestation, peut porter des informations propres. Cadre les intervenants, les compétences requises et le ou les rendez-vous. |
|
||||||
|
| **RDV de service** | Rendez-vous planifié d'une intervention. Une intervention peut comporter **plusieurs** RDV de service. |
|
||||||
|
| **Mission** | Prestation technique vendue (type de contrôle). Associée à un code matériel et à des qualifications. |
|
||||||
|
| **Site / Lieu d'intervention** | Emplacement physique où se déroule la production chez le client. |
|
||||||
|
|
||||||
|
## Acteurs et organisation
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **CDP** | Centre de profit. Entité organisationnelle gestionnaire d'un contrat. |
|
||||||
|
| **CDP gestionnaire** | CDP auquel le contrat est rattaché et qui en assure la gestion. |
|
||||||
|
| **Apporteur d'affaire (AA)** | Acteur à l'origine de l'affaire. ⚪ Obligatoire à la mise en production (MEP). |
|
||||||
|
| **Manager** | Responsable d'un CDP ; intervient notamment dans l'escalade RFI et le suivi de la sous-traitance de son CDP. |
|
||||||
|
| **Admin / Administrative** | Acteur administratif qui paramètre le contrat et traite les actes manuels (consignes, validation d'écarts RFI, facturation). |
|
||||||
|
| **Ordonnancement (Ordo)** | Acteur en charge de l'affectation et de la planification. |
|
||||||
|
| **Intervenant / Technicien** | Personne qui réalise la production sur le terrain. |
|
||||||
|
|
||||||
|
## Contrat & facturation
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **Donneur d'ordre** | Entité qui commande la prestation. |
|
||||||
|
| **Compte payeur / Payeur** | Entité destinataire de la facture, définie au contrat (ou spécifiquement à la prestation). |
|
||||||
|
| **BDC** | Bon de commande (document, référence, date, validité). Peut être rendu obligatoire pour facturer. |
|
||||||
|
| **BPU** | Bordereau de prix unitaires (issu d'un accord de référencement). |
|
||||||
|
| **Accord (de référencement)** | Cadre tarifaire et contractuel auquel un contrat peut être rattaché. Apporte une **grille tarifaire** (BPU), des règles d'indexation et un périmètre autorisé. Voir aussi « Natures de contrat ». |
|
||||||
|
| **Indexation / Réindexation** | Règle de révision des prix : date, formule, pourcentage fixe, etc. |
|
||||||
|
| **Minima de facturation** | Montant plancher appliqué à la facturation. |
|
||||||
|
| **Complément de facturation** | Ligne ajoutée à la facture (ex. déclenchée par un fait générateur). |
|
||||||
|
| **Frais annexes refacturables** | Frais (ex. déplacement) pouvant être refacturés au client. |
|
||||||
|
| **Fait générateur** | Événement qui autorise/déclenche la facturation (déplacement, annulation, production partielle, etc.). |
|
||||||
|
| **Mode de facturation** | Modalité de facturation : par intervention (ITV), globale, ou FE. ⚪ *FE à expliciter.* |
|
||||||
|
| **Proforma** | Facture provisoire, sans valeur comptable définitive. |
|
||||||
|
| **Avoir** | Document d'annulation / de régularisation d'une facture. |
|
||||||
|
|
||||||
|
## Natures de contrat
|
||||||
|
|
||||||
|
> Trois natures, selon le rattachement et le mode de vente (précisions atelier) :
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **Contrat gré à gré** | Contrat **non** rattaché à un accord de référencement **ni** directif. Issu **obligatoirement d'une offre** (ou d'une offre d'avenant pour le compléter) ; il **ne peut pas** être déclenché par un simple bon de commande client. |
|
||||||
|
| **Contrat rattaché à un accord de référencement** | Contrat qui hérite de la **grille tarifaire** (BPU) de l'accord. Peut vendre des missions de la grille **au même prix, à un prix différent**, ou des **missions complémentaires** (à prix propre au contrat). |
|
||||||
|
| **Contrat directif** | Variante d'accord où le **prix de la grille doit être suivi** (pas de prix dérogé) et où l'on **ne peut pas** vendre de mission complémentaire (sinon elle relève d'un contrat propre, hors directif). Volume de prestations à consommer : suivi de consommation, clôture automatique. Non rattachable à un autre accord. |
|
||||||
|
| **Commande directe** | Commande client qui, **rattachée à un accord**, crée un contrat (puis ses prestations) ; ou, **rattachée à un contrat existant**, déclenche la **part de prestation** du contrat relative à la commande (complément). Ne crée pas de contrat dans ce second cas. |
|
||||||
|
| **Contrat à bon de commande** | Contrat où certaines prestations ne deviennent **productibles** que lorsque le **bon de commande client** correspondant a été passé ; sinon l'intervention est **non valide** et ne peut être produite. |
|
||||||
|
|
||||||
|
## Production & conformité
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **MEP** | Mise en production. Étape qui déclenche le découpage du contrat en prestations puis la génération des interventions. |
|
||||||
|
| **RFI** | Revue de Fin d'Intervention. Contrôle de conformité entre la production réellement réalisée et ce qui a été vendu au contrat. |
|
||||||
|
| **Écart** | Différence constatée entre la production remontée du terrain et le contrat (quantité, temps, missions réalisées). Qualifié d'**acceptable** ou **non acceptable**. |
|
||||||
|
| **Avenant** | Modification contractuelle générée lorsqu'un écart non acceptable impose un ajustement du contrat (traité dans le bloc Offre, hors SatPRO). |
|
||||||
|
| **Recalage** | Ajustement du contrat / des interventions futures suite aux retours RFI. |
|
||||||
|
| **Code état** | Statut d'avancement d'une intervention (ex. suspendue, annulée, reprise, terminée). Indépendant du code facturation. |
|
||||||
|
| **Code facturation** | Statut de facturation d'une intervention. Indépendant du code état. |
|
||||||
|
| **FPV** | ⚪ *À préciser en atelier.* Paramètre lié à la vérification (type de sortie, lieu de vérification). |
|
||||||
|
| **Avis d'intervention / Avis de passage** | Notification envoyée au client avant l'intervention (ex. automatique à J-14, paramétrable). |
|
||||||
|
|
||||||
|
## Consignes & documents
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **NA** | Note administrative. Consigne administrative pouvant être jointe au contrat / à la prestation. |
|
||||||
|
| **NP** | Note de production. Consigne de production transmise notamment au technicien. |
|
||||||
|
| **Consignes administratives** | Instructions de gestion administrative saisies par l'admin. |
|
||||||
|
| **Consignes de production** | Instructions de production saisies par l'admin et transmises à la production. |
|
||||||
|
| **Rapport** | Document de restitution de l'intervention (PDF + pièces jointes), paramétrable (format, façonnage). |
|
||||||
|
| **Paramètres LI (L1 à L4)** | **4 valeurs texte** paramétrables **au niveau du client** uniquement. **Aucun lien d'héritage** vers le contrat ou la prestation. 🟡 Usage/signification de chaque valeur à préciser en atelier. |
|
||||||
|
|
||||||
|
## Sous-traitance
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **STI** | Sous-traitance interne **à la filiale** (pas au niveau groupe). Déclenchée par défaut, utilisée si les ressources qualifiées du CDP gestionnaire sont indisponibles. |
|
||||||
|
| **STE** | Sous-traitance externe (fournisseur **hors groupe**). Ajoutée par l'AA ou l'ordonnancement, pour un travail complet ou une prestation partielle. |
|
||||||
|
| **Sous-traitance intra-groupe** | Sous-traitance dont le fournisseur est une **autre filiale du groupe**. Fonctionne **comme une STE** (mêmes mécanismes), mais le prestataire est interne au groupe. |
|
||||||
|
| **Marge** | Écart entre le prix vendu et le coût de sous-traitance ; calculée automatiquement. |
|
||||||
|
|
||||||
|
## Systèmes externes
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **Bingo Pro** | Outil d'offre / devis approuvé, source de création automatique du contrat. |
|
||||||
|
| **CRM** | Référentiel client (identité, SIREN, adresse, GPS). |
|
||||||
|
| **PlanUp / 7Opteam** | Outil(s) de planification des techniciens ; peut être maître de la planification (dossier « Lié PlanUp »). |
|
||||||
|
| **Saisie Terrain** | Outil de production terrain utilisé par le technicien. |
|
||||||
|
| **DekraBill / DBill** | Outil de facturation. |
|
||||||
|
| **Controlling** | Fonction fournissant les tarifs de sous-traitance par code matériel. |
|
||||||
|
| **Cubes** | Environnement de restitution décisionnelle / reporting. |
|
||||||
|
| **Power BI** | Outil de tableaux de bord et d'analyse décisionnelle (reporting). |
|
||||||
|
|
||||||
|
## Fonctionnalités existantes non reconduites
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **C2I** | ❌ Fonctionnalité existante **supprimée** dans la réécriture. Elle gérait notamment le **déplacement infructueux** (« mode échec ») ; ce besoin est à repenser dans le nouvel outil (voir [10 — Facturation](10-facturation.md)). |
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 03 — Acteurs & rôles
|
||||||
|
|
||||||
|
> Les acteurs **internes** utilisent SatPRO. Les acteurs/systèmes **externes** échangent des données avec SatPRO sans en être utilisateurs directs.
|
||||||
|
> ⚪ Le détail des responsabilités et des droits par rôle est à valider en atelier.
|
||||||
|
|
||||||
|
## 1. Acteurs internes (utilisateurs de SatPRO)
|
||||||
|
|
||||||
|
| Acteur | Rôle principal | Interventions clés dans le cycle de vie |
|
||||||
|
|--------|----------------|------------------------------------------|
|
||||||
|
| **Commercial** | Élabore et fait valider l'offre (bloc Offre, en amont). Saisit les éléments du bloc offre repris automatiquement dans le contrat. | Alimente l'initialisation du contrat ; peut saisir les paramètres de facturation. |
|
||||||
|
| **Admin / Administrative** | Paramètre le contrat, saisit les consignes administratives et de production, traite les actes manuels. | Initialisation du contrat ; validation manuelle des écarts RFI ; préparation / blocage de facture. |
|
||||||
|
| **CDP gestionnaire** | Centre de profit rattaché au contrat. | Rattachement du contrat au bon référentiel. |
|
||||||
|
| **Manager (de CDP)** | Encadre un CDP. | Escalade RFI selon les cas ; suivi de la sous-traitance interne (STI) de son CDP. |
|
||||||
|
| **Apporteur d'affaire (AA)** | À l'origine de l'affaire. | Obligatoire à la MEP ; peut ajouter de la sous-traitance externe (STE). |
|
||||||
|
| **Ordonnancement (Ordo)** | Affecte et planifie les ressources. | Affectation / planification des interventions ; peut ajouter de la STE. |
|
||||||
|
| **Intervenant / Technicien** | Réalise la production sur le terrain. | Reçoit les éléments de production ; remonte les données de production (via Saisie Terrain). |
|
||||||
|
|
||||||
|
## 2. Acteur externe (bénéficiaire)
|
||||||
|
|
||||||
|
| Acteur | Rôle | Interactions |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| **Client** | Bénéficiaire des prestations de contrôle. Peut être donneur d'ordre et/ou payeur. | Reçoit l'avis d'intervention / de passage (ex. J-14), le rapport et la facture, selon les destinataires paramétrés au contrat / à la prestation. Peut demander une suspension d'intervention. |
|
||||||
|
|
||||||
|
## 3. Systèmes externes
|
||||||
|
|
||||||
|
Voir [01 — Vision & périmètre §5](01-vision-et-perimetre.md). Rappel synthétique :
|
||||||
|
|
||||||
|
| Système | Fournit à SatPRO | Reçoit de SatPRO |
|
||||||
|
|---------|------------------|------------------|
|
||||||
|
| **Bingo Pro** | Devis approuvé (données d'offre) | — |
|
||||||
|
| **CRM** | Identité client | — |
|
||||||
|
| **Accord** | BPU, indexation, périmètre autorisé | — |
|
||||||
|
| **PlanUp / 7Opteam** | Affectation technicien & créneaux (si maître) | Infos interventions à planifier |
|
||||||
|
| **Saisie Terrain** | Données de production (temps, quantités, écarts, rapport) | Éléments de production, consignes, NP |
|
||||||
|
| **DekraBill / DBill** | — | Paramètres et lignes de facture |
|
||||||
|
| **Controlling** | Tarifs de sous-traitance par code matériel | — |
|
||||||
|
| **Cubes** | — | Indicateurs (CA facturé, coûts STI, …) |
|
||||||
|
|
||||||
|
## 4. Matrice acteurs × étapes (RACI simplifié)
|
||||||
|
|
||||||
|
> ⚪ Première proposition à challenger en atelier. R = Réalise, C = Contribue/consulté.
|
||||||
|
|
||||||
|
| Étape | Commercial | Admin | CDP/Manager | Ordo | Technicien |
|
||||||
|
|-------|:---------:|:-----:|:-----------:|:----:|:----------:|
|
||||||
|
| Initialisation du contrat | C | **R** | C | | |
|
||||||
|
| Prestations | | **R** | C | C | |
|
||||||
|
| Interventions | | C | | **R** | C |
|
||||||
|
| Production (hors SatPRO) | | | | | **R** |
|
||||||
|
| RFI | | **R** | C (escalade) | | |
|
||||||
|
| Facturation | C | **R** | | | |
|
||||||
|
| Sous-traitance STI | | | **R** (manager) | C | |
|
||||||
|
| Sous-traitance STE | | | | **R** (AA/Ordo) | |
|
||||||
|
|
||||||
|
## 5. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Définir précisément les **droits par rôle** (lecture / écriture / validation) sur chaque bloc.
|
||||||
|
- 🔴 Préciser qui déclenche la **mise en production** (admin ? automatique ?).
|
||||||
|
- ⚪ Confirmer le rôle exact de l'**AA** vs l'**ordonnancement** dans la STE.
|
||||||
|
- 🔴 Définir les **workflows de validation** (qui valide quoi, escalades) — voir [12 — Fonctions transverses](12-fonctions-transverses.md).
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 04 — Processus global
|
||||||
|
|
||||||
|
## 1. Vue d'ensemble du cycle de vie
|
||||||
|
|
||||||
|
Le cycle de vie complet d'une affaire s'étend du contact commercial jusqu'à la facturation. SatPRO prend en charge le cœur de ce cycle (encadré ci-dessous), de l'**initialisation du contrat** à la **facturation**, en s'appuyant sur des blocs amont (Offre) et aval (Production terrain, Facturation DBill) hors de son périmètre.
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Contact & [ Offre ] ┌──────────────── SatPRO ────────────────┐
|
||||||
|
opportunités ] ──▶ (devis) ──▶ │ Initialisation du contrat │
|
||||||
|
(hors scope) (hors scope) │ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Gestion des accords (optionnel) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Prestations & interventions │
|
||||||
|
│ │ │
|
||||||
|
└─────────────┼───────────────────────────┘
|
||||||
|
▼
|
||||||
|
Affectation & planification
|
||||||
|
(PlanUp / 7Opteam)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Production (hors scope — terrain)
|
||||||
|
│
|
||||||
|
┌─────────────┼───────────────────────────┐
|
||||||
|
│ ▼ │
|
||||||
|
│ RFI │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Facturation ──▶ DBill │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Enchaînement : **Contact & opportunités → Offre → Initialisation du contrat → (Gestion des accords, optionnel) → Prestations & interventions → Affectation & planification → Production → RFI → Facturation.**
|
||||||
|
|
||||||
|
Les blocs **Initialisation du contrat**, **Gestion des accords** (optionnel), **Prestations & interventions**, **RFI** et **Facturation** sont réalisés dans SatPRO. Les blocs **Contact & opportunités**, **Offre**, **Affectation & planification** (quand maître externe) et **Production** sont hors scope mais interfacés.
|
||||||
|
|
||||||
|
## 2. Les grandes étapes
|
||||||
|
|
||||||
|
| # | Étape | Dans SatPRO | Fiche |
|
||||||
|
|---|-------|:-----------:|-------|
|
||||||
|
| 1 | Contact & opportunités | Non | — (hors scope) |
|
||||||
|
| 2 | Offre | Non | — (hors scope, alimente le contrat) |
|
||||||
|
| 3 | Initialisation du contrat | Oui | [05](05-initialisation-contrat.md) |
|
||||||
|
| 3bis | Gestion des accords (optionnel) | Oui | [06](06-accords.md) |
|
||||||
|
| 4 | Prestations | Oui | [07](07-prestations.md) |
|
||||||
|
| 5 | Interventions | Oui | [08](08-interventions.md) |
|
||||||
|
| 6 | Affectation & planification | Interface | [08](08-interventions.md) (PlanUp / 7Opteam) |
|
||||||
|
| 7 | Production | Non | — (hors scope, terrain) |
|
||||||
|
| 8 | RFI | Oui | [09](09-rfi.md) |
|
||||||
|
| 9 | Facturation | Oui (préparation) | [10](10-facturation.md) |
|
||||||
|
|
||||||
|
Transverses : [Sous-traitance](11-sous-traitance.md), [Fonctions transverses](12-fonctions-transverses.md), [Pilotage](13-pilotage.md).
|
||||||
|
|
||||||
|
## 3. Principes de propagation (fil rouge de bout en bout)
|
||||||
|
|
||||||
|
La chaîne **Accord → Contrat → Prestation → Intervention** fonctionne par **héritage descendant** :
|
||||||
|
|
||||||
|
1. Un contrat rattaché à un **accord** hérite du BPU, de l'indexation et du périmètre autorisé.
|
||||||
|
2. Les **prestations** sont générées depuis le contrat et héritent de ses paramètres.
|
||||||
|
3. Les **interventions** sont générées depuis les prestations et héritent de leurs paramètres (et des visites précédentes).
|
||||||
|
|
||||||
|
Règles de propagation :
|
||||||
|
|
||||||
|
- Toute modification d'un **élément héritable** sur un niveau se répercute sur le niveau inférieur **tant qu'il n'est pas produit**.
|
||||||
|
- Une donnée saisie à un niveau inférieur devient **prioritaire** et n'est pas écrasée par une modification du niveau supérieur.
|
||||||
|
- Les éléments **déjà produits** sont **figés** : ils conservent le paramétrage en vigueur au moment de leur production (valeur historique).
|
||||||
|
|
||||||
|
## 4. Statuts
|
||||||
|
|
||||||
|
Deux axes de statut **indépendants** sont suivis sur l'intervention :
|
||||||
|
|
||||||
|
- **Code état** : avancement opérationnel (ex. en cours, suspendue, annulée, reprise, terminée).
|
||||||
|
- **Code facturation** : avancement de facturation.
|
||||||
|
|
||||||
|
Les transitions de statut sont ajustées automatiquement selon des règles définies. 🔴 Les listes de valeurs et les règles de transition sont à formaliser en atelier.
|
||||||
|
|
||||||
|
## 5. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Formaliser la **liste des codes état** et des **codes facturation** et leurs règles de transition.
|
||||||
|
- 🟡 Clarifier le degré d'**automatisation** vs d'**action manuelle** à chaque passage d'étape (découpage, MEP, génération ITV, libération facture).
|
||||||
|
- ⚪ Confirmer la frontière exacte **SatPRO / PlanUp-7Opteam** sur l'affectation et la planification.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# 05 — Initialisation du contrat
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> Premier bloc du périmètre SatPRO : il transforme une offre validée en contrat exploitable, prêt pour la mise en production.
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Créer et paramétrer le contrat qui lie le client à l'entreprise, à partir de l'offre validée, puis le préparer pour la mise en production (découpage en prestations). Le contrat porte **l'ensemble des paramètres** qui seront ensuite hérités par les prestations et les interventions.
|
||||||
|
|
||||||
|
> 🟢 **Origine d'un contrat (décision atelier).** Un contrat ne peut être créé **que** :
|
||||||
|
> - à partir d'une **offre** (cas général, offre d'avenant incluse pour compléter un contrat existant) ; **ou**
|
||||||
|
> - à partir d'une **commande directe rattachée à un accord** de référencement.
|
||||||
|
>
|
||||||
|
> Une **commande directe rattachée à un contrat existant** ne crée pas de contrat :
|
||||||
|
> elle **déclenche la part de prestation** du contrat relative à cette commande (complément).
|
||||||
|
> Les trois natures de contrat (gré à gré, référencement, directif) sont définies au [glossaire](02-glossaire.md).
|
||||||
|
|
||||||
|
> 🟡 **Découpage pour gestion interne.** Le découpage d'un contrat pour des besoins de
|
||||||
|
> **gestion interne** ne doit **pas** être mené en agence. Reprendre toutefois les
|
||||||
|
> **contraintes actuelles** de cette pratique pour les corriger dans le nouvel outil
|
||||||
|
> (filtres manquants, **axe de regroupement paramétrable**).
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Commercial** — saisit les éléments du bloc offre, validés en revue de contrat (en amont).
|
||||||
|
- **Admin / Administrative** — paramètre le contrat dans SatPRO (consignes, destinataires, facturation si non saisie par le commerce).
|
||||||
|
- **CDP gestionnaire / Manager** — rattachement au bon référentiel.
|
||||||
|
|
||||||
|
## 3. Déclencheur & sortie
|
||||||
|
|
||||||
|
- **Déclencheur** : un devis est **approuvé** dans l'outil d'offre (Bingo Pro) et la revue de contrat est validée.
|
||||||
|
- **Sortie** : un contrat paramétré, mis en production, dont les paramètres sont transmis au bloc Prestations et à l'outil de facturation.
|
||||||
|
|
||||||
|
## 4. Étapes du sous-process
|
||||||
|
|
||||||
|
### 4.1 Réception des données d'offre
|
||||||
|
Création **automatique** du contrat à partir des données du bloc offre, **sans action admin**. Le commercial a saisi les éléments dans l'offre ; ils sont repris automatiquement dans SatPRO. L'identité client redescend automatiquement du CRM.
|
||||||
|
|
||||||
|
### 4.2 Assignation au bon référentiel
|
||||||
|
Rattachement du contrat au bon référentiel : définition du **CDP gestionnaire**, de l'**apporteur d'affaire**, et le cas échéant de l'**accord de référencement** auquel le contrat est rattaché.
|
||||||
|
|
||||||
|
### 4.3 Paramétrage du contrat
|
||||||
|
Saisie par l'admin des éléments non issus de l'offre : consignes administratives et de production (avec NA / NP jointes si plus exhaustives), horaires de disponibilité, mode de règlement, courriers client paramétrables, destinataires.
|
||||||
|
|
||||||
|
Paramètres complémentaires relevés en atelier :
|
||||||
|
|
||||||
|
- **Durée du contrat** : **durée finie** (date de fin) ou **durée indéterminée** (tacite reconduction).
|
||||||
|
- **Contrat à bon de commande** : certaines prestations n'ont leurs interventions **valides** (donc productibles) **que si le bon de commande client a été passé**. Une intervention non valide ne peut pas être produite.
|
||||||
|
- **Paramétrage imposé** : possibilité d'indiquer qu'un paramétrage est **imposé** et **ne peut être dérogé** au niveau inférieur (prestation / intervention). Complète le principe de « priorité du niveau le plus bas » : un paramètre marqué imposé verrouille la valeur héritée.
|
||||||
|
|
||||||
|
### 4.4 Paramétrage facturation & indexation
|
||||||
|
Saisie (si non renseignée par le commerce) des paramètres de facturation et des **règles d'indexation des prix** (date, formule, pourcentage fixe, etc.).
|
||||||
|
|
||||||
|
Précisions atelier :
|
||||||
|
|
||||||
|
- **Facturation additionnelle** : paramétrage de montants additionnels (ex. **intervention le week-end**, **minimum de facturation**, etc.).
|
||||||
|
- **Augmentation automatique des tarifs** : les tarifs doivent pouvoir être **augmentés automatiquement** selon la règle d'indexation paramétrée.
|
||||||
|
- **Origine de la règle d'augmentation** : selon le cas, l'augmentation suit une **directive commerciale** (secteur **privé**), les **CGV** (secteur **public**), ou une règle **propre au contrat**.
|
||||||
|
|
||||||
|
### 4.5 Gestion des destinataires
|
||||||
|
Définition des destinataires **par type d'interaction** : commerciale, administrative, avis d'intervention, rapport, facture, etc.
|
||||||
|
|
||||||
|
Précisions atelier :
|
||||||
|
|
||||||
|
- **Destinataire des livrables** : définir des destinataires pour les livrables (avis d'intervention, rapport, etc.). Pour chaque livrable, un destinataire peut être marqué comme **principal**.
|
||||||
|
- **Simplifier la notion de rapport** : rationaliser les variantes actuelles **Rapport CT / Rapport CT Terrain / Rapport provisoire** (🟡 cible de simplification à préciser en atelier).
|
||||||
|
|
||||||
|
### 4.6 Mise en production (MEP)
|
||||||
|
Transmission de l'héritage complet du contrat au bloc Prestations, et des paramètres de facturation à l'outil de facturation. Pour un **contrat directif** : suivi de la consommation du volume.
|
||||||
|
|
||||||
|
Précisions atelier :
|
||||||
|
|
||||||
|
- **Continuité de prestation** : à la MEP, une prestation doit pouvoir être **liée à une prestation existante terminée ou résiliée**, afin d'assurer la **continuité** (reprise des paramétrages, des rapports précédents, de l'historique).
|
||||||
|
- **Contrat gré à gré** : un contrat **ni rattaché à un accord ni directif** est dit **gré à gré**. Il est **issu d'une offre** (offre obligatoire, ou offre d'avenant pour compléter un gré à gré existant) et **ne peut pas** être déclenché par un bon de commande client.
|
||||||
|
|
||||||
|
## 5. Règles de gestion
|
||||||
|
|
||||||
|
- **RG-CTR-01** — Le contrat est créé **automatiquement** depuis le devis approuvé, sans ressaisie admin des données d'offre.
|
||||||
|
- **RG-CTR-02** — L'identité client provient du CRM (redescente automatique) et ne se saisit pas manuellement.
|
||||||
|
- **RG-CTR-03** — Un contrat **peut** être rattaché à un **accord** ; il hérite alors du BPU, de l'indexation et du périmètre autorisé de l'accord.
|
||||||
|
- **RG-CTR-04** — L'**apporteur d'affaire** est **obligatoire** pour permettre la mise en production.
|
||||||
|
- **RG-CTR-05** — Les paramètres de facturation peuvent être saisis par le commerce ; à défaut, l'admin les complète.
|
||||||
|
- **RG-CTR-06** — Les destinataires sont définis **par type d'interaction** (commerciale, admin, avis, rapport, facture).
|
||||||
|
- **RG-CTR-07** — À la MEP, **toutes** les données héritables du contrat sont transmises au bloc Prestations.
|
||||||
|
- **RG-CTR-08** — Un **contrat directif** n'est **pas rattachable à un accord**, fait l'objet d'un **suivi de consommation** de volume et d'une **clôture automatique** une fois le volume consommé.
|
||||||
|
- **RG-CTR-09** — **Bingo ne porte que l'acte commercial** (déclenchement / mise à jour du contrat). La **formalisation** du contrat conclu et le **suivi de ses évolutions** sont dans **SatPRO**, source de vérité. Le **commerce** peut **consulter les données en cours** du contrat lors d'un **nouveau devis**.
|
||||||
|
- **RG-CTR-10** — Toute **modification** d'un contrat conclu nécessite une **étape administrative de mise en production des changements**, avec prise en compte des **impacts sur les prestations** (existantes et/ou nouvelles à créer).
|
||||||
|
- **RG-CTR-11** — Un contrat est créé **uniquement** depuis une **offre** (offre d'avenant incluse) **ou** une **commande directe rattachée à un accord**. Une commande directe sur un **contrat existant** ne crée pas de contrat : elle déclenche la **part de prestation** correspondante.
|
||||||
|
- **RG-CTR-12** — La **référence de l'offre** (et de l'**avenant**) doit être un **critère de recherche** permettant de retrouver le contrat créé dans SatPRO.
|
||||||
|
- **RG-CTR-13** — Un contrat a une **durée finie** (date de fin) ou **indéterminée** (tacite reconduction).
|
||||||
|
- **RG-CTR-14** — Dans un **contrat à bon de commande**, une intervention n'est **productible** que si le **bon de commande client** correspondant a été passé (sinon « non valide »).
|
||||||
|
- **RG-CTR-15** — Les **tarifs** peuvent être **augmentés automatiquement** selon la règle d'indexation, dont l'origine est une **directive commerciale** (privé), les **CGV** (public) ou une règle **propre au contrat**.
|
||||||
|
- **RG-CTR-16** — Un paramètre peut être marqué **imposé** : il **ne peut pas être dérogé** aux niveaux inférieurs (prestation / intervention).
|
||||||
|
- **RG-CTR-17** — Un **contrat gré à gré** (ni accord ni directif) est **issu d'une offre** et **ne peut pas** être déclenché par un bon de commande client.
|
||||||
|
- **RG-CTR-18** — À la MEP, une prestation peut être **liée à une prestation terminée ou résiliée** pour en assurer la **continuité** (reprise paramétrages, rapports précédents, historique).
|
||||||
|
|
||||||
|
## 6. Données
|
||||||
|
|
||||||
|
### 6.1 Données reçues
|
||||||
|
| Source | Données |
|
||||||
|
|--------|---------|
|
||||||
|
| **Bingo Pro** (devis approuvé) | Donneur d'ordre, compte payeur, adresse admin, missions & qualifications, prix, lieux d'intervention, durée, période, BDC (doc, réf, date, validité), conditions de tarification, flag national / réseau |
|
||||||
|
| **CRM** (redescente auto) | Identité client : raison sociale, SIREN, adresse, coordonnées GPS |
|
||||||
|
| **Accord** (si rattaché) | BPU, règles d'indexation, périmètre autorisé |
|
||||||
|
|
||||||
|
### 6.2 Données paramétrables dans SatPRO
|
||||||
|
- **Consignes & notes** : NA (note administrative), NP (note de production), consignes admin & production.
|
||||||
|
- **Organisation** : apporteur d'affaire (obligatoire MEP), horaires de disponibilité.
|
||||||
|
- **Relation client** : mode de règlement, courriers client paramétrables, destinataires par type d'interaction.
|
||||||
|
- **Facturation** : mode de facturation (par ITV / globale / FE), BDC obligatoire ou non, minima & compléments de facturation, frais annexes refacturables, **facturation additionnelle** (week-end, minimum de facturation…).
|
||||||
|
- **Indexation** : formule d'indexation retenue (date, formule, pourcentage fixe), **augmentation automatique** des tarifs (origine : directive commerciale / CGV / propre au contrat).
|
||||||
|
- **Durée** : contrat à durée finie ou indéterminée (tacite reconduction).
|
||||||
|
- **Nature** : gré à gré / rattaché à un accord / directif ; option **contrat à bon de commande**.
|
||||||
|
- **Dérogation** : marquage d'un paramètre comme **imposé** (non dérogeable au niveau inférieur).
|
||||||
|
|
||||||
|
### 6.3 Données envoyées
|
||||||
|
| Destination | Données |
|
||||||
|
|-------------|---------|
|
||||||
|
| **SatPRO / Prestations & ITV** | Héritage de **toutes** les données du contrat |
|
||||||
|
| **DekraBill** | Paramètres de facturation |
|
||||||
|
|
||||||
|
## 7. Cas particuliers
|
||||||
|
|
||||||
|
- **Contrat directif** : suivi de la consommation du volume, clôture automatique, non rattachable à un accord (voir RG-CTR-08).
|
||||||
|
- **Contrat rattaché à un accord** : les règles tarifaires et le périmètre proviennent de l'accord (voir RG-CTR-03).
|
||||||
|
- **Flag national / réseau** : marque un contrat de dimension nationale ou réseau. ⚪ Impacts métier à préciser.
|
||||||
|
|
||||||
|
## 8. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Qui déclenche la **MEP** et sous quelles conditions de complétude (contrôles bloquants avant MEP) ?
|
||||||
|
- 🔴 Lister les **contrôles de cohérence** obligatoires avant MEP (ex. AA renseigné, destinataire facture défini, mode de facturation choisi).
|
||||||
|
- 🟡 Préciser le fonctionnement de la **tacite reconduction** (dates, préavis, renouvellement des prestations).
|
||||||
|
- ⚪ Expliciter le mode de facturation **FE**.
|
||||||
|
- ⚪ Préciser les impacts du **flag national / réseau**.
|
||||||
|
- 🟡 Catalogue des **courriers client paramétrables** à définir.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 06 — Gestion des accords
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> **Fonctionnalité existante de SatPRO** (précision atelier) : l'accord n'est pas un système
|
||||||
|
> externe, il se gère dans l'outil. Un contrat peut y être rattaché et en hériter.
|
||||||
|
> Étape **optionnelle** du cycle de vie : elle n'intervient que lorsqu'un accord de
|
||||||
|
> référencement encadre le(s) contrat(s). Se situe entre l'[initialisation du contrat](05-initialisation-contrat.md) et les [prestations](07-prestations.md).
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Gérer les **accords de référencement** qui encadrent commercialement et tarifairement un
|
||||||
|
ensemble de contrats : bordereau de prix unitaires (BPU), règles d'indexation et périmètre
|
||||||
|
autorisé. Les contrats rattachés à un accord en **héritent** (voir [05 — Initialisation du contrat](05-initialisation-contrat.md)).
|
||||||
|
|
||||||
|
### 1.1 Deux niveaux d'accord (précisions atelier)
|
||||||
|
|
||||||
|
- **Accord de référencement** : il apporte une **grille tarifaire**. Un contrat rattaché peut
|
||||||
|
vendre des missions de la grille **au même prix, à un prix différent**, ou des **missions
|
||||||
|
complémentaires** (à **prix propre au contrat**).
|
||||||
|
- **Contrat directif** : variante où le **prix de la grille doit être suivi** (pas de prix
|
||||||
|
dérogé) et où l'on **ne peut pas** vendre de mission complémentaire. Si des missions
|
||||||
|
complémentaires sont nécessaires, elles relèvent d'un **contrat propre, hors directif**.
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Commercial / CDP gestionnaire** — négocie et porte l'accord. ⚪ À préciser en atelier.
|
||||||
|
- **Admin / Administrative** — saisit et maintient les paramètres de l'accord.
|
||||||
|
- **Manager** — suivi. ⚪ À préciser.
|
||||||
|
|
||||||
|
## 3. Déclencheur & sortie
|
||||||
|
|
||||||
|
- **Déclencheur** : l'accord **s'initie à partir de Bingo** (Bingo Pro). Il est ensuite
|
||||||
|
exploitable dans SatPRO pour y rattacher des contrats.
|
||||||
|
- **Sortie** : accord exploitable auquel des contrats peuvent être rattachés, et dont les
|
||||||
|
paramètres redescendent sur ces contrats.
|
||||||
|
|
||||||
|
## 3bis. Rattachement d'un contrat à un accord
|
||||||
|
|
||||||
|
Le rattachement d'un contrat à un accord peut se faire à **deux moments** :
|
||||||
|
|
||||||
|
- **Dès l'offre** : le rattachement est porté depuis l'offre (Bingo) et repris dans SatPRO.
|
||||||
|
- **En gestion de contrat** : rattachement (ou détachement) ultérieur, directement dans SatPRO.
|
||||||
|
|
||||||
|
Il s'agit de **reprendre la fonctionnalité existante** de SatPRO permettant de **rattacher
|
||||||
|
et détacher** des contrats à un accord existant.
|
||||||
|
|
||||||
|
> 🟢 **Rattachement non bloquant (décision atelier).** Rattacher un contrat dont les tarifs
|
||||||
|
> **ne suivent pas le BPU** de l'accord n'est **pas bloquant** : le contrat est simplement
|
||||||
|
> mis en **« anomalie » informative** (signalement, sans empêcher le rattachement ni la
|
||||||
|
> production). *(Ne s'applique pas au contrat directif, où le prix de grille doit être suivi.)*
|
||||||
|
|
||||||
|
## 4. Contenu d'un accord
|
||||||
|
|
||||||
|
| Élément | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **BPU** | Bordereau de prix unitaires applicable aux contrats rattachés |
|
||||||
|
| **Indexation** | Règles de révision des prix (date, formule, pourcentage fixe) |
|
||||||
|
| **Périmètre autorisé** | Périmètre (missions, sites, entités…) couvert par l'accord |
|
||||||
|
|
||||||
|
## 5. Règles de gestion
|
||||||
|
|
||||||
|
- **RG-ACC-01** — L'accord **s'initie à partir de Bingo** puis est **géré dans SatPRO** (modification, suivi, rattachements) ; ce n'est pas un référentiel externe.
|
||||||
|
- **RG-ACC-02** — Un contrat **peut** être rattaché à un accord ; il hérite alors du **BPU**, de l'**indexation** et du **périmètre autorisé** de l'accord.
|
||||||
|
- **RG-ACC-03** — Un **contrat directif** n'est **pas rattachable** à un accord (cf. [05 — RG-CTR-08](05-initialisation-contrat.md)).
|
||||||
|
- **RG-ACC-05** — Le rattachement d'un contrat à un accord peut intervenir **dès l'offre** (porté depuis Bingo) **ou** plus tard **en gestion de contrat** dans SatPRO.
|
||||||
|
- **RG-ACC-06** — SatPRO permet de **rattacher et détacher** un contrat d'un accord existant (reprise de la fonctionnalité existante).
|
||||||
|
- **RG-ACC-07** — **Bingo ne porte que l'acte commercial** (déclenchement / mise à jour d'un accord). La **formalisation** de l'accord conclu et le **suivi de ses évolutions** sont dans **SatPRO**, qui en est la source de vérité.
|
||||||
|
- **RG-ACC-08** — Le **commerce** peut **consulter les données en cours** de l'accord dans SatPRO lors de l'établissement d'un **nouveau devis**.
|
||||||
|
- **RG-ACC-09** — Toute **modification** d'un accord nécessite une **étape administrative de mise en production des changements**, avec prise en compte des **impacts sur les prestations** (existantes et/ou nouvelles à créer) — cf. RG-ACC-04 pour les règles de propagation.
|
||||||
|
- **RG-ACC-04** — 🔴 Règles de propagation d'une modification d'accord vers les contrats déjà rattachés : à définir (s'applique-t-elle aux contrats en cours ? aux seules nouvelles prestations ? historique figé ?).
|
||||||
|
- **RG-ACC-10** — Un **accord de référencement** apporte une **grille tarifaire** ; un contrat rattaché peut vendre à **prix identique, différent** ou proposer des **missions complémentaires** (prix propre au contrat).
|
||||||
|
- **RG-ACC-11** — Dans la variante **contrat directif**, le **prix de grille est suivi** (non dérogeable) et les **missions complémentaires sont interdites** (elles relèvent d'un contrat propre, hors directif).
|
||||||
|
- **RG-ACC-12** — Rattacher un contrat dont les tarifs **ne suivent pas le BPU** n'est **pas bloquant** : le contrat est mis en **« anomalie » informative** (hors directif, où le respect de la grille est requis).
|
||||||
|
|
||||||
|
## 6. Données
|
||||||
|
|
||||||
|
### 6.1 Données reçues
|
||||||
|
| Source | Données |
|
||||||
|
|--------|---------|
|
||||||
|
| **Bingo (Bingo Pro)** | Initialisation de l'accord ; rattachement contrat ↔ accord porté dès l'offre |
|
||||||
|
|
||||||
|
### 6.2 Données paramétrables dans SatPRO
|
||||||
|
- BPU (lignes de prix), règles d'indexation, périmètre autorisé.
|
||||||
|
- Rattachement / détachement contrat ↔ accord (dès l'offre ou en gestion de contrat).
|
||||||
|
|
||||||
|
### 6.3 Données envoyées (héritage interne)
|
||||||
|
| Destination | Données |
|
||||||
|
|-------------|---------|
|
||||||
|
| **Contrat rattaché** | BPU, indexation, périmètre autorisé |
|
||||||
|
|
||||||
|
## 7. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Définir le **cycle de vie** d'un accord (création, validité, renouvellement, résiliation).
|
||||||
|
- 🔴 Définir la **propagation** des modifications d'accord vers les contrats rattachés (cf. RG-ACC-04).
|
||||||
|
- 🟡 Préciser les **acteurs** et droits (qui crée / modifie / valide un accord).
|
||||||
|
- 🟡 Préciser le **suivi de consommation** éventuel au niveau accord (vs contrat directif).
|
||||||
|
- 🟢 **Frontière Bingo ↔ SatPRO tranchée** (cf. RG-ACC-07 à 09) : Bingo = acte commercial ; SatPRO = formalisation, suivi des évolutions et mise en production administrative des changements. Reste à préciser le **détail des règles de propagation** sur les prestations (cf. RG-ACC-04).
|
||||||
|
- ⚪ Confirmer le périmètre exact couvert par la « gestion des accords » existante dans SatPRO.
|
||||||
|
- 🔴 Définir l'**étape administrative de mise en production des changements** d'accord : qui la déclenche, quels contrôles, comment sont identifiés et appliqués les impacts sur les prestations existantes vs nouvelles.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 07 — Prestations
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> La prestation est le **canevas / chapeau** des interventions : elle découpe les missions vendues et sert de modèle à toutes les interventions produites sur la durée du contrat.
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Découper les missions vendues du contrat en **prestations** cohérentes (regroupement par périodicité, domaine technique et lieu), paramétrer chaque prestation, puis générer les interventions associées. La prestation hérite du contrat et transmet aux interventions.
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Admin / Administrative** — paramètre les prestations.
|
||||||
|
- **Ordonnancement** — contribue au regroupement (coordination planification).
|
||||||
|
- **CDP / Manager** — suivi.
|
||||||
|
|
||||||
|
## 3. Déclencheur & sortie
|
||||||
|
|
||||||
|
- **Déclencheur** : mise en production du contrat (fin du bloc Initialisation).
|
||||||
|
- **Sortie** : prestations validées → génération automatique des interventions ; information de regroupement transmise à la planification.
|
||||||
|
|
||||||
|
## 4. Étapes du sous-process
|
||||||
|
|
||||||
|
### 4.1 Génération automatique depuis le contrat
|
||||||
|
Les paramètres héritables du contrat redescendent automatiquement au niveau des prestations. Le découpage regroupe les missions de **même périodicité**, **même domaine technique** et **même lieu d'intervention**.
|
||||||
|
|
||||||
|
### 4.2 Paramétrage des prestations
|
||||||
|
Les champs hérités peuvent être modifiés : la valeur saisie au niveau prestation devient **prioritaire** (sauf si le paramètre est marqué **imposé** au contrat — voir [05 — RG-CTR-16](05-initialisation-contrat.md)). Paramétrage spécifique : destinataires de documents (certains obligatoires), rapports (format, façonnage), facturation au niveau prestation, FPV (type de sortie, lieu de vérification), consignes complémentaires (conservées au renouvellement).
|
||||||
|
|
||||||
|
> 🟡 **Scinder / dispatcher (précision atelier).** On doit pouvoir **scinder** une prestation
|
||||||
|
> en plusieurs (dispatch), tant que les prestations résultantes **respectent le contrat**
|
||||||
|
> auquel elles appartiennent.
|
||||||
|
|
||||||
|
### 4.3 Regroupement / dégroupement
|
||||||
|
Possibilité de **regrouper** des prestations compatibles (même mois, même lieu, même compétence) ; cible : élargir aux sites proches. Possibilité de **dégrouper** en cas d'erreur ou de changement de besoin client. La compatibilité des **comptes payeurs** doit être vérifiée.
|
||||||
|
|
||||||
|
> ⏸️ **Fonctionnalité en attente (décision atelier).** Le regroupement / dégroupement est
|
||||||
|
> **mis en attente** : vérifier d'abord si **Plan'UP** le gère nativement — auquel cas cette
|
||||||
|
> fonctionnalité deviendrait **caduque**. À défaut, elle sera traitée en **fin de phase projet**.
|
||||||
|
|
||||||
|
### 4.4 Validation & génération des interventions
|
||||||
|
À la validation de la prestation, les interventions sont générées automatiquement. Toute modification **post-validation** d'un élément héritable se propage aux interventions non encore produites.
|
||||||
|
|
||||||
|
## 5. Règles de gestion
|
||||||
|
|
||||||
|
- **RG-PRE-01** — Les prestations résultent d'un **découpage** des missions vendues regroupées par **périodicité + domaine technique + lieu d'intervention** identiques.
|
||||||
|
- **RG-PRE-02** — Tous les paramètres héritables du contrat **redescendent** sur la prestation.
|
||||||
|
- **RG-PRE-03** — Toute modification d'un élément hérité **depuis le contrat** modifie la prestation (propagation descendante).
|
||||||
|
- **RG-PRE-04** — Toute modification d'un élément héritable **sur la prestation** modifie les interventions **non encore produites**.
|
||||||
|
- **RG-PRE-05** — Une donnée saisie sur la prestation devient **prioritaire** : une modification ultérieure de la donnée héritée du contrat **ne l'écrase pas**.
|
||||||
|
- **RG-PRE-06** — Certaines consignes (admin, production) peuvent avoir des **champs supplémentaires propres à la prestation**, pour préciser ou remplacer l'attendu contrat ; modifier la donnée héritée du contrat **ne change pas** ces champs propres.
|
||||||
|
- **RG-PRE-07** — Le regroupement n'est autorisé que si **timing, lieu et compétence** sont compatibles ; la compatibilité des **comptes payeurs** doit être vérifiée.
|
||||||
|
- **RG-PRE-08** — Une prestation peut être **modifiée**, **résiliée** (sur sélection d'un motif) ou **réactivée** après résiliation.
|
||||||
|
- **RG-PRE-09** — Les consignes complémentaires de prestation sont **conservées au renouvellement** du contrat.
|
||||||
|
- **RG-PRE-10** — La génération des interventions est **automatique à la validation** de la prestation.
|
||||||
|
- **RG-PRE-11** — Une prestation peut être **scindée / dispatchée** en plusieurs prestations, à condition que chacune **respecte le contrat** d'appartenance.
|
||||||
|
- **RG-PRE-12** — ⏸️ Le **regroupement / dégroupement** (RG-PRE-07) est **en attente** : à confirmer selon la prise en charge native par **Plan'UP** ; sinon traité en fin de phase projet.
|
||||||
|
|
||||||
|
## 6. Données
|
||||||
|
|
||||||
|
### 6.1 Données héritées (du contrat)
|
||||||
|
Montants contractuels ; temps & durée prévue des interventions ; mois de programmation ; périodicité ; infos client (nom, adresse, téléphone, accès) ; missions, code matériel, qualifications.
|
||||||
|
|
||||||
|
> Note : les **paramètres LI (L1 à L4)** ne sont **pas** hérités ici — ce sont 4 valeurs texte paramétrées **au niveau du client** uniquement (voir [02 — Glossaire](02-glossaire.md)).
|
||||||
|
|
||||||
|
### 6.2 Données paramétrables dans SatPRO
|
||||||
|
- Tous les champs hérités (la donnée sur la prestation devient **prioritaire**).
|
||||||
|
- Destinataires de documents (certains obligatoires).
|
||||||
|
- Paramétrage des rapports (format, façonnage).
|
||||||
|
- Paramétrage de la facturation au niveau prestation.
|
||||||
|
- FPV (type de sortie, lieu de vérification).
|
||||||
|
- Consignes complémentaires (conservées au renouvellement).
|
||||||
|
- **Regroupement** : par même mois / lieu / compétence ; cible sites proches ; vérification des comptes payeurs.
|
||||||
|
|
||||||
|
### 6.3 Données envoyées
|
||||||
|
| Destination | Données |
|
||||||
|
|-------------|---------|
|
||||||
|
| **SatPRO / Interventions** | Génération automatique à la validation ; propagation des modifications post-validation sur les ITV non produites |
|
||||||
|
| **PlanUp / 7Opteam** | Information de regroupement pour la coordination de la planification |
|
||||||
|
|
||||||
|
## 7. Cas particuliers
|
||||||
|
|
||||||
|
- **Résiliation / réactivation** de prestation sur motif.
|
||||||
|
- **Dégroupement** suite à erreur ou évolution du besoin client.
|
||||||
|
- **Renouvellement** : les consignes complémentaires de prestation sont conservées.
|
||||||
|
|
||||||
|
## 8. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Degré d'**automatisation du découpage** initial : entièrement automatique, semi-automatique, ou manuel avec proposition ?
|
||||||
|
- 🔴 Lister les **motifs de résiliation** de prestation.
|
||||||
|
- 🟡 Préciser les règles de **regroupement « sites proches »** (critère de proximité, périmètre géographique).
|
||||||
|
- 🟡 Comportement d'un **dégroupement** sur des interventions déjà générées / produites.
|
||||||
|
- ⚪ Préciser la signification et l'usage du **FPV**. (Les **LI L1-L4** sont des valeurs client, hors héritage — voir glossaire.)
|
||||||
|
- ⚪ Liste des **destinataires de documents obligatoires** au niveau prestation.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 08 — Interventions
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> L'intervention (ITV) est la **production concrète** d'une part du contrat chez le client. Elle hérite de la prestation et peut porter des informations propres.
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Générer et gérer les interventions issues des prestations, les affecter et les planifier (via PlanUp / 7Opteam), transmettre les éléments de production au technicien, puis suivre leur avancement (codes état et facturation) jusqu'à la fin d'intervention qui alimente la RFI.
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Ordonnancement** — affectation et planification.
|
||||||
|
- **Admin / Administrative** — suivi, documents joints, consignes complémentaires, gestion des statuts.
|
||||||
|
- **Technicien** — reçoit les éléments de production, réalise l'intervention (production hors SatPRO).
|
||||||
|
- **Client** — reçoit l'avis de passage ; peut demander une suspension.
|
||||||
|
|
||||||
|
## 3. Déclencheur & sortie
|
||||||
|
|
||||||
|
- **Déclencheur** : validation d'une prestation (génération automatique des ITV).
|
||||||
|
- **Sortie** : fin d'intervention — les données de production remontent de la Saisie Terrain et alimentent la RFI.
|
||||||
|
|
||||||
|
## 4. Étapes du sous-process
|
||||||
|
|
||||||
|
### 4.1 Génération automatique depuis les prestations
|
||||||
|
À la validation des prestations, les interventions sont générées. Elles héritent des paramètres de la prestation **et des visites précédentes** (rapports, FPV, consignes de la chaîne accord → contrat → prestation). Une intervention peut comporter **plusieurs RDV de service**. Tous les éléments de planification sont transmis automatiquement au bloc Affectation & planification.
|
||||||
|
|
||||||
|
> 🟢 **Horizon de génération (décision atelier).** Les interventions sont générées sur un
|
||||||
|
> horizon de **24 mois glissants** (et non sur toute la durée du contrat d'un coup).
|
||||||
|
|
||||||
|
### 4.2 Affectation & planification
|
||||||
|
Transmission des infos d'intervention pour affectation. Réception de l'affectation du technicien et du créneau horaire. Si le dossier est **« Lié PlanUp »**, l'affectation est **bloquée côté SatPRO** : **7Opteam est maître** de la planification.
|
||||||
|
|
||||||
|
### 4.3 Envoi au technicien
|
||||||
|
Envoi des éléments de production au technicien (Saisie Terrain) : client, missions, durée, rapports précédents, NP, matériels à contrôler. Envoi au **client** d'un avis de passage automatique (ex. J-14, paramétrable).
|
||||||
|
|
||||||
|
### 4.4 Fin d'intervention & suivi
|
||||||
|
Réception des données de production (temps réel, observations, photos, écarts déclarés, rapport PDF + pièces jointes). Suivi des **codes état** (suspension / annulée / reprise / terminée) et des **codes facturation**, deux statuts **indépendants** ajustés automatiquement selon les règles définies.
|
||||||
|
|
||||||
|
Précisions atelier :
|
||||||
|
|
||||||
|
- **Suspendue ≠ résiliée** : distinguer une intervention **suspendue** (interruption temporaire, à reprendre) d'une intervention **résiliée** du fait de la **résiliation de la prestation** (fin définitive). Ce sont deux états distincts à ne pas confondre.
|
||||||
|
- **Annulation par le client** : mieux suivre l'annulation client, qui peut **déclencher une facturation** sans pour autant **empêcher la replanification**. Y compris quand le rendez-vous n'a pu être honoré (ex. **intervenant malade**) : il faut **alerter sur la nécessité de replanifier** l'intervention.
|
||||||
|
|
||||||
|
## 5. Règles de gestion
|
||||||
|
|
||||||
|
- **RG-ITV-01** — Les interventions sont générées **automatiquement** à la validation de la prestation.
|
||||||
|
- **RG-ITV-02** — Une intervention hérite des paramètres de la prestation **et des visites précédentes**.
|
||||||
|
- **RG-ITV-03** — Une intervention peut comporter **plusieurs RDV de service**.
|
||||||
|
- **RG-ITV-04** — Toute modification post-validation d'un élément héritable de la prestation se propage aux interventions **non encore produites** ; les interventions **produites restent figées**.
|
||||||
|
- **RG-ITV-05** — Les **codes état** et **codes facturation** sont **indépendants** et ajustés automatiquement selon des règles définies.
|
||||||
|
- **RG-ITV-06** — Une intervention peut être **suspendue** (sur demande client ou Dekra). Une intervention **suspendue doit être reprise** ; à défaut, elle est considérée **terminée**.
|
||||||
|
- **RG-ITV-07** — Si le dossier est **« Lié PlanUp »**, l'affectation est **bloquée** dans SatPRO (7Opteam maître).
|
||||||
|
- **RG-ITV-08** — Un **avis de passage** est envoyé automatiquement au client (ex. J-14, paramétrable).
|
||||||
|
- **RG-ITV-09** — Une intervention **annulée** est suivie (date, motif, élément déclencheur) et **facturée** selon les paramètres de facturation prédéfinis.
|
||||||
|
- **RG-ITV-10** — Les interventions sont générées sur un horizon de **24 mois glissants**.
|
||||||
|
- **RG-ITV-11** — Une intervention **suspendue** (temporaire, à reprendre) est **distincte** d'une intervention **résiliée** suite à la **résiliation de la prestation** (définitif).
|
||||||
|
- **RG-ITV-12** — Une **annulation client** peut **déclencher une facturation** sans **bloquer la replanification**. Lorsqu'un RDV ne peut être honoré (ex. intervenant indisponible), le système **alerte** sur la **replanification** à effectuer.
|
||||||
|
|
||||||
|
## 6. Données
|
||||||
|
|
||||||
|
### 6.1 Données héritées (de la prestation)
|
||||||
|
Paramètres (montants, temps, missions, qualifications) ; visite précédente, rapports, FPV ; consignes héritées (accord → contrat → prestation).
|
||||||
|
|
||||||
|
### 6.2 Données paramétrables dans SatPRO
|
||||||
|
- Documents joints (NP, BDC, annexes).
|
||||||
|
- Consignes complémentaires propres à l'intervention.
|
||||||
|
- Code état : suspension / annulée / reprise.
|
||||||
|
|
||||||
|
### 6.3 Données échangées
|
||||||
|
| Sens | Système | Données |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Envoyées | **PlanUp / 7Opteam** | Transmission des infos ITV pour affectation |
|
||||||
|
| Reçues | **PlanUp / 7Opteam** | Affectation technicien & créneau horaire (si « Lié PlanUp » → affectation bloquée côté SatPRO, 7Opteam maître) |
|
||||||
|
| Envoyées | **Saisie Terrain** | Client, missions, durée, rapports précédents, NP, matériels à contrôler |
|
||||||
|
| Envoyées | **Client** | Avis de passage automatique (J-14 paramétrable) |
|
||||||
|
| Reçues | **Saisie Terrain** | Temps réel, observations, photos, écarts déclarés, rapport (PDF + PJ) |
|
||||||
|
|
||||||
|
## 7. Cas particuliers
|
||||||
|
|
||||||
|
- **Suspension / reprise** : une suspension non reprise équivaut à une fin d'intervention (voir RG-ITV-06).
|
||||||
|
- **Annulation** : suivie et facturée selon les paramètres (voir RG-ITV-09).
|
||||||
|
- **Dossier « Lié PlanUp »** : planification pilotée par 7Opteam.
|
||||||
|
- **Interventions à RDV multiples** : plusieurs rendez-vous de service pour une même intervention.
|
||||||
|
|
||||||
|
## 8. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Lister les **codes état** et **codes facturation**, leurs valeurs et leurs **règles de transition automatiques**.
|
||||||
|
- 🔴 Définir les **motifs d'annulation** et les règles de facturation associées (ex. annulation à 48h / jour même).
|
||||||
|
- 🟡 Préciser les règles de reprise d'une intervention suspendue (délai au-delà duquel elle est terminée).
|
||||||
|
- ⚪ Confirmer la frontière d'affectation **SatPRO ↔ PlanUp / 7Opteam** (quand chacun est maître).
|
||||||
|
- ⚪ Confirmer le délai paramétrable de l'**avis de passage** (J-14 par défaut ?).
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 09 — RFI (Revue de Fin d'Intervention)
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> La RFI est le **point de contrôle** entre production réalisée et contrat : elle conditionne le déclenchement de la facturation.
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Vérifier que la production réalisée par l'intervenant est **conforme** à ce qui a été vendu au contrat. En l'absence d'écart, libérer automatiquement la facturation. En présence d'écart, router vers le bon interlocuteur pour décision et acter l'impact sur la facturation et/ou le contrat.
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Admin / Administrative** — traite les RFI nécessitant un acte manuel, décide de l'impact des écarts.
|
||||||
|
- **Manager** — escalade selon les cas.
|
||||||
|
- **Commercial** — intervient en cas d'écart non acceptable nécessitant un avenant (bloc Offre).
|
||||||
|
|
||||||
|
## 3. Déclencheur & sortie
|
||||||
|
|
||||||
|
- **Déclencheur** : fin d'intervention et remontée des données de production (Saisie Terrain).
|
||||||
|
- **Sortie** : soit libération automatique de la facturation (aucun écart), soit décision admin puis libération / recalage / avenant.
|
||||||
|
|
||||||
|
## 4. Étapes du sous-process
|
||||||
|
|
||||||
|
### 4.1 Réception des écarts
|
||||||
|
Réception des données de production : écarts déclarés en fin d'ITV, temps réel, quantité contrôlée, missions réellement réalisées. **Détection automatique** des écarts entre les remontées terrain et le contrat.
|
||||||
|
|
||||||
|
### 4.2 Routage, traitement & décision
|
||||||
|
- **Aucun écart** → RFI **validée automatiquement**, génération automatique de la facture.
|
||||||
|
- **Écart acceptable** → recalage du contrat / des interventions futures selon les retours RFI ; ajustement possible de la quantité et du temps.
|
||||||
|
- **Écart non acceptable** → RFI admin nécessaire, escalade au **manager** selon les cas ; génération d'un **avenant** dans le bloc Offre et attente d'une **action commerciale** pour ajuster le contrat.
|
||||||
|
|
||||||
|
Le **routage** vers le bon interlocuteur suit des règles métier à définir selon le caractère acceptable ou non de l'écart.
|
||||||
|
|
||||||
|
### 4.3 Préparation de facture
|
||||||
|
Libération automatique vers l'outil de facturation si aucun écart. Recalage des interventions futures et, si nécessaire, du contrat.
|
||||||
|
|
||||||
|
### 4.4 Suivi des RFI
|
||||||
|
Visualisation des RFI en attente et de leur statut de résolution.
|
||||||
|
|
||||||
|
## 5. Règles de gestion
|
||||||
|
|
||||||
|
- **RG-RFI-01** — Les écarts sont **détectés automatiquement** par comparaison production terrain vs contrat.
|
||||||
|
- **RG-RFI-02** — **Sans écart** : RFI **validée automatiquement** + génération automatique de la facture.
|
||||||
|
- **RG-RFI-03** — **Avec écart** : un **acte manuel** de l'admin est requis pour décider de l'impact sur la facturation.
|
||||||
|
- **RG-RFI-04** — **Écart acceptable** : recalage du contrat / des interventions futures ; quantité et temps ajustables.
|
||||||
|
- **RG-RFI-05** — **Écart non acceptable** : escalade manager (selon cas) + génération d'un **avenant** (bloc Offre) ; la facturation **attend** l'action commerciale.
|
||||||
|
- **RG-RFI-06** — Le **routage** des RFI suit des règles métier basées sur le type d'écart (acceptable / non acceptable). 🔴 Règles à définir.
|
||||||
|
- **RG-RFI-07** — La facturation reste **bloquée** tant que la **RFI admin** n'est pas faite.
|
||||||
|
|
||||||
|
## 6. Données
|
||||||
|
|
||||||
|
### 6.1 Données reçues
|
||||||
|
| Source | Données |
|
||||||
|
|--------|---------|
|
||||||
|
| **Saisie Terrain** | Écarts déclarés en fin d'ITV, temps réel, quantité contrôlée, missions réellement réalisées |
|
||||||
|
|
||||||
|
### 6.2 Données paramétrables dans SatPRO
|
||||||
|
- Si écart acceptable : ajustement de la **quantité** et du **temps**.
|
||||||
|
|
||||||
|
### 6.3 Données envoyées
|
||||||
|
| Destination | Données |
|
||||||
|
|-------------|---------|
|
||||||
|
| **Bloc Offre** | Génération d'un **avenant** si écart non acceptable |
|
||||||
|
| **DBill** | Libération automatique si aucun écart noté |
|
||||||
|
| **SatPRO** | Recalage des interventions futures ; recalage du contrat si nécessaire |
|
||||||
|
|
||||||
|
## 7. Cas particuliers
|
||||||
|
|
||||||
|
- **Écart acceptable vs non acceptable** : seuil / critères de classification à définir.
|
||||||
|
- **Escalade manager** : conditions de déclenchement à préciser.
|
||||||
|
- **Avenant** : produit hors SatPRO (bloc Offre), avec boucle de retour vers le contrat.
|
||||||
|
|
||||||
|
## 8. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Définir ce qui distingue un **écart acceptable** d'un **écart non acceptable** (critères, seuils, par type d'écart : quantité / temps / mission).
|
||||||
|
- 🔴 Définir les **règles de routage** des RFI et les **conditions d'escalade** au manager.
|
||||||
|
- 🟡 Préciser le mécanisme de **recalage** (ce qui est ajusté sur les interventions futures et sur le contrat).
|
||||||
|
- 🟡 Préciser le **statut de résolution** des RFI suivies (liste de valeurs).
|
||||||
|
- ⚪ Confirmer que la génération de facture « automatique » = libération vers DBill (pas d'émission par SatPRO).
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 10 — Facturation
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> SatPRO **prépare** et **déclenche** la facturation ; l'émission de la facture est réalisée par l'outil de facturation (DBill).
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Préparer la facture à partir des éléments contractuels, des paramètres de prestation et d'intervention et des données de production, gérer les cas de facturation partielle et les cas spécifiques (avoirs, proformas, blocages, refacturation de frais annexes), puis transmettre à DBill et suivre.
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Admin / Administrative** — prépare la facture, gère les proformas, mentions, compléments, blocages/déblocages.
|
||||||
|
- **Commercial** — contribue (éléments de facturation définis en amont).
|
||||||
|
|
||||||
|
## 3. Déclencheur & sortie
|
||||||
|
|
||||||
|
- **Déclencheur** : libération automatique post-RFI (aucun écart) ou décision admin après traitement d'écart.
|
||||||
|
- **Sortie** : lignes de facture transmises à DBill ; facture (et rapport en PJ si paramétré) envoyée au client ; indicateurs envoyés aux Cubes.
|
||||||
|
|
||||||
|
## 4. Étapes du sous-process
|
||||||
|
|
||||||
|
### 4.1 Libération automatique post-RFI
|
||||||
|
Récupération des données de production (Saisie Terrain) et des données SatPRO (mode de facturation, BDC, mentions, minima, frais annexes). La libération est automatique si la RFI n'a relevé aucun écart.
|
||||||
|
|
||||||
|
### 4.2 Préparation de la facture
|
||||||
|
Préparation à partir des éléments contractuels, des paramètres de prestation / intervention et des données de production. Possibilité de gérer : proforma, mentions spécifiques, compléments de facturation, blocage / déblocage. Possibilité d'ajouter des lignes de **refacturation** sur les frais annexes.
|
||||||
|
|
||||||
|
### 4.3 Envoi vers DBill
|
||||||
|
Transmission à DBill : lignes, montants, BDC, mentions, PJ, format, compléments. L'envoi est **bloqué tant que la RFI admin n'est pas faite**. Envoi au **client** : facture, et rapport en PJ si paramétré.
|
||||||
|
|
||||||
|
### 4.4 Suivi & relance
|
||||||
|
Suivi des statuts (code état : suspension / annulée / reprise). Envoi d'indicateurs aux Cubes (CA facturé, …).
|
||||||
|
|
||||||
|
## 5. Règles de gestion
|
||||||
|
|
||||||
|
- **RG-FAC-01** — La facture est préparée à partir des **éléments contractuels + paramètres prestation/ITV + données de production**.
|
||||||
|
- **RG-FAC-02** — La **facturation partielle** est possible dès qu'un **fait générateur** existe (déplacement, annulation, production partielle, etc.).
|
||||||
|
- **RG-FAC-03** — Le mode de facturation peut être **par intervention (ITV)**, **globale** ou **FE**.
|
||||||
|
- **RG-FAC-04** — Si le **BDC est obligatoire**, il conditionne la facturation.
|
||||||
|
- **RG-FAC-05** — Les **minima** et **compléments** de facturation sont appliqués selon le paramétrage contrat / prestation.
|
||||||
|
- **RG-FAC-06** — Des lignes de **refacturation de frais annexes** peuvent être ajoutées.
|
||||||
|
- **RG-FAC-07** — Cas spécifiques gérés : **avoirs**, **proformas**, **blocage de facturation**.
|
||||||
|
- **RG-FAC-08** — L'envoi vers DBill est **bloqué tant que la RFI admin n'est pas réalisée** (lien avec RG-RFI-07).
|
||||||
|
- **RG-FAC-09** — Le **rapport** est joint à la facture envoyée au client **si paramétré**.
|
||||||
|
- **RG-FAC-10** — Le payeur est celui défini au **contrat**, ou à la **prestation** si une information spécifique s'applique dans ce cadre.
|
||||||
|
|
||||||
|
## 6. Données
|
||||||
|
|
||||||
|
### 6.1 Données reçues
|
||||||
|
| Source | Données |
|
||||||
|
|--------|---------|
|
||||||
|
| **Saisie Terrain** | Données de production, compléments déclenchés |
|
||||||
|
| **SatPRO** | Mode de facturation (ITV / globale / FE), BDC, mentions, minima, frais annexes |
|
||||||
|
|
||||||
|
### 6.2 Données paramétrables dans SatPRO
|
||||||
|
- Proforma.
|
||||||
|
- Mentions spécifiques.
|
||||||
|
- Compléments de facturation.
|
||||||
|
- Blocage / déblocage.
|
||||||
|
- Code état : suspension / annulée / reprise.
|
||||||
|
|
||||||
|
### 6.3 Données envoyées
|
||||||
|
| Destination | Données |
|
||||||
|
|-------------|---------|
|
||||||
|
| **DBill** | Lignes, montants, BDC, mentions, PJ, format, compléments (bloqué tant que RFI admin non faite) |
|
||||||
|
| **Client** | Facture ; rapport en PJ si paramétré |
|
||||||
|
| **Cubes** | CA facturé (+ indicateurs à compléter) |
|
||||||
|
|
||||||
|
## 7. Cas particuliers
|
||||||
|
|
||||||
|
- **Facturation partielle** sur fait générateur.
|
||||||
|
- **Avoirs** et **proformas**.
|
||||||
|
- **Blocage / déblocage** de facturation.
|
||||||
|
- **Refacturation** de frais annexes.
|
||||||
|
- **Payeur spécifique** au niveau prestation.
|
||||||
|
- **Déplacement infructueux** : lorsqu'un déplacement a lieu sans que la production puisse
|
||||||
|
se faire (déplacement « en échec »), il peut constituer un **fait générateur** de facturation.
|
||||||
|
|
||||||
|
> 🔴 **Process déplacement infructueux à revoir (décision atelier).** Ce cas est aujourd'hui
|
||||||
|
> géré par **C2I** (mode « échec »), fonctionnalité **non reconduite** (voir
|
||||||
|
> [01 — Vision & périmètre §4](01-vision-et-perimetre.md)). Le process doit être **repensé**
|
||||||
|
> dans le nouvel outil : détection / déclaration du déplacement infructueux, impact sur la
|
||||||
|
> facturation et sur la replanification éventuelle.
|
||||||
|
|
||||||
|
## 8. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 Expliciter le mode de facturation **FE** et ses règles.
|
||||||
|
- 🔴 Lister les **faits générateurs** de facturation partielle et leurs règles.
|
||||||
|
- 🟡 Préciser le processus **avoir** (déclencheurs, validation, lien avec la facture d'origine).
|
||||||
|
- 🟡 Définir les règles de **blocage / déblocage** (qui, quand, motifs).
|
||||||
|
- ⚪ Compléter les **indicateurs** envoyés aux Cubes (slide « À compléter »).
|
||||||
|
- ⚪ Confirmer que SatPRO ne fait que **préparer/transmettre** (émission = DBill).
|
||||||
|
- 🔴 **Redéfinir le process de déplacement infructueux** (ex-C2I « mode échec », non reconduit) : déclenchement, facturation, lien avec la replanification.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 11 — Sous-traitance (STI / STE / intra-groupe)
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> Trois formes de sous-traitance : **STI** (interne à la filiale), **STE** (externe, hors
|
||||||
|
> groupe) et **intra-groupe** (fournisseur = autre filiale du groupe, fonctionnant comme une
|
||||||
|
> STE). ⚠️ Les volets STE et intra-groupe sont partiellement « à détailler » dans le support d'atelier.
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Permettre la réalisation d'une prestation par une ressource autre que le CDP gestionnaire :
|
||||||
|
en interne à la filiale (STI) quand les ressources qualifiées sont indisponibles, en externe
|
||||||
|
hors groupe (STE), ou via une autre filiale du groupe (intra-groupe, mécanique identique à la
|
||||||
|
STE) — le tout pour tout ou partie de la prestation, avec suivi des coûts et calcul automatique de la marge.
|
||||||
|
|
||||||
|
## 2. Acteurs
|
||||||
|
|
||||||
|
- **Manager (de CDP)** — suit la STI de son CDP.
|
||||||
|
- **Apporteur d'affaire (AA)** / **Ordonnancement** — ajoutent la STE (ou l'intra-groupe).
|
||||||
|
- **Controlling** — fournit les tarifs STI par code matériel (mise à jour annuelle).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTIE A — STI (sous-traitance interne à la filiale)
|
||||||
|
|
||||||
|
### A.1 Étapes
|
||||||
|
|
||||||
|
| Étape | Contenu |
|
||||||
|
|-------|---------|
|
||||||
|
| **Déclenchement** | Paramétrage **par défaut sur activé** sur tous les CDP ; déclenchée **uniquement si** les ressources qualifiées du CDP gestionnaire sont **indisponibles** |
|
||||||
|
| **Tarification** | Tarifs par **code matériel** fournis par le Controlling (mise à jour **annuelle**) |
|
||||||
|
| **Calcul automatique** | Coût **prévisionnel** = durée planifiée × tarif ; coût **réel** = durée exécutée × tarif |
|
||||||
|
| **Suivi** | Coûts réels STI envoyés aux Cubes |
|
||||||
|
|
||||||
|
### A.2 Règles de gestion
|
||||||
|
|
||||||
|
- **RG-STI-01** — La STI est **activée par défaut** sur tous les CDP.
|
||||||
|
- **RG-STI-02** — Elle n'est **déclenchée que si** les ressources qualifiées du CDP gestionnaire sont **indisponibles**.
|
||||||
|
- **RG-STI-03** — Le **manager** suit la STI de son CDP.
|
||||||
|
- **RG-STI-04** — Les **coûts** reposent sur le tarif par **code matériel**, actualisé **annuellement** par le Controlling.
|
||||||
|
- **RG-STI-05** — Coût prévisionnel = **durée planifiée × tarif** ; coût réel = **durée exécutée × tarif**.
|
||||||
|
|
||||||
|
### A.3 Données
|
||||||
|
|
||||||
|
| Sens | Système | Données |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Interne | SatPRO | Paramétrage par défaut activé ; déclenchement si ressources indisponibles |
|
||||||
|
| Reçues | **Controlling** | Tarifs par code matériel (MAJ annuelle) |
|
||||||
|
| Reçues | **Saisie Terrain** | Durée exécutée |
|
||||||
|
| Calcul | SatPRO | Durée planifiée, tarif, coût prévisionnel, coût réel |
|
||||||
|
| Envoyées | **Cubes** | Coûts réels STI (+ à compléter) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTIE B — STE (sous-traitance externe)
|
||||||
|
|
||||||
|
### B.1 Étapes
|
||||||
|
|
||||||
|
| Étape | Contenu |
|
||||||
|
|-------|---------|
|
||||||
|
| **Paramétrage** | Ajoutée par l'**AA** ou l'**ordonnancement** à tout moment ; pour un **travail complet** ou une **prestation partielle** |
|
||||||
|
| **Production** | 🔴 À détailler en atelier |
|
||||||
|
| **Calcul automatique** | Marge calculée automatiquement ; 🔴 modalités à détailler |
|
||||||
|
|
||||||
|
### B.2 Règles de gestion
|
||||||
|
|
||||||
|
- **RG-STE-01** — La STE peut être **ajoutée à tout moment** par l'AA ou l'ordonnancement.
|
||||||
|
- **RG-STE-02** — Elle couvre un **travail complet** ou une **prestation partielle**.
|
||||||
|
- **RG-STE-03** — Les **montants** sont saisis **sur la prestation**, avec **propagation sur les interventions non facturées**.
|
||||||
|
- **RG-STE-04** — Un **ajustement** reste possible **sur l'intervention**.
|
||||||
|
- **RG-STE-05** — La **marge** est **calculée automatiquement**.
|
||||||
|
- **RG-STE-06** — En cas de **prestation partielle**, les éléments de production ne sont envoyés qu'au **technicien Dekra**.
|
||||||
|
|
||||||
|
### B.3 Données
|
||||||
|
|
||||||
|
| Sens | Système | Données |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Paramétrage | SatPRO | Montants saisis sur prestation, propagation ITV non facturées, ajustement ITV |
|
||||||
|
| Calcul | SatPRO | Marge automatique |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTIE C — Sous-traitance intra-groupe
|
||||||
|
|
||||||
|
Sous-traitance confiée à une **autre filiale du groupe**. Elle fonctionne **comme une STE**
|
||||||
|
(même paramétrage, même propagation sur les interventions non facturées, même calcul de marge),
|
||||||
|
la seule différence étant que le **fournisseur est interne au groupe** (filiale sœur).
|
||||||
|
|
||||||
|
### C.1 Règles de gestion
|
||||||
|
|
||||||
|
- **RG-STG-01** — La sous-traitance intra-groupe suit **les mêmes mécanismes que la STE** (RG-STE-01 à RG-STE-06).
|
||||||
|
- **RG-STG-02** — Le **fournisseur** est une **filiale du groupe** (distinct de la STI, interne à la filiale, et de la STE, hors groupe).
|
||||||
|
- **RG-STG-03** — 🔴 Spécificités éventuelles (tarification interne, refacturation inter-filiales, marge) : à détailler en atelier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 **Détailler l'intégralité du volet STE** : paramétrage, production, calcul automatique (slides « À détailler »).
|
||||||
|
- 🔴 Préciser le circuit de **production partielle** (répartition travail STE / technicien Dekra).
|
||||||
|
- 🔴 Préciser les **spécificités de l'intra-groupe** par rapport à la STE (tarification, refacturation inter-filiales).
|
||||||
|
- 🟡 Préciser le mode de **sélection du sous-traitant** (externe ou filiale du groupe).
|
||||||
|
- 🟡 Préciser la **restitution de la marge** (seuils d'alerte, reporting).
|
||||||
|
- ⚪ Confirmer l'exhaustivité des **indicateurs STI / STE / intra-groupe** envoyés aux Cubes.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 12 — Fonctions transverses
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> Fonctions utilisées à travers tous les blocs métier (contrat, prestation, intervention).
|
||||||
|
|
||||||
|
## 1. Historisation
|
||||||
|
|
||||||
|
**Objectif** : tracer les modifications apportées à un contrat, une prestation ou une intervention.
|
||||||
|
|
||||||
|
- **RG-TRV-01** — Chaque modification est historisée avec : **date**, **élément d'origine** (valeur avant), **élément modifié** (valeur après) et **personne** ayant effectué la modification.
|
||||||
|
- Points ouverts :
|
||||||
|
- 🟡 Périmètre exact des objets / champs historisés.
|
||||||
|
- 🟡 Durée de conservation et accès à l'historique (consultation, export).
|
||||||
|
|
||||||
|
## 2. Recherche
|
||||||
|
|
||||||
|
**Objectif** : retrouver rapidement un objet métier.
|
||||||
|
|
||||||
|
- **RG-TRV-02** — Recherche de **client, contrat, prestation, intervention**, filtrable par **statut, région, code état, code facturation**, etc.
|
||||||
|
- Points ouverts :
|
||||||
|
- 🟡 Liste complète des **critères et filtres** de recherche.
|
||||||
|
- ⚪ Besoin de recherches **enregistrées / favorites** ?
|
||||||
|
|
||||||
|
## 3. Paramétrage transverse
|
||||||
|
|
||||||
|
**Objectif** : définir des valeurs par défaut applicables largement.
|
||||||
|
|
||||||
|
- **RG-TRV-03** — Permet de rendre certains paramètres **par défaut** : minimum de facturation, prix d'annulation à 48h / jour même, etc.
|
||||||
|
- Points ouverts :
|
||||||
|
- 🔴 Liste exhaustive des **paramètres par défaut** gérés et de leur portée (global / CDP / contrat).
|
||||||
|
|
||||||
|
## 4. Génération du portefeuille
|
||||||
|
|
||||||
|
**Objectif** : appliquer des paramètres par défaut à un portefeuille.
|
||||||
|
|
||||||
|
- **RG-TRV-04** — Permet de rendre certains paramètres par défaut (minimum de facturation, prix d'annulation à 48h / jour même, etc.).
|
||||||
|
- **RG-TRV-06** — La **vision minimum** du portefeuille attendue est de **24 mois glissants** (cohérent avec l'horizon de génération des interventions, cf. [08 — RG-ITV-10](08-interventions.md)).
|
||||||
|
- Points ouverts :
|
||||||
|
- 🔴 Clarifier la différence entre **« Paramétrage transverse »** et **« Génération du portefeuille »** (le support reprend le même libellé pour les deux). À distinguer en atelier.
|
||||||
|
- 🔴 Définir ce qu'est précisément un **portefeuille** et sa maille.
|
||||||
|
|
||||||
|
## 5. Workflow de validation
|
||||||
|
|
||||||
|
**Objectif** : encadrer les validations métier (contrat, écarts RFI, facturation, etc.).
|
||||||
|
|
||||||
|
- **RG-TRV-05** — 🔴 **À définir** : étapes de validation, rôles validateurs, règles d'escalade.
|
||||||
|
- Points ouverts :
|
||||||
|
- 🔴 Lister les **objets soumis à validation** et les **circuits** associés.
|
||||||
|
- 🔴 Définir les **escalades** (notamment RFI → manager, voir [09 — RFI](09-rfi.md)).
|
||||||
|
|
||||||
|
## 6. Mises à jour en masse
|
||||||
|
|
||||||
|
**Objectif** : appliquer une modification à un ensemble d'objets en une opération.
|
||||||
|
|
||||||
|
> ⚠️ Slide **vide** dans le support d'atelier (contenu « XXX »). Section à construire intégralement.
|
||||||
|
|
||||||
|
- Points ouverts :
|
||||||
|
- 🔴 Définir les **cas d'usage** de mise à jour en masse (ex. réindexation, changement de destinataire, repositionnement de dates).
|
||||||
|
- 🔴 Définir le **périmètre** sélectionnable (par contrat, prestation, portefeuille, région…).
|
||||||
|
- 🔴 Définir les **garde-fous** (prévisualisation, confirmation, respect du principe « ne pas toucher au produit », historisation des changements de masse).
|
||||||
|
|
||||||
|
## 7. Synthèse des points ouverts transverses
|
||||||
|
|
||||||
|
| Réf | Sujet | Statut |
|
||||||
|
|-----|-------|:------:|
|
||||||
|
| TRV-A | Périmètre et conservation de l'historisation | 🟡 |
|
||||||
|
| TRV-B | Critères complets de recherche | 🟡 |
|
||||||
|
| TRV-C | Catalogue des paramètres par défaut et portée | 🔴 |
|
||||||
|
| TRV-D | Distinction paramétrage transverse / génération du portefeuille | 🔴 |
|
||||||
|
| TRV-E | Workflows de validation et escalades | 🔴 |
|
||||||
|
| TRV-F | Cas d'usage et garde-fous des mises à jour en masse | 🔴 |
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 13 — Pilotage
|
||||||
|
|
||||||
|
> Cahier des charges — scope et expression du besoin macro.
|
||||||
|
> ⚠️ Dans le support d'atelier, cette section est une **page de titre sans contenu**. Elle est donc à **construire intégralement** en atelier.
|
||||||
|
|
||||||
|
## 1. Objectif (à confirmer)
|
||||||
|
|
||||||
|
Donner aux acteurs internes (admin, CDP, manager, direction) une **vue de pilotage** de l'activité : état d'avancement des contrats, prestations et interventions, suivi de la production, de la facturation, de la sous-traitance et des RFI.
|
||||||
|
|
||||||
|
> ⚪ Objectif ci-dessus proposé par déduction — à valider par le métier.
|
||||||
|
|
||||||
|
## 2. Pistes de contenu (à cadrer en atelier)
|
||||||
|
|
||||||
|
Éléments plausibles, à retenir / écarter avec le métier :
|
||||||
|
|
||||||
|
- **Suivi des contrats directifs** : consommation du volume vendu vs produit (déjà cité en [05 — Initialisation du contrat](05-initialisation-contrat.md)).
|
||||||
|
- **Suivi des RFI** : RFI en attente et statut de résolution (déjà cité en [09 — RFI](09-rfi.md)).
|
||||||
|
- **Suivi de facturation** : CA facturé, reste à facturer, blocages (lien Cubes, voir [10 — Facturation](10-facturation.md)).
|
||||||
|
- **Suivi de la sous-traitance** : coûts STI/STE, marge (voir [11 — Sous-traitance](11-sous-traitance.md)).
|
||||||
|
- **Suivi de la production** : interventions planifiées / réalisées / suspendues / annulées.
|
||||||
|
- **Suivi des contrats à renégocier** : identifier les contrats arrivant à échéance / à revoir.
|
||||||
|
- **Indicateurs par CDP / région / manager**.
|
||||||
|
|
||||||
|
## 3. Supports de reporting
|
||||||
|
|
||||||
|
Le reporting peut s'appuyer sur **trois canaux complémentaires** (précision atelier) :
|
||||||
|
|
||||||
|
- **Au sein de l'application SatPRO** : pilotage opérationnel (tableaux, suivis en direct).
|
||||||
|
- **Cubes** : restitution décisionnelle (CA facturé, coûts de sous-traitance, etc.).
|
||||||
|
- **Power BI** : tableaux de bord et analyses décisionnelles.
|
||||||
|
|
||||||
|
### 3.1 Tableau de bord à l'ouverture (précision atelier)
|
||||||
|
|
||||||
|
Dans le cadre des échanges par **mail** (ex. envoi des avis d'intervention) avec **accusé de
|
||||||
|
lecture**, prévoir à l'ouverture de l'application un **tableau de bord** rappelant les
|
||||||
|
**lectures des N derniers jours** sur le **périmètre de l'utilisateur** (suivi des accusés
|
||||||
|
de lecture). 🟡 Valeur de N et périmètre exact à préciser.
|
||||||
|
|
||||||
|
> 🔴 À trancher : la **répartition** entre ces trois canaux — ce qui relève du pilotage
|
||||||
|
> opérationnel **dans SatPRO** vs du reporting décisionnel **dans Cubes / Power BI**.
|
||||||
|
|
||||||
|
## 4. Points ouverts
|
||||||
|
|
||||||
|
- 🔴 **Définir intégralement** le besoin de pilotage (objectifs, indicateurs, destinataires, fréquence).
|
||||||
|
- 🔴 Lister les **indicateurs clés** (KPI) attendus et leur maille (contrat / prestation / ITV / CDP / région).
|
||||||
|
- 🔴 Répartir le reporting entre **SatPRO**, **Cubes** et **Power BI**.
|
||||||
|
- ⚪ Identifier les **tableaux de bord** par rôle (admin, manager, direction).
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 14 — Questions & points ouverts (atelier)
|
||||||
|
|
||||||
|
> Consolidation de tous les points à trancher, détailler ou confirmer avec les opérationnels.
|
||||||
|
> Statuts : 🔴 **À définir** · 🟡 **À ajuster / enrichir** · ⚪ **À confirmer** · 🟢 **Conserver**.
|
||||||
|
|
||||||
|
## 1. Priorités (vue synthétique)
|
||||||
|
|
||||||
|
| Priorité | Thème | Fiche |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| 🔴 Haute | Codes état & codes facturation : valeurs + règles de transition | [04](04-processus-global.md), [08](08-interventions.md) |
|
||||||
|
| 🔴 Haute | Écart RFI acceptable vs non acceptable : critères et seuils | [09](09-rfi.md) |
|
||||||
|
| 🔴 Haute | Règles de routage et d'escalade des RFI | [09](09-rfi.md) |
|
||||||
|
| 🔴 Haute | Degré d'automatisation du découpage en prestations | [07](07-prestations.md) |
|
||||||
|
| 🔴 Haute | Déclenchement et contrôles bloquants de la MEP | [05](05-initialisation-contrat.md) |
|
||||||
|
| 🔴 Haute | Workflows de validation & escalades | [03](03-acteurs-et-roles.md), [12](12-fonctions-transverses.md) |
|
||||||
|
| 🔴 Haute | Pilotage : à définir intégralement | [13](13-pilotage.md) |
|
||||||
|
| 🔴 Haute | STE : volet à détailler intégralement | [11](11-sous-traitance.md) |
|
||||||
|
| 🔴 Haute | Mises à jour en masse : cas d'usage & garde-fous | [12](12-fonctions-transverses.md) |
|
||||||
|
| 🟡 Moyenne | Tacite reconduction, renouvellement | [05](05-initialisation-contrat.md), [07](07-prestations.md) |
|
||||||
|
| 🟡 Moyenne | Recalage contrat / interventions futures | [09](09-rfi.md) |
|
||||||
|
| ⚪ À confirmer | Vocabulaire : FE, FPV, LI (L1-L4), flag national/réseau | [02](02-glossaire.md) |
|
||||||
|
|
||||||
|
## 2. Détail par fiche
|
||||||
|
|
||||||
|
### 05 — Initialisation du contrat
|
||||||
|
- 🔴 Qui déclenche la **MEP** et sous quelles conditions de complétude ?
|
||||||
|
- 🔴 **Contrôles de cohérence** obligatoires avant MEP (AA, destinataire facture, mode de facturation…).
|
||||||
|
- 🟡 Fonctionnement de la **tacite reconduction** (dates, préavis, renouvellement).
|
||||||
|
- ⚪ Expliciter le mode de facturation **FE**.
|
||||||
|
- ⚪ Impacts du **flag national / réseau**.
|
||||||
|
- 🟡 Catalogue des **courriers client paramétrables**.
|
||||||
|
|
||||||
|
### 06 — Prestations
|
||||||
|
- 🔴 Degré d'**automatisation du découpage** (auto / semi / manuel assisté).
|
||||||
|
- 🔴 Liste des **motifs de résiliation** de prestation.
|
||||||
|
- 🟡 Règles de regroupement **« sites proches »** (critère de proximité).
|
||||||
|
- 🟡 Comportement du **dégroupement** sur ITV déjà générées / produites.
|
||||||
|
- ⚪ Signification / usage des **paramètres LI (L1-L4)** et du **FPV**.
|
||||||
|
- ⚪ **Destinataires de documents obligatoires** au niveau prestation.
|
||||||
|
|
||||||
|
### 07 — Interventions
|
||||||
|
- 🔴 **Codes état** et **codes facturation** : valeurs + règles de transition automatiques.
|
||||||
|
- 🔴 **Motifs d'annulation** et règles de facturation associées (48h / jour même).
|
||||||
|
- 🟡 Règles de **reprise** d'une intervention suspendue (délai de bascule en « terminée »).
|
||||||
|
- ⚪ Frontière d'affectation **SatPRO ↔ PlanUp / 7Opteam**.
|
||||||
|
- ⚪ Délai paramétrable de l'**avis de passage** (J-14 par défaut ?).
|
||||||
|
|
||||||
|
### 08 — RFI
|
||||||
|
- 🔴 Distinction **écart acceptable / non acceptable** (critères, seuils, par type d'écart).
|
||||||
|
- 🔴 **Règles de routage** et **conditions d'escalade** manager.
|
||||||
|
- 🟡 Mécanisme de **recalage** (ITV futures + contrat).
|
||||||
|
- 🟡 **Statut de résolution** des RFI (liste de valeurs).
|
||||||
|
- ⚪ Confirmer : « facture automatique » = libération vers DBill.
|
||||||
|
|
||||||
|
### 09 — Facturation
|
||||||
|
- 🔴 Expliciter le mode de facturation **FE**.
|
||||||
|
- 🔴 Lister les **faits générateurs** de facturation partielle.
|
||||||
|
- 🟡 Processus **avoir** (déclencheurs, validation, lien facture d'origine).
|
||||||
|
- 🟡 Règles de **blocage / déblocage** (qui, quand, motifs).
|
||||||
|
- ⚪ Compléter les **indicateurs** envoyés aux Cubes.
|
||||||
|
- ⚪ Confirmer : SatPRO prépare/transmet, DBill émet.
|
||||||
|
|
||||||
|
### 10 — Sous-traitance
|
||||||
|
- 🔴 **Détailler le volet STE** (paramétrage, production, calcul).
|
||||||
|
- 🔴 Circuit de **production partielle** (STE / technicien Dekra).
|
||||||
|
- 🟡 Mode de **sélection du sous-traitant** externe.
|
||||||
|
- 🟡 **Restitution de la marge** (seuils d'alerte, reporting).
|
||||||
|
- ⚪ Indicateurs STI/STE envoyés aux **Cubes**.
|
||||||
|
|
||||||
|
### 11 — Fonctions transverses
|
||||||
|
- 🟡 Périmètre et conservation de l'**historisation**.
|
||||||
|
- 🟡 Critères complets de **recherche**.
|
||||||
|
- 🔴 Catalogue des **paramètres par défaut** et leur portée.
|
||||||
|
- 🔴 Distinction **paramétrage transverse** / **génération du portefeuille** (même libellé dans le support).
|
||||||
|
- 🔴 **Workflows de validation** et escalades.
|
||||||
|
- 🔴 **Mises à jour en masse** : cas d'usage, périmètre, garde-fous.
|
||||||
|
|
||||||
|
### 12 — Pilotage
|
||||||
|
- 🔴 **Définir intégralement** le besoin (objectifs, KPI, destinataires, fréquence).
|
||||||
|
- 🔴 Frontière **SatPRO ↔ Cubes** pour le pilotage et le reporting.
|
||||||
|
- ⚪ Tableaux de bord par rôle.
|
||||||
|
|
||||||
|
## 3. Vocabulaire à clarifier (glossaire)
|
||||||
|
|
||||||
|
| Terme | À préciser |
|
||||||
|
|-------|------------|
|
||||||
|
| **FE** | Mode de facturation — signification exacte |
|
||||||
|
| **FPV** | Paramètre lié à la vérification (type sortie, lieu) |
|
||||||
|
| **LI (L1 à L4)** | 4 valeurs texte au niveau client (sans héritage) — signification de chaque valeur à préciser |
|
||||||
|
| **Flag national / réseau** | Impacts métier |
|
||||||
|
|
||||||
|
## 4. Décisions « reprendre / ajuster / supprimer »
|
||||||
|
|
||||||
|
> Grille à remplir en atelier pour chaque fonctionnalité de l'existant.
|
||||||
|
|
||||||
|
| Fonctionnalité | Décision (🟢 conserver / 🟡 ajuster / 🔴 revoir / ❌ supprimer) | Commentaire |
|
||||||
|
|----------------|:---:|---|
|
||||||
|
| *(à compléter en séance)* | | |
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 15 — Journal des décisions
|
||||||
|
|
||||||
|
> Traçabilité des retours d'atelier (annotations) et des décisions appliquées aux specs.
|
||||||
|
> **Usage** : une fois une entrée marquée « ✅ Appliqué », l'annotation correspondante
|
||||||
|
> (colonne *ID annotation*) peut être passée en **« traitée »** dans l'outil d'annotation.
|
||||||
|
|
||||||
|
## Légende statut
|
||||||
|
|
||||||
|
- ✅ **Appliqué** — modification reportée dans les specs.
|
||||||
|
- 🔄 **En cours** — en cours de traitement.
|
||||||
|
- ⏳ **En attente** — nécessite une décision ou un complément métier.
|
||||||
|
|
||||||
|
## Lot du 2026-10-04 — source : `retours/20261004_annotations-satpro.json`
|
||||||
|
|
||||||
|
| # | ID annotation | Auteur | Document / section | Retour | Décision appliquée | Fichiers | Statut |
|
||||||
|
|---|---------------|--------|--------------------|--------|--------------------|----------|:------:|
|
||||||
|
| 1 | `d0c0bfe86f` | nikolaz.stephan | 01 — Vision & périmètre / §5 Systèmes externes | La gestion de l'accord est à intégrer au périmètre (fonctionnalité existante). | Accord retiré des systèmes externes et ajouté au périmètre fonctionnel. Création d'une fiche dédiée **optionnelle** en position 06, entre initialisation du contrat (05) et prestations (07). Renumérotation en chaîne des fiches 06→14 et mise à jour du sommaire, du processus global (table + diagramme + enchaînement) et des liens croisés. | `01-vision-et-perimetre.md`, `06-accords.md` (nouveau), `00-sommaire.md`, `04-processus-global.md` (+ renommage 07→14) | ✅ |
|
||||||
|
| 2 | `95c5a6138d` | nikolaz.stephan | 02 — Glossaire / Consignes & documents | Les paramètres LI (L1 à L4) sont des informations paramétrées à chaque compte client. | Précisé : **4 valeurs texte** paramétrables **au niveau du client uniquement**, **sans héritage** vers contrat/prestation. Retirées de la liste des données héritées de la fiche prestations ; mention corrigée dans les questions atelier. | `02-glossaire.md`, `07-prestations.md`, `14-questions-ateliers.md` | ✅ |
|
||||||
|
| 3 | `7bf372bd22` | nikolaz.stephan | 02 — Glossaire / Sous-traitance | STI = interne **à la filiale** (pas au groupe). Prévoir une sous-traitance **intra-groupe** fonctionnant comme une STE mais avec un fournisseur filiale du groupe. | Glossaire corrigé (STI = filiale, STE = hors groupe) + ajout du terme **intra-groupe**. Fiche sous-traitance mise à jour : intro, objectif, acteurs, et nouvelle **Partie C — intra-groupe** (règles RG-STG-01 à 03) + points ouverts. | `02-glossaire.md`, `11-sous-traitance.md` | ✅ |
|
||||||
|
| 4 | `1cfd51fd37` | nikolaz.stephan | 12 → 13 — Pilotage / Articulation Cubes | Report au sein de l'application, Cubes ou Power BI. | Section renommée « Supports de reporting » : trois canaux (application SatPRO / Cubes / Power BI). Répartition entre canaux ajoutée aux points ouverts. | `13-pilotage.md` | ✅ |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- La renumérotation du lot 1 (insertion de l'accord en 06) a décalé les fiches suivantes :
|
||||||
|
prestations `07`, interventions `08`, RFI `09`, facturation `10`, sous-traitance `11`,
|
||||||
|
fonctions transverses `12`, pilotage `13`, questions atelier `14`. Les liens internes ont
|
||||||
|
été vérifiés (aucun lien cassé).
|
||||||
|
- Points **restés ouverts** issus de ces retours (à traiter en atelier) :
|
||||||
|
- signification métier de chacune des valeurs **L1 à L4** ;
|
||||||
|
- spécificités de l'**intra-groupe** vs STE (tarification, refacturation inter-filiales) ;
|
||||||
|
- répartition du reporting entre **SatPRO / Cubes / Power BI** ;
|
||||||
|
- cycle de vie et propagation des **accords** (fiche 06).
|
||||||
|
|
||||||
|
## Lot du 2026-10-04 (2) — source : `retours/20261004-2_annotations-satpro.json`
|
||||||
|
|
||||||
|
| # | ID annotation | Auteur | Document / section | Retour | Décision appliquée | Fichiers | Statut |
|
||||||
|
|---|---------------|--------|--------------------|--------|--------------------|----------|:------:|
|
||||||
|
| 5 | `6b73b3cc05` | nikolaz.stephan | 06 — Gestion des accords | L'accord s'initie à partir de Bingo ; le contrat peut y être rattaché dès l'offre ou en gestion de contrat (reprendre la fonctionnalité existante de rattacher/détacher des contrats d'un accord). | Fiche accords enrichie : origine **Bingo** (déclencheur + RG-ACC-01, données reçues), nouvelle section **3bis — Rattachement** (dès l'offre ou en gestion de contrat), règles **RG-ACC-05** (deux moments de rattachement) et **RG-ACC-06** (rattacher/détacher, reprise de l'existant). | `06-accords.md` | ✅ |
|
||||||
|
| 5b | `6b73b3cc05` (précision échange) | nikolaz.stephan | 05 — Initialisation du contrat & 06 — Accords | Frontière Bingo ↔ SatPRO : Bingo = acte commercial ; SatPRO = formalisation + suivi des évolutions ; consultation des données en cours au devis ; toute modification passe par une MEP administrative des changements (impacts prestations). | **Frontière tranchée.** Accords : RG-ACC-07 (Bingo = commercial, SatPRO = formalisation/source de vérité), RG-ACC-08 (consultation au nouveau devis), RG-ACC-09 (MEP administrative des changements). Contrat : RG-CTR-09 et RG-CTR-10 (mêmes principes). Points ouverts mis à jour (frontière 🟢 tranchée ; MEP des changements 🔴 à détailler). | `06-accords.md`, `05-initialisation-contrat.md` | ✅ |
|
||||||
|
|
||||||
|
## Lot du 2026-10-05 — source : `retours/20261005_annotations-satpro.json`
|
||||||
|
|
||||||
|
> 27 annotations traitées (l'annotation `6b73b3cc05` de ce fichier était déjà traitée au lot précédent).
|
||||||
|
|
||||||
|
| # | ID annotation | Document / section | Retour | Décision appliquée | Fichiers | Statut |
|
||||||
|
|---|---------------|--------------------|--------|--------------------|----------|:------:|
|
||||||
|
| 6 | `a081ea4363` | 01 — Vision & périmètre | Une commande directe n'existe que rattachée à un accord (→ contrat/prestation) ou à un contrat existant (complément). | §4.1 règle de création du contrat ; natures de contrat au glossaire. | `01-vision-et-perimetre.md`, `02-glossaire.md` | ✅ |
|
||||||
|
| 7 | `3be000392c` | 01 — Vision & périmètre | Supprimer la fonctionnalité existante **C2I**. | §4 « fonctionnalités à supprimer » ; glossaire « non reconduites ». | `01-vision-et-perimetre.md`, `02-glossaire.md` | ✅ |
|
||||||
|
| 8 | `cde4eebc74` | 05 — Initialisation du contrat | Réf. de l'offre et de l'avenant comme critères de recherche du contrat. | **RG-CTR-12**. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 9 | `7f38808869` | 05 — Initialisation du contrat | Découpage pour gestion interne : pas en agence, mais reprendre/corriger les contraintes actuelles (filtres, axe de regroupement paramétrable). | Note §1 (découpage gestion interne). | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 10 | `84b4d451bd` | 05 — Initialisation du contrat | Contrat créé seulement depuis offre ou commande directe rattachée à un accord ; commande directe sur contrat déclenche la part de prestation. | §1 origine + **RG-CTR-11**. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 11 | `8ed19366eb` | 05 — Initialisation du contrat | Paramétrage de facturation additionnelle (week-end, minimum…). | §4.4 + données §6.2. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 12 | `b77f6186dc` | 05 — Initialisation du contrat | Contrat à durée finie ou indéterminée (tacite reconduction). | §4.3 + **RG-CTR-13**. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 13 | `edd08e4101` | 05 — Initialisation du contrat | Augmentation automatique des tarifs selon la règle d'indexation. | §4.4 + **RG-CTR-15**. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 14 | `2296e7c6c8` | 05 — Initialisation du contrat | Origine de l'augmentation : directive commerciale (privé) / CGV (public) / propre au contrat. | §4.4 + **RG-CTR-15**. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 15 | `5c335e331f` | 05 — Initialisation du contrat | Contrat à bon de commande : interventions valides seulement si BdC client passé. | §4.3 + **RG-CTR-14** ; glossaire. | `05-initialisation-contrat.md`, `02-glossaire.md` | ✅ |
|
||||||
|
| 16 | `4b7775b06c` | 05 — Initialisation du contrat | Simplifier la notion de rapport CT / CT Terrain / provisoire. | §4.5 (🟡 cible de simplification). | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 17 | `78a40238f1` | 05 — Initialisation du contrat | Destinataire des livrables, définissable comme principal par livrable. | §4.5. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 18 | `299a64bc2e` | 05 — Initialisation du contrat | MEP : lier une prestation à une prestation terminée/résiliée pour continuité. | §4.6 + **RG-CTR-18**. | `05-initialisation-contrat.md` | ✅ |
|
||||||
|
| 19 | `6f21f4d02f` | 05 — Initialisation du contrat | Paramétrage « imposé » non dérogeable au niveau inférieur. | §4.3 + **RG-CTR-16** ; répercuté en 07 §4.2. | `05-initialisation-contrat.md`, `07-prestations.md` | ✅ |
|
||||||
|
| 20 | `3a084a223d` | 05 — Initialisation du contrat | Définition du contrat gré à gré (ni accord ni directif, issu d'une offre, pas de BdC client). | §4.6 + **RG-CTR-17** ; glossaire natures de contrat. | `05-initialisation-contrat.md`, `02-glossaire.md` | ✅ |
|
||||||
|
| 21 | `5b4f138ac1` | 06 — Accords | Accord de référencement = grille tarifaire ; contrat rattaché vend à prix identique/différent ou missions complémentaires. | §1.1 + **RG-ACC-10**. | `06-accords.md` | ✅ |
|
||||||
|
| 22 | `139d9dc256` | 06 — Accords | Variante contrat directif : grille suivie, pas de mission complémentaire. | §1.1 + **RG-ACC-11** ; glossaire. | `06-accords.md`, `02-glossaire.md` | ✅ |
|
||||||
|
| 23 | `465fcda487` | 06 — Accords | Rattachement hors BPU non bloquant → « anomalie » informative. | §3bis + **RG-ACC-12**. | `06-accords.md` | ✅ |
|
||||||
|
| 24 | `ac84665e84` | 07 — Prestations | Regroupement/dégroupement en attente (voir si Plan'UP le gère ; sinon fin de projet). | §4.3 + **RG-PRE-12**. | `07-prestations.md` | ✅ |
|
||||||
|
| 25 | `88c650bbe5` | 07 — Prestations | Scinder / dispatcher une prestation en plusieurs (respect du contrat). | §4.2 + **RG-PRE-11**. | `07-prestations.md` | ✅ |
|
||||||
|
| 26 | `ea4525216d` | 08 — Interventions | Génération des interventions sur 24 mois glissants. | §4.1 + **RG-ITV-10**. | `08-interventions.md` | ✅ |
|
||||||
|
| 27 | `7dbec6122c` | 08 — Interventions | Différencier intervention suspendue vs résiliée (résiliation de prestation). | §4.4 + **RG-ITV-11**. | `08-interventions.md` | ✅ |
|
||||||
|
| 28 | `5b4bb07c8d` | 08 — Interventions | Suivi de l'annulation client : facturer sans bloquer la replanification + alerte. | §4.4 + **RG-ITV-12**. | `08-interventions.md` | ✅ |
|
||||||
|
| 29 | `e9e48a4387` | 10 — Facturation | Revoir le process de déplacement infructueux (ex-C2I « mode échec », non reconduit). | §7 cas particulier + point ouvert 🔴. | `10-facturation.md` | ✅ |
|
||||||
|
| 30 | `66e332dc14` | 12 — Fonctions transverses | Portefeuille : vision minimum 24 mois glissants. | §4 + **RG-TRV-06**. | `12-fonctions-transverses.md` | ✅ |
|
||||||
|
| 31 | `8c10194942` | 13 — Pilotage | Tableau de bord à l'ouverture : rappel des accusés de lecture des N derniers jours sur le périmètre. | §3.1. | `13-pilotage.md` | ✅ |
|
||||||
|
| 32 | `0291094b86` | 13 — Pilotage | Suivi des contrats à renégocier. | §2 pistes de contenu. | `13-pilotage.md` | ✅ |
|
||||||
|
|
||||||
|
### Points restés ouverts (issus du lot 2026-10-05)
|
||||||
|
|
||||||
|
- Sens métier et usage des **3 natures de contrat** à affiner si besoin (gré à gré / référencement / directif).
|
||||||
|
- **Simplification des rapports** CT / CT Terrain / provisoire : cible à définir.
|
||||||
|
- **Process de déplacement infructueux** à redéfinir (ex-C2I).
|
||||||
|
- **Contrat à bon de commande** : règles de validité/production des interventions à détailler.
|
||||||
|
- **Paramétrage imposé** : périmètre des paramètres concernés.
|
||||||
|
- **Tableau de bord accusés de lecture** : valeur de N et périmètre utilisateur.
|
||||||
|
- **Regroupement / dégroupement** des prestations : décision suspendue à l'étude Plan'UP.
|
||||||
|
|
||||||
|
## Prochain lot
|
||||||
|
|
||||||
|
> _(à compléter au prochain export d'annotations déposé dans `retours/`)_
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"id":"6b73b3cc05","docCle":"06-accords","ancre":"","titreSection":"","type":"remarque","texte":"L'accord s'initie à partir de Bingo, le contrat peut y être rattaché dès l'offre ou en gestion de contrat (reprendre la fonctionnalité existante de SatPRO pour rattacher/détacher des contrats d'un accord existant)","auteur":"nikolaz.stephan","statut":"ouverte","creeLe":"2026-10-04T17:16:55.675Z","modifieLe":"2026-10-04T17:16:55.675Z"}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"id":"d0c0bfe86f","docCle":"01-vision-et-perimetre","ancre":"5-systemes-externes-en-interface","titreSection":"5. Systèmes externes en interface","type":"remarque","texte":"La gestion de l'accord est à intégrer au périmètre du projet (fonctionnalité existante dans SatPRO)","auteur":"nikolaz.stephan","statut":"ouverte","creeLe":"2026-10-04T11:36:44.777Z","modifieLe":"2026-10-04T11:36:44.777Z"},{"id":"95c5a6138d","docCle":"02-glossaire","ancre":"consignes-documents","titreSection":"Consignes & documents","type":"remarque","texte":"Les paramètres Li (L1 à L4) sont des informations paramétrés à chaque compte client","auteur":"nikolaz.stephan","statut":"ouverte","creeLe":"2026-10-04T12:09:17.790Z","modifieLe":"2026-10-04T12:09:17.790Z"},{"id":"7bf372bd22","docCle":"02-glossaire","ancre":"sous-traitance","titreSection":"Sous-traitance","type":"remarque","texte":"la STI est une sous-traitance interne à la filiale (et non au sein du groupe). On peut imaginer une sous-traitance intra groupe, qui fonctionnera comme une sous-traitance externe mais dont le fournisseur est une filiale du groupe","auteur":"nikolaz.stephan","statut":"ouverte","creeLe":"2026-10-04T12:11:09.312Z","modifieLe":"2026-10-04T12:11:09.312Z"},{"id":"1cfd51fd37","docCle":"12-pilotage","ancre":"3-articulation-avec-les-cubes","titreSection":"3. Articulation avec les Cubes","type":"remarque","texte":"Report au sein de l'application, cubes ou PowerBI","auteur":"nikolaz.stephan","statut":"ouverte","creeLe":"2026-10-04T12:06:36.414Z","modifieLe":"2026-10-04T12:07:13.679Z"}]
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
|||||||
|
# Exemple de configuration d'environnement pour Docker Compose.
|
||||||
|
# Copiez ce fichier vers `.env` puis renseignez les valeurs réelles :
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# Le fichier `.env` n'est PAS commité (voir .gitignore). Ne mettez jamais de secret
|
||||||
|
# réel dans `.env.example`.
|
||||||
|
|
||||||
|
# --- Session ---
|
||||||
|
# Secret de signature JWT (OBLIGATOIRE). Générez une valeur aléatoire longue (>= 32 caractères).
|
||||||
|
# Ex. pour générer : openssl rand -hex 32
|
||||||
|
JWT_SECRET=change-me-generer-une-longue-chaine-aleatoire-de-32-caracteres-min
|
||||||
|
JWT_EXPIRATION_MINUTES=720
|
||||||
|
|
||||||
|
# --- Compte administrateur (UNIQUE compte défini hors de l'application) ---
|
||||||
|
# Tous les autres comptes se créent/gèrent dans le site, page « Gestion des accès ».
|
||||||
|
ADMIN_USER=madjax
|
||||||
|
ADMIN_PASSWORD=Change-moi
|
||||||
|
|
||||||
|
# --- Amorçage facultatif des comptes initiaux (1er démarrage, si store vide) ---
|
||||||
|
# Format : "login:motdepasse:role,..." avec role ∈ consultation|contributeur|desactive.
|
||||||
|
# Laisser vide pour utiliser l'amorçage par défaut intégré (les 3 comptes d'atelier
|
||||||
|
# en contributeur). Les comptes amorcés devront changer leur mot de passe à la
|
||||||
|
# première connexion. Une fois le store créé, cette variable n'a plus d'effet.
|
||||||
|
SEED_USERS=
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Secrets / configuration locale
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Dépendances
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Artefacts de build Angular
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.angular/
|
||||||
|
|
||||||
|
# Annotations générées à l'exécution (données d'atelier), on garde le dossier (.gitkeep)
|
||||||
|
annotations/*.json
|
||||||
|
|
||||||
|
# Comptes gérés via le site (mots de passe hachés) — données d'exécution, dossier gardé
|
||||||
|
users/*.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Exemple de configuration Caddy pour exposer le site des specs SatPRO en HTTPS.
|
||||||
|
#
|
||||||
|
# Topologie (Caddy éventuellement sur une machine séparée de la machine Docker) :
|
||||||
|
# Internet ──HTTPS──> [Caddy] ──HTTP──> [Machine Docker] : port 2002
|
||||||
|
# (frontend nginx du compose : app + /api)
|
||||||
|
#
|
||||||
|
# Remplace `192.168.1.50` par l'IP (LAN/VPN) de la machine Docker qui publie le compose.
|
||||||
|
# Le DNS de satpro.madjax.fr doit pointer vers la machine qui exécute CE Caddy.
|
||||||
|
|
||||||
|
satpro.madjax.fr {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Renforce l'anti-indexation côté edge (en plus de nginx, du <meta robots> et du robots.txt).
|
||||||
|
header {
|
||||||
|
X-Robots-Tag "noindex, nofollow, noarchive, nosnippet"
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy 192.168.1.50:2002 {
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
|
||||||
|
health_uri /health
|
||||||
|
health_interval 30s
|
||||||
|
}
|
||||||
|
|
||||||
|
# Caddy gère automatiquement le certificat TLS (Let's Encrypt) pour satpro.madjax.fr,
|
||||||
|
# à condition que le DNS pointe vers cette machine et que les ports 80/443 soient ouverts.
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- SÉCURITÉ RÉSEAU (si machines séparées) ---
|
||||||
|
# Restreindre l'accès au port 2002 de la machine Docker à la seule IP de la machine Caddy :
|
||||||
|
# ufw allow from <IP_CADDY> to any port 2002 proto tcp
|
||||||
|
# ufw deny 2002
|
||||||
|
# Idéalement, faire communiquer les deux machines via un réseau privé/VPN (WireGuard, etc.).
|
||||||
|
#
|
||||||
|
# Si Caddy tourne SUR la même machine que Docker, remplace l'IP par localhost :
|
||||||
|
# reverse_proxy localhost:2002 { ... }
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
# SatPRO — Site de présentation des specs (pré-projet)
|
||||||
|
|
||||||
|
Site web privé pour **présenter les specs métier SatPRO** (les fichiers Markdown de
|
||||||
|
`../docs/specs-pre-projet/`) en séance d'atelier.
|
||||||
|
|
||||||
|
- **Viewer Markdown** : navigation entre les documents, rendu GFM + tableaux + diagrammes Mermaid.
|
||||||
|
- **Lecture en direct** : le site lit les fichiers `.md` sur le disque. Quand les specs
|
||||||
|
sont éditées (dans l'IDE), un simple **Rafraîchir** affiche la dernière version. Aucune
|
||||||
|
recompilation, aucune duplication de contenu.
|
||||||
|
- **Accès gérés dans le site** : un **administrateur** unique est défini dans `.env` ;
|
||||||
|
tous les autres comptes se créent/gèrent via la page **Gestion des accès** (rôles
|
||||||
|
consultation / contributeur / désactivé, réinitialisation de mot de passe, changement
|
||||||
|
imposé à la première connexion). Session JWT.
|
||||||
|
- **Annotations en ligne** : chaque utilisateur peut annoter une section (ajout / correction /
|
||||||
|
question / remarque). Les annotations sont stockées côté serveur et **exportables** (admin)
|
||||||
|
en Markdown de synthèse ou JSON, pour transmission et mise à jour des `.md`.
|
||||||
|
- **Non indexable** : `robots.txt`, balise `<meta robots>`, en-tête `X-Robots-Tag` (nginx + Caddy).
|
||||||
|
|
||||||
|
> **Note sur le chat IA.** Ce site est volontairement **sans chat** (Option B). L'assistant
|
||||||
|
> (Kiro) vit dans l'IDE : pendant l'atelier, les questions se posent dans l'IDE, les specs
|
||||||
|
> y sont éditées, et le site reflète les changements. Le site **présente**, il ne discute pas.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
site/
|
||||||
|
├─ frontend/ Angular 22 (viewer : marked + DOMPurify + mermaid), servi par nginx
|
||||||
|
├─ backend/ API Node/Express : auth + /api/specs + /api/annotations + /api/users
|
||||||
|
├─ annotations/ Données d'annotations (1 JSON par document) — volume RW, non commité
|
||||||
|
├─ users/ Comptes gérés (users.json, mots de passe bcrypt) — volume RW, non commité
|
||||||
|
├─ docker-compose.yml
|
||||||
|
├─ Caddyfile.example (reverse-proxy HTTPS pour satpro.madjax.fr)
|
||||||
|
├─ .env.example (comptes + secret JWT — à copier en .env, NON commité)
|
||||||
|
└─ .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Les specs ne sont **pas** copiées dans l'image : le dossier `../docs/specs-pre-projet`
|
||||||
|
est monté **en lecture seule** (`:ro`) dans le conteneur backend sur `/specs`. Les
|
||||||
|
annotations, elles, sont écrites dans `./annotations` (monté **en lecture-écriture** sur
|
||||||
|
`/annotations`), donc persistées sur l'hôte et faciles à récupérer.
|
||||||
|
|
||||||
|
## Configuration (.env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis éditez `.env` :
|
||||||
|
|
||||||
|
| Variable | Rôle |
|
||||||
|
|----------|------|
|
||||||
|
| `JWT_SECRET` | Secret de signature des sessions (**obligatoire**, ≥ 32 caractères). Ex. `openssl rand -hex 32` |
|
||||||
|
| `JWT_EXPIRATION_MINUTES` | Durée de session (défaut 720 = 12 h) |
|
||||||
|
| `ADMIN_USER` | Login de l'**administrateur** unique (seul compte défini hors application). Ex. `madjax` |
|
||||||
|
| `ADMIN_PASSWORD` | Mot de passe de l'administrateur. Ex. `Change-moi` |
|
||||||
|
| `SEED_USERS` | *(facultatif)* Comptes amorcés au **premier** démarrage si le store est vide : `login:mdp:role,...` (`role` ∈ `consultation`/`contributeur`/`desactive`). Vide = amorçage par défaut intégré. Sans effet une fois `users/users.json` créé. |
|
||||||
|
|
||||||
|
> Seul l'`ADMIN_USER` vit dans `.env`. **Tous les autres comptes** se créent et se gèrent
|
||||||
|
> dans l'application (page **Gestion des accès**, accessible à l'admin) et sont persistés
|
||||||
|
> dans `users/users.json` (mots de passe hachés bcrypt).
|
||||||
|
|
||||||
|
## Lancement en production (Docker)
|
||||||
|
|
||||||
|
Depuis `site/` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Le frontend (nginx) est publié sur le port **2002** de la machine Docker.
|
||||||
|
- Le backend reste interne au réseau Compose (non publié).
|
||||||
|
- Vérifier : `curl http://localhost:2002/health` → `{"statut":"ok"}`.
|
||||||
|
|
||||||
|
### Exposition HTTPS (Caddy) — satpro.madjax.fr
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp Caddyfile.example Caddyfile # puis adapter l'IP de la machine Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy termine le TLS (Let's Encrypt) et proxifie vers le port 2002. Voir les notes de
|
||||||
|
sécurité réseau dans `Caddyfile.example` (filtrage du port 2002, réseau privé/VPN).
|
||||||
|
|
||||||
|
Prérequis DNS : `satpro.madjax.fr` doit pointer vers la machine qui exécute Caddy.
|
||||||
|
|
||||||
|
## Développement local (sans Docker)
|
||||||
|
|
||||||
|
### Méthode recommandée : `run-dev.sh`
|
||||||
|
|
||||||
|
Depuis `site/` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script :
|
||||||
|
|
||||||
|
- charge `JWT_SECRET`, `ADMIN_USER`/`ADMIN_PASSWORD` et `SEED_USERS` depuis `.env` s'il
|
||||||
|
existe (sinon : secret de dev aléatoire + admin par défaut `admin` / `admin`) ;
|
||||||
|
- installe les dépendances si `node_modules` manque (back et front) ;
|
||||||
|
- démarre le **backend** Node sur `:8080` (specs lues en direct dans `../docs/specs-pre-projet`)
|
||||||
|
et le **frontend** Angular sur `:4200` ;
|
||||||
|
- attend que le frontend soit réellement prêt (HTTP 200) avant d'afficher le récapitulatif ;
|
||||||
|
- arrête proprement les deux processus à `Ctrl+C`.
|
||||||
|
|
||||||
|
Ouvrir ensuite http://localhost:4200 (admin `admin` / `admin` par défaut en dev, ou les
|
||||||
|
valeurs de `.env` ; les comptes amorcés devront changer leur mot de passe à la 1re connexion).
|
||||||
|
|
||||||
|
### Méthode manuelle (deux terminaux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Backend (port 8080)
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
JWT_SECRET="dev-secret-32-caracteres-minimum-ok" \
|
||||||
|
ADMIN_USER="admin" ADMIN_PASSWORD="admin" \
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 2) Frontend (port 4200, proxy vers :8080 via environment.ts)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ajouter / modifier un document
|
||||||
|
|
||||||
|
1. Créez ou éditez un fichier dans `../docs/specs-pre-projet/` (ex. `14-annexes.md`).
|
||||||
|
2. Le **menu se met à jour automatiquement** : les fichiers `.md` sont listés par ordre
|
||||||
|
de nom (préfixes `00`, `01`, …) et le titre est tiré du premier titre `#` du fichier.
|
||||||
|
3. En séance : cliquez **Rafraîchir** (ou rechargez la page) pour voir la dernière version.
|
||||||
|
|
||||||
|
## Gestion des accès (administrateur)
|
||||||
|
|
||||||
|
L'administrateur (défini dans `.env`) dispose d'un bouton **Gestion des accès** dans
|
||||||
|
l'en-tête. Cette page permet de :
|
||||||
|
|
||||||
|
- **Créer un compte** : login + mot de passe par défaut + rôle. Le compte devra **changer
|
||||||
|
son mot de passe à la première connexion**.
|
||||||
|
- **Changer le rôle** d'un compte :
|
||||||
|
- **Consultation** — lecture seule (consulte les specs et les annotations, ne peut pas annoter) ;
|
||||||
|
- **Contributeur** — peut annoter (créer / éditer / supprimer ses propres annotations) ;
|
||||||
|
- **Désactivé** — connexion refusée (idéal pour couper un accès entre deux ateliers sans le supprimer).
|
||||||
|
- **Réinitialiser le mot de passe** d'un compte (si l'utilisateur l'a oublié) : un nouveau
|
||||||
|
mot de passe par défaut est fixé et le changement est de nouveau imposé à la connexion suivante.
|
||||||
|
- **Supprimer** un compte (pour un usage entre ateliers, préférez **Désactivé**).
|
||||||
|
|
||||||
|
Les changements de rôle et les désactivations prennent effet **immédiatement**, sans que
|
||||||
|
l'utilisateur ait à se reconnecter. Les comptes sont persistés dans `users/users.json`
|
||||||
|
(mots de passe hachés bcrypt) et conservés entre les ateliers.
|
||||||
|
|
||||||
|
> L'administrateur n'apparaît pas dans cette liste : il se configure uniquement dans `.env`
|
||||||
|
> (`ADMIN_USER` / `ADMIN_PASSWORD`).
|
||||||
|
|
||||||
|
## Annotations (retours des utilisateurs)
|
||||||
|
|
||||||
|
Pendant l'atelier, chaque utilisateur **contributeur** (ou l'admin) peut annoter les specs
|
||||||
|
(un compte en **consultation** voit les annotations mais ne peut pas en ajouter) :
|
||||||
|
|
||||||
|
1. Dans un document, survoler un titre de section fait apparaître un bouton **💬**.
|
||||||
|
Cliquer dessus ouvre le panneau d'annotation avec la section préremplie.
|
||||||
|
2. Choisir un **type** (remarque / ajout / correction / question), saisir le texte, **Ajouter**.
|
||||||
|
3. Le bouton **💬 Annotations** (barre du haut) ouvre/ferme le panneau latéral qui liste
|
||||||
|
toutes les annotations du document. Un badge indique le nombre d'annotations **ouvertes**.
|
||||||
|
4. Chaque utilisateur peut **éditer / supprimer ses propres** annotations et basculer leur
|
||||||
|
statut (ouverte ↔ traitée). Un **administrateur** (`ADMIN_USERS`) peut gérer **toutes**
|
||||||
|
les annotations.
|
||||||
|
|
||||||
|
### Stockage
|
||||||
|
|
||||||
|
- Un fichier JSON par document dans `site/annotations/` (ex. `06-prestations.json`).
|
||||||
|
- Persisté sur l'hôte (volume Docker RW). Les `.json` ne sont pas commités (`.gitignore`).
|
||||||
|
|
||||||
|
### Export et transmission (admin)
|
||||||
|
|
||||||
|
Dans le panneau d'annotations, un administrateur dispose de deux exports :
|
||||||
|
|
||||||
|
- **Markdown** (`annotations-satpro.md`) : synthèse lisible, regroupée par document puis par
|
||||||
|
section — c'est le format à **transmettre à Kiro** pour mettre à jour les `.md`.
|
||||||
|
- **JSON** (`annotations-satpro.json`) : données brutes.
|
||||||
|
|
||||||
|
Alternative sans l'UI : récupérer directement les fichiers de `site/annotations/*.json` sur
|
||||||
|
le serveur (ils constituent la source complète des retours).
|
||||||
|
|
||||||
|
Le flux de bout en bout : *les utilisateurs annotent → l'admin exporte le Markdown → vous me
|
||||||
|
le transmettez → je mets à jour les fichiers de `docs/specs-pre-projet/`.*
|
||||||
|
|
||||||
|
## Déploiement par archive (tar)
|
||||||
|
|
||||||
|
Le `docker-compose.yml` monte `../docs/specs-pre-projet` : l'archive doit donc contenir
|
||||||
|
**`site/` ET `docs/specs-pre-projet/`** avec la même arborescence relative. On se place à la
|
||||||
|
racine `DEKRA/SatPRO` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/madjax/KiroProject/DEKRA/SatPRO
|
||||||
|
|
||||||
|
tar czf satpro-site.tar.gz \
|
||||||
|
--exclude='site/frontend/node_modules' \
|
||||||
|
--exclude='site/backend/node_modules' \
|
||||||
|
--exclude='site/frontend/dist' \
|
||||||
|
--exclude='site/frontend/.angular' \
|
||||||
|
--exclude='site/.env' \
|
||||||
|
--exclude='site/annotations/*.json' \
|
||||||
|
--exclude='site/users/*.json' \
|
||||||
|
site docs/specs-pre-projet
|
||||||
|
```
|
||||||
|
|
||||||
|
> On exclut `site/.env` (identifiants) — à recréer sur le serveur — et les annotations
|
||||||
|
> `*.json` (données d'exécution). Le dossier `site/annotations/` reste inclus (via `.gitkeep`)
|
||||||
|
> pour que le volume RW ait son point de montage.
|
||||||
|
|
||||||
|
Côté serveur :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp satpro-site.tar.gz user@serveur:/chemin/cible/
|
||||||
|
# puis sur le serveur :
|
||||||
|
cd /chemin/cible && tar xzf satpro-site.tar.gz
|
||||||
|
cd site
|
||||||
|
cp .env.example .env # renseigner JWT_SECRET, ADMIN_USER, ADMIN_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Le site répond sur le port **2002**, prêt à être exposé en HTTPS via Caddy.
|
||||||
|
|
||||||
|
> Mise à jour ultérieure des specs sur le serveur : recopier `docs/specs-pre-projet/`
|
||||||
|
> (pas besoin de rebuilder). Les annotations existantes dans `site/annotations/` sont conservées.
|
||||||
|
|
||||||
|
## Points de sécurité
|
||||||
|
|
||||||
|
- `.env` n'est **jamais** commité (`.gitignore`).
|
||||||
|
- Le backend refuse de démarrer sans `JWT_SECRET` (≥ 32 caractères) ni comptes.
|
||||||
|
- Les specs sont montées **en lecture seule** : le site ne peut pas modifier les fichiers.
|
||||||
|
- Les annotations sont écrites dans un volume **séparé** (`/annotations`), jamais dans les specs.
|
||||||
|
- Protection contre la traversée de répertoire sur `/api/specs/:cle` et `/api/annotations/:cle`.
|
||||||
|
- Modification/suppression d'une annotation réservée à son **auteur** ou à un **admin** ;
|
||||||
|
export global réservé aux **admins**.
|
||||||
|
- Contenu non indexable (robots.txt + meta + `X-Robots-Tag`) **et** protégé par login.
|
||||||
|
|
||||||
|
## Vérifications effectuées
|
||||||
|
|
||||||
|
- Build frontend production : OK (`dist/satpro-specs-frontend/browser`).
|
||||||
|
- Backend : `/health`, login multi-comptes (succès/échec), liste des 14 documents,
|
||||||
|
lecture de contenu en direct, en-tête `X-Robots-Tag`, blocage de la traversée de répertoire.
|
||||||
|
- Annotations : création, édition (auteur), bascule de statut, suppression, droits
|
||||||
|
auteur/admin, export Markdown et JSON (réservé admin, `403` sinon).
|
||||||
|
- Gestion des accès : admin unique via `.env`, comptes gérés en JSON (bcrypt), rôles
|
||||||
|
consultation/contributeur/désactivé appliqués côté serveur, changement de mot de passe
|
||||||
|
imposé à la 1re connexion et après réinitialisation, effet immédiat des changements de rôle.
|
||||||
|
- `docker compose config` : port publié `2002`, montages `../docs/specs-pre-projet` → `/specs`
|
||||||
|
(ro), `./annotations` → `/annotations` (rw) et `./users` → `/users` (rw), `ADMIN_USER` injecté.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Ce dossier reçoit les annotations (un fichier JSON par document), écrites par le
|
||||||
|
# backend à l'exécution. Les .json ne sont pas versionnés (voir ../.gitignore) ;
|
||||||
|
# ce .gitkeep garantit que le dossier existe (montage du volume Docker en RW).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# curl pour le healthcheck du compose
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Dépendances (couche cache)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Code source
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
Generated
+988
@@ -0,0 +1,988 @@
|
|||||||
|
{
|
||||||
|
"name": "satpro-specs-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "satpro-specs-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||||
|
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.16.0",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
|
||||||
|
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.5",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.13",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.16.0",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||||
|
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4",
|
||||||
|
"side-channel-list": "^1.0.1",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "satpro-specs-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API Node servant les specs markdown SatPRO (lecture en direct) avec authentification multi-comptes.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Stockage des annotations : un fichier JSON par document, dans ANNOTATIONS_DIR.
|
||||||
|
// Format d'un fichier (ex. annotations/06-prestations.json) :
|
||||||
|
// { "cle": "06-prestations", "annotations": [ { ...annotation... }, ... ] }
|
||||||
|
//
|
||||||
|
// Une annotation :
|
||||||
|
// {
|
||||||
|
// id: "a1b2c3", // identifiant court unique
|
||||||
|
// docCle: "06-prestations", // document concerné
|
||||||
|
// ancre: "4-1-decoupage", // id de section (slug du titre) ou "" (doc entier)
|
||||||
|
// titreSection: "4.1 Découpage", // libellé de section au moment de l'annotation
|
||||||
|
// type: "correction", // ajout | correction | question | remarque
|
||||||
|
// texte: "…", // contenu libre
|
||||||
|
// auteur: "invite1", // login de l'auteur
|
||||||
|
// statut: "ouverte", // ouverte | traitee
|
||||||
|
// creeLe: "2026-10-04T…Z",
|
||||||
|
// modifieLe: "2026-10-04T…Z"
|
||||||
|
// }
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { ANNOTATIONS_DIR } from './config.js';
|
||||||
|
|
||||||
|
const CLE_VALIDE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
||||||
|
const TYPES = new Set(['ajout', 'correction', 'question', 'remarque']);
|
||||||
|
const STATUTS = new Set(['ouverte', 'traitee']);
|
||||||
|
|
||||||
|
/** Garantit l'existence du dossier d'annotations. */
|
||||||
|
async function assurerDossier() {
|
||||||
|
await fs.mkdir(ANNOTATIONS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chemin sûr du fichier JSON d'un document. */
|
||||||
|
function cheminFichier(docCle) {
|
||||||
|
if (!CLE_VALIDE.test(docCle)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const chemin = path.resolve(ANNOTATIONS_DIR, `${docCle}.json`);
|
||||||
|
const racine = path.resolve(ANNOTATIONS_DIR) + path.sep;
|
||||||
|
if (!chemin.startsWith(racine)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return chemin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lit les annotations d'un document (tableau, éventuellement vide). */
|
||||||
|
export async function lireAnnotations(docCle) {
|
||||||
|
const chemin = cheminFichier(docCle);
|
||||||
|
if (!chemin) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const brut = await fs.readFile(chemin, 'utf8');
|
||||||
|
const data = JSON.parse(brut);
|
||||||
|
return Array.isArray(data.annotations) ? data.annotations : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écrit (atomiquement) les annotations d'un document. */
|
||||||
|
async function ecrireAnnotations(docCle, annotations) {
|
||||||
|
const chemin = cheminFichier(docCle);
|
||||||
|
if (!chemin) {
|
||||||
|
throw new Error('Clé de document invalide.');
|
||||||
|
}
|
||||||
|
await assurerDossier();
|
||||||
|
const contenu = JSON.stringify({ cle: docCle, annotations }, null, 2);
|
||||||
|
const tmp = `${chemin}.tmp-${process.pid}`;
|
||||||
|
await fs.writeFile(tmp, contenu, 'utf8');
|
||||||
|
await fs.rename(tmp, chemin); // rename atomique
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise/valide les champs d'entrée. Retourne { ok, valeur|erreur }. */
|
||||||
|
function validerEntree({ type, texte, ancre, titreSection, statut }) {
|
||||||
|
const texteNet = typeof texte === 'string' ? texte.trim() : '';
|
||||||
|
if (!texteNet) {
|
||||||
|
return { ok: false, erreur: 'Le texte de l’annotation est requis.' };
|
||||||
|
}
|
||||||
|
if (texteNet.length > 5000) {
|
||||||
|
return { ok: false, erreur: 'Texte trop long (5000 caractères max).' };
|
||||||
|
}
|
||||||
|
const typeNet = TYPES.has(type) ? type : 'remarque';
|
||||||
|
const statutNet = STATUTS.has(statut) ? statut : 'ouverte';
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
valeur: {
|
||||||
|
type: typeNet,
|
||||||
|
texte: texteNet,
|
||||||
|
ancre: typeof ancre === 'string' ? ancre.slice(0, 200) : '',
|
||||||
|
titreSection: typeof titreSection === 'string' ? titreSection.slice(0, 300) : '',
|
||||||
|
statut: statutNet
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée une annotation sur un document. */
|
||||||
|
export async function creerAnnotation(docCle, auteur, entree) {
|
||||||
|
if (!CLE_VALIDE.test(docCle)) {
|
||||||
|
throw new Error('Clé de document invalide.');
|
||||||
|
}
|
||||||
|
const v = validerEntree(entree);
|
||||||
|
if (!v.ok) {
|
||||||
|
return { erreur: v.erreur };
|
||||||
|
}
|
||||||
|
const maintenant = new Date().toISOString();
|
||||||
|
const annotation = {
|
||||||
|
id: crypto.randomBytes(5).toString('hex'),
|
||||||
|
docCle,
|
||||||
|
ancre: v.valeur.ancre,
|
||||||
|
titreSection: v.valeur.titreSection,
|
||||||
|
type: v.valeur.type,
|
||||||
|
texte: v.valeur.texte,
|
||||||
|
auteur,
|
||||||
|
statut: 'ouverte',
|
||||||
|
creeLe: maintenant,
|
||||||
|
modifieLe: maintenant
|
||||||
|
};
|
||||||
|
const liste = await lireAnnotations(docCle);
|
||||||
|
liste.push(annotation);
|
||||||
|
await ecrireAnnotations(docCle, liste);
|
||||||
|
return { annotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour une annotation (texte/type/statut).
|
||||||
|
* Autorisé si l'appelant est l'auteur OU administrateur.
|
||||||
|
*/
|
||||||
|
export async function majAnnotation(docCle, id, appelant, estAdminAppelant, maj) {
|
||||||
|
const liste = await lireAnnotations(docCle);
|
||||||
|
const idx = liste.findIndex((a) => a.id === id);
|
||||||
|
if (idx === -1) {
|
||||||
|
return { erreur: 'introuvable' };
|
||||||
|
}
|
||||||
|
const annotation = liste[idx];
|
||||||
|
if (annotation.auteur !== appelant && !estAdminAppelant) {
|
||||||
|
return { erreur: 'interdit' };
|
||||||
|
}
|
||||||
|
if (typeof maj.texte === 'string') {
|
||||||
|
const t = maj.texte.trim();
|
||||||
|
if (!t) return { erreur: 'Le texte ne peut pas être vide.' };
|
||||||
|
if (t.length > 5000) return { erreur: 'Texte trop long (5000 caractères max).' };
|
||||||
|
annotation.texte = t;
|
||||||
|
}
|
||||||
|
if (TYPES.has(maj.type)) {
|
||||||
|
annotation.type = maj.type;
|
||||||
|
}
|
||||||
|
if (STATUTS.has(maj.statut)) {
|
||||||
|
annotation.statut = maj.statut;
|
||||||
|
}
|
||||||
|
annotation.modifieLe = new Date().toISOString();
|
||||||
|
liste[idx] = annotation;
|
||||||
|
await ecrireAnnotations(docCle, liste);
|
||||||
|
return { annotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime une annotation (auteur ou admin). */
|
||||||
|
export async function supprimerAnnotation(docCle, id, appelant, estAdminAppelant) {
|
||||||
|
const liste = await lireAnnotations(docCle);
|
||||||
|
const idx = liste.findIndex((a) => a.id === id);
|
||||||
|
if (idx === -1) {
|
||||||
|
return { erreur: 'introuvable' };
|
||||||
|
}
|
||||||
|
if (liste[idx].auteur !== appelant && !estAdminAppelant) {
|
||||||
|
return { erreur: 'interdit' };
|
||||||
|
}
|
||||||
|
liste.splice(idx, 1);
|
||||||
|
await ecrireAnnotations(docCle, liste);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste TOUTES les annotations (tous documents), triées par document puis date. */
|
||||||
|
export async function listerToutes() {
|
||||||
|
await assurerDossier();
|
||||||
|
let fichiers = [];
|
||||||
|
try {
|
||||||
|
fichiers = await fs.readdir(ANNOTATIONS_DIR);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const toutes = [];
|
||||||
|
for (const nom of fichiers.filter((f) => /\.json$/i.test(f))) {
|
||||||
|
const docCle = nom.replace(/\.json$/i, '');
|
||||||
|
const liste = await lireAnnotations(docCle);
|
||||||
|
toutes.push(...liste);
|
||||||
|
}
|
||||||
|
toutes.sort((a, b) => {
|
||||||
|
if (a.docCle !== b.docCle) return a.docCle.localeCompare(b.docCle, 'fr', { numeric: true });
|
||||||
|
return a.creeLe.localeCompare(b.creeLe);
|
||||||
|
});
|
||||||
|
return toutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construit un markdown de synthèse regroupé par document puis par section. */
|
||||||
|
export function construireExportMarkdown(toutes) {
|
||||||
|
const lignes = [];
|
||||||
|
lignes.push('# Annotations des specs SatPRO — synthèse');
|
||||||
|
lignes.push('');
|
||||||
|
lignes.push(`> Export généré le ${new Date().toISOString()}.`);
|
||||||
|
lignes.push(`> ${toutes.length} annotation(s). À transmettre à Kiro pour mise à jour des .md.`);
|
||||||
|
lignes.push('');
|
||||||
|
|
||||||
|
const parDoc = new Map();
|
||||||
|
for (const a of toutes) {
|
||||||
|
if (!parDoc.has(a.docCle)) parDoc.set(a.docCle, []);
|
||||||
|
parDoc.get(a.docCle).push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = { ajout: '➕', correction: '✏️', question: '❓', remarque: '💬' };
|
||||||
|
|
||||||
|
for (const [docCle, liste] of parDoc) {
|
||||||
|
lignes.push(`## ${docCle}`);
|
||||||
|
lignes.push('');
|
||||||
|
// Regroupe par section (titreSection), les annotations "document entier" en tête.
|
||||||
|
const parSection = new Map();
|
||||||
|
for (const a of liste) {
|
||||||
|
const cle = a.titreSection || '(document entier)';
|
||||||
|
if (!parSection.has(cle)) parSection.set(cle, []);
|
||||||
|
parSection.get(cle).push(a);
|
||||||
|
}
|
||||||
|
for (const [section, annos] of parSection) {
|
||||||
|
lignes.push(`### ${section}`);
|
||||||
|
lignes.push('');
|
||||||
|
for (const a of annos) {
|
||||||
|
const type = emoji[a.type] || '💬';
|
||||||
|
const etat = a.statut === 'traitee' ? ' _(traitée)_' : '';
|
||||||
|
lignes.push(`- ${type} **${a.type}** — _${a.auteur}_, ${a.creeLe}${etat}`);
|
||||||
|
for (const l of a.texte.split(/\r?\n/)) {
|
||||||
|
lignes.push(` > ${l}`);
|
||||||
|
}
|
||||||
|
lignes.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lignes.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Authentification : vérification des identifiants et émission/validation des JWT.
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
import {
|
||||||
|
JWT_SECRET,
|
||||||
|
JWT_EXPIRATION_MINUTES,
|
||||||
|
ADMIN_USER,
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
estAdmin
|
||||||
|
} from './config.js';
|
||||||
|
import { verifierConnexion, trouverUtilisateur } from './users.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie les identifiants.
|
||||||
|
* Ordre : d'abord l'ADMIN du .env, puis les comptes gérés (store users.json).
|
||||||
|
*
|
||||||
|
* Retourne un objet décrivant le résultat :
|
||||||
|
* - { ok:true, login, role, admin, doitChangerMdp }
|
||||||
|
* - { ok:false, raison } avec raison ∈ 'identifiants' | 'desactive'
|
||||||
|
*/
|
||||||
|
export async function verifierIdentifiants(login, motDePasse) {
|
||||||
|
if (typeof login !== 'string' || typeof motDePasse !== 'string') {
|
||||||
|
return { ok: false, raison: 'identifiants' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Compte administrateur (défini dans le .env, hors store).
|
||||||
|
if (ADMIN_USER && login === ADMIN_USER) {
|
||||||
|
if (motDePasse === ADMIN_PASSWORD) {
|
||||||
|
return { ok: true, login, role: 'admin', admin: true, doitChangerMdp: false };
|
||||||
|
}
|
||||||
|
return { ok: false, raison: 'identifiants' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Comptes gérés via le site.
|
||||||
|
const res = await verifierConnexion(login, motDePasse);
|
||||||
|
if (res.ok) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
login: res.utilisateur.login,
|
||||||
|
role: res.utilisateur.role,
|
||||||
|
admin: false,
|
||||||
|
doitChangerMdp: res.utilisateur.doitChangerMdp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (res.raison === 'desactive') {
|
||||||
|
return { ok: false, raison: 'desactive' };
|
||||||
|
}
|
||||||
|
return { ok: false, raison: 'identifiants' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Émet un JWT signé. Le token ne porte que le login (`sub`) : le rôle est relu à
|
||||||
|
* chaque requête depuis le store, pour que tout changement de rôle ou désactivation
|
||||||
|
* décidé par l'admin prenne effet immédiatement, sans attendre une reconnexion.
|
||||||
|
*/
|
||||||
|
export function emettreJeton(login) {
|
||||||
|
return jwt.sign({ sub: login }, JWT_SECRET, {
|
||||||
|
expiresIn: `${JWT_EXPIRATION_MINUTES}m`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware Express : exige un JWT valide (Authorization: Bearer <token>).
|
||||||
|
* Renseigne req.utilisateur = { login, role, admin }. Le rôle est relu en direct :
|
||||||
|
* - admin du .env -> role 'admin' ;
|
||||||
|
* - sinon rôle courant du store ; un compte supprimé ou désactivé est refusé (401/403).
|
||||||
|
*/
|
||||||
|
export async function exigerAuth(req, res, next) {
|
||||||
|
const entete = req.headers.authorization || '';
|
||||||
|
const [schema, jeton] = entete.split(' ');
|
||||||
|
if (schema !== 'Bearer' || !jeton) {
|
||||||
|
return res.status(401).json({ erreur: 'Authentification requise.' });
|
||||||
|
}
|
||||||
|
let charge;
|
||||||
|
try {
|
||||||
|
charge = jwt.verify(jeton, JWT_SECRET);
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ erreur: 'Session invalide ou expirée.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = charge.sub;
|
||||||
|
if (estAdmin(login)) {
|
||||||
|
req.utilisateur = { login, role: 'admin', admin: true };
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rôle relu depuis le store à chaque requête.
|
||||||
|
const u = await trouverUtilisateur(login);
|
||||||
|
if (!u) {
|
||||||
|
return res.status(401).json({ erreur: 'Compte introuvable.' });
|
||||||
|
}
|
||||||
|
if (u.role === 'desactive') {
|
||||||
|
return res.status(403).json({ erreur: 'Ce compte est désactivé.' });
|
||||||
|
}
|
||||||
|
req.utilisateur = { login, role: u.role, admin: false };
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Middleware : réservé à l'administrateur. */
|
||||||
|
export function exigerAdmin(req, res, next) {
|
||||||
|
if (!req.utilisateur?.admin) {
|
||||||
|
return res.status(403).json({ erreur: 'Réservé à l’administrateur.' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware : exige un rôle autorisé à contribuer (annoter).
|
||||||
|
* Admin et contributeur passent ; consultation est refusé (403).
|
||||||
|
*/
|
||||||
|
export function exigerContributeur(req, res, next) {
|
||||||
|
const role = req.utilisateur?.role;
|
||||||
|
if (role === 'admin' || role === 'contributeur') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({ erreur: 'Votre accès est en lecture seule.' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Configuration du backend, lue depuis les variables d'environnement.
|
||||||
|
// Aucune valeur sensible n'est codée en dur : tout vient de l'environnement (.env via docker-compose).
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répertoire contenant les fichiers .md à présenter.
|
||||||
|
* Par défaut, en conteneur, les specs sont montées en lecture seule sur /specs.
|
||||||
|
* En dev local, on retombe sur le dossier docs/specs-pre-projet du dépôt.
|
||||||
|
*/
|
||||||
|
export const SPECS_DIR =
|
||||||
|
process.env.SPECS_DIR ||
|
||||||
|
path.resolve(__dirname, '../../../docs/specs-pre-projet');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répertoire d'écriture des annotations (un fichier JSON par document).
|
||||||
|
* En conteneur, monté en lecture-écriture sur /annotations (volume dédié).
|
||||||
|
* En dev local, retombe sur site/annotations.
|
||||||
|
*/
|
||||||
|
export const ANNOTATIONS_DIR =
|
||||||
|
process.env.ANNOTATIONS_DIR ||
|
||||||
|
path.resolve(__dirname, '../../annotations');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répertoire d'écriture des comptes utilisateurs gérés via le site (users.json).
|
||||||
|
* En conteneur, monté en lecture-écriture sur /users (volume dédié).
|
||||||
|
* En dev local, retombe sur site/users.
|
||||||
|
*/
|
||||||
|
export const USERS_DIR =
|
||||||
|
process.env.USERS_DIR ||
|
||||||
|
path.resolve(__dirname, '../../users');
|
||||||
|
|
||||||
|
/** Port d'écoute HTTP du backend. */
|
||||||
|
export const PORT = Number(process.env.PORT || 8080);
|
||||||
|
|
||||||
|
/** Secret de signature des JWT. OBLIGATOIRE : le serveur refuse de démarrer sans. */
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET || '';
|
||||||
|
|
||||||
|
/** Durée de validité d'un jeton de session, en minutes. */
|
||||||
|
export const JWT_EXPIRATION_MINUTES = Number(process.env.JWT_EXPIRATION_MINUTES || 720);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte ADMINISTRATEUR unique, défini dans le .env (hors du store géré par le site).
|
||||||
|
* C'est le seul compte qui ne vit pas dans users.json. Il gère les accès et les exports.
|
||||||
|
* ADMIN_USER=madjax
|
||||||
|
* ADMIN_PASSWORD=Change-moi
|
||||||
|
*/
|
||||||
|
export const ADMIN_USER = (process.env.ADMIN_USER || '').trim();
|
||||||
|
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||||
|
|
||||||
|
/** Vrai si le login correspond à l'administrateur du .env. */
|
||||||
|
export function estAdmin(login) {
|
||||||
|
return !!ADMIN_USER && login === ADMIN_USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation de la configuration au démarrage : échec rapide si incohérent. */
|
||||||
|
export function validerConfig() {
|
||||||
|
const erreurs = [];
|
||||||
|
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||||
|
erreurs.push('JWT_SECRET est requis et doit faire au moins 32 caractères.');
|
||||||
|
}
|
||||||
|
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||||||
|
erreurs.push('ADMIN_USER et ADMIN_PASSWORD sont requis (compte administrateur).');
|
||||||
|
}
|
||||||
|
return erreurs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// Point d'entrée du backend : API Express.
|
||||||
|
// GET /health -> sonde de santé (publique)
|
||||||
|
// POST /api/auth/login -> { login, motDePasse } -> { token, login, role, admin, doitChangerMdp }
|
||||||
|
// POST /api/auth/change-password -> { ancien, nouveau } (protégé)
|
||||||
|
// GET /api/specs[/:cle] -> documents markdown (protégé)
|
||||||
|
// GET/POST/PUT/DELETE /api/annotations -> annotations (contrib. pour l'écriture)
|
||||||
|
// GET/POST/PUT/DELETE /api/users -> gestion des comptes (admin)
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
|
import { PORT, SPECS_DIR, validerConfig } from './config.js';
|
||||||
|
import {
|
||||||
|
verifierIdentifiants,
|
||||||
|
emettreJeton,
|
||||||
|
exigerAuth,
|
||||||
|
exigerAdmin,
|
||||||
|
exigerContributeur
|
||||||
|
} from './auth.js';
|
||||||
|
import { listerDocuments, obtenirDocument } from './specs.js';
|
||||||
|
import {
|
||||||
|
lireAnnotations,
|
||||||
|
creerAnnotation,
|
||||||
|
majAnnotation,
|
||||||
|
supprimerAnnotation,
|
||||||
|
listerToutes,
|
||||||
|
construireExportMarkdown
|
||||||
|
} from './annotations.js';
|
||||||
|
import {
|
||||||
|
listerUtilisateurs,
|
||||||
|
creerUtilisateur,
|
||||||
|
definirRole,
|
||||||
|
reinitialiserMotDePasse,
|
||||||
|
changerMotDePasse,
|
||||||
|
supprimerUtilisateur,
|
||||||
|
amorcerSiVide
|
||||||
|
} from './users.js';
|
||||||
|
|
||||||
|
// --- Validation de configuration : échec rapide au démarrage ---
|
||||||
|
const erreursConfig = validerConfig();
|
||||||
|
if (erreursConfig.length > 0) {
|
||||||
|
for (const e of erreursConfig) {
|
||||||
|
console.error(`[config] ${e}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Amorçage des comptes initiaux (une seule fois, si le store est vide) ---
|
||||||
|
// Les 3 comptes repris de l'ancien .env, en contributeur, avec changement de mot de
|
||||||
|
// passe imposé à la première connexion. Le seed est modifiable via SEED_USERS
|
||||||
|
// ("login:mdp:role,...") mais possède une valeur par défaut.
|
||||||
|
function parserSeed() {
|
||||||
|
const brut = (process.env.SEED_USERS || '').trim();
|
||||||
|
if (!brut) {
|
||||||
|
return [
|
||||||
|
{ login: 'nikolaz.stephan', motDePasse: 'A251365', role: 'contributeur' },
|
||||||
|
{ login: 'philippe.courivaud', motDePasse: 'A252750', role: 'contributeur' },
|
||||||
|
{ login: 'bruno.peyraud', motDePasse: 'A251992', role: 'contributeur' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return brut.split(',').map((t) => {
|
||||||
|
const [login, motDePasse, role] = t.split(':');
|
||||||
|
return { login: (login || '').trim(), motDePasse, role: (role || 'contributeur').trim() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await amorcerSiVide(parserSeed()).then((r) => {
|
||||||
|
if (r.amorce) {
|
||||||
|
console.log(`[satpro-specs] ${r.nombre} compte(s) initial(aux) amorcé(s).`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '256kb' }));
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Empêche l'indexation côté API également.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Santé (publique) ---
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ statut: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Authentification ---
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
const { login, motDePasse } = req.body || {};
|
||||||
|
const r = await verifierIdentifiants(login, motDePasse);
|
||||||
|
if (!r.ok) {
|
||||||
|
if (r.raison === 'desactive') {
|
||||||
|
return res.status(403).json({ erreur: 'Ce compte est désactivé.' });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ erreur: 'Identifiants invalides.' });
|
||||||
|
}
|
||||||
|
const token = emettreJeton(r.login);
|
||||||
|
return res.json({
|
||||||
|
token,
|
||||||
|
login: r.login,
|
||||||
|
role: r.role,
|
||||||
|
admin: r.admin,
|
||||||
|
doitChangerMdp: r.doitChangerMdp
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Changement de mot de passe par l'utilisateur lui-même (comptes gérés).
|
||||||
|
app.post('/api/auth/change-password', exigerAuth, async (req, res) => {
|
||||||
|
if (req.utilisateur.admin) {
|
||||||
|
return res.status(400).json({ erreur: "Le compte administrateur se gère via le fichier .env." });
|
||||||
|
}
|
||||||
|
const { ancien, nouveau } = req.body || {};
|
||||||
|
const r = await changerMotDePasse(req.utilisateur.login, ancien, nouveau);
|
||||||
|
if (r.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Compte introuvable.' });
|
||||||
|
}
|
||||||
|
if (r.erreur === 'mauvais_ancien') {
|
||||||
|
return res.status(400).json({ erreur: 'Ancien mot de passe incorrect.' });
|
||||||
|
}
|
||||||
|
if (r.erreur) {
|
||||||
|
return res.status(400).json({ erreur: r.erreur });
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Specs (protégées) ---
|
||||||
|
app.get('/api/specs', exigerAuth, async (req, res) => {
|
||||||
|
const docs = await listerDocuments();
|
||||||
|
res.json(docs.map(({ cle, titre }) => ({ cle, titre, disponible: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/specs/:cle', exigerAuth, async (req, res) => {
|
||||||
|
const doc = await obtenirDocument(req.params.cle);
|
||||||
|
if (!doc) {
|
||||||
|
return res.status(404).json({ erreur: 'Document introuvable.' });
|
||||||
|
}
|
||||||
|
res.json(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Gestion des comptes (admin uniquement) ---
|
||||||
|
app.get('/api/users', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
res.json(await listerUtilisateurs());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
const { login, motDePasse, role } = req.body || {};
|
||||||
|
const r = await creerUtilisateur({ login, motDePasse, role });
|
||||||
|
if (r.erreur) {
|
||||||
|
return res.status(400).json({ erreur: r.erreur });
|
||||||
|
}
|
||||||
|
res.status(201).json(r.utilisateur);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/users/:login/role', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
const r = await definirRole(req.params.login, (req.body || {}).role);
|
||||||
|
if (r.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Compte introuvable.' });
|
||||||
|
}
|
||||||
|
if (r.erreur) {
|
||||||
|
return res.status(400).json({ erreur: r.erreur });
|
||||||
|
}
|
||||||
|
res.json(r.utilisateur);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/:login/reset-password', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
const r = await reinitialiserMotDePasse(req.params.login, (req.body || {}).motDePasse);
|
||||||
|
if (r.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Compte introuvable.' });
|
||||||
|
}
|
||||||
|
if (r.erreur) {
|
||||||
|
return res.status(400).json({ erreur: r.erreur });
|
||||||
|
}
|
||||||
|
res.json(r.utilisateur);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:login', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
const r = await supprimerUtilisateur(req.params.login);
|
||||||
|
if (r.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Compte introuvable.' });
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Annotations ---
|
||||||
|
|
||||||
|
// Export global (admin). Placé AVANT /api/annotations/:cle.
|
||||||
|
app.get('/api/annotations/export.md', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
const toutes = await listerToutes();
|
||||||
|
const md = construireExportMarkdown(toutes);
|
||||||
|
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="annotations-satpro.md"');
|
||||||
|
res.send(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/annotations/export.json', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
const toutes = await listerToutes();
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="annotations-satpro.json"');
|
||||||
|
res.json(toutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liste de toutes les annotations (admin).
|
||||||
|
app.get('/api/annotations', exigerAuth, exigerAdmin, async (req, res) => {
|
||||||
|
res.json(await listerToutes());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Annotations d'un document (tout utilisateur authentifié, y compris consultation en lecture).
|
||||||
|
app.get('/api/annotations/:cle', exigerAuth, async (req, res) => {
|
||||||
|
const liste = await lireAnnotations(req.params.cle);
|
||||||
|
res.json(liste);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Création — réservé aux contributeurs (et admin).
|
||||||
|
app.post('/api/annotations/:cle', exigerAuth, exigerContributeur, async (req, res) => {
|
||||||
|
const resultat = await creerAnnotation(req.params.cle, req.utilisateur.login, req.body || {});
|
||||||
|
if (resultat.erreur) {
|
||||||
|
return res.status(400).json({ erreur: resultat.erreur });
|
||||||
|
}
|
||||||
|
res.status(201).json(resultat.annotation);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mise à jour — contributeur/admin, et seulement auteur ou admin.
|
||||||
|
app.put('/api/annotations/:cle/:id', exigerAuth, exigerContributeur, async (req, res) => {
|
||||||
|
const resultat = await majAnnotation(
|
||||||
|
req.params.cle,
|
||||||
|
req.params.id,
|
||||||
|
req.utilisateur.login,
|
||||||
|
req.utilisateur.admin,
|
||||||
|
req.body || {}
|
||||||
|
);
|
||||||
|
if (resultat.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Annotation introuvable.' });
|
||||||
|
}
|
||||||
|
if (resultat.erreur === 'interdit') {
|
||||||
|
return res.status(403).json({ erreur: 'Vous ne pouvez modifier que vos annotations.' });
|
||||||
|
}
|
||||||
|
if (resultat.erreur) {
|
||||||
|
return res.status(400).json({ erreur: resultat.erreur });
|
||||||
|
}
|
||||||
|
res.json(resultat.annotation);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suppression — contributeur/admin, et seulement auteur ou admin.
|
||||||
|
app.delete('/api/annotations/:cle/:id', exigerAuth, exigerContributeur, async (req, res) => {
|
||||||
|
const resultat = await supprimerAnnotation(
|
||||||
|
req.params.cle,
|
||||||
|
req.params.id,
|
||||||
|
req.utilisateur.login,
|
||||||
|
req.utilisateur.admin
|
||||||
|
);
|
||||||
|
if (resultat.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Annotation introuvable.' });
|
||||||
|
}
|
||||||
|
if (resultat.erreur === 'interdit') {
|
||||||
|
return res.status(403).json({ erreur: 'Vous ne pouvez supprimer que vos annotations.' });
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[satpro-specs] backend à l'écoute sur :${PORT}`);
|
||||||
|
console.log(`[satpro-specs] specs lues depuis : ${SPECS_DIR}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Lecture des fichiers de specs Markdown depuis le disque, EN DIRECT.
|
||||||
|
// Aucune mise en cache du contenu : chaque requête relit le fichier, de sorte que
|
||||||
|
// les éditions faites dans l'IDE sont immédiatement visibles après rafraîchissement.
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SPECS_DIR } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clé d'un document = nom de fichier sans extension (ex. "05-initialisation-contrat").
|
||||||
|
* On n'autorise que ce format pour empêcher toute traversée de répertoire.
|
||||||
|
*/
|
||||||
|
const CLE_VALIDE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
||||||
|
|
||||||
|
/** Convertit un nom de fichier en clé (retire l'extension .md). */
|
||||||
|
function fichierVersCle(nomFichier) {
|
||||||
|
return nomFichier.replace(/\.md$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dérive un titre lisible :
|
||||||
|
* - priorité au premier titre de niveau 1 (# ...) du contenu ;
|
||||||
|
* - sinon, à partir du nom de fichier (préfixe numérique retiré, tirets -> espaces).
|
||||||
|
*/
|
||||||
|
function deriverTitre(cle, contenu) {
|
||||||
|
const ligneTitre = contenu
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((l) => /^#\s+/.test(l.trim()));
|
||||||
|
if (ligneTitre) {
|
||||||
|
return ligneTitre.trim().replace(/^#\s+/, '').trim();
|
||||||
|
}
|
||||||
|
// Repli : "05-initialisation-contrat" -> "Initialisation contrat"
|
||||||
|
const sansPrefixe = cle.replace(/^\d+[-_]?/, '');
|
||||||
|
const mots = sansPrefixe.replace(/[-_]+/g, ' ').trim();
|
||||||
|
return mots.charAt(0).toUpperCase() + mots.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résout de façon sûre le chemin d'un fichier .md à partir d'une clé. */
|
||||||
|
function cheminSur(cle) {
|
||||||
|
if (!CLE_VALIDE.test(cle)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const chemin = path.resolve(SPECS_DIR, `${cle}.md`);
|
||||||
|
// Garde-fou anti-traversée : le chemin résolu doit rester sous SPECS_DIR.
|
||||||
|
const racine = path.resolve(SPECS_DIR) + path.sep;
|
||||||
|
if (!chemin.startsWith(racine)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return chemin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les documents disponibles, triés par nom de fichier (préfixes 00, 01, …).
|
||||||
|
* Retourne [{ cle, titre, ordre }].
|
||||||
|
*/
|
||||||
|
export async function listerDocuments() {
|
||||||
|
let entrees;
|
||||||
|
try {
|
||||||
|
entrees = await fs.readdir(SPECS_DIR, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fichiersMd = entrees
|
||||||
|
.filter((e) => e.isFile() && /\.md$/i.test(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'fr', { numeric: true }));
|
||||||
|
|
||||||
|
const docs = [];
|
||||||
|
for (const nom of fichiersMd) {
|
||||||
|
const cle = fichierVersCle(nom);
|
||||||
|
if (!CLE_VALIDE.test(cle)) continue;
|
||||||
|
let contenu = '';
|
||||||
|
try {
|
||||||
|
contenu = await fs.readFile(path.join(SPECS_DIR, nom), 'utf8');
|
||||||
|
} catch {
|
||||||
|
// ignore le fichier illisible
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
docs.push({ cle, titre: deriverTitre(cle, contenu), fichier: nom });
|
||||||
|
}
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le contenu Markdown d'un document par sa clé, ou null si introuvable.
|
||||||
|
* { cle, titre, markdown }
|
||||||
|
*/
|
||||||
|
export async function obtenirDocument(cle) {
|
||||||
|
const chemin = cheminSur(cle);
|
||||||
|
if (!chemin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let markdown;
|
||||||
|
try {
|
||||||
|
markdown = await fs.readFile(chemin, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { cle, titre: deriverTitre(cle, markdown), markdown };
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Gestion des comptes utilisateurs gérés via le site (hors admin, défini dans .env).
|
||||||
|
// Stockage : un unique fichier JSON { "users": [ ... ] } dans USERS_DIR/users.json.
|
||||||
|
//
|
||||||
|
// Un utilisateur :
|
||||||
|
// {
|
||||||
|
// login: "nikolaz.stephan",
|
||||||
|
// motDePasseHash: "$2a$10$...", // bcrypt, jamais en clair
|
||||||
|
// role: "contributeur", // consultation | contributeur | desactive
|
||||||
|
// doitChangerMdp: true, // force le changement à la prochaine connexion
|
||||||
|
// creeLe, modifieLe
|
||||||
|
// }
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
import { USERS_DIR } from './config.js';
|
||||||
|
|
||||||
|
export const ROLES = ['consultation', 'contributeur', 'desactive'];
|
||||||
|
const LOGIN_VALIDE = /^[a-zA-Z0-9._-]{2,64}$/;
|
||||||
|
const FICHIER = () => path.join(USERS_DIR, 'users.json');
|
||||||
|
|
||||||
|
async function assurerDossier() {
|
||||||
|
await fs.mkdir(USERS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lit la liste brute des utilisateurs (avec hash). Usage interne. */
|
||||||
|
async function lireTous() {
|
||||||
|
try {
|
||||||
|
const brut = await fs.readFile(FICHIER(), 'utf8');
|
||||||
|
const data = JSON.parse(brut);
|
||||||
|
return Array.isArray(data.users) ? data.users : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écrit (atomiquement) la liste des utilisateurs. */
|
||||||
|
async function ecrireTous(users) {
|
||||||
|
await assurerDossier();
|
||||||
|
const contenu = JSON.stringify({ users }, null, 2);
|
||||||
|
const tmp = `${FICHIER()}.tmp-${process.pid}`;
|
||||||
|
await fs.writeFile(tmp, contenu, 'utf8');
|
||||||
|
await fs.rename(tmp, FICHIER());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projection publique (sans le hash de mot de passe). */
|
||||||
|
function versPublic(u) {
|
||||||
|
return {
|
||||||
|
login: u.login,
|
||||||
|
role: u.role,
|
||||||
|
doitChangerMdp: !!u.doitChangerMdp,
|
||||||
|
creeLe: u.creeLe,
|
||||||
|
modifieLe: u.modifieLe
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste publique de tous les comptes (sans hash). */
|
||||||
|
export async function listerUtilisateurs() {
|
||||||
|
const users = await lireTous();
|
||||||
|
return users
|
||||||
|
.sort((a, b) => a.login.localeCompare(b.login, 'fr'))
|
||||||
|
.map(versPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupère un utilisateur par login (objet brut avec hash) ou null. */
|
||||||
|
export async function trouverUtilisateur(login) {
|
||||||
|
const users = await lireTous();
|
||||||
|
return users.find((u) => u.login === login) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie les identifiants d'un compte géré.
|
||||||
|
* Retourne { ok, raison, utilisateur } :
|
||||||
|
* - raison = 'inconnu' | 'desactive' | 'mauvais_mdp'
|
||||||
|
* - utilisateur = projection publique si ok.
|
||||||
|
*/
|
||||||
|
export async function verifierConnexion(login, motDePasse) {
|
||||||
|
const u = await trouverUtilisateur(login);
|
||||||
|
if (!u) {
|
||||||
|
return { ok: false, raison: 'inconnu' };
|
||||||
|
}
|
||||||
|
if (u.role === 'desactive') {
|
||||||
|
return { ok: false, raison: 'desactive' };
|
||||||
|
}
|
||||||
|
const correspond = bcrypt.compareSync(motDePasse, u.motDePasseHash);
|
||||||
|
if (!correspond) {
|
||||||
|
return { ok: false, raison: 'mauvais_mdp' };
|
||||||
|
}
|
||||||
|
return { ok: true, utilisateur: versPublic(u) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée un compte. role par défaut 'contributeur', doitChangerMdp=true. */
|
||||||
|
export async function creerUtilisateur({ login, motDePasse, role }) {
|
||||||
|
const loginNet = (login || '').trim();
|
||||||
|
if (!LOGIN_VALIDE.test(loginNet)) {
|
||||||
|
return { erreur: 'Login invalide (2 à 64 caractères : lettres, chiffres, . _ -).' };
|
||||||
|
}
|
||||||
|
if (!motDePasse || String(motDePasse).length < 4) {
|
||||||
|
return { erreur: 'Mot de passe par défaut requis (4 caractères minimum).' };
|
||||||
|
}
|
||||||
|
const roleNet = ROLES.includes(role) ? role : 'contributeur';
|
||||||
|
const users = await lireTous();
|
||||||
|
if (users.some((u) => u.login === loginNet)) {
|
||||||
|
return { erreur: 'Ce login existe déjà.' };
|
||||||
|
}
|
||||||
|
const maintenant = new Date().toISOString();
|
||||||
|
const u = {
|
||||||
|
login: loginNet,
|
||||||
|
motDePasseHash: bcrypt.hashSync(String(motDePasse), 10),
|
||||||
|
role: roleNet,
|
||||||
|
doitChangerMdp: true,
|
||||||
|
creeLe: maintenant,
|
||||||
|
modifieLe: maintenant
|
||||||
|
};
|
||||||
|
users.push(u);
|
||||||
|
await ecrireTous(users);
|
||||||
|
return { utilisateur: versPublic(u) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met à jour le rôle d'un compte. */
|
||||||
|
export async function definirRole(login, role) {
|
||||||
|
if (!ROLES.includes(role)) {
|
||||||
|
return { erreur: 'Rôle invalide.' };
|
||||||
|
}
|
||||||
|
const users = await lireTous();
|
||||||
|
const u = users.find((x) => x.login === login);
|
||||||
|
if (!u) return { erreur: 'introuvable' };
|
||||||
|
u.role = role;
|
||||||
|
u.modifieLe = new Date().toISOString();
|
||||||
|
await ecrireTous(users);
|
||||||
|
return { utilisateur: versPublic(u) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le mot de passe d'un compte (admin). Impose le changement à la
|
||||||
|
* prochaine connexion.
|
||||||
|
*/
|
||||||
|
export async function reinitialiserMotDePasse(login, motDePasseParDefaut) {
|
||||||
|
if (!motDePasseParDefaut || String(motDePasseParDefaut).length < 4) {
|
||||||
|
return { erreur: 'Mot de passe par défaut requis (4 caractères minimum).' };
|
||||||
|
}
|
||||||
|
const users = await lireTous();
|
||||||
|
const u = users.find((x) => x.login === login);
|
||||||
|
if (!u) return { erreur: 'introuvable' };
|
||||||
|
u.motDePasseHash = bcrypt.hashSync(String(motDePasseParDefaut), 10);
|
||||||
|
u.doitChangerMdp = true;
|
||||||
|
u.modifieLe = new Date().toISOString();
|
||||||
|
await ecrireTous(users);
|
||||||
|
return { utilisateur: versPublic(u) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le mot de passe par l'utilisateur lui-même (après vérification de l'ancien).
|
||||||
|
* Retire le flag doitChangerMdp.
|
||||||
|
*/
|
||||||
|
export async function changerMotDePasse(login, ancien, nouveau) {
|
||||||
|
if (!nouveau || String(nouveau).length < 4) {
|
||||||
|
return { erreur: 'Le nouveau mot de passe doit faire au moins 4 caractères.' };
|
||||||
|
}
|
||||||
|
const users = await lireTous();
|
||||||
|
const u = users.find((x) => x.login === login);
|
||||||
|
if (!u) return { erreur: 'introuvable' };
|
||||||
|
if (!bcrypt.compareSync(String(ancien), u.motDePasseHash)) {
|
||||||
|
return { erreur: 'mauvais_ancien' };
|
||||||
|
}
|
||||||
|
if (String(nouveau) === String(ancien)) {
|
||||||
|
return { erreur: 'Le nouveau mot de passe doit être différent de l’ancien.' };
|
||||||
|
}
|
||||||
|
u.motDePasseHash = bcrypt.hashSync(String(nouveau), 10);
|
||||||
|
u.doitChangerMdp = false;
|
||||||
|
u.modifieLe = new Date().toISOString();
|
||||||
|
await ecrireTous(users);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime un compte. */
|
||||||
|
export async function supprimerUtilisateur(login) {
|
||||||
|
const users = await lireTous();
|
||||||
|
const idx = users.findIndex((u) => u.login === login);
|
||||||
|
if (idx === -1) return { erreur: 'introuvable' };
|
||||||
|
users.splice(idx, 1);
|
||||||
|
await ecrireTous(users);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amorce les comptes initiaux au premier démarrage si le store est vide.
|
||||||
|
* `seed` = [{ login, motDePasse, role }]. Ne fait rien si des comptes existent déjà.
|
||||||
|
*/
|
||||||
|
export async function amorcerSiVide(seed) {
|
||||||
|
const users = await lireTous();
|
||||||
|
if (users.length > 0 || !Array.isArray(seed) || seed.length === 0) {
|
||||||
|
return { amorce: false };
|
||||||
|
}
|
||||||
|
const maintenant = new Date().toISOString();
|
||||||
|
const nouveaux = seed
|
||||||
|
.filter((s) => LOGIN_VALIDE.test((s.login || '').trim()) && s.motDePasse)
|
||||||
|
.map((s) => ({
|
||||||
|
login: s.login.trim(),
|
||||||
|
motDePasseHash: bcrypt.hashSync(String(s.motDePasse), 10),
|
||||||
|
role: ROLES.includes(s.role) ? s.role : 'contributeur',
|
||||||
|
doitChangerMdp: true,
|
||||||
|
creeLe: maintenant,
|
||||||
|
modifieLe: maintenant
|
||||||
|
}));
|
||||||
|
if (nouveaux.length > 0) {
|
||||||
|
await ecrireTous(nouveaux);
|
||||||
|
}
|
||||||
|
return { amorce: nouveaux.length > 0, nombre: nouveaux.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
# Les specs sont lues depuis /specs (monté en lecture seule, voir volumes).
|
||||||
|
- SPECS_DIR=/specs
|
||||||
|
# Les annotations sont écrites dans /annotations (monté en lecture-écriture).
|
||||||
|
- ANNOTATIONS_DIR=/annotations
|
||||||
|
# Les comptes gérés via le site sont écrits dans /users (monté en lecture-écriture).
|
||||||
|
- USERS_DIR=/users
|
||||||
|
# --- Authentification ---
|
||||||
|
# :? fait échouer le démarrage si le secret est absent (jamais de clé vide).
|
||||||
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET requis (>= 32 caracteres)}
|
||||||
|
- JWT_EXPIRATION_MINUTES=${JWT_EXPIRATION_MINUTES:-720}
|
||||||
|
# Compte ADMINISTRATEUR unique (seul compte défini hors du site). Les autres
|
||||||
|
# comptes se gèrent dans l'application (page « Gestion des accès »).
|
||||||
|
- ADMIN_USER=${ADMIN_USER:?ADMIN_USER requis}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD requis}
|
||||||
|
# Amorçage facultatif des comptes initiaux au 1er démarrage (si le store est vide) :
|
||||||
|
# "login:mdp:role,...". Laissé vide = pas d'amorçage personnalisé (défaut interne).
|
||||||
|
- SEED_USERS=${SEED_USERS:-}
|
||||||
|
volumes:
|
||||||
|
# Specs montées EN LECTURE SEULE depuis le dépôt. Les éditions faites dans l'IDE
|
||||||
|
# sont donc visibles en direct par le site (après rafraîchissement du navigateur).
|
||||||
|
# Le ':ro' empêche toute écriture depuis le conteneur.
|
||||||
|
- ../docs/specs-pre-projet:/specs:ro
|
||||||
|
# Annotations EN LECTURE-ÉCRITURE : persistées sur l'hôte dans ./annotations
|
||||||
|
# (un fichier JSON par document). Facile à récupérer/exporter et à transmettre.
|
||||||
|
- ./annotations:/annotations
|
||||||
|
# Comptes gérés EN LECTURE-ÉCRITURE : persistés sur l'hôte dans ./users/users.json
|
||||||
|
# (mots de passe hachés bcrypt). Conservés entre deux ateliers.
|
||||||
|
- ./users:/users
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "2002:80" # hôte 2002 -> nginx (frontend). Caddy proxifiera vers ce port.
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ---- Étape build (Node) ----
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dépendances (couche cache indépendante du code source)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Code source + build production (utilise environment.prod.ts via fileReplacements)
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build -- --configuration production
|
||||||
|
|
||||||
|
# ---- Étape serve (nginx) ----
|
||||||
|
FROM nginx:1.27-alpine AS serve
|
||||||
|
|
||||||
|
# Configuration nginx : SPA Angular + reverse-proxy /api + en-têtes anti-indexation
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Le builder @angular-devkit/build-angular:application (Angular 22) émet les fichiers
|
||||||
|
# statiques dans dist/<projet>/browser.
|
||||||
|
COPY --from=build /app/dist/satpro-specs-frontend/browser /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"satpro-specs-frontend": {
|
||||||
|
"projectType": "application",
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/satpro-specs-frontend",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "3mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "8kb",
|
||||||
|
"maximumError": "16kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"namedChunks": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "satpro-specs-frontend:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "satpro-specs-frontend:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Anti-indexation : en-tête ajouté à TOUTES les réponses servies par nginx.
|
||||||
|
# Combiné à la balise <meta robots> et au robots.txt, et au login applicatif.
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive, nosnippet" always;
|
||||||
|
|
||||||
|
# robots.txt explicite (présent dans les assets, mais on garde la cohérence).
|
||||||
|
location = /robots.txt {
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA Angular : toute route inconnue retombe sur index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse-proxy des appels API vers le backend Node (réseau interne Compose)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sonde de santé exposée via nginx -> backend (utilisée par Caddy et le compose).
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://backend:8080/health;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+13231
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "satpro-specs-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^22.1.6",
|
||||||
|
"@angular/compiler": "^22.1.6",
|
||||||
|
"@angular/core": "^22.1.6",
|
||||||
|
"@angular/forms": "^22.1.6",
|
||||||
|
"@angular/platform-browser": "^22.1.6",
|
||||||
|
"@angular/router": "^22.1.6",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
|
"marked": "^18.0.13",
|
||||||
|
"mermaid": "^12.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^22.1.8",
|
||||||
|
"@angular/cli": "^22.1.8",
|
||||||
|
"@angular/compiler-cli": "^22.1.6",
|
||||||
|
"typescript": "~6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@if (enteteVisible()) {
|
||||||
|
<header class="entete">
|
||||||
|
<a class="entete__marque" routerLink="/specs/00-sommaire" title="Retour aux specifications">
|
||||||
|
<span class="entete__logo" aria-hidden="true">▶</span>
|
||||||
|
<span class="entete__titre">SatPRO — Specs pré-projet</span>
|
||||||
|
</a>
|
||||||
|
<div class="entete__compte">
|
||||||
|
@if (estAdmin()) {
|
||||||
|
<a class="entete__lien" routerLink="/acces" title="Gérer les comptes et accès">Gestion des accès</a>
|
||||||
|
<div class="entete__export" title="Exporter TOUTES les annotations (tous les documents)">
|
||||||
|
<span class="entete__export-label">Export annotations :</span>
|
||||||
|
<button type="button" (click)="exporter('md')" [disabled]="exportEnCours()">Markdown</button>
|
||||||
|
<button type="button" (click)="exporter('json')" [disabled]="exportEnCours()">JSON</button>
|
||||||
|
</div>
|
||||||
|
<span class="entete__role" title="Vous êtes administrateur">admin</span>
|
||||||
|
}
|
||||||
|
<span class="entete__user">{{ login() }}</span>
|
||||||
|
<button type="button" class="entete__deconnexion" (click)="seDeconnecter()">
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (exportMessage()) {
|
||||||
|
<span class="entete__export-msg" role="alert">{{ exportMessage() }}</span>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
|
||||||
|
<main class="contenu">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--dekra-vert);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__marque {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__marque:hover .entete__titre {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__logo {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__titre {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__compte {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__user {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__lien {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__lien:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__export {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__export-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__export button {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__export button:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__export button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__role {
|
||||||
|
background: var(--ds-yellow-200);
|
||||||
|
color: var(--ds-yellow-700);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__export-msg {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
right: 1rem;
|
||||||
|
background: var(--ds-danger-500);
|
||||||
|
color: var(--ds-white);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__deconnexion {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete__deconnexion:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contenu {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
|
import { NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from './core/services/auth.service';
|
||||||
|
import { AnnotationService } from './core/services/annotation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly annotations = inject(AnnotationService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly login = this.auth.login;
|
||||||
|
readonly estConnecte = this.auth.estConnecte;
|
||||||
|
readonly estAdmin = this.auth.estAdmin;
|
||||||
|
readonly exportEnCours = signal(false);
|
||||||
|
readonly exportMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
/** URL courante, suivie via les événements du routeur (pour masquer l'en-tête sur /login). */
|
||||||
|
private readonly urlCourante = signal<string>(this.router.url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'en-tête est affichée uniquement si l'utilisateur est connecté ET hors page de login.
|
||||||
|
* Un jeton résiduel sur /login ne doit pas faire réapparaître la barre.
|
||||||
|
*/
|
||||||
|
readonly enteteVisible = () =>
|
||||||
|
this.estConnecte() &&
|
||||||
|
!this.urlCourante().startsWith('/login') &&
|
||||||
|
!this.urlCourante().startsWith('/changer-mot-de-passe');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
||||||
|
.subscribe((e) => this.urlCourante.set(e.urlAfterRedirects));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export global de toutes les annotations (admin), depuis l'en-tête. */
|
||||||
|
async exporter(format: 'md' | 'json'): Promise<void> {
|
||||||
|
this.exportEnCours.set(true);
|
||||||
|
this.exportMessage.set(null);
|
||||||
|
const ok = await this.annotations.telechargerExport(format, this.auth.obtenirToken());
|
||||||
|
this.exportEnCours.set(false);
|
||||||
|
if (!ok) {
|
||||||
|
this.exportMessage.set("Échec de l'export.");
|
||||||
|
setTimeout(() => this.exportMessage.set(null), 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seDeconnecter(): void {
|
||||||
|
this.auth.seDeconnecter();
|
||||||
|
void this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor]))
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { authGuard, adminGuard, sessionGuard } from './core/guards/auth.guard';
|
||||||
|
import { LoginComponent } from './features/auth/login.component';
|
||||||
|
import { ChangePasswordComponent } from './features/auth/change-password.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes de l'application.
|
||||||
|
* - `login` : publique.
|
||||||
|
* - `changer-mot-de-passe` : session requise (imposé à la 1re connexion / après reset).
|
||||||
|
* - `specs/:cle` : viewer markdown, protégé par authGuard (+ changement mdp éventuel).
|
||||||
|
* - `acces` : gestion des comptes, réservée à l'administrateur.
|
||||||
|
*/
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', redirectTo: 'specs/00-sommaire', pathMatch: 'full' },
|
||||||
|
|
||||||
|
{ path: 'login', component: LoginComponent },
|
||||||
|
{ path: 'changer-mot-de-passe', component: ChangePasswordComponent, canActivate: [sessionGuard] },
|
||||||
|
|
||||||
|
{ path: 'specs', redirectTo: 'specs/00-sommaire', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'specs/:cle',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/specs/specs.component').then((m) => m.SpecsComponent),
|
||||||
|
canActivate: [authGuard]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'acces',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/admin/users.component').then((m) => m.UsersComponent),
|
||||||
|
canActivate: [adminGuard]
|
||||||
|
},
|
||||||
|
|
||||||
|
{ path: '**', redirectTo: 'specs/00-sommaire' }
|
||||||
|
];
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de route : exige une session active ET que le mot de passe n'ait pas à être
|
||||||
|
* changé. Si un changement est imposé (première connexion / reset), redirige vers
|
||||||
|
* l'écran dédié. Sinon, renvoie vers /login.
|
||||||
|
*/
|
||||||
|
export const authGuard: CanActivateFn = (route, state) => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (!auth.estConnecte()) {
|
||||||
|
return router.createUrlTree(['/login'], { queryParams: { retour: state.url } });
|
||||||
|
}
|
||||||
|
if (auth.doitChangerMdp()) {
|
||||||
|
return router.createUrlTree(['/changer-mot-de-passe']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Garde réservée à l'administrateur. */
|
||||||
|
export const adminGuard: CanActivateFn = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (!auth.estConnecte()) {
|
||||||
|
return router.createUrlTree(['/login']);
|
||||||
|
}
|
||||||
|
if (!auth.estAdmin()) {
|
||||||
|
return router.createUrlTree(['/specs/00-sommaire']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Garde de l'écran de changement de mot de passe : nécessite seulement une session. */
|
||||||
|
export const sessionGuard: CanActivateFn = (route, state) => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
if (!auth.estConnecte()) {
|
||||||
|
return router.createUrlTree(['/login'], { queryParams: { retour: state.url } });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { catchError, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepteur HTTP :
|
||||||
|
* - ajoute l'en-tête Authorization: Bearer <token> aux appels /api (hors login) ;
|
||||||
|
* - sur réponse 401, purge la session et renvoie vers /login.
|
||||||
|
*/
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
const token = auth.obtenirToken();
|
||||||
|
|
||||||
|
const estLogin = req.url.includes('/auth/login');
|
||||||
|
const requete =
|
||||||
|
token && !estLogin
|
||||||
|
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||||
|
: req;
|
||||||
|
|
||||||
|
return next(requete).pipe(
|
||||||
|
catchError((err: HttpErrorResponse) => {
|
||||||
|
if (err.status === 401 && !estLogin) {
|
||||||
|
auth.seDeconnecter();
|
||||||
|
void router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export type TypeAnnotation = 'ajout' | 'correction' | 'question' | 'remarque';
|
||||||
|
export type StatutAnnotation = 'ouverte' | 'traitee';
|
||||||
|
|
||||||
|
export interface Annotation {
|
||||||
|
id: string;
|
||||||
|
docCle: string;
|
||||||
|
ancre: string;
|
||||||
|
titreSection: string;
|
||||||
|
type: TypeAnnotation;
|
||||||
|
texte: string;
|
||||||
|
auteur: string;
|
||||||
|
statut: StatutAnnotation;
|
||||||
|
creeLe: string;
|
||||||
|
modifieLe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NouvelleAnnotation {
|
||||||
|
type: TypeAnnotation;
|
||||||
|
texte: string;
|
||||||
|
ancre: string;
|
||||||
|
titreSection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MajAnnotation {
|
||||||
|
texte?: string;
|
||||||
|
type?: TypeAnnotation;
|
||||||
|
statut?: StatutAnnotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Service d'accès aux annotations (protégé par l'authentification). */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AnnotationService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.apiUrl}/annotations`;
|
||||||
|
|
||||||
|
/** Annotations d'un document. */
|
||||||
|
lister(docCle: string): Observable<Annotation[]> {
|
||||||
|
return this.http.get<Annotation[]>(`${this.baseUrl}/${docCle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée une annotation sur un document. */
|
||||||
|
creer(docCle: string, annotation: NouvelleAnnotation): Observable<Annotation> {
|
||||||
|
return this.http.post<Annotation>(`${this.baseUrl}/${docCle}`, annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met à jour une annotation (auteur ou admin). */
|
||||||
|
mettreAJour(docCle: string, id: string, maj: MajAnnotation): Observable<Annotation> {
|
||||||
|
return this.http.put<Annotation>(`${this.baseUrl}/${docCle}/${id}`, maj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime une annotation (auteur ou admin). */
|
||||||
|
supprimer(docCle: string, id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/${docCle}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL d'export markdown (admin). */
|
||||||
|
urlExportMarkdown(): string {
|
||||||
|
return `${this.baseUrl}/export.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL d'export JSON (admin). */
|
||||||
|
urlExportJson(): string {
|
||||||
|
return `${this.baseUrl}/export.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge l'export GLOBAL (toutes annotations, tous documents) via un fetch
|
||||||
|
* authentifié, puis déclenche l'enregistrement du fichier. Réservé aux admins
|
||||||
|
* côté serveur (403 sinon). Retourne false en cas d'échec.
|
||||||
|
*/
|
||||||
|
async telechargerExport(format: 'md' | 'json', token: string | null): Promise<boolean> {
|
||||||
|
const url = format === 'md' ? this.urlExportMarkdown() : this.urlExportJson();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const lien = document.createElement('a');
|
||||||
|
lien.href = URL.createObjectURL(blob);
|
||||||
|
lien.download = format === 'md' ? 'annotations-satpro.md' : 'annotations-satpro.json';
|
||||||
|
document.body.appendChild(lien);
|
||||||
|
lien.click();
|
||||||
|
lien.remove();
|
||||||
|
URL.revokeObjectURL(lien.href);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { Observable, tap } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export type Role = 'admin' | 'contributeur' | 'consultation';
|
||||||
|
|
||||||
|
interface ReponseLogin {
|
||||||
|
token: string;
|
||||||
|
login: string;
|
||||||
|
role: Role;
|
||||||
|
admin: boolean;
|
||||||
|
doitChangerMdp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLE_TOKEN = 'satpro.token';
|
||||||
|
const CLE_LOGIN = 'satpro.login';
|
||||||
|
const CLE_ADMIN = 'satpro.admin';
|
||||||
|
const CLE_ROLE = 'satpro.role';
|
||||||
|
const CLE_CHANGE = 'satpro.change';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'authentification : login, logout, changement de mot de passe,
|
||||||
|
* conservation du jeton JWT + rôle (localStorage). Le jeton est injecté dans les
|
||||||
|
* requêtes /api par l'intercepteur HTTP.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.apiUrl}/auth`;
|
||||||
|
|
||||||
|
private readonly _token = signal<string | null>(this.tokenInitial());
|
||||||
|
private readonly _login = signal<string | null>(localStorage.getItem(CLE_LOGIN));
|
||||||
|
private readonly _admin = signal<boolean>(localStorage.getItem(CLE_ADMIN) === '1');
|
||||||
|
private readonly _role = signal<Role>((localStorage.getItem(CLE_ROLE) as Role) || 'consultation');
|
||||||
|
private readonly _doitChangerMdp = signal<boolean>(localStorage.getItem(CLE_CHANGE) === '1');
|
||||||
|
|
||||||
|
/** Login de l'utilisateur courant (ou null). */
|
||||||
|
readonly login = this._login.asReadonly();
|
||||||
|
|
||||||
|
/** Vrai si l'utilisateur courant est administrateur. */
|
||||||
|
readonly estAdmin = this._admin.asReadonly();
|
||||||
|
|
||||||
|
/** Rôle courant. */
|
||||||
|
readonly role = this._role.asReadonly();
|
||||||
|
|
||||||
|
/** Vrai si l'utilisateur peut contribuer (annoter) : admin ou contributeur. */
|
||||||
|
readonly peutContribuer = computed(() => this._role() === 'admin' || this._role() === 'contributeur');
|
||||||
|
|
||||||
|
/** Vrai si un changement de mot de passe est requis avant d'accéder au contenu. */
|
||||||
|
readonly doitChangerMdp = this._doitChangerMdp.asReadonly();
|
||||||
|
|
||||||
|
/** Vrai si un jeton valide (non expiré) est présent. */
|
||||||
|
readonly estConnecte = computed(() => this._token() !== null);
|
||||||
|
|
||||||
|
private tokenInitial(): string | null {
|
||||||
|
const token = localStorage.getItem(CLE_TOKEN);
|
||||||
|
if (token && !AuthService.tokenExpire(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
this.purger();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private purger(): void {
|
||||||
|
for (const c of [CLE_TOKEN, CLE_LOGIN, CLE_ADMIN, CLE_ROLE, CLE_CHANGE]) {
|
||||||
|
localStorage.removeItem(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static tokenExpire(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
if (typeof payload.exp !== 'number') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() >= payload.exp * 1000;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jeton courant (utilisé par l'intercepteur). */
|
||||||
|
obtenirToken(): string | null {
|
||||||
|
return this._token();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authentifie l'utilisateur et mémorise le jeton + rôle. */
|
||||||
|
seConnecter(login: string, motDePasse: string): Observable<ReponseLogin> {
|
||||||
|
return this.http.post<ReponseLogin>(`${this.baseUrl}/login`, { login, motDePasse }).pipe(
|
||||||
|
tap((rep) => {
|
||||||
|
localStorage.setItem(CLE_TOKEN, rep.token);
|
||||||
|
localStorage.setItem(CLE_LOGIN, rep.login);
|
||||||
|
localStorage.setItem(CLE_ADMIN, rep.admin ? '1' : '0');
|
||||||
|
localStorage.setItem(CLE_ROLE, rep.role);
|
||||||
|
localStorage.setItem(CLE_CHANGE, rep.doitChangerMdp ? '1' : '0');
|
||||||
|
this._token.set(rep.token);
|
||||||
|
this._login.set(rep.login);
|
||||||
|
this._admin.set(!!rep.admin);
|
||||||
|
this._role.set(rep.role);
|
||||||
|
this._doitChangerMdp.set(!!rep.doitChangerMdp);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change le mot de passe du compte courant (comptes gérés). */
|
||||||
|
changerMotDePasse(ancien: string, nouveau: string): Observable<{ ok: boolean }> {
|
||||||
|
return this.http
|
||||||
|
.post<{ ok: boolean }>(`${this.baseUrl}/change-password`, { ancien, nouveau })
|
||||||
|
.pipe(
|
||||||
|
tap(() => {
|
||||||
|
this._doitChangerMdp.set(false);
|
||||||
|
localStorage.setItem(CLE_CHANGE, '0');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déconnecte (purge du jeton local). */
|
||||||
|
seDeconnecter(): void {
|
||||||
|
this.purger();
|
||||||
|
this._token.set(null);
|
||||||
|
this._login.set(null);
|
||||||
|
this._admin.set(false);
|
||||||
|
this._role.set('consultation');
|
||||||
|
this._doitChangerMdp.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export interface SpecDocument {
|
||||||
|
cle: string;
|
||||||
|
titre: string;
|
||||||
|
disponible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecContenu {
|
||||||
|
cle: string;
|
||||||
|
titre: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accès aux documents de spécification (lecture seule). Les endpoints sont protégés
|
||||||
|
* par l'authentification (l'intercepteur ajoute le jeton).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SpecService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.apiUrl}/specs`;
|
||||||
|
|
||||||
|
/** Liste des documents disponibles (GET /api/specs). */
|
||||||
|
lister(): Observable<SpecDocument[]> {
|
||||||
|
return this.http.get<SpecDocument[]>(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contenu Markdown d'un document (GET /api/specs/{cle}). */
|
||||||
|
obtenir(cle: string): Observable<SpecContenu> {
|
||||||
|
return this.http.get<SpecContenu>(`${this.baseUrl}/${cle}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Role } from './auth.service';
|
||||||
|
|
||||||
|
export type RoleGerable = 'consultation' | 'contributeur' | 'desactive';
|
||||||
|
|
||||||
|
export interface Utilisateur {
|
||||||
|
login: string;
|
||||||
|
role: RoleGerable;
|
||||||
|
doitChangerMdp: boolean;
|
||||||
|
creeLe: string;
|
||||||
|
modifieLe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gestion des comptes (réservée à l'administrateur côté backend). */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.apiUrl}/users`;
|
||||||
|
|
||||||
|
lister(): Observable<Utilisateur[]> {
|
||||||
|
return this.http.get<Utilisateur[]>(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
creer(login: string, motDePasse: string, role: RoleGerable): Observable<Utilisateur> {
|
||||||
|
return this.http.post<Utilisateur>(this.baseUrl, { login, motDePasse, role });
|
||||||
|
}
|
||||||
|
|
||||||
|
definirRole(login: string, role: RoleGerable): Observable<Utilisateur> {
|
||||||
|
return this.http.put<Utilisateur>(`${this.baseUrl}/${encodeURIComponent(login)}/role`, { role });
|
||||||
|
}
|
||||||
|
|
||||||
|
reinitialiserMotDePasse(login: string, motDePasse: string): Observable<Utilisateur> {
|
||||||
|
return this.http.post<Utilisateur>(
|
||||||
|
`${this.baseUrl}/${encodeURIComponent(login)}/reset-password`,
|
||||||
|
{ motDePasse }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
supprimer(login: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/${encodeURIComponent(login)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<section class="acces" aria-label="Gestion des accès">
|
||||||
|
<header class="acces__entete">
|
||||||
|
<h1 class="acces__titre">Gestion des accès</h1>
|
||||||
|
<button type="button" class="acces__btn" (click)="recharger()">⟳ Rafraîchir</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="acces__note">
|
||||||
|
L'administrateur se configure dans le fichier <code>.env</code> et n'apparaît pas ici.
|
||||||
|
Pour un usage entre ateliers, préférez <strong>désactiver / réactiver</strong> un accès
|
||||||
|
plutôt que le supprimer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (message()) {
|
||||||
|
<p class="acces__message" role="status">{{ message() }}</p>
|
||||||
|
}
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="acces__erreur" role="alert">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Création d'un compte -->
|
||||||
|
<form class="acces__form" (ngSubmit)="creer()">
|
||||||
|
<h2 class="acces__sous-titre">Ajouter un accès</h2>
|
||||||
|
<div class="acces__form-grille">
|
||||||
|
<label>
|
||||||
|
<span>Login</span>
|
||||||
|
<input type="text" name="login" [ngModel]="nLogin()" (ngModelChange)="nLogin.set($event)" placeholder="prenom.nom" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Mot de passe par défaut</span>
|
||||||
|
<input type="text" name="mdp" [ngModel]="nMdp()" (ngModelChange)="nMdp.set($event)" placeholder="à communiquer à l'utilisateur" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Rôle</span>
|
||||||
|
<select name="role" [ngModel]="nRole()" (ngModelChange)="nRole.set($event)">
|
||||||
|
<option value="contributeur">Contributeur (annotations)</option>
|
||||||
|
<option value="consultation">Consultation (lecture seule)</option>
|
||||||
|
<option value="desactive">Désactivé</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="acces__ajouter" [disabled]="creationEnCours()">
|
||||||
|
{{ creationEnCours() ? 'Création…' : 'Ajouter' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="acces__hint">Le compte créé devra changer son mot de passe à la première connexion.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Liste des comptes -->
|
||||||
|
@if (chargement()) {
|
||||||
|
<p class="acces__info">Chargement…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!chargement()) {
|
||||||
|
<table class="acces__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Rôle</th>
|
||||||
|
<th>1re connexion</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (u of comptes(); track u.login) {
|
||||||
|
<tr [class.acces__ligne--desactive]="u.role === 'desactive'">
|
||||||
|
<td class="acces__login">{{ u.login }}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
class="acces__role-select"
|
||||||
|
[ngModel]="u.role"
|
||||||
|
(ngModelChange)="changerRole(u, $event)"
|
||||||
|
[attr.aria-label]="'Rôle de ' + u.login"
|
||||||
|
>
|
||||||
|
<option value="contributeur">Contributeur</option>
|
||||||
|
<option value="consultation">Consultation</option>
|
||||||
|
<option value="desactive">Désactivé</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (u.doitChangerMdp) {
|
||||||
|
<span class="acces__badge acces__badge--attente" title="Changement de mot de passe requis à la prochaine connexion">à changer</span>
|
||||||
|
} @else {
|
||||||
|
<span class="acces__badge acces__badge--ok">OK</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="acces__actions">
|
||||||
|
<button type="button" (click)="reinitialiser(u)" title="Réinitialiser le mot de passe">Réinitialiser mdp</button>
|
||||||
|
<button type="button" class="acces__suppr" (click)="supprimer(u)" title="Supprimer le compte">Supprimer</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (comptes().length === 0) {
|
||||||
|
<tr><td colspan="4" class="acces__vide">Aucun compte géré pour l'instant.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
.acces {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 2rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__entete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__titre {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__btn {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__btn:hover {
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__note {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--ds-gray-600);
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
border-left: 3px solid var(--ds-dark-green-300);
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__message {
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__erreur {
|
||||||
|
color: var(--ds-danger-500);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__form {
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__sous-titre {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__form-grille {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.2fr 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__form-grille label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__form-grille input,
|
||||||
|
.acces__form-grille select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__ajouter {
|
||||||
|
background: var(--ds-dark-green-500);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__ajouter:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__hint {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ds-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__table th,
|
||||||
|
.acces__table td {
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__table th {
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__login {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__ligne--desactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__role-select {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__badge {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__badge--attente {
|
||||||
|
background: var(--ds-yellow-200);
|
||||||
|
color: var(--ds-yellow-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__badge--ok {
|
||||||
|
background: var(--ds-dark-green-100);
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__actions button {
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ds-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__actions button:hover {
|
||||||
|
background: var(--ds-gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__suppr {
|
||||||
|
color: var(--ds-danger-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acces__vide {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ds-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.acces__form-grille {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
UserService,
|
||||||
|
Utilisateur,
|
||||||
|
RoleGerable
|
||||||
|
} from '../../core/services/user.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page « Gestion des accès » (administrateur) : liste des comptes gérés, création,
|
||||||
|
* changement de rôle (consultation / contributeur / désactivé), réinitialisation de
|
||||||
|
* mot de passe et suppression.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-users',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './users.component.html',
|
||||||
|
styleUrl: './users.component.scss'
|
||||||
|
})
|
||||||
|
export class UsersComponent implements OnInit {
|
||||||
|
private readonly userService = inject(UserService);
|
||||||
|
|
||||||
|
readonly comptes = signal<Utilisateur[]>([]);
|
||||||
|
readonly chargement = signal(false);
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
readonly message = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Formulaire de création
|
||||||
|
readonly nLogin = signal('');
|
||||||
|
readonly nMdp = signal('');
|
||||||
|
readonly nRole = signal<RoleGerable>('contributeur');
|
||||||
|
readonly creationEnCours = signal(false);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.recharger();
|
||||||
|
}
|
||||||
|
|
||||||
|
recharger(): void {
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.erreur.set(null);
|
||||||
|
this.userService.lister().subscribe({
|
||||||
|
next: (c) => {
|
||||||
|
this.comptes.set(c);
|
||||||
|
this.chargement.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.erreur.set('Impossible de charger les comptes.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private flash(msg: string): void {
|
||||||
|
this.message.set(msg);
|
||||||
|
setTimeout(() => this.message.set(null), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
creer(): void {
|
||||||
|
this.erreur.set(null);
|
||||||
|
const login = this.nLogin().trim();
|
||||||
|
const mdp = this.nMdp();
|
||||||
|
if (!login || !mdp) {
|
||||||
|
this.erreur.set('Login et mot de passe par défaut requis.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.creationEnCours.set(true);
|
||||||
|
this.userService.creer(login, mdp, this.nRole()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.creationEnCours.set(false);
|
||||||
|
this.nLogin.set('');
|
||||||
|
this.nMdp.set('');
|
||||||
|
this.nRole.set('contributeur');
|
||||||
|
this.flash(`Compte « ${login} » créé.`);
|
||||||
|
this.recharger();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.creationEnCours.set(false);
|
||||||
|
this.erreur.set(e?.error?.erreur || 'Échec de la création.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
changerRole(u: Utilisateur, role: RoleGerable): void {
|
||||||
|
if (role === u.role) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.userService.definirRole(u.login, role).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.flash(`Rôle de « ${u.login} » : ${this.libelleRole(role)}.`);
|
||||||
|
this.recharger();
|
||||||
|
},
|
||||||
|
error: (e) => this.erreur.set(e?.error?.erreur || 'Échec du changement de rôle.')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reinitialiser(u: Utilisateur): void {
|
||||||
|
const mdp = prompt(
|
||||||
|
`Nouveau mot de passe par défaut pour « ${u.login} »\n(l'utilisateur devra le changer à sa prochaine connexion) :`
|
||||||
|
);
|
||||||
|
if (mdp === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mdp.length < 4) {
|
||||||
|
this.erreur.set('Le mot de passe doit faire au moins 4 caractères.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.userService.reinitialiserMotDePasse(u.login, mdp).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.flash(`Mot de passe de « ${u.login} » réinitialisé.`);
|
||||||
|
this.recharger();
|
||||||
|
},
|
||||||
|
error: (e) => this.erreur.set(e?.error?.erreur || 'Échec de la réinitialisation.')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
supprimer(u: Utilisateur): void {
|
||||||
|
if (!confirm(`Supprimer définitivement le compte « ${u.login} » ?\n(pour un usage entre ateliers, préférez « désactiver »)`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.userService.supprimer(u.login).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.flash(`Compte « ${u.login} » supprimé.`);
|
||||||
|
this.recharger();
|
||||||
|
},
|
||||||
|
error: (e) => this.erreur.set(e?.error?.erreur || 'Échec de la suppression.')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
libelleRole(role: RoleGerable): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'consultation':
|
||||||
|
return 'Consultation (lecture seule)';
|
||||||
|
case 'contributeur':
|
||||||
|
return 'Contributeur (annotations)';
|
||||||
|
case 'desactive':
|
||||||
|
return 'Désactivé';
|
||||||
|
default:
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<section class="login" aria-label="Changement de mot de passe">
|
||||||
|
<form class="login__carte" (ngSubmit)="soumettre()">
|
||||||
|
<h1 class="login__titre">SatPRO</h1>
|
||||||
|
<p class="login__sous-titre">
|
||||||
|
@if (impose()) {
|
||||||
|
Première connexion : définissez votre nouveau mot de passe.
|
||||||
|
} @else {
|
||||||
|
Changer votre mot de passe
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="login__champ">
|
||||||
|
<span>Mot de passe actuel</span>
|
||||||
|
<app-password-input
|
||||||
|
name="ancien"
|
||||||
|
autocomplete="current-password"
|
||||||
|
[ngModel]="ancien()"
|
||||||
|
(ngModelChange)="ancien.set($event)"
|
||||||
|
[disabled]="enCours()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="login__champ">
|
||||||
|
<span>Nouveau mot de passe</span>
|
||||||
|
<app-password-input
|
||||||
|
name="nouveau"
|
||||||
|
autocomplete="new-password"
|
||||||
|
[ngModel]="nouveau()"
|
||||||
|
(ngModelChange)="nouveau.set($event)"
|
||||||
|
[disabled]="enCours()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="login__champ">
|
||||||
|
<span>Confirmer le nouveau mot de passe</span>
|
||||||
|
<app-password-input
|
||||||
|
name="confirmation"
|
||||||
|
autocomplete="new-password"
|
||||||
|
[ngModel]="confirmation()"
|
||||||
|
(ngModelChange)="confirmation.set($event)"
|
||||||
|
[disabled]="enCours()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="login__erreur" role="alert">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit" class="login__bouton" [disabled]="enCours()">
|
||||||
|
{{ enCours() ? 'Enregistrement…' : 'Changer le mot de passe' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { PasswordInputComponent } from '../../shared/password-input.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran de changement de mot de passe. Imposé à la première connexion et après
|
||||||
|
* une réinitialisation par l'administrateur (flag `doitChangerMdp`).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-change-password',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule, PasswordInputComponent],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './change-password.component.html',
|
||||||
|
styleUrl: './login.component.scss'
|
||||||
|
})
|
||||||
|
export class ChangePasswordComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly ancien = signal('');
|
||||||
|
readonly nouveau = signal('');
|
||||||
|
readonly confirmation = signal('');
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
readonly enCours = signal(false);
|
||||||
|
|
||||||
|
/** Vrai si le changement est imposé (première connexion / reset). */
|
||||||
|
readonly impose = this.auth.doitChangerMdp;
|
||||||
|
|
||||||
|
soumettre(): void {
|
||||||
|
this.erreur.set(null);
|
||||||
|
const ancien = this.ancien();
|
||||||
|
const nouveau = this.nouveau();
|
||||||
|
if (!ancien || !nouveau) {
|
||||||
|
this.erreur.set('Renseignez l’ancien et le nouveau mot de passe.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nouveau.length < 4) {
|
||||||
|
this.erreur.set('Le nouveau mot de passe doit faire au moins 4 caractères.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nouveau !== this.confirmation()) {
|
||||||
|
this.erreur.set('La confirmation ne correspond pas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nouveau === ancien) {
|
||||||
|
this.erreur.set('Le nouveau mot de passe doit être différent de l’ancien.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.enCours.set(true);
|
||||||
|
this.auth.changerMotDePasse(ancien, nouveau).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.enCours.set(false);
|
||||||
|
void this.router.navigateByUrl('/specs/00-sommaire');
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.enCours.set(false);
|
||||||
|
this.erreur.set(e?.error?.erreur || 'Échec du changement de mot de passe.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<section class="login" aria-label="Connexion">
|
||||||
|
<form class="login__carte" (ngSubmit)="soumettre()">
|
||||||
|
<h1 class="login__titre">SatPRO</h1>
|
||||||
|
<p class="login__sous-titre">Specs pré-projet — accès restreint</p>
|
||||||
|
|
||||||
|
<label class="login__champ">
|
||||||
|
<span>Identifiant</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="login"
|
||||||
|
autocomplete="username"
|
||||||
|
[ngModel]="login()"
|
||||||
|
(ngModelChange)="login.set($event)"
|
||||||
|
[disabled]="enCours()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="login__champ">
|
||||||
|
<span>Mot de passe</span>
|
||||||
|
<app-password-input
|
||||||
|
name="motDePasse"
|
||||||
|
autocomplete="current-password"
|
||||||
|
[ngModel]="motDePasse()"
|
||||||
|
(ngModelChange)="motDePasse.set($event)"
|
||||||
|
[disabled]="enCours()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="login__erreur" role="alert">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit" class="login__bouton" [disabled]="enCours()">
|
||||||
|
{{ enCours() ? 'Connexion…' : 'Se connecter' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__carte {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.75rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__titre {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--dekra-vert-fonce);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__sous-titre {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__champ input {
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__champ input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--dekra-vert);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 122, 51, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__erreur {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-danger-500);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__bouton {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--dekra-vert);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.7rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__bouton:hover:not(:disabled) {
|
||||||
|
background: var(--dekra-vert-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__bouton:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { PasswordInputComponent } from '../../shared/password-input.component';
|
||||||
|
|
||||||
|
/** Page de connexion : login + mot de passe, vers les comptes configurés côté backend. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule, PasswordInputComponent],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrl: './login.component.scss'
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
readonly login = signal('');
|
||||||
|
readonly motDePasse = signal('');
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
readonly enCours = signal(false);
|
||||||
|
|
||||||
|
soumettre(): void {
|
||||||
|
this.erreur.set(null);
|
||||||
|
const identifiant = this.login().trim();
|
||||||
|
const mdp = this.motDePasse();
|
||||||
|
if (!identifiant || !mdp) {
|
||||||
|
this.erreur.set('Renseigne un identifiant et un mot de passe.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.enCours.set(true);
|
||||||
|
this.erreur.set(null);
|
||||||
|
this.auth.seConnecter(identifiant, mdp).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.enCours.set(false);
|
||||||
|
if (this.auth.doitChangerMdp()) {
|
||||||
|
// Première connexion ou après réinitialisation : changement imposé.
|
||||||
|
void this.router.navigateByUrl('/changer-mot-de-passe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retour = this.route.snapshot.queryParamMap.get('retour');
|
||||||
|
void this.router.navigateByUrl(retour || '/specs/00-sommaire');
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.enCours.set(false);
|
||||||
|
if (e?.status === 403) {
|
||||||
|
this.erreur.set('Ce compte est désactivé. Contactez l’administrateur.');
|
||||||
|
} else {
|
||||||
|
this.erreur.set('Identifiants invalides.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<section class="specs" aria-label="Documents de spécification">
|
||||||
|
<aside class="specs__menu" aria-label="Liste des documents">
|
||||||
|
<h2 class="specs__menu-titre">Documents</h2>
|
||||||
|
<nav class="specs__liens">
|
||||||
|
@for (doc of documents(); track doc.cle) {
|
||||||
|
<a
|
||||||
|
class="specs__lien"
|
||||||
|
[routerLink]="['/specs', doc.cle]"
|
||||||
|
routerLinkActive="specs__lien--actif"
|
||||||
|
>
|
||||||
|
{{ doc.titre }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article class="specs__contenu">
|
||||||
|
<div class="specs__barre">
|
||||||
|
<button type="button" class="specs__btn" (click)="rafraichir()" title="Recharger la dernière version du document">
|
||||||
|
⟳ Rafraîchir
|
||||||
|
</button>
|
||||||
|
<button type="button" class="specs__btn specs__btn--annot" (click)="basculerPanneau()" title="Afficher / masquer les annotations">
|
||||||
|
💬 Annotations
|
||||||
|
@if (nbOuvertes() > 0) {
|
||||||
|
<span class="specs__badge">{{ nbOuvertes() }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (chargement()) {
|
||||||
|
<p class="specs__info">Chargement du document…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="specs__erreur" role="alert">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!chargement() && !erreur() && contenuHtml()) {
|
||||||
|
<div #markdownHost class="specs__markdown markdown-body" [innerHTML]="contenuHtml()"></div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Panneau latéral des annotations -->
|
||||||
|
@if (panneauOuvert()) {
|
||||||
|
<aside class="annot" aria-label="Annotations du document">
|
||||||
|
<!-- Zone haute figée : titre, export, formulaire, filtre -->
|
||||||
|
<div class="annot__haut">
|
||||||
|
<header class="annot__entete">
|
||||||
|
<h2 class="annot__titre">Annotations</h2>
|
||||||
|
<button type="button" class="annot__fermer" (click)="basculerPanneau()" title="Fermer">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!peutContribuer()) {
|
||||||
|
<p class="annot__lecture-seule">
|
||||||
|
Votre accès est en <strong>lecture seule</strong> : vous pouvez consulter les
|
||||||
|
annotations mais pas en ajouter. Contactez l'administrateur pour un accès contributeur.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Formulaire création / édition (contributeurs et admin uniquement) -->
|
||||||
|
@if (peutContribuer()) {
|
||||||
|
<div class="annot__form-bloc">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="annot__form-toggle"
|
||||||
|
(click)="formReplie.set(!formReplie())"
|
||||||
|
[attr.aria-expanded]="!formReplie()"
|
||||||
|
[title]="formReplie() ? 'Déplier la saisie' : 'Replier la saisie'"
|
||||||
|
>
|
||||||
|
<span class="annot__form-chevron">{{ formReplie() ? '▸' : '▾' }}</span>
|
||||||
|
{{ editionId() ? 'Modifier l’annotation' : 'Ajouter une annotation' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (!formReplie()) {
|
||||||
|
<form class="annot__form" (ngSubmit)="soumettreForm()">
|
||||||
|
<p class="annot__form-cible">
|
||||||
|
@if (fTitreSection()) {
|
||||||
|
Section : <strong>{{ fTitreSection() }}</strong>
|
||||||
|
} @else {
|
||||||
|
Nouvelle annotation
|
||||||
|
}
|
||||||
|
@if (editionId()) { <em>(édition)</em> }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="annot__champ">
|
||||||
|
<span>Type</span>
|
||||||
|
<select [ngModel]="fType()" (ngModelChange)="fType.set($event)" name="type">
|
||||||
|
<option value="remarque">Remarque</option>
|
||||||
|
<option value="ajout">Ajout</option>
|
||||||
|
<option value="correction">Correction</option>
|
||||||
|
<option value="question">Question</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="annot__champ">
|
||||||
|
<span>Texte</span>
|
||||||
|
<textarea
|
||||||
|
rows="5"
|
||||||
|
name="texte"
|
||||||
|
[ngModel]="fTexte()"
|
||||||
|
(ngModelChange)="fTexte.set($event)"
|
||||||
|
placeholder="Votre remarque, ajout ou correction…"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (erreurForm()) {
|
||||||
|
<p class="annot__erreur" role="alert">{{ erreurForm() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="annot__form-actions">
|
||||||
|
<button type="submit" class="annot__valider" [disabled]="envoiEnCours()">
|
||||||
|
{{ editionId() ? 'Enregistrer' : 'Ajouter' }}
|
||||||
|
</button>
|
||||||
|
@if (editionId()) {
|
||||||
|
<button type="button" class="annot__annuler" (click)="annulerForm()">Annuler</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="annot__hint">
|
||||||
|
Astuce : cliquez sur 💬 à côté d'un titre de section pour préremplir la cible.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Barre de filtre -->
|
||||||
|
<div class="annot__filtre">
|
||||||
|
<span class="annot__filtre-compte">
|
||||||
|
{{ nbOuvertes() }} ouverte(s) · {{ nbTraitees() }} traitée(s)
|
||||||
|
</span>
|
||||||
|
<label class="annot__toggle" title="Afficher ou masquer les annotations traitées">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="masquerTraitees()"
|
||||||
|
(change)="masquerTraitees.set($any($event.target).checked)"
|
||||||
|
/>
|
||||||
|
Masquer les traitées
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des annotations du document (défile sous la zone haute) -->
|
||||||
|
<div class="annot__liste">
|
||||||
|
@if (annotations().length === 0) {
|
||||||
|
<p class="annot__vide">Aucune annotation sur ce document pour l'instant.</p>
|
||||||
|
} @else if (annotationsAffichees().length === 0) {
|
||||||
|
<p class="annot__vide">Toutes les annotations sont traitées (filtre actif).</p>
|
||||||
|
}
|
||||||
|
@for (a of annotationsAffichees(); track a.id) {
|
||||||
|
<div class="annot__item" [class.annot__item--traitee]="a.statut === 'traitee'">
|
||||||
|
<div class="annot__item-tete">
|
||||||
|
<span class="annot__type annot__type--{{ a.type }}">{{ a.type }}</span>
|
||||||
|
<span class="annot__auteur">{{ a.auteur }}</span>
|
||||||
|
@if (a.statut === 'traitee') {
|
||||||
|
<span class="annot__statut">traitée</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (a.titreSection) {
|
||||||
|
<div class="annot__section">↳ {{ a.titreSection }}</div>
|
||||||
|
}
|
||||||
|
<p class="annot__texte">{{ a.texte }}</p>
|
||||||
|
@if (peutModifier(a)) {
|
||||||
|
<div class="annot__actions">
|
||||||
|
<button type="button" (click)="ouvrirEdition(a)">Éditer</button>
|
||||||
|
<button type="button" (click)="basculerStatut(a)">
|
||||||
|
{{ a.statut === 'ouverte' ? 'Marquer traitée' : 'Rouvrir' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="annot__suppr" (click)="supprimer(a)">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
.specs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quand le panneau d'annotations est ouvert, 3 colonnes. */
|
||||||
|
.specs:has(.annot) {
|
||||||
|
grid-template-columns: 280px 1fr 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__menu {
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid var(--gris-bord);
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
/* Reste visible au défilement, scroll interne si la liste est longue. */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__menu-titre {
|
||||||
|
margin: 0 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__liens {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__lien {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--gris-texte);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__lien:hover {
|
||||||
|
background: var(--gris-fond);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__lien--actif {
|
||||||
|
background: #eef7f0;
|
||||||
|
border-left-color: var(--dekra-vert);
|
||||||
|
color: var(--dekra-vert-fonce);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__contenu {
|
||||||
|
padding: 1.5rem 2rem 3rem;
|
||||||
|
min-width: 0; /* évite le débordement horizontal des tableaux/pre larges */
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__barre {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
/* Reste accessible pendant la lecture (bouton Rafraîchir / Annotations). */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
background: linear-gradient(var(--gris-fond) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--dekra-vert-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__btn:hover {
|
||||||
|
background: #eef7f0;
|
||||||
|
border-color: var(--dekra-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__badge {
|
||||||
|
background: var(--dekra-vert);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__info,
|
||||||
|
.specs__erreur {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__erreur {
|
||||||
|
color: var(--ds-danger-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton « annoter » accolé aux titres (injecté dans le HTML rendu). */
|
||||||
|
.specs__markdown :is(h2, h3, h4) .btn-annoter {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease-in-out;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__markdown :is(h2, h3, h4):hover .btn-annoter {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs__markdown .btn-annoter:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Panneau latéral des annotations ----- */
|
||||||
|
.annot {
|
||||||
|
background: #fff;
|
||||||
|
border-left: 1px solid var(--gris-bord);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
/* Suit le défilement de la page : reste visible en haut de la fenêtre. */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
height: calc(100vh - 56px); /* 56px = hauteur de l'en-tête */
|
||||||
|
overflow: hidden; /* le scroll est délégué à .annot__liste */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone haute figée : titre, export, formulaire, filtre. Ne défile pas. */
|
||||||
|
.annot__haut {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--ds-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__entete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__titre {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--dekra-vert-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__fermer {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__export {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__export button {
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--dekra-vert-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__export button:hover {
|
||||||
|
background: #eef7f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form-bloc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form-toggle:hover {
|
||||||
|
background: var(--ds-dark-green-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form-chevron {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form {
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--gris-fond);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form-cible {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__champ select,
|
||||||
|
.annot__champ textarea {
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__champ textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 11rem; /* zone de saisie 2x plus haute */
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__valider {
|
||||||
|
background: var(--dekra-vert);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__valider:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__annuler {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__erreur {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-danger-500);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__liste {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
/* Seule la liste défile, sous la zone haute figée. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__filtre {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ds-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__filtre-compte {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__toggle input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__vide {
|
||||||
|
color: var(--gris-doux);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__lecture-seule {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--ds-gray-600);
|
||||||
|
background: var(--ds-gray-50);
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__item {
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
border-left: 3px solid var(--dekra-vert-clair);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__item--traitee {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-left-color: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__item-tete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__type {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__type--ajout { background: var(--ds-success-500); }
|
||||||
|
.annot__type--correction { background: var(--ds-warning-400); }
|
||||||
|
.annot__type--question { background: var(--ds-info-500); }
|
||||||
|
.annot__type--remarque { background: var(--ds-dark-green-500); }
|
||||||
|
|
||||||
|
.annot__auteur {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__statut {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--dekra-vert-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__section {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--gris-doux);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__texte {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__actions button {
|
||||||
|
border: 1px solid var(--gris-bord);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--gris-texte);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__actions button:hover {
|
||||||
|
background: var(--gris-fond);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot__suppr {
|
||||||
|
color: var(--ds-danger-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.specs,
|
||||||
|
.specs:has(.annot) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
/* En colonne unique, on désactive le sticky/hauteur plein écran. */
|
||||||
|
.specs__menu,
|
||||||
|
.annot {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.specs__menu {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--gris-bord);
|
||||||
|
}
|
||||||
|
.annot {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--gris-bord);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { ViewportScroller } from '@angular/common';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
import { SpecDocument, SpecService } from '../../core/services/spec.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import {
|
||||||
|
Annotation,
|
||||||
|
AnnotationService,
|
||||||
|
StatutAnnotation,
|
||||||
|
TypeAnnotation
|
||||||
|
} from '../../core/services/annotation.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SpecsComponent` — consultation des specs (Markdown rendu en direct) + annotations.
|
||||||
|
*
|
||||||
|
* Chaque titre de section reçoit un identifiant (slug) et un bouton « 💬 » permettant
|
||||||
|
* d'annoter la section. Les annotations sont listées dans un panneau latéral droit ;
|
||||||
|
* leur création/édition/suppression passe par l'API (droits : auteur ou admin).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-specs',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule, RouterLink, RouterLinkActive],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './specs.component.html',
|
||||||
|
styleUrl: './specs.component.scss'
|
||||||
|
})
|
||||||
|
export class SpecsComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly specService = inject(SpecService);
|
||||||
|
private readonly annotationService = inject(AnnotationService);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly scroller = inject(ViewportScroller);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly cdr = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
|
@ViewChild('markdownHost') private markdownHost?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
readonly documents = signal<SpecDocument[]>([]);
|
||||||
|
readonly cleCourante = signal<string | null>(null);
|
||||||
|
readonly titreCourant = signal<string>('');
|
||||||
|
readonly contenuHtml = signal<SafeHtml | null>(null);
|
||||||
|
readonly chargement = signal(false);
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
|
||||||
|
// --- Annotations ---
|
||||||
|
readonly annotations = signal<Annotation[]>([]);
|
||||||
|
readonly panneauOuvert = signal(false);
|
||||||
|
readonly login = this.auth.login;
|
||||||
|
readonly estAdmin = this.auth.estAdmin;
|
||||||
|
/** L'utilisateur peut-il annoter (admin/contributeur) ? Sinon lecture seule. */
|
||||||
|
readonly peutContribuer = this.auth.peutContribuer;
|
||||||
|
|
||||||
|
/** Nombre d'annotations ouvertes sur le document courant (badge). */
|
||||||
|
readonly nbOuvertes = computed(
|
||||||
|
() => this.annotations().filter((a) => a.statut === 'ouverte').length
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Nombre d'annotations traitées sur le document courant. */
|
||||||
|
readonly nbTraitees = computed(
|
||||||
|
() => this.annotations().filter((a) => a.statut === 'traitee').length
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Filtre d'affichage : masquer les annotations traitées. */
|
||||||
|
readonly masquerTraitees = signal(false);
|
||||||
|
|
||||||
|
/** Repli du formulaire de saisie/édition (pour agrandir la liste). */
|
||||||
|
readonly formReplie = signal(false);
|
||||||
|
|
||||||
|
/** Annotations réellement affichées dans la liste, selon le filtre. */
|
||||||
|
readonly annotationsAffichees = computed(() =>
|
||||||
|
this.masquerTraitees()
|
||||||
|
? this.annotations().filter((a) => a.statut !== 'traitee')
|
||||||
|
: this.annotations()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Formulaire de création/édition
|
||||||
|
readonly editionId = signal<string | null>(null); // null = création
|
||||||
|
readonly fTexte = signal('');
|
||||||
|
readonly fType = signal<TypeAnnotation>('remarque');
|
||||||
|
readonly fAncre = signal('');
|
||||||
|
readonly fTitreSection = signal('');
|
||||||
|
readonly envoiEnCours = signal(false);
|
||||||
|
readonly erreurForm = signal<string | null>(null);
|
||||||
|
|
||||||
|
private listeSub?: Subscription;
|
||||||
|
private contenuSub?: Subscription;
|
||||||
|
private paramSub?: Subscription;
|
||||||
|
private readonly souscriptions: Subscription[] = [];
|
||||||
|
|
||||||
|
private readonly markedInstance = this.creerMarked();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestionnaire de clic délégué sur le contenu rendu :
|
||||||
|
* - boutons « annoter » (.btn-annoter) -> ouvre le formulaire d'annotation ;
|
||||||
|
* - liens internes vers un autre document (.lien-interne) -> navigation SPA ;
|
||||||
|
* - liens d'ancre pure (#section) -> défilement vers la section.
|
||||||
|
*/
|
||||||
|
private readonly onClicContenu = (ev: Event) => {
|
||||||
|
const el = ev.target as HTMLElement;
|
||||||
|
|
||||||
|
// 1) Bouton d'annotation
|
||||||
|
const btn = el?.closest('.btn-annoter') as HTMLElement | null;
|
||||||
|
if (btn) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.ouvrirCreation(btn.getAttribute('data-ancre') || '', btn.getAttribute('data-titre') || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Lien (document interne ou ancre)
|
||||||
|
const lien = el?.closest('a') as HTMLAnchorElement | null;
|
||||||
|
if (!lien) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien interne vers un autre document de specs (réécrit par le renderer).
|
||||||
|
const cleCible = lien.getAttribute('data-cle');
|
||||||
|
if (cleCible) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const fragment = lien.getAttribute('data-fragment') || undefined;
|
||||||
|
void this.router.navigate(['/specs', cleCible], { fragment });
|
||||||
|
if (fragment) {
|
||||||
|
setTimeout(() => this.scroller.scrollToAnchor(fragment), 50);
|
||||||
|
} else {
|
||||||
|
this.scroller.scrollToPosition([0, 0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien d'ancre pure (#id) dans le document courant.
|
||||||
|
const href = lien.getAttribute('href') || '';
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.scroller.scrollToAnchor(href.slice(1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.listeSub = this.specService.lister().subscribe({
|
||||||
|
next: (docs) => {
|
||||||
|
this.documents.set(docs);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.erreur.set('Impossible de charger la liste des documents.');
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.paramSub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const cle = params.get('cle');
|
||||||
|
if (cle) {
|
||||||
|
this.chargerDocument(cle);
|
||||||
|
this.chargerAnnotations(cle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.listeSub?.unsubscribe();
|
||||||
|
this.contenuSub?.unsubscribe();
|
||||||
|
this.paramSub?.unsubscribe();
|
||||||
|
this.souscriptions.forEach((s) => s.unsubscribe());
|
||||||
|
this.markdownHost?.nativeElement.removeEventListener('click', this.onClicContenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafraichir(): void {
|
||||||
|
const cle = this.cleCourante();
|
||||||
|
if (cle) {
|
||||||
|
this.chargerDocument(cle);
|
||||||
|
this.chargerAnnotations(cle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Document
|
||||||
|
|
||||||
|
private chargerDocument(cle: string): void {
|
||||||
|
this.contenuSub?.unsubscribe();
|
||||||
|
this.cleCourante.set(cle);
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.erreur.set(null);
|
||||||
|
this.contenuHtml.set(null);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
|
||||||
|
this.contenuSub = this.specService.obtenir(cle).subscribe({
|
||||||
|
next: (doc) => {
|
||||||
|
this.titreCourant.set(doc.titre);
|
||||||
|
const html = this.markedInstance.parse(doc.markdown, { async: false }) as string;
|
||||||
|
const propre = DOMPurify.sanitize(html, {
|
||||||
|
ADD_ATTR: [
|
||||||
|
'class',
|
||||||
|
'id',
|
||||||
|
'data-ancre',
|
||||||
|
'data-titre',
|
||||||
|
'data-cle',
|
||||||
|
'data-fragment',
|
||||||
|
'title',
|
||||||
|
'type',
|
||||||
|
'target',
|
||||||
|
'rel'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
this.contenuHtml.set(this.sanitizer.bypassSecurityTrustHtml(propre));
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
setTimeout(() => {
|
||||||
|
void this.rendreMermaid();
|
||||||
|
// (Ré)attache le gestionnaire de clic délégué sur le conteneur injecté.
|
||||||
|
const hote = this.markdownHost?.nativeElement;
|
||||||
|
if (hote) {
|
||||||
|
hote.removeEventListener('click', this.onClicContenu);
|
||||||
|
hote.addEventListener('click', this.onClicContenu);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.erreur.set("Ce document est introuvable ou n'a pas pu être chargé.");
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Annotations
|
||||||
|
|
||||||
|
private chargerAnnotations(cle: string): void {
|
||||||
|
const sub = this.annotationService.lister(cle).subscribe({
|
||||||
|
next: (liste) => {
|
||||||
|
this.annotations.set(liste);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Silencieux : l'absence d'annotations ne doit pas bloquer la lecture.
|
||||||
|
this.annotations.set([]);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.souscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
basculerPanneau(): void {
|
||||||
|
this.panneauOuvert.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Annotations à afficher pour une ancre donnée (regroupées sous chaque section). */
|
||||||
|
annotationsPourAncre(ancre: string): Annotation[] {
|
||||||
|
return this.annotations().filter((a) => a.ancre === ancre);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouvre le formulaire en création pour une section. */
|
||||||
|
ouvrirCreation(ancre: string, titreSection: string): void {
|
||||||
|
this.editionId.set(null);
|
||||||
|
this.fTexte.set('');
|
||||||
|
this.fType.set('remarque');
|
||||||
|
this.fAncre.set(ancre);
|
||||||
|
this.fTitreSection.set(titreSection);
|
||||||
|
this.erreurForm.set(null);
|
||||||
|
this.panneauOuvert.set(true);
|
||||||
|
this.formReplie.set(false); // déplie le formulaire pour saisir
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouvre le formulaire en édition d'une annotation existante. */
|
||||||
|
ouvrirEdition(a: Annotation): void {
|
||||||
|
this.editionId.set(a.id);
|
||||||
|
this.fTexte.set(a.texte);
|
||||||
|
this.fType.set(a.type);
|
||||||
|
this.fAncre.set(a.ancre);
|
||||||
|
this.fTitreSection.set(a.titreSection);
|
||||||
|
this.erreurForm.set(null);
|
||||||
|
this.panneauOuvert.set(true);
|
||||||
|
this.formReplie.set(false); // déplie le formulaire pour éditer
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
annulerForm(): void {
|
||||||
|
this.editionId.set(null);
|
||||||
|
this.fTexte.set('');
|
||||||
|
this.erreurForm.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Droit de modifier/supprimer : admin, ou auteur ayant un accès contributeur. */
|
||||||
|
peutModifier(a: Annotation): boolean {
|
||||||
|
if (this.estAdmin()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.peutContribuer() && a.auteur === this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
soumettreForm(): void {
|
||||||
|
const cle = this.cleCourante();
|
||||||
|
if (!cle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const texte = this.fTexte().trim();
|
||||||
|
if (!texte) {
|
||||||
|
this.erreurForm.set('Le texte est requis.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.envoiEnCours.set(true);
|
||||||
|
this.erreurForm.set(null);
|
||||||
|
|
||||||
|
const id = this.editionId();
|
||||||
|
if (id) {
|
||||||
|
// Édition
|
||||||
|
const sub = this.annotationService
|
||||||
|
.mettreAJour(cle, id, { texte, type: this.fType() })
|
||||||
|
.subscribe({
|
||||||
|
next: (maj) => {
|
||||||
|
this.annotations.update((l) => l.map((a) => (a.id === maj.id ? maj : a)));
|
||||||
|
this.envoiEnCours.set(false);
|
||||||
|
this.annulerForm();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.envoiEnCours.set(false);
|
||||||
|
this.erreurForm.set("Échec de l'enregistrement.");
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.souscriptions.push(sub);
|
||||||
|
} else {
|
||||||
|
// Création
|
||||||
|
const sub = this.annotationService
|
||||||
|
.creer(cle, {
|
||||||
|
texte,
|
||||||
|
type: this.fType(),
|
||||||
|
ancre: this.fAncre(),
|
||||||
|
titreSection: this.fTitreSection()
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (creee) => {
|
||||||
|
this.annotations.update((l) => [...l, creee]);
|
||||||
|
this.envoiEnCours.set(false);
|
||||||
|
this.annulerForm();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.envoiEnCours.set(false);
|
||||||
|
this.erreurForm.set("Échec de l'enregistrement.");
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.souscriptions.push(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
basculerStatut(a: Annotation): void {
|
||||||
|
const cle = this.cleCourante();
|
||||||
|
if (!cle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nouveau: StatutAnnotation = a.statut === 'ouverte' ? 'traitee' : 'ouverte';
|
||||||
|
const sub = this.annotationService.mettreAJour(cle, a.id, { statut: nouveau }).subscribe({
|
||||||
|
next: (maj) => {
|
||||||
|
this.annotations.update((l) => l.map((x) => (x.id === maj.id ? maj : x)));
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => this.cdr.markForCheck()
|
||||||
|
});
|
||||||
|
this.souscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
supprimer(a: Annotation): void {
|
||||||
|
const cle = this.cleCourante();
|
||||||
|
if (!cle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sub = this.annotationService.supprimer(cle, a.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.annotations.update((l) => l.filter((x) => x.id !== a.id));
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => this.cdr.markForCheck()
|
||||||
|
});
|
||||||
|
this.souscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'export des annotations est désormais accessible uniquement depuis l'en-tête du
|
||||||
|
// site (composant racine), pour l'administrateur. Il a donc été retiré de ce panneau.
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Mermaid
|
||||||
|
|
||||||
|
private async rendreMermaid(): Promise<void> {
|
||||||
|
const hote = this.markdownHost?.nativeElement;
|
||||||
|
if (!hote) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blocs = hote.querySelectorAll<HTMLElement>('pre.mermaid');
|
||||||
|
if (blocs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mermaid = (await import('mermaid')).default;
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });
|
||||||
|
await mermaid.run({ nodes: Array.from(blocs) });
|
||||||
|
} catch {
|
||||||
|
// Repli : le bloc de code brut reste visible si le rendu échoue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- marked
|
||||||
|
|
||||||
|
private creerMarked(): typeof marked {
|
||||||
|
const slugs = this.compteurSlugs();
|
||||||
|
// Lu à chaque rendu : masque le bouton « annoter » pour les comptes en lecture seule.
|
||||||
|
const peutContribuer = () => this.peutContribuer();
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
code(this: unknown, { text, lang }: { text: string; lang?: string }): string {
|
||||||
|
if ((lang ?? '').trim().toLowerCase() === 'mermaid') {
|
||||||
|
return `<pre class="mermaid">${SpecsComponent.echapper(text)}</pre>`;
|
||||||
|
}
|
||||||
|
const langue = (lang ?? '').trim();
|
||||||
|
const classe = langue ? ` class="language-${SpecsComponent.echapper(langue)}"` : '';
|
||||||
|
return `<pre><code${classe}>${SpecsComponent.echapper(text)}</code></pre>`;
|
||||||
|
},
|
||||||
|
link({ href, title, text }: { href: string; title?: string | null; text: string }): string {
|
||||||
|
// `text` est le libellé déjà rendu (HTML inline) du lien.
|
||||||
|
const texte = text;
|
||||||
|
const t = title ? ` title="${SpecsComponent.echapper(title)}"` : '';
|
||||||
|
|
||||||
|
// Lien interne vers un autre document de specs : "06-prestations.md" ou
|
||||||
|
// "06-prestations.md#4-1" ou "./06-prestations.md". On réécrit en route SPA.
|
||||||
|
const interne = /^(?:\.\/)?([a-z0-9][a-z0-9-]*)\.md(?:#([^)]*))?$/i.exec(href || '');
|
||||||
|
if (interne) {
|
||||||
|
const cle = interne[1];
|
||||||
|
const fragment = interne[2] || '';
|
||||||
|
const dataFragment = fragment ? ` data-fragment="${SpecsComponent.echapper(fragment)}"` : '';
|
||||||
|
return `<a href="/specs/${SpecsComponent.echapper(cle)}" class="lien-interne" data-cle="${SpecsComponent.echapper(
|
||||||
|
cle
|
||||||
|
)}"${dataFragment}${t}>${texte}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien externe : ouvre dans un nouvel onglet, sécurisé.
|
||||||
|
const estExterne = /^https?:\/\//i.test(href || '');
|
||||||
|
const rel = estExterne ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
return `<a href="${SpecsComponent.echapper(href || '#')}"${t}${rel}>${texte}</a>`;
|
||||||
|
},
|
||||||
|
heading({ text, depth, tokens }: { text: string; depth: number; tokens?: unknown }): string {
|
||||||
|
// `text` peut contenir du HTML inline déjà rendu ; on dérive un libellé propre.
|
||||||
|
const brut = SpecsComponent.texteBrut(text);
|
||||||
|
const ancre = slugs(brut);
|
||||||
|
// Bouton d'annotation : pas sur le H1 (titre du document), et seulement si
|
||||||
|
// l'utilisateur peut contribuer (masqué en lecture seule).
|
||||||
|
const bouton =
|
||||||
|
depth === 1 || !peutContribuer()
|
||||||
|
? ''
|
||||||
|
: ` <button type="button" class="btn-annoter" data-ancre="${SpecsComponent.echapper(
|
||||||
|
ancre
|
||||||
|
)}" data-titre="${SpecsComponent.echapper(brut)}" title="Annoter cette section">💬</button>`;
|
||||||
|
return `<h${depth} id="${SpecsComponent.echapper(ancre)}">${text}${bouton}</h${depth}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return marked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un générateur de slugs uniques (gère les doublons par suffixe -n). */
|
||||||
|
private compteurSlugs(): (texte: string) => string {
|
||||||
|
const vus = new Map<string, number>();
|
||||||
|
return (texte: string) => {
|
||||||
|
const base =
|
||||||
|
texte
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // accents
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'section';
|
||||||
|
const n = vus.get(base) ?? 0;
|
||||||
|
vus.set(base, n + 1);
|
||||||
|
return n === 0 ? base : `${base}-${n}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static texteBrut(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static echapper(valeur: string): string {
|
||||||
|
return valeur
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, forwardRef, signal } from '@angular/core';
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ de saisie de mot de passe avec bouton afficher/masquer (œil).
|
||||||
|
* Implémente ControlValueAccessor pour être utilisable avec [(ngModel)].
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* <app-password-input name="mdp" autocomplete="current-password"
|
||||||
|
* [(ngModel)]="motDePasse" [disabled]="enCours()" />
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-password-input',
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => PasswordInputComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="mdp-champ">
|
||||||
|
<input
|
||||||
|
[type]="visible() ? 'text' : 'password'"
|
||||||
|
[attr.name]="name"
|
||||||
|
[attr.autocomplete]="autocomplete"
|
||||||
|
[value]="valeur()"
|
||||||
|
[disabled]="desactive()"
|
||||||
|
(input)="surSaisie($event)"
|
||||||
|
(blur)="onTouched()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mdp-oeil"
|
||||||
|
(click)="basculer()"
|
||||||
|
[attr.aria-label]="visible() ? 'Masquer le mot de passe' : 'Afficher le mot de passe'"
|
||||||
|
[attr.aria-pressed]="visible()"
|
||||||
|
[title]="visible() ? 'Masquer' : 'Afficher'"
|
||||||
|
[disabled]="desactive()"
|
||||||
|
>
|
||||||
|
{{ visible() ? '🙈' : '👁' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.mdp-champ {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.mdp-champ input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 2.4rem 0.6rem 0.7rem;
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.mdp-champ input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ds-dark-green-500);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 107, 82, 0.15);
|
||||||
|
}
|
||||||
|
.mdp-oeil {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.3rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.mdp-oeil:hover:not(:disabled) {
|
||||||
|
background: var(--ds-gray-100);
|
||||||
|
}
|
||||||
|
.mdp-oeil:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PasswordInputComponent implements ControlValueAccessor {
|
||||||
|
@Input() name = '';
|
||||||
|
@Input() autocomplete = 'current-password';
|
||||||
|
|
||||||
|
readonly valeur = signal('');
|
||||||
|
readonly visible = signal(false);
|
||||||
|
readonly desactive = signal(false);
|
||||||
|
|
||||||
|
private onChange: (v: string) => void = () => {};
|
||||||
|
onTouched: () => void = () => {};
|
||||||
|
|
||||||
|
basculer(): void {
|
||||||
|
this.visible.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
surSaisie(ev: Event): void {
|
||||||
|
const v = (ev.target as HTMLInputElement).value;
|
||||||
|
this.valeur.set(v);
|
||||||
|
this.onChange(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ControlValueAccessor ---
|
||||||
|
writeValue(v: string | null): void {
|
||||||
|
this.valeur.set(v ?? '');
|
||||||
|
}
|
||||||
|
registerOnChange(fn: (v: string) => void): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
setDisabledState(estDesactive: boolean): void {
|
||||||
|
this.desactive.set(estDesactive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Environnement de production : le front est servi par nginx, qui proxifie /api
|
||||||
|
// vers le backend sur la même origine. L'URL d'API est donc relative.
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: '/api'
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Environnement de développement : le front (ng serve, :4200) adresse le backend Node (:8080).
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:8080/api'
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>SatPRO — Specs pré-projet</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<!-- Non indexable : combiné au robots.txt et à l'en-tête X-Robots-Tag (nginx). -->
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||||
|
<link rel="icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
Thème global — Design system KiwUI (DEKRA)
|
||||||
|
Référence : docs/design-system-kiwui.md — tokens extraits de Figma.
|
||||||
|
IMPORTANT : ne pas coder de couleur en dur ; utiliser les tokens --ds-*.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* --- Vert logo (identité) --- */
|
||||||
|
--ds-logo-green-500: #008143;
|
||||||
|
|
||||||
|
/* --- Dark green (primaire d'interface) --- */
|
||||||
|
--ds-dark-green-50: #f2f8f6;
|
||||||
|
--ds-dark-green-100: #cce1dc;
|
||||||
|
--ds-dark-green-200: #b3d3cb;
|
||||||
|
--ds-dark-green-300: #80b5a9;
|
||||||
|
--ds-dark-green-400: #4d9786;
|
||||||
|
--ds-dark-green-500: #006b52;
|
||||||
|
--ds-dark-green-600: #01604a;
|
||||||
|
--ds-dark-green-700: #015642;
|
||||||
|
--ds-dark-green-800: #014031;
|
||||||
|
--ds-dark-green-900: #002019;
|
||||||
|
|
||||||
|
/* --- Bright green (accent) --- */
|
||||||
|
--ds-bright-green-300: #9ee46c;
|
||||||
|
--ds-bright-green-400: #7bb154;
|
||||||
|
--ds-bright-green-500: #527638;
|
||||||
|
|
||||||
|
/* --- Jaune (secondaire) --- */
|
||||||
|
--ds-yellow-100: #ffe066;
|
||||||
|
--ds-yellow-200: #ffcc00;
|
||||||
|
--ds-yellow-700: #594700;
|
||||||
|
|
||||||
|
/* --- Gris (neutres) --- */
|
||||||
|
--ds-gray-50: #f8f8f8;
|
||||||
|
--ds-gray-100: #ededed;
|
||||||
|
--ds-gray-200: #d2d2d2;
|
||||||
|
--ds-gray-300: #b4b4b4;
|
||||||
|
--ds-gray-400: #9d9d9d;
|
||||||
|
--ds-gray-500: #696969;
|
||||||
|
--ds-gray-600: #595959;
|
||||||
|
--ds-gray-700: #444444;
|
||||||
|
--ds-gray-800: #353535;
|
||||||
|
--ds-gray-1000: #1e1e1e;
|
||||||
|
--ds-neutral-50: #fafafa;
|
||||||
|
--ds-white: #ffffff;
|
||||||
|
|
||||||
|
/* --- Sémantiques --- */
|
||||||
|
--ds-success-400: #64b32c;
|
||||||
|
--ds-success-500: #467d1f;
|
||||||
|
--ds-success-700: #2b4d13;
|
||||||
|
--ds-info-500: #4d6d88;
|
||||||
|
--ds-info-800: #002e55;
|
||||||
|
--ds-warning-400: #ef7d02;
|
||||||
|
--ds-warning-700: #733c00;
|
||||||
|
--ds-danger-400: #e36666;
|
||||||
|
--ds-danger-500: #d10000;
|
||||||
|
--ds-danger-700: #9d0000;
|
||||||
|
|
||||||
|
/* --- Rôles sémantiques --- */
|
||||||
|
--ds-text-primary: #1e1e1e;
|
||||||
|
--ds-text-on-primary: #ffffff;
|
||||||
|
--ds-text-btn-secondary-neutral: #006b52;
|
||||||
|
--ds-surface-primary: #ffffff;
|
||||||
|
--ds-surface-secondary-bold: #006b52;
|
||||||
|
--ds-border-primary: #696969;
|
||||||
|
--ds-border-secondary: #d2d2d2;
|
||||||
|
|
||||||
|
/* --- Effets --- */
|
||||||
|
--ds-shadow-soft: #1e1e1e26;
|
||||||
|
--ds-shadow-card: 0 0 10px 0 var(--ds-shadow-soft);
|
||||||
|
|
||||||
|
/* --- Typographie --- */
|
||||||
|
--ds-font-family: 'Noto Sans', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Alias de compatibilité : les variables historiques du site pointent
|
||||||
|
désormais vers les tokens KiwUI (re-thématise tout le site sans
|
||||||
|
réécrire chaque composant).
|
||||||
|
===================================================================== */
|
||||||
|
--dekra-vert: var(--ds-dark-green-500);
|
||||||
|
--dekra-vert-clair: var(--ds-bright-green-400);
|
||||||
|
--dekra-vert-fonce: var(--ds-dark-green-700);
|
||||||
|
--gris-fond: var(--ds-gray-50);
|
||||||
|
--gris-bord: var(--ds-gray-200);
|
||||||
|
--gris-texte: var(--ds-text-primary);
|
||||||
|
--gris-doux: var(--ds-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--ds-font-family);
|
||||||
|
color: var(--ds-text-primary);
|
||||||
|
background: var(--ds-gray-50);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Rendu du Markdown ----- */
|
||||||
|
.markdown-body {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ds-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h1,
|
||||||
|
.markdown-body h2,
|
||||||
|
.markdown-body h3,
|
||||||
|
.markdown-body h4 {
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h1 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
border-bottom: 2px solid var(--ds-dark-green-500);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h2 {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
border-bottom: 1px solid var(--ds-gray-200);
|
||||||
|
padding-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body p,
|
||||||
|
.markdown-body ul,
|
||||||
|
.markdown-body ol {
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a {
|
||||||
|
color: var(--ds-dark-green-500);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body code {
|
||||||
|
background: var(--ds-gray-100);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
font-family: 'Fira Code', Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre {
|
||||||
|
background: var(--ds-dark-green-900);
|
||||||
|
color: #e6e9ef;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre.mermaid {
|
||||||
|
background: var(--ds-white);
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
color: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote {
|
||||||
|
margin: 0.8em 0;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
border-left: 4px solid var(--ds-bright-green-400);
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
color: var(--ds-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th,
|
||||||
|
.markdown-body td {
|
||||||
|
border: 1px solid var(--ds-gray-200);
|
||||||
|
padding: 0.5em 0.7em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th {
|
||||||
|
background: var(--ds-dark-green-50);
|
||||||
|
color: var(--ds-dark-green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body tr:nth-child(even) td {
|
||||||
|
background: var(--ds-gray-50);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Lance en mode développement le backend Node (port 8080) et le frontend Angular
|
||||||
|
# (port 4200), qui lit les specs directement dans ../docs/specs-pre-projet.
|
||||||
|
# À utiliser hors Docker, pour le développement / la préparation d'atelier en local.
|
||||||
|
#
|
||||||
|
# Usage : ./run-dev.sh
|
||||||
|
# Arrêt : Ctrl+C (les deux processus sont arrêtés ensemble).
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# --- Lecture facultative du .env (comptes + secret JWT) ---------------------
|
||||||
|
# On lit JWT_SECRET et AUTH_USERS depuis .env s'ils existent, sans l'exporter en bloc
|
||||||
|
# (le .env peut contenir des commentaires). Variables déjà présentes dans
|
||||||
|
# l'environnement prioritaires.
|
||||||
|
lire_env() {
|
||||||
|
local cle="$1"
|
||||||
|
[[ -f "$RACINE/.env" ]] || return 0
|
||||||
|
grep -E "^${cle}=" "$RACINE/.env" | head -n1 | cut -d= -f2- || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Secret JWT -------------------------------------------------------------
|
||||||
|
# Priorité : variable d'environnement > .env > secret de dev aléatoire.
|
||||||
|
# Un placeholder copié tel quel depuis .env.example est ignoré.
|
||||||
|
if [[ -z "${JWT_SECRET:-}" ]]; then
|
||||||
|
JWT_FROM_ENV="$(lire_env JWT_SECRET)"
|
||||||
|
PLACEHOLDER="change-me-generer-une-longue-chaine-aleatoire-de-32-caracteres-min"
|
||||||
|
if [[ -n "$JWT_FROM_ENV" && "$JWT_FROM_ENV" != "$PLACEHOLDER" ]]; then
|
||||||
|
export JWT_SECRET="$JWT_FROM_ENV"
|
||||||
|
echo "→ Secret JWT chargé depuis .env."
|
||||||
|
else
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
export JWT_SECRET="$(openssl rand -base64 48)"
|
||||||
|
else
|
||||||
|
export JWT_SECRET="$(head -c 48 /dev/urandom | base64)"
|
||||||
|
fi
|
||||||
|
echo "⚠ Aucun secret JWT exploitable dans .env : secret de dev aléatoire généré"
|
||||||
|
echo " (les sessions sont invalidées à chaque relance du script)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Compte administrateur --------------------------------------------------
|
||||||
|
# Priorité : variable d'environnement > .env > défaut dev (admin/admin).
|
||||||
|
if [[ -z "${ADMIN_USER:-}" ]]; then
|
||||||
|
ADMIN_USER_ENV="$(lire_env ADMIN_USER)"
|
||||||
|
export ADMIN_USER="${ADMIN_USER_ENV:-admin}"
|
||||||
|
fi
|
||||||
|
if [[ -z "${ADMIN_PASSWORD:-}" ]]; then
|
||||||
|
ADMIN_PWD_ENV="$(lire_env ADMIN_PASSWORD)"
|
||||||
|
export ADMIN_PASSWORD="${ADMIN_PWD_ENV:-admin}"
|
||||||
|
fi
|
||||||
|
echo "→ Administrateur : ${ADMIN_USER}."
|
||||||
|
|
||||||
|
# Amorçage des comptes initiaux (facultatif, repris du .env).
|
||||||
|
if [[ -z "${SEED_USERS:-}" ]]; then
|
||||||
|
SEED_ENV="$(lire_env SEED_USERS)"
|
||||||
|
[[ -n "$SEED_ENV" ]] && export SEED_USERS="$SEED_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dossier des specs lues en direct (chemin absolu pour être indépendant du cwd).
|
||||||
|
export SPECS_DIR="${SPECS_DIR:-$RACINE/../docs/specs-pre-projet}"
|
||||||
|
# Dossier d'écriture des annotations en dev local.
|
||||||
|
export ANNOTATIONS_DIR="${ANNOTATIONS_DIR:-$RACINE/annotations}"
|
||||||
|
# Dossier d'écriture des comptes gérés en dev local.
|
||||||
|
export USERS_DIR="${USERS_DIR:-$RACINE/users}"
|
||||||
|
export PORT="${PORT:-8080}"
|
||||||
|
|
||||||
|
# --- Backend (Node) ---------------------------------------------------------
|
||||||
|
if [[ ! -d "$RACINE/backend/node_modules" ]]; then
|
||||||
|
echo "→ Installation des dépendances backend (npm install)…"
|
||||||
|
( cd "$RACINE/backend" && npm install --no-audit --no-fund )
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Démarrage du backend sur http://localhost:${PORT} (specs : $SPECS_DIR)"
|
||||||
|
( cd "$RACINE/backend" && node src/server.js ) &
|
||||||
|
PID_BACK=$!
|
||||||
|
|
||||||
|
# --- Frontend (Angular) -----------------------------------------------------
|
||||||
|
if [[ ! -d "$RACINE/frontend/node_modules" ]]; then
|
||||||
|
echo "→ Installation des dépendances frontend (npm install)…"
|
||||||
|
( cd "$RACINE/frontend" && npm install --no-audit --no-fund )
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Nettoyage du cache Angular (.angular/cache)"
|
||||||
|
rm -rf "$RACINE/frontend/.angular/cache"
|
||||||
|
|
||||||
|
echo "→ Démarrage du frontend sur http://localhost:4200 (première compilation : patiente…)"
|
||||||
|
( cd "$RACINE/frontend" && npm start ) &
|
||||||
|
PID_FRONT=$!
|
||||||
|
|
||||||
|
# --- Arrêt propre des deux processus ----------------------------------------
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "→ Arrêt…"
|
||||||
|
kill "$PID_BACK" "$PID_FRONT" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# --- Attente active : ng serve prêt à répondre en HTTP 200 ------------------
|
||||||
|
attendre_frontend() {
|
||||||
|
local url="http://localhost:4200/"
|
||||||
|
local max=120 # secondes
|
||||||
|
local t=0
|
||||||
|
printf "→ Compilation du frontend"
|
||||||
|
while [ "$t" -lt "$max" ]; do
|
||||||
|
if ! kill -0 "$PID_FRONT" 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "✗ Le frontend s'est arrêté avant d'être prêt (voir les logs ci-dessus)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo 000)
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
echo " prêt."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf "."
|
||||||
|
sleep 2
|
||||||
|
t=$((t + 2))
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "✗ Le frontend n'a pas répondu 200 au bout de ${max}s. Vérifie les logs ng serve."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if attendre_frontend; then
|
||||||
|
echo ""
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Site des specs SatPRO prêt."
|
||||||
|
echo " Backend : http://localhost:${PORT}/api"
|
||||||
|
echo " Frontend : http://localhost:4200 ← ouvre cette URL maintenant"
|
||||||
|
echo ""
|
||||||
|
echo " Admin : ${ADMIN_USER} (gère les accès via « Gestion des accès »)."
|
||||||
|
echo " Autres comptes : amorcés au 1er démarrage puis gérés dans l'application."
|
||||||
|
echo " Les specs sont lues en direct dans : $SPECS_DIR"
|
||||||
|
echo " Comptes gérés : $USERS_DIR/users.json"
|
||||||
|
echo " Après édition d'un .md, clique « Rafraîchir » dans la page."
|
||||||
|
echo "======================================================================"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Ce dossier reçoit users.json (comptes gérés via le site, mots de passe hachés bcrypt),
|
||||||
|
# écrit par le backend à l'exécution. Le .json n'est pas versionné (voir ../.gitignore) ;
|
||||||
|
# ce .gitkeep garantit que le dossier existe (point de montage du volume Docker en RW).
|
||||||
Reference in New Issue
Block a user