Wiki/docs/15-baserow-mpd.md
Corentin JOGUET 668576cdc4 chore: initial commit — formation-hub conception phase
Conception complete (Phase 0) pour formation-hub Acadenice :

- 19 docs Merise Agile + UML + GitOps + plans (tests/deploy/ops/api)
  cf docs/00-readme.md pour l'index complet
- Stack Docker compose (Docmost + Baserow + Postgres + Redis + MinIO local FS)
  compose.yml + compose.staging.yml + compose.prod.yml
- CI/CD GitHub Actions skeleton (ci, deploy-staging, deploy-prod)
- Bridge service skeleton (Hono + TS + Biome + Vitest + zod + pino)
- Templates GitHub : PR + 3 issue types + CODEOWNERS + dependabot.yml
- Scripts ops : healthcheck, backup quotidien, smoke-test post-deploy
- LICENSE AGPL-3.0 + SECURITY.md + CONTRIBUTING.md + CHANGELOG.md
- Diagramme drawIO archi infra (XML importable dans diagrams.net)

Decisions structurelles enregistrees :
- Scope CFA + Agence avec entite PERSONNE pivot multi-roles (ADR-001)
- Stack composite Docmost AGPL + Baserow MIT + bridge custom (ADR-001)
- Path B : UX quasi-unified via Tiptap node-views custom (ADR-002)
- Monorepo trunk-based development (ADR-003)
- Postgres separe Docmost/Baserow (ADR-004)
- Bridge stack Node 22 + Hono (ADR-005)
- Repo neuf prefere a fork Docmost
- Prod-like des le jour 1 (pas MVP)
2026-05-07 12:16:19 +02:00

428 lines
21 KiB
Markdown

