From 2ed73fa948cf8c9f153be5565c3d8761be486f41 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 22:12:32 +0200 Subject: [PATCH] feat(bridge): R1 refactor proxy generique style Notion Pivot strategique : DocAdenice = produit Notion-like generique. Le bridge est livre vide a un user qui cree ses tables Baserow comme il veut. Code sans aucune ontologie metier. Suppressions : - 9 entites domain metier (Personne, Formation, Bloc, Module, Attribution, Client, Projet, Tache, Intervention) + types.ts (Role, statuts) - baserow-repo.ts (mega-fichier 554 LOC avec 9 repos heritant BaseRepo) - 6 routes metier (personnes, formations, projets, modules, interventions, attributions) + tests associes - Lookup PersonneRepo.findByEmail dans middleware auth - Mapping DEFAULT_ROLE_SCOPES dans middleware/scopes.ts - Cascade rollup metier dans webhooks/baserow-handler.ts Ajouts : - Domain generique : Table, Row, Field, View + schemas zod refondus - 4 repos generiques : tables / rows / fields / views - Route unique routes/tables.ts avec 9 endpoints REST CRUD generiques - Claim JWT acadenice_permissions[] lu directement dans le middleware auth (alimente par RBAC dynamique cote DocAdenice en R2) - examples/acadenice-formation-hub/ : README + seed-baserow.md schema 9 tables + example-roles.md (Formateur, Developpeur, Direction, Support, Admin avec permissions generiques) Refactors : - BaserowClient etendu : listTables, getTable, listFields, listViews, getGridViewRows - middleware/auth.ts : extractPermissions(payload), AuthenticatedUser remplace roles[] par permissions[] - middleware/scopes.ts : computeOidcScopes(groups, permissions, map) - webhooks/baserow-handler.ts : invalidation generique bridge:tables::* sans cascade cross-table - lib/cache.ts : invalidateEntity -> invalidateTable(redis, tableId, rowId?) - container.ts : drop tableIds, RepoSet={tables, rows, fields, views} - 501 NOT_IMPLEMENTED si DB token sur endpoints /tables qui exigent JWT Tests : 250/250 verts (depuis 319). Coverage : domain 98.9%, adapters 89%, auth 97.08%, rate-limit 100%, cache 100%, webhooks 100%. Quality gates verts : typecheck, lint biome, vitest, coverage thresholds. Refs: R1 dans le pivot strategique DocAdenice Notion-like generique. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fast-app/formation-hub/SESSION-RESUME.md | 104 +++- bridge/.env.example | 21 +- bridge/src/adapters/baserow-client.ts | 71 ++- bridge/src/domain/attribution.ts | 82 --- bridge/src/domain/bloc.ts | 55 -- bridge/src/domain/client.ts | 61 -- bridge/src/domain/field.ts | 32 + bridge/src/domain/formation.ts | 76 --- bridge/src/domain/index.ts | 27 +- bridge/src/domain/intervention.ts | 46 -- bridge/src/domain/module.ts | 123 ---- bridge/src/domain/personne.ts | 165 ------ bridge/src/domain/projet.ts | 90 --- bridge/src/domain/row.ts | 36 ++ bridge/src/domain/schemas.ts | 170 ++---- bridge/src/domain/table.ts | 34 ++ bridge/src/domain/tache.ts | 90 --- bridge/src/domain/types.ts | 39 -- bridge/src/domain/view.ts | 29 + bridge/src/index.ts | 41 +- bridge/src/lib/cache.ts | 77 +-- bridge/src/lib/config.ts | 4 - bridge/src/lib/container.ts | 51 +- bridge/src/middleware/auth.ts | 140 ++--- bridge/src/middleware/scopes.ts | 44 +- bridge/src/repos/baserow-fields-repo.ts | 42 ++ bridge/src/repos/baserow-repo.ts | 554 ------------------ bridge/src/repos/baserow-rows-repo.ts | 114 ++++ bridge/src/repos/baserow-tables-repo.ts | 50 ++ bridge/src/repos/baserow-views-repo.ts | 77 +++ bridge/src/routes/attributions.ts | 57 -- bridge/src/routes/formations.ts | 89 --- bridge/src/routes/interventions.ts | 82 --- bridge/src/routes/modules.ts | 106 ---- bridge/src/routes/personnes.ts | 111 ---- bridge/src/routes/projets.ts | 75 --- bridge/src/routes/tables.ts | 241 ++++++++ bridge/src/routes/webhooks.ts | 6 +- bridge/src/webhooks/baserow-handler.ts | 87 +-- bridge/tests/domain/attribution.test.ts | 110 ---- bridge/tests/domain/bloc.test.ts | 42 -- bridge/tests/domain/client.test.ts | 36 -- bridge/tests/domain/field.test.ts | 30 + bridge/tests/domain/formation.test.ts | 66 --- bridge/tests/domain/intervention.test.ts | 43 -- bridge/tests/domain/module.test.ts | 150 ----- bridge/tests/domain/personne.test.ts | 177 ------ bridge/tests/domain/projet.test.ts | 112 ---- bridge/tests/domain/row.test.ts | 49 ++ bridge/tests/domain/schemas.test.ts | 212 +++---- bridge/tests/domain/table.test.ts | 28 + bridge/tests/domain/tache.test.ts | 121 ---- bridge/tests/domain/view.test.ts | 17 + bridge/tests/helpers/fake-repos.ts | 143 ----- bridge/tests/helpers/fixtures.ts | 101 ---- bridge/tests/helpers/test-app.ts | 65 +- .../tests/integration/rate-limit-app.test.ts | 127 ++-- bridge/tests/middleware/auth.test.ts | 323 ++++------ bridge/tests/middleware/rate-limit.test.ts | 18 +- bridge/tests/middleware/scopes.test.ts | 44 +- .../tests/repos/baserow-fields-repo.test.ts | 51 ++ bridge/tests/repos/baserow-repo.test.ts | 307 ---------- bridge/tests/repos/baserow-rows-repo.test.ts | 133 +++++ .../tests/repos/baserow-tables-repo.test.ts | 51 ++ bridge/tests/repos/baserow-views-repo.test.ts | 62 ++ bridge/tests/routes/attributions.test.ts | 74 --- bridge/tests/routes/formations.test.ts | 70 --- bridge/tests/routes/interventions.test.ts | 88 --- bridge/tests/routes/modules.test.ts | 100 ---- bridge/tests/routes/personnes.test.ts | 94 --- bridge/tests/routes/projets.test.ts | 62 -- bridge/tests/routes/tables.test.ts | 525 +++++++++++++++++ bridge/tests/unit/cache.test.ts | 150 ++--- bridge/tests/unit/config.test.ts | 48 ++ bridge/tests/unit/errors.test.ts | 70 +++ bridge/tests/unit/http.test.ts | 108 ++++ bridge/tests/webhooks/baserow-handler.test.ts | 153 ++--- bridge/tests/webhooks/routes.test.ts | 43 +- examples/acadenice-formation-hub/README.md | 59 ++ .../acadenice-formation-hub/example-roles.md | 94 +++ .../acadenice-formation-hub/seed-baserow.md | 157 +++++ 81 files changed, 2927 insertions(+), 4985 deletions(-) delete mode 100644 bridge/src/domain/attribution.ts delete mode 100644 bridge/src/domain/bloc.ts delete mode 100644 bridge/src/domain/client.ts create mode 100644 bridge/src/domain/field.ts delete mode 100644 bridge/src/domain/formation.ts delete mode 100644 bridge/src/domain/intervention.ts delete mode 100644 bridge/src/domain/module.ts delete mode 100644 bridge/src/domain/personne.ts delete mode 100644 bridge/src/domain/projet.ts create mode 100644 bridge/src/domain/row.ts create mode 100644 bridge/src/domain/table.ts delete mode 100644 bridge/src/domain/tache.ts delete mode 100644 bridge/src/domain/types.ts create mode 100644 bridge/src/domain/view.ts create mode 100644 bridge/src/repos/baserow-fields-repo.ts delete mode 100644 bridge/src/repos/baserow-repo.ts create mode 100644 bridge/src/repos/baserow-rows-repo.ts create mode 100644 bridge/src/repos/baserow-tables-repo.ts create mode 100644 bridge/src/repos/baserow-views-repo.ts delete mode 100644 bridge/src/routes/attributions.ts delete mode 100644 bridge/src/routes/formations.ts delete mode 100644 bridge/src/routes/interventions.ts delete mode 100644 bridge/src/routes/modules.ts delete mode 100644 bridge/src/routes/personnes.ts delete mode 100644 bridge/src/routes/projets.ts create mode 100644 bridge/src/routes/tables.ts delete mode 100644 bridge/tests/domain/attribution.test.ts delete mode 100644 bridge/tests/domain/bloc.test.ts delete mode 100644 bridge/tests/domain/client.test.ts create mode 100644 bridge/tests/domain/field.test.ts delete mode 100644 bridge/tests/domain/formation.test.ts delete mode 100644 bridge/tests/domain/intervention.test.ts delete mode 100644 bridge/tests/domain/module.test.ts delete mode 100644 bridge/tests/domain/personne.test.ts delete mode 100644 bridge/tests/domain/projet.test.ts create mode 100644 bridge/tests/domain/row.test.ts create mode 100644 bridge/tests/domain/table.test.ts delete mode 100644 bridge/tests/domain/tache.test.ts create mode 100644 bridge/tests/domain/view.test.ts delete mode 100644 bridge/tests/helpers/fake-repos.ts delete mode 100644 bridge/tests/helpers/fixtures.ts create mode 100644 bridge/tests/repos/baserow-fields-repo.test.ts delete mode 100644 bridge/tests/repos/baserow-repo.test.ts create mode 100644 bridge/tests/repos/baserow-rows-repo.test.ts create mode 100644 bridge/tests/repos/baserow-tables-repo.test.ts create mode 100644 bridge/tests/repos/baserow-views-repo.test.ts delete mode 100644 bridge/tests/routes/attributions.test.ts delete mode 100644 bridge/tests/routes/formations.test.ts delete mode 100644 bridge/tests/routes/interventions.test.ts delete mode 100644 bridge/tests/routes/modules.test.ts delete mode 100644 bridge/tests/routes/personnes.test.ts delete mode 100644 bridge/tests/routes/projets.test.ts create mode 100644 bridge/tests/routes/tables.test.ts create mode 100644 bridge/tests/unit/config.test.ts create mode 100644 bridge/tests/unit/errors.test.ts create mode 100644 bridge/tests/unit/http.test.ts create mode 100644 examples/acadenice-formation-hub/README.md create mode 100644 examples/acadenice-formation-hub/example-roles.md create mode 100644 examples/acadenice-formation-hub/seed-baserow.md diff --git a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md index 7d393d9..b824f53 100644 --- a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md +++ b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md @@ -1,6 +1,106 @@ -# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 5) +# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 R1 refactor) -## CHANGELOG depuis derniere update (Bloc 5 — rate limit + cache invalidation cote writes) +## Vision — DocAdenice = Notion-like generique + +**Pivot strategique 2026-05-07** : DocAdenice n'est plus un outil metier +formation-hub mais un **produit Notion-like generique**. Le bridge est livre +vide a un user (admin), qui cree ses tables Baserow comme il veut via UI ou +API. Le code n'a aucune ontologie metier. + +Composants cibles : +- **Pages / Spaces** : Docmost reskin (DocAdenice fork) + spaces hierarchiques +- **Databases custom** : tables Baserow exposees via le bridge `/api/v1/tables/*` +- **RBAC dynamique** : roles custom declares cote DocAdenice qui projettent + dans le JWT le claim `acadenice_permissions[]` +- **Bidirec backlinks** : pages <-> rows + mentions cross-page (R3) +- **Slash commands custom** : Tiptap node-views (R3) +- **Dual editor** : edition wiki Docmost + edition table Baserow inline (R3) + +Le metier formation-hub (CFA + Agence Acadenice) devient un **exemple parmi +d'autres** : `examples/acadenice-formation-hub/`. + +## CHANGELOG depuis derniere update (R1 — refactor proxy generique style Notion) + +- **R1 livre (suppression domain metier + bridge generique)** : + - Supprime tout le metier formation-hub du bridge : + - 9 entites domain (Personne, Formation, Bloc, Module, Attribution, Client, Projet, Tache, Intervention) + - types.ts (Role, StatutPersonne, etc.) + - baserow-repo.ts (le mega-fichier 554 LOC avec 9 repos heritant de BaseRepo) + - 6 routes metier (personnes, formations, projets, modules, interventions, attributions) + - Tous les tests metier correspondants (10 tests domain + 6 tests routes + 1 test repo + 1 test rate-limit-app metier) + - Cree le domain generique : + - `domain/table.ts` (Table : id, name, databaseId, fields[], orderIndex) + - `domain/row.ts` (Row : id, tableId, fields opaque, createdOn, updatedOn, order) + - `domain/field.ts` (Field : id, name, type libre, primary, options nullable) + - `domain/view.ts` (View : id, name, type, tableId) + - `domain/schemas.ts` refonte zod (TableSchema, RowSchema, FieldSchema, ViewSchema, RowFieldsSchema permissif) + - Cree les repos generiques : + - `repos/baserow-tables-repo.ts` (list/get tables — JWT requis upstream) + - `repos/baserow-rows-repo.ts` (CRUD rows par tableId — DB token OK) + - `repos/baserow-fields-repo.ts` (list fields par tableId — DB token OK) + - `repos/baserow-views-repo.ts` (list views + runGrid — DB token OK) + - Cree la route generique unique : + - `routes/tables.ts` avec 9 endpoints REST : + `GET /tables`, `GET /tables/:id` (+ fields embarques), + `GET /tables/:id/fields`, `GET /tables/:id/views`, + `GET /tables/:id/views/:viewId/rows`, + `GET /tables/:id/rows`, `GET /tables/:id/rows/:rowId`, + `POST /tables/:id/rows`, `PATCH /tables/:id/rows/:rowId`, + `DELETE /tables/:id/rows/:rowId` + - Scopes generiques : `read:tables`, `write:tables`, `admin:*` + - 501 NOT_IMPLEMENTED si DB token sur endpoint qui exige JWT (list/get tables metadata) + - Etendu `BaserowClient` : `listTables`, `getTable`, `listFields`, `listViews`, `getGridViewRows` + - Refactor `middleware/auth.ts` : + - Supprime entierement le lookup `personneRepo.findByEmail` + cache Personne par email + - Supprime `strictMapping` (plus de notion d'email orphelin) + - Lit le claim JWT `acadenice_permissions[]` directement dans `extractPermissions(payload)` + - `AuthenticatedUser.scopes` = union (groups -> scopes) + (permissions claim) + - Plus de `roles[]` dans `AuthenticatedUser` — remplace par `permissions[]` + - Refactor `middleware/scopes.ts` : + - Supprime `DEFAULT_ROLE_SCOPES` (plus de mapping role formation-hub) + - `computeOidcScopes(groups, permissions, groupsMap)` — la signature change + - Refactor `webhooks/baserow-handler.ts` : + - Plus de cascade rollup metier (attribution -> module + personne, etc.) + - Pour chaque event Baserow sur `tableX` : invalide uniquement + `bridge:tables::list:*`, `bridge:tables::views:*`, + `bridge:tables::row:` (si update/delete) + - Si l'utilisateur veut des cascades cross-table, il les pose en formules/lookups Baserow + qui emettent leurs propres webhooks naturellement + - Refactor `lib/cache.ts` : + - `invalidateEntity(redis, entity, id?)` -> `invalidateTable(redis, tableId, rowId?)` + - Patterns : `bridge:tables::*` (plus de pattern par entite metier) + - Refactor container : + - Supprime `tableIds` field (plus de mapping name->id metier) + - `RepoSet` = `{ tables, rows, fields, views }` (4 repos generiques) + - Supprime `pickTableIds` + `resolveTableIds` au boot (plus necessaire) + - Refactor config : + - Supprime `authStrictMapping` (plus de Personne lookup) + - `BASEROW_TABLE_IDS` env retire (plus de mapping metier) + - `.env.example` reecrit : scopes generiques, plus de mention formation-hub + - Sortie metier vers exemples : cree `examples/acadenice-formation-hub/` + avec README.md, seed-baserow.md (schema 9 tables markdown), example-roles.md + (Formateur, Developpeur, Direction, Support, Admin avec permissions generiques) + - Tests : 250/250 verts (depuis 319/319). 33 tests metier supprimes ; 33 tests + generiques ajoutes (4 domain : table/row/field/view, 4 repos generiques, + 19 routes /tables, edge cases, errors helpers, http helpers, isOidcEnabled). + - Coverage globale : 89.54% lines / 92.42% branches. + - domain/** : 98.9% lines / 93.75% branches (>= 80% ✓) + - adapters/** : 89.04% lines / 95.04% branches (>= 70% ✓) + - middleware/auth.ts : 97.08% lines / 92% branches (>= 85% ✓) + - middleware/rate-limit.ts : 100% (>= 85% ✓) + - lib/cache.ts : 100% (>= 85% ✓) + - webhooks/** : 100% (>= 80% ✓) + - Quality gates verts : `typecheck`, `lint`, `test`, `test:coverage`. + +## Status R1/R2/R3 + +| Bloc | Status | Detail | +|------|--------|--------| +| **R1 — Bridge refactor proxy generique style Notion** | DONE | Suppression domain metier + nouvelles routes /api/v1/tables/* | +| R2 — RBAC dynamique cote DocAdenice (claim `acadenice_permissions[]`) | TODO | docmost-fork-dev | +| R3 — Bidirec backlinks + slash commands + dual editor | TODO | Phase 3 | + +## CHANGELOG anterieur (Bloc 5 — rate limit + cache invalidation cote writes) - **Bloc 5 livre (rate limit defensif + invalidation cache writes)** : - Nouveau module `src/middleware/rate-limit.ts` : middleware Hono autour de `RedisCache.checkRateLimit` (sliding window deja teste integration). Cle derivee de l'identite avec priorites : `tokenId` (service token) > `email` OIDC (lower-cased) > `sub` OIDC > IP via `x-forwarded-for` (avec WARN log car spoofable) > `anonymous`. Throw `errors.rateLimited(windowSeconds)` avec headers `X-RateLimit-Limit/Remaining/Reset`. Helper exporte `defaultRateLimitKey` pour composer (`${default}:mut`). diff --git a/bridge/.env.example b/bridge/.env.example index 12d1e81..44a0614 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -10,11 +10,11 @@ LOG_LEVEL=debug BASEROW_API_URL=http://baserow:80/api BASEROW_API_TOKEN= -# Docmost API -DOCMOST_API_URL=http://docmost:3000/api -DOCMOST_API_TOKEN= +# Docmost API (optionnel — pas utilise par le bridge generique R1) +# DOCMOST_API_URL=http://docmost:3000/api +# DOCMOST_API_TOKEN= -# Redis (cache + idempotence webhooks + lookup Personne) +# Redis (cache + idempotence webhooks + rate limit) REDIS_URL=redis://docmost-redis:6379 # Webhooks Baserow signature secret (HMAC-SHA256, header X-Baserow-Signature) @@ -22,11 +22,11 @@ BASEROW_WEBHOOK_SECRET= # Webhooks Docmost signature secret (HMAC-SHA256, header X-Docmost-Signature) # Stub Bloc 7b — handlers metier viennent en Bloc 8 (Tiptap node-views) -DOCMOST_WEBHOOK_SECRET= +# DOCMOST_WEBHOOK_SECRET= # Auth tokens bridge — JSON serialise (Phase 2 simple) -# Format: [{"token":"brg_xxx","name":"label","scopes":["read:personnes",...]}] -# Phase 3 : migration vers DB dediee +# Format: [{"token":"brg_xxx","name":"label","scopes":["read:tables",...]}] +# Scopes generiques R1 : read:tables, write:tables, admin:* BRIDGE_API_TOKENS= # Authentik OIDC (optional — laisse vide pour mode local-only avec service tokens) @@ -34,8 +34,11 @@ BRIDGE_API_TOKENS= # AUTHENTIK_ISSUER=https://auth.acadenice.com/application/o/formation-hub/ # AUTHENTIK_JWKS_URI=https://auth.acadenice.com/application/o/formation-hub/jwks/ # AUTHENTIK_AUDIENCE=formation-hub-bridge -# AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]} -# AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement) +# Mapping group Authentik -> scopes bridge (optionnel). +# AUTH_GROUPS_SCOPES_MAP={"acadenice-admins":["admin:*"],"acadenice-formateurs":["read:tables","write:tables"]} +# +# R1 generique : le bridge lit aussi le claim JWT `acadenice_permissions[]` +# qui alimente directement les scopes (alimente cote DocAdenice par le RBAC R2). # Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/* # (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense). diff --git a/bridge/src/adapters/baserow-client.ts b/bridge/src/adapters/baserow-client.ts index e5bb595..5336162 100644 --- a/bridge/src/adapters/baserow-client.ts +++ b/bridge/src/adapters/baserow-client.ts @@ -131,7 +131,9 @@ export class BaserowClient { /** * Resoud le mapping table_name → table_id pour la database. - * Utilise par le bridge au boot pour eviter de coder les ids en dur. + * REQUIERT un JWT user — un DB token (`Token brg_*`) ne peut PAS appeler + * cette route. Si l'appel echoue avec 401, on renvoie une erreur claire. + * Pour DB token, configurer manuellement les ids cote consumer. */ async resolveTableIds(databaseId: number): Promise> { const tables = await this.fetch>( @@ -140,6 +142,73 @@ export class BaserowClient { return Object.fromEntries(tables.map((t) => [t.name, t.id])); } + /** + * Liste les tables d'une database. Comme `resolveTableIds` requiert un JWT + * user — DB token tombera sur 401. Le caller doit gerer ce cas (renvoyer + * 501 NOT_IMPLEMENTED si DB token). + */ + async listTables( + databaseId: number, + ): Promise> { + return this.fetch>( + `/api/database/tables/database/${databaseId}/`, + ); + } + + /** Metadata d'une table. Necessite un JWT user. */ + async getTable( + tableId: number, + ): Promise<{ id: number; name: string; order: number; database_id: number }> { + return this.fetch<{ id: number; name: string; order: number; database_id: number }>( + `/api/database/tables/${tableId}/`, + ); + } + + /** + * Liste les fields (colonnes) d'une table. DB token OK. + */ + async listFields( + tableId: number, + ): Promise< + Array<{ id: number; name: string; type: string; primary?: boolean } & Record> + > { + return this.fetch< + Array<{ id: number; name: string; type: string; primary?: boolean } & Record> + >(`/api/database/fields/table/${tableId}/`); + } + + /** + * Liste les vues d'une table. DB token OK. + */ + async listViews( + tableId: number, + ): Promise< + Array<{ id: number; name: string; type: string; table_id: number } & Record> + > { + return this.fetch< + Array<{ id: number; name: string; type: string; table_id: number } & Record> + >(`/api/database/views/table/${tableId}/`); + } + + /** + * Recupere les rows d'une grid view (filtres + sorts pre-definis cote + * Baserow). Pagination identique a `listRows`. + */ + async getGridViewRows( + viewId: number, + opts: BaserowListOptions = {}, + ): Promise { + const params: Record = { + user_field_names: String(opts.userFieldNames ?? true), + size: String(opts.size ?? 100), + page: String(opts.page ?? 1), + }; + if (opts.search) params.search = opts.search; + if (opts.orderBy) params.order_by = opts.orderBy; + const query = new URLSearchParams(params).toString(); + return this.fetch(`/api/database/views/grid/${viewId}/?${query}`); + } + async healthCheck(): Promise { try { await ofetch(`${this.baseUrl}/api/_health/`, { timeout: 3000 }); diff --git a/bridge/src/domain/attribution.ts b/bridge/src/domain/attribution.ts deleted file mode 100644 index f4a69c8..0000000 --- a/bridge/src/domain/attribution.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Decimal } from 'decimal.js'; -import type { StatutAttribution } from './types.js'; - -export interface AttributionProps { - id: number; - moduleId: number; - personneId: number; - heuresAttribuees: Decimal; - heuresRealisees?: Decimal; - dateDebut?: Date | null; - dateFin?: Date | null; - statut?: StatutAttribution; -} - -const ZERO = new Decimal(0); - -export class Attribution { - public readonly id: number; - public readonly moduleId: number; - public readonly personneId: number; - public heuresAttribuees: Decimal; - public heuresRealisees: Decimal; - public dateDebut: Date | null; - public dateFin: Date | null; - public statut: StatutAttribution; - - constructor(props: AttributionProps) { - if (props.heuresAttribuees.lte(0)) { - throw new Error('heuresAttribuees doit etre > 0'); - } - if (props.heuresRealisees?.lt(0)) { - throw new Error('heuresRealisees doit etre >= 0'); - } - if (props.dateDebut && props.dateFin && props.dateFin < props.dateDebut) { - throw new Error('dateFin doit etre >= dateDebut'); - } - - this.id = props.id; - this.moduleId = props.moduleId; - this.personneId = props.personneId; - this.heuresAttribuees = props.heuresAttribuees; - this.heuresRealisees = props.heuresRealisees ?? ZERO; - this.dateDebut = props.dateDebut ?? null; - this.dateFin = props.dateFin ?? null; - this.statut = props.statut ?? 'planifie'; - } - - isActive(): boolean { - return this.statut === 'planifie' || this.statut === 'en_cours'; - } - - demarrer(): void { - if (this.statut !== 'planifie') { - throw new Error(`Transition invalide ${this.statut} -> en_cours`); - } - this.statut = 'en_cours'; - } - - saisirHeuresRealisees(heures: Decimal): void { - if (this.statut === 'annule' || this.statut === 'realise') { - throw new Error(`Saisie heures impossible sur attribution ${this.statut}`); - } - if (heures.lt(0)) { - throw new Error('heures doit etre >= 0'); - } - this.heuresRealisees = heures; - } - - cloturer(): void { - if (this.statut !== 'en_cours' && this.statut !== 'planifie') { - throw new Error(`Transition invalide ${this.statut} -> realise`); - } - this.statut = 'realise'; - } - - annuler(_raison: string): void { - if (this.statut === 'realise') { - throw new Error('Une attribution realisee ne peut etre annulee'); - } - this.statut = 'annule'; - } -} diff --git a/bridge/src/domain/bloc.ts b/bridge/src/domain/bloc.ts deleted file mode 100644 index 1733d8c..0000000 --- a/bridge/src/domain/bloc.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Decimal } from 'decimal.js'; -import type { Module } from './module.js'; - -export interface BlocProps { - id: number; - formationId: number; - nom: string; - heuresPrevues: Decimal; - ordre?: number; - modules?: Module[]; -} - -const ZERO = new Decimal(0); - -export class Bloc { - public readonly id: number; - public readonly formationId: number; - public nom: string; - public heuresPrevues: Decimal; - public ordre: number; - public readonly modules: Module[]; - - constructor(props: BlocProps) { - if (props.heuresPrevues.lt(0)) { - throw new Error('heuresPrevues doit etre >= 0'); - } - this.id = props.id; - this.formationId = props.formationId; - this.nom = props.nom; - this.heuresPrevues = props.heuresPrevues; - this.ordre = props.ordre ?? 0; - this.modules = props.modules ?? []; - } - - heuresAttribuees(): Decimal { - return this.modules.reduce((acc, m) => acc.plus(m.heuresPrevues), ZERO); - } - - heuresRestantes(): Decimal { - return this.heuresPrevues.minus(this.heuresAttribuees()); - } - - ajouterModule(module: Module): void { - if (this.modules.some((m) => m.id === module.id)) { - throw new Error(`Module ${module.id} deja present dans le bloc`); - } - const total = this.heuresAttribuees().plus(module.heuresPrevues); - if (total.gt(this.heuresPrevues)) { - throw new Error( - `Capacite bloc depassee: ${total.toString()} > ${this.heuresPrevues.toString()}`, - ); - } - this.modules.push(module); - } -} diff --git a/bridge/src/domain/client.ts b/bridge/src/domain/client.ts deleted file mode 100644 index 41f4605..0000000 --- a/bridge/src/domain/client.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { Projet } from './projet.js'; -import type { StatutClient } from './types.js'; - -export interface ClientProps { - id: number; - nom: string; - contactPrincipal?: string | null; - contactEmail?: string | null; - contactTelephone?: string | null; - secteur?: string | null; - notes?: string | null; - statut?: StatutClient; - projets?: Projet[]; -} - -export class Client { - public readonly id: number; - public nom: string; - public contactPrincipal: string | null; - public contactEmail: string | null; - public contactTelephone: string | null; - public secteur: string | null; - public notes: string | null; - public statut: StatutClient; - public readonly projets: Projet[]; - - constructor(props: ClientProps) { - this.id = props.id; - this.nom = props.nom; - this.contactPrincipal = props.contactPrincipal ?? null; - this.contactEmail = props.contactEmail ?? null; - this.contactTelephone = props.contactTelephone ?? null; - this.secteur = props.secteur ?? null; - this.notes = props.notes ?? null; - this.statut = props.statut ?? 'prospect'; - this.projets = props.projets ?? []; - } - - creerProjet(nom: string, nextId: number): Projet { - if (this.statut === 'archive') { - throw new Error('Client archive : creation projet impossible'); - } - if (this.projets.some((p) => p.nom === nom)) { - throw new Error(`Projet ${nom} existe deja pour ce client`); - } - const projet = new Projet({ - id: nextId, - clientId: this.id, - nom, - chargeHeures: new Decimal(0), - statut: 'devis', - }); - this.projets.push(projet); - return projet; - } - - archiver(): void { - this.statut = 'archive'; - } -} diff --git a/bridge/src/domain/field.ts b/bridge/src/domain/field.ts new file mode 100644 index 0000000..39540b0 --- /dev/null +++ b/bridge/src/domain/field.ts @@ -0,0 +1,32 @@ +/** + * Field entity — descriptor de colonne d'une table Baserow. + * + * `type` reflete les field types Baserow (text, number, date, single_select, + * link_row, formula, etc.). Le bridge ne contraint pas la valeur enum : tout + * type que Baserow expose est passe tel quel cote API. `options` contient les + * meta type-specifiques (ex: choix d'un single_select, formule rollup, etc.). + */ + +export interface FieldProps { + id: number; + name: string; + type: string; + primary?: boolean; + options?: Record | null; +} + +export class Field { + readonly id: number; + readonly name: string; + readonly type: string; + readonly primary: boolean; + readonly options: Record | null; + + constructor(props: FieldProps) { + this.id = props.id; + this.name = props.name; + this.type = props.type; + this.primary = props.primary ?? false; + this.options = props.options ?? null; + } +} diff --git a/bridge/src/domain/formation.ts b/bridge/src/domain/formation.ts deleted file mode 100644 index 617ada8..0000000 --- a/bridge/src/domain/formation.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Decimal } from 'decimal.js'; -import type { Bloc } from './bloc.js'; -import type { Filiere, StatutFormation } from './types.js'; - -export interface FormationProps { - id: number; - nom: string; - filiere?: Filiere | null; - heuresTotales: Decimal; - statut?: StatutFormation; - dateDebut?: Date | null; - dateFin?: Date | null; - blocs?: Bloc[]; -} - -const ZERO = new Decimal(0); - -export class Formation { - public readonly id: number; - public nom: string; - public filiere: Filiere | null; - public heuresTotales: Decimal; - public statut: StatutFormation; - public dateDebut: Date | null; - public dateFin: Date | null; - public readonly blocs: Bloc[]; - - constructor(props: FormationProps) { - if (props.heuresTotales.lt(0)) { - throw new Error('heuresTotales doit etre >= 0'); - } - if (props.dateDebut && props.dateFin && props.dateFin < props.dateDebut) { - throw new Error('dateFin doit etre >= dateDebut'); - } - this.id = props.id; - this.nom = props.nom; - this.filiere = props.filiere ?? null; - this.heuresTotales = props.heuresTotales; - this.statut = props.statut ?? 'draft'; - this.dateDebut = props.dateDebut ?? null; - this.dateFin = props.dateFin ?? null; - this.blocs = props.blocs ?? []; - } - - heuresAttribuees(): Decimal { - return this.blocs.reduce((acc, b) => acc.plus(b.heuresPrevues), ZERO); - } - - heuresRestantes(): Decimal { - return this.heuresTotales.minus(this.heuresAttribuees()); - } - - activer(): void { - if (this.statut === 'archive') { - throw new Error('Formation archivee ne peut etre activee'); - } - this.statut = 'actif'; - } - - archiver(): void { - this.statut = 'archive'; - } - - ajouterBloc(bloc: Bloc): void { - if (this.blocs.some((b) => b.id === bloc.id)) { - throw new Error(`Bloc ${bloc.id} deja present dans la formation`); - } - const total = this.heuresAttribuees().plus(bloc.heuresPrevues); - if (total.gt(this.heuresTotales)) { - throw new Error( - `Capacite formation depassee: ${total.toString()} > ${this.heuresTotales.toString()}`, - ); - } - this.blocs.push(bloc); - } -} diff --git a/bridge/src/domain/index.ts b/bridge/src/domain/index.ts index 3c3015f..402d31a 100644 --- a/bridge/src/domain/index.ts +++ b/bridge/src/domain/index.ts @@ -1,20 +1,9 @@ -export * from './types.js'; -export { Personne } from './personne.js'; -export type { PersonneProps } from './personne.js'; -export { Formation } from './formation.js'; -export type { FormationProps } from './formation.js'; -export { Bloc } from './bloc.js'; -export type { BlocProps } from './bloc.js'; -export { Module } from './module.js'; -export type { ModuleProps } from './module.js'; -export { Attribution } from './attribution.js'; -export type { AttributionProps } from './attribution.js'; -export { Client } from './client.js'; -export type { ClientProps } from './client.js'; -export { Projet } from './projet.js'; -export type { ProjetProps } from './projet.js'; -export { Tache } from './tache.js'; -export type { TacheProps } from './tache.js'; -export { Intervention } from './intervention.js'; -export type { InterventionProps } from './intervention.js'; +export { Table } from './table.js'; +export type { TableProps } from './table.js'; +export { Row } from './row.js'; +export type { RowProps } from './row.js'; +export { Field } from './field.js'; +export type { FieldProps } from './field.js'; +export { View } from './view.js'; +export type { ViewType, ViewProps } from './view.js'; export * from './schemas.js'; diff --git a/bridge/src/domain/intervention.ts b/bridge/src/domain/intervention.ts deleted file mode 100644 index 7dfdac4..0000000 --- a/bridge/src/domain/intervention.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Decimal } from 'decimal.js'; -import type { StatutIntervention } from './types.js'; - -export interface InterventionProps { - id: number; - tacheId: number; - personneId: number; - heures: Decimal; - date: Date; - notes?: string | null; - statut?: StatutIntervention; -} - -export class Intervention { - public readonly id: number; - public readonly tacheId: number; - public readonly personneId: number; - public heures: Decimal; - public date: Date; - public notes: string | null; - public statut: StatutIntervention; - - constructor(props: InterventionProps) { - if (props.heures.lte(0)) { - throw new Error('heures doit etre > 0'); - } - this.id = props.id; - this.tacheId = props.tacheId; - this.personneId = props.personneId; - this.heures = props.heures; - this.date = props.date; - this.notes = props.notes ?? null; - this.statut = props.statut ?? 'realise'; - } - - isActive(): boolean { - return this.statut === 'planifie' || this.statut === 'realise'; - } - - annuler(_raison: string): void { - if (this.statut === 'annule') { - throw new Error('Intervention deja annulee'); - } - this.statut = 'annule'; - } -} diff --git a/bridge/src/domain/module.ts b/bridge/src/domain/module.ts deleted file mode 100644 index a7e42f3..0000000 --- a/bridge/src/domain/module.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { logger } from '../lib/logger.js'; -import { Attribution } from './attribution.js'; -import type { Personne } from './personne.js'; -import type { StatutModule } from './types.js'; - -export interface ModuleProps { - id: number; - blocId: number; - nom: string; - heuresPrevues: Decimal; - statut?: StatutModule; - attributions?: Attribution[]; -} - -const ZERO = new Decimal(0); - -export class Module { - public readonly id: number; - public readonly blocId: number; - public nom: string; - public heuresPrevues: Decimal; - public statut: StatutModule; - public readonly attributions: Attribution[]; - - constructor(props: ModuleProps) { - if (props.heuresPrevues.lt(0)) { - throw new Error('heuresPrevues doit etre >= 0'); - } - this.id = props.id; - this.blocId = props.blocId; - this.nom = props.nom; - this.heuresPrevues = props.heuresPrevues; - this.statut = props.statut ?? 'a_attribuer'; - this.attributions = props.attributions ?? []; - } - - heuresAttribuees(): Decimal { - return this.attributions - .filter((a) => a.statut !== 'annule') - .reduce((acc, a) => acc.plus(a.heuresAttribuees), ZERO); - } - - heuresRealisees(): Decimal { - return this.attributions - .filter((a) => a.statut !== 'annule') - .reduce((acc, a) => acc.plus(a.heuresRealisees), ZERO); - } - - heuresRestantes(): Decimal { - return this.heuresPrevues.minus(this.heuresAttribuees()); - } - - /** - * Cree une attribution sur ce module pour `personne`. - * RG-01 : `SUM(attributions.heures) + heures <= heuresPrevues`. - * Warn si depassement de la capacite formation de la personne (n'echoue pas — decision de gestion). - */ - creerAttribution( - personne: Personne, - heures: Decimal, - dateDebut: Date | null, - dateFin: Date | null, - nextId: number, - ): Attribution { - if (!personne.hasRole('formateur')) { - throw new Error('Personne doit avoir le role formateur'); - } - if (personne.statut !== 'actif') { - throw new Error('Personne inactive : attribution interdite'); - } - if (heures.lte(0)) { - throw new Error('heures doit etre > 0'); - } - const total = this.heuresAttribuees().plus(heures); - if (total.gt(this.heuresPrevues)) { - throw new Error( - `RG-01: heures attribuees (${total.toString()}) > heuresPrevues (${this.heuresPrevues.toString()})`, - ); - } - if (heures.gt(personne.heuresRestantesFormation())) { - logger.warn( - { - moduleId: this.id, - personneId: personne.id, - heures: heures.toString(), - restantes: personne.heuresRestantesFormation().toString(), - }, - 'Attribution depasse capacite formation restante de la personne', - ); - } - - const attribution = new Attribution({ - id: nextId, - moduleId: this.id, - personneId: personne.id, - heuresAttribuees: heures, - dateDebut, - dateFin, - statut: 'planifie', - }); - this.attributions.push(attribution); - personne._appliquerHeuresFormation(heures); - if (this.statut === 'a_attribuer') { - this.statut = 'attribue'; - } - return attribution; - } - - annuler(): void { - if (this.statut === 'realise') { - throw new Error('Module realise ne peut etre annule'); - } - this.statut = 'annule'; - } - - cloturer(): void { - if (this.statut === 'annule') { - throw new Error('Module annule ne peut etre cloture'); - } - this.statut = 'realise'; - } -} diff --git a/bridge/src/domain/personne.ts b/bridge/src/domain/personne.ts deleted file mode 100644 index a0e95f0..0000000 --- a/bridge/src/domain/personne.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { logger } from '../lib/logger.js'; -import type { Role, StatutPersonne } from './types.js'; - -export interface PersonneProps { - id: number; - nom: string; - prenom: string; - email: string; - capaciteAnnuelle: Decimal; - splitFormationPct: Decimal; - splitAgencePct: Decimal; - roles: Set; - statut: StatutPersonne; - heuresAttribueesFormation?: Decimal; - heuresAttribueesAgence?: Decimal; -} - -const ZERO = new Decimal(0); -const HUNDRED = new Decimal(100); - -export class Personne { - public readonly id: number; - public nom: string; - public prenom: string; - public email: string; - public capaciteAnnuelle: Decimal; - public splitFormationPct: Decimal; - public splitAgencePct: Decimal; - public roles: Set; - public statut: StatutPersonne; - - // Rollups projetes depuis l'aggregat — sources = ATTRIBUTION/INTERVENTION cote DB - private _heuresAttribueesFormation: Decimal; - private _heuresAttribueesAgence: Decimal; - - constructor(props: PersonneProps) { - if (props.capaciteAnnuelle.lt(0)) { - throw new Error('capaciteAnnuelle doit etre >= 0'); - } - if (props.splitFormationPct.lt(0) || props.splitFormationPct.gt(100)) { - throw new Error('splitFormationPct doit etre entre 0 et 100'); - } - if (props.splitAgencePct.lt(0) || props.splitAgencePct.gt(100)) { - throw new Error('splitAgencePct doit etre entre 0 et 100'); - } - if (!props.splitFormationPct.plus(props.splitAgencePct).equals(HUNDRED)) { - throw new Error('splitFormationPct + splitAgencePct doit egaler 100'); - } - - this.id = props.id; - this.nom = props.nom; - this.prenom = props.prenom; - this.email = props.email; - this.capaciteAnnuelle = props.capaciteAnnuelle; - this.splitFormationPct = props.splitFormationPct; - this.splitAgencePct = props.splitAgencePct; - this.roles = new Set(props.roles); - this.statut = props.statut; - this._heuresAttribueesFormation = props.heuresAttribueesFormation ?? ZERO; - this._heuresAttribueesAgence = props.heuresAttribueesAgence ?? ZERO; - } - - get heuresAttribueesFormation(): Decimal { - return this._heuresAttribueesFormation; - } - - get heuresAttribueesAgence(): Decimal { - return this._heuresAttribueesAgence; - } - - capaciteFormation(): Decimal { - return this.capaciteAnnuelle.times(this.splitFormationPct).div(HUNDRED); - } - - capaciteAgence(): Decimal { - return this.capaciteAnnuelle.times(this.splitAgencePct).div(HUNDRED); - } - - heuresRestantesFormation(): Decimal { - return this.capaciteFormation().minus(this._heuresAttribueesFormation); - } - - heuresRestantesAgence(): Decimal { - return this.capaciteAgence().minus(this._heuresAttribueesAgence); - } - - heuresRestantesTotal(): Decimal { - return this.capaciteAnnuelle - .minus(this._heuresAttribueesFormation) - .minus(this._heuresAttribueesAgence); - } - - hasRole(role: Role): boolean { - return this.roles.has(role); - } - - ajouterRole(role: Role): void { - this.roles.add(role); // idempotent par construction du Set - } - - /** - * Le retrait est bloque si la personne porte des allocations actives sur ce role - * — sinon on orphelinerait des Attributions/Interventions cote DB. - * Le caller fournit les compteurs car la classe ne connait pas ses aggregats children. - */ - retirerRole( - role: Role, - opts?: { activeAttributions?: number; activeInterventions?: number }, - ): void { - if (role === 'formateur' && (opts?.activeAttributions ?? 0) > 0) { - throw new Error( - 'Impossible de retirer le role formateur : attributions actives encore liees', - ); - } - if (role === 'developpeur' && (opts?.activeInterventions ?? 0) > 0) { - throw new Error( - 'Impossible de retirer le role developpeur : interventions actives encore liees', - ); - } - this.roles.delete(role); - } - - activer(): void { - this.statut = 'actif'; - } - - inactiver(): void { - this.statut = 'inactif'; - } - - /** Mutation interne du rollup formation — appele par les aggregat methods. */ - _appliquerHeuresFormation(delta: Decimal): void { - const next = this._heuresAttribueesFormation.plus(delta); - if (next.lt(0)) { - throw new Error('heuresAttribueesFormation ne peut pas devenir negatif'); - } - if (next.gt(this.capaciteFormation())) { - logger.warn( - { - personneId: this.id, - next: next.toString(), - capacite: this.capaciteFormation().toString(), - }, - 'Personne en surcharge formation', - ); - } - this._heuresAttribueesFormation = next; - } - - /** Mutation interne du rollup agence. */ - _appliquerHeuresAgence(delta: Decimal): void { - const next = this._heuresAttribueesAgence.plus(delta); - if (next.lt(0)) { - throw new Error('heuresAttribueesAgence ne peut pas devenir negatif'); - } - if (next.gt(this.capaciteAgence())) { - logger.warn( - { personneId: this.id, next: next.toString(), capacite: this.capaciteAgence().toString() }, - 'Personne en surcharge agence', - ); - } - this._heuresAttribueesAgence = next; - } -} diff --git a/bridge/src/domain/projet.ts b/bridge/src/domain/projet.ts deleted file mode 100644 index 5534fb9..0000000 --- a/bridge/src/domain/projet.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Decimal } from 'decimal.js'; -import type { Formation } from './formation.js'; -import { Tache } from './tache.js'; -import type { ProjetType, StatutProjet } from './types.js'; - -export interface ProjetProps { - id: number; - clientId: number; - nom: string; - type?: ProjetType | null; - chargeHeures: Decimal; - statut?: StatutProjet; - formationId?: number | null; - taches?: Tache[]; -} - -const ZERO = new Decimal(0); - -export class Projet { - public readonly id: number; - public readonly clientId: number; - public nom: string; - public type: ProjetType | null; - public chargeHeures: Decimal; - public statut: StatutProjet; - public formationId: number | null; - public readonly taches: Tache[]; - - constructor(props: ProjetProps) { - if (props.chargeHeures.lt(0)) { - throw new Error('chargeHeures doit etre >= 0'); - } - this.id = props.id; - this.clientId = props.clientId; - this.nom = props.nom; - this.type = props.type ?? null; - this.chargeHeures = props.chargeHeures; - this.statut = props.statut ?? 'devis'; - this.formationId = props.formationId ?? null; - this.taches = props.taches ?? []; - } - - heuresAttribuees(): Decimal { - return this.taches.reduce((acc, t) => acc.plus(t.chargeHeures), ZERO); - } - - heuresRealisees(): Decimal { - return this.taches.reduce((acc, t) => acc.plus(t.heuresRealisees()), ZERO); - } - - heuresRestantes(): Decimal { - return this.chargeHeures.minus(this.heuresRealisees()); - } - - ajouterTache(titre: string, charge: Decimal, nextId: number): Tache { - if (this.statut === 'cloture' || this.statut === 'abandonne') { - throw new Error(`Impossible d'ajouter une tache : projet ${this.statut}`); - } - if (charge.lt(0)) { - throw new Error('charge doit etre >= 0'); - } - const tache = new Tache({ - id: nextId, - projetId: this.id, - titre, - chargeHeures: charge, - statut: 'todo', - }); - this.taches.push(tache); - return tache; - } - - lierFormationPedagogique(formation: Formation): void { - this.formationId = formation.id; - } - - livrer(): void { - if (this.statut !== 'en_cours' && this.statut !== 'devis') { - throw new Error(`Transition invalide ${this.statut} -> livre`); - } - this.statut = 'livre'; - } - - cloturer(): void { - if (this.statut === 'abandonne') { - throw new Error('Projet abandonne ne peut etre cloture'); - } - this.statut = 'cloture'; - } -} diff --git a/bridge/src/domain/row.ts b/bridge/src/domain/row.ts new file mode 100644 index 0000000..c20b224 --- /dev/null +++ b/bridge/src/domain/row.ts @@ -0,0 +1,36 @@ +/** + * Row entity — generic record dans une table Baserow. + * + * `fields` est un Record opaque : le bridge ne valide pas le + * contenu metier (c'est le job du frontend ou de la couche logique cote + * utilisateur). On expose juste les ids structurels et les timestamps si + * Baserow les renvoie. + */ + +export interface RowProps { + id: number; + tableId: number; + fields: Record; + createdOn?: Date | null; + updatedOn?: Date | null; + /** order Baserow (string decimal). Conserve tel quel pour les sorts. */ + order?: string | null; +} + +export class Row { + readonly id: number; + readonly tableId: number; + readonly fields: Record; + readonly createdOn: Date | null; + readonly updatedOn: Date | null; + readonly order: string | null; + + constructor(props: RowProps) { + this.id = props.id; + this.tableId = props.tableId; + this.fields = props.fields; + this.createdOn = props.createdOn ?? null; + this.updatedOn = props.updatedOn ?? null; + this.order = props.order ?? null; + } +} diff --git a/bridge/src/domain/schemas.ts b/bridge/src/domain/schemas.ts index 421e4f0..7e86f82 100644 --- a/bridge/src/domain/schemas.ts +++ b/bridge/src/domain/schemas.ts @@ -1,145 +1,59 @@ -import { z } from 'zod'; - /** * Schemas zod pour validation runtime au boundary HTTP/webhook. - * Convention : `decimal` est valide via `z.coerce.number()` puis converti en `Decimal` dans la mapping layer. + * + * Refonte R1 : plus de schemas metier, juste les structures generiques + * Table/Row/Field/View. La forme des row.fields est volontairement laxiste + * (Record) — le bridge ne valide pas le contenu metier. */ -export const RoleSchema = z.enum(['formateur', 'developpeur', 'admin', 'direction', 'support']); +import { z } from 'zod'; -export const FiliereSchema = z.enum(['dev', 'graphisme', 'marketing', 'iot', 'cybersec']); +export const FieldSchema = z.object({ + id: z.number().int().nonnegative(), + name: z.string().min(1), + type: z.string().min(1), + primary: z.boolean().default(false), + options: z.record(z.unknown()).nullable().optional(), +}); -export const PrioriteSchema = z.enum(['faible', 'normale', 'haute', 'critique']); - -export const ProjetTypeSchema = z.enum([ - 'site_web', - 'app_mobile', - 'api', - 'infra', - 'audit', - 'support', - 'autre', +export const ViewTypeSchema = z.union([ + z.enum(['grid', 'kanban', 'calendar', 'gallery', 'form']), + z.string().min(1), ]); -export const StatutPersonneSchema = z.enum(['actif', 'inactif']); -export const StatutFormationSchema = z.enum(['draft', 'actif', 'termine', 'archive']); -export const StatutModuleSchema = z.enum([ - 'a_attribuer', - 'attribue', - 'en_cours', - 'realise', - 'annule', -]); -export const StatutAttributionSchema = z.enum(['planifie', 'en_cours', 'realise', 'annule']); -export const StatutClientSchema = z.enum(['prospect', 'actif', 'inactif', 'archive']); -export const StatutProjetSchema = z.enum(['devis', 'en_cours', 'livre', 'cloture', 'abandonne']); -export const StatutTacheSchema = z.enum(['todo', 'in_progress', 'review', 'done', 'abandoned']); -export const StatutInterventionSchema = z.enum(['planifie', 'realise', 'annule']); - -export const PersonneSchema = z - .object({ - id: z.number().int().nonnegative(), - nom: z.string().min(1).max(100), - prenom: z.string().min(1).max(100), - email: z.string().email(), - telephone: z.string().max(20).optional().nullable(), - capaciteAnnuelle: z.coerce.number().nonnegative(), - splitFormationPct: z.coerce.number().min(0).max(100), - splitAgencePct: z.coerce.number().min(0).max(100), - roles: z.array(RoleSchema), - statut: StatutPersonneSchema.default('actif'), - heuresAttribueesFormation: z.coerce.number().nonnegative().optional(), - heuresAttribueesAgence: z.coerce.number().nonnegative().optional(), - }) - .refine((d) => d.splitFormationPct + d.splitAgencePct === 100, { - message: 'splits doivent sommer a 100', - path: ['splitFormationPct'], - }); - -export const FormationSchema = z.object({ +export const ViewSchema = z.object({ id: z.number().int().nonnegative(), - nom: z.string().min(1).max(200), - description: z.string().optional().nullable(), - filiere: FiliereSchema.optional().nullable(), - heuresTotales: z.coerce.number().nonnegative(), - statut: StatutFormationSchema.default('draft'), - dateDebut: z.coerce.date().optional().nullable(), - dateFin: z.coerce.date().optional().nullable(), + name: z.string().min(1), + type: ViewTypeSchema, + tableId: z.number().int().positive(), }); -export const BlocSchema = z.object({ - id: z.number().int().nonnegative(), - formationId: z.number().int().nonnegative(), - nom: z.string().min(1).max(200), - heuresPrevues: z.coerce.number().nonnegative(), - ordre: z.number().int().nonnegative().default(0), +export const TableSchema = z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + databaseId: z.number().int().positive(), + fields: z.array(FieldSchema).optional(), + orderIndex: z.number().int().nonnegative().default(0), }); -export const ModuleSchema = z.object({ +export const RowSchema = z.object({ id: z.number().int().nonnegative(), - blocId: z.number().int().nonnegative(), - nom: z.string().min(1).max(200), - heuresPrevues: z.coerce.number().nonnegative(), - statut: StatutModuleSchema.default('a_attribuer'), + tableId: z.number().int().positive(), + fields: z.record(z.unknown()), + createdOn: z.coerce.date().nullable().optional(), + updatedOn: z.coerce.date().nullable().optional(), + order: z.string().nullable().optional(), }); -export const AttributionSchema = z.object({ - id: z.number().int().nonnegative(), - moduleId: z.number().int().nonnegative(), - personneId: z.number().int().nonnegative(), - heuresAttribuees: z.coerce.number().positive(), - heuresRealisees: z.coerce.number().nonnegative().default(0), - dateDebut: z.coerce.date().optional().nullable(), - dateFin: z.coerce.date().optional().nullable(), - statut: StatutAttributionSchema.default('planifie'), -}); +/** + * Body d'un create/update row : juste un Record. Le bridge + * proxie tel quel vers Baserow qui appliquera ses propres validations + * (types de champs, contraintes, formules read-only, etc.). + */ +export const RowFieldsSchema = z.record(z.unknown()); -export const ClientSchema = z.object({ - id: z.number().int().nonnegative(), - nom: z.string().min(1).max(200), - contactPrincipal: z.string().max(200).optional().nullable(), - contactEmail: z.string().email().optional().nullable(), - contactTelephone: z.string().max(20).optional().nullable(), - secteur: z.string().max(100).optional().nullable(), - notes: z.string().optional().nullable(), - statut: StatutClientSchema.default('prospect'), -}); - -export const ProjetSchema = z.object({ - id: z.number().int().nonnegative(), - clientId: z.number().int().nonnegative(), - nom: z.string().min(1).max(200), - type: ProjetTypeSchema.optional().nullable(), - chargeHeures: z.coerce.number().nonnegative(), - statut: StatutProjetSchema.default('devis'), - formationId: z.number().int().nonnegative().optional().nullable(), -}); - -export const TacheSchema = z.object({ - id: z.number().int().nonnegative(), - projetId: z.number().int().nonnegative(), - titre: z.string().min(1).max(200), - chargeHeures: z.coerce.number().nonnegative(), - priorite: PrioriteSchema.optional().nullable(), - statut: StatutTacheSchema.default('todo'), -}); - -export const InterventionSchema = z.object({ - id: z.number().int().nonnegative(), - tacheId: z.number().int().nonnegative(), - personneId: z.number().int().nonnegative(), - heures: z.coerce.number().positive(), - date: z.coerce.date(), - notes: z.string().optional().nullable(), - statut: StatutInterventionSchema.default('realise'), -}); - -export type PersonneInput = z.infer; -export type FormationInput = z.infer; -export type BlocInput = z.infer; -export type ModuleInput = z.infer; -export type AttributionInput = z.infer; -export type ClientInput = z.infer; -export type ProjetInput = z.infer; -export type TacheInput = z.infer; -export type InterventionInput = z.infer; +export type FieldInput = z.infer; +export type ViewInput = z.infer; +export type TableInput = z.infer; +export type RowInput = z.infer; +export type RowFieldsInput = z.infer; diff --git a/bridge/src/domain/table.ts b/bridge/src/domain/table.ts new file mode 100644 index 0000000..a5b23dc --- /dev/null +++ b/bridge/src/domain/table.ts @@ -0,0 +1,34 @@ +/** + * Table entity — generic Notion-like database/table descriptor. + * + * Le bridge est un proxy generique : il ne sait rien des conventions metier + * que l'utilisateur met dans ses tables Baserow. Une Table est donc un objet + * structurel : id Baserow, name, databaseId proprietaire, schema des fields + * et ordre d'affichage tel que Baserow l'expose. + */ + +import type { Field } from './field.js'; + +export interface TableProps { + id: number; + name: string; + databaseId: number; + fields?: Field[]; + orderIndex?: number; +} + +export class Table { + readonly id: number; + readonly name: string; + readonly databaseId: number; + readonly fields: Field[]; + readonly orderIndex: number; + + constructor(props: TableProps) { + this.id = props.id; + this.name = props.name; + this.databaseId = props.databaseId; + this.fields = props.fields ?? []; + this.orderIndex = props.orderIndex ?? 0; + } +} diff --git a/bridge/src/domain/tache.ts b/bridge/src/domain/tache.ts deleted file mode 100644 index e04c690..0000000 --- a/bridge/src/domain/tache.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { Intervention } from './intervention.js'; -import type { Personne } from './personne.js'; -import type { Priorite, StatutTache } from './types.js'; - -export interface TacheProps { - id: number; - projetId: number; - titre: string; - chargeHeures: Decimal; - priorite?: Priorite | null; - statut?: StatutTache; - interventions?: Intervention[]; -} - -const ZERO = new Decimal(0); - -export class Tache { - public readonly id: number; - public readonly projetId: number; - public titre: string; - public chargeHeures: Decimal; - public priorite: Priorite | null; - public statut: StatutTache; - public readonly interventions: Intervention[]; - - constructor(props: TacheProps) { - if (props.chargeHeures.lt(0)) { - throw new Error('chargeHeures doit etre >= 0'); - } - this.id = props.id; - this.projetId = props.projetId; - this.titre = props.titre; - this.chargeHeures = props.chargeHeures; - this.priorite = props.priorite ?? null; - this.statut = props.statut ?? 'todo'; - this.interventions = props.interventions ?? []; - } - - heuresRealisees(): Decimal { - return this.interventions - .filter((i) => i.statut !== 'annule') - .reduce((acc, i) => acc.plus(i.heures), ZERO); - } - - creerIntervention(personne: Personne, heures: Decimal, date: Date, nextId: number): Intervention { - if (!personne.hasRole('developpeur')) { - throw new Error('Personne doit avoir le role developpeur'); - } - if (personne.statut !== 'actif') { - throw new Error('Personne inactive : intervention interdite'); - } - if (heures.lte(0)) { - throw new Error('heures doit etre > 0'); - } - - const intervention = new Intervention({ - id: nextId, - tacheId: this.id, - personneId: personne.id, - heures, - date, - statut: 'realise', - }); - this.interventions.push(intervention); - personne._appliquerHeuresAgence(heures); - return intervention; - } - - marquerInProgress(): void { - if (this.statut === 'done' || this.statut === 'abandoned') { - throw new Error(`Transition invalide ${this.statut} -> in_progress`); - } - this.statut = 'in_progress'; - } - - marquerReview(): void { - if (this.statut !== 'in_progress') { - throw new Error(`Transition invalide ${this.statut} -> review`); - } - this.statut = 'review'; - } - - marquerDone(): void { - if (this.statut === 'abandoned' || this.statut === 'done') { - throw new Error(`Transition invalide ${this.statut} -> done`); - } - this.statut = 'done'; - } -} diff --git a/bridge/src/domain/types.ts b/bridge/src/domain/types.ts deleted file mode 100644 index c5e2121..0000000 --- a/bridge/src/domain/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Types et value objects partages du domaine. - * - * Statuts modelises en discriminated unions plutot qu'en enums TS — ESM-friendly, - * pas de runtime cost, narrowing precis au point d'usage. - */ - -export type Role = 'formateur' | 'developpeur' | 'admin' | 'direction' | 'support'; - -export const ROLES: readonly Role[] = [ - 'formateur', - 'developpeur', - 'admin', - 'direction', - 'support', -] as const; - -export type Filiere = 'dev' | 'graphisme' | 'marketing' | 'iot' | 'cybersec'; - -export type Priorite = 'faible' | 'normale' | 'haute' | 'critique'; - -export type ProjetType = - | 'site_web' - | 'app_mobile' - | 'api' - | 'infra' - | 'audit' - | 'support' - | 'autre'; - -// Statuts par entite — chacun ferme son cycle de vie -export type StatutPersonne = 'actif' | 'inactif'; -export type StatutFormation = 'draft' | 'actif' | 'termine' | 'archive'; -export type StatutModule = 'a_attribuer' | 'attribue' | 'en_cours' | 'realise' | 'annule'; -export type StatutAttribution = 'planifie' | 'en_cours' | 'realise' | 'annule'; -export type StatutClient = 'prospect' | 'actif' | 'inactif' | 'archive'; -export type StatutProjet = 'devis' | 'en_cours' | 'livre' | 'cloture' | 'abandonne'; -export type StatutTache = 'todo' | 'in_progress' | 'review' | 'done' | 'abandoned'; -export type StatutIntervention = 'planifie' | 'realise' | 'annule'; diff --git a/bridge/src/domain/view.ts b/bridge/src/domain/view.ts new file mode 100644 index 0000000..2479341 --- /dev/null +++ b/bridge/src/domain/view.ts @@ -0,0 +1,29 @@ +/** + * View entity — vue Baserow (grid, kanban, calendar, gallery, form). + * + * On expose juste id, name, type et tableId proprietaire. Les filtres et tris + * de la vue sont resolus cote Baserow quand on lit `/views/grid/:id/`. + */ + +export type ViewType = 'grid' | 'kanban' | 'calendar' | 'gallery' | 'form' | string; + +export interface ViewProps { + id: number; + name: string; + type: ViewType; + tableId: number; +} + +export class View { + readonly id: number; + readonly name: string; + readonly type: ViewType; + readonly tableId: number; + + constructor(props: ViewProps) { + this.id = props.id; + this.name = props.name; + this.type = props.type; + this.tableId = props.tableId; + } +} diff --git a/bridge/src/index.ts b/bridge/src/index.ts index abf2857..de419d9 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -1,8 +1,11 @@ /** * Bridge service entrypoint — Hono HTTP server. * - * Boot sequence : loadConfig -> initContainer (Baserow + Redis + repos + token map) - * -> wire middleware globaux + routes /api/v1/* avec auth + serve. + * Boot sequence : loadConfig -> initContainer -> wire middleware globaux + + * routes /api/v1/tables/* avec auth + serve. + * + * R1 — Plus de routes metier. Le bridge expose un proxy generique + * `/api/v1/tables/*` style Notion. Le metier vit cote consumer. */ import { serve } from '@hono/node-server'; @@ -14,12 +17,7 @@ import { logger } from './lib/logger.js'; import { type AuthVariables, authMiddleware } from './middleware/auth.js'; import { errorHandler } from './middleware/error-handler.js'; import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js'; -import { attributionsRoutes } from './routes/attributions.js'; -import { formationsRoutes } from './routes/formations.js'; -import { interventionsRoutes } from './routes/interventions.js'; -import { modulesRoutes } from './routes/modules.js'; -import { personnesRoutes } from './routes/personnes.js'; -import { projetsRoutes } from './routes/projets.js'; +import { tablesRoutes } from './routes/tables.js'; import { webhooksRoutes } from './routes/webhooks.js'; export async function buildApp(): Promise> { @@ -52,9 +50,6 @@ export async function buildApp(): Promise> { tokens: ctn.tokens, oidc: ctn.oidc, groupsScopesMap: ctn.groupsScopesMap, - strictMapping: ctn.config.authStrictMapping, - cache: ctn.redis, - finder: ctn.repos.personnes, logger: ctn.logger, }), ); @@ -82,12 +77,7 @@ export async function buildApp(): Promise> { } await next(); }); - v1.route('/personnes', personnesRoutes); - v1.route('/formations', formationsRoutes); - v1.route('/projets', projetsRoutes); - v1.route('/modules', modulesRoutes); - v1.route('/interventions', interventionsRoutes); - v1.route('/attributions', attributionsRoutes); + v1.route('/tables', tablesRoutes); app.route('/api/v1', v1); app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404)); @@ -97,22 +87,7 @@ export async function buildApp(): Promise> { async function main() { const config = loadConfig(); - // Soit BASEROW_TABLE_IDS={"personne":609,...} (preferred — DB tokens n'ont pas - // acces a /api/database/tables/database/:id/), soit BASEROW_DATABASE_ID + un JWT - // user (Phase 3+). Cf doc 19 §5. - const tableIdsRaw = process.env.BASEROW_TABLE_IDS; - const databaseIdRaw = process.env.BASEROW_DATABASE_ID; - let initOpts: Parameters[0]; - if (tableIdsRaw) { - initOpts = { config, tableIds: JSON.parse(tableIdsRaw) }; - } else { - const databaseId = databaseIdRaw ? Number.parseInt(databaseIdRaw, 10) : undefined; - if (!databaseId || Number.isNaN(databaseId)) { - throw new Error('BASEROW_TABLE_IDS ou BASEROW_DATABASE_ID requis'); - } - initOpts = { config, databaseId }; - } - await initContainer(initOpts); + await initContainer({ config }); const app = await buildApp(); serve({ fetch: app.fetch, port: config.port }, (info) => { diff --git a/bridge/src/lib/cache.ts b/bridge/src/lib/cache.ts index cb698b4..a9f278a 100644 --- a/bridge/src/lib/cache.ts +++ b/bridge/src/lib/cache.ts @@ -1,68 +1,41 @@ /** - * Helpers d'invalidation cache cote bridge. + * Helpers d'invalidation cache cote bridge — generique style Notion. * - * Quand une route REST `/api/v1/*` mute Baserow (POST/PATCH/PUT/DELETE), Baserow - * va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler.ts`. - * MAIS la latence webhook est variable (ms a quelques secondes selon la conf - * Baserow + reseau) — entre l'ecriture et l'arrivee du webhook, une lecture - * concurrente peut servir une valeur stale. L'invalidation immediate cote write - * ferme cette fenetre et evite la double-source-of-truth temporaire. + * Quand une route REST `/api/v1/tables/:tableId/rows*` mute Baserow, Baserow + * va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler`. + * MAIS la latence webhook est variable — entre l'ecriture et l'arrivee du + * webhook, une lecture concurrente peut servir une valeur stale. + * L'invalidation immediate cote write ferme cette fenetre. * - * Volontairement pas de coordination avec le webhook : si les deux invalidations - * tombent (write local puis webhook), `invalidatePattern` est idempotent (un - * pattern qui ne matche rien retourne 0, pas d'erreur). + * Pas de cascade cross-table : c'est volontaire. Le bridge ne connait pas le + * graphe de relations entre les tables (link_row, formula, lookup) — c'est + * Baserow qui le sait. Les rollups cross-table emettent leurs propres + * webhooks naturellement, donc l'invalidation cascade au fil de l'eau. + * + * Pattern keyspace : `bridge:tables::row:`, + * `bridge:tables::list:*`, + * `bridge:tables::views:*`. */ -import type { TableName } from '../repos/baserow-repo.js'; - export interface CacheInvalidator { invalidatePattern: (pattern: string) => Promise; } /** - * Invalide le cache local pour une entite + cascade sur les rollups parents. - * Mirror de la logique webhook (`buildInvalidationPatterns`) — duplique - * volontairement ici plutot que d'extraire car les contextes sont differents - * (event_type webhook vs intent route). - * - * Si `id` fourni : invalide la row precise + la liste. Sinon : juste la liste - * (utile sur les creates ou on n'a pas encore l'id parent a invalider). + * Invalide le cache local pour une table. Si `rowId` fourni : invalide la row + * precise + la liste + les vues. Sinon : juste la liste + les vues. */ -export async function invalidateEntity( +export async function invalidateTable( redis: CacheInvalidator, - entity: TableName, - id?: number, + tableId: number, + rowId?: number, ): Promise { - const patterns: string[] = [`bridge:${entity}:list:*`]; - if (typeof id === 'number') { - patterns.push(`bridge:${entity}:row:${id}`); - } - - // Cascade rollups parent : aligned avec webhooks/baserow-handler.ts. - switch (entity) { - case 'attribution': - patterns.push('bridge:module:row:*', 'bridge:module:list:*'); - patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); - break; - case 'intervention': - patterns.push('bridge:tache:row:*', 'bridge:tache:list:*'); - patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); - break; - case 'module': - patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*'); - patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); - break; - case 'bloc': - patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); - break; - case 'tache': - patterns.push('bridge:projet:row:*', 'bridge:projet:list:*'); - break; - case 'projet': - patterns.push('bridge:client:row:*', 'bridge:client:list:*'); - break; - default: - break; + const patterns: string[] = [ + `bridge:tables:${tableId}:list:*`, + `bridge:tables:${tableId}:views:*`, + ]; + if (typeof rowId === 'number') { + patterns.push(`bridge:tables:${tableId}:row:${rowId}`); } let total = 0; diff --git a/bridge/src/lib/config.ts b/bridge/src/lib/config.ts index bc8bac3..ec0963b 100644 --- a/bridge/src/lib/config.ts +++ b/bridge/src/lib/config.ts @@ -21,9 +21,6 @@ const ConfigSchema = z.object({ authentikAudience: z.string().min(1).optional(), // JSON serialise group->scopes ; parse fait dans le middleware auth. authGroupsScopesMap: z.string().optional(), - // Si false : un JWT OIDC valide dont l'email n'a pas de Personne attache passe quand meme - // (scopes derives uniquement des groups Authentik). Defaut strict. - authStrictMapping: z.coerce.boolean().default(true), // Rate limiting (Bloc 5). Global s'applique sur tout /api/v1/* ; mutation s'ajoute // sur POST/PATCH/PUT/DELETE et est volontairement plus strict pour proteger // contre les bursts buggy / scripts mal configures. @@ -52,7 +49,6 @@ export function loadConfig(): Config { authentikJwksUri: process.env.AUTHENTIK_JWKS_URI, authentikAudience: process.env.AUTHENTIK_AUDIENCE, authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP, - authStrictMapping: process.env.AUTH_STRICT_MAPPING, rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX, rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW, rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX, diff --git a/bridge/src/lib/container.ts b/bridge/src/lib/container.ts index 87932d2..9cfc0bf 100644 --- a/bridge/src/lib/container.ts +++ b/bridge/src/lib/container.ts @@ -2,6 +2,10 @@ * DI container — initialise les dependances une seule fois au boot et expose * un singleton typed pour les routes. Pour les tests, `setContainer` permet * d'injecter un mock complet sans toucher a `getContainer`. + * + * R1 — Plus de TableIds metier. Le bridge expose `/tables` generique. Les ids + * sont passes en query/path — c'est le consumer qui sait quelles tables + * exister dans sa Baserow. */ import type { Logger } from 'pino'; @@ -11,18 +15,27 @@ import type { ApiTokenRecord } from '../middleware/auth.js'; import { parseTokens } from '../middleware/auth.js'; import { OidcVerifier } from '../middleware/oidc-verifier.js'; import { type GroupsScopesMap, parseGroupsScopesMap } from '../middleware/scopes.js'; -import { type RepoSet, TABLE_NAMES, type TableIds, buildRepos } from '../repos/baserow-repo.js'; +import { BaserowFieldsRepo } from '../repos/baserow-fields-repo.js'; +import { BaserowRowsRepo } from '../repos/baserow-rows-repo.js'; +import { BaserowTablesRepo } from '../repos/baserow-tables-repo.js'; +import { BaserowViewsRepo } from '../repos/baserow-views-repo.js'; import type { Config } from './config.js'; import { isOidcEnabled } from './config.js'; import { logger as rootLogger } from './logger.js'; +export interface RepoSet { + tables: BaserowTablesRepo; + rows: BaserowRowsRepo; + fields: BaserowFieldsRepo; + views: BaserowViewsRepo; +} + export interface Container { config: Config; baserow: BaserowClient; redis: RedisCache; repos: RepoSet; tokens: ReadonlyMap; - tableIds: TableIds; /** Null si mode OIDC desactive (vars Authentik manquantes). */ oidc: OidcVerifier | null; groupsScopesMap: GroupsScopesMap; @@ -44,12 +57,9 @@ export function setContainer(c: Container | null): void { export interface InitOptions { config: Config; - /** Pour tests : skip le resolveTableIds reseau. */ - tableIds?: TableIds; /** Pour tests : injecter une implem de Baserow/Redis. */ baserow?: BaserowClient; redis?: RedisCache; - databaseId?: number; } export async function initContainer(opts: InitOptions): Promise { @@ -63,18 +73,13 @@ export async function initContainer(opts: InitOptions): Promise { }); const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger }); - let tableIds: TableIds; - if (opts.tableIds) { - tableIds = opts.tableIds; - } else { - if (typeof opts.databaseId !== 'number') { - throw new Error('initContainer: databaseId requis si tableIds non fourni'); - } - const resolved = await baserow.resolveTableIds(opts.databaseId); - tableIds = pickTableIds(resolved); - } + const repos: RepoSet = { + tables: new BaserowTablesRepo({ client: baserow, logger: rootLogger }), + rows: new BaserowRowsRepo({ client: baserow, logger: rootLogger }), + fields: new BaserowFieldsRepo({ client: baserow, logger: rootLogger }), + views: new BaserowViewsRepo({ client: baserow, logger: rootLogger }), + }; - const repos = buildRepos(baserow, tableIds, rootLogger); const tokens = parseTokens(config.bridgeApiTokens); const groupsScopesMap = parseGroupsScopesMap(config.authGroupsScopesMap); @@ -100,7 +105,6 @@ export async function initContainer(opts: InitOptions): Promise { redis, repos, tokens, - tableIds, oidc, groupsScopesMap, logger: rootLogger, @@ -108,16 +112,3 @@ export async function initContainer(opts: InitOptions): Promise { setContainer(container); return container; } - -/** Verifie que toutes les tables attendues sont presentes dans le mapping name->id. */ -function pickTableIds(resolved: Record): TableIds { - const out: Partial = {}; - for (const name of TABLE_NAMES) { - const id = resolved[name]; - if (typeof id !== 'number') { - throw new Error(`Table Baserow manquante : ${name}`); - } - out[name] = id; - } - return out as TableIds; -} diff --git a/bridge/src/middleware/auth.ts b/bridge/src/middleware/auth.ts index ffc7e54..37a6a44 100644 --- a/bridge/src/middleware/auth.ts +++ b/bridge/src/middleware/auth.ts @@ -2,29 +2,30 @@ * Auth middleware bridge — dual mode : * * 1. Service tokens `brg_*` (`Authorization: ApiKey brg_*` ou `Bearer brg_*`) - * pour M2M (webhooks emis par scripts, admin tools). Inchanges depuis Bloc 3. + * pour M2M (webhooks emis par scripts, admin tools, frontend serveur). Les + * scopes sont declares dans `BRIDGE_API_TOKENS` (JSON env var). * * 2. OIDC JWT Authentik (`Authorization: Bearer ` ou cookie `authToken`) - * pour utilisateurs Docmost. Active uniquement si `AUTHENTIK_ISSUER` + - * `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set dans la config. + * pour utilisateurs Docmost/DocAdenice. Active uniquement si + * `AUTHENTIK_ISSUER` + `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set. + * + * R1 — Plus de lookup `PersonneRepo.findByEmail` : le bridge est generique, + * il ne connait pas la table Personne. Le mapping email -> permissions + * metier est entierement cote DocAdenice (R2 RBAC dynamique). Les scopes + * effectifs viennent de : + * - groups Authentik via `groupsScopesMap` + * - claim JWT `acadenice_permissions[]` (R2) — fallback vide si absent * * Ordre de detection : - * - Header `Authorization`: si commence par `brg_` (apres ApiKey/Bearer) -> service token - * - Header `Authorization: Bearer ` (commence par `eyJ`) -> JWT OIDC + * - `Authorization: brg_*` -> service token + * - `Authorization: Bearer ` (commence par `eyJ`) -> JWT OIDC * - Cookie `authToken` -> JWT OIDC * - Sinon -> 401 AUTH_REQUIRED - * - * Pourquoi un seul middleware au lieu de deux ? Une seule passe = pas de doute - * sur l'ordre de priorite, et les routes /api/v1/* n'ont pas a savoir quelle - * source d'identite a ete utilisee. */ -import { createHash } from 'node:crypto'; import type { MiddlewareHandler } from 'hono'; import { getCookie } from 'hono/cookie'; import type { Logger } from 'pino'; -import type { Personne } from '../domain/personne.js'; -import type { Role } from '../domain/types.js'; import { errors } from '../lib/errors.js'; import type { OidcVerifier } from './oidc-verifier.js'; import { extractEmail, extractGroups } from './oidc-verifier.js'; @@ -46,24 +47,22 @@ export interface AuthenticatedUser { email?: string; /** Pour OIDC : sub claim (id stable Authentik). */ sub?: string; - /** Si lookup PersonneRepo a reussi. */ - personneId?: number; - /** Roles formation-hub deduits via Personne (vide pour service tokens). */ - roles: Role[]; /** Groups Authentik bruts (vide pour service tokens). */ groups: string[]; - /** Scopes effectifs : union (groups->scopes) + (roles->scopes) + token.scopes. */ + /** Permissions explicites du JWT (claim `acadenice_permissions[]`). Vide si absent. */ + permissions: string[]; + /** Scopes effectifs : union (groups->scopes) + permissions + token.scopes. */ scopes: string[]; } -/** Hono context variables — `auth` reste pour compat ; `user` est la nouvelle source d'identite. */ +/** Hono context variables. `auth` reste pour compat. */ export type AuthVariables = { auth: { tokenName: string; scopes: ReadonlySet }; user: AuthenticatedUser; }; // --------------------------------------------------------------------------- -// Service tokens (Bloc 3 inchange — JSON parsing tolere ApiKey/Bearer) +// Service tokens // --------------------------------------------------------------------------- export function parseTokens(raw: string | undefined): Map { @@ -94,11 +93,10 @@ export function parseTokens(raw: string | undefined): Map, required: string): boolean { if (owned.has('admin:*')) return true; if (owned.has(required)) return true; - // Wildcard suffix (`prefix:*` -> couvre `prefix:foo`, `prefix:bar`) const colonIdx = required.indexOf(':'); if (colonIdx > 0) { const prefixWildcard = `${required.slice(0, colonIdx)}:*`; @@ -126,65 +124,18 @@ export function requireScope(scope: string): MiddlewareHandler<{ Variables: Auth } // --------------------------------------------------------------------------- -// Personne lookup avec cache Redis (sha256 email pour eviter PII en clair) +// Helpers extraction claims OIDC // --------------------------------------------------------------------------- -export interface PersonneByEmailCache { - get: (key: string) => Promise; - set: (key: string, value: T, ttlSeconds?: number) => Promise; -} - -export interface PersonneFinder { - findByEmail: (email: string) => Promise; -} - -interface CachedPersonne { - id: number; - roles: Role[]; -} - -const PERSONNE_CACHE_TTL = 60; - -function hashEmail(email: string): string { - return createHash('sha256').update(email.trim().toLowerCase()).digest('hex'); -} - -export async function lookupPersonneByEmail( - email: string, - finder: PersonneFinder, - cache: PersonneByEmailCache, - logger: Logger, -): Promise { - const key = `bridge:auth:personne-by-email:${hashEmail(email)}`; - try { - const hit = await cache.get(key); - if (hit) { - if ('miss' in hit) return null; - return hit; - } - } catch (err) { - logger.warn({ err: (err as Error).message }, 'cache get failed, falling through to repo'); - } - const personne = await finder.findByEmail(email); - if (!personne) { - // Cache aussi le miss pour eviter de marteler Baserow. - try { - await cache.set(key, { miss: true }, PERSONNE_CACHE_TTL); - } catch { - /* cache miss-write best effort */ - } - return null; - } - const value: CachedPersonne = { - id: personne.id, - roles: Array.from(personne.roles), - }; - try { - await cache.set(key, value, PERSONNE_CACHE_TTL); - } catch (err) { - logger.warn({ err: (err as Error).message }, 'cache set failed (non-blocking)'); - } - return value; +/** + * Extrait `acadenice_permissions` d'un payload JWT. C'est le claim que + * DocAdenice (R2) attachera au token via le RBAC dynamique. Tolerant : accepte + * un tableau de strings, ignore les valeurs non-strings ou vides. + */ +export function extractPermissions(payload: Record): string[] { + const raw = payload.acadenice_permissions; + if (!Array.isArray(raw)) return []; + return raw.filter((p): p is string => typeof p === 'string' && p.length > 0); } // --------------------------------------------------------------------------- @@ -197,11 +148,6 @@ export interface AuthMiddlewareOptions { oidc: OidcVerifier | null; /** Map groups Authentik -> scopes. */ groupsScopesMap: GroupsScopesMap; - /** Si true et email orphelin (pas de Personne) -> 403. Sinon -> autorise avec scopes des groups uniquement. */ - strictMapping: boolean; - /** Cache Redis (peut etre RedisCache ou n'importe quel impl compatible). */ - cache: PersonneByEmailCache; - finder: PersonneFinder; logger: Logger; } @@ -224,7 +170,7 @@ function parseAuthHeader(header: string | undefined): ParsedHeader { export function authMiddleware( opts: AuthMiddlewareOptions, ): MiddlewareHandler<{ Variables: AuthVariables }> { - const { tokens, oidc, groupsScopesMap, strictMapping, cache, finder, logger } = opts; + const { tokens, oidc, groupsScopesMap, logger } = opts; return async (c, next) => { const headerRaw = c.req.header('Authorization'); @@ -241,8 +187,8 @@ export function authMiddleware( const user: AuthenticatedUser = { source: 'service-token', tokenId: record.name, - roles: [], groups: [], + permissions: [], scopes, }; c.set('user', user); @@ -282,36 +228,18 @@ export function authMiddleware( const email = extractEmail(verified.payload); const sub = typeof verified.payload.sub === 'string' ? verified.payload.sub : undefined; const groups = extractGroups(verified.payload); + const permissions = extractPermissions(verified.payload as Record); - let personneId: number | undefined; - let roles: Role[] = []; - if (email) { - const found = await lookupPersonneByEmail(email, finder, cache, logger); - if (found) { - personneId = found.id; - roles = found.roles; - } else if (strictMapping) { - logger.warn({ email, sub }, 'OIDC user not found in Personne (strict mode)'); - throw errors.forbiddenIdentity('Aucune Personne formation-hub liee a cet email', { - email, - }); - } - } else if (strictMapping) { - throw errors.forbiddenIdentity('JWT sans email exploitable', {}); - } - - const scopes = computeOidcScopes(groups, new Set(roles), groupsScopesMap); + const scopes = computeOidcScopes(groups, permissions, groupsScopesMap); const user: AuthenticatedUser = { source: source ?? 'oidc-jwt', email: email ?? undefined, sub, - personneId, - roles, groups, + permissions, scopes, }; c.set('user', user); - // Compat : c.var.auth pour les rares endpoints Bloc 3 qui le lisent encore. c.set('auth', { tokenName: email ?? sub ?? 'oidc-anonymous', scopes: new Set(scopes), diff --git a/bridge/src/middleware/scopes.ts b/bridge/src/middleware/scopes.ts index 9513c7d..bf2d88e 100644 --- a/bridge/src/middleware/scopes.ts +++ b/bridge/src/middleware/scopes.ts @@ -1,31 +1,19 @@ /** - * Mapping groupes Authentik + roles formation-hub vers scopes bridge. + * Mapping groupes Authentik vers scopes bridge. * - * Sources de scopes pour un utilisateur OIDC : + * R1 generique : plus de mapping role formation-hub. Les sources de scopes + * pour un utilisateur OIDC sont : * 1. groups Authentik (mappes via `AUTH_GROUPS_SCOPES_MAP` JSON) - * 2. roles formation-hub portes par la Personne (mappes via DEFAULT_ROLE_SCOPES) - * 3. union des deux + * 2. claim direct `acadenice_permissions[]` du JWT (alimente cote DocAdenice + * R2 par le RBAC dynamique). Lu par le middleware auth, pas ici. * - * Le default role-scope mapping est volontairement conservateur : seul `admin` - * obtient `admin:*`. Les autres roles reçoivent le strict necessaire pour leur travail. + * Les permissions metier (Formateur, Developpeur, Admin, Direction, Support) + * vivent maintenant dans les exemples (`examples/acadenice-formation-hub/`) + * et sont declarees cote DocAdenice — le bridge n'en sait rien. */ -import type { Role } from '../domain/types.js'; - export type GroupsScopesMap = Record; -/** - * Defaut role -> scopes si rien n'est configure dans `AUTH_GROUPS_SCOPES_MAP`. - * Mantra IA-1 (Trust But Verify) : pas de wildcard sauf admin explicite. - */ -export const DEFAULT_ROLE_SCOPES: Record = { - admin: ['admin:*'], - direction: ['read:personnes', 'read:formations', 'read:projets'], - formateur: ['read:personnes', 'read:formations', 'write:attributions'], - developpeur: ['read:personnes', 'read:projets', 'write:interventions'], - support: ['read:personnes', 'read:formations', 'read:projets'], -}; - export function parseGroupsScopesMap(raw: string | undefined): GroupsScopesMap { if (!raw || raw.trim().length === 0) return {}; let parsed: unknown; @@ -48,14 +36,16 @@ export function parseGroupsScopesMap(raw: string | undefined): GroupsScopesMap { } /** - * Calcule l'union des scopes pour un user OIDC. - * - groups Authentik : si pas de mapping fourni, fallback sur le nom de groupe - * qui matche un Role connu (ex: `formation-hub-formateurs` -> formateur). - * - roles formation-hub : DEFAULT_ROLE_SCOPES. + * Calcule l'union des scopes pour un user OIDC : + * - groups Authentik via le mapping configure + * - permissions explicites (claim `acadenice_permissions[]` ou equivalent) + * + * Si rien ne matche : tableau vide. C'est OK — le middleware d'auth attache + * juste l'identite, et `requireScope` rejettera les routes protegees. */ export function computeOidcScopes( groups: string[], - roles: ReadonlySet, + permissions: string[], groupsMap: GroupsScopesMap, ): string[] { const scopes = new Set(); @@ -65,8 +55,8 @@ export function computeOidcScopes( for (const s of direct) scopes.add(s); } } - for (const role of roles) { - for (const s of DEFAULT_ROLE_SCOPES[role] ?? []) scopes.add(s); + for (const p of permissions) { + if (typeof p === 'string' && p.length > 0) scopes.add(p); } return Array.from(scopes).sort(); } diff --git a/bridge/src/repos/baserow-fields-repo.ts b/bridge/src/repos/baserow-fields-repo.ts new file mode 100644 index 0000000..4e879e3 --- /dev/null +++ b/bridge/src/repos/baserow-fields-repo.ts @@ -0,0 +1,42 @@ +/** + * Repository fields Baserow — list par tableId. DB token OK. + * + * Le bridge passe `options` brut (Record) tel que Baserow + * l'expose : pour un single_select c'est `{select_options: [...]}`, pour un + * link_row c'est `{link_row_table_id, ...}`, etc. Le frontend interpretera. + */ + +import type { Logger } from 'pino'; +import type { BaserowClient } from '../adapters/baserow-client.js'; +import { Field } from '../domain/field.js'; + +export interface BaserowFieldsRepoOptions { + client: BaserowClient; + logger: Logger; +} + +export class BaserowFieldsRepo { + protected readonly client: BaserowClient; + protected readonly logger: Logger; + + constructor(opts: BaserowFieldsRepoOptions) { + this.client = opts.client; + this.logger = opts.logger.child({ repo: 'fields' }); + } + + async list(tableId: number): Promise { + const raws = await this.client.listFields(tableId); + return raws.map((r) => { + const { id, name, type, primary, ...rest } = r; + return new Field({ + id, + name, + type, + primary: Boolean(primary), + // Baserow renvoie les meta type-specifiques au top-level — on les + // groupe sous options pour eviter d'exposer la forme exacte cote API. + options: Object.keys(rest).length > 0 ? rest : null, + }); + }); + } +} diff --git a/bridge/src/repos/baserow-repo.ts b/bridge/src/repos/baserow-repo.ts deleted file mode 100644 index 92d60aa..0000000 --- a/bridge/src/repos/baserow-repo.ts +++ /dev/null @@ -1,554 +0,0 @@ -/** - * Repository layer — wrappe BaserowClient et fait le mapping `BaserowRow` <-> domain. - * - * Choix : 1 classe par entite, qui herite de `BaseRepo`. Le BaseRepo encapsule - * la pagination/get/create/update typee, les mappers sont l'unique custom point. - * Plus simple qu'un mega-generic abstrait : chaque repo a 5 LOC de mapping clair. - */ - -import { Decimal } from 'decimal.js'; -import type { Logger } from 'pino'; -import type { - BaserowClient, - BaserowListOptions, - BaserowPaginatedResponse, - BaserowRow, -} from '../adapters/baserow-client.js'; -import { Attribution } from '../domain/attribution.js'; -import { Bloc } from '../domain/bloc.js'; -import { Client as ClientEntity } from '../domain/client.js'; -import { Formation } from '../domain/formation.js'; -import { Intervention } from '../domain/intervention.js'; -import { Module } from '../domain/module.js'; -import { Personne } from '../domain/personne.js'; -import { Projet } from '../domain/projet.js'; -import { Tache } from '../domain/tache.js'; -import type { - Filiere, - Priorite, - ProjetType, - Role, - StatutAttribution, - StatutClient, - StatutFormation, - StatutIntervention, - StatutModule, - StatutPersonne, - StatutProjet, - StatutTache, -} from '../domain/types.js'; -import { errors } from '../lib/errors.js'; - -/** Table names from baserow/seed/schema.json. */ -export const TABLE_NAMES = [ - 'personne', - 'formation', - 'bloc', - 'module', - 'attribution', - 'client', - 'projet', - 'tache', - 'intervention', -] as const; - -export type TableName = (typeof TABLE_NAMES)[number]; -export type TableIds = Record; - -/** Cast safe d'un select Baserow (objet `{id, value, color}`) vers la valeur string. */ -function readSelect(raw: unknown): string | null { - if (raw == null) return null; - if (typeof raw === 'string') return raw; - if (typeof raw === 'object' && raw !== null && 'value' in raw) { - const v = (raw as { value: unknown }).value; - return typeof v === 'string' ? v : null; - } - return null; -} - -function readMultiSelect(raw: unknown): string[] { - if (!Array.isArray(raw)) return []; - return raw - .map((item) => readSelect(item)) - .filter((v): v is string => typeof v === 'string' && v.length > 0); -} - -function readNumber(raw: unknown): Decimal { - if (raw == null || raw === '') return new Decimal(0); - if (raw instanceof Decimal) return raw; - if (typeof raw === 'number') return new Decimal(raw); - if (typeof raw === 'string') return new Decimal(raw); - return new Decimal(0); -} - -function readDate(raw: unknown): Date | null { - if (raw == null || raw === '') return null; - if (raw instanceof Date) return raw; - if (typeof raw === 'string') { - const d = new Date(raw); - return Number.isNaN(d.getTime()) ? null : d; - } - return null; -} - -function readString(raw: unknown, fallback = ''): string { - return typeof raw === 'string' ? raw : fallback; -} - -/** Retourne le 1er id d'un link field Baserow (`[{id, value}]`) ou null. */ -function readLinkId(raw: unknown): number | null { - if (!Array.isArray(raw) || raw.length === 0) return null; - const first = raw[0]; - if (typeof first === 'object' && first !== null && 'id' in first) { - const id = (first as { id: unknown }).id; - return typeof id === 'number' ? id : null; - } - if (typeof first === 'number') return first; - return null; -} - -export interface BaseRepoOptions { - client: BaserowClient; - tableId: number; - entityName: string; - logger: Logger; -} - -abstract class BaseRepo { - protected readonly client: BaserowClient; - protected readonly tableId: number; - protected readonly entityName: string; - protected readonly logger: Logger; - - constructor(opts: BaseRepoOptions) { - this.client = opts.client; - this.tableId = opts.tableId; - this.entityName = opts.entityName; - this.logger = opts.logger.child({ repo: opts.entityName }); - } - - protected abstract toDomain(row: BaserowRow): TDomain; - - async list(opts: BaserowListOptions = {}): Promise<{ - items: TDomain[]; - meta: { - page: number; - per_page: number; - total: number; - total_pages: number; - skipped?: number; - }; - }> { - const page = opts.page ?? 1; - const size = Math.min(opts.size ?? 50, 200); - const res: BaserowPaginatedResponse = await this.client.listRows(this.tableId, { - ...opts, - page, - size, - }); - // Skip rows that fail domain validation (split != 100, etc.) plutot que - // de casser la liste entiere. La row corrompue est loguee pour investigation - // manuelle. cf doc 19 §10 : robustness vs visibility. - const items: TDomain[] = []; - let skipped = 0; - for (const row of res.results) { - try { - items.push(this.toDomain(row)); - } catch (err) { - skipped++; - this.logger.warn( - { rowId: row.id, err: err instanceof Error ? err.message : String(err) }, - 'row skipped — invalid domain mapping', - ); - } - } - return { - items, - meta: { - page, - per_page: size, - total: res.count, - total_pages: Math.max(1, Math.ceil(res.count / size)), - ...(skipped > 0 ? { skipped } : {}), - }, - }; - } - - async get(id: number): Promise { - try { - const row = await this.client.getRow(this.tableId, id); - return this.toDomain(row); - } catch (err) { - if (err instanceof Error && 'code' in err && err.code === 'NOT_FOUND') { - throw errors.notFound(this.entityName, id); - } - throw err; - } - } - - async getRaw(id: number): Promise { - try { - return await this.client.getRow(this.tableId, id); - } catch (err) { - if (err instanceof Error && 'code' in err && err.code === 'NOT_FOUND') { - throw errors.notFound(this.entityName, id); - } - throw err; - } - } -} - -// --------------------------------------------------------------------------- -// Personne -// --------------------------------------------------------------------------- - -export class PersonneRepo extends BaseRepo { - /** - * Recherche par email exact. Renvoie null si aucune Personne ne match - * (au lieu de NOT_FOUND) — utile pour l'auth OIDC ou un email orphelin - * n'est pas une erreur protocolaire mais un cas metier (politique strict/permissive). - */ - async findByEmail(email: string): Promise { - const normalized = email.trim().toLowerCase(); - if (normalized.length === 0) return null; - // Baserow filter __contains via `filter` ou `search` full-text. On utilise search - // (suffisant pour un email puisqu'il est unique) puis on filtre exact post-fetch - // pour eviter qu'un email substring matche un autre. - const res = await this.client.listRows(this.tableId, { - search: normalized, - size: 10, - }); - const exact = res.results.find((row) => { - const raw = row.personne_email; - return typeof raw === 'string' && raw.trim().toLowerCase() === normalized; - }); - if (!exact) return null; - try { - return this.toDomain(exact); - } catch (err) { - this.logger.warn( - { email: normalized, err: err instanceof Error ? err.message : String(err) }, - 'findByEmail: row malformee, ignoree', - ); - return null; - } - } - - protected toDomain(row: BaserowRow): Personne { - const splitFormation = readNumber(row.personne_split_formation_pct); - const splitAgence = readNumber(row.personne_split_agence_pct); - const roles = readMultiSelect(row.personne_roles).filter((r): r is Role => - ['formateur', 'developpeur', 'admin', 'direction', 'support'].includes(r), - ); - const statutRaw = readSelect(row.personne_statut) ?? 'actif'; - const statut: StatutPersonne = statutRaw === 'inactif' ? 'inactif' : 'actif'; - - return new Personne({ - id: row.id, - nom: readString(row.personne_nom), - prenom: readString(row.personne_prenom), - email: readString(row.personne_email), - capaciteAnnuelle: readNumber(row.personne_capacite_annuelle), - splitFormationPct: splitFormation, - splitAgencePct: splitAgence, - roles: new Set(roles), - statut, - heuresAttribueesFormation: readNumber(row.personne_heures_attribuees_formation), - heuresAttribueesAgence: readNumber(row.personne_heures_attribuees_agence), - }); - } -} - -// --------------------------------------------------------------------------- -// Formation + Bloc + Module + Attribution -// --------------------------------------------------------------------------- - -export class FormationRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Formation { - const filiereRaw = readSelect(row.formation_filiere); - const filiere: Filiere | null = - filiereRaw && ['dev', 'graphisme', 'marketing', 'iot', 'cybersec'].includes(filiereRaw) - ? (filiereRaw as Filiere) - : null; - const statutRaw = readSelect(row.formation_statut) ?? 'draft'; - const statut: StatutFormation = ['draft', 'actif', 'termine', 'archive'].includes(statutRaw) - ? (statutRaw as StatutFormation) - : 'draft'; - - return new Formation({ - id: row.id, - nom: readString(row.formation_nom), - filiere, - heuresTotales: readNumber(row.formation_heures_totales), - statut, - dateDebut: readDate(row.formation_date_debut), - dateFin: readDate(row.formation_date_fin), - }); - } -} - -export class BlocRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Bloc { - const formationId = readLinkId(row.bloc_formation) ?? 0; - return new Bloc({ - id: row.id, - formationId, - nom: readString(row.bloc_nom), - heuresPrevues: readNumber(row.bloc_heures_prevues), - ordre: Number(readNumber(row.bloc_ordre).toFixed(0)), - }); - } -} - -export class ModuleRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Module { - const blocId = readLinkId(row.module_bloc) ?? 0; - const statutRaw = readSelect(row.module_statut) ?? 'a_attribuer'; - const statut: StatutModule = [ - 'a_attribuer', - 'attribue', - 'en_cours', - 'realise', - 'annule', - ].includes(statutRaw) - ? (statutRaw as StatutModule) - : 'a_attribuer'; - return new Module({ - id: row.id, - blocId, - nom: readString(row.module_nom), - heuresPrevues: readNumber(row.module_heures_prevues), - statut, - }); - } -} - -export class AttributionRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Attribution { - const moduleId = readLinkId(row.attribution_module) ?? 0; - const personneId = readLinkId(row.attribution_personne) ?? 0; - const statutRaw = readSelect(row.attribution_statut) ?? 'planifie'; - const statut: StatutAttribution = ['planifie', 'en_cours', 'realise', 'annule'].includes( - statutRaw, - ) - ? (statutRaw as StatutAttribution) - : 'planifie'; - const heuresAttribuees = readNumber(row.attribution_heures_attribuees); - if (heuresAttribuees.lte(0)) { - // Domain refuse heures <= 0. Skip cette ligne corrompue plutot que de crasher la liste. - throw errors.internal(`Attribution row ${row.id} a heures_attribuees <= 0`); - } - return new Attribution({ - id: row.id, - moduleId, - personneId, - heuresAttribuees, - heuresRealisees: readNumber(row.attribution_heures_realisees), - dateDebut: readDate(row.attribution_date_debut), - dateFin: readDate(row.attribution_date_fin), - statut, - }); - } - - /** - * Persist domain Attribution → Baserow row (champs writable uniquement). - * Renvoie la row creee (avec id assigne par Baserow). - */ - async create(input: { - moduleId: number; - personneId: number; - heuresAttribuees: Decimal; - dateDebut: Date | null; - dateFin: Date | null; - statut: StatutAttribution; - }): Promise { - return this.client.createRow(this.tableId, { - attribution_heures_attribuees: input.heuresAttribuees.toNumber(), - attribution_heures_realisees: 0, - attribution_date_debut: input.dateDebut?.toISOString().slice(0, 10) ?? null, - attribution_date_fin: input.dateFin?.toISOString().slice(0, 10) ?? null, - attribution_statut: input.statut, - attribution_module: [input.moduleId], - attribution_personne: [input.personneId], - }); - } - - async updateHeuresRealisees(id: number, heures: Decimal): Promise { - return this.client.updateRow(this.tableId, id, { - attribution_heures_realisees: heures.toNumber(), - }); - } -} - -// --------------------------------------------------------------------------- -// Client / Projet / Tache / Intervention -// --------------------------------------------------------------------------- - -export class ClientRepo extends BaseRepo { - protected toDomain(row: BaserowRow): ClientEntity { - const statutRaw = readSelect(row.client_statut) ?? 'prospect'; - const statut: StatutClient = ['prospect', 'actif', 'inactif', 'archive'].includes(statutRaw) - ? (statutRaw as StatutClient) - : 'prospect'; - return new ClientEntity({ - id: row.id, - nom: readString(row.client_nom), - contactPrincipal: readString(row.client_contact_principal) || null, - contactEmail: readString(row.client_contact_email) || null, - contactTelephone: readString(row.client_contact_telephone) || null, - secteur: readString(row.client_secteur) || null, - notes: readString(row.client_notes) || null, - statut, - }); - } -} - -export class ProjetRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Projet { - const clientId = readLinkId(row.projet_client) ?? 0; - const formationId = readLinkId(row.projet_formation_pedagogique); - const typeRaw = readSelect(row.projet_type); - const type: ProjetType | null = - typeRaw && - ['site_web', 'app_mobile', 'api', 'infra', 'audit', 'support', 'autre'].includes(typeRaw) - ? (typeRaw as ProjetType) - : null; - const statutRaw = readSelect(row.projet_statut) ?? 'devis'; - const statut: StatutProjet = ['devis', 'en_cours', 'livre', 'cloture', 'abandonne'].includes( - statutRaw, - ) - ? (statutRaw as StatutProjet) - : 'devis'; - - return new Projet({ - id: row.id, - clientId, - nom: readString(row.projet_nom), - type, - chargeHeures: readNumber(row.projet_charge_heures), - statut, - formationId, - }); - } -} - -export class TacheRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Tache { - const projetId = readLinkId(row.tache_projet) ?? 0; - const prioriteRaw = readSelect(row.tache_priorite); - const priorite: Priorite | null = - prioriteRaw && ['faible', 'normale', 'haute', 'critique'].includes(prioriteRaw) - ? (prioriteRaw as Priorite) - : null; - const statutRaw = readSelect(row.tache_statut) ?? 'todo'; - const statut: StatutTache = ['todo', 'in_progress', 'review', 'done', 'abandoned'].includes( - statutRaw, - ) - ? (statutRaw as StatutTache) - : 'todo'; - - return new Tache({ - id: row.id, - projetId, - titre: readString(row.tache_titre), - chargeHeures: readNumber(row.tache_charge_heures), - priorite, - statut, - }); - } -} - -export class InterventionRepo extends BaseRepo { - protected toDomain(row: BaserowRow): Intervention { - const tacheId = readLinkId(row.intervention_tache) ?? 0; - const personneId = readLinkId(row.intervention_personne) ?? 0; - const statutRaw = readSelect(row.intervention_statut) ?? 'realise'; - const statut: StatutIntervention = ['planifie', 'realise', 'annule'].includes(statutRaw) - ? (statutRaw as StatutIntervention) - : 'realise'; - const date = readDate(row.intervention_date) ?? new Date(); - return new Intervention({ - id: row.id, - tacheId, - personneId, - heures: readNumber(row.intervention_heures), - date, - notes: readString(row.intervention_notes) || null, - statut, - }); - } - - async create(input: { - tacheId: number; - personneId: number; - heures: Decimal; - date: Date; - notes: string | null; - statut: StatutIntervention; - }): Promise { - return this.client.createRow(this.tableId, { - intervention_heures: input.heures.toNumber(), - intervention_date: input.date.toISOString().slice(0, 10), - intervention_notes: input.notes, - intervention_statut: input.statut, - intervention_tache: [input.tacheId], - intervention_personne: [input.personneId], - }); - } -} - -export interface RepoSet { - personnes: PersonneRepo; - formations: FormationRepo; - blocs: BlocRepo; - modules: ModuleRepo; - attributions: AttributionRepo; - clients: ClientRepo; - projets: ProjetRepo; - taches: TacheRepo; - interventions: InterventionRepo; -} - -export function buildRepos(client: BaserowClient, tableIds: TableIds, logger: Logger): RepoSet { - return { - personnes: new PersonneRepo({ - client, - tableId: tableIds.personne, - entityName: 'Personne', - logger, - }), - formations: new FormationRepo({ - client, - tableId: tableIds.formation, - entityName: 'Formation', - logger, - }), - blocs: new BlocRepo({ client, tableId: tableIds.bloc, entityName: 'Bloc', logger }), - modules: new ModuleRepo({ client, tableId: tableIds.module, entityName: 'Module', logger }), - attributions: new AttributionRepo({ - client, - tableId: tableIds.attribution, - entityName: 'Attribution', - logger, - }), - clients: new ClientRepo({ - client, - tableId: tableIds.client, - entityName: 'Client', - logger, - }), - projets: new ProjetRepo({ - client, - tableId: tableIds.projet, - entityName: 'Projet', - logger, - }), - taches: new TacheRepo({ client, tableId: tableIds.tache, entityName: 'Tache', logger }), - interventions: new InterventionRepo({ - client, - tableId: tableIds.intervention, - entityName: 'Intervention', - logger, - }), - }; -} diff --git a/bridge/src/repos/baserow-rows-repo.ts b/bridge/src/repos/baserow-rows-repo.ts new file mode 100644 index 0000000..edddc1e --- /dev/null +++ b/bridge/src/repos/baserow-rows-repo.ts @@ -0,0 +1,114 @@ +/** + * Repository rows Baserow — CRUD generique par tableId. + * + * Le bridge proxie tel quel : pas de mapping metier, le payload `fields` est + * un Record opaque shipped vers Baserow. Pagination + filter/search/orderBy + * supportes en read. + */ + +import type { Logger } from 'pino'; +import type { + BaserowClient, + BaserowListOptions, + BaserowPaginatedResponse, +} from '../adapters/baserow-client.js'; +import { Row } from '../domain/row.js'; +import { errors } from '../lib/errors.js'; + +export interface BaserowRowsRepoOptions { + client: BaserowClient; + logger: Logger; +} + +export interface ListRowsResult { + items: Row[]; + meta: { + page: number; + per_page: number; + total: number; + total_pages: number; + }; +} + +function rawToRow( + raw: { id: number; order?: string | unknown } & Record, + tableId: number, +): Row { + const { id, order, ...fields } = raw; + return new Row({ + id, + tableId, + fields, + order: typeof order === 'string' ? order : null, + }); +} + +export class BaserowRowsRepo { + protected readonly client: BaserowClient; + protected readonly logger: Logger; + + constructor(opts: BaserowRowsRepoOptions) { + this.client = opts.client; + this.logger = opts.logger.child({ repo: 'rows' }); + } + + async list(tableId: number, opts: BaserowListOptions = {}): Promise { + const page = opts.page ?? 1; + const size = Math.min(opts.size ?? 50, 200); + const res: BaserowPaginatedResponse = await this.client.listRows(tableId, { + ...opts, + page, + size, + }); + const items = res.results.map((r) => rawToRow(r, tableId)); + return { + items, + meta: { + page, + per_page: size, + total: res.count, + total_pages: Math.max(1, Math.ceil(res.count / size)), + }, + }; + } + + async get(tableId: number, rowId: number): Promise { + try { + const raw = await this.client.getRow(tableId, rowId); + return rawToRow(raw, tableId); + } catch (err) { + if (err instanceof Error && 'code' in err && (err as { code: string }).code === 'NOT_FOUND') { + throw errors.notFound('Row', rowId); + } + throw err; + } + } + + async create(tableId: number, fields: Record): Promise { + const raw = await this.client.createRow(tableId, fields); + return rawToRow(raw, tableId); + } + + async update(tableId: number, rowId: number, fields: Record): Promise { + try { + const raw = await this.client.updateRow(tableId, rowId, fields); + return rawToRow(raw, tableId); + } catch (err) { + if (err instanceof Error && 'code' in err && (err as { code: string }).code === 'NOT_FOUND') { + throw errors.notFound('Row', rowId); + } + throw err; + } + } + + async delete(tableId: number, rowId: number): Promise { + try { + await this.client.deleteRow(tableId, rowId); + } catch (err) { + if (err instanceof Error && 'code' in err && (err as { code: string }).code === 'NOT_FOUND') { + throw errors.notFound('Row', rowId); + } + throw err; + } + } +} diff --git a/bridge/src/repos/baserow-tables-repo.ts b/bridge/src/repos/baserow-tables-repo.ts new file mode 100644 index 0000000..401abde --- /dev/null +++ b/bridge/src/repos/baserow-tables-repo.ts @@ -0,0 +1,50 @@ +/** + * Repository tables Baserow — list / get metadata. + * + * Note JWT vs DB token : `listTables` et `getTable` necessitent un JWT user + * (Baserow API distingue). Avec un DB token (`Token brg_*`), Baserow renvoie + * 401. Le caller (route `/api/v1/tables`) doit traduire en `501 + * NOT_IMPLEMENTED` avec un message clair. + */ + +import type { Logger } from 'pino'; +import type { BaserowClient } from '../adapters/baserow-client.js'; +import { Table } from '../domain/table.js'; + +export interface BaserowTablesRepoOptions { + client: BaserowClient; + logger: Logger; +} + +export class BaserowTablesRepo { + protected readonly client: BaserowClient; + protected readonly logger: Logger; + + constructor(opts: BaserowTablesRepoOptions) { + this.client = opts.client; + this.logger = opts.logger.child({ repo: 'tables' }); + } + + async list(databaseId: number): Promise { + const raws = await this.client.listTables(databaseId); + return raws.map( + (r) => + new Table({ + id: r.id, + name: r.name, + databaseId: r.database_id, + orderIndex: r.order, + }), + ); + } + + async get(tableId: number): Promise { + const raw = await this.client.getTable(tableId); + return new Table({ + id: raw.id, + name: raw.name, + databaseId: raw.database_id, + orderIndex: raw.order, + }); + } +} diff --git a/bridge/src/repos/baserow-views-repo.ts b/bridge/src/repos/baserow-views-repo.ts new file mode 100644 index 0000000..4d8ed77 --- /dev/null +++ b/bridge/src/repos/baserow-views-repo.ts @@ -0,0 +1,77 @@ +/** + * Repository views Baserow — list par tableId + run grid view. + * + * `runGridView` execute la vue avec ses filtres/sorts Baserow et retourne les + * rows mappees. DB token OK. + */ + +import type { Logger } from 'pino'; +import type { BaserowClient, BaserowListOptions } from '../adapters/baserow-client.js'; +import { Row } from '../domain/row.js'; +import { View } from '../domain/view.js'; + +export interface BaserowViewsRepoOptions { + client: BaserowClient; + logger: Logger; +} + +export interface ListRowsResult { + items: Row[]; + meta: { + page: number; + per_page: number; + total: number; + total_pages: number; + }; +} + +export class BaserowViewsRepo { + protected readonly client: BaserowClient; + protected readonly logger: Logger; + + constructor(opts: BaserowViewsRepoOptions) { + this.client = opts.client; + this.logger = opts.logger.child({ repo: 'views' }); + } + + async list(tableId: number): Promise { + const raws = await this.client.listViews(tableId); + return raws.map( + (r) => + new View({ + id: r.id, + name: r.name, + type: r.type, + tableId: r.table_id, + }), + ); + } + + async runGrid( + viewId: number, + tableId: number, + opts: BaserowListOptions = {}, + ): Promise { + const page = opts.page ?? 1; + const size = Math.min(opts.size ?? 50, 200); + const res = await this.client.getGridViewRows(viewId, { ...opts, page, size }); + const items = res.results.map((r) => { + const { id, order, ...fields } = r; + return new Row({ + id, + tableId, + fields, + order: typeof order === 'string' ? order : null, + }); + }); + return { + items, + meta: { + page, + per_page: size, + total: res.count, + total_pages: Math.max(1, Math.ceil(res.count / size)), + }, + }; + } +} diff --git a/bridge/src/routes/attributions.ts b/bridge/src/routes/attributions.ts deleted file mode 100644 index b78c89a..0000000 --- a/bridge/src/routes/attributions.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Routes /api/v1/attributions — PATCH /:id/heures-realisees. - * Reuse Attribution.saisirHeuresRealisees pour la transition d'etat. - */ - -import { Decimal } from 'decimal.js'; -import { Hono } from 'hono'; -import { z } from 'zod'; -import { invalidateEntity } from '../lib/cache.js'; -import { getContainer } from '../lib/container.js'; -import { errors } from '../lib/errors.js'; -import { dec, parseBody } from '../lib/http.js'; -import { type AuthVariables, requireScope } from '../middleware/auth.js'; - -export const attributionsRoutes = new Hono<{ Variables: AuthVariables }>(); - -const HeuresRealiseesBodySchema = z.object({ - heures_realisees: z.number().nonnegative(), - comment: z.string().optional(), -}); - -attributionsRoutes.patch('/:id/heures-realisees', requireScope('write:attributions'), async (c) => { - const id = Number.parseInt(c.req.param('id'), 10); - if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]); - const body = await parseBody(c, HeuresRealiseesBodySchema); - - const { repos } = getContainer(); - const attribution = await repos.attributions.get(id); - - try { - attribution.saisirHeuresRealisees(new Decimal(body.heures_realisees)); - } catch (err) { - if (err instanceof Error) { - const msg = err.message; - if (msg.includes('annule') || msg.includes('realise')) { - throw errors.conflict(msg, { attributionId: id, statut: attribution.statut }); - } - throw errors.validation([{ message: msg }]); - } - throw err; - } - - await repos.attributions.updateHeuresRealisees(id, attribution.heuresRealisees); - - // Invalidation cache locale apres write — ferme la fenetre stale pre-webhook. - const { redis } = getContainer(); - await invalidateEntity(redis, 'attribution', id); - - return c.json({ - data: { - attribution_id: id, - heures_attribuees: dec(attribution.heuresAttribuees), - heures_realisees: dec(attribution.heuresRealisees), - statut: attribution.statut, - }, - }); -}); diff --git a/bridge/src/routes/formations.ts b/bridge/src/routes/formations.ts deleted file mode 100644 index 93f56d3..0000000 --- a/bridge/src/routes/formations.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Routes /api/v1/formations — read-only Tier 1. - * Le detail compose blocs + modules en assemblant les list endpoints repo. - */ - -import { Decimal } from 'decimal.js'; -import { Hono } from 'hono'; -import type { Bloc } from '../domain/bloc.js'; -import type { Formation } from '../domain/formation.js'; -import type { Module } from '../domain/module.js'; -import { getContainer } from '../lib/container.js'; -import { errors } from '../lib/errors.js'; -import { dec, parseListQuery } from '../lib/http.js'; -import { type AuthVariables, requireScope } from '../middleware/auth.js'; - -export const formationsRoutes = new Hono<{ Variables: AuthVariables }>(); - -function serializeFormation(f: Formation) { - return { - id: f.id, - nom: f.nom, - filiere: f.filiere, - heures_totales: dec(f.heuresTotales), - statut: f.statut, - date_debut: f.dateDebut?.toISOString() ?? null, - date_fin: f.dateFin?.toISOString() ?? null, - }; -} - -function serializeModule(m: Module) { - return { - id: m.id, - bloc_id: m.blocId, - nom: m.nom, - heures_prevues: dec(m.heuresPrevues), - statut: m.statut, - }; -} - -function serializeBloc(b: Bloc, modules: Module[]) { - return { - id: b.id, - formation_id: b.formationId, - nom: b.nom, - heures_prevues: dec(b.heuresPrevues), - ordre: b.ordre, - modules: modules.map(serializeModule), - }; -} - -formationsRoutes.get('/', requireScope('read:formations'), async (c) => { - const { page, per_page, sort } = parseListQuery(c); - const { repos } = getContainer(); - const result = await repos.formations.list({ page, size: per_page, orderBy: sort }); - return c.json({ data: result.items.map(serializeFormation), meta: result.meta }); -}); - -formationsRoutes.get('/:id', requireScope('read:formations'), async (c) => { - const id = Number.parseInt(c.req.param('id'), 10); - if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]); - const { repos } = getContainer(); - const formation = await repos.formations.get(id); - - // Recupere blocs + modules. Pas d'index server-side par link → filter client-side. - const [allBlocs, allModules] = await Promise.all([ - repos.blocs.list({ size: 200 }), - repos.modules.list({ size: 200 }), - ]); - const blocs = allBlocs.items.filter((b) => b.formationId === id); - const blocsSerialized = blocs.map((b) => { - const modules = allModules.items.filter((m) => m.blocId === b.id); - return serializeBloc(b, modules); - }); - - // Le repo Formation ne charge pas la liste imbriquee de blocs (un appel par list). - // On recalcule les rollups a partir des blocs fetched ci-dessus, plutot que d'appeler - // formation.heuresAttribuees() qui retournerait 0. - const heuresAttribuees = blocs.reduce((acc, b) => acc.plus(b.heuresPrevues), new Decimal(0)); - const heuresRestantes = formation.heuresTotales.minus(heuresAttribuees); - - return c.json({ - data: { - ...serializeFormation(formation), - blocs: blocsSerialized, - heures_attribuees: dec(heuresAttribuees), - heures_restantes: dec(heuresRestantes), - }, - }); -}); diff --git a/bridge/src/routes/interventions.ts b/bridge/src/routes/interventions.ts deleted file mode 100644 index 600c37e..0000000 --- a/bridge/src/routes/interventions.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Routes /api/v1/interventions — write Tier 1. - * Tache.creerIntervention valide role developpeur + heures > 0. - */ - -import { Decimal } from 'decimal.js'; -import { Hono } from 'hono'; -import { z } from 'zod'; -import { invalidateEntity } from '../lib/cache.js'; -import { getContainer } from '../lib/container.js'; -import { errors } from '../lib/errors.js'; -import { dec, parseBody } from '../lib/http.js'; -import { type AuthVariables, requireScope } from '../middleware/auth.js'; - -export const interventionsRoutes = new Hono<{ Variables: AuthVariables }>(); - -const InterventionBodySchema = z.object({ - tache_id: z.number().int().positive(), - personne_id: z.number().int().positive(), - heures: z.number().positive(), - date: z.string(), - notes: z.string().optional().nullable(), -}); - -interventionsRoutes.post('/', requireScope('write:interventions'), async (c) => { - const body = await parseBody(c, InterventionBodySchema); - - const { repos } = getContainer(); - const [tache, personne] = await Promise.all([ - repos.taches.get(body.tache_id), - repos.personnes.get(body.personne_id), - ]); - - const date = new Date(body.date); - if (Number.isNaN(date.getTime())) { - throw errors.validation([{ message: 'date must be a valid ISO date' }]); - } - - let createdId = 0; - try { - const intervention = tache.creerIntervention(personne, new Decimal(body.heures), date, 0); - const row = await repos.interventions.create({ - tacheId: tache.id, - personneId: personne.id, - heures: intervention.heures, - date: intervention.date, - notes: body.notes ?? null, - statut: intervention.statut, - }); - createdId = row.id; - } catch (err) { - if (err instanceof Error) { - const msg = err.message; - if ( - msg.includes('developpeur') || - msg.includes('inactive') || - msg.includes('heures doit etre') - ) { - throw errors.validation([{ message: msg }]); - } - } - throw err; - } - - // Invalidation cache locale apres create — cascade tache + personne (rollups). - const { redis } = getContainer(); - await invalidateEntity(redis, 'intervention', createdId); - - return c.json( - { - data: { - intervention_id: createdId, - tache_id: tache.id, - personne_id: personne.id, - heures: dec(new Decimal(body.heures)), - date: date.toISOString(), - statut: 'realise', - }, - }, - 201, - ); -}); diff --git a/bridge/src/routes/modules.ts b/bridge/src/routes/modules.ts deleted file mode 100644 index 4a1441a..0000000 --- a/bridge/src/routes/modules.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Routes /api/v1/modules — write Tier 1 : POST /:id/attribuer. - * - * RG-01 enforced via Module.creerAttribution. Le domain throw si dépassement — - * on convertit en BridgeError 422. La persistance suit la validation domaine - * (write-after-validate) : si Baserow echoue, le rollup deja applique cote - * Personne in-memory n'est pas persiste mais l'objet est jete (route stateless). - */ - -import { Decimal } from 'decimal.js'; -import { Hono } from 'hono'; -import { z } from 'zod'; -import { invalidateEntity } from '../lib/cache.js'; -import { getContainer } from '../lib/container.js'; -import { errors } from '../lib/errors.js'; -import { dec, parseBody } from '../lib/http.js'; -import { type AuthVariables, requireScope } from '../middleware/auth.js'; - -export const modulesRoutes = new Hono<{ Variables: AuthVariables }>(); - -const AttribuerBodySchema = z.object({ - personne_id: z.number().int().positive(), - heures: z.number().positive(), - date_debut: z.string().datetime().optional().nullable(), - date_fin: z.string().datetime().optional().nullable(), -}); - -modulesRoutes.post('/:id/attribuer', requireScope('write:attributions'), async (c) => { - const moduleId = Number.parseInt(c.req.param('id'), 10); - if (Number.isNaN(moduleId)) { - throw errors.validation([{ message: 'module id must be a number' }]); - } - const body = await parseBody(c, AttribuerBodySchema); - - const { repos } = getContainer(); - - // 1. Charger module + ses attributions actives pour evaluer RG-01. - const [moduleEntity, personne, allAttribs] = await Promise.all([ - repos.modules.get(moduleId), - repos.personnes.get(body.personne_id), - repos.attributions.list({ size: 200 }), - ]); - for (const attrib of allAttribs.items.filter((a) => a.moduleId === moduleId)) { - moduleEntity.attributions.push(attrib); - } - - const dateDebut = body.date_debut ? new Date(body.date_debut) : null; - const dateFin = body.date_fin ? new Date(body.date_fin) : null; - - let createdId = 0; - try { - const attribution = moduleEntity.creerAttribution( - personne, - new Decimal(body.heures), - dateDebut, - dateFin, - 0, // id provisoire — Baserow attribuera le vrai - ); - const row = await repos.attributions.create({ - moduleId, - personneId: personne.id, - heuresAttribuees: attribution.heuresAttribuees, - dateDebut, - dateFin, - statut: attribution.statut, - }); - createdId = row.id; - } catch (err) { - if (err instanceof Error) { - const msg = err.message; - if (msg.includes('RG-01')) { - throw errors.rgViolation('RG-01', msg, { - moduleId, - personneId: body.personne_id, - heuresPrevues: moduleEntity.heuresPrevues.toNumber(), - heuresDejaAttribuees: moduleEntity - .heuresAttribuees() - .minus(new Decimal(body.heures)) - .toNumber(), - heuresDemandees: body.heures, - }); - } - if (msg.includes('formateur') || msg.includes('inactive') || msg.includes('heures')) { - throw errors.validation([{ message: msg }]); - } - } - throw err; - } - - // Invalidation cache locale apres create — cascade module + personne (rollups). - const { redis } = getContainer(); - await invalidateEntity(redis, 'attribution', createdId); - - return c.json( - { - data: { - attribution_id: createdId, - module_id: moduleId, - personne_id: personne.id, - heures_attribuees: dec(new Decimal(body.heures)), - statut: 'planifie', - }, - }, - 201, - ); -}); diff --git a/bridge/src/routes/personnes.ts b/bridge/src/routes/personnes.ts deleted file mode 100644 index 313c51b..0000000 --- a/bridge/src/routes/personnes.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Routes /api/v1/personnes — read-only (Tier 1 MVP). - * Le dashboard agrège attributions + interventions pour donner une vue 360 capacite. - */ - -import { Decimal } from 'decimal.js'; -import { Hono } from 'hono'; -import type { Personne } from '../domain/personne.js'; -import { getContainer } from '../lib/container.js'; -import { errors } from '../lib/errors.js'; -import { dec, parseListQuery } from '../lib/http.js'; -import { type AuthVariables, requireScope } from '../middleware/auth.js'; - -export const personnesRoutes = new Hono<{ Variables: AuthVariables }>(); - -function serializePersonne(p: Personne) { - return { - id: p.id, - nom: p.nom, - prenom: p.prenom, - email: p.email, - capacite_annuelle: dec(p.capaciteAnnuelle), - split_formation_pct: dec(p.splitFormationPct), - split_agence_pct: dec(p.splitAgencePct), - roles: Array.from(p.roles), - statut: p.statut, - heures_attribuees_formation: dec(p.heuresAttribueesFormation), - heures_attribuees_agence: dec(p.heuresAttribueesAgence), - heures_restantes_formation: dec(p.heuresRestantesFormation()), - heures_restantes_agence: dec(p.heuresRestantesAgence()), - heures_restantes_total: dec(p.heuresRestantesTotal()), - }; -} - -personnesRoutes.get('/', requireScope('read:personnes'), async (c) => { - const { page, per_page, filter, sort } = parseListQuery(c); - const { repos } = getContainer(); - // Baserow filter API: only push role/statut, ignore unknown filters silencieusement. - const baseFilter: Record = {}; - if (filter.role) baseFilter.personne_roles = filter.role; - if (filter.statut) baseFilter.personne_statut = filter.statut; - - const result = await repos.personnes.list({ - page, - size: per_page, - filter: baseFilter, - orderBy: sort, - }); - return c.json({ - data: result.items.map(serializePersonne), - meta: result.meta, - }); -}); - -personnesRoutes.get('/:id', requireScope('read:personnes'), async (c) => { - const id = Number.parseInt(c.req.param('id'), 10); - if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]); - const { repos } = getContainer(); - const personne = await repos.personnes.get(id); - return c.json({ data: serializePersonne(personne) }); -}); - -personnesRoutes.get('/:id/dashboard', requireScope('read:personnes'), async (c) => { - const id = Number.parseInt(c.req.param('id'), 10); - if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]); - const { repos } = getContainer(); - const personne = await repos.personnes.get(id); - - // Filtre cote Baserow par link id : `filter__attribution_personne__link_row_has` - // n'est pas standard ; on ramene la liste paginee large et filtre cote bridge. - const [attribs, interventions] = await Promise.all([ - repos.attributions.list({ size: 200 }), - repos.interventions.list({ size: 200 }), - ]); - const myAttribs = attribs.items.filter((a) => a.personneId === id); - const myInterv = interventions.items.filter((i) => i.personneId === id); - const attribsActives = myAttribs.filter((a) => a.isActive()); - - return c.json({ - data: { - personne: serializePersonne(personne), - capacite: { - annuelle: dec(personne.capaciteAnnuelle), - formation: dec(personne.capaciteFormation()), - agence: dec(personne.capaciteAgence()), - }, - attributions: { - total: myAttribs.length, - actives: attribsActives.length, - items: attribsActives.map((a) => ({ - id: a.id, - module_id: a.moduleId, - heures_attribuees: dec(a.heuresAttribuees), - heures_realisees: dec(a.heuresRealisees), - statut: a.statut, - date_debut: a.dateDebut?.toISOString() ?? null, - date_fin: a.dateFin?.toISOString() ?? null, - })), - }, - interventions: { - total: myInterv.length, - actives: myInterv.filter((i) => i.isActive()).length, - heures_total: dec( - myInterv - .filter((i) => i.statut !== 'annule') - .reduce((acc, i) => acc.plus(i.heures), new Decimal(0)), - ), - }, - }, - }); -}); diff --git a/bridge/src/routes/projets.ts b/bridge/src/routes/projets.ts deleted file mode 100644 index 153c286..0000000 --- a/bridge/src/routes/projets.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Routes /api/v1/projets — read-only Tier 1. - */ - -import { Decimal } from 'decimal.js'; -import { Hono } from 'hono'; -import type { Projet } from '../domain/projet.js'; -import type { Tache } from '../domain/tache.js'; -import { getContainer } from '../lib/container.js'; -import { errors } from '../lib/errors.js'; -import { dec, parseListQuery } from '../lib/http.js'; -import { type AuthVariables, requireScope } from '../middleware/auth.js'; - -export const projetsRoutes = new Hono<{ Variables: AuthVariables }>(); - -function serializeProjet(p: Projet) { - return { - id: p.id, - client_id: p.clientId, - nom: p.nom, - type: p.type, - charge_heures: dec(p.chargeHeures), - statut: p.statut, - formation_id: p.formationId, - }; -} - -function serializeTache(t: Tache) { - return { - id: t.id, - projet_id: t.projetId, - titre: t.titre, - charge_heures: dec(t.chargeHeures), - priorite: t.priorite, - statut: t.statut, - heures_realisees: dec(t.heuresRealisees()), - }; -} - -projetsRoutes.get('/', requireScope('read:projets'), async (c) => { - const { page, per_page, filter, sort } = parseListQuery(c); - const { repos } = getContainer(); - const baseFilter: Record = {}; - if (filter.statut) baseFilter.projet_statut = filter.statut; - if (filter.client) baseFilter.projet_client = filter.client; - - const result = await repos.projets.list({ - page, - size: per_page, - filter: baseFilter, - orderBy: sort, - }); - return c.json({ data: result.items.map(serializeProjet), meta: result.meta }); -}); - -projetsRoutes.get('/:id', requireScope('read:projets'), async (c) => { - const id = Number.parseInt(c.req.param('id'), 10); - if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]); - const { repos } = getContainer(); - const projet = await repos.projets.get(id); - - const allTaches = await repos.taches.list({ size: 200 }); - const taches = allTaches.items.filter((t) => t.projetId === id); - - return c.json({ - data: { - ...serializeProjet(projet), - taches: taches.map(serializeTache), - heures_realisees: dec( - taches.reduce((acc, t) => acc.plus(t.heuresRealisees()), new Decimal(0)), - ), - heures_restantes: dec(projet.heuresRestantes()), - }, - }); -}); diff --git a/bridge/src/routes/tables.ts b/bridge/src/routes/tables.ts new file mode 100644 index 0000000..9a06beb --- /dev/null +++ b/bridge/src/routes/tables.ts @@ -0,0 +1,241 @@ +/** + * Routes /api/v1/tables — proxy generique style Notion. + * + * Read-only sur tables/fields/views (metadata Baserow), CRUD sur rows. Les + * scopes sont generiques : `read:tables`, `write:tables`. Les permissions + * fines sur des tables specifiques sont a faire cote consumer (DocAdenice + * RBAC R2 par exemple). + */ + +import { Hono } from 'hono'; +import type { Field } from '../domain/field.js'; +import type { Row } from '../domain/row.js'; +import { RowFieldsSchema } from '../domain/schemas.js'; +import type { Table } from '../domain/table.js'; +import type { View } from '../domain/view.js'; +import { invalidateTable } from '../lib/cache.js'; +import { getContainer } from '../lib/container.js'; +import { BridgeError, errors } from '../lib/errors.js'; +import { parseBody, parseListQuery } from '../lib/http.js'; +import { type AuthVariables, requireScope } from '../middleware/auth.js'; + +export const tablesRoutes = new Hono<{ Variables: AuthVariables }>(); + +// --------------------------------------------------------------------------- +// Serialisation +// --------------------------------------------------------------------------- + +function serializeTable(t: Table) { + return { + id: t.id, + name: t.name, + database_id: t.databaseId, + order_index: t.orderIndex, + fields: t.fields.length > 0 ? t.fields.map(serializeField) : undefined, + }; +} + +function serializeField(f: Field) { + return { + id: f.id, + name: f.name, + type: f.type, + primary: f.primary, + options: f.options, + }; +} + +function serializeView(v: View) { + return { + id: v.id, + name: v.name, + type: v.type, + table_id: v.tableId, + }; +} + +function serializeRow(r: Row) { + return { + id: r.id, + table_id: r.tableId, + fields: r.fields, + order: r.order, + created_on: r.createdOn?.toISOString() ?? null, + updated_on: r.updatedOn?.toISOString() ?? null, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseIntParam(raw: string, label: string): number { + const n = Number.parseInt(raw, 10); + if (Number.isNaN(n) || n <= 0) { + throw errors.validation([{ message: `${label} must be a positive integer` }]); + } + return n; +} + +/** 501 NOT_IMPLEMENTED helper pour le cas DB token sur un endpoint qui exige JWT. */ +function notImplementedJwtRequired(operation: string): BridgeError { + return new BridgeError( + 'INTERNAL', + 501, + `${operation} requires a Baserow user JWT — current bridge config uses a database token.`, + { reason: 'jwt_required', operation }, + ); +} + +// --------------------------------------------------------------------------- +// Tables (metadata) — JWT required cote Baserow upstream +// --------------------------------------------------------------------------- + +/** + * GET /tables — list tables d'une database. + * Query : `databaseId` (requis tant que pas de defaut configurable). + * + * Note : Baserow `/api/database/tables/database/:id/` requiert un JWT user. + * Avec un DB token, retourne 501 avec un message clair. + */ +tablesRoutes.get('/', requireScope('read:tables'), async (c) => { + const url = new URL(c.req.url); + const dbIdRaw = url.searchParams.get('databaseId') ?? url.searchParams.get('database_id'); + if (!dbIdRaw) { + throw errors.validation([{ message: 'databaseId query param required' }]); + } + const databaseId = parseIntParam(dbIdRaw, 'databaseId'); + const { repos } = getContainer(); + try { + const tables = await repos.tables.list(databaseId); + return c.json({ data: tables.map(serializeTable) }); + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err as { code: string }).code === 'AUTH_INVALID' + ) { + throw notImplementedJwtRequired('GET /tables'); + } + throw err; + } +}); + +tablesRoutes.get('/:tableId', requireScope('read:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const { repos } = getContainer(); + try { + const table = await repos.tables.get(tableId); + // On enrichit avec les fields (DB token-friendly). + const fields = await repos.fields.list(tableId); + return c.json({ + data: { + ...serializeTable(table), + fields: fields.map(serializeField), + }, + }); + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err as { code: string }).code === 'AUTH_INVALID' + ) { + throw notImplementedJwtRequired('GET /tables/:tableId'); + } + throw err; + } +}); + +// --------------------------------------------------------------------------- +// Fields +// --------------------------------------------------------------------------- + +tablesRoutes.get('/:tableId/fields', requireScope('read:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const { repos } = getContainer(); + const fields = await repos.fields.list(tableId); + return c.json({ data: fields.map(serializeField) }); +}); + +// --------------------------------------------------------------------------- +// Views +// --------------------------------------------------------------------------- + +tablesRoutes.get('/:tableId/views', requireScope('read:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const { repos } = getContainer(); + const views = await repos.views.list(tableId); + return c.json({ data: views.map(serializeView) }); +}); + +tablesRoutes.get('/:tableId/views/:viewId/rows', requireScope('read:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const viewId = parseIntParam(c.req.param('viewId'), 'viewId'); + const { page, per_page } = parseListQuery(c); + const url = new URL(c.req.url); + const search = url.searchParams.get('search') ?? undefined; + const { repos } = getContainer(); + const result = await repos.views.runGrid(viewId, tableId, { + page, + size: per_page, + search, + }); + return c.json({ data: result.items.map(serializeRow), meta: result.meta }); +}); + +// --------------------------------------------------------------------------- +// Rows CRUD +// --------------------------------------------------------------------------- + +tablesRoutes.get('/:tableId/rows', requireScope('read:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const { page, per_page, sort, filter } = parseListQuery(c); + const url = new URL(c.req.url); + const search = url.searchParams.get('search') ?? undefined; + const { repos } = getContainer(); + + const result = await repos.rows.list(tableId, { + page, + size: per_page, + search, + orderBy: sort, + filter: Object.keys(filter).length > 0 ? filter : undefined, + }); + return c.json({ data: result.items.map(serializeRow), meta: result.meta }); +}); + +tablesRoutes.get('/:tableId/rows/:rowId', requireScope('read:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const rowId = parseIntParam(c.req.param('rowId'), 'rowId'); + const { repos } = getContainer(); + const row = await repos.rows.get(tableId, rowId); + return c.json({ data: serializeRow(row) }); +}); + +tablesRoutes.post('/:tableId/rows', requireScope('write:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const fields = await parseBody(c, RowFieldsSchema); + const { repos, redis } = getContainer(); + const row = await repos.rows.create(tableId, fields); + await invalidateTable(redis, tableId, row.id); + return c.json({ data: serializeRow(row) }, 201); +}); + +tablesRoutes.patch('/:tableId/rows/:rowId', requireScope('write:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const rowId = parseIntParam(c.req.param('rowId'), 'rowId'); + const fields = await parseBody(c, RowFieldsSchema); + const { repos, redis } = getContainer(); + const row = await repos.rows.update(tableId, rowId, fields); + await invalidateTable(redis, tableId, rowId); + return c.json({ data: serializeRow(row) }); +}); + +tablesRoutes.delete('/:tableId/rows/:rowId', requireScope('write:tables'), async (c) => { + const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); + const rowId = parseIntParam(c.req.param('rowId'), 'rowId'); + const { repos, redis } = getContainer(); + await repos.rows.delete(tableId, rowId); + await invalidateTable(redis, tableId, rowId); + return c.body(null, 204); +}); diff --git a/bridge/src/routes/webhooks.ts b/bridge/src/routes/webhooks.ts index a7c7da1..e832f39 100644 --- a/bridge/src/routes/webhooks.ts +++ b/bridge/src/routes/webhooks.ts @@ -24,7 +24,7 @@ export const DOCMOST_SIGNATURE_HEADER = 'X-Docmost-Signature'; export const webhooksRoutes = new Hono(); webhooksRoutes.post('/baserow', async (c) => { - const { config, redis, tableIds, logger } = getContainer(); + const { config, redis, logger } = getContainer(); const signature = c.req.header(BASEROW_SIGNATURE_HEADER); if (!signature) { @@ -58,12 +58,12 @@ webhooksRoutes.post('/baserow', async (c) => { return c.json({ status: 'duplicate', eventId: payload.event_id }, 200); } - const result = await handleBaserowEvent(payload, { redis, tableIds, logger }); + const result = await handleBaserowEvent(payload, { redis, logger }); return c.json( { status: result.status, eventId: payload.event_id, - entity: result.entity, + tableId: result.tableId, invalidatedKeys: result.invalidatedKeys, }, 200, diff --git a/bridge/src/webhooks/baserow-handler.ts b/bridge/src/webhooks/baserow-handler.ts index ffc8ee9..cdc0db1 100644 --- a/bridge/src/webhooks/baserow-handler.ts +++ b/bridge/src/webhooks/baserow-handler.ts @@ -1,88 +1,53 @@ /** - * Handler webhooks Baserow. + * Handler webhooks Baserow — generique style Notion. * * Pipeline : payload deja valide (zod) + idempotence verifiee en amont (route). - * Ici on mappe table_id -> entite domain et on invalide les caches Redis - * pertinents. Le recalcul des agregations parent (modules, formations, projets) - * est differe au prochain GET (cache miss -> repo -> fresh data) — voir doc 19 - * §8 cache strategy. + * Ici on invalide les caches Redis associes a la table touchee. Plus de + * cascade rollup metier : si l'utilisateur a configure des formules/lookups + * cross-table cote Baserow, elles emettront leurs propres webhooks + * naturellement (chaque table touchee declenche son propre event). + * + * Le handler ne sait pas quelle table c'est metier-parlant — il invalide + * juste `bridge:tables::*`. */ import type { Logger } from 'pino'; import type { RedisCache } from '../adapters/redis-cache.js'; -import type { TableIds, TableName } from '../repos/baserow-repo.js'; import type { BaserowEventType, BaserowWebhookPayload } from './types.js'; export interface BaserowHandlerDeps { redis: RedisCache; - tableIds: TableIds; logger: Logger; } export interface BaserowHandleResult { status: 'processed' | 'ignored'; - entity: TableName | null; + tableId: number | null; invalidatedKeys: number; } /** - * Reverse map id -> table name (mappe juste les tables qu'on suit). - * Construit une fois par appel — taille fixe (9 entries), cout negligeable. - */ -function findEntityByTableId(tableIds: TableIds, tableId: number): TableName | null { - for (const [name, id] of Object.entries(tableIds) as [TableName, number][]) { - if (id === tableId) return name; - } - return null; -} - -/** - * Patterns d'invalidation cache. Hierarchie : - * - cache row precis (`bridge::row:`) si update/delete - * - cache liste (`bridge::list:*`) toujours - * - cache parent (rollups) si l'entite est enfant d'un agregat + * Patterns d'invalidation cache pour une table. + * - `list:*` : toutes les listes paginees / filtrees + * - `views:*` : toutes les rows fetched via une view + * - `row:` : la row precise (si update/delete avec items) */ function buildInvalidationPatterns( - entity: TableName, + tableId: number, eventType: BaserowEventType, itemIds: number[], ): string[] { - const patterns: string[] = [`bridge:${entity}:list:*`]; + const patterns: string[] = [ + `bridge:tables:${tableId}:list:*`, + `bridge:tables:${tableId}:views:*`, + ]; if (eventType === 'rows.updated' || eventType === 'rows.deleted') { for (const id of itemIds) { - patterns.push(`bridge:${entity}:row:${id}`); + patterns.push(`bridge:tables:${tableId}:row:${id}`); } } - // Cascade rollups parent. Les rollups sont calcules cote Baserow (formules) - // mais notre cache row du parent peut contenir des heures aggregees stale. - switch (entity) { - case 'attribution': - patterns.push('bridge:module:row:*', 'bridge:module:list:*'); - patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); - break; - case 'intervention': - patterns.push('bridge:tache:row:*', 'bridge:tache:list:*'); - patterns.push('bridge:personne:row:*', 'bridge:personne:list:*'); - break; - case 'module': - patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*'); - patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); - break; - case 'bloc': - patterns.push('bridge:formation:row:*', 'bridge:formation:list:*'); - break; - case 'tache': - patterns.push('bridge:projet:row:*', 'bridge:projet:list:*'); - break; - case 'projet': - patterns.push('bridge:client:row:*', 'bridge:client:list:*'); - break; - default: - break; - } - return patterns; } @@ -90,18 +55,16 @@ export async function handleBaserowEvent( payload: BaserowWebhookPayload, deps: BaserowHandlerDeps, ): Promise { - const entity = findEntityByTableId(deps.tableIds, payload.table_id); - - if (!entity) { + if (!Number.isFinite(payload.table_id) || payload.table_id <= 0) { deps.logger.warn( { tableId: payload.table_id, eventId: payload.event_id, eventType: payload.event_type }, - 'baserow webhook: table_id inconnu, ignore', + 'baserow webhook: table_id invalide, ignore', ); - return { status: 'ignored', entity: null, invalidatedKeys: 0 }; + return { status: 'ignored', tableId: null, invalidatedKeys: 0 }; } const itemIds = payload.items.map((i) => i.id); - const patterns = buildInvalidationPatterns(entity, payload.event_type, itemIds); + const patterns = buildInvalidationPatterns(payload.table_id, payload.event_type, itemIds); let total = 0; for (const pattern of patterns) { @@ -113,7 +76,7 @@ export async function handleBaserowEvent( { eventId: payload.event_id, eventType: payload.event_type, - entity, + tableId: payload.table_id, itemIds, patternsApplied: patterns.length, keysInvalidated: total, @@ -121,5 +84,5 @@ export async function handleBaserowEvent( 'baserow webhook processed', ); - return { status: 'processed', entity, invalidatedKeys: total }; + return { status: 'processed', tableId: payload.table_id, invalidatedKeys: total }; } diff --git a/bridge/tests/domain/attribution.test.ts b/bridge/tests/domain/attribution.test.ts deleted file mode 100644 index be8e0e3..0000000 --- a/bridge/tests/domain/attribution.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Attribution } from '../../src/domain/attribution.js'; - -const make = (overrides: Partial[0]> = {}) => - new Attribution({ - id: 1, - moduleId: 10, - personneId: 5, - heuresAttribuees: new Decimal(20), - ...overrides, - }); - -describe('Attribution — constructeur', () => { - it('cree une attribution valide', () => { - const a = make(); - expect(a.statut).toBe('planifie'); - expect(a.heuresRealisees.toNumber()).toBe(0); - }); - - it('throw si heuresAttribuees <= 0', () => { - expect(() => make({ heuresAttribuees: new Decimal(0) })).toThrow(); - expect(() => make({ heuresAttribuees: new Decimal(-5) })).toThrow(); - }); - - it('throw si heuresRealisees < 0', () => { - expect(() => make({ heuresRealisees: new Decimal(-1) })).toThrow(); - }); - - it('throw si dateFin < dateDebut', () => { - expect(() => - make({ dateDebut: new Date('2026-02-01'), dateFin: new Date('2026-01-01') }), - ).toThrow(); - }); -}); - -describe('Attribution — transitions de statut', () => { - it('demarrer: planifie -> en_cours', () => { - const a = make(); - a.demarrer(); - expect(a.statut).toBe('en_cours'); - }); - - it('demarrer invalide depuis realise', () => { - const a = make({ statut: 'realise' }); - expect(() => a.demarrer()).toThrow(); - }); - - it('cloturer: en_cours -> realise', () => { - const a = make({ statut: 'en_cours' }); - a.cloturer(); - expect(a.statut).toBe('realise'); - }); - - it('cloturer: planifie -> realise (skip en_cours autorise)', () => { - const a = make(); - a.cloturer(); - expect(a.statut).toBe('realise'); - }); - - it('cloturer invalide depuis annule', () => { - const a = make({ statut: 'annule' }); - expect(() => a.cloturer()).toThrow(); - }); - - it('annuler depuis planifie OK', () => { - const a = make(); - a.annuler('client desistement'); - expect(a.statut).toBe('annule'); - }); - - it('annuler invalide depuis realise', () => { - const a = make({ statut: 'realise' }); - expect(() => a.annuler('motif')).toThrow(); - }); -}); - -describe('Attribution — saisirHeuresRealisees', () => { - it('cas nominal', () => { - const a = make(); - a.saisirHeuresRealisees(new Decimal(15)); - expect(a.heuresRealisees.toNumber()).toBe(15); - }); - - it('throw si heures < 0', () => { - const a = make(); - expect(() => a.saisirHeuresRealisees(new Decimal(-1))).toThrow(); - }); - - it('throw si attribution annulee', () => { - const a = make({ statut: 'annule' }); - expect(() => a.saisirHeuresRealisees(new Decimal(5))).toThrow(); - }); - - it('throw si attribution realisee', () => { - const a = make({ statut: 'realise' }); - expect(() => a.saisirHeuresRealisees(new Decimal(5))).toThrow(); - }); -}); - -describe('Attribution — isActive', () => { - it('planifie et en_cours sont actifs', () => { - expect(make().isActive()).toBe(true); - expect(make({ statut: 'en_cours' }).isActive()).toBe(true); - }); - it('realise et annule ne sont pas actifs', () => { - expect(make({ statut: 'realise' }).isActive()).toBe(false); - expect(make({ statut: 'annule' }).isActive()).toBe(false); - }); -}); diff --git a/bridge/tests/domain/bloc.test.ts b/bridge/tests/domain/bloc.test.ts deleted file mode 100644 index 383295a..0000000 --- a/bridge/tests/domain/bloc.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Bloc } from '../../src/domain/bloc.js'; -import { Module } from '../../src/domain/module.js'; - -const makeModule = (id: number, h: number) => - new Module({ id, blocId: 1, nom: `M${id}`, heuresPrevues: new Decimal(h) }); - -describe('Bloc', () => { - it('cree un bloc vide', () => { - const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) }); - expect(b.modules.length).toBe(0); - expect(b.heuresAttribuees().toNumber()).toBe(0); - expect(b.heuresRestantes().toNumber()).toBe(120); - }); - - it('throw si heuresPrevues < 0', () => { - expect( - () => new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(-1) }), - ).toThrow(); - }); - - it('ajouterModule rollup correct', () => { - const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) }); - b.ajouterModule(makeModule(1, 40)); - b.ajouterModule(makeModule(2, 60)); - expect(b.heuresAttribuees().toNumber()).toBe(100); - expect(b.heuresRestantes().toNumber()).toBe(20); - }); - - it('throw si module duplique', () => { - const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) }); - b.ajouterModule(makeModule(1, 40)); - expect(() => b.ajouterModule(makeModule(1, 10))).toThrow(/deja present/); - }); - - it('throw si capacite bloc depassee', () => { - const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(50) }); - b.ajouterModule(makeModule(1, 30)); - expect(() => b.ajouterModule(makeModule(2, 30))).toThrow(/depassee/); - }); -}); diff --git a/bridge/tests/domain/client.test.ts b/bridge/tests/domain/client.test.ts deleted file mode 100644 index 919fac5..0000000 --- a/bridge/tests/domain/client.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Client } from '../../src/domain/client.js'; - -describe('Client', () => { - it('cree un client valide', () => { - const c = new Client({ id: 1, nom: 'Acme' }); - expect(c.statut).toBe('prospect'); - expect(c.projets.length).toBe(0); - }); - - it('creerProjet cas nominal', () => { - const c = new Client({ id: 1, nom: 'Acme' }); - const p = c.creerProjet('Site corpo', 100); - expect(p.id).toBe(100); - expect(p.clientId).toBe(1); - expect(c.projets.length).toBe(1); - }); - - it('creerProjet throw si nom duplique', () => { - const c = new Client({ id: 1, nom: 'Acme' }); - c.creerProjet('Site corpo', 100); - expect(() => c.creerProjet('Site corpo', 101)).toThrow(/existe deja/); - }); - - it('creerProjet bloque si client archive', () => { - const c = new Client({ id: 1, nom: 'Acme' }); - c.archiver(); - expect(() => c.creerProjet('X', 1)).toThrow(/archive/); - }); - - it('archiver transition', () => { - const c = new Client({ id: 1, nom: 'Acme' }); - c.archiver(); - expect(c.statut).toBe('archive'); - }); -}); diff --git a/bridge/tests/domain/field.test.ts b/bridge/tests/domain/field.test.ts new file mode 100644 index 0000000..d730b49 --- /dev/null +++ b/bridge/tests/domain/field.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { Field } from '../../src/domain/field.js'; + +describe('Field', () => { + it('construit avec props minimales', () => { + const f = new Field({ id: 1, name: 'titre', type: 'text' }); + expect(f.id).toBe(1); + expect(f.name).toBe('titre'); + expect(f.type).toBe('text'); + expect(f.primary).toBe(false); + expect(f.options).toBeNull(); + }); + + it('accepte primary + options', () => { + const f = new Field({ + id: 2, + name: 'statut', + type: 'single_select', + primary: false, + options: { select_options: [{ id: 1, value: 'actif' }] }, + }); + expect(f.primary).toBe(false); + expect(f.options).toEqual({ select_options: [{ id: 1, value: 'actif' }] }); + }); + + it('preserve les types non-enum (Baserow expose tout)', () => { + const f = new Field({ id: 3, name: 'rollup', type: 'formula' }); + expect(f.type).toBe('formula'); + }); +}); diff --git a/bridge/tests/domain/formation.test.ts b/bridge/tests/domain/formation.test.ts deleted file mode 100644 index bafe41b..0000000 --- a/bridge/tests/domain/formation.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Bloc } from '../../src/domain/bloc.js'; -import { Formation } from '../../src/domain/formation.js'; - -describe('Formation', () => { - it('cree une formation valide', () => { - const f = new Formation({ id: 1, nom: 'BTS SIO', heuresTotales: new Decimal(1400) }); - expect(f.statut).toBe('draft'); - expect(f.heuresRestantes().toNumber()).toBe(1400); - }); - - it('throw si heuresTotales < 0', () => { - expect(() => new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(-1) })).toThrow(); - }); - - it('throw si dateFin < dateDebut', () => { - expect( - () => - new Formation({ - id: 1, - nom: 'X', - heuresTotales: new Decimal(100), - dateDebut: new Date('2026-09-01'), - dateFin: new Date('2026-08-01'), - }), - ).toThrow(); - }); - - it('activer / archiver transitions', () => { - const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) }); - f.activer(); - expect(f.statut).toBe('actif'); - f.archiver(); - expect(f.statut).toBe('archive'); - }); - - it('activer apres archive est interdit', () => { - const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) }); - f.archiver(); - expect(() => f.activer()).toThrow(); - }); - - it('ajouterBloc + heuresRestantes rollup', () => { - const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(300) }); - const b1 = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(100) }); - const b2 = new Bloc({ id: 2, formationId: 1, nom: 'B2', heuresPrevues: new Decimal(150) }); - f.ajouterBloc(b1); - f.ajouterBloc(b2); - expect(f.heuresAttribuees().toNumber()).toBe(250); - expect(f.heuresRestantes().toNumber()).toBe(50); - }); - - it('throw si bloc duplique', () => { - const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(300) }); - const b = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(100) }); - f.ajouterBloc(b); - expect(() => f.ajouterBloc(b)).toThrow(/deja present/); - }); - - it('throw si depassement capacite formation', () => { - const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) }); - const b = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(150) }); - expect(() => f.ajouterBloc(b)).toThrow(/depassee/); - }); -}); diff --git a/bridge/tests/domain/intervention.test.ts b/bridge/tests/domain/intervention.test.ts deleted file mode 100644 index c4cf98f..0000000 --- a/bridge/tests/domain/intervention.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Intervention } from '../../src/domain/intervention.js'; - -const make = (overrides: Partial[0]> = {}) => - new Intervention({ - id: 1, - tacheId: 10, - personneId: 5, - heures: new Decimal(4), - date: new Date('2026-05-01'), - ...overrides, - }); - -describe('Intervention', () => { - it('cree une intervention valide', () => { - const i = make(); - expect(i.statut).toBe('realise'); - expect(i.isActive()).toBe(true); - }); - - it('throw si heures <= 0', () => { - expect(() => make({ heures: new Decimal(0) })).toThrow(); - expect(() => make({ heures: new Decimal(-1) })).toThrow(); - }); - - it('annuler depuis realise', () => { - const i = make(); - i.annuler('erreur saisie'); - expect(i.statut).toBe('annule'); - expect(i.isActive()).toBe(false); - }); - - it('throw si annule deja annulee', () => { - const i = make({ statut: 'annule' }); - expect(() => i.annuler('test')).toThrow(); - }); - - it('isActive false sur annule', () => { - expect(make({ statut: 'annule' }).isActive()).toBe(false); - expect(make({ statut: 'planifie' }).isActive()).toBe(true); - }); -}); diff --git a/bridge/tests/domain/module.test.ts b/bridge/tests/domain/module.test.ts deleted file mode 100644 index 57991ae..0000000 --- a/bridge/tests/domain/module.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Module } from '../../src/domain/module.js'; -import { Personne } from '../../src/domain/personne.js'; -import type { Role } from '../../src/domain/types.js'; - -const formateurActif = () => - new Personne({ - id: 1, - nom: 'X', - prenom: 'Y', - email: 'x@y.fr', - capaciteAnnuelle: new Decimal(1000), - splitFormationPct: new Decimal(50), - splitAgencePct: new Decimal(50), - roles: new Set(['formateur']), - statut: 'actif', - }); - -describe('Module — constructeur', () => { - it('cree un module valide', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - expect(m.statut).toBe('a_attribuer'); - }); - - it('throw si heuresPrevues < 0', () => { - expect( - () => new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(-1) }), - ).toThrow(); - }); -}); - -describe('Module — creerAttribution happy path', () => { - it('cree attribution + maj rollups + statut module', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - const p = formateurActif(); - const a = m.creerAttribution(p, new Decimal(20), null, null, 100); - expect(a.id).toBe(100); - expect(m.attributions.length).toBe(1); - expect(m.heuresAttribuees().toNumber()).toBe(20); - expect(p.heuresAttribueesFormation.toNumber()).toBe(20); - expect(m.statut).toBe('attribue'); - }); -}); - -describe('Module — creerAttribution validations', () => { - it('throw si personne sans role formateur', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - const p = formateurActif(); - p.retirerRole('formateur'); - p.ajouterRole('developpeur'); - expect(() => m.creerAttribution(p, new Decimal(10), null, null, 1)).toThrow(/formateur/); - }); - - it('throw si personne inactive', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - const p = formateurActif(); - p.inactiver(); - expect(() => m.creerAttribution(p, new Decimal(10), null, null, 1)).toThrow(/inactiv/); - }); - - it('throw si heures <= 0', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - const p = formateurActif(); - expect(() => m.creerAttribution(p, new Decimal(0), null, null, 1)).toThrow(); - }); - - it('RG-01 violation : SUM(heures) > heuresPrevues', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - const p = formateurActif(); - m.creerAttribution(p, new Decimal(30), null, null, 1); - expect(() => m.creerAttribution(p, new Decimal(15), null, null, 2)).toThrow(/RG-01/); - }); - - it('warning si heures > capacite formation restante de la personne (ne throw pas)', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(1000) }); - const p = new Personne({ - id: 1, - nom: 'X', - prenom: 'Y', - email: 'x@y.fr', - capaciteAnnuelle: new Decimal(100), - splitFormationPct: new Decimal(50), - splitAgencePct: new Decimal(50), - roles: new Set(['formateur']), - statut: 'actif', - }); - // capacite formation = 50. On attribue 80. - const a = m.creerAttribution(p, new Decimal(80), null, null, 1); - expect(a.heuresAttribuees.toNumber()).toBe(80); - expect(p.heuresAttribueesFormation.toNumber()).toBe(80); - }); - - it('RG-01 ignore les attributions annulees', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - const p = formateurActif(); - const a1 = m.creerAttribution(p, new Decimal(40), null, null, 1); - a1.annuler('test'); - // La nouvelle attribution doit etre acceptee maintenant. - const a2 = m.creerAttribution(p, new Decimal(20), null, null, 2); - expect(a2.heuresAttribuees.toNumber()).toBe(20); - }); -}); - -describe('Module — heuresRealisees / heuresRestantes', () => { - it('calcule heures realisees en ignorant annulations', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(50) }); - const p = formateurActif(); - const a = m.creerAttribution(p, new Decimal(30), null, null, 1); - a.saisirHeuresRealisees(new Decimal(10)); - expect(m.heuresRealisees().toNumber()).toBe(10); - expect(m.heuresRestantes().toNumber()).toBe(20); - }); -}); - -describe('Module — transitions de statut', () => { - it('cloturer', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - m.cloturer(); - expect(m.statut).toBe('realise'); - }); - - it('annuler depuis a_attribuer', () => { - const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) }); - m.annuler(); - expect(m.statut).toBe('annule'); - }); - - it('annuler bloque depuis realise', () => { - const m = new Module({ - id: 1, - blocId: 10, - nom: 'M1', - heuresPrevues: new Decimal(40), - statut: 'realise', - }); - expect(() => m.annuler()).toThrow(); - }); - - it('cloturer bloque depuis annule', () => { - const m = new Module({ - id: 1, - blocId: 10, - nom: 'M1', - heuresPrevues: new Decimal(40), - statut: 'annule', - }); - expect(() => m.cloturer()).toThrow(); - }); -}); diff --git a/bridge/tests/domain/personne.test.ts b/bridge/tests/domain/personne.test.ts deleted file mode 100644 index 6294cb6..0000000 --- a/bridge/tests/domain/personne.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Personne } from '../../src/domain/personne.js'; -import type { Role } from '../../src/domain/types.js'; - -const baseProps = (overrides: Partial[0]> = {}) => ({ - id: 1, - nom: 'Dupont', - prenom: 'Pierre', - email: 'pierre@acadenice.fr', - capaciteAnnuelle: new Decimal(1000), - splitFormationPct: new Decimal(60), - splitAgencePct: new Decimal(40), - roles: new Set(['formateur']), - statut: 'actif' as const, - ...overrides, -}); - -describe('Personne — constructeur', () => { - it('cree une personne valide', () => { - const p = new Personne(baseProps()); - expect(p.id).toBe(1); - expect(p.statut).toBe('actif'); - }); - - it('throw si splits != 100', () => { - expect( - () => - new Personne( - baseProps({ splitFormationPct: new Decimal(70), splitAgencePct: new Decimal(40) }), - ), - ).toThrow(/100/); - }); - - it('throw si capaciteAnnuelle < 0', () => { - expect(() => new Personne(baseProps({ capaciteAnnuelle: new Decimal(-1) }))).toThrow(); - }); - - it('throw si splitFormationPct hors [0,100]', () => { - expect( - () => - new Personne( - baseProps({ splitFormationPct: new Decimal(-10), splitAgencePct: new Decimal(110) }), - ), - ).toThrow(); - }); - - it('throw si splitAgencePct hors [0,100]', () => { - expect( - () => - new Personne( - baseProps({ splitFormationPct: new Decimal(150), splitAgencePct: new Decimal(-50) }), - ), - ).toThrow(); - }); - - it('admin pur capacite=0 splits=0/100 — accepte uniquement si somme=100', () => { - const p = new Personne( - baseProps({ - capaciteAnnuelle: new Decimal(0), - splitFormationPct: new Decimal(0), - splitAgencePct: new Decimal(100), - roles: new Set(['admin']), - }), - ); - expect(p.capaciteAnnuelle.toNumber()).toBe(0); - }); -}); - -describe('Personne — calculs heures restantes', () => { - it('cas nominal split 60/40, capacite 1000', () => { - const p = new Personne(baseProps()); - expect(p.capaciteFormation().toNumber()).toBe(600); - expect(p.capaciteAgence().toNumber()).toBe(400); - expect(p.heuresRestantesFormation().toNumber()).toBe(600); - expect(p.heuresRestantesAgence().toNumber()).toBe(400); - expect(p.heuresRestantesTotal().toNumber()).toBe(1000); - }); - - it('avec heures attribuees', () => { - const p = new Personne( - baseProps({ - heuresAttribueesFormation: new Decimal(150), - heuresAttribueesAgence: new Decimal(100), - }), - ); - expect(p.heuresRestantesFormation().toNumber()).toBe(450); - expect(p.heuresRestantesAgence().toNumber()).toBe(300); - expect(p.heuresRestantesTotal().toNumber()).toBe(750); - }); - - it('cas zero capacite', () => { - const p = new Personne( - baseProps({ - capaciteAnnuelle: new Decimal(0), - splitFormationPct: new Decimal(50), - splitAgencePct: new Decimal(50), - }), - ); - expect(p.heuresRestantesTotal().toNumber()).toBe(0); - expect(p.heuresRestantesFormation().toNumber()).toBe(0); - }); -}); - -describe('Personne — gestion roles', () => { - it('ajouterRole idempotent', () => { - const p = new Personne(baseProps()); - p.ajouterRole('formateur'); - p.ajouterRole('formateur'); - expect(p.roles.size).toBe(1); - }); - - it('ajouterRole nouveau', () => { - const p = new Personne(baseProps()); - p.ajouterRole('developpeur'); - expect(p.hasRole('developpeur')).toBe(true); - expect(p.roles.size).toBe(2); - }); - - it('retirerRole sans attributions actives', () => { - const p = new Personne(baseProps()); - p.retirerRole('formateur'); - expect(p.hasRole('formateur')).toBe(false); - }); - - it('retirerRole formateur bloque si attributions actives', () => { - const p = new Personne(baseProps()); - expect(() => p.retirerRole('formateur', { activeAttributions: 2 })).toThrow(/attributions/); - }); - - it('retirerRole developpeur bloque si interventions actives', () => { - const p = new Personne(baseProps({ roles: new Set(['formateur', 'developpeur']) })); - expect(() => p.retirerRole('developpeur', { activeInterventions: 1 })).toThrow(/interventions/); - }); -}); - -describe('Personne — transitions de statut', () => { - it('activer / inactiver', () => { - const p = new Personne(baseProps({ statut: 'inactif' })); - p.activer(); - expect(p.statut).toBe('actif'); - p.inactiver(); - expect(p.statut).toBe('inactif'); - }); -}); - -describe('Personne — mutations rollups', () => { - it('appliquer heures formation accumule', () => { - const p = new Personne(baseProps()); - p._appliquerHeuresFormation(new Decimal(100)); - p._appliquerHeuresFormation(new Decimal(50)); - expect(p.heuresAttribueesFormation.toNumber()).toBe(150); - }); - - it('appliquer heures agence accumule', () => { - const p = new Personne(baseProps()); - p._appliquerHeuresAgence(new Decimal(80)); - expect(p.heuresAttribueesAgence.toNumber()).toBe(80); - }); - - it('throw si rollup formation devient negatif', () => { - const p = new Personne(baseProps()); - expect(() => p._appliquerHeuresFormation(new Decimal(-10))).toThrow(); - }); - - it('throw si rollup agence devient negatif', () => { - const p = new Personne(baseProps()); - expect(() => p._appliquerHeuresAgence(new Decimal(-5))).toThrow(); - }); - - it('warn quand surcharge formation (depasse capacite)', () => { - const p = new Personne(baseProps({ capaciteAnnuelle: new Decimal(100) })); - // capacite formation = 60. Apply 80 → warn mais accepte. - p._appliquerHeuresFormation(new Decimal(80)); - expect(p.heuresAttribueesFormation.toNumber()).toBe(80); - }); -}); diff --git a/bridge/tests/domain/projet.test.ts b/bridge/tests/domain/projet.test.ts deleted file mode 100644 index 4821b3b..0000000 --- a/bridge/tests/domain/projet.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Formation } from '../../src/domain/formation.js'; -import { Projet } from '../../src/domain/projet.js'; - -describe('Projet', () => { - it('cree un projet valide', () => { - const p = new Projet({ id: 1, clientId: 5, nom: 'Site corpo', chargeHeures: new Decimal(80) }); - expect(p.statut).toBe('devis'); - expect(p.heuresRestantes().toNumber()).toBe(80); - }); - - it('throw si chargeHeures < 0', () => { - expect( - () => new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(-1) }), - ).toThrow(); - }); - - it('ajouterTache cas nominal', () => { - const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) }); - const t = p.ajouterTache('Setup repo', new Decimal(2), 100); - expect(t.titre).toBe('Setup repo'); - expect(p.taches.length).toBe(1); - expect(p.heuresAttribuees().toNumber()).toBe(2); - }); - - it('ajouterTache bloque si projet cloture', () => { - const p = new Projet({ - id: 1, - clientId: 5, - nom: 'X', - chargeHeures: new Decimal(80), - statut: 'cloture', - }); - expect(() => p.ajouterTache('T', new Decimal(2), 1)).toThrow(); - }); - - it('ajouterTache bloque si projet abandonne', () => { - const p = new Projet({ - id: 1, - clientId: 5, - nom: 'X', - chargeHeures: new Decimal(80), - statut: 'abandonne', - }); - expect(() => p.ajouterTache('T', new Decimal(2), 1)).toThrow(); - }); - - it('ajouterTache throw si charge < 0', () => { - const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) }); - expect(() => p.ajouterTache('T', new Decimal(-2), 1)).toThrow(); - }); - - it('lierFormationPedagogique', () => { - const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) }); - const f = new Formation({ id: 42, nom: 'Bootcamp', heuresTotales: new Decimal(700) }); - p.lierFormationPedagogique(f); - expect(p.formationId).toBe(42); - }); - - it('livrer transitions', () => { - const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) }); - p.livrer(); - expect(p.statut).toBe('livre'); - }); - - it('livrer depuis en_cours OK', () => { - const p = new Projet({ - id: 1, - clientId: 5, - nom: 'X', - chargeHeures: new Decimal(80), - statut: 'en_cours', - }); - p.livrer(); - expect(p.statut).toBe('livre'); - }); - - it('livrer bloque depuis cloture', () => { - const p = new Projet({ - id: 1, - clientId: 5, - nom: 'X', - chargeHeures: new Decimal(80), - statut: 'cloture', - }); - expect(() => p.livrer()).toThrow(); - }); - - it('cloturer transition', () => { - const p = new Projet({ - id: 1, - clientId: 5, - nom: 'X', - chargeHeures: new Decimal(80), - statut: 'livre', - }); - p.cloturer(); - expect(p.statut).toBe('cloture'); - }); - - it('cloturer bloque depuis abandonne', () => { - const p = new Projet({ - id: 1, - clientId: 5, - nom: 'X', - chargeHeures: new Decimal(80), - statut: 'abandonne', - }); - expect(() => p.cloturer()).toThrow(); - }); -}); diff --git a/bridge/tests/domain/row.test.ts b/bridge/tests/domain/row.test.ts new file mode 100644 index 0000000..5d90d80 --- /dev/null +++ b/bridge/tests/domain/row.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { Row } from '../../src/domain/row.js'; + +describe('Row', () => { + it('construit avec fields opaques', () => { + const r = new Row({ + id: 100, + tableId: 5, + fields: { nom: 'Dupont', heures: 40, actif: true }, + }); + expect(r.id).toBe(100); + expect(r.tableId).toBe(5); + expect(r.fields.nom).toBe('Dupont'); + expect(r.fields.heures).toBe(40); + expect(r.fields.actif).toBe(true); + expect(r.createdOn).toBeNull(); + expect(r.updatedOn).toBeNull(); + expect(r.order).toBeNull(); + }); + + it('accepte timestamps + order', () => { + const created = new Date('2026-01-01'); + const updated = new Date('2026-02-02'); + const r = new Row({ + id: 1, + tableId: 1, + fields: {}, + createdOn: created, + updatedOn: updated, + order: '1.00000000000000000000', + }); + expect(r.createdOn).toBe(created); + expect(r.updatedOn).toBe(updated); + expect(r.order).toBe('1.00000000000000000000'); + }); + + it('preserve les valeurs nested', () => { + const r = new Row({ + id: 1, + tableId: 1, + fields: { + select: { id: 1, value: 'actif', color: 'green' }, + link: [{ id: 5, value: 'Pierre' }], + }, + }); + expect(r.fields.select).toEqual({ id: 1, value: 'actif', color: 'green' }); + expect(r.fields.link).toEqual([{ id: 5, value: 'Pierre' }]); + }); +}); diff --git a/bridge/tests/domain/schemas.test.ts b/bridge/tests/domain/schemas.test.ts index 430f15b..a002049 100644 --- a/bridge/tests/domain/schemas.test.ts +++ b/bridge/tests/domain/schemas.test.ts @@ -1,125 +1,103 @@ import { describe, expect, it } from 'vitest'; import { - AttributionSchema, - BlocSchema, - ClientSchema, - FormationSchema, - InterventionSchema, - ModuleSchema, - PersonneSchema, - ProjetSchema, - TacheSchema, + FieldSchema, + RowFieldsSchema, + RowSchema, + TableSchema, + ViewSchema, } from '../../src/domain/schemas.js'; -describe('schemas zod', () => { - it('PersonneSchema valide', () => { - const r = PersonneSchema.parse({ +describe('FieldSchema', () => { + it('valide un field minimal', () => { + const r = FieldSchema.parse({ id: 1, name: 'nom', type: 'text' }); + expect(r.primary).toBe(false); + expect(r.options).toBeUndefined(); + }); + + it('rejette name vide', () => { + expect(() => FieldSchema.parse({ id: 1, name: '', type: 'text' })).toThrow(); + }); + + it('accepte type custom (Baserow expose tout)', () => { + const r = FieldSchema.parse({ id: 1, name: 'x', type: 'rollup' }); + expect(r.type).toBe('rollup'); + }); + + it('options accept Record', () => { + const r = FieldSchema.parse({ id: 1, - nom: 'Doe', - prenom: 'John', - email: 'john@a.fr', - capaciteAnnuelle: '1000', - splitFormationPct: 50, - splitAgencePct: 50, - roles: ['formateur'], + name: 'statut', + type: 'single_select', + options: { select_options: [{ id: 1 }] }, }); - expect(r.statut).toBe('actif'); - expect(r.capaciteAnnuelle).toBe(1000); - }); - - it('PersonneSchema rejette splits qui ne somment pas a 100', () => { - expect(() => - PersonneSchema.parse({ - id: 1, - nom: 'X', - prenom: 'Y', - email: 'x@y.fr', - capaciteAnnuelle: 1000, - splitFormationPct: 50, - splitAgencePct: 40, - roles: ['formateur'], - }), - ).toThrow(); - }); - - it('PersonneSchema rejette email invalide', () => { - expect(() => - PersonneSchema.parse({ - id: 1, - nom: 'X', - prenom: 'Y', - email: 'not-an-email', - capaciteAnnuelle: 1000, - splitFormationPct: 50, - splitAgencePct: 50, - roles: ['formateur'], - }), - ).toThrow(); - }); - - it('FormationSchema valide avec defaults', () => { - const r = FormationSchema.parse({ id: 1, nom: 'BTS', heuresTotales: 500 }); - expect(r.statut).toBe('draft'); - }); - - it('BlocSchema rejette heuresPrevues negative', () => { - expect(() => - BlocSchema.parse({ id: 1, formationId: 1, nom: 'B', heuresPrevues: -5 }), - ).toThrow(); - }); - - it('ModuleSchema valide', () => { - const r = ModuleSchema.parse({ id: 1, blocId: 1, nom: 'M', heuresPrevues: 20 }); - expect(r.statut).toBe('a_attribuer'); - }); - - it('AttributionSchema rejette heuresAttribuees = 0', () => { - expect(() => - AttributionSchema.parse({ id: 1, moduleId: 1, personneId: 1, heuresAttribuees: 0 }), - ).toThrow(); - }); - - it('ClientSchema valide minimal', () => { - const r = ClientSchema.parse({ id: 1, nom: 'Acme' }); - expect(r.statut).toBe('prospect'); - }); - - it('ProjetSchema valide avec formationId', () => { - const r = ProjetSchema.parse({ - id: 1, - clientId: 1, - nom: 'P', - chargeHeures: 100, - formationId: 5, - }); - expect(r.formationId).toBe(5); - }); - - it('TacheSchema valide minimal', () => { - const r = TacheSchema.parse({ id: 1, projetId: 1, titre: 'T', chargeHeures: 4 }); - expect(r.statut).toBe('todo'); - }); - - it('InterventionSchema parse date string', () => { - const r = InterventionSchema.parse({ - id: 1, - tacheId: 1, - personneId: 1, - heures: 3, - date: '2026-05-01', - }); - expect(r.date instanceof Date).toBe(true); - }); - - it('InterventionSchema rejette heures = 0', () => { - expect(() => - InterventionSchema.parse({ - id: 1, - tacheId: 1, - personneId: 1, - heures: 0, - date: '2026-05-01', - }), - ).toThrow(); + expect(r.options).toEqual({ select_options: [{ id: 1 }] }); + }); +}); + +describe('ViewSchema', () => { + it('valide une view grid', () => { + const r = ViewSchema.parse({ id: 1, name: 'Tous', type: 'grid', tableId: 5 }); + expect(r.type).toBe('grid'); + }); + + it('accepte un type custom', () => { + const r = ViewSchema.parse({ id: 1, name: 'X', type: 'weird', tableId: 5 }); + expect(r.type).toBe('weird'); + }); + + it('rejette tableId negatif', () => { + expect(() => ViewSchema.parse({ id: 1, name: 'X', type: 'grid', tableId: 0 })).toThrow(); + }); +}); + +describe('TableSchema', () => { + it('valide une table minimale', () => { + const r = TableSchema.parse({ id: 1, name: 'Personne', databaseId: 5 }); + expect(r.orderIndex).toBe(0); + }); + + it('rejette id <= 0', () => { + expect(() => TableSchema.parse({ id: 0, name: 'x', databaseId: 1 })).toThrow(); + }); +}); + +describe('RowSchema', () => { + it('valide une row avec fields opaques', () => { + const r = RowSchema.parse({ + id: 1, + tableId: 5, + fields: { nom: 'x', heures: 40 }, + }); + expect(r.fields.heures).toBe(40); + }); + + it('id 0 est accepte (NEW row temp client-side)', () => { + const r = RowSchema.parse({ id: 0, tableId: 1, fields: {} }); + expect(r.id).toBe(0); + }); +}); + +describe('RowFieldsSchema', () => { + it('accepte n importe quel record', () => { + expect(RowFieldsSchema.parse({ a: 1, b: 'x', c: null })).toEqual({ a: 1, b: 'x', c: null }); + }); + + it('rejette un non-objet', () => { + expect(() => RowFieldsSchema.parse([1, 2])).toThrow(); + expect(() => RowFieldsSchema.parse('foo')).toThrow(); + }); + + it('accepte un objet vide (PATCH partiel possible)', () => { + expect(RowFieldsSchema.parse({})).toEqual({}); + }); + + it('accepte des valeurs nested arbitraires (link_row, select, formula result)', () => { + const v = { + link: [{ id: 1, value: 'X' }], + select: { id: 5, value: 'actif', color: 'green' }, + formula_result: 42.5, + tags: ['a', 'b'], + }; + expect(RowFieldsSchema.parse(v)).toEqual(v); }); }); diff --git a/bridge/tests/domain/table.test.ts b/bridge/tests/domain/table.test.ts new file mode 100644 index 0000000..6969a98 --- /dev/null +++ b/bridge/tests/domain/table.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { Field } from '../../src/domain/field.js'; +import { Table } from '../../src/domain/table.js'; + +describe('Table', () => { + it('construit avec props minimales', () => { + const t = new Table({ id: 1, name: 'Personne', databaseId: 5 }); + expect(t.id).toBe(1); + expect(t.name).toBe('Personne'); + expect(t.databaseId).toBe(5); + expect(t.fields).toEqual([]); + expect(t.orderIndex).toBe(0); + }); + + it('accepte fields + orderIndex', () => { + const f = new Field({ id: 10, name: 'nom', type: 'text', primary: true }); + const t = new Table({ + id: 2, + name: 'Bloc', + databaseId: 5, + fields: [f], + orderIndex: 3, + }); + expect(t.fields).toHaveLength(1); + expect(t.fields[0]?.name).toBe('nom'); + expect(t.orderIndex).toBe(3); + }); +}); diff --git a/bridge/tests/domain/tache.test.ts b/bridge/tests/domain/tache.test.ts deleted file mode 100644 index 3296dc2..0000000 --- a/bridge/tests/domain/tache.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { describe, expect, it } from 'vitest'; -import { Personne } from '../../src/domain/personne.js'; -import { Tache } from '../../src/domain/tache.js'; -import type { Role } from '../../src/domain/types.js'; - -const developpeur = (overrides: Partial[0]> = {}) => - new Personne({ - id: 1, - nom: 'Dev', - prenom: 'Jane', - email: 'jane@acadenice.fr', - capaciteAnnuelle: new Decimal(1000), - splitFormationPct: new Decimal(20), - splitAgencePct: new Decimal(80), - roles: new Set(['developpeur']), - statut: 'actif', - ...overrides, - }); - -describe('Tache — constructeur', () => { - it('cree une tache valide', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - expect(t.statut).toBe('todo'); - }); - - it('throw si charge < 0', () => { - expect( - () => new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(-1) }), - ).toThrow(); - }); -}); - -describe('Tache — creerIntervention', () => { - it('happy path', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - const p = developpeur(); - const i = t.creerIntervention(p, new Decimal(3), new Date('2026-05-01'), 100); - expect(i.heures.toNumber()).toBe(3); - expect(t.heuresRealisees().toNumber()).toBe(3); - expect(p.heuresAttribueesAgence.toNumber()).toBe(3); - }); - - it('throw si role != developpeur', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - const p = developpeur({ roles: new Set(['formateur']) }); - expect(() => t.creerIntervention(p, new Decimal(3), new Date(), 1)).toThrow(/developpeur/); - }); - - it('throw si heures <= 0', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - const p = developpeur(); - expect(() => t.creerIntervention(p, new Decimal(0), new Date(), 1)).toThrow(); - expect(() => t.creerIntervention(p, new Decimal(-1), new Date(), 1)).toThrow(); - }); - - it('throw si personne inactive', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - const p = developpeur({ statut: 'inactif' }); - expect(() => t.creerIntervention(p, new Decimal(2), new Date(), 1)).toThrow(/inactiv/); - }); - - it('annulation exclue du rollup heuresRealisees', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - const p = developpeur(); - const i1 = t.creerIntervention(p, new Decimal(3), new Date(), 1); - t.creerIntervention(p, new Decimal(2), new Date(), 2); - i1.annuler('test'); - expect(t.heuresRealisees().toNumber()).toBe(2); - }); -}); - -describe('Tache — transitions de statut', () => { - it('marquerInProgress', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - t.marquerInProgress(); - expect(t.statut).toBe('in_progress'); - }); - - it('marquerReview depuis in_progress', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - t.marquerInProgress(); - t.marquerReview(); - expect(t.statut).toBe('review'); - }); - - it('marquerReview invalide depuis todo', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - expect(() => t.marquerReview()).toThrow(); - }); - - it('marquerDone depuis review', () => { - const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) }); - t.marquerInProgress(); - t.marquerReview(); - t.marquerDone(); - expect(t.statut).toBe('done'); - }); - - it('marquerDone bloque depuis done', () => { - const t = new Tache({ - id: 1, - projetId: 5, - titre: 'T1', - chargeHeures: new Decimal(8), - statut: 'done', - }); - expect(() => t.marquerDone()).toThrow(); - }); - - it('marquerInProgress bloque depuis abandoned', () => { - const t = new Tache({ - id: 1, - projetId: 5, - titre: 'T1', - chargeHeures: new Decimal(8), - statut: 'abandoned', - }); - expect(() => t.marquerInProgress()).toThrow(); - }); -}); diff --git a/bridge/tests/domain/view.test.ts b/bridge/tests/domain/view.test.ts new file mode 100644 index 0000000..f20df4f --- /dev/null +++ b/bridge/tests/domain/view.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { View } from '../../src/domain/view.js'; + +describe('View', () => { + it('construit avec props minimales', () => { + const v = new View({ id: 1, name: 'Tous', type: 'grid', tableId: 5 }); + expect(v.id).toBe(1); + expect(v.name).toBe('Tous'); + expect(v.type).toBe('grid'); + expect(v.tableId).toBe(5); + }); + + it('accepte un type custom (string libre)', () => { + const v = new View({ id: 1, name: 'Custom', type: 'mystery_view', tableId: 5 }); + expect(v.type).toBe('mystery_view'); + }); +}); diff --git a/bridge/tests/helpers/fake-repos.ts b/bridge/tests/helpers/fake-repos.ts deleted file mode 100644 index 2b6a0f7..0000000 --- a/bridge/tests/helpers/fake-repos.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Fake repos pour les tests routes : implementent l'API publique des repos - * (list/get/create/update*) en utilisant un store in-memory. - */ - -import type { Decimal } from 'decimal.js'; -import type { Attribution } from '../../src/domain/attribution.js'; -import type { Bloc } from '../../src/domain/bloc.js'; -import type { Client } from '../../src/domain/client.js'; -import type { Formation } from '../../src/domain/formation.js'; -import type { Intervention } from '../../src/domain/intervention.js'; -import type { Module } from '../../src/domain/module.js'; -import type { Personne } from '../../src/domain/personne.js'; -import type { Projet } from '../../src/domain/projet.js'; -import type { Tache } from '../../src/domain/tache.js'; -import type { StatutAttribution, StatutIntervention } from '../../src/domain/types.js'; -import { errors } from '../../src/lib/errors.js'; -import type { RepoSet } from '../../src/repos/baserow-repo.js'; - -interface ListResult { - items: T[]; - meta: { page: number; per_page: number; total: number; total_pages: number }; -} - -class FakeReadRepo { - constructor( - private readonly entityName: string, - public store: T[] = [], - ) {} - - list(): Promise> { - return Promise.resolve({ - items: this.store, - meta: { page: 1, per_page: 200, total: this.store.length, total_pages: 1 }, - }); - } - - get(id: number): Promise { - const found = this.store.find((x) => x.id === id); - if (!found) return Promise.reject(errors.notFound(this.entityName, id)); - return Promise.resolve(found); - } -} - -export class FakeAttributionRepo extends FakeReadRepo { - public lastCreated?: { - moduleId: number; - personneId: number; - heuresAttribuees: Decimal; - statut: StatutAttribution; - }; - public lastUpdate?: { id: number; heures: Decimal }; - public nextId = 1000; - - constructor(store: Attribution[] = []) { - super('Attribution', store); - } - - create(input: { - moduleId: number; - personneId: number; - heuresAttribuees: Decimal; - dateDebut: Date | null; - dateFin: Date | null; - statut: StatutAttribution; - }) { - this.lastCreated = input; - const id = this.nextId++; - return Promise.resolve({ id, order: '1', ...input }); - } - - updateHeuresRealisees(id: number, heures: Decimal) { - this.lastUpdate = { id, heures }; - return Promise.resolve({ id, order: '1', attribution_heures_realisees: heures.toNumber() }); - } -} - -export class FakeInterventionRepo extends FakeReadRepo { - public lastCreated?: { - tacheId: number; - personneId: number; - heures: Decimal; - date: Date; - notes: string | null; - statut: StatutIntervention; - }; - public nextId = 2000; - - constructor(store: Intervention[] = []) { - super('Intervention', store); - } - - create(input: { - tacheId: number; - personneId: number; - heures: Decimal; - date: Date; - notes: string | null; - statut: StatutIntervention; - }) { - this.lastCreated = input; - const id = this.nextId++; - return Promise.resolve({ id, order: '1', ...input }); - } -} - -export interface FakeReposBundle extends RepoSet { - personnes: FakeReadRepo & RepoSet['personnes']; - formations: FakeReadRepo & RepoSet['formations']; - blocs: FakeReadRepo & RepoSet['blocs']; - modules: FakeReadRepo & RepoSet['modules']; - attributions: FakeAttributionRepo & RepoSet['attributions']; - clients: FakeReadRepo & RepoSet['clients']; - projets: FakeReadRepo & RepoSet['projets']; - taches: FakeReadRepo & RepoSet['taches']; - interventions: FakeInterventionRepo & RepoSet['interventions']; -} - -export function buildFakeRepos(stores: { - personnes?: Personne[]; - formations?: Formation[]; - blocs?: Bloc[]; - modules?: Module[]; - attributions?: Attribution[]; - clients?: Client[]; - projets?: Projet[]; - taches?: Tache[]; - interventions?: Intervention[]; -}): FakeReposBundle { - // Cast force : on shippe l'interface publique RepoSet meme si les classes - // BaseRepo ne sont pas etendues. Les tests ne tapent que les methodes utilisees. - return { - personnes: new FakeReadRepo('Personne', stores.personnes ?? []), - formations: new FakeReadRepo('Formation', stores.formations ?? []), - blocs: new FakeReadRepo('Bloc', stores.blocs ?? []), - modules: new FakeReadRepo('Module', stores.modules ?? []), - attributions: new FakeAttributionRepo(stores.attributions ?? []), - clients: new FakeReadRepo('Client', stores.clients ?? []), - projets: new FakeReadRepo('Projet', stores.projets ?? []), - taches: new FakeReadRepo('Tache', stores.taches ?? []), - interventions: new FakeInterventionRepo(stores.interventions ?? []), - } as unknown as FakeReposBundle; -} diff --git a/bridge/tests/helpers/fixtures.ts b/bridge/tests/helpers/fixtures.ts deleted file mode 100644 index 76af2e1..0000000 --- a/bridge/tests/helpers/fixtures.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Fixtures domain partagees par les tests routes. - */ - -import { Decimal } from 'decimal.js'; -import { Attribution } from '../../src/domain/attribution.js'; -import { Bloc } from '../../src/domain/bloc.js'; -import { Formation } from '../../src/domain/formation.js'; -import { Intervention } from '../../src/domain/intervention.js'; -import { Module } from '../../src/domain/module.js'; -import { Personne } from '../../src/domain/personne.js'; -import { Projet } from '../../src/domain/projet.js'; -import { Tache } from '../../src/domain/tache.js'; -import type { Role } from '../../src/domain/types.js'; - -export function makePersonne(over: Partial<{ id: number; roles: Role[] }> = {}): Personne { - return new Personne({ - id: over.id ?? 1, - nom: 'Dupont', - prenom: 'Pierre', - email: 'pierre@acadenice.fr', - capaciteAnnuelle: new Decimal(1000), - splitFormationPct: new Decimal(60), - splitAgencePct: new Decimal(40), - roles: new Set(over.roles ?? ['formateur']), - statut: 'actif', - }); -} - -export function makeFormation(id = 10): Formation { - return new Formation({ - id, - nom: 'Dev Fullstack', - heuresTotales: new Decimal(500), - statut: 'actif', - }); -} - -export function makeBloc(id = 100, formationId = 10): Bloc { - return new Bloc({ - id, - formationId, - nom: 'Bloc JS', - heuresPrevues: new Decimal(100), - ordre: 1, - }); -} - -export function makeModule(id = 200, blocId = 100): Module { - return new Module({ - id, - blocId, - nom: 'JS Fondamentaux', - heuresPrevues: new Decimal(30), - }); -} - -export function makeAttribution( - over: Partial<{ id: number; moduleId: number; personneId: number }> = {}, -): Attribution { - return new Attribution({ - id: over.id ?? 500, - moduleId: over.moduleId ?? 200, - personneId: over.personneId ?? 1, - heuresAttribuees: new Decimal(10), - statut: 'planifie', - }); -} - -export function makeProjet(id = 300, clientId = 50): Projet { - return new Projet({ - id, - clientId, - nom: 'Site Acme', - chargeHeures: new Decimal(80), - statut: 'en_cours', - }); -} - -export function makeTache(id = 400, projetId = 300): Tache { - return new Tache({ - id, - projetId, - titre: 'Setup repo', - chargeHeures: new Decimal(8), - statut: 'todo', - }); -} - -export function makeIntervention( - over: Partial<{ id: number; tacheId: number; personneId: number }> = {}, -): Intervention { - return new Intervention({ - id: over.id ?? 600, - tacheId: over.tacheId ?? 400, - personneId: over.personneId ?? 2, - heures: new Decimal(2), - date: new Date('2026-05-01'), - statut: 'realise', - }); -} diff --git a/bridge/tests/helpers/test-app.ts b/bridge/tests/helpers/test-app.ts index 1f8dcfa..cbc2558 100644 --- a/bridge/tests/helpers/test-app.ts +++ b/bridge/tests/helpers/test-app.ts @@ -1,14 +1,15 @@ /** - * Test helper : construit une app Hono iso-prod avec un container minimal en memoire. - * Pas de testcontainers ici — les routes utilisent les repos qu'on mock dans chaque suite. + * Test helper : construit une app Hono iso-prod avec un container minimal en + * memoire, puis expose les routes generiques /api/v1/tables/*. + * + * R1 — Pas de tableIds metier. Les repos sont injectes via overrides. */ import { Hono } from 'hono'; import { logger as honoLogger } from 'hono/logger'; import type { BaserowClient } from '../../src/adapters/baserow-client.js'; import type { RedisCache } from '../../src/adapters/redis-cache.js'; -import type { Personne } from '../../src/domain/personne.js'; -import type { Container } from '../../src/lib/container.js'; +import type { Container, RepoSet } from '../../src/lib/container.js'; import { setContainer } from '../../src/lib/container.js'; import { logger } from '../../src/lib/logger.js'; import { @@ -17,42 +18,16 @@ import { authMiddleware, } from '../../src/middleware/auth.js'; import { errorHandler } from '../../src/middleware/error-handler.js'; -import type { RepoSet, TableIds } from '../../src/repos/baserow-repo.js'; -import { attributionsRoutes } from '../../src/routes/attributions.js'; -import { formationsRoutes } from '../../src/routes/formations.js'; -import { interventionsRoutes } from '../../src/routes/interventions.js'; -import { modulesRoutes } from '../../src/routes/modules.js'; -import { personnesRoutes } from '../../src/routes/personnes.js'; -import { projetsRoutes } from '../../src/routes/projets.js'; +import { tablesRoutes } from '../../src/routes/tables.js'; import { webhooksRoutes } from '../../src/routes/webhooks.js'; -const FAKE_TABLE_IDS: TableIds = { - personne: 1, - formation: 2, - bloc: 3, - module: 4, - attribution: 5, - client: 6, - projet: 7, - tache: 8, - intervention: 9, -}; - -export const READ_ALL_TOKEN = 'brg_read_all'; -export const WRITE_ALL_TOKEN = 'brg_write_all'; +export const READ_TOKEN = 'brg_read'; +export const WRITE_TOKEN = 'brg_write'; export const ADMIN_TOKEN = 'brg_admin'; export const TEST_TOKENS: ApiTokenRecord[] = [ - { - token: READ_ALL_TOKEN, - name: 'test-read', - scopes: ['read:personnes', 'read:formations', 'read:projets'], - }, - { - token: WRITE_ALL_TOKEN, - name: 'test-write', - scopes: ['write:attributions', 'write:interventions'], - }, + { token: READ_TOKEN, name: 'test-read', scopes: ['read:tables'] }, + { token: WRITE_TOKEN, name: 'test-write', scopes: ['read:tables', 'write:tables'] }, { token: ADMIN_TOKEN, name: 'test-admin', scopes: ['admin:*'] }, ]; @@ -92,7 +67,6 @@ export function installTestContainer(over: TestContainerOverrides): Container { baserowWebhookSecret: 'fake_secret_at_least_16_chars', docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars', bridgeApiTokens: undefined, - authStrictMapping: true, rateLimitGlobalMax: 10000, rateLimitGlobalWindow: 60, rateLimitMutationMax: 10000, @@ -102,7 +76,6 @@ export function installTestContainer(over: TestContainerOverrides): Container { redis: fakeRedis, repos: over.repos, tokens: tokensMap, - tableIds: FAKE_TABLE_IDS, oidc: null, groupsScopesMap: {}, logger, @@ -111,14 +84,6 @@ export function installTestContainer(over: TestContainerOverrides): Container { return container; } -const NOOP_CACHE = { - get: async (_key: string): Promise => null, - set: async (_key: string, _value: T, _ttl?: number): Promise => {}, -}; -const NOOP_FINDER = { - findByEmail: async (_email: string): Promise => null, -}; - export function resetTestContainer(): void { setContainer(null); } @@ -139,18 +104,10 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab tokens: container.tokens, oidc: container.oidc, groupsScopesMap: container.groupsScopesMap, - strictMapping: container.config.authStrictMapping, - cache: NOOP_CACHE, - finder: NOOP_FINDER, logger, }), ); - v1.route('/personnes', personnesRoutes); - v1.route('/formations', formationsRoutes); - v1.route('/projets', projetsRoutes); - v1.route('/modules', modulesRoutes); - v1.route('/interventions', interventionsRoutes); - v1.route('/attributions', attributionsRoutes); + v1.route('/tables', tablesRoutes); app.route('/api/v1', v1); return app; diff --git a/bridge/tests/integration/rate-limit-app.test.ts b/bridge/tests/integration/rate-limit-app.test.ts index 301b17e..0205b19 100644 --- a/bridge/tests/integration/rate-limit-app.test.ts +++ b/bridge/tests/integration/rate-limit-app.test.ts @@ -3,39 +3,22 @@ * PAS sur /api/health, /api/ready, /api/webhooks/*. Et que l'invalidation * cache est declenchee apres POST/PATCH/PUT/DELETE sur les routes mutation. * - * On reconstitue une mini app proche de buildApp() (sans serve()) avec un - * fake Redis qui compte les calls invalidatePattern + checkRateLimit. + * R1 — Reecrit pour /api/v1/tables/:id/rows (proxy generique). */ -import { Decimal } from 'decimal.js'; import { Hono } from 'hono'; import { afterEach, describe, expect, it } from 'vitest'; import type { RedisCache } from '../../src/adapters/redis-cache.js'; -import type { Personne } from '../../src/domain/personne.js'; +import { Row } from '../../src/domain/row.js'; +import type { RepoSet } from '../../src/lib/container.js'; import { setContainer } from '../../src/lib/container.js'; +import { errors } from '../../src/lib/errors.js'; import { logger } from '../../src/lib/logger.js'; import { type AuthVariables, authMiddleware } from '../../src/middleware/auth.js'; import { errorHandler } from '../../src/middleware/error-handler.js'; import { defaultRateLimitKey, rateLimit } from '../../src/middleware/rate-limit.js'; -import { attributionsRoutes } from '../../src/routes/attributions.js'; -import { interventionsRoutes } from '../../src/routes/interventions.js'; -import { modulesRoutes } from '../../src/routes/modules.js'; -import { personnesRoutes } from '../../src/routes/personnes.js'; +import { tablesRoutes } from '../../src/routes/tables.js'; import { webhooksRoutes } from '../../src/routes/webhooks.js'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makeAttribution, makePersonne } from '../helpers/fixtures.js'; - -const TABLE_IDS = { - personne: 1, - formation: 2, - bloc: 3, - module: 4, - attribution: 5, - client: 6, - projet: 7, - tache: 8, - intervention: 9, -} as const; class FakeRedis { public invalidations: string[] = []; @@ -62,13 +45,36 @@ class FakeRedis { } } +class FakeRowsRepo { + async list(tableId: number) { + return { + items: [new Row({ id: 1, tableId, fields: { nom: 'X' } })], + meta: { page: 1, per_page: 50, total: 1, total_pages: 1 }, + }; + } + async get(tableId: number, rowId: number): Promise { + if (rowId === 9999) throw errors.notFound('Row', rowId); + return new Row({ id: rowId, tableId, fields: { nom: 'X' } }); + } + async create(tableId: number, fields: Record): Promise { + return new Row({ id: 1000, tableId, fields }); + } + async update(tableId: number, rowId: number, fields: Record): Promise { + return new Row({ id: rowId, tableId, fields }); + } + async delete(_tableId: number, _rowId: number): Promise {} +} + const READ_TOKEN = 'brg_read'; const WRITE_TOKEN = 'brg_write'; function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) { - const personne = makePersonne({ id: 1, roles: ['formateur'] }); - const attribution = makeAttribution({ id: 500 }); - const repos = buildFakeRepos({ personnes: [personne], attributions: [attribution] }); + const repos: RepoSet = { + tables: {} as RepoSet['tables'], + fields: {} as RepoSet['fields'], + views: {} as RepoSet['views'], + rows: new FakeRowsRepo() as unknown as RepoSet['rows'], + }; setContainer({ config: { @@ -81,7 +87,6 @@ function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationM baserowWebhookSecret: 'fake-secret-at-least-16-chars-long', docmostWebhookSecret: undefined, bridgeApiTokens: undefined, - authStrictMapping: true, rateLimitGlobalMax: opts.globalMax, rateLimitGlobalWindow: 60, rateLimitMutationMax: opts.mutationMax, @@ -92,18 +97,16 @@ function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationM redis: redis as unknown as RedisCache, repos, tokens: new Map([ - [READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }], + [READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:tables'] }], [ WRITE_TOKEN, - { token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] }, + { token: WRITE_TOKEN, name: 'writer', scopes: ['read:tables', 'write:tables'] }, ], ]), - tableIds: TABLE_IDS, oidc: null, groupsScopesMap: {}, logger, }); - return repos; } function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) { @@ -119,20 +122,14 @@ function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; muta '*', authMiddleware({ tokens: new Map([ - [READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }], + [READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:tables'] }], [ WRITE_TOKEN, - { token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] }, + { token: WRITE_TOKEN, name: 'writer', scopes: ['read:tables', 'write:tables'] }, ], ]), oidc: null, groupsScopesMap: {}, - strictMapping: true, - cache: { - get: async () => null, - set: async () => {}, - }, - finder: { findByEmail: async (): Promise => null }, logger, }), ); @@ -149,17 +146,14 @@ function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; muta } await next(); }); - v1.route('/personnes', personnesRoutes); - v1.route('/modules', modulesRoutes); - v1.route('/interventions', interventionsRoutes); - v1.route('/attributions', attributionsRoutes); + v1.route('/tables', tablesRoutes); app.route('/api/v1', v1); return app; } afterEach(() => setContainer(null)); -describe('Rate limit application sur /api/v1/*', () => { +describe('Rate limit + cache invalidation sur /api/v1/*', () => { it('GET /api/health : pas de rate limit (route publique)', async () => { const redis = new FakeRedis(); installContainer(redis, { globalMax: 1, mutationMax: 1 }); @@ -197,12 +191,12 @@ describe('Rate limit application sur /api/v1/*', () => { expect(redis.rateChecks).toHaveLength(0); }); - it('GET /api/v1/personnes : rate limit consomme', async () => { + it('GET /api/v1/tables/5/rows : rate limit consomme', async () => { const redis = new FakeRedis(); installContainer(redis, { globalMax: 100, mutationMax: 100 }); const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 }); - const r = await app.request('/api/v1/personnes', { + const r = await app.request('/api/v1/tables/5/rows', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(r.status).toBe(200); @@ -216,41 +210,42 @@ describe('Rate limit application sur /api/v1/*', () => { const app = buildAppWithRateLimit(redis, { globalMax: 2, mutationMax: 100 }); const headers = { Authorization: `Bearer ${READ_TOKEN}` }; - const r1 = await app.request('/api/v1/personnes', { headers }); - const r2 = await app.request('/api/v1/personnes', { headers }); - const r3 = await app.request('/api/v1/personnes', { headers }); + const r1 = await app.request('/api/v1/tables/5/rows', { headers }); + const r2 = await app.request('/api/v1/tables/5/rows', { headers }); + const r3 = await app.request('/api/v1/tables/5/rows', { headers }); expect(r1.status).toBe(200); expect(r2.status).toBe(200); expect(r3.status).toBe(429); }); - it('PATCH attribution : applique mutation rate limit + invalide le cache', async () => { + it('POST row : applique mutation rate limit + invalide le cache de la table', async () => { const redis = new FakeRedis(); installContainer(redis, { globalMax: 100, mutationMax: 100 }); const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 }); - const r = await app.request('/api/v1/attributions/500/heures-realisees', { - method: 'PATCH', + const r = await app.request('/api/v1/tables/5/rows', { + method: 'POST', headers: { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ heures_realisees: 4 }), + body: JSON.stringify({ nom: 'X', heures: 40 }), }); - expect(r.status).toBe(200); + expect(r.status).toBe(201); // Deux compteurs Redis distincts : token:writer et token:writer:mut. const keys = redis.rateChecks.map((c) => c.key); expect(keys).toContain('token:writer'); expect(keys).toContain('token:writer:mut'); - // Cache invalidation : attribution row + list + cascade module + personne. - expect(redis.invalidations).toContain('bridge:attribution:list:*'); - expect(redis.invalidations).toContain('bridge:attribution:row:500'); - expect(redis.invalidations).toContain('bridge:module:list:*'); - expect(redis.invalidations).toContain('bridge:personne:list:*'); + // Cache invalidation generique : juste la table touchee + sa row. + expect(redis.invalidations).toContain('bridge:tables:5:list:*'); + expect(redis.invalidations).toContain('bridge:tables:5:views:*'); + expect(redis.invalidations.some((p) => p.startsWith('bridge:tables:5:row:'))).toBe(true); + // Pas de cascade cross-table. + expect(redis.invalidations.every((p) => p.startsWith('bridge:tables:5:'))).toBe(true); }); it('mutation au-dela de mutationMax -> 429 meme si globalMax pas atteint', async () => { @@ -259,20 +254,12 @@ describe('Rate limit application sur /api/v1/*', () => { const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 1 }); const headers = { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json' }; - const body = JSON.stringify({ heures_realisees: new Decimal(1).toNumber() }); + const body = JSON.stringify({ nom: 'X' }); - const r1 = await app.request('/api/v1/attributions/500/heures-realisees', { - method: 'PATCH', - headers, - body, - }); - const r2 = await app.request('/api/v1/attributions/500/heures-realisees', { - method: 'PATCH', - headers, - body, - }); + const r1 = await app.request('/api/v1/tables/5/rows', { method: 'POST', headers, body }); + const r2 = await app.request('/api/v1/tables/5/rows', { method: 'POST', headers, body }); - expect(r1.status).toBe(200); + expect(r1.status).toBe(201); expect(r2.status).toBe(429); }); }); diff --git a/bridge/tests/middleware/auth.test.ts b/bridge/tests/middleware/auth.test.ts index d6259b9..65d5d22 100644 --- a/bridge/tests/middleware/auth.test.ts +++ b/bridge/tests/middleware/auth.test.ts @@ -1,5 +1,8 @@ /** - * Tests integration auth middleware — dual mode service-token + OIDC. + * Tests integration auth middleware (R1 generique) — dual mode service-token + OIDC. + * + * R1 — Plus de lookup PersonneRepo, plus de roles formation-hub. Le claim + * `acadenice_permissions[]` du JWT alimente directement les scopes. * * Strategie JWKS : mini serveur HTTP local qui expose un /.well-known/jwks.json * avec une cle RSA generee a la volee via `jose.generateKeyPair`. Plus realiste @@ -8,16 +11,15 @@ import { type Server, createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; -import { Decimal } from 'decimal.js'; import { Hono } from 'hono'; import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { Personne } from '../../src/domain/personne.js'; import { logger } from '../../src/lib/logger.js'; import { type ApiTokenRecord, type AuthVariables, authMiddleware, + extractPermissions, hasScope, parseTokens, requireScope, @@ -86,57 +88,6 @@ async function signJwt( return builder.sign(fx.privateKey); } -// --------------------------------------------------------------------------- -// Fakes pour cache + finder -// --------------------------------------------------------------------------- - -class FakeCache { - store = new Map(); - hits = 0; - setCalls = 0; - - async get(key: string): Promise { - if (this.store.has(key)) { - this.hits += 1; - return this.store.get(key) as T; - } - return null; - } - - async set(key: string, value: T, _ttl?: number): Promise { - this.setCalls += 1; - this.store.set(key, value); - } -} - -class FakeFinder { - byEmail = new Map(); - calls = 0; - - async findByEmail(email: string): Promise { - this.calls += 1; - return this.byEmail.get(email.toLowerCase()) ?? null; - } -} - -function makePersonne(opts: { - id: number; - email: string; - roles: Array<'formateur' | 'developpeur' | 'admin' | 'direction' | 'support'>; -}): Personne { - return new Personne({ - id: opts.id, - nom: 'Doe', - prenom: 'Jane', - email: opts.email, - capaciteAnnuelle: new Decimal(1500), - splitFormationPct: new Decimal(60), - splitAgencePct: new Decimal(40), - roles: new Set(opts.roles), - statut: 'actif', - }); -} - // --------------------------------------------------------------------------- // App builder // --------------------------------------------------------------------------- @@ -144,9 +95,6 @@ function makePersonne(opts: { interface BuildAppOpts { tokens?: ApiTokenRecord[]; oidcEnabled: boolean; - strictMapping?: boolean; - cache?: FakeCache; - finder?: FakeFinder; jwks?: JwksFixture; groupsScopesMap?: Record; } @@ -156,9 +104,6 @@ function buildApp(opts: BuildAppOpts) { const map = new Map(); for (const t of tokens) map.set(t.token, t); - const cache = opts.cache ?? new FakeCache(); - const finder = opts.finder ?? new FakeFinder(); - let verifier: OidcVerifier | null = null; if (opts.oidcEnabled) { if (!opts.jwks) throw new Error('jwks fixture required when oidcEnabled'); @@ -179,9 +124,6 @@ function buildApp(opts: BuildAppOpts) { tokens: map, oidc: verifier, groupsScopesMap: opts.groupsScopesMap ?? {}, - strictMapping: opts.strictMapping ?? true, - cache, - finder, logger, }), ); @@ -190,12 +132,12 @@ function buildApp(opts: BuildAppOpts) { const user = c.get('user'); return c.json({ user }); }); - app.get('/protected/needs-formation-read', requireScope('formation:read'), (c) => + app.get('/protected/needs-read-tables', requireScope('read:tables'), (c) => c.json({ ok: true, scopes: c.get('user').scopes }), ); app.get('/protected/needs-admin', requireScope('admin:write'), (c) => c.json({ ok: true })); - return { app, cache, finder }; + return { app }; } // --------------------------------------------------------------------------- @@ -205,7 +147,7 @@ function buildApp(opts: BuildAppOpts) { describe('parseTokens', () => { it('parse JSON valide', () => { const map = parseTokens( - JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:personnes'] }]), + JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:tables'] }]), ); expect(map.get('brg_x')?.name).toBe('a'); }); @@ -232,21 +174,39 @@ describe('parseTokens', () => { describe('hasScope', () => { it('match exact', () => { - expect(hasScope(new Set(['read:personnes']), 'read:personnes')).toBe(true); - expect(hasScope(new Set(['read:personnes']), 'read:projets')).toBe(false); + expect(hasScope(new Set(['read:tables']), 'read:tables')).toBe(true); + expect(hasScope(new Set(['read:tables']), 'write:tables')).toBe(false); }); it('admin:* couvre tout', () => { expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true); }); it('prefix wildcard couvre meme prefix', () => { - expect(hasScope(new Set(['read:*']), 'read:personnes')).toBe(true); - expect(hasScope(new Set(['read:*']), 'write:personnes')).toBe(false); + expect(hasScope(new Set(['read:*']), 'read:tables')).toBe(true); + expect(hasScope(new Set(['read:*']), 'write:tables')).toBe(false); }); }); -describe('auth middleware — service tokens (mode local)', () => { +describe('extractPermissions', () => { + it('retourne le claim acadenice_permissions[] si present', () => { + expect(extractPermissions({ acadenice_permissions: ['a', 'b'] })).toEqual(['a', 'b']); + }); + it('retourne [] si claim absent', () => { + expect(extractPermissions({})).toEqual([]); + }); + it('retourne [] si claim pas un array', () => { + expect(extractPermissions({ acadenice_permissions: 'foo' })).toEqual([]); + }); + it('filtre les valeurs non-strings et vides', () => { + expect(extractPermissions({ acadenice_permissions: ['ok', 1, '', null, 'good'] })).toEqual([ + 'ok', + 'good', + ]); + }); +}); + +describe('auth middleware — service tokens', () => { const tokens: ApiTokenRecord[] = [ - { token: 'brg_valid', name: 'demo', scopes: ['formation:read', 'admin:write'] }, + { token: 'brg_valid', name: 'demo', scopes: ['read:tables', 'admin:write'] }, ]; it('cas 1 — service token valid via Bearer -> 200 + source=service-token', async () => { @@ -260,7 +220,7 @@ describe('auth middleware — service tokens (mode local)', () => { }; expect(body.user.source).toBe('service-token'); expect(body.user.tokenId).toBe('demo'); - expect(body.user.scopes).toContain('formation:read'); + expect(body.user.scopes).toContain('read:tables'); }); it('service token valid via ApiKey scheme -> 200', async () => { @@ -320,7 +280,7 @@ describe('auth middleware — OIDC desactive + JWT envoye', () => { }); }); -describe('auth middleware — OIDC actif', () => { +describe('auth middleware — OIDC actif (R1 generique)', () => { let jwks: JwksFixture; beforeAll(async () => { @@ -331,18 +291,13 @@ describe('auth middleware — OIDC actif', () => { await new Promise((resolve) => jwks.server.close(() => resolve())); }); - it('cas 4 — JWT valid + email -> Personne -> 200, source=oidc-jwt, roles', async () => { - const finder = new FakeFinder(); - finder.byEmail.set( - 'jane@acadenice.fr', - makePersonne({ id: 42, email: 'jane@acadenice.fr', roles: ['formateur'] }), - ); - - const { app } = buildApp({ oidcEnabled: true, jwks, finder }); + it('cas 4 — JWT valid + claim acadenice_permissions[] -> 200, scopes alimentes directement', async () => { + const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'jane@acadenice.fr', sub: 'authentik-jane-uuid', - groups: ['formation-hub-formateurs'], + groups: [], + acadenice_permissions: ['read:tables', 'write:tables'], }); const res = await app.request('/protected/me', { @@ -350,22 +305,80 @@ describe('auth middleware — OIDC actif', () => { }); expect(res.status).toBe(200); const body = (await res.json()) as { - user: { source: string; personneId: number; roles: string[]; scopes: string[]; sub: string }; + user: { + source: string; + scopes: string[]; + permissions: string[]; + sub: string; + email: string; + }; }; expect(body.user.source).toBe('oidc-jwt'); - expect(body.user.personneId).toBe(42); - expect(body.user.roles).toContain('formateur'); + expect(body.user.email).toBe('jane@acadenice.fr'); expect(body.user.sub).toBe('authentik-jane-uuid'); - // Default role->scope mapping pour formateur inclut formation:* style write:attributions - expect(body.user.scopes).toContain('write:attributions'); + expect(body.user.scopes).toContain('read:tables'); + expect(body.user.scopes).toContain('write:tables'); + expect(body.user.permissions).toEqual(['read:tables', 'write:tables']); + }); + + it('cas 4b — JWT sans claim acadenice_permissions[] : auth OK mais scopes vides', async () => { + const { app } = buildApp({ oidcEnabled: true, jwks }); + const token = await signJwt(jwks, { + email: 'jane@acadenice.fr', + sub: 'sub-x', + groups: [], + }); + + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { user: { permissions: string[]; scopes: string[] } }; + expect(body.user.permissions).toEqual([]); + expect(body.user.scopes).toEqual([]); + }); + + it('cas 4c — JWT avec groups Authentik mappes -> scopes via groupsScopesMap', async () => { + const { app } = buildApp({ + oidcEnabled: true, + jwks, + groupsScopesMap: { 'role-formateur': ['read:tables'] }, + }); + const token = await signJwt(jwks, { + email: 'fmt@acadenice.fr', + groups: ['role-formateur'], + }); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { user: { scopes: string[]; groups: string[] } }; + expect(body.user.scopes).toContain('read:tables'); + expect(body.user.groups).toContain('role-formateur'); + }); + + it('cas 4d — union groups + permissions explicites', async () => { + const { app } = buildApp({ + oidcEnabled: true, + jwks, + groupsScopesMap: { 'role-x': ['read:tables'] }, + }); + const token = await signJwt(jwks, { + email: 'x@acadenice.fr', + groups: ['role-x'], + acadenice_permissions: ['write:tables'], + }); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + const body = (await res.json()) as { user: { scopes: string[] } }; + expect(body.user.scopes).toContain('read:tables'); + expect(body.user.scopes).toContain('write:tables'); }); it('cas 5 — JWT signature invalide -> 401 AUTH_INVALID', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'x@y.z' }); - // Tamper signature : remplace les 16 derniers chars par des '0' base64url valides. - // Garantit que la signature change vraiment (un seul char flip peut tomber sur - // une variation base64 qui code la meme valeur binaire). const tampered = `${token.slice(0, -16)}AAAAAAAAAAAAAAAA`; const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${tampered}` }, @@ -402,96 +415,42 @@ describe('auth middleware — OIDC actif', () => { expect(res.status).toBe(401); }); - it('cas 9 — JWT email orphelin (mode strict) -> 403 FORBIDDEN', async () => { - const { app, finder } = buildApp({ oidcEnabled: true, jwks, strictMapping: true }); + it('cas 9 — Cookie authToken valid -> 200, source=oidc-cookie', async () => { + const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { - email: 'nobody@acadenice.fr', - sub: 'authentik-nobody', - groups: [], + email: 'cookie@acadenice.fr', + acadenice_permissions: ['read:tables'], }); - const res = await app.request('/protected/me', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('FORBIDDEN'); - expect(finder.calls).toBe(1); - }); - - it('mode permissif : email orphelin -> 200 avec scopes des groups uniquement', async () => { - const { app } = buildApp({ - oidcEnabled: true, - jwks, - strictMapping: false, - groupsScopesMap: { 'formation-hub-formateurs': ['formation:read'] }, - }); - const token = await signJwt(jwks, { - email: 'nobody@acadenice.fr', - groups: ['formation-hub-formateurs'], - }); - const res = await app.request('/protected/me', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - user: { scopes: string[]; personneId?: number; roles: string[] }; - }; - expect(body.user.scopes).toContain('formation:read'); - expect(body.user.personneId).toBeUndefined(); - expect(body.user.roles).toEqual([]); - }); - - it('cas 10 — Cookie authToken valid -> 200, source=oidc-cookie', async () => { - const finder = new FakeFinder(); - finder.byEmail.set( - 'cookie@acadenice.fr', - makePersonne({ id: 7, email: 'cookie@acadenice.fr', roles: ['developpeur'] }), - ); - const { app } = buildApp({ oidcEnabled: true, jwks, finder }); - const token = await signJwt(jwks, { email: 'cookie@acadenice.fr', groups: [] }); const res = await app.request('/protected/me', { headers: { Cookie: `authToken=${token}` }, }); expect(res.status).toBe(200); - const body = (await res.json()) as { user: { source: string; personneId: number } }; + const body = (await res.json()) as { user: { source: string; email: string } }; expect(body.user.source).toBe('oidc-cookie'); - expect(body.user.personneId).toBe(7); + expect(body.user.email).toBe('cookie@acadenice.fr'); }); - it('cas 11 — requireScope match via groups Authentik -> 200', async () => { - const finder = new FakeFinder(); - finder.byEmail.set( - 'fmt@acadenice.fr', - makePersonne({ id: 1, email: 'fmt@acadenice.fr', roles: [] }), - ); - const { app } = buildApp({ - oidcEnabled: true, - jwks, - finder, - groupsScopesMap: { 'formation-hub-formateurs': ['formation:read'] }, - }); + it('cas 10 — requireScope match via permissions claim -> 200', async () => { + const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { - email: 'fmt@acadenice.fr', - groups: ['formation-hub-formateurs'], + email: 'a@b.c', + acadenice_permissions: ['read:tables'], }); - const res = await app.request('/protected/needs-formation-read', { + const res = await app.request('/protected/needs-read-tables', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; scopes: string[] }; expect(body.ok).toBe(true); - expect(body.scopes).toContain('formation:read'); + expect(body.scopes).toContain('read:tables'); }); - it('cas 12 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => { - const finder = new FakeFinder(); - finder.byEmail.set( - 'fmt2@acadenice.fr', - makePersonne({ id: 2, email: 'fmt2@acadenice.fr', roles: ['formateur'] }), - ); - const { app } = buildApp({ oidcEnabled: true, jwks, finder }); - const token = await signJwt(jwks, { email: 'fmt2@acadenice.fr', groups: [] }); - // formateur n'a pas admin:write par defaut. + it('cas 11 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => { + const { app } = buildApp({ oidcEnabled: true, jwks }); + const token = await signJwt(jwks, { + email: 'a@b.c', + acadenice_permissions: ['read:tables'], + }); const res = await app.request('/protected/needs-admin', { headers: { Authorization: `Bearer ${token}` }, }); @@ -499,42 +458,4 @@ describe('auth middleware — OIDC actif', () => { const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('FORBIDDEN_SCOPE'); }); - - it("cache hit : 2 requetes consecutives ne tapent qu'une fois le repo", async () => { - const finder = new FakeFinder(); - finder.byEmail.set( - 'cached@acadenice.fr', - makePersonne({ id: 99, email: 'cached@acadenice.fr', roles: ['developpeur'] }), - ); - const cache = new FakeCache(); - const { app } = buildApp({ oidcEnabled: true, jwks, finder, cache }); - const token = await signJwt(jwks, { email: 'cached@acadenice.fr', groups: [] }); - - const res1 = await app.request('/protected/me', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res1.status).toBe(200); - const res2 = await app.request('/protected/me', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res2.status).toBe(200); - expect(finder.calls).toBe(1); - expect(cache.hits).toBe(1); - }); - - it('cache miss persist : email inexistant => second appel hit cache', async () => { - const finder = new FakeFinder(); - const cache = new FakeCache(); - const { app } = buildApp({ - oidcEnabled: true, - jwks, - finder, - cache, - strictMapping: false, - }); - const token = await signJwt(jwks, { email: 'ghost@acadenice.fr', groups: [] }); - await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` } }); - await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` } }); - expect(finder.calls).toBe(1); - }); }); diff --git a/bridge/tests/middleware/rate-limit.test.ts b/bridge/tests/middleware/rate-limit.test.ts index 660010d..12b4234 100644 --- a/bridge/tests/middleware/rate-limit.test.ts +++ b/bridge/tests/middleware/rate-limit.test.ts @@ -20,8 +20,8 @@ interface FakeUser { tokenId?: string; email?: string; sub?: string; - roles: string[]; groups: string[]; + permissions: string[]; scopes: string[]; } @@ -80,7 +80,7 @@ describe('rateLimit middleware', () => { { source: 'service-token', tokenId: 'svc-A', - roles: [], + permissions: [], groups: [], scopes: [], }, @@ -111,7 +111,7 @@ describe('rateLimit middleware', () => { { source: 'service-token', tokenId: 'svc-B', - roles: [], + permissions: [], groups: [], scopes: [], }, @@ -134,7 +134,7 @@ describe('rateLimit middleware', () => { const user: FakeUser = { source: 'service-token', tokenId: 'svc-C', - roles: [], + permissions: [], groups: [], scopes: [], }; @@ -181,7 +181,7 @@ describe('rateLimit middleware', () => { tokenId: 'svc-D', email: 'should-not-be-used@test', sub: 'sub-x', - roles: [], + permissions: [], groups: [], scopes: [], }, @@ -200,7 +200,7 @@ describe('rateLimit middleware', () => { source: 'oidc-jwt', email: 'Foo@Bar.IO', sub: 'sub-y', - roles: [], + permissions: [], groups: [], scopes: [], }, @@ -219,7 +219,7 @@ describe('rateLimit middleware', () => { { source: 'oidc-jwt', sub: 'sub-z', - roles: [], + permissions: [], groups: [], scopes: [], }, @@ -258,7 +258,7 @@ describe('rateLimit middleware', () => { const app = buildApp( limiter, { max: 5, window: 60, keyFrom: () => 'custom-key' }, - { source: 'service-token', tokenId: 'svc-E', roles: [], groups: [], scopes: [] }, + { source: 'service-token', tokenId: 'svc-E', permissions: [], groups: [], scopes: [] }, ); await app.request('/'); @@ -273,7 +273,7 @@ describe('rateLimit middleware', () => { { source: 'service-token', tokenId: 'svc-F', - roles: [], + permissions: [], groups: [], scopes: [], }, diff --git a/bridge/tests/middleware/scopes.test.ts b/bridge/tests/middleware/scopes.test.ts index 179ff6b..8d4c223 100644 --- a/bridge/tests/middleware/scopes.test.ts +++ b/bridge/tests/middleware/scopes.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - DEFAULT_ROLE_SCOPES, - computeOidcScopes, - parseGroupsScopesMap, -} from '../../src/middleware/scopes.js'; +import { computeOidcScopes, parseGroupsScopesMap } from '../../src/middleware/scopes.js'; describe('parseGroupsScopesMap', () => { it('retourne {} si vide', () => { @@ -31,32 +27,40 @@ describe('parseGroupsScopesMap', () => { }); }); -describe('computeOidcScopes', () => { - it('union groups + roles + dedup', () => { - const scopes = computeOidcScopes(['formation-hub-formateurs'], new Set(['formateur']), { - 'formation-hub-formateurs': ['formation:read', 'admin:custom'], +describe('computeOidcScopes (R1 generique)', () => { + it('union groups + permissions + dedup', () => { + const scopes = computeOidcScopes(['group-formateurs'], ['custom:perm', 'read:tables'], { + 'group-formateurs': ['read:tables', 'admin:custom'], }); - expect(scopes).toContain('formation:read'); + expect(scopes).toContain('read:tables'); expect(scopes).toContain('admin:custom'); - // Vient du DEFAULT_ROLE_SCOPES.formateur - expect(scopes).toContain('write:attributions'); + expect(scopes).toContain('custom:perm'); + // Dedup : read:tables apparait une seule fois + expect(scopes.filter((s) => s === 'read:tables')).toHaveLength(1); }); it("group inconnu ignore (pas d'erreur)", () => { - const scopes = computeOidcScopes(['unknown-group'], new Set(), {}); + const scopes = computeOidcScopes(['unknown-group'], [], {}); expect(scopes).toEqual([]); }); - it('default mapping admin role -> admin:*', () => { - const scopes = computeOidcScopes([], new Set(['admin']), {}); - expect(scopes).toContain('admin:*'); + it('aucun group + aucune permission = scopes vides', () => { + expect(computeOidcScopes([], [], {})).toEqual([]); }); - it('aucun group + aucun role = scopes vides', () => { - expect(computeOidcScopes([], new Set(), {})).toEqual([]); + it('permissions explicites sans group fonctionnent (claim direct du JWT)', () => { + const scopes = computeOidcScopes([], ['read:tables', 'write:tables'], {}); + expect(scopes).toContain('read:tables'); + expect(scopes).toContain('write:tables'); }); - it('DEFAULT_ROLE_SCOPES couvre les 5 roles', () => { - expect(Object.keys(DEFAULT_ROLE_SCOPES)).toHaveLength(5); + it('ignore les permissions non-strings ou vides', () => { + const scopes = computeOidcScopes([], ['ok', '', 'also-ok'], {}); + expect(scopes).toEqual(['also-ok', 'ok']); + }); + + it('output trie alphabetiquement (stabilite)', () => { + const scopes = computeOidcScopes(['g'], ['z', 'a'], { g: ['m', 'b'] }); + expect(scopes).toEqual(['a', 'b', 'm', 'z']); }); }); diff --git a/bridge/tests/repos/baserow-fields-repo.test.ts b/bridge/tests/repos/baserow-fields-repo.test.ts new file mode 100644 index 0000000..d089e6b --- /dev/null +++ b/bridge/tests/repos/baserow-fields-repo.test.ts @@ -0,0 +1,51 @@ +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import type { BaserowClient } from '../../src/adapters/baserow-client.js'; +import { BaserowFieldsRepo } from '../../src/repos/baserow-fields-repo.js'; + +const silentLogger = () => pino({ level: 'silent' }); + +class FakeClient { + constructor( + public raws: Array< + { id: number; name: string; type: string; primary?: boolean } & Record + > = [], + ) {} + listFields(_tableId: number) { + return Promise.resolve(this.raws); + } +} + +describe('BaserowFieldsRepo', () => { + it('list mappe raw fields -> Field[] avec options groupees', async () => { + const fake = new FakeClient([ + { id: 10, name: 'titre', type: 'text', primary: true }, + { + id: 11, + name: 'statut', + type: 'single_select', + primary: false, + select_options: [{ id: 1, value: 'a' }], + }, + ]); + const repo = new BaserowFieldsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const fields = await repo.list(5); + expect(fields).toHaveLength(2); + expect(fields[0]?.primary).toBe(true); + expect(fields[0]?.options).toBeNull(); + expect(fields[1]?.options).toEqual({ select_options: [{ id: 1, value: 'a' }] }); + }); + + it('field sans meta extra : options=null', async () => { + const fake = new FakeClient([{ id: 1, name: 'x', type: 'text', primary: false }]); + const repo = new BaserowFieldsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const fields = await repo.list(5); + expect(fields[0]?.options).toBeNull(); + }); +}); diff --git a/bridge/tests/repos/baserow-repo.test.ts b/bridge/tests/repos/baserow-repo.test.ts deleted file mode 100644 index 1c4093e..0000000 --- a/bridge/tests/repos/baserow-repo.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Tests des mappers Row -> Domain. On instancie les repos avec un BaserowClient mock - * minimal qui rend juste getRow/listRows. - */ - -import type { Logger } from 'pino'; -import { describe, expect, it, vi } from 'vitest'; -import type { - BaserowClient, - BaserowPaginatedResponse, - BaserowRow, -} from '../../src/adapters/baserow-client.js'; -import { logger } from '../../src/lib/logger.js'; -import { - AttributionRepo, - FormationRepo, - ModuleRepo, - PersonneRepo, - ProjetRepo, -} from '../../src/repos/baserow-repo.js'; - -function fakeClient(rowsByTable: Record): BaserowClient { - return { - listRows: vi.fn( - (tableId: number): Promise => - Promise.resolve({ - count: rowsByTable[tableId]?.length ?? 0, - next: null, - previous: null, - results: rowsByTable[tableId] ?? [], - }), - ), - getRow: vi.fn((tableId: number, rowId: number): Promise => { - const row = (rowsByTable[tableId] ?? []).find((r) => r.id === rowId); - if (!row) return Promise.reject(Object.assign(new Error('not found'), { code: 'NOT_FOUND' })); - return Promise.resolve(row); - }), - createRow: vi.fn(), - updateRow: vi.fn(), - deleteRow: vi.fn(), - resolveTableIds: vi.fn(), - healthCheck: vi.fn(), - } as unknown as BaserowClient; -} - -const log = logger as Logger; - -describe('PersonneRepo', () => { - it('mappe une row Baserow vers Personne', async () => { - const row: BaserowRow = { - id: 42, - order: '1', - personne_nom: 'Dupont', - personne_prenom: 'Pierre', - personne_email: 'p@a.fr', - personne_capacite_annuelle: '1000', - personne_split_formation_pct: 60, - personne_split_agence_pct: 40, - personne_roles: [{ id: 1, value: 'formateur', color: 'blue' }], - personne_statut: { id: 2, value: 'actif', color: 'green' }, - personne_heures_attribuees_formation: '0', - personne_heures_attribuees_agence: '0', - }; - const repo = new PersonneRepo({ - client: fakeClient({ 1: [row] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - const personne = await repo.get(42); - expect(personne.id).toBe(42); - expect(personne.nom).toBe('Dupont'); - expect(personne.hasRole('formateur')).toBe(true); - expect(personne.statut).toBe('actif'); - expect(personne.capaciteAnnuelle.toNumber()).toBe(1000); - }); - - it('list pagine et map', async () => { - const repo = new PersonneRepo({ - client: fakeClient({ - 1: [ - { - id: 1, - order: '1', - personne_nom: 'A', - personne_prenom: 'B', - personne_email: 'a@b.c', - personne_capacite_annuelle: '1', - personne_split_formation_pct: 50, - personne_split_agence_pct: 50, - personne_roles: [], - personne_statut: 'actif', - }, - ], - }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - const res = await repo.list({ size: 50 }); - expect(res.items).toHaveLength(1); - expect(res.meta.total).toBe(1); - }); - - it('throw NOT_FOUND si row inexistante', async () => { - const repo = new PersonneRepo({ - client: fakeClient({ 1: [] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - await expect(repo.get(999)).rejects.toMatchObject({ code: 'NOT_FOUND' }); - }); - - describe('findByEmail', () => { - function makeRow(email: string, id = 1): BaserowRow { - return { - id, - order: '1', - personne_nom: 'Doe', - personne_prenom: 'Jane', - personne_email: email, - personne_capacite_annuelle: '1000', - personne_split_formation_pct: 50, - personne_split_agence_pct: 50, - personne_roles: [{ id: 1, value: 'formateur', color: 'blue' }], - personne_statut: { id: 2, value: 'actif', color: 'green' }, - }; - } - - it('retourne la Personne sur match exact', async () => { - const repo = new PersonneRepo({ - client: fakeClient({ 1: [makeRow('jane@acadenice.fr', 7)] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - const found = await repo.findByEmail('jane@acadenice.fr'); - expect(found?.id).toBe(7); - }); - - it('insensitive a la casse + trim', async () => { - const repo = new PersonneRepo({ - client: fakeClient({ 1: [makeRow('jane@acadenice.fr', 7)] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - const found = await repo.findByEmail(' Jane@AcadeNice.fr '); - expect(found?.id).toBe(7); - }); - - it('retourne null si email vide', async () => { - const repo = new PersonneRepo({ - client: fakeClient({ 1: [] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - expect(await repo.findByEmail('')).toBeNull(); - expect(await repo.findByEmail(' ')).toBeNull(); - }); - - it('retourne null si aucune row ne match exact (substring rejet)', async () => { - const repo = new PersonneRepo({ - client: fakeClient({ 1: [makeRow('john.jane@acadenice.fr', 7)] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - const found = await repo.findByEmail('jane@acadenice.fr'); - expect(found).toBeNull(); - }); - - it('retourne null si row corrompue (split != 100)', async () => { - const corrupt: BaserowRow = { - id: 1, - order: '1', - personne_nom: 'X', - personne_prenom: 'Y', - personne_email: 'corrupt@test.fr', - personne_capacite_annuelle: '1000', - personne_split_formation_pct: 60, - personne_split_agence_pct: 60, // total = 120 -> domain throw - personne_roles: [], - personne_statut: 'actif', - }; - const repo = new PersonneRepo({ - client: fakeClient({ 1: [corrupt] }), - tableId: 1, - entityName: 'Personne', - logger: log, - }); - const found = await repo.findByEmail('corrupt@test.fr'); - expect(found).toBeNull(); - }); - }); -}); - -describe('FormationRepo', () => { - it('mappe filiere/statut select', async () => { - const row: BaserowRow = { - id: 10, - order: '1', - formation_nom: 'Dev', - formation_filiere: { id: 1, value: 'dev', color: 'blue' }, - formation_heures_totales: 500, - formation_statut: { id: 2, value: 'actif', color: 'green' }, - }; - const repo = new FormationRepo({ - client: fakeClient({ 2: [row] }), - tableId: 2, - entityName: 'Formation', - logger: log, - }); - const f = await repo.get(10); - expect(f.filiere).toBe('dev'); - expect(f.statut).toBe('actif'); - }); -}); - -describe('ModuleRepo', () => { - it('mappe blocId via link field', async () => { - const row: BaserowRow = { - id: 200, - order: '1', - module_nom: 'JS', - module_heures_prevues: 30, - module_statut: 'a_attribuer', - module_bloc: [{ id: 100, value: 'Bloc JS' }], - }; - const repo = new ModuleRepo({ - client: fakeClient({ 4: [row] }), - tableId: 4, - entityName: 'Module', - logger: log, - }); - const m = await repo.get(200); - expect(m.blocId).toBe(100); - expect(m.statut).toBe('a_attribuer'); - }); -}); - -describe('AttributionRepo', () => { - it('mappe + create persiste les bons fields', async () => { - const row: BaserowRow = { - id: 500, - order: '1', - attribution_heures_attribuees: 10, - attribution_heures_realisees: 0, - attribution_module: [{ id: 200, value: 'JS' }], - attribution_personne: [{ id: 1, value: 'Pierre' }], - attribution_statut: 'planifie', - }; - const client = fakeClient({ 5: [row] }); - const repo = new AttributionRepo({ - client, - tableId: 5, - entityName: 'Attribution', - logger: log, - }); - const a = await repo.get(500); - expect(a.moduleId).toBe(200); - expect(a.personneId).toBe(1); - - await repo.create({ - moduleId: 200, - personneId: 1, - heuresAttribuees: a.heuresAttribuees, - dateDebut: new Date('2026-09-01'), - dateFin: null, - statut: 'planifie', - }); - expect(client.createRow).toHaveBeenCalledWith( - 5, - expect.objectContaining({ - attribution_module: [200], - attribution_personne: [1], - attribution_statut: 'planifie', - }), - ); - }); -}); - -describe('ProjetRepo', () => { - it('mappe statut + clientId', async () => { - const row: BaserowRow = { - id: 300, - order: '1', - projet_nom: 'Acme', - projet_charge_heures: 80, - projet_client: [{ id: 50, value: 'Acme Inc' }], - projet_statut: { id: 1, value: 'en_cours', color: 'blue' }, - projet_type: { id: 2, value: 'site_web', color: 'blue' }, - }; - const repo = new ProjetRepo({ - client: fakeClient({ 7: [row] }), - tableId: 7, - entityName: 'Projet', - logger: log, - }); - const p = await repo.get(300); - expect(p.clientId).toBe(50); - expect(p.statut).toBe('en_cours'); - expect(p.type).toBe('site_web'); - }); -}); diff --git a/bridge/tests/repos/baserow-rows-repo.test.ts b/bridge/tests/repos/baserow-rows-repo.test.ts new file mode 100644 index 0000000..d15b9e2 --- /dev/null +++ b/bridge/tests/repos/baserow-rows-repo.test.ts @@ -0,0 +1,133 @@ +/** + * Tests unit BaserowRowsRepo : mock du BaserowClient pour valider mapping + * raw -> Row, gestion 404, pagination clamp. + */ + +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import type { BaserowClient, BaserowRow } from '../../src/adapters/baserow-client.js'; +import { BridgeError } from '../../src/lib/errors.js'; +import { BaserowRowsRepo } from '../../src/repos/baserow-rows-repo.js'; + +const silentLogger = () => pino({ level: 'silent' }); + +class FakeClient { + public listCalls: Array<{ tableId: number; opts: unknown }> = []; + constructor( + public results: BaserowRow[] = [], + public count = 0, + ) {} + listRows(tableId: number, opts: unknown) { + this.listCalls.push({ tableId, opts }); + return Promise.resolve({ + count: this.count, + next: null, + previous: null, + results: this.results, + }); + } + getRow(_tableId: number, rowId: number) { + const r = this.results.find((x) => x.id === rowId); + if (!r) { + const err = new BridgeError('NOT_FOUND', 404, 'not found', {}); + return Promise.reject(err); + } + return Promise.resolve(r); + } + createRow(_tableId: number, data: Record) { + return Promise.resolve({ id: 1000, order: '1.0', ...data } as BaserowRow); + } + updateRow(_tableId: number, rowId: number, data: Record) { + return Promise.resolve({ id: rowId, order: '1.0', ...data } as BaserowRow); + } + deleteRow(_tableId: number, _rowId: number) { + return Promise.resolve(); + } +} + +describe('BaserowRowsRepo', () => { + it('list mappe raw rows -> Row, calcule meta', async () => { + const fake = new FakeClient( + [ + { id: 1, order: '1.0', nom: 'Alice', heures: 40 } as BaserowRow, + { id: 2, order: '2.0', nom: 'Bob' } as BaserowRow, + ], + 2, + ); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const res = await repo.list(5); + expect(res.items).toHaveLength(2); + expect(res.items[0]?.id).toBe(1); + expect(res.items[0]?.tableId).toBe(5); + expect(res.items[0]?.fields.nom).toBe('Alice'); + expect(res.items[0]?.fields.heures).toBe(40); + expect(res.items[0]?.order).toBe('1.0'); + expect(res.meta.total).toBe(2); + }); + + it('list clamp size a 200 max', async () => { + const fake = new FakeClient([], 0); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + await repo.list(5, { size: 500 }); + const opts = fake.listCalls[0]?.opts as { size: number }; + expect(opts.size).toBe(200); + }); + + it('get retourne une Row mappee', async () => { + const fake = new FakeClient([{ id: 7, order: '1.0', nom: 'X' } as BaserowRow]); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const row = await repo.get(5, 7); + expect(row.id).toBe(7); + expect(row.fields.nom).toBe('X'); + }); + + it('get throw NOT_FOUND si row introuvable', async () => { + const fake = new FakeClient([]); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + await expect(repo.get(5, 9999)).rejects.toThrow(BridgeError); + }); + + it('create retourne la Row creee avec id assigne', async () => { + const fake = new FakeClient(); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const row = await repo.create(5, { nom: 'New' }); + expect(row.id).toBe(1000); + expect(row.fields.nom).toBe('New'); + expect(row.tableId).toBe(5); + }); + + it('update retourne la Row mise a jour', async () => { + const fake = new FakeClient(); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const row = await repo.update(5, 100, { heures: 45 }); + expect(row.id).toBe(100); + expect(row.fields.heures).toBe(45); + }); + + it('delete ne throw pas si OK', async () => { + const fake = new FakeClient(); + const repo = new BaserowRowsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + await expect(repo.delete(5, 100)).resolves.toBeUndefined(); + }); +}); diff --git a/bridge/tests/repos/baserow-tables-repo.test.ts b/bridge/tests/repos/baserow-tables-repo.test.ts new file mode 100644 index 0000000..aa0be22 --- /dev/null +++ b/bridge/tests/repos/baserow-tables-repo.test.ts @@ -0,0 +1,51 @@ +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import type { BaserowClient } from '../../src/adapters/baserow-client.js'; +import { BaserowTablesRepo } from '../../src/repos/baserow-tables-repo.js'; + +const silentLogger = () => pino({ level: 'silent' }); + +class FakeClient { + constructor( + public tables: Array<{ id: number; name: string; order: number; database_id: number }> = [], + ) {} + listTables(_databaseId: number) { + return Promise.resolve(this.tables); + } + getTable(tableId: number) { + const t = this.tables.find((x) => x.id === tableId); + if (!t) throw new Error('not found'); + return Promise.resolve(t); + } +} + +describe('BaserowTablesRepo', () => { + it('list mappe raw tables -> Table[]', async () => { + const fake = new FakeClient([ + { id: 1, name: 'Personne', order: 0, database_id: 5 }, + { id: 2, name: 'Bloc', order: 1, database_id: 5 }, + ]); + const repo = new BaserowTablesRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const res = await repo.list(5); + expect(res).toHaveLength(2); + expect(res[0]?.id).toBe(1); + expect(res[0]?.name).toBe('Personne'); + expect(res[0]?.databaseId).toBe(5); + expect(res[0]?.orderIndex).toBe(0); + }); + + it('get retourne une Table', async () => { + const fake = new FakeClient([{ id: 42, name: 'X', order: 3, database_id: 7 }]); + const repo = new BaserowTablesRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const t = await repo.get(42); + expect(t.id).toBe(42); + expect(t.databaseId).toBe(7); + expect(t.orderIndex).toBe(3); + }); +}); diff --git a/bridge/tests/repos/baserow-views-repo.test.ts b/bridge/tests/repos/baserow-views-repo.test.ts new file mode 100644 index 0000000..f66b39c --- /dev/null +++ b/bridge/tests/repos/baserow-views-repo.test.ts @@ -0,0 +1,62 @@ +import pino from 'pino'; +import { describe, expect, it } from 'vitest'; +import type { BaserowClient, BaserowRow } from '../../src/adapters/baserow-client.js'; +import { BaserowViewsRepo } from '../../src/repos/baserow-views-repo.js'; + +const silentLogger = () => pino({ level: 'silent' }); + +class FakeClient { + constructor( + public views: Array< + { id: number; name: string; type: string; table_id: number } & Record + > = [], + public gridRows: BaserowRow[] = [], + ) {} + listViews(_tableId: number) { + return Promise.resolve(this.views); + } + getGridViewRows(_viewId: number, _opts: { page?: number; size?: number } = {}) { + return Promise.resolve({ + count: this.gridRows.length, + next: null, + previous: null, + results: this.gridRows, + }); + } +} + +describe('BaserowViewsRepo', () => { + it('list mappe raw views', async () => { + const fake = new FakeClient([ + { id: 100, name: 'Tous', type: 'grid', table_id: 5 }, + { id: 101, name: 'Kanban', type: 'kanban', table_id: 5 }, + ]); + const repo = new BaserowViewsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const views = await repo.list(5); + expect(views).toHaveLength(2); + expect(views[1]?.type).toBe('kanban'); + expect(views[1]?.tableId).toBe(5); + }); + + it('runGrid retourne les rows + meta', async () => { + const fake = new FakeClient( + [], + [ + { id: 1, order: '1.0', nom: 'A' } as BaserowRow, + { id: 2, order: '2.0', nom: 'B' } as BaserowRow, + ], + ); + const repo = new BaserowViewsRepo({ + client: fake as unknown as BaserowClient, + logger: silentLogger(), + }); + const res = await repo.runGrid(100, 5); + expect(res.items).toHaveLength(2); + expect(res.items[0]?.tableId).toBe(5); + expect(res.items[0]?.fields.nom).toBe('A'); + expect(res.meta.total).toBe(2); + }); +}); diff --git a/bridge/tests/routes/attributions.test.ts b/bridge/tests/routes/attributions.test.ts deleted file mode 100644 index c42ef9a..0000000 --- a/bridge/tests/routes/attributions.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Decimal } from 'decimal.js'; -import { afterEach, describe, expect, it } from 'vitest'; -import { Attribution } from '../../src/domain/attribution.js'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makeAttribution } from '../helpers/fixtures.js'; -import { - WRITE_ALL_TOKEN, - buildTestApp, - installTestContainer, - resetTestContainer, -} from '../helpers/test-app.js'; - -function patchHeures( - app: ReturnType, - id: number, - body: Record, -) { - return app.request(`/api/v1/attributions/${id}/heures-realisees`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${WRITE_ALL_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); -} - -describe('PATCH /api/v1/attributions/:id/heures-realisees', () => { - afterEach(resetTestContainer); - - it('happy path 200', async () => { - const attrib = makeAttribution({ id: 500 }); - const repos = buildFakeRepos({ attributions: [attrib] }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await patchHeures(app, 500, { heures_realisees: 4.5, comment: 'sprint 1' }); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { heures_realisees: string } }; - expect(body.data.heures_realisees).toBe('4.50'); - expect(repos.attributions.lastUpdate?.id).toBe(500); - expect(repos.attributions.lastUpdate?.heures.toNumber()).toBe(4.5); - }); - - it('409 si attribution annulee', async () => { - const annulee = new Attribution({ - id: 500, - moduleId: 200, - personneId: 1, - heuresAttribuees: new Decimal(10), - statut: 'annule', - }); - const repos = buildFakeRepos({ attributions: [annulee] }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await patchHeures(app, 500, { heures_realisees: 2 }); - expect(res.status).toBe(409); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('CONFLICT'); - }); - - it('404 si attribution inconnue', async () => { - const repos = buildFakeRepos({}); - const app = buildTestApp(installTestContainer({ repos })); - const res = await patchHeures(app, 9999, { heures_realisees: 1 }); - expect(res.status).toBe(404); - }); - - it('400 si heures negatives', async () => { - const repos = buildFakeRepos({ attributions: [makeAttribution({ id: 500 })] }); - const app = buildTestApp(installTestContainer({ repos })); - const res = await patchHeures(app, 500, { heures_realisees: -1 }); - expect(res.status).toBe(400); - }); -}); diff --git a/bridge/tests/routes/formations.test.ts b/bridge/tests/routes/formations.test.ts deleted file mode 100644 index d4a9f43..0000000 --- a/bridge/tests/routes/formations.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makeBloc, makeFormation, makeModule } from '../helpers/fixtures.js'; -import { - READ_ALL_TOKEN, - WRITE_ALL_TOKEN, - buildTestApp, - installTestContainer, - resetTestContainer, -} from '../helpers/test-app.js'; - -function bootApp() { - const repos = buildFakeRepos({ - formations: [makeFormation(10)], - blocs: [makeBloc(100, 10)], - modules: [makeModule(200, 100)], - }); - const container = installTestContainer({ repos }); - return { app: buildTestApp(container) }; -} - -describe('GET /api/v1/formations', () => { - afterEach(resetTestContainer); - - it('401 sans token', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/formations'); - expect(res.status).toBe(401); - }); - - it('403 si scope manquant', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/formations', { - headers: { Authorization: `Bearer ${WRITE_ALL_TOKEN}` }, - }); - expect(res.status).toBe(403); - }); - - it('200 list', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/formations', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { id: number }[] }; - expect(body.data).toHaveLength(1); - }); - - it('GET /:id avec blocs/modules + rollups', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/formations/10', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { blocs: { id: number; modules: { id: number }[] }[]; heures_attribuees: string }; - }; - expect(body.data.blocs).toHaveLength(1); - expect(body.data.blocs[0]?.modules).toHaveLength(1); - expect(body.data.heures_attribuees).toBe('100.00'); - }); - - it('404 si formation inconnue', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/formations/9999', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(404); - }); -}); diff --git a/bridge/tests/routes/interventions.test.ts b/bridge/tests/routes/interventions.test.ts deleted file mode 100644 index 1fddf2d..0000000 --- a/bridge/tests/routes/interventions.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makePersonne, makeTache } from '../helpers/fixtures.js'; -import { - WRITE_ALL_TOKEN, - buildTestApp, - installTestContainer, - resetTestContainer, -} from '../helpers/test-app.js'; - -function postIntervention(app: ReturnType, body: Record) { - return app.request('/api/v1/interventions', { - method: 'POST', - headers: { - Authorization: `Bearer ${WRITE_ALL_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); -} - -describe('POST /api/v1/interventions', () => { - afterEach(resetTestContainer); - - it('happy path 201', async () => { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 2, roles: ['developpeur'] })], - taches: [makeTache(400, 300)], - }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await postIntervention(app, { - tache_id: 400, - personne_id: 2, - heures: 3, - date: '2026-05-07T10:00:00Z', - notes: 'cours JS', - }); - expect(res.status).toBe(201); - const body = (await res.json()) as { data: { intervention_id: number; heures: string } }; - expect(body.data.heures).toBe('3.00'); - expect(repos.interventions.lastCreated?.heures.toNumber()).toBe(3); - }); - - it('422 (validation) si role pas developpeur', async () => { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 1, roles: ['formateur'] })], - taches: [makeTache(400, 300)], - }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await postIntervention(app, { - tache_id: 400, - personne_id: 1, - heures: 3, - date: '2026-05-07T10:00:00Z', - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('VALIDATION_ERROR'); - }); - - it('400 si heures <= 0 (Zod)', async () => { - const repos = buildFakeRepos({}); - const app = buildTestApp(installTestContainer({ repos })); - const res = await postIntervention(app, { - tache_id: 400, - personne_id: 2, - heures: 0, - date: '2026-05-07T10:00:00Z', - }); - expect(res.status).toBe(400); - }); - - it('404 si tache inconnue', async () => { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 2, roles: ['developpeur'] })], - }); - const app = buildTestApp(installTestContainer({ repos })); - const res = await postIntervention(app, { - tache_id: 999, - personne_id: 2, - heures: 3, - date: '2026-05-07T10:00:00Z', - }); - expect(res.status).toBe(404); - }); -}); diff --git a/bridge/tests/routes/modules.test.ts b/bridge/tests/routes/modules.test.ts deleted file mode 100644 index 504fb2a..0000000 --- a/bridge/tests/routes/modules.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makeAttribution, makeModule, makePersonne } from '../helpers/fixtures.js'; -import { - READ_ALL_TOKEN, - WRITE_ALL_TOKEN, - buildTestApp, - installTestContainer, - resetTestContainer, -} from '../helpers/test-app.js'; - -function postAttribuer( - app: ReturnType, - body: Record, - token = WRITE_ALL_TOKEN, -) { - return app.request('/api/v1/modules/200/attribuer', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); -} - -describe('POST /api/v1/modules/:id/attribuer', () => { - afterEach(resetTestContainer); - - it('happy path 201 + persistance via repo', async () => { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 1, roles: ['formateur'] })], - modules: [makeModule(200, 100)], - }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await postAttribuer(app, { - personne_id: 1, - heures: 10, - date_debut: '2026-09-01T00:00:00Z', - }); - expect(res.status).toBe(201); - const body = (await res.json()) as { data: { attribution_id: number; statut: string } }; - expect(body.data.statut).toBe('planifie'); - expect(repos.attributions.lastCreated?.heuresAttribuees.toNumber()).toBe(10); - }); - - it('422 RG-01 si heures > capacite module', async () => { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 1, roles: ['formateur'] })], - modules: [makeModule(200, 100)], // heuresPrevues = 30 - attributions: [makeAttribution({ id: 500, moduleId: 200, personneId: 99 })], // 10h deja - }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await postAttribuer(app, { personne_id: 1, heures: 50 }); - expect(res.status).toBe(422); - const body = (await res.json()) as { error: { code: string; details: { rule: string } } }; - expect(body.error.code).toBe('RG_VIOLATION'); - expect(body.error.details.rule).toBe('RG-01'); - }); - - it('422 (validation) si role formateur manquant', async () => { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 1, roles: ['developpeur'] })], - modules: [makeModule(200, 100)], - }); - const app = buildTestApp(installTestContainer({ repos })); - - const res = await postAttribuer(app, { personne_id: 1, heures: 5 }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('VALIDATION_ERROR'); - }); - - it('400 si body invalide (heures negatives)', async () => { - const repos = buildFakeRepos({}); - const app = buildTestApp(installTestContainer({ repos })); - const res = await postAttribuer(app, { personne_id: 1, heures: -3 }); - expect(res.status).toBe(400); - }); - - it('401 sans token', async () => { - const repos = buildFakeRepos({}); - const app = buildTestApp(installTestContainer({ repos })); - const res = await app.request('/api/v1/modules/200/attribuer', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ personne_id: 1, heures: 5 }), - }); - expect(res.status).toBe(401); - }); - - it('403 avec mauvais scope', async () => { - const repos = buildFakeRepos({}); - const app = buildTestApp(installTestContainer({ repos })); - const res = await postAttribuer(app, { personne_id: 1, heures: 5 }, READ_ALL_TOKEN); - expect(res.status).toBe(403); - }); -}); diff --git a/bridge/tests/routes/personnes.test.ts b/bridge/tests/routes/personnes.test.ts deleted file mode 100644 index fbd8378..0000000 --- a/bridge/tests/routes/personnes.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makeAttribution, makeIntervention, makePersonne } from '../helpers/fixtures.js'; -import { resetTestContainer } from '../helpers/test-app.js'; -import { READ_ALL_TOKEN, buildTestApp, installTestContainer } from '../helpers/test-app.js'; - -function bootApp() { - const repos = buildFakeRepos({ - personnes: [makePersonne({ id: 1 })], - attributions: [ - makeAttribution({ id: 500, moduleId: 200, personneId: 1 }), - makeAttribution({ id: 501, moduleId: 200, personneId: 99 }), - ], - interventions: [makeIntervention({ id: 600, personneId: 1 })], - }); - const container = installTestContainer({ repos }); - return { app: buildTestApp(container), repos }; -} - -describe('GET /api/v1/personnes', () => { - beforeEach(() => {}); - afterEach(resetTestContainer); - - it('401 sans token', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/personnes'); - expect(res.status).toBe(401); - }); - - it('200 list paginee', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/personnes', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[]; meta: { total: number } }; - expect(body.data).toHaveLength(1); - expect(body.meta.total).toBe(1); - }); - - it('GET /:id 200 avec heures restantes', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/personnes/1', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { - id: number; - heures_restantes_formation: string; - heures_restantes_total: string; - }; - }; - expect(body.data.id).toBe(1); - expect(body.data.heures_restantes_formation).toBe('600.00'); - expect(body.data.heures_restantes_total).toBe('1000.00'); - }); - - it('GET /:id 404 si inconnu', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/personnes/9999', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('NOT_FOUND'); - }); - - it('GET /:id 400 si id non numerique', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/personnes/abc', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(400); - }); - - it('GET /:id/dashboard agrege attributions + interventions', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/personnes/1/dashboard', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { - attributions: { total: number; actives: number }; - interventions: { total: number; heures_total: string }; - }; - }; - expect(body.data.attributions.total).toBe(1); - expect(body.data.attributions.actives).toBe(1); - expect(body.data.interventions.total).toBe(1); - expect(body.data.interventions.heures_total).toBe('2.00'); - }); -}); diff --git a/bridge/tests/routes/projets.test.ts b/bridge/tests/routes/projets.test.ts deleted file mode 100644 index 62d53ed..0000000 --- a/bridge/tests/routes/projets.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { buildFakeRepos } from '../helpers/fake-repos.js'; -import { makeIntervention, makeProjet, makeTache } from '../helpers/fixtures.js'; -import { - READ_ALL_TOKEN, - buildTestApp, - installTestContainer, - resetTestContainer, -} from '../helpers/test-app.js'; - -function bootApp() { - const tache = makeTache(400, 300); - // Une intervention realisee de 2h sur la tache. - tache.interventions.push(makeIntervention({ id: 600, tacheId: 400, personneId: 2 })); - const repos = buildFakeRepos({ - projets: [makeProjet(300, 50)], - taches: [tache], - }); - const container = installTestContainer({ repos }); - return { app: buildTestApp(container) }; -} - -describe('GET /api/v1/projets', () => { - afterEach(resetTestContainer); - - it('401 sans token', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/projets'); - expect(res.status).toBe(401); - }); - - it('200 list', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/projets', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { id: number }[] }; - expect(body.data).toHaveLength(1); - }); - - it('GET /:id avec taches + heures rollup', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/projets/300', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { taches: { id: number; heures_realisees: string }[]; heures_realisees: string }; - }; - expect(body.data.taches).toHaveLength(1); - expect(body.data.heures_realisees).toBe('2.00'); - }); - - it('404 si projet inconnu', async () => { - const { app } = bootApp(); - const res = await app.request('/api/v1/projets/9999', { - headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` }, - }); - expect(res.status).toBe(404); - }); -}); diff --git a/bridge/tests/routes/tables.test.ts b/bridge/tests/routes/tables.test.ts new file mode 100644 index 0000000..0882ebb --- /dev/null +++ b/bridge/tests/routes/tables.test.ts @@ -0,0 +1,525 @@ +/** + * Tests integration des routes /api/v1/tables/* — proxy generique R1. + * Repos faked en memoire — pas d'appel reseau. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { Field } from '../../src/domain/field.js'; +import { Row } from '../../src/domain/row.js'; +import { Table } from '../../src/domain/table.js'; +import { View } from '../../src/domain/view.js'; +import type { RepoSet } from '../../src/lib/container.js'; +import { errors } from '../../src/lib/errors.js'; +import { + ADMIN_TOKEN, + READ_TOKEN, + WRITE_TOKEN, + buildTestApp, + installTestContainer, + resetTestContainer, +} from '../helpers/test-app.js'; + +// --------------------------------------------------------------------------- +// Fake repos +// --------------------------------------------------------------------------- + +class FakeTablesRepo { + public listCalls: number[] = []; + public failOnList = false; + public failOnGet = false; + + constructor( + public tablesByDb: Map = new Map(), + public tablesById: Map = new Map(), + ) {} + + async list(databaseId: number): Promise { + this.listCalls.push(databaseId); + if (this.failOnList) { + throw errors.authInvalid(); + } + return this.tablesByDb.get(databaseId) ?? []; + } + + async get(tableId: number): Promise
{ + if (this.failOnGet) { + throw errors.authInvalid(); + } + const t = this.tablesById.get(tableId); + if (!t) throw errors.notFound('Table', tableId); + return t; + } +} + +class FakeFieldsRepo { + constructor(public fieldsByTable: Map = new Map()) {} + async list(tableId: number): Promise { + return this.fieldsByTable.get(tableId) ?? []; + } +} + +class FakeViewsRepo { + constructor( + public viewsByTable: Map = new Map(), + public rowsByView: Map = new Map(), + ) {} + async list(tableId: number): Promise { + return this.viewsByTable.get(tableId) ?? []; + } + async runGrid(viewId: number, _tableId: number) { + const items = this.rowsByView.get(viewId) ?? []; + return { + items, + meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 }, + }; + } +} + +class FakeRowsRepo { + public lastCreate?: { tableId: number; fields: Record }; + public lastUpdate?: { tableId: number; rowId: number; fields: Record }; + public lastDelete?: { tableId: number; rowId: number }; + public nextId = 1000; + + constructor(public rowsByTable: Map = new Map()) {} + + async list(tableId: number) { + const items = this.rowsByTable.get(tableId) ?? []; + return { + items, + meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 }, + }; + } + + async get(tableId: number, rowId: number): Promise { + const items = this.rowsByTable.get(tableId) ?? []; + const found = items.find((r) => r.id === rowId); + if (!found) throw errors.notFound('Row', rowId); + return found; + } + + async create(tableId: number, fields: Record): Promise { + this.lastCreate = { tableId, fields }; + const id = this.nextId++; + return new Row({ id, tableId, fields }); + } + + async update(tableId: number, rowId: number, fields: Record): Promise { + this.lastUpdate = { tableId, rowId, fields }; + return new Row({ id: rowId, tableId, fields }); + } + + async delete(tableId: number, rowId: number): Promise { + this.lastDelete = { tableId, rowId }; + } +} + +function buildFakeRepos(opts: { + tables?: FakeTablesRepo; + fields?: FakeFieldsRepo; + views?: FakeViewsRepo; + rows?: FakeRowsRepo; +}): RepoSet { + return { + tables: (opts.tables ?? new FakeTablesRepo()) as unknown as RepoSet['tables'], + fields: (opts.fields ?? new FakeFieldsRepo()) as unknown as RepoSet['fields'], + views: (opts.views ?? new FakeViewsRepo()) as unknown as RepoSet['views'], + rows: (opts.rows ?? new FakeRowsRepo()) as unknown as RepoSet['rows'], + }; +} + +function bootApp(opts: Parameters[0] = {}) { + const repos = buildFakeRepos(opts); + const container = installTestContainer({ repos }); + return { app: buildTestApp(container), repos }; +} + +afterEach(resetTestContainer); + +// --------------------------------------------------------------------------- +// /tables (metadata) +// --------------------------------------------------------------------------- + +describe('GET /api/v1/tables', () => { + it('401 sans token', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables?databaseId=5'); + expect(res.status).toBe(401); + }); + + it('400 sans databaseId', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(400); + }); + + it('200 list tables d une database', async () => { + const tables = new FakeTablesRepo( + new Map([ + [ + 5, + [ + new Table({ id: 1, name: 'Personne', databaseId: 5 }), + new Table({ id: 2, name: 'Bloc', databaseId: 5 }), + ], + ], + ]), + ); + const { app } = bootApp({ tables }); + const res = await app.request('/api/v1/tables?databaseId=5', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Array<{ id: number; name: string }> }; + expect(body.data).toHaveLength(2); + expect(body.data[0]?.name).toBe('Personne'); + }); + + it('501 si le upstream Baserow renvoie 401 (DB token au lieu de JWT)', async () => { + const tables = new FakeTablesRepo(); + tables.failOnList = true; + const { app } = bootApp({ tables }); + const res = await app.request('/api/v1/tables?databaseId=5', { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + }); + expect(res.status).toBe(501); + const body = (await res.json()) as { error: { details?: { reason?: string } } }; + expect(body.error.details?.reason).toBe('jwt_required'); + }); +}); + +describe('GET /api/v1/tables/:tableId', () => { + it('200 avec fields embarques', async () => { + const tables = new FakeTablesRepo( + new Map(), + new Map([[42, new Table({ id: 42, name: 'X', databaseId: 5 })]]), + ); + const fields = new FakeFieldsRepo( + new Map([[42, [new Field({ id: 100, name: 'nom', type: 'text', primary: true })]]]), + ); + const { app } = bootApp({ tables, fields }); + const res = await app.request('/api/v1/tables/42', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { id: number; name: string; fields: Array<{ name: string; primary: boolean }> }; + }; + expect(body.data.id).toBe(42); + expect(body.data.fields).toHaveLength(1); + expect(body.data.fields[0]?.primary).toBe(true); + }); + + it('400 si tableId non numerique', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/abc', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(400); + }); + + it('404 si table inconnue', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/9999', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// /tables/:tableId/fields +// --------------------------------------------------------------------------- + +describe('GET /api/v1/tables/:tableId/fields', () => { + it('200 list fields', async () => { + const fields = new FakeFieldsRepo( + new Map([[1, [new Field({ id: 10, name: 'titre', type: 'text', primary: true })]]]), + ); + const { app } = bootApp({ fields }); + const res = await app.request('/api/v1/tables/1/fields', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Array<{ name: string }> }; + expect(body.data[0]?.name).toBe('titre'); + }); +}); + +// --------------------------------------------------------------------------- +// /tables/:tableId/views +// --------------------------------------------------------------------------- + +describe('GET /api/v1/tables/:tableId/views', () => { + it('200 list views', async () => { + const views = new FakeViewsRepo( + new Map([ + [ + 1, + [ + new View({ id: 100, name: 'Tous', type: 'grid', tableId: 1 }), + new View({ id: 101, name: 'Kanban', type: 'kanban', tableId: 1 }), + ], + ], + ]), + ); + const { app } = bootApp({ views }); + const res = await app.request('/api/v1/tables/1/views', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Array<{ id: number; type: string }> }; + expect(body.data).toHaveLength(2); + expect(body.data[1]?.type).toBe('kanban'); + }); +}); + +describe('GET /api/v1/tables/:tableId/views/:viewId/rows', () => { + it('200 rows filtrees par la view', async () => { + const views = new FakeViewsRepo( + new Map(), + new Map([ + [ + 100, + [ + new Row({ id: 1, tableId: 1, fields: { nom: 'Alice' } }), + new Row({ id: 2, tableId: 1, fields: { nom: 'Bob' } }), + ], + ], + ]), + ); + const { app } = bootApp({ views }); + const res = await app.request('/api/v1/tables/1/views/100/rows', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ id: number; fields: Record }>; + }; + expect(body.data).toHaveLength(2); + expect(body.data[0]?.fields.nom).toBe('Alice'); + }); +}); + +// --------------------------------------------------------------------------- +// /tables/:tableId/rows CRUD +// --------------------------------------------------------------------------- + +describe('GET /api/v1/tables/:tableId/rows', () => { + it('200 list paginee', async () => { + const rows = new FakeRowsRepo( + new Map([ + [ + 5, + [ + new Row({ id: 1, tableId: 5, fields: { nom: 'A' } }), + new Row({ id: 2, tableId: 5, fields: { nom: 'B' } }), + ], + ], + ]), + ); + const { app } = bootApp({ rows }); + const res = await app.request('/api/v1/tables/5/rows', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: unknown[]; meta: { total: number } }; + expect(body.data).toHaveLength(2); + expect(body.meta.total).toBe(2); + }); +}); + +describe('GET /api/v1/tables/:tableId/rows/:rowId', () => { + it('200 avec fields opaques', async () => { + const rows = new FakeRowsRepo( + new Map([[5, [new Row({ id: 100, tableId: 5, fields: { nom: 'X', heures: 42 } })]]]), + ); + const { app } = bootApp({ rows }); + const res = await app.request('/api/v1/tables/5/rows/100', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { id: number; fields: Record }; + }; + expect(body.data.id).toBe(100); + expect(body.data.fields.heures).toBe(42); + }); + + it('404 si row inconnue', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/5/rows/9999', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(404); + }); +}); + +describe('POST /api/v1/tables/:tableId/rows', () => { + it('201 cree une row + invalide cache', async () => { + const rows = new FakeRowsRepo(); + const { app } = bootApp({ rows }); + const res = await app.request('/api/v1/tables/5/rows', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ nom: 'Pierre', heures: 40 }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { id: number; fields: Record } }; + expect(body.data.fields.nom).toBe('Pierre'); + expect(rows.lastCreate?.tableId).toBe(5); + expect(rows.lastCreate?.fields).toEqual({ nom: 'Pierre', heures: 40 }); + }); + + it('403 sans scope write:tables', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/5/rows', { + method: 'POST', + headers: { + Authorization: `Bearer ${READ_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ nom: 'X' }), + }); + expect(res.status).toBe(403); + }); + + it('400 si body pas un objet', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/5/rows', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: '[1,2,3]', + }); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /api/v1/tables/:tableId/rows/:rowId', () => { + it('200 update + payload partiel', async () => { + const rows = new FakeRowsRepo(); + const { app } = bootApp({ rows }); + const res = await app.request('/api/v1/tables/5/rows/100', { + method: 'PATCH', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ heures: 45 }), + }); + expect(res.status).toBe(200); + expect(rows.lastUpdate?.rowId).toBe(100); + expect(rows.lastUpdate?.fields).toEqual({ heures: 45 }); + }); +}); + +describe('DELETE /api/v1/tables/:tableId/rows/:rowId', () => { + it('204 + invalide cache', async () => { + const rows = new FakeRowsRepo(); + const { app } = bootApp({ rows }); + const res = await app.request('/api/v1/tables/5/rows/100', { + method: 'DELETE', + headers: { Authorization: `Bearer ${WRITE_TOKEN}` }, + }); + expect(res.status).toBe(204); + expect(rows.lastDelete).toEqual({ tableId: 5, rowId: 100 }); + }); + + it('403 sans scope write:tables', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/5/rows/100', { + method: 'DELETE', + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(403); + }); +}); + +describe('Edge cases /api/v1/tables', () => { + it('400 si tableId = 0 (positif strict)', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables/0/rows', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(400); + }); + + it('400 si databaseId non numerique', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/tables?databaseId=abc', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(400); + }); + + it('admin token (admin:*) acces toutes les routes', async () => { + const rows = new FakeRowsRepo( + new Map([[5, [new Row({ id: 1, tableId: 5, fields: { x: 1 } })]]]), + ); + const { app } = bootApp({ rows }); + const res = await app.request('/api/v1/tables/5/rows', { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + }); + expect(res.status).toBe(200); + }); + + it('GET rows avec search query param est passe au repo', async () => { + const rows = new FakeRowsRepo(); + const repo: typeof rows & { lastListSearch?: string } = rows as typeof rows & { + lastListSearch?: string; + }; + const orig = rows.list.bind(rows); + rows.list = (tableId: number, opts?: { search?: string }) => { + repo.lastListSearch = opts?.search; + return orig(tableId); + }; + const { app } = bootApp({ rows }); + await app.request('/api/v1/tables/5/rows?search=alice', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(repo.lastListSearch).toBe('alice'); + }); + + it('GET tables/:id renvoie les fields meme si DB token (fields use DB token)', async () => { + const tables = new FakeTablesRepo( + new Map(), + new Map([[42, new Table({ id: 42, name: 'X', databaseId: 5 })]]), + ); + const fields = new FakeFieldsRepo( + new Map([ + [ + 42, + [ + new Field({ id: 1, name: 'a', type: 'text', primary: true }), + new Field({ id: 2, name: 'b', type: 'number' }), + ], + ], + ]), + ); + const { app } = bootApp({ tables, fields }); + const res = await app.request('/api/v1/tables/42', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + const body = (await res.json()) as { data: { fields: Array<{ name: string }> } }; + expect(body.data.fields).toHaveLength(2); + }); + + it('GET tables/:id 501 si DB token sur lecture metadata', async () => { + const tables = new FakeTablesRepo(); + tables.failOnGet = true; + const fields = new FakeFieldsRepo(); + const { app } = bootApp({ tables, fields }); + const res = await app.request('/api/v1/tables/42', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(501); + }); +}); diff --git a/bridge/tests/unit/cache.test.ts b/bridge/tests/unit/cache.test.ts index 0246dc3..34e87f7 100644 --- a/bridge/tests/unit/cache.test.ts +++ b/bridge/tests/unit/cache.test.ts @@ -1,134 +1,80 @@ /** - * Tests unit pour invalidateEntity — verifie les patterns generes par entite, - * la cascade rollups parent, et l'idempotence (deux invalidations meme key). + * Tests unit pour invalidateTable — verifie les patterns generes (generique + * style Notion, plus de cascade rollup metier). */ import { describe, expect, it } from 'vitest'; -import { type CacheInvalidator, invalidateEntity } from '../../src/lib/cache.js'; +import { type CacheInvalidator, invalidateTable } from '../../src/lib/cache.js'; class FakeRedis implements CacheInvalidator { public patterns: string[] = []; - // Map pour simuler des keys persistees (incrementee a chaque set fictif). - public callCount = 0; async invalidatePattern(pattern: string): Promise { this.patterns.push(pattern); - this.callCount++; - return 1; // un match fictif + return 1; } } -describe('invalidateEntity', () => { - it('attribution : cascade sur module + personne (rollups RG-01)', async () => { +describe('invalidateTable', () => { + it('avec rowId : invalide list + views + row precis', async () => { const redis = new FakeRedis(); - await invalidateEntity(redis, 'attribution', 42); + await invalidateTable(redis, 42, 100); - expect(redis.patterns).toContain('bridge:attribution:list:*'); - expect(redis.patterns).toContain('bridge:attribution:row:42'); - expect(redis.patterns).toContain('bridge:module:row:*'); - expect(redis.patterns).toContain('bridge:module:list:*'); - expect(redis.patterns).toContain('bridge:personne:row:*'); - expect(redis.patterns).toContain('bridge:personne:list:*'); + expect(redis.patterns).toContain('bridge:tables:42:list:*'); + expect(redis.patterns).toContain('bridge:tables:42:views:*'); + expect(redis.patterns).toContain('bridge:tables:42:row:100'); + expect(redis.patterns).toHaveLength(3); }); - it('intervention : cascade sur tache + personne', async () => { + it('sans rowId : invalide list + views uniquement', async () => { const redis = new FakeRedis(); - await invalidateEntity(redis, 'intervention', 100); + await invalidateTable(redis, 42); - expect(redis.patterns).toContain('bridge:intervention:list:*'); - expect(redis.patterns).toContain('bridge:intervention:row:100'); - expect(redis.patterns).toContain('bridge:tache:row:*'); - expect(redis.patterns).toContain('bridge:tache:list:*'); - expect(redis.patterns).toContain('bridge:personne:row:*'); - expect(redis.patterns).toContain('bridge:personne:list:*'); - }); - - it('module : cascade sur bloc + formation', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'module', 7); - expect(redis.patterns).toContain('bridge:module:list:*'); - expect(redis.patterns).toContain('bridge:module:row:7'); - expect(redis.patterns).toContain('bridge:bloc:row:*'); - expect(redis.patterns).toContain('bridge:bloc:list:*'); - expect(redis.patterns).toContain('bridge:formation:row:*'); - expect(redis.patterns).toContain('bridge:formation:list:*'); - }); - - it('bloc : cascade formation seulement', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'bloc', 3); - expect(redis.patterns).toContain('bridge:bloc:list:*'); - expect(redis.patterns).toContain('bridge:bloc:row:3'); - expect(redis.patterns).toContain('bridge:formation:row:*'); - expect(redis.patterns).toContain('bridge:formation:list:*'); - // Pas de cascade modules au-dessus. - expect(redis.patterns).not.toContain('bridge:module:list:*'); - }); - - it('tache : cascade projet', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'tache', 8); - expect(redis.patterns).toContain('bridge:tache:list:*'); - expect(redis.patterns).toContain('bridge:tache:row:8'); - expect(redis.patterns).toContain('bridge:projet:row:*'); - expect(redis.patterns).toContain('bridge:projet:list:*'); - }); - - it('projet : cascade client', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'projet', 5); - expect(redis.patterns).toContain('bridge:projet:list:*'); - expect(redis.patterns).toContain('bridge:projet:row:5'); - expect(redis.patterns).toContain('bridge:client:row:*'); - expect(redis.patterns).toContain('bridge:client:list:*'); - }); - - it('personne : pas de cascade parent (entite racine)', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'personne', 1); - expect(redis.patterns).toContain('bridge:personne:list:*'); - expect(redis.patterns).toContain('bridge:personne:row:1'); + expect(redis.patterns).toContain('bridge:tables:42:list:*'); + expect(redis.patterns).toContain('bridge:tables:42:views:*'); expect(redis.patterns).toHaveLength(2); }); - it('formation : pas de cascade parent (entite racine)', async () => { + it('pas de cascade cross-table (style Notion : webhook par table)', async () => { const redis = new FakeRedis(); - await invalidateEntity(redis, 'formation', 9); - expect(redis.patterns).toContain('bridge:formation:list:*'); - expect(redis.patterns).toContain('bridge:formation:row:9'); - expect(redis.patterns).toHaveLength(2); + await invalidateTable(redis, 42, 1); + // Aucun pattern d'autre table. + expect(redis.patterns.every((p) => p.startsWith('bridge:tables:42:'))).toBe(true); }); - it('client : pas de cascade parent', async () => { + it('idempotent : deux invalidations meme key', async () => { const redis = new FakeRedis(); - await invalidateEntity(redis, 'client', 4); - expect(redis.patterns).toContain('bridge:client:list:*'); - expect(redis.patterns).toContain('bridge:client:row:4'); - expect(redis.patterns).toHaveLength(2); - }); - - it('sans id : invalide juste la liste + cascade (cas create avant id connu)', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'attribution'); - - expect(redis.patterns).toContain('bridge:attribution:list:*'); - expect(redis.patterns).not.toContain('bridge:attribution:row:undefined'); - // Cascade toujours appliquee meme sans id. - expect(redis.patterns).toContain('bridge:module:list:*'); - }); - - it('idempotent : deux invalidations meme key ne throw pas', async () => { - const redis = new FakeRedis(); - await invalidateEntity(redis, 'attribution', 42); - await expect(invalidateEntity(redis, 'attribution', 42)).resolves.toBeGreaterThanOrEqual(0); - // Les patterns sont appeles deux fois, c'est attendu. - expect(redis.patterns.filter((p) => p === 'bridge:attribution:row:42')).toHaveLength(2); + await invalidateTable(redis, 42, 100); + await expect(invalidateTable(redis, 42, 100)).resolves.toBeGreaterThanOrEqual(0); + expect(redis.patterns.filter((p) => p === 'bridge:tables:42:row:100')).toHaveLength(2); }); it('retourne le total des keys invalidees', async () => { const redis = new FakeRedis(); - const total = await invalidateEntity(redis, 'attribution', 1); - // FakeRedis retourne 1 par appel, 6 patterns -> 6. - expect(total).toBe(6); + const total = await invalidateTable(redis, 42, 1); + // FakeRedis retourne 1 par appel, 3 patterns. + expect(total).toBe(3); + }); + + it('tableId 0 ou negatif : pattern construit tel quel (le caller doit valider)', async () => { + const redis = new FakeRedis(); + await invalidateTable(redis, 0); + expect(redis.patterns).toContain('bridge:tables:0:list:*'); + }); + + it('rowId numerique 0 inclus comme cle valide', async () => { + const redis = new FakeRedis(); + await invalidateTable(redis, 5, 0); + expect(redis.patterns).toContain('bridge:tables:5:row:0'); + }); + + it('plusieurs invalidations consecutives sur differentes tables sont independantes', async () => { + const redis = new FakeRedis(); + await invalidateTable(redis, 1, 100); + await invalidateTable(redis, 2, 200); + expect(redis.patterns).toContain('bridge:tables:1:row:100'); + expect(redis.patterns).toContain('bridge:tables:2:row:200'); + expect(redis.patterns.filter((p) => p.startsWith('bridge:tables:1:'))).toHaveLength(3); + expect(redis.patterns.filter((p) => p.startsWith('bridge:tables:2:'))).toHaveLength(3); }); }); diff --git a/bridge/tests/unit/config.test.ts b/bridge/tests/unit/config.test.ts new file mode 100644 index 0000000..7bbd069 --- /dev/null +++ b/bridge/tests/unit/config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { isOidcEnabled } from '../../src/lib/config.js'; + +describe('isOidcEnabled', () => { + it('true ssi 3 vars Authentik present', () => { + expect( + isOidcEnabled({ + authentikIssuer: 'https://auth.example/', + authentikJwksUri: 'https://auth.example/jwks', + authentikAudience: 'aud', + }), + ).toBe(true); + }); + + it('false si l une des 3 manque', () => { + expect( + isOidcEnabled({ + authentikIssuer: 'https://auth.example/', + authentikJwksUri: undefined, + authentikAudience: 'aud', + }), + ).toBe(false); + expect( + isOidcEnabled({ + authentikIssuer: 'https://auth.example/', + authentikJwksUri: 'https://auth.example/jwks', + authentikAudience: undefined, + }), + ).toBe(false); + expect( + isOidcEnabled({ + authentikIssuer: undefined, + authentikJwksUri: 'https://auth.example/jwks', + authentikAudience: 'aud', + }), + ).toBe(false); + }); + + it('false si toutes vides', () => { + expect( + isOidcEnabled({ + authentikIssuer: undefined, + authentikJwksUri: undefined, + authentikAudience: undefined, + }), + ).toBe(false); + }); +}); diff --git a/bridge/tests/unit/errors.test.ts b/bridge/tests/unit/errors.test.ts new file mode 100644 index 0000000..04ded87 --- /dev/null +++ b/bridge/tests/unit/errors.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { BridgeError, errors } from '../../src/lib/errors.js'; + +describe('BridgeError', () => { + it('toJSON serialise code + message + details', () => { + const err = new BridgeError('VALIDATION_ERROR', 400, 'invalid', { field: 'x' }); + expect(err.toJSON()).toEqual({ + error: { code: 'VALIDATION_ERROR', message: 'invalid', details: { field: 'x' } }, + }); + }); + + it('toJSON sans details : pas de cle details', () => { + const err = new BridgeError('NOT_FOUND', 404, 'gone'); + expect(err.toJSON()).toEqual({ error: { code: 'NOT_FOUND', message: 'gone' } }); + }); + + it('preserve name = BridgeError', () => { + const err = new BridgeError('INTERNAL', 500, 'x'); + expect(err.name).toBe('BridgeError'); + }); +}); + +describe('errors helpers', () => { + it('authRequired', () => { + const e = errors.authRequired(); + expect(e.code).toBe('AUTH_REQUIRED'); + expect(e.status).toBe(401); + }); + + it('authInvalid', () => { + expect(errors.authInvalid().status).toBe(401); + }); + + it('forbidden inclut le scope dans details', () => { + const e = errors.forbidden('admin:write'); + expect(e.code).toBe('FORBIDDEN_SCOPE'); + expect(e.status).toBe(403); + expect(e.details).toEqual({ scope: 'admin:write' }); + }); + + it('notFound', () => { + const e = errors.notFound('Row', 99); + expect(e.status).toBe(404); + expect(e.details).toEqual({ entity: 'Row', id: 99 }); + }); + + it('validation', () => { + const e = errors.validation([{ message: 'bad' }]); + expect(e.status).toBe(400); + expect(e.code).toBe('VALIDATION_ERROR'); + }); + + it('rateLimited', () => { + const e = errors.rateLimited(60); + expect(e.status).toBe(429); + expect(e.details).toEqual({ retry_after: 60 }); + }); + + it('baserowDown', () => { + expect(errors.baserowDown().status).toBe(502); + }); + + it('docmostDown', () => { + expect(errors.docmostDown().status).toBe(502); + }); + + it('internal', () => { + expect(errors.internal('boom').status).toBe(500); + }); +}); diff --git a/bridge/tests/unit/http.test.ts b/bridge/tests/unit/http.test.ts new file mode 100644 index 0000000..663f0c5 --- /dev/null +++ b/bridge/tests/unit/http.test.ts @@ -0,0 +1,108 @@ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { dec, parseBody, parseListQuery } from '../../src/lib/http.js'; +import { errorHandler } from '../../src/middleware/error-handler.js'; + +describe('parseListQuery', () => { + it('extrait page/per_page/sort/filter', async () => { + const app = new Hono(); + app.get('/', (c) => { + const q = parseListQuery(c); + return c.json(q); + }); + const res = await app.request('/?page=2&per_page=20&sort=-name&filter[statut]=actif'); + const body = (await res.json()) as { + page: number; + per_page: number; + sort: string; + filter: Record; + }; + expect(body.page).toBe(2); + expect(body.per_page).toBe(20); + expect(body.sort).toBe('-name'); + expect(body.filter.statut).toBe('actif'); + }); + + it('clamp per_page entre 1 et 200', async () => { + const app = new Hono(); + app.get('/', (c) => c.json(parseListQuery(c))); + + const r1 = await app.request('/?per_page=500'); + const b1 = (await r1.json()) as { per_page: number }; + expect(b1.per_page).toBe(200); + + const r2 = await app.request('/?per_page=0'); + const b2 = (await r2.json()) as { per_page: number }; + // 0 -> default fallback 50 + expect(b2.per_page).toBeGreaterThanOrEqual(1); + }); + + it('defaults page=1 per_page=50', async () => { + const app = new Hono(); + app.get('/', (c) => c.json(parseListQuery(c))); + const res = await app.request('/'); + const body = (await res.json()) as { page: number; per_page: number }; + expect(body.page).toBe(1); + expect(body.per_page).toBe(50); + }); +}); + +describe('dec', () => { + it('toFixed 2 sur Decimal', async () => { + const { Decimal } = await import('decimal.js'); + expect(dec(new Decimal('40.123'))).toBe('40.12'); + expect(dec(new Decimal(0))).toBe('0.00'); + }); + it('null/undefined -> "0"', () => { + expect(dec(null)).toBe('0'); + expect(dec(undefined)).toBe('0'); + }); +}); + +describe('parseBody', () => { + it('valide via schema', async () => { + const app = new Hono(); + app.onError(errorHandler); + app.post('/', async (c) => { + const body = await parseBody(c, z.object({ a: z.number() })); + return c.json(body); + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ a: 1 }), + }); + expect(res.status).toBe(200); + }); + + it('400 si body pas JSON', async () => { + const app = new Hono(); + app.onError(errorHandler); + app.post('/', async (c) => { + await parseBody(c, z.object({})); + return c.json({}); + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not-json', + }); + expect(res.status).toBe(400); + }); + + it('400 si schema mismatch', async () => { + const app = new Hono(); + app.onError(errorHandler); + app.post('/', async (c) => { + await parseBody(c, z.object({ a: z.number() })); + return c.json({}); + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ a: 'not-a-number' }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/bridge/tests/webhooks/baserow-handler.test.ts b/bridge/tests/webhooks/baserow-handler.test.ts index d6a6ea8..236474a 100644 --- a/bridge/tests/webhooks/baserow-handler.test.ts +++ b/bridge/tests/webhooks/baserow-handler.test.ts @@ -1,22 +1,13 @@ +/** + * Tests handler webhooks Baserow — generique R1 (plus de cascade rollup metier). + */ + import pino from 'pino'; import { describe, expect, it } from 'vitest'; import type { RedisCache } from '../../src/adapters/redis-cache.js'; -import type { TableIds } from '../../src/repos/baserow-repo.js'; import { handleBaserowEvent } from '../../src/webhooks/baserow-handler.js'; import type { BaserowWebhookPayload } from '../../src/webhooks/types.js'; -const FAKE_TABLE_IDS: TableIds = { - personne: 1, - formation: 2, - bloc: 3, - module: 4, - attribution: 5, - client: 6, - projet: 7, - tache: 8, - intervention: 9, -}; - class FakeRedis { public calls: string[] = []; invalidatePattern(pattern: string): Promise { @@ -31,143 +22,85 @@ function makePayload(over: Partial = {}): BaserowWebhookP return { event_id: 'evt-1', event_type: 'rows.created', - table_id: 1, - items: [{ id: 42 }], + table_id: 42, + items: [{ id: 100 }], ...over, } as BaserowWebhookPayload; } -describe('handleBaserowEvent', () => { - it('rows.created sur personne -> invalide list (pas row id)', async () => { +describe('handleBaserowEvent (R1 generique)', () => { + it('rows.created sur tableId -> invalide list + views (pas row precis)', async () => { const redis = new FakeRedis(); const res = await handleBaserowEvent(makePayload(), { redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, logger: silentLogger(), }); expect(res.status).toBe('processed'); - expect(res.entity).toBe('personne'); - expect(redis.calls).toContain('bridge:personne:list:*'); - expect(redis.calls).not.toContain('bridge:personne:row:42'); + expect(res.tableId).toBe(42); + expect(redis.calls).toContain('bridge:tables:42:list:*'); + expect(redis.calls).toContain('bridge:tables:42:views:*'); + expect(redis.calls).not.toContain('bridge:tables:42:row:100'); }); - it('rows.updated sur personne -> invalide list + row precis', async () => { + it('rows.updated -> invalide list + views + row precis', async () => { const redis = new FakeRedis(); await handleBaserowEvent( - makePayload({ event_type: 'rows.updated', items: [{ id: 42 }, { id: 43 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, + makePayload({ event_type: 'rows.updated', items: [{ id: 100 }, { id: 101 }] }), + { redis: redis as unknown as RedisCache, logger: silentLogger() }, ); - expect(redis.calls).toContain('bridge:personne:row:42'); - expect(redis.calls).toContain('bridge:personne:row:43'); - expect(redis.calls).toContain('bridge:personne:list:*'); + expect(redis.calls).toContain('bridge:tables:42:row:100'); + expect(redis.calls).toContain('bridge:tables:42:row:101'); + expect(redis.calls).toContain('bridge:tables:42:list:*'); + expect(redis.calls).toContain('bridge:tables:42:views:*'); }); - it('rows.deleted sur attribution -> cascade module + personne', async () => { + it('rows.deleted -> invalide list + views + row precis', async () => { const redis = new FakeRedis(); await handleBaserowEvent( - makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 100 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, + makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 200 }] }), + { redis: redis as unknown as RedisCache, logger: silentLogger() }, ); - expect(redis.calls).toContain('bridge:attribution:row:100'); - expect(redis.calls).toContain('bridge:module:row:*'); - expect(redis.calls).toContain('bridge:personne:row:*'); - expect(redis.calls).toContain('bridge:personne:list:*'); + expect(redis.calls).toContain('bridge:tables:5:row:200'); }); - it('intervention -> cascade tache + personne', async () => { - const redis = new FakeRedis(); - await handleBaserowEvent( - makePayload({ event_type: 'rows.updated', table_id: 9, items: [{ id: 1 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, - ); - expect(redis.calls).toContain('bridge:tache:row:*'); - expect(redis.calls).toContain('bridge:personne:row:*'); - }); - - it('module -> cascade bloc + formation', async () => { - const redis = new FakeRedis(); - await handleBaserowEvent( - makePayload({ event_type: 'rows.updated', table_id: 4, items: [{ id: 1 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, - ); - expect(redis.calls).toContain('bridge:bloc:row:*'); - expect(redis.calls).toContain('bridge:formation:row:*'); - }); - - it('bloc -> cascade formation', async () => { - const redis = new FakeRedis(); - await handleBaserowEvent( - makePayload({ event_type: 'rows.updated', table_id: 3, items: [{ id: 1 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, - ); - expect(redis.calls).toContain('bridge:formation:row:*'); - }); - - it('tache -> cascade projet', async () => { - const redis = new FakeRedis(); - await handleBaserowEvent( - makePayload({ event_type: 'rows.updated', table_id: 8, items: [{ id: 1 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, - ); - expect(redis.calls).toContain('bridge:projet:row:*'); - }); - - it('projet -> cascade client', async () => { + it('aucune cascade cross-table : tout reste sous bridge:tables::*', async () => { const redis = new FakeRedis(); await handleBaserowEvent( makePayload({ event_type: 'rows.updated', table_id: 7, items: [{ id: 1 }] }), - { - redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, - logger: silentLogger(), - }, + { redis: redis as unknown as RedisCache, logger: silentLogger() }, ); - expect(redis.calls).toContain('bridge:client:row:*'); + expect(redis.calls.every((p) => p.startsWith('bridge:tables:7:'))).toBe(true); }); - it('table_id inconnu -> ignored, aucune invalidation', async () => { + it('table_id <= 0 -> ignored, aucune invalidation', async () => { const redis = new FakeRedis(); - const res = await handleBaserowEvent(makePayload({ table_id: 99999 }), { + const res = await handleBaserowEvent(makePayload({ table_id: 0 }), { redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, logger: silentLogger(), }); expect(res.status).toBe('ignored'); - expect(res.entity).toBeNull(); + expect(res.tableId).toBeNull(); expect(redis.calls).toHaveLength(0); }); - it('rows.created sans items -> invalide list, pas de row precis', async () => { + it('rows.created sans items -> invalide list + views, pas de row precis', async () => { const redis = new FakeRedis(); await handleBaserowEvent(makePayload({ event_type: 'rows.created', items: [] }), { redis: redis as unknown as RedisCache, - tableIds: FAKE_TABLE_IDS, logger: silentLogger(), }); - expect(redis.calls).toContain('bridge:personne:list:*'); + expect(redis.calls).toContain('bridge:tables:42:list:*'); + expect(redis.calls).toContain('bridge:tables:42:views:*'); + expect(redis.calls.some((p) => p.includes(':row:'))).toBe(false); + }); + + it('renvoie le total des keys invalidees', async () => { + const redis = new FakeRedis(); + const res = await handleBaserowEvent( + makePayload({ event_type: 'rows.updated', items: [{ id: 1 }, { id: 2 }] }), + { redis: redis as unknown as RedisCache, logger: silentLogger() }, + ); + // 4 patterns : list, views, row:1, row:2 → 4 keys. + expect(res.invalidatedKeys).toBe(4); }); }); diff --git a/bridge/tests/webhooks/routes.test.ts b/bridge/tests/webhooks/routes.test.ts index bf9e673..71dee01 100644 --- a/bridge/tests/webhooks/routes.test.ts +++ b/bridge/tests/webhooks/routes.test.ts @@ -1,6 +1,6 @@ /** - * Tests integration des routes /api/webhooks/{baserow,docmost}. - * Pas de vrai Redis : on injecte un fake qui implemente l'API minimale necessaire. + * Tests integration des routes /api/webhooks/{baserow,docmost} (R1). + * Pas de vrai Redis : on injecte un fake qui implemente l'API minimale. */ import { createHmac } from 'node:crypto'; @@ -15,18 +15,6 @@ import { webhooksRoutes } from '../../src/routes/webhooks.js'; const BASEROW_SECRET = 'baserow-test-secret-32chars-long-ok'; const DOCMOST_SECRET = 'docmost-test-secret-32chars-long-ok'; -const TABLE_IDS = { - personne: 1, - formation: 2, - bloc: 3, - module: 4, - attribution: 5, - client: 6, - projet: 7, - tache: 8, - intervention: 9, -} as const; - class FakeRedis { public seen = new Set(); public invalidated: string[] = []; @@ -55,7 +43,6 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) { baserowWebhookSecret: BASEROW_SECRET, docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined, bridgeApiTokens: undefined, - authStrictMapping: true, rateLimitGlobalMax: 10000, rateLimitGlobalWindow: 60, rateLimitMutationMax: 10000, @@ -67,7 +54,8 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) { // biome-ignore lint/suspicious/noExplicitAny: fake injection repos: {} as any, tokens: new Map(), - tableIds: TABLE_IDS, + oidc: null, + groupsScopesMap: {}, logger, }); } @@ -96,8 +84,8 @@ describe('POST /api/webhooks/baserow', () => { const body = JSON.stringify({ event_id: 'evt-baserow-1', event_type: 'rows.created', - table_id: 1, - items: [{ id: 42 }], + table_id: 42, + items: [{ id: 100 }], }); const res = await app.request('/api/webhooks/baserow', { method: 'POST', @@ -108,10 +96,11 @@ describe('POST /api/webhooks/baserow', () => { body, }); expect(res.status).toBe(200); - const json = (await res.json()) as { status: string; entity: string }; + const json = (await res.json()) as { status: string; tableId: number }; expect(json.status).toBe('processed'); - expect(json.entity).toBe('personne'); - expect(redis.invalidated).toContain('bridge:personne:list:*'); + expect(json.tableId).toBe(42); + expect(redis.invalidated).toContain('bridge:tables:42:list:*'); + expect(redis.invalidated).toContain('bridge:tables:42:views:*'); }); it('401 AUTH_REQUIRED si header absent', async () => { @@ -179,15 +168,15 @@ describe('POST /api/webhooks/baserow', () => { expect(json2.eventId).toBe('evt-dup'); }); - it('table inconnue -> 200 status ignored, aucune invalidation', async () => { + it('table_id 0 -> 400 (validation zod : table_id positif)', async () => { const redis = new FakeRedis(); installContainer(redis); const app = buildApp(); const body = JSON.stringify({ - event_id: 'evt-ignore', + event_id: 'evt', event_type: 'rows.created', - table_id: 99999, + table_id: 0, items: [], }); const res = await app.request('/api/webhooks/baserow', { @@ -198,11 +187,7 @@ describe('POST /api/webhooks/baserow', () => { }, body, }); - expect(res.status).toBe(200); - const json = (await res.json()) as { status: string; entity: string | null }; - expect(json.status).toBe('ignored'); - expect(json.entity).toBeNull(); - expect(redis.invalidated).toHaveLength(0); + expect(res.status).toBe(400); }); it('payload malforme (event_id manquant) -> 400 VALIDATION_ERROR', async () => { diff --git a/examples/acadenice-formation-hub/README.md b/examples/acadenice-formation-hub/README.md new file mode 100644 index 0000000..2af9114 --- /dev/null +++ b/examples/acadenice-formation-hub/README.md @@ -0,0 +1,59 @@ +# Example — Acadenice formation-hub + +Cet exemple montre **un cas d'usage parmi d'autres** du bridge generique +DocAdenice / Notion-like : le suivi d'heures formateurs et developpeurs pour +le **CFA + Agence Acadenice**. + +## Contexte + +Acadenice est : +- un CFA (Centre de Formation des Apprentis) — heures de formation a tracer +- une Agence dev — heures de prestations a tracer + +Une meme personne peut porter plusieurs roles : formateur (CFA), developpeur +(Agence), admin/direction/support transverse. La capacite annuelle est +splittee entre les deux activites. + +## Pourquoi un exemple et pas du code metier dans le bridge + +Le bridge DocAdenice est un **proxy generique style Notion**. Il expose +n'importe quelle table Baserow via `/api/v1/tables/*` sans rien savoir du +metier. La modelisation Acadenice (9 tables, formules de rollup, regles de +gestion) vit : + +- **cote Baserow** : schema des 9 tables + formules + vues (cf + `seed-baserow.md`) +- **cote DocAdenice** : RBAC dynamique avec roles custom (Formateur, + Developpeur, Direction, Support) qui produisent des claims OIDC + `acadenice_permissions[]` (cf `example-roles.md`) +- **cote frontend** : interface metier (dashboards capacite, attribution + modules, saisie heures) consomme l'API generique du bridge + +## Comment rejouer cet exemple + +1. Provisionner Baserow avec les 9 tables decrites dans `seed-baserow.md` +2. Creer les roles custom DocAdenice de `example-roles.md` +3. Configurer le bridge : il proxie tout, pas de config metier specifique +4. Construire le frontend (Tiptap node-views ou pages dediees Docmost) + +## Mapping bridge generique <-> ce metier + +| Generique bridge | Vu dans cet exemple | +|---------------------------|----------------------------------------------| +| `/tables` | 9 tables : Personne, Formation, Bloc, ... | +| `/tables/:id/rows` | CRUD sur une de ces tables | +| Webhooks invalidation | Cache Redis invalide par tableId touche | +| Cascades cross-table | Faites cote Baserow via formules + lookups | +| Permissions `read:tables` | Donnees globalement par DocAdenice via OIDC | +| Permissions specifiques | Filtrees cote frontend selon role custom | + +## Autres exemples envisageables + +Le meme bridge pourrait servir : + +- un CRM custom (3 tables : Contacts, Opportunites, Notes) +- un tracker de bugs (2 tables : Issues, Comments) +- une base d'inventaire IT (5 tables : Asset, Owner, Site, Maintenance, Log) + +Aucun de ces cas ne necessite de modification du bridge — juste une config +Baserow + DocAdenice. diff --git a/examples/acadenice-formation-hub/example-roles.md b/examples/acadenice-formation-hub/example-roles.md new file mode 100644 index 0000000..f72ec19 --- /dev/null +++ b/examples/acadenice-formation-hub/example-roles.md @@ -0,0 +1,94 @@ +# Example — Roles custom Acadenice + +Roles RBAC custom declares **cote DocAdenice (R2)**, projetes vers le +bridge via le claim JWT `acadenice_permissions[]`. + +> Le bridge n'a pas de role hardcode. Il accepte les permissions presentes +> dans le claim et les mappe directement vers les scopes generiques +> `read:tables` / `write:tables` (ou plus fin si DocAdenice le decide). + +## Roles fonctionnels + +### Formateur + +Forme les apprentis sur des modules CFA. Peut saisir ses heures realisees. + +| Permission | Justification | +|--------------|---------------------------------------------------| +| `read:tables`| Voir personnes, formations, blocs, modules, ses attributions | +| `write:tables`| Saisir attribution.heures_realisees | + +Filtrage cote frontend : ne voit que les attributions ou +`attribution_personne` matche son `personne_id`. + +### Developpeur + +Travaille sur des projets agence. Saisit ses interventions. + +| Permission | Justification | +|--------------|---------------------------------------------------| +| `read:tables`| Voir clients, projets, taches, ses interventions | +| `write:tables`| Creer interventions sur ses taches | + +Filtrage cote frontend : ne voit que ses interventions et les taches +auxquelles il est assigne. + +### Direction + +Vue 360 lecture seule. + +| Permission | Justification | +|--------------|---------------------------------------------------| +| `read:tables`| Toutes les tables, vue dashboard agregee | + +### Support + +Operations administratives, pas de saisie metier. + +| Permission | Justification | +|--------------|---------------------------------------------------| +| `read:tables`| Toutes les tables | +| `write:tables`| Mises a jour ponctuelles (statut, notes) | + +### Admin + +Toutes operations sans restriction. + +| Permission | Justification | +|--------------|---------------------------------------------------| +| `admin:*` | Wildcard couvre tout | + +## Mapping JWT claim -> scopes bridge + +DocAdenice (R2) projettera dans le JWT : + +```json +{ + "sub": "authentik-uuid-1234", + "email": "pierre@acadenice.fr", + "groups": ["acadenice-formateurs"], + "acadenice_permissions": ["read:tables", "write:tables"] +} +``` + +Le bridge : +- ignore `groups` sauf si un mapping `AUTH_GROUPS_SCOPES_MAP` est configure +- lit `acadenice_permissions[]` directement et l'union avec les groupes mappes + +Resultat dans `c.var.user.scopes` du bridge : +`['read:tables', 'write:tables']` -> autorise GET / POST / PATCH / DELETE +sur `/api/v1/tables/*`. + +## Notes de design + +- Le bridge ne fait **aucun filtrage par tableId** : si l'utilisateur a + `read:tables`, il peut lire n'importe quelle table. Le filtrage fin + (« ce formateur ne voit que ses attributions ») est applique cote + frontend / DocAdenice via les filtres Baserow ou des middlewares + applicatifs sur le frontend. +- Pour une protection plus stricte, DocAdenice peut emettre des permissions + scope-table comme `read:tables:609` (table Personne) — le bridge + acceptera, mais il faut alors etendre `requireScope` cote routes (R3). +- Les permissions explicites declarees dans le JWT priment sur les groups : + c'est volontaire pour permettre les overrides individuels (`personne X + est formateur sauf qu'on lui retire write:tables temporairement`). diff --git a/examples/acadenice-formation-hub/seed-baserow.md b/examples/acadenice-formation-hub/seed-baserow.md new file mode 100644 index 0000000..373a1f1 --- /dev/null +++ b/examples/acadenice-formation-hub/seed-baserow.md @@ -0,0 +1,157 @@ +# Seed Baserow — schema formation-hub Acadenice + +Schema reference des 9 tables Baserow pour cet exemple metier. + +> Ce document decrit le **modele** que l'utilisateur cree dans son Baserow. +> Le bridge ne sait rien de tout cela : il proxie generique. Le code de +> seeding (Python ou autre) peut venir plus tard ; c'est la mise en oeuvre +> typique de la doc 19 Bridge API design (cas Acadenice). + +## Database + +- Workspace : `Acadenice` +- Database : `formation-hub` +- 9 tables au singulier + +## Table Personne + +Pivot multi-roles. Capacite annuelle d'heures splittee entre formation et +agence. + +| Field name | Type | Notes | +|-------------------------------------|-----------------------------------|----------------------------------------------------| +| `personne_nom` | text (primary) | | +| `personne_prenom` | text | | +| `personne_email` | text (email) | Unique. Lie au sub Authentik via DocAdenice. | +| `personne_telephone` | text | Optionnel. | +| `personne_capacite_annuelle` | number | Heures annuelles theoriques (ex 1500). | +| `personne_split_formation_pct` | number | 0-100. Doit sommer 100 avec split_agence. | +| `personne_split_agence_pct` | number | 0-100. | +| `personne_roles` | multi_select | Choix : formateur, developpeur, admin, direction, support | +| `personne_statut` | single_select | actif / inactif | +| `personne_heures_attribuees_formation` | formula (rollup sum attributions) | Auto. | +| `personne_heures_attribuees_agence` | formula (rollup sum interventions)| Auto. | +| `personne_heures_restantes_formation` | formula | capacite × split_formation_pct - heures_attribuees | + +## Table Formation + +| Field name | Type | Notes | +|-----------------------------|----------------|--------------------------------| +| `formation_nom` | text (primary) | | +| `formation_filiere` | single_select | dev, graphisme, marketing, iot, cybersec | +| `formation_heures_totales` | number | | +| `formation_statut` | single_select | draft, actif, termine, archive | +| `formation_date_debut` | date | | +| `formation_date_fin` | date | | +| `formation_heures_attribuees` | formula | Sum rollup blocs.heures_prevues | + +## Table Bloc + +Decoupage pedagogique d'une Formation. + +| Field name | Type | Notes | +|------------------------|-----------------------------|----------------------| +| `bloc_nom` | text (primary) | | +| `bloc_formation` | link_row -> Formation | FK obligatoire. | +| `bloc_heures_prevues` | number | | +| `bloc_ordre` | number | | + +## Table Module + +Brique elementaire d'un Bloc, attribuable a une Personne. + +| Field name | Type | Notes | +|-------------------------|---------------------|------------------------------------| +| `module_nom` | text (primary) | | +| `module_bloc` | link_row -> Bloc | | +| `module_heures_prevues` | number | | +| `module_statut` | single_select | a_attribuer, attribue, en_cours, realise, annule | +| `module_heures_attribuees` | formula | Sum rollup attributions. | +| `module_heures_realisees` | formula | Sum rollup attributions. | + +## Table Attribution + +Lien Module <-> Personne[role=formateur] avec heures et statut. + +| Field name | Type | Notes | +|----------------------------------|-----------------------|----------------------| +| `attribution_module` | link_row -> Module | | +| `attribution_personne` | link_row -> Personne | role=formateur exige cote DocAdenice | +| `attribution_heures_attribuees` | number | RG-01 : sum <= module.heures_prevues | +| `attribution_heures_realisees` | number | Saisi par le formateur | +| `attribution_date_debut` | date | | +| `attribution_date_fin` | date | | +| `attribution_statut` | single_select | planifie, en_cours, realise, annule | + +## Table Client + +| Field name | Type | Notes | +|-------------------------|----------------|-----------------------------------------------| +| `client_nom` | text (primary) | | +| `client_contact_principal` | text | | +| `client_contact_email` | text (email) | | +| `client_contact_telephone` | text | | +| `client_secteur` | text | | +| `client_notes` | long_text | | +| `client_statut` | single_select | prospect, actif, inactif, archive | + +## Table Projet + +| Field name | Type | Notes | +|-------------------------------|----------------------------|------------------------------------------| +| `projet_nom` | text (primary) | | +| `projet_client` | link_row -> Client | | +| `projet_type` | single_select | site_web, app_mobile, api, infra, audit, support, autre | +| `projet_charge_heures` | number | Devis valide. | +| `projet_statut` | single_select | devis, en_cours, livre, cloture, abandonne | +| `projet_formation_pedagogique`| link_row -> Formation | Optionnel. Lien projet pedagogique. | +| `projet_heures_realisees` | formula | Sum rollup taches.heures_realisees | + +## Table Tache + +| Field name | Type | Notes | +|-------------------------|-----------------------|----------------------------------------| +| `tache_titre` | text (primary) | | +| `tache_projet` | link_row -> Projet | | +| `tache_charge_heures` | number | | +| `tache_priorite` | single_select | faible, normale, haute, critique | +| `tache_statut` | single_select | todo, in_progress, review, done, abandoned | +| `tache_heures_realisees`| formula | Sum rollup interventions.heures | + +## Table Intervention + +Lien Tache <-> Personne[role=developpeur] avec heures saisies. + +| Field name | Type | Notes | +|-------------------------|------------------------|--------------------------------------| +| `intervention_tache` | link_row -> Tache | | +| `intervention_personne` | link_row -> Personne | role=developpeur exige cote DocAdenice | +| `intervention_heures` | number | > 0 | +| `intervention_date` | date | | +| `intervention_notes` | long_text | Optionnel. | +| `intervention_statut` | single_select | planifie, realise, annule | + +## Webhooks Baserow vers le bridge + +Configurer un webhook par table (ou un seul global selon la version Baserow) +qui pointe sur `POST /api/webhooks/baserow` du bridge avec : +- header `X-Baserow-Signature: ` +- secret partage via env `BASEROW_WEBHOOK_SECRET` du bridge + +Le bridge invalidera juste `bridge:tables::*` — sans cascade +metier. Si vous avez besoin de mettre a jour une table parente, posez une +formule Baserow qui fait le rollup ; Baserow emettra son propre webhook +quand la formule recalcule. + +## Regles de gestion principales (RG) + +Ces regles vivent **cote frontend / DocAdenice** — pas dans le bridge. + +| Code | Regle | +|--------|------------------------------------------------------------------------------| +| RG-01 | Sum(attributions.heures_attribuees) <= module.heures_prevues | +| RG-02 | personne.split_formation_pct + split_agence_pct = 100 | +| RG-03 | Attribution exige personne.role contient 'formateur' | +| RG-04 | Intervention exige personne.role contient 'developpeur' | +| RG-05 | personne.heures_attribuees_formation <= capacite × split_formation_pct | +| RG-06 | personne.heures_attribuees_agence <= capacite × split_agence_pct |