# MPD — Modele Physique de Donnees (Baserow)
> Implementation concrete dans Baserow : 9 tables avec types exacts, formules, vues, permissions.
> Ce doc est **actionnable** : Corentin l'ouvre cote a cote avec Baserow et cree les tables une par une.
> Source : `07-merise-mld.md` (MLD relationnel) + `05-data-dictionary.md`.
## 1. Setup initial
### 1.1 Hierarchie Baserow
```
Workspace : Acadenice formation-hub
└── Database : formation-hub
├── Tables CFA : formation, bloc, module, attribution
├── Tables Agence : client, projet, tache, intervention
└── Table pivot : personne
```
### 1.2 Order de creation (dependances FK)
Creer les tables dans cet ordre — chaque table dependant des precedentes via FK :
1. **personne** (aucune dep)
2. **client** (aucune dep)
3. **formation** (aucune dep)
4. **bloc** (FK → formation)
5. **projet** (FK → client, optionnel FK → formation)
6. **module** (FK → bloc)
7. **tache** (FK → projet)
8. **attribution** (FK → module + personne)
9. **intervention** (FK → tache + personne)
### 1.3 Conventions Baserow
| Concept | Implementation Baserow |
|---------|------------------------|
| ID auto-increment (PK) | Baserow `id` natif (genere auto) |
| Nom du field | `snake_case`, prefixes par mnemonique d'entite (ex `formation_nom`, `personne_email`) |
| Field "Primary" | Baserow oblige a avoir un Primary field — choisir le nom le plus explicite (ex `formation_nom`) |
| Foreign Key | Field type `Link to table` |
| ENUM | Field type `Single select` avec options exactes |
| MULTI ENUM | Field type `Multiple select` |
| Champ calcule (formula) | Field type `Formula` |
| Champ derive d'une relation | Field type `Lookup` ou `Count` |
| Audit timestamps | Fields `Created on` + `Last modified time` natifs |
| Audit acteur | Fields `Created by` + `Last modified by` natifs |
### 1.4 API token
Apres creation des tables :
- Settings → API tokens → Create token
- Permissions : `read`/`create`/`update`/`delete` sur la database `formation-hub`
- Stocker dans `.env` cote bridge : `BASEROW_API_TOKEN=...`
---
## 2. Table `personne`
**Primary field** : `personne_nom` (texte affiche par defaut dans les links)
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `personne_nom` | Text | — | Nom de famille (Primary) |
| 2 | `personne_prenom` | Text | — | Prenom |
| 3 | `personne_email` | Email | — | Email pro (unique recommande, validation cote bridge) |
| 4 | `personne_telephone` | Phone number | — | Telephone (optionnel) |
| 5 | `personne_capacite_annuelle` | Number | Decimal places: 2 | Heures totales/an |
| 6 | `personne_split_formation_pct` | Number | Decimal places: 1, default 50 | % capacite alloue formation |
| 7 | `personne_split_agence_pct` | Number | Decimal places: 1, default 50 | % capacite alloue agence |
| 8 | `personne_roles` | Multiple select | Options: `formateur`, `developpeur`, `admin`, `direction`, `support` | Roles cumules |
| 9 | `personne_statut` | Single select | Options: `actif` (default), `inactif` | Statut |
| 10 | `personne_attributions` | Link to table | Lien vers `attribution` (champ inverse auto-cree apres creation de attribution) | Toutes les attributions de cette personne |
| 11 | `personne_interventions` | Link to table | Lien vers `intervention` (apres creation) | Toutes les interventions |
| 12 | `personne_heures_attribuees_formation` | Formula | `sum(lookup('personne_attributions', 'attribution_heures_attribuees_active'))` | Rollup des attributions actives |
| 13 | `personne_heures_attribuees_agence` | Formula | `sum(lookup('personne_interventions', 'intervention_heures_active'))` | Rollup des interventions actives |
| 14 | `personne_heures_restantes_formation` | Formula | `(field('personne_capacite_annuelle') * field('personne_split_formation_pct') / 100) - field('personne_heures_attribuees_formation')` | Capacite formation restante |
| 15 | `personne_heures_restantes_agence` | Formula | `(field('personne_capacite_annuelle') * field('personne_split_agence_pct') / 100) - field('personne_heures_attribuees_agence')` | Capacite agence restante |
| 16 | `personne_heures_restantes_total` | Formula | `field('personne_capacite_annuelle') - field('personne_heures_attribuees_formation') - field('personne_heures_attribuees_agence')` | Capacite totale restante |
**Vues recommandees** :
- `Tous` (grid, default) — tableau complet
- `Actifs` (grid, filtre `personne_statut = actif`)
- `Formateurs` (grid, filtre `personne_roles contient formateur`)
- `Developpeurs` (grid, filtre `personne_roles contient developpeur`)
- `Capacite restante` (grid, sort `personne_heures_restantes_total ascending`)
---
## 3. Table `formation`
**Primary field** : `formation_nom`
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `formation_nom` | Text | — | Nom (Primary, unique conseille) |
| 2 | `formation_description` | Long text | rich text autorise | Description longue |
| 3 | `formation_filiere` | Single select | `dev`, `graphisme`, `marketing`, `iot`, `cybersec` | Filiere |
| 4 | `formation_heures_totales` | Number | Decimal places: 2 | Heures totales prevues |
| 5 | `formation_statut` | Single select | `draft` (default), `actif`, `termine`, `archive` | Cycle de vie |
| 6 | `formation_date_debut` | Date | format `YYYY-MM-DD` | Date debut |
| 7 | `formation_date_fin` | Date | — | Date fin |
| 8 | `formation_blocs` | Link to table | Lien vers `bloc` (apres creation bloc) | Blocs de la formation |
| 9 | `formation_projets_pedagogiques` | Link to table | Lien vers `projet` (optionnel) | Projets agence lies en pedagogique |
| 10 | `formation_heures_attribuees` | Formula | `sum(lookup('formation_blocs', 'bloc_heures_prevues'))` | Rollup heures des blocs |
| 11 | `formation_heures_restantes` | Formula | `field('formation_heures_totales') - field('formation_heures_attribuees')` | Reste a attribuer |
| 12 | `formation_created_at` | Created on | — | Audit |
| 13 | `formation_updated_at` | Last modified time | — | Audit |
**Vues** :
- `Tous` (grid)
- `Actives` (grid, filtre `formation_statut = actif`)
- `Par filiere` (grid, group by `formation_filiere`)
- `Calendrier` (calendar view, sur `formation_date_debut`)
- `Capacite restante` (grid, sort `formation_heures_restantes ascending`)
---
## 4. Table `bloc`
**Primary field** : `bloc_nom`
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `bloc_nom` | Text | — | Nom du bloc (Primary) |
| 2 | `bloc_description` | Long text | — | Description |
| 3 | `bloc_formation` | Link to table | Lien vers `formation` (single) | Formation parente |
| 4 | `bloc_heures_prevues` | Number | Decimal places: 2 | Heures du bloc |
| 5 | `bloc_ordre` | Number | Decimal places: 0 | Ordre dans la formation |
| 6 | `bloc_modules` | Link to table | Lien vers `module` (apres creation) | Modules du bloc |
| 7 | `bloc_heures_attribuees` | Formula | `sum(lookup('bloc_modules', 'module_heures_prevues_active'))` | Rollup heures modules actifs |
| 8 | `bloc_heures_restantes` | Formula | `field('bloc_heures_prevues') - field('bloc_heures_attribuees')` | Reste a decomposer |
**Note** : la regle metier "un bloc a un nom unique par formation" se valide **cote bridge** ou via une vue filtree de duplication.
**Vues** :
- `Tous` (grid)
- `Par formation` (grid, group by `bloc_formation`)
---
## 5. Table `module`
**Primary field** : `module_nom`
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `module_nom` | Text | — | Nom du module (Primary) |
| 2 | `module_description` | Long text | — | Description |
| 3 | `module_bloc` | Link to table | Lien vers `bloc` (single) | Bloc parent |
| 4 | `module_heures_prevues` | Number | Decimal places: 2 | Heures du module |
| 5 | `module_statut` | Single select | `a_attribuer` (default), `attribue`, `en_cours`, `realise`, `annule` | Cycle de vie |
| 6 | `module_attributions` | Link to table | Lien vers `attribution` (apres creation) | Attributions du module |
| 7 | `module_heures_prevues_active` | Formula | `if(field('module_statut') = 'annule', 0, field('module_heures_prevues'))` | Pour rollup bloc (exclut annule) |
| 8 | `module_heures_attribuees` | Formula | `sum(lookup('module_attributions', 'attribution_heures_attribuees_active'))` | Rollup |
| 9 | `module_heures_realisees` | Formula | `sum(lookup('module_attributions', 'attribution_heures_realisees'))` | Rollup |
**Vues** :
- `Tous` (grid)
- **`A attribuer`** (kanban, group by `module_statut`) — vue principale pour l'admin
- `Par bloc` (grid, group by `module_bloc`)
- `Realises` (grid, filtre `module_statut = realise`)
---
## 6. Table `attribution`
**Primary field** : `attribution_titre` (formula : nom_module + " → " + nom_personne)
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `attribution_titre` | Formula | `concat(lookup('attribution_module', 'module_nom'), ' → ', lookup('attribution_personne', 'personne_prenom'), ' ', lookup('attribution_personne', 'personne_nom'))` | Titre auto (Primary) |
| 2 | `attribution_module` | Link to table | Lien vers `module` (single) | Module attribue |
| 3 | `attribution_personne` | Link to table | Lien vers `personne` (single) | Formateur (role formateur requis) |
| 4 | `attribution_heures_attribuees` | Number | Decimal places: 2 | Heures planifiees |
| 5 | `attribution_heures_realisees` | Number | Decimal places: 2, default 0 | Heures effectuees |
| 6 | `attribution_date_debut` | Date | — | Debut periode |
| 7 | `attribution_date_fin` | Date | — | Fin periode |
| 8 | `attribution_statut` | Single select | `planifie` (default), `en_cours`, `realise`, `annule` | Statut |
| 9 | `attribution_heures_attribuees_active` | Formula | `if(field('attribution_statut') = 'annule', 0, field('attribution_heures_attribuees'))` | Pour rollup module/personne |
**Validation cote bridge** :
- `attribution_personne.personne_roles` doit contenir `formateur`
- `sum(attribution_heures_attribuees) for module <= module_heures_prevues` (RG-01)
**Vues** :
- `Tous` (grid)
- **`Mes attributions`** (grid, filtre `attribution_personne = current user`) — vue formateur
- `En cours` (grid, filtre `attribution_statut = en_cours`)
- `Calendrier` (calendar view sur `attribution_date_debut` ou `attribution_date_fin`)
- `Form public` (form view) — formateur saisit ses heures realisees rapide
---
## 7. Table `client`
**Primary field** : `client_nom`
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `client_nom` | Text | — | Nom (Primary) |
| 2 | `client_contact_principal` | Text | — | Nom + role du contact |
| 3 | `client_contact_email` | Email | — | Email contact |
| 4 | `client_contact_telephone` | Phone number | — | Telephone |
| 5 | `client_secteur` | Text | — | Secteur d'activite |
| 6 | `client_notes` | Long text | — | Notes libres |
| 7 | `client_statut` | Single select | `prospect` (default), `actif`, `inactif`, `archive` | Statut |
| 8 | `client_projets` | Link to table | Lien vers `projet` | Projets du client |
| 9 | `client_created_at` | Created on | — | Audit |
**Vues** :
- `Tous` (grid)
- `Actifs` (grid, filtre `client_statut = actif`)
- **`Pipeline`** (kanban, group by `client_statut`) — vue commerciale
---
## 8. Table `projet`
**Primary field** : `projet_nom`
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `projet_nom` | Text | — | Nom (Primary) |
| 2 | `projet_description` | Long text | — | Description |
| 3 | `projet_client` | Link to table | Lien vers `client` (single) | Client |
| 4 | `projet_type` | Single select | `site_web`, `app_mobile`, `api`, `infra`, `audit`, `support`, `autre` | Type |
| 5 | `projet_charge_heures` | Number | Decimal places: 2 | Charge estimee |
| 6 | `projet_date_debut` | Date | — | Date debut |
| 7 | `projet_date_fin_prevue` | Date | — | Date fin prevue |
| 8 | `projet_date_livraison` | Date | — | Date livraison effective |
| 9 | `projet_statut` | Single select | `devis` (default), `en_cours`, `livre`, `cloture`, `abandonne` | Statut |
| 10 | `projet_formation_pedagogique` | Link to table | Lien vers `formation` (single, optionnel) | Lien pedagogique |
| 11 | `projet_url` | URL | — | Site livraison |
| 12 | `projet_repository` | URL | — | Repo Git |
| 13 | `projet_taches` | Link to table | Lien vers `tache` | Taches du projet |
| 14 | `projet_heures_attribuees` | Formula | `sum(lookup('projet_taches', 'tache_charge_heures'))` | Rollup taches |
| 15 | `projet_heures_realisees` | Formula | `sum(lookup('projet_taches', 'tache_heures_realisees'))` | Rollup |
| 16 | `projet_heures_restantes` | Formula | `field('projet_charge_heures') - field('projet_heures_realisees')` | Reste a faire |
**Vues** :
- `Tous` (grid)
- **`Pipeline`** (kanban, group by `projet_statut`) — vue principale
- `En cours` (grid, filtre `projet_statut = en_cours`)
- `Timeline` (timeline view sur date_debut → date_fin_prevue)
- `Par client` (grid, group by `projet_client`)
---
## 9. Table `tache`
**Primary field** : `tache_titre`
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `tache_titre` | Text | — | Titre (Primary) |
| 2 | `tache_description` | Long text | — | Description |
| 3 | `tache_projet` | Link to table | Lien vers `projet` (single) | Projet parent |
| 4 | `tache_charge_heures` | Number | Decimal places: 2 | Charge estimee |
| 5 | `tache_priorite` | Single select | `faible`, `normale`, `haute`, `critique` | Priorite |
| 6 | `tache_statut` | Single select | `todo` (default), `in_progress`, `review`, `done`, `abandoned` | Statut |
| 7 | `tache_date_debut` | Date | — | Debut prevu |
| 8 | `tache_date_fin_prevue` | Date | — | Fin prevue |
| 9 | `tache_assignee` | Link to table | Lien vers `personne` (single, optionnel) | Dev assignee informellement |
| 10 | `tache_interventions` | Link to table | Lien vers `intervention` | Interventions sur cette tache |
| 11 | `tache_heures_realisees` | Formula | `sum(lookup('tache_interventions', 'intervention_heures_active'))` | Rollup |
**Vues** :
- `Tous` (grid)
- **`Kanban`** (kanban, group by `tache_statut`) — vue principale
- `Par priorite` (grid, group by `tache_priorite`, sort par priorite)
- `Mes taches` (grid, filtre `tache_assignee = current user`)
- `Done recentes` (grid, filtre `tache_statut = done`, sort par date desc)
---
## 10. Table `intervention`
**Primary field** : `intervention_titre` (formula auto)
| # | Field | Type Baserow | Parametres | Description |
|---|-------|-------------|------------|-------------|
| 1 | `intervention_titre` | Formula | `concat(lookup('intervention_tache', 'tache_titre'), ' - ', lookup('intervention_personne', 'personne_prenom'), ' (', totext(field('intervention_date')), ')')` | Titre auto (Primary) |
| 2 | `intervention_tache` | Link to table | Lien vers `tache` (single) | Tache concernee |
| 3 | `intervention_personne` | Link to table | Lien vers `personne` (single) | Developpeur (role developpeur requis) |
| 4 | `intervention_heures` | Number | Decimal places: 2 | Heures effectuees |
| 5 | `intervention_date` | Date | default today | Date intervention |
| 6 | `intervention_notes` | Long text | — | Notes / commit ref / lien PR |
| 7 | `intervention_statut` | Single select | `planifie`, `realise` (default), `annule` | Statut |
| 8 | `intervention_heures_active` | Formula | `if(field('intervention_statut') = 'annule', 0, field('intervention_heures'))` | Pour rollup tache/personne |
**Validation cote bridge** :
- `intervention_personne.personne_roles` doit contenir `developpeur`
- `intervention_heures > 0`
**Vues** :
- `Tous` (grid, sort `intervention_date desc`)
- **`Mes interventions`** (grid, filtre `intervention_personne = current user`) — vue dev
- **`Form rapide`** (form public) — saisie heures rapide mobile
- `Par projet` (grid, group by `intervention_tache.tache_projet`)
- `Cette semaine` (grid, filtre `intervention_date >= start_of_week`)
---
## 11. Permissions et sharing
### 11.1 Roles Baserow
| Role | Membres | Capacites |
|------|---------|-----------|
| Admin workspace | Corentin, Yan, Ludo | Plein controle |
| Editor | Sophie + autres admins | Read/write toutes tables |
| Builder | Formateurs / Devs | Read/write **leur ligne** via vues filtrees + form rapide |
| Viewer | Stakeholders ponctuels | Read seul |
**Limitation** : Baserow native permissions sont au niveau database/table, pas row-level. Pour limiter formateur/dev a leurs propres rows :
- Soit **vues filtrees partagees publiquement** (form pour saisie + grid filtree pour lecture)
- Soit **bridge service** qui filtre cote API selon `current_user_id`
### 11.2 Forms publics pour saisie rapide
Plus simple que de gerer les permissions row-level :
- Form public sur `attribution` — formateur saisit ses heures via lien sans compte Baserow
- Form public sur `intervention` — dev saisit son intervention idem
Le user qui saisit n'a pas besoin de voir le reste des donnees, juste son formulaire.
---
## 12. Webhooks (Phase 2 — bridge integration)
Configurer dans Baserow → Database Settings → Webhooks :
| Evenement | URL cible | Usage bridge |
|-----------|-----------|--------------|
| `row.created` sur `attribution` | `https://bridge.acadenice.fr/webhooks/attribution-created` | Notif formateur, recalcul cache mention |
| `row.updated` sur `attribution` | `https://bridge.acadenice.fr/webhooks/attribution-updated` | Recalcul cache, notif si statut change |
| `row.created` sur `intervention` | `https://bridge.acadenice.fr/webhooks/intervention-created` | Notif admin si depassement capacite |
| `row.updated` sur `module` (si `module_statut` change) | `https://bridge.acadenice.fr/webhooks/module-status-changed` | Trigger cloturer formation auto |
Authentification webhook : header `X-Bridge-Token` avec un secret partage (`.env`).
---
## 13. Seed data initial
Apres creation des 9 tables, seed avec :
- **personne** : equipe Acadenice (Yan, Corentin, Ludo, Sophie + formateurs intervenants)
- **client** : 1-2 clients existants (Centralis Europe + autre)
- **formation** : les 5 filieres en cours pour 2026-2027
- **bloc** : decoupage RNCP par filiere
- **module** : programme detaille
- **projet** : projets clients en cours
- Pas d'attribution / intervention seed — saisies par l'usage
Script de seed : `baserow/seed/seed.py` (a coder Phase 1).
---
## 14. Validation post-creation
Checklist apres creation des 9 tables :
- [ ] Les 9 tables existent dans la database `formation-hub`
- [ ] Toutes les FK sont liees correctement (verifier en cliquant sur un lien dans une row)
- [ ] Les rollups fonctionnent (creer une row test, verifier le calcul de `formation_heures_attribuees`)
- [ ] Les formulas s'evaluent sans erreur (regarder les rows test)
- [ ] Les Single Select / Multiple Select ont les bonnes options
- [ ] Au moins une vue par table est creee (grid `Tous` minimum)
- [ ] Les vues kanban (module, projet, tache, client) sont fonctionnelles
- [ ] Le form public pour saisie heures (attribution, intervention) marche
- [ ] L'API token est genere et fonctionne (test `curl`)
```bash
# Test API token
curl -H "Authorization: Token $BASEROW_API_TOKEN" \
"$BASEROW_URL/api/database/rows/table/<TABLE_ID>/?user_field_names=true"
```
---
## 15. Notes d'implementation
### 15.1 Limitations Baserow connues
- **Pas de FK `ON DELETE` configurable** : c'est `SET NULL` par defaut quand le lien est rompu. Pour forcer un comportement CASCADE/RESTRICT, le bridge service doit l'implementer (ou un workflow Baserow).
- **Pas de CHECK constraint** : validation cote bridge ou cote UI.
- **Pas d'index custom** : Baserow indexe automatiquement les Link to table et les Primary fields.
- **Formules limitees** : pas de boucles ni de subqueries complexes. Pour calculs lourds, calcul cote bridge + ecriture en batch.
### 15.2 Alternatives si Baserow limite
Si une formule devient trop complexe ou si on a besoin de validation forte :
- Option A : **Bridge fait le calcul** et ecrit en Baserow via API
- Option B : **Vue filtree dediee** + formula simple
- Option C : **Migration vers Postgres direct** (futur — si on perd Baserow)
### 15.3 Migration data initiale
Si donnees existent dans Excel/Trello/autre :
1. Exporter en CSV
2. Mapper les colonnes vers les fields Baserow
3. Importer via Baserow UI (`Import data`)
4. Verifier les liens FK manuellement (Baserow ne mappe pas auto les liens via CSV)
---
## 16. Resume — checklist d'implementation Phase 1
```
[ ] 1. Setup workspace + database
[ ] 2. Creer table 'personne' (sans liens encore)
[ ] 3. Creer table 'client' (sans liens encore)
[ ] 4. Creer table 'formation' (sans liens encore)
[ ] 5. Creer table 'bloc' + lien vers formation
[ ] 6. Creer table 'projet' + lien vers client (+ optionnel formation)
[ ] 7. Creer table 'module' + lien vers bloc
[ ] 8. Creer table 'tache' + lien vers projet (+ optionnel personne assignee)
[ ] 9. Creer table 'attribution' + liens vers module + personne
[ ] 10. Creer table 'intervention' + liens vers tache + personne
[ ] 11. Ajouter formulas et lookups (apres tous les liens crees)
[ ] 12. Creer vues recommandees par table
[ ] 13. Configurer permissions roles + sharing
[ ] 14. Seed data initial
[ ] 15. Generer API token + verifier
[ ] 16. Documenter exports JSON dans `baserow/schemas/`
```
Apres ca : la base structurelle est en place. La saisie metier peut commencer **immediat** — sans attendre Phase 2 / bridge.