feat(bridge): R1 refactor proxy generique style Notion
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
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:<tableId>:* 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) <noreply@anthropic.com>
This commit is contained in:
parent
0cf6533885
commit
2ed73fa948
81 changed files with 2927 additions and 4985 deletions
|
|
@ -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:<tableX>:list:*`, `bridge:tables:<tableX>:views:*`,
|
||||||
|
`bridge:tables:<tableX>:row:<id>` (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:<tableId>:*` (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)** :
|
- **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`).
|
- 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`).
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ LOG_LEVEL=debug
|
||||||
BASEROW_API_URL=http://baserow:80/api
|
BASEROW_API_URL=http://baserow:80/api
|
||||||
BASEROW_API_TOKEN=
|
BASEROW_API_TOKEN=
|
||||||
|
|
||||||
# Docmost API
|
# Docmost API (optionnel — pas utilise par le bridge generique R1)
|
||||||
DOCMOST_API_URL=http://docmost:3000/api
|
# DOCMOST_API_URL=http://docmost:3000/api
|
||||||
DOCMOST_API_TOKEN=
|
# DOCMOST_API_TOKEN=
|
||||||
|
|
||||||
# Redis (cache + idempotence webhooks + lookup Personne)
|
# Redis (cache + idempotence webhooks + rate limit)
|
||||||
REDIS_URL=redis://docmost-redis:6379
|
REDIS_URL=redis://docmost-redis:6379
|
||||||
|
|
||||||
# Webhooks Baserow signature secret (HMAC-SHA256, header X-Baserow-Signature)
|
# 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)
|
# Webhooks Docmost signature secret (HMAC-SHA256, header X-Docmost-Signature)
|
||||||
# Stub Bloc 7b — handlers metier viennent en Bloc 8 (Tiptap node-views)
|
# 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)
|
# Auth tokens bridge — JSON serialise (Phase 2 simple)
|
||||||
# Format: [{"token":"brg_xxx","name":"label","scopes":["read:personnes",...]}]
|
# Format: [{"token":"brg_xxx","name":"label","scopes":["read:tables",...]}]
|
||||||
# Phase 3 : migration vers DB dediee
|
# Scopes generiques R1 : read:tables, write:tables, admin:*
|
||||||
BRIDGE_API_TOKENS=
|
BRIDGE_API_TOKENS=
|
||||||
|
|
||||||
# Authentik OIDC (optional — laisse vide pour mode local-only avec service 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_ISSUER=https://auth.acadenice.com/application/o/formation-hub/
|
||||||
# AUTHENTIK_JWKS_URI=https://auth.acadenice.com/application/o/formation-hub/jwks/
|
# AUTHENTIK_JWKS_URI=https://auth.acadenice.com/application/o/formation-hub/jwks/
|
||||||
# AUTHENTIK_AUDIENCE=formation-hub-bridge
|
# AUTHENTIK_AUDIENCE=formation-hub-bridge
|
||||||
# AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]}
|
# Mapping group Authentik -> scopes bridge (optionnel).
|
||||||
# AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement)
|
# 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/*
|
# Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/*
|
||||||
# (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense).
|
# (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense).
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@ export class BaserowClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resoud le mapping table_name → table_id pour la database.
|
* 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<Record<string, number>> {
|
async resolveTableIds(databaseId: number): Promise<Record<string, number>> {
|
||||||
const tables = await this.fetch<Array<{ id: number; name: string }>>(
|
const tables = await this.fetch<Array<{ id: number; name: string }>>(
|
||||||
|
|
@ -140,6 +142,73 @@ export class BaserowClient {
|
||||||
return Object.fromEntries(tables.map((t) => [t.name, t.id]));
|
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<Array<{ id: number; name: string; order: number; database_id: number }>> {
|
||||||
|
return this.fetch<Array<{ id: number; name: string; order: number; database_id: number }>>(
|
||||||
|
`/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<string, unknown>>
|
||||||
|
> {
|
||||||
|
return this.fetch<
|
||||||
|
Array<{ id: number; name: string; type: string; primary?: boolean } & Record<string, unknown>>
|
||||||
|
>(`/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<string, unknown>>
|
||||||
|
> {
|
||||||
|
return this.fetch<
|
||||||
|
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||||
|
>(`/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<BaserowPaginatedResponse> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
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<BaserowPaginatedResponse>(`/api/database/views/grid/${viewId}/?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await ofetch(`${this.baseUrl}/api/_health/`, { timeout: 3000 });
|
await ofetch(`${this.baseUrl}/api/_health/`, { timeout: 3000 });
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
32
bridge/src/domain/field.ts
Normal file
32
bridge/src/domain/field.ts
Normal file
|
|
@ -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<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Field {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly primary: boolean;
|
||||||
|
readonly options: Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
export * from './types.js';
|
export { Table } from './table.js';
|
||||||
export { Personne } from './personne.js';
|
export type { TableProps } from './table.js';
|
||||||
export type { PersonneProps } from './personne.js';
|
export { Row } from './row.js';
|
||||||
export { Formation } from './formation.js';
|
export type { RowProps } from './row.js';
|
||||||
export type { FormationProps } from './formation.js';
|
export { Field } from './field.js';
|
||||||
export { Bloc } from './bloc.js';
|
export type { FieldProps } from './field.js';
|
||||||
export type { BlocProps } from './bloc.js';
|
export { View } from './view.js';
|
||||||
export { Module } from './module.js';
|
export type { ViewType, ViewProps } from './view.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 * from './schemas.js';
|
export * from './schemas.js';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Role>;
|
|
||||||
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<Role>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
bridge/src/domain/row.ts
Normal file
36
bridge/src/domain/row.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Row entity — generic record dans une table Baserow.
|
||||||
|
*
|
||||||
|
* `fields` est un Record<string, unknown> 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,145 +1,59 @@
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schemas zod pour validation runtime au boundary HTTP/webhook.
|
* 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<string, unknown>) — 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 ViewTypeSchema = z.union([
|
||||||
|
z.enum(['grid', 'kanban', 'calendar', 'gallery', 'form']),
|
||||||
export const ProjetTypeSchema = z.enum([
|
z.string().min(1),
|
||||||
'site_web',
|
|
||||||
'app_mobile',
|
|
||||||
'api',
|
|
||||||
'infra',
|
|
||||||
'audit',
|
|
||||||
'support',
|
|
||||||
'autre',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const StatutPersonneSchema = z.enum(['actif', 'inactif']);
|
export const ViewSchema = z.object({
|
||||||
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({
|
|
||||||
id: z.number().int().nonnegative(),
|
id: z.number().int().nonnegative(),
|
||||||
nom: z.string().min(1).max(200),
|
name: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
type: ViewTypeSchema,
|
||||||
filiere: FiliereSchema.optional().nullable(),
|
tableId: z.number().int().positive(),
|
||||||
heuresTotales: z.coerce.number().nonnegative(),
|
|
||||||
statut: StatutFormationSchema.default('draft'),
|
|
||||||
dateDebut: z.coerce.date().optional().nullable(),
|
|
||||||
dateFin: z.coerce.date().optional().nullable(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BlocSchema = z.object({
|
export const TableSchema = z.object({
|
||||||
id: z.number().int().nonnegative(),
|
id: z.number().int().positive(),
|
||||||
formationId: z.number().int().nonnegative(),
|
name: z.string().min(1),
|
||||||
nom: z.string().min(1).max(200),
|
databaseId: z.number().int().positive(),
|
||||||
heuresPrevues: z.coerce.number().nonnegative(),
|
fields: z.array(FieldSchema).optional(),
|
||||||
ordre: z.number().int().nonnegative().default(0),
|
orderIndex: z.number().int().nonnegative().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ModuleSchema = z.object({
|
export const RowSchema = z.object({
|
||||||
id: z.number().int().nonnegative(),
|
id: z.number().int().nonnegative(),
|
||||||
blocId: z.number().int().nonnegative(),
|
tableId: z.number().int().positive(),
|
||||||
nom: z.string().min(1).max(200),
|
fields: z.record(z.unknown()),
|
||||||
heuresPrevues: z.coerce.number().nonnegative(),
|
createdOn: z.coerce.date().nullable().optional(),
|
||||||
statut: StatutModuleSchema.default('a_attribuer'),
|
updatedOn: z.coerce.date().nullable().optional(),
|
||||||
|
order: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AttributionSchema = z.object({
|
/**
|
||||||
id: z.number().int().nonnegative(),
|
* Body d'un create/update row : juste un Record<string, unknown>. Le bridge
|
||||||
moduleId: z.number().int().nonnegative(),
|
* proxie tel quel vers Baserow qui appliquera ses propres validations
|
||||||
personneId: z.number().int().nonnegative(),
|
* (types de champs, contraintes, formules read-only, etc.).
|
||||||
heuresAttribuees: z.coerce.number().positive(),
|
*/
|
||||||
heuresRealisees: z.coerce.number().nonnegative().default(0),
|
export const RowFieldsSchema = z.record(z.unknown());
|
||||||
dateDebut: z.coerce.date().optional().nullable(),
|
|
||||||
dateFin: z.coerce.date().optional().nullable(),
|
|
||||||
statut: StatutAttributionSchema.default('planifie'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ClientSchema = z.object({
|
export type FieldInput = z.infer<typeof FieldSchema>;
|
||||||
id: z.number().int().nonnegative(),
|
export type ViewInput = z.infer<typeof ViewSchema>;
|
||||||
nom: z.string().min(1).max(200),
|
export type TableInput = z.infer<typeof TableSchema>;
|
||||||
contactPrincipal: z.string().max(200).optional().nullable(),
|
export type RowInput = z.infer<typeof RowSchema>;
|
||||||
contactEmail: z.string().email().optional().nullable(),
|
export type RowFieldsInput = z.infer<typeof RowFieldsSchema>;
|
||||||
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<typeof PersonneSchema>;
|
|
||||||
export type FormationInput = z.infer<typeof FormationSchema>;
|
|
||||||
export type BlocInput = z.infer<typeof BlocSchema>;
|
|
||||||
export type ModuleInput = z.infer<typeof ModuleSchema>;
|
|
||||||
export type AttributionInput = z.infer<typeof AttributionSchema>;
|
|
||||||
export type ClientInput = z.infer<typeof ClientSchema>;
|
|
||||||
export type ProjetInput = z.infer<typeof ProjetSchema>;
|
|
||||||
export type TacheInput = z.infer<typeof TacheSchema>;
|
|
||||||
export type InterventionInput = z.infer<typeof InterventionSchema>;
|
|
||||||
|
|
|
||||||
34
bridge/src/domain/table.ts
Normal file
34
bridge/src/domain/table.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
|
||||||
29
bridge/src/domain/view.ts
Normal file
29
bridge/src/domain/view.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Bridge service entrypoint — Hono HTTP server.
|
* Bridge service entrypoint — Hono HTTP server.
|
||||||
*
|
*
|
||||||
* Boot sequence : loadConfig -> initContainer (Baserow + Redis + repos + token map)
|
* Boot sequence : loadConfig -> initContainer -> wire middleware globaux +
|
||||||
* -> wire middleware globaux + routes /api/v1/* avec auth + serve.
|
* 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';
|
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 { type AuthVariables, authMiddleware } from './middleware/auth.js';
|
||||||
import { errorHandler } from './middleware/error-handler.js';
|
import { errorHandler } from './middleware/error-handler.js';
|
||||||
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
|
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
|
||||||
import { attributionsRoutes } from './routes/attributions.js';
|
import { tablesRoutes } from './routes/tables.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 { webhooksRoutes } from './routes/webhooks.js';
|
import { webhooksRoutes } from './routes/webhooks.js';
|
||||||
|
|
||||||
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||||
|
|
@ -52,9 +50,6 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||||
tokens: ctn.tokens,
|
tokens: ctn.tokens,
|
||||||
oidc: ctn.oidc,
|
oidc: ctn.oidc,
|
||||||
groupsScopesMap: ctn.groupsScopesMap,
|
groupsScopesMap: ctn.groupsScopesMap,
|
||||||
strictMapping: ctn.config.authStrictMapping,
|
|
||||||
cache: ctn.redis,
|
|
||||||
finder: ctn.repos.personnes,
|
|
||||||
logger: ctn.logger,
|
logger: ctn.logger,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -82,12 +77,7 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
v1.route('/personnes', personnesRoutes);
|
v1.route('/tables', tablesRoutes);
|
||||||
v1.route('/formations', formationsRoutes);
|
|
||||||
v1.route('/projets', projetsRoutes);
|
|
||||||
v1.route('/modules', modulesRoutes);
|
|
||||||
v1.route('/interventions', interventionsRoutes);
|
|
||||||
v1.route('/attributions', attributionsRoutes);
|
|
||||||
app.route('/api/v1', v1);
|
app.route('/api/v1', v1);
|
||||||
|
|
||||||
app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404));
|
app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404));
|
||||||
|
|
@ -97,22 +87,7 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
// Soit BASEROW_TABLE_IDS={"personne":609,...} (preferred — DB tokens n'ont pas
|
await initContainer({ config });
|
||||||
// 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<typeof initContainer>[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);
|
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Quand une route REST `/api/v1/tables/:tableId/rows*` mute Baserow, Baserow
|
||||||
* va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler.ts`.
|
* va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler`.
|
||||||
* MAIS la latence webhook est variable (ms a quelques secondes selon la conf
|
* MAIS la latence webhook est variable — entre l'ecriture et l'arrivee du
|
||||||
* Baserow + reseau) — entre l'ecriture et l'arrivee du webhook, une lecture
|
* webhook, une lecture concurrente peut servir une valeur stale.
|
||||||
* concurrente peut servir une valeur stale. L'invalidation immediate cote write
|
* L'invalidation immediate cote write ferme cette fenetre.
|
||||||
* ferme cette fenetre et evite la double-source-of-truth temporaire.
|
|
||||||
*
|
*
|
||||||
* Volontairement pas de coordination avec le webhook : si les deux invalidations
|
* Pas de cascade cross-table : c'est volontaire. Le bridge ne connait pas le
|
||||||
* tombent (write local puis webhook), `invalidatePattern` est idempotent (un
|
* graphe de relations entre les tables (link_row, formula, lookup) — c'est
|
||||||
* pattern qui ne matche rien retourne 0, pas d'erreur).
|
* 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:<tableId>:row:<rowId>`,
|
||||||
|
* `bridge:tables:<tableId>:list:*`,
|
||||||
|
* `bridge:tables:<tableId>:views:*`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TableName } from '../repos/baserow-repo.js';
|
|
||||||
|
|
||||||
export interface CacheInvalidator {
|
export interface CacheInvalidator {
|
||||||
invalidatePattern: (pattern: string) => Promise<number>;
|
invalidatePattern: (pattern: string) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalide le cache local pour une entite + cascade sur les rollups parents.
|
* Invalide le cache local pour une table. Si `rowId` fourni : invalide la row
|
||||||
* Mirror de la logique webhook (`buildInvalidationPatterns`) — duplique
|
* precise + la liste + les vues. Sinon : juste la liste + les vues.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
export async function invalidateEntity(
|
export async function invalidateTable(
|
||||||
redis: CacheInvalidator,
|
redis: CacheInvalidator,
|
||||||
entity: TableName,
|
tableId: number,
|
||||||
id?: number,
|
rowId?: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const patterns: string[] = [`bridge:${entity}:list:*`];
|
const patterns: string[] = [
|
||||||
if (typeof id === 'number') {
|
`bridge:tables:${tableId}:list:*`,
|
||||||
patterns.push(`bridge:${entity}:row:${id}`);
|
`bridge:tables:${tableId}:views:*`,
|
||||||
}
|
];
|
||||||
|
if (typeof rowId === 'number') {
|
||||||
// Cascade rollups parent : aligned avec webhooks/baserow-handler.ts.
|
patterns.push(`bridge:tables:${tableId}:row:${rowId}`);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,6 @@ const ConfigSchema = z.object({
|
||||||
authentikAudience: z.string().min(1).optional(),
|
authentikAudience: z.string().min(1).optional(),
|
||||||
// JSON serialise group->scopes ; parse fait dans le middleware auth.
|
// JSON serialise group->scopes ; parse fait dans le middleware auth.
|
||||||
authGroupsScopesMap: z.string().optional(),
|
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
|
// 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
|
// sur POST/PATCH/PUT/DELETE et est volontairement plus strict pour proteger
|
||||||
// contre les bursts buggy / scripts mal configures.
|
// contre les bursts buggy / scripts mal configures.
|
||||||
|
|
@ -52,7 +49,6 @@ export function loadConfig(): Config {
|
||||||
authentikJwksUri: process.env.AUTHENTIK_JWKS_URI,
|
authentikJwksUri: process.env.AUTHENTIK_JWKS_URI,
|
||||||
authentikAudience: process.env.AUTHENTIK_AUDIENCE,
|
authentikAudience: process.env.AUTHENTIK_AUDIENCE,
|
||||||
authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP,
|
authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP,
|
||||||
authStrictMapping: process.env.AUTH_STRICT_MAPPING,
|
|
||||||
rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX,
|
rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX,
|
||||||
rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW,
|
rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW,
|
||||||
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
* DI container — initialise les dependances une seule fois au boot et expose
|
* DI container — initialise les dependances une seule fois au boot et expose
|
||||||
* un singleton typed pour les routes. Pour les tests, `setContainer` permet
|
* un singleton typed pour les routes. Pour les tests, `setContainer` permet
|
||||||
* d'injecter un mock complet sans toucher a `getContainer`.
|
* 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';
|
import type { Logger } from 'pino';
|
||||||
|
|
@ -11,18 +15,27 @@ import type { ApiTokenRecord } from '../middleware/auth.js';
|
||||||
import { parseTokens } from '../middleware/auth.js';
|
import { parseTokens } from '../middleware/auth.js';
|
||||||
import { OidcVerifier } from '../middleware/oidc-verifier.js';
|
import { OidcVerifier } from '../middleware/oidc-verifier.js';
|
||||||
import { type GroupsScopesMap, parseGroupsScopesMap } from '../middleware/scopes.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 type { Config } from './config.js';
|
||||||
import { isOidcEnabled } from './config.js';
|
import { isOidcEnabled } from './config.js';
|
||||||
import { logger as rootLogger } from './logger.js';
|
import { logger as rootLogger } from './logger.js';
|
||||||
|
|
||||||
|
export interface RepoSet {
|
||||||
|
tables: BaserowTablesRepo;
|
||||||
|
rows: BaserowRowsRepo;
|
||||||
|
fields: BaserowFieldsRepo;
|
||||||
|
views: BaserowViewsRepo;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Container {
|
export interface Container {
|
||||||
config: Config;
|
config: Config;
|
||||||
baserow: BaserowClient;
|
baserow: BaserowClient;
|
||||||
redis: RedisCache;
|
redis: RedisCache;
|
||||||
repos: RepoSet;
|
repos: RepoSet;
|
||||||
tokens: ReadonlyMap<string, ApiTokenRecord>;
|
tokens: ReadonlyMap<string, ApiTokenRecord>;
|
||||||
tableIds: TableIds;
|
|
||||||
/** Null si mode OIDC desactive (vars Authentik manquantes). */
|
/** Null si mode OIDC desactive (vars Authentik manquantes). */
|
||||||
oidc: OidcVerifier | null;
|
oidc: OidcVerifier | null;
|
||||||
groupsScopesMap: GroupsScopesMap;
|
groupsScopesMap: GroupsScopesMap;
|
||||||
|
|
@ -44,12 +57,9 @@ export function setContainer(c: Container | null): void {
|
||||||
|
|
||||||
export interface InitOptions {
|
export interface InitOptions {
|
||||||
config: Config;
|
config: Config;
|
||||||
/** Pour tests : skip le resolveTableIds reseau. */
|
|
||||||
tableIds?: TableIds;
|
|
||||||
/** Pour tests : injecter une implem de Baserow/Redis. */
|
/** Pour tests : injecter une implem de Baserow/Redis. */
|
||||||
baserow?: BaserowClient;
|
baserow?: BaserowClient;
|
||||||
redis?: RedisCache;
|
redis?: RedisCache;
|
||||||
databaseId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initContainer(opts: InitOptions): Promise<Container> {
|
export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
|
|
@ -63,18 +73,13 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
});
|
});
|
||||||
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
|
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
|
||||||
|
|
||||||
let tableIds: TableIds;
|
const repos: RepoSet = {
|
||||||
if (opts.tableIds) {
|
tables: new BaserowTablesRepo({ client: baserow, logger: rootLogger }),
|
||||||
tableIds = opts.tableIds;
|
rows: new BaserowRowsRepo({ client: baserow, logger: rootLogger }),
|
||||||
} else {
|
fields: new BaserowFieldsRepo({ client: baserow, logger: rootLogger }),
|
||||||
if (typeof opts.databaseId !== 'number') {
|
views: new BaserowViewsRepo({ client: baserow, logger: rootLogger }),
|
||||||
throw new Error('initContainer: databaseId requis si tableIds non fourni');
|
};
|
||||||
}
|
|
||||||
const resolved = await baserow.resolveTableIds(opts.databaseId);
|
|
||||||
tableIds = pickTableIds(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
const repos = buildRepos(baserow, tableIds, rootLogger);
|
|
||||||
const tokens = parseTokens(config.bridgeApiTokens);
|
const tokens = parseTokens(config.bridgeApiTokens);
|
||||||
const groupsScopesMap = parseGroupsScopesMap(config.authGroupsScopesMap);
|
const groupsScopesMap = parseGroupsScopesMap(config.authGroupsScopesMap);
|
||||||
|
|
||||||
|
|
@ -100,7 +105,6 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
redis,
|
redis,
|
||||||
repos,
|
repos,
|
||||||
tokens,
|
tokens,
|
||||||
tableIds,
|
|
||||||
oidc,
|
oidc,
|
||||||
groupsScopesMap,
|
groupsScopesMap,
|
||||||
logger: rootLogger,
|
logger: rootLogger,
|
||||||
|
|
@ -108,16 +112,3 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
setContainer(container);
|
setContainer(container);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verifie que toutes les tables attendues sont presentes dans le mapping name->id. */
|
|
||||||
function pickTableIds(resolved: Record<string, number>): TableIds {
|
|
||||||
const out: Partial<TableIds> = {};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,30 @@
|
||||||
* Auth middleware bridge — dual mode :
|
* Auth middleware bridge — dual mode :
|
||||||
*
|
*
|
||||||
* 1. Service tokens `brg_*` (`Authorization: ApiKey brg_*` ou `Bearer brg_*`)
|
* 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 <jwt>` ou cookie `authToken`)
|
* 2. OIDC JWT Authentik (`Authorization: Bearer <jwt>` ou cookie `authToken`)
|
||||||
* pour utilisateurs Docmost. Active uniquement si `AUTHENTIK_ISSUER` +
|
* pour utilisateurs Docmost/DocAdenice. Active uniquement si
|
||||||
* `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set dans la config.
|
* `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 :
|
* Ordre de detection :
|
||||||
* - Header `Authorization`: si commence par `brg_` (apres ApiKey/Bearer) -> service token
|
* - `Authorization: <ApiKey|Bearer> brg_*` -> service token
|
||||||
* - Header `Authorization: Bearer <jwt>` (commence par `eyJ`) -> JWT OIDC
|
* - `Authorization: Bearer <jwt>` (commence par `eyJ`) -> JWT OIDC
|
||||||
* - Cookie `authToken` -> JWT OIDC
|
* - Cookie `authToken` -> JWT OIDC
|
||||||
* - Sinon -> 401 AUTH_REQUIRED
|
* - 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 type { MiddlewareHandler } from 'hono';
|
||||||
import { getCookie } from 'hono/cookie';
|
import { getCookie } from 'hono/cookie';
|
||||||
import type { Logger } from 'pino';
|
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 { errors } from '../lib/errors.js';
|
||||||
import type { OidcVerifier } from './oidc-verifier.js';
|
import type { OidcVerifier } from './oidc-verifier.js';
|
||||||
import { extractEmail, extractGroups } from './oidc-verifier.js';
|
import { extractEmail, extractGroups } from './oidc-verifier.js';
|
||||||
|
|
@ -46,24 +47,22 @@ export interface AuthenticatedUser {
|
||||||
email?: string;
|
email?: string;
|
||||||
/** Pour OIDC : sub claim (id stable Authentik). */
|
/** Pour OIDC : sub claim (id stable Authentik). */
|
||||||
sub?: string;
|
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 Authentik bruts (vide pour service tokens). */
|
||||||
groups: string[];
|
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[];
|
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 = {
|
export type AuthVariables = {
|
||||||
auth: { tokenName: string; scopes: ReadonlySet<string> };
|
auth: { tokenName: string; scopes: ReadonlySet<string> };
|
||||||
user: AuthenticatedUser;
|
user: AuthenticatedUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Service tokens (Bloc 3 inchange — JSON parsing tolere ApiKey/Bearer)
|
// Service tokens
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
|
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
|
||||||
|
|
@ -94,11 +93,10 @@ export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Match scope avec wildcard. `admin:*` couvre tout, `read:*` couvre `read:personnes`, etc. */
|
/** Match scope avec wildcard. `admin:*` couvre tout, `read:*` couvre `read:tables`, etc. */
|
||||||
export function hasScope(owned: ReadonlySet<string>, required: string): boolean {
|
export function hasScope(owned: ReadonlySet<string>, required: string): boolean {
|
||||||
if (owned.has('admin:*')) return true;
|
if (owned.has('admin:*')) return true;
|
||||||
if (owned.has(required)) return true;
|
if (owned.has(required)) return true;
|
||||||
// Wildcard suffix (`prefix:*` -> couvre `prefix:foo`, `prefix:bar`)
|
|
||||||
const colonIdx = required.indexOf(':');
|
const colonIdx = required.indexOf(':');
|
||||||
if (colonIdx > 0) {
|
if (colonIdx > 0) {
|
||||||
const prefixWildcard = `${required.slice(0, colonIdx)}:*`;
|
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: <T>(key: string) => Promise<T | null>;
|
* Extrait `acadenice_permissions` d'un payload JWT. C'est le claim que
|
||||||
set: <T>(key: string, value: T, ttlSeconds?: number) => Promise<void>;
|
* DocAdenice (R2) attachera au token via le RBAC dynamique. Tolerant : accepte
|
||||||
}
|
* un tableau de strings, ignore les valeurs non-strings ou vides.
|
||||||
|
*/
|
||||||
export interface PersonneFinder {
|
export function extractPermissions(payload: Record<string, unknown>): string[] {
|
||||||
findByEmail: (email: string) => Promise<Personne | null>;
|
const raw = payload.acadenice_permissions;
|
||||||
}
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
||||||
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<CachedPersonne | null> {
|
|
||||||
const key = `bridge:auth:personne-by-email:${hashEmail(email)}`;
|
|
||||||
try {
|
|
||||||
const hit = await cache.get<CachedPersonne | { miss: true }>(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -197,11 +148,6 @@ export interface AuthMiddlewareOptions {
|
||||||
oidc: OidcVerifier | null;
|
oidc: OidcVerifier | null;
|
||||||
/** Map groups Authentik -> scopes. */
|
/** Map groups Authentik -> scopes. */
|
||||||
groupsScopesMap: GroupsScopesMap;
|
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;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,7 +170,7 @@ function parseAuthHeader(header: string | undefined): ParsedHeader {
|
||||||
export function authMiddleware(
|
export function authMiddleware(
|
||||||
opts: AuthMiddlewareOptions,
|
opts: AuthMiddlewareOptions,
|
||||||
): MiddlewareHandler<{ Variables: AuthVariables }> {
|
): MiddlewareHandler<{ Variables: AuthVariables }> {
|
||||||
const { tokens, oidc, groupsScopesMap, strictMapping, cache, finder, logger } = opts;
|
const { tokens, oidc, groupsScopesMap, logger } = opts;
|
||||||
|
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const headerRaw = c.req.header('Authorization');
|
const headerRaw = c.req.header('Authorization');
|
||||||
|
|
@ -241,8 +187,8 @@ export function authMiddleware(
|
||||||
const user: AuthenticatedUser = {
|
const user: AuthenticatedUser = {
|
||||||
source: 'service-token',
|
source: 'service-token',
|
||||||
tokenId: record.name,
|
tokenId: record.name,
|
||||||
roles: [],
|
|
||||||
groups: [],
|
groups: [],
|
||||||
|
permissions: [],
|
||||||
scopes,
|
scopes,
|
||||||
};
|
};
|
||||||
c.set('user', user);
|
c.set('user', user);
|
||||||
|
|
@ -282,36 +228,18 @@ export function authMiddleware(
|
||||||
const email = extractEmail(verified.payload);
|
const email = extractEmail(verified.payload);
|
||||||
const sub = typeof verified.payload.sub === 'string' ? verified.payload.sub : undefined;
|
const sub = typeof verified.payload.sub === 'string' ? verified.payload.sub : undefined;
|
||||||
const groups = extractGroups(verified.payload);
|
const groups = extractGroups(verified.payload);
|
||||||
|
const permissions = extractPermissions(verified.payload as Record<string, unknown>);
|
||||||
|
|
||||||
let personneId: number | undefined;
|
const scopes = computeOidcScopes(groups, permissions, groupsScopesMap);
|
||||||
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 user: AuthenticatedUser = {
|
const user: AuthenticatedUser = {
|
||||||
source: source ?? 'oidc-jwt',
|
source: source ?? 'oidc-jwt',
|
||||||
email: email ?? undefined,
|
email: email ?? undefined,
|
||||||
sub,
|
sub,
|
||||||
personneId,
|
|
||||||
roles,
|
|
||||||
groups,
|
groups,
|
||||||
|
permissions,
|
||||||
scopes,
|
scopes,
|
||||||
};
|
};
|
||||||
c.set('user', user);
|
c.set('user', user);
|
||||||
// Compat : c.var.auth pour les rares endpoints Bloc 3 qui le lisent encore.
|
|
||||||
c.set('auth', {
|
c.set('auth', {
|
||||||
tokenName: email ?? sub ?? 'oidc-anonymous',
|
tokenName: email ?? sub ?? 'oidc-anonymous',
|
||||||
scopes: new Set(scopes),
|
scopes: new Set(scopes),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
* 1. groups Authentik (mappes via `AUTH_GROUPS_SCOPES_MAP` JSON)
|
||||||
* 2. roles formation-hub portes par la Personne (mappes via DEFAULT_ROLE_SCOPES)
|
* 2. claim direct `acadenice_permissions[]` du JWT (alimente cote DocAdenice
|
||||||
* 3. union des deux
|
* R2 par le RBAC dynamique). Lu par le middleware auth, pas ici.
|
||||||
*
|
*
|
||||||
* Le default role-scope mapping est volontairement conservateur : seul `admin`
|
* Les permissions metier (Formateur, Developpeur, Admin, Direction, Support)
|
||||||
* obtient `admin:*`. Les autres roles reçoivent le strict necessaire pour leur travail.
|
* 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<string, string[]>;
|
export type GroupsScopesMap = Record<string, string[]>;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<Role, string[]> = {
|
|
||||||
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 {
|
export function parseGroupsScopesMap(raw: string | undefined): GroupsScopesMap {
|
||||||
if (!raw || raw.trim().length === 0) return {};
|
if (!raw || raw.trim().length === 0) return {};
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
|
|
@ -48,14 +36,16 @@ export function parseGroupsScopesMap(raw: string | undefined): GroupsScopesMap {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule l'union des scopes pour un user OIDC.
|
* Calcule l'union des scopes pour un user OIDC :
|
||||||
* - groups Authentik : si pas de mapping fourni, fallback sur le nom de groupe
|
* - groups Authentik via le mapping configure
|
||||||
* qui matche un Role connu (ex: `formation-hub-formateurs` -> formateur).
|
* - permissions explicites (claim `acadenice_permissions[]` ou equivalent)
|
||||||
* - roles formation-hub : DEFAULT_ROLE_SCOPES.
|
*
|
||||||
|
* 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(
|
export function computeOidcScopes(
|
||||||
groups: string[],
|
groups: string[],
|
||||||
roles: ReadonlySet<Role>,
|
permissions: string[],
|
||||||
groupsMap: GroupsScopesMap,
|
groupsMap: GroupsScopesMap,
|
||||||
): string[] {
|
): string[] {
|
||||||
const scopes = new Set<string>();
|
const scopes = new Set<string>();
|
||||||
|
|
@ -65,8 +55,8 @@ export function computeOidcScopes(
|
||||||
for (const s of direct) scopes.add(s);
|
for (const s of direct) scopes.add(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const role of roles) {
|
for (const p of permissions) {
|
||||||
for (const s of DEFAULT_ROLE_SCOPES[role] ?? []) scopes.add(s);
|
if (typeof p === 'string' && p.length > 0) scopes.add(p);
|
||||||
}
|
}
|
||||||
return Array.from(scopes).sort();
|
return Array.from(scopes).sort();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
bridge/src/repos/baserow-fields-repo.ts
Normal file
42
bridge/src/repos/baserow-fields-repo.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Repository fields Baserow — list par tableId. DB token OK.
|
||||||
|
*
|
||||||
|
* Le bridge passe `options` brut (Record<string, unknown>) 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<Field[]> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,554 +0,0 @@
|
||||||
/**
|
|
||||||
* Repository layer — wrappe BaserowClient et fait le mapping `BaserowRow` <-> domain.
|
|
||||||
*
|
|
||||||
* Choix : 1 classe par entite, qui herite de `BaseRepo<TDomain>`. 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<TableName, number>;
|
|
||||||
|
|
||||||
/** 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<TDomain> {
|
|
||||||
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<TDomain> {
|
|
||||||
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<BaserowRow> {
|
|
||||||
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<Personne> {
|
|
||||||
/**
|
|
||||||
* 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<Personne | null> {
|
|
||||||
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<Formation> {
|
|
||||||
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<Bloc> {
|
|
||||||
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<Module> {
|
|
||||||
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<Attribution> {
|
|
||||||
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<BaserowRow> {
|
|
||||||
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<BaserowRow> {
|
|
||||||
return this.client.updateRow(this.tableId, id, {
|
|
||||||
attribution_heures_realisees: heures.toNumber(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Client / Projet / Tache / Intervention
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class ClientRepo extends BaseRepo<ClientEntity> {
|
|
||||||
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<Projet> {
|
|
||||||
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<Tache> {
|
|
||||||
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<Intervention> {
|
|
||||||
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<BaserowRow> {
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
114
bridge/src/repos/baserow-rows-repo.ts
Normal file
114
bridge/src/repos/baserow-rows-repo.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||||
|
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<ListRowsResult> {
|
||||||
|
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<Row> {
|
||||||
|
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<string, unknown>): Promise<Row> {
|
||||||
|
const raw = await this.client.createRow(tableId, fields);
|
||||||
|
return rawToRow(raw, tableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
bridge/src/repos/baserow-tables-repo.ts
Normal file
50
bridge/src/repos/baserow-tables-repo.ts
Normal file
|
|
@ -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<Table[]> {
|
||||||
|
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<Table> {
|
||||||
|
const raw = await this.client.getTable(tableId);
|
||||||
|
return new Table({
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
databaseId: raw.database_id,
|
||||||
|
orderIndex: raw.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
bridge/src/repos/baserow-views-repo.ts
Normal file
77
bridge/src/repos/baserow-views-repo.ts
Normal file
|
|
@ -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<View[]> {
|
||||||
|
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<ListRowsResult> {
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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<string, string> = {};
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<string, string> = {};
|
|
||||||
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()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
241
bridge/src/routes/tables.ts
Normal file
241
bridge/src/routes/tables.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -24,7 +24,7 @@ export const DOCMOST_SIGNATURE_HEADER = 'X-Docmost-Signature';
|
||||||
export const webhooksRoutes = new Hono();
|
export const webhooksRoutes = new Hono();
|
||||||
|
|
||||||
webhooksRoutes.post('/baserow', async (c) => {
|
webhooksRoutes.post('/baserow', async (c) => {
|
||||||
const { config, redis, tableIds, logger } = getContainer();
|
const { config, redis, logger } = getContainer();
|
||||||
|
|
||||||
const signature = c.req.header(BASEROW_SIGNATURE_HEADER);
|
const signature = c.req.header(BASEROW_SIGNATURE_HEADER);
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
|
|
@ -58,12 +58,12 @@ webhooksRoutes.post('/baserow', async (c) => {
|
||||||
return c.json({ status: 'duplicate', eventId: payload.event_id }, 200);
|
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(
|
return c.json(
|
||||||
{
|
{
|
||||||
status: result.status,
|
status: result.status,
|
||||||
eventId: payload.event_id,
|
eventId: payload.event_id,
|
||||||
entity: result.entity,
|
tableId: result.tableId,
|
||||||
invalidatedKeys: result.invalidatedKeys,
|
invalidatedKeys: result.invalidatedKeys,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,53 @@
|
||||||
/**
|
/**
|
||||||
* Handler webhooks Baserow.
|
* Handler webhooks Baserow — generique style Notion.
|
||||||
*
|
*
|
||||||
* Pipeline : payload deja valide (zod) + idempotence verifiee en amont (route).
|
* Pipeline : payload deja valide (zod) + idempotence verifiee en amont (route).
|
||||||
* Ici on mappe table_id -> entite domain et on invalide les caches Redis
|
* Ici on invalide les caches Redis associes a la table touchee. Plus de
|
||||||
* pertinents. Le recalcul des agregations parent (modules, formations, projets)
|
* cascade rollup metier : si l'utilisateur a configure des formules/lookups
|
||||||
* est differe au prochain GET (cache miss -> repo -> fresh data) — voir doc 19
|
* cross-table cote Baserow, elles emettront leurs propres webhooks
|
||||||
* §8 cache strategy.
|
* naturellement (chaque table touchee declenche son propre event).
|
||||||
|
*
|
||||||
|
* Le handler ne sait pas quelle table c'est metier-parlant — il invalide
|
||||||
|
* juste `bridge:tables:<tableId>:*`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import type { RedisCache } from '../adapters/redis-cache.js';
|
import type { RedisCache } from '../adapters/redis-cache.js';
|
||||||
import type { TableIds, TableName } from '../repos/baserow-repo.js';
|
|
||||||
import type { BaserowEventType, BaserowWebhookPayload } from './types.js';
|
import type { BaserowEventType, BaserowWebhookPayload } from './types.js';
|
||||||
|
|
||||||
export interface BaserowHandlerDeps {
|
export interface BaserowHandlerDeps {
|
||||||
redis: RedisCache;
|
redis: RedisCache;
|
||||||
tableIds: TableIds;
|
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaserowHandleResult {
|
export interface BaserowHandleResult {
|
||||||
status: 'processed' | 'ignored';
|
status: 'processed' | 'ignored';
|
||||||
entity: TableName | null;
|
tableId: number | null;
|
||||||
invalidatedKeys: number;
|
invalidatedKeys: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse map id -> table name (mappe juste les tables qu'on suit).
|
* Patterns d'invalidation cache pour une table.
|
||||||
* Construit une fois par appel — taille fixe (9 entries), cout negligeable.
|
* - `list:*` : toutes les listes paginees / filtrees
|
||||||
*/
|
* - `views:*` : toutes les rows fetched via une view
|
||||||
function findEntityByTableId(tableIds: TableIds, tableId: number): TableName | null {
|
* - `row:<id>` : la row precise (si update/delete avec items)
|
||||||
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:<entity>:row:<id>`) si update/delete
|
|
||||||
* - cache liste (`bridge:<entity>:list:*`) toujours
|
|
||||||
* - cache parent (rollups) si l'entite est enfant d'un agregat
|
|
||||||
*/
|
*/
|
||||||
function buildInvalidationPatterns(
|
function buildInvalidationPatterns(
|
||||||
entity: TableName,
|
tableId: number,
|
||||||
eventType: BaserowEventType,
|
eventType: BaserowEventType,
|
||||||
itemIds: number[],
|
itemIds: number[],
|
||||||
): string[] {
|
): 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') {
|
if (eventType === 'rows.updated' || eventType === 'rows.deleted') {
|
||||||
for (const id of itemIds) {
|
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;
|
return patterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,18 +55,16 @@ export async function handleBaserowEvent(
|
||||||
payload: BaserowWebhookPayload,
|
payload: BaserowWebhookPayload,
|
||||||
deps: BaserowHandlerDeps,
|
deps: BaserowHandlerDeps,
|
||||||
): Promise<BaserowHandleResult> {
|
): Promise<BaserowHandleResult> {
|
||||||
const entity = findEntityByTableId(deps.tableIds, payload.table_id);
|
if (!Number.isFinite(payload.table_id) || payload.table_id <= 0) {
|
||||||
|
|
||||||
if (!entity) {
|
|
||||||
deps.logger.warn(
|
deps.logger.warn(
|
||||||
{ tableId: payload.table_id, eventId: payload.event_id, eventType: payload.event_type },
|
{ 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 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;
|
let total = 0;
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
|
|
@ -113,7 +76,7 @@ export async function handleBaserowEvent(
|
||||||
{
|
{
|
||||||
eventId: payload.event_id,
|
eventId: payload.event_id,
|
||||||
eventType: payload.event_type,
|
eventType: payload.event_type,
|
||||||
entity,
|
tableId: payload.table_id,
|
||||||
itemIds,
|
itemIds,
|
||||||
patternsApplied: patterns.length,
|
patternsApplied: patterns.length,
|
||||||
keysInvalidated: total,
|
keysInvalidated: total,
|
||||||
|
|
@ -121,5 +84,5 @@ export async function handleBaserowEvent(
|
||||||
'baserow webhook processed',
|
'baserow webhook processed',
|
||||||
);
|
);
|
||||||
|
|
||||||
return { status: 'processed', entity, invalidatedKeys: total };
|
return { status: 'processed', tableId: payload.table_id, invalidatedKeys: total };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<ConstructorParameters<typeof Attribution>[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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
30
bridge/tests/domain/field.test.ts
Normal file
30
bridge/tests/domain/field.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<ConstructorParameters<typeof Intervention>[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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<Role>(['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<Role>(['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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<ConstructorParameters<typeof Personne>[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<Role>(['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<Role>(['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<Role>(['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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
49
bridge/tests/domain/row.test.ts
Normal file
49
bridge/tests/domain/row.test.ts
Normal file
|
|
@ -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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,125 +1,103 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
AttributionSchema,
|
FieldSchema,
|
||||||
BlocSchema,
|
RowFieldsSchema,
|
||||||
ClientSchema,
|
RowSchema,
|
||||||
FormationSchema,
|
TableSchema,
|
||||||
InterventionSchema,
|
ViewSchema,
|
||||||
ModuleSchema,
|
|
||||||
PersonneSchema,
|
|
||||||
ProjetSchema,
|
|
||||||
TacheSchema,
|
|
||||||
} from '../../src/domain/schemas.js';
|
} from '../../src/domain/schemas.js';
|
||||||
|
|
||||||
describe('schemas zod', () => {
|
describe('FieldSchema', () => {
|
||||||
it('PersonneSchema valide', () => {
|
it('valide un field minimal', () => {
|
||||||
const r = PersonneSchema.parse({
|
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<string, unknown>', () => {
|
||||||
|
const r = FieldSchema.parse({
|
||||||
id: 1,
|
id: 1,
|
||||||
nom: 'Doe',
|
name: 'statut',
|
||||||
prenom: 'John',
|
type: 'single_select',
|
||||||
email: 'john@a.fr',
|
options: { select_options: [{ id: 1 }] },
|
||||||
capaciteAnnuelle: '1000',
|
|
||||||
splitFormationPct: 50,
|
|
||||||
splitAgencePct: 50,
|
|
||||||
roles: ['formateur'],
|
|
||||||
});
|
});
|
||||||
expect(r.statut).toBe('actif');
|
expect(r.options).toEqual({ select_options: [{ id: 1 }] });
|
||||||
expect(r.capaciteAnnuelle).toBe(1000);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PersonneSchema rejette splits qui ne somment pas a 100', () => {
|
describe('ViewSchema', () => {
|
||||||
expect(() =>
|
it('valide une view grid', () => {
|
||||||
PersonneSchema.parse({
|
const r = ViewSchema.parse({ id: 1, name: 'Tous', type: 'grid', tableId: 5 });
|
||||||
id: 1,
|
expect(r.type).toBe('grid');
|
||||||
nom: 'X',
|
});
|
||||||
prenom: 'Y',
|
|
||||||
email: 'x@y.fr',
|
it('accepte un type custom', () => {
|
||||||
capaciteAnnuelle: 1000,
|
const r = ViewSchema.parse({ id: 1, name: 'X', type: 'weird', tableId: 5 });
|
||||||
splitFormationPct: 50,
|
expect(r.type).toBe('weird');
|
||||||
splitAgencePct: 40,
|
});
|
||||||
roles: ['formateur'],
|
|
||||||
}),
|
it('rejette tableId negatif', () => {
|
||||||
).toThrow();
|
expect(() => ViewSchema.parse({ id: 1, name: 'X', type: 'grid', tableId: 0 })).toThrow();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it('PersonneSchema rejette email invalide', () => {
|
|
||||||
expect(() =>
|
describe('TableSchema', () => {
|
||||||
PersonneSchema.parse({
|
it('valide une table minimale', () => {
|
||||||
id: 1,
|
const r = TableSchema.parse({ id: 1, name: 'Personne', databaseId: 5 });
|
||||||
nom: 'X',
|
expect(r.orderIndex).toBe(0);
|
||||||
prenom: 'Y',
|
});
|
||||||
email: 'not-an-email',
|
|
||||||
capaciteAnnuelle: 1000,
|
it('rejette id <= 0', () => {
|
||||||
splitFormationPct: 50,
|
expect(() => TableSchema.parse({ id: 0, name: 'x', databaseId: 1 })).toThrow();
|
||||||
splitAgencePct: 50,
|
});
|
||||||
roles: ['formateur'],
|
});
|
||||||
}),
|
|
||||||
).toThrow();
|
describe('RowSchema', () => {
|
||||||
});
|
it('valide une row avec fields opaques', () => {
|
||||||
|
const r = RowSchema.parse({
|
||||||
it('FormationSchema valide avec defaults', () => {
|
id: 1,
|
||||||
const r = FormationSchema.parse({ id: 1, nom: 'BTS', heuresTotales: 500 });
|
tableId: 5,
|
||||||
expect(r.statut).toBe('draft');
|
fields: { nom: 'x', heures: 40 },
|
||||||
});
|
});
|
||||||
|
expect(r.fields.heures).toBe(40);
|
||||||
it('BlocSchema rejette heuresPrevues negative', () => {
|
});
|
||||||
expect(() =>
|
|
||||||
BlocSchema.parse({ id: 1, formationId: 1, nom: 'B', heuresPrevues: -5 }),
|
it('id 0 est accepte (NEW row temp client-side)', () => {
|
||||||
).toThrow();
|
const r = RowSchema.parse({ id: 0, tableId: 1, fields: {} });
|
||||||
});
|
expect(r.id).toBe(0);
|
||||||
|
});
|
||||||
it('ModuleSchema valide', () => {
|
});
|
||||||
const r = ModuleSchema.parse({ id: 1, blocId: 1, nom: 'M', heuresPrevues: 20 });
|
|
||||||
expect(r.statut).toBe('a_attribuer');
|
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('AttributionSchema rejette heuresAttribuees = 0', () => {
|
});
|
||||||
expect(() =>
|
|
||||||
AttributionSchema.parse({ id: 1, moduleId: 1, personneId: 1, heuresAttribuees: 0 }),
|
it('rejette un non-objet', () => {
|
||||||
).toThrow();
|
expect(() => RowFieldsSchema.parse([1, 2])).toThrow();
|
||||||
});
|
expect(() => RowFieldsSchema.parse('foo')).toThrow();
|
||||||
|
});
|
||||||
it('ClientSchema valide minimal', () => {
|
|
||||||
const r = ClientSchema.parse({ id: 1, nom: 'Acme' });
|
it('accepte un objet vide (PATCH partiel possible)', () => {
|
||||||
expect(r.statut).toBe('prospect');
|
expect(RowFieldsSchema.parse({})).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ProjetSchema valide avec formationId', () => {
|
it('accepte des valeurs nested arbitraires (link_row, select, formula result)', () => {
|
||||||
const r = ProjetSchema.parse({
|
const v = {
|
||||||
id: 1,
|
link: [{ id: 1, value: 'X' }],
|
||||||
clientId: 1,
|
select: { id: 5, value: 'actif', color: 'green' },
|
||||||
nom: 'P',
|
formula_result: 42.5,
|
||||||
chargeHeures: 100,
|
tags: ['a', 'b'],
|
||||||
formationId: 5,
|
};
|
||||||
});
|
expect(RowFieldsSchema.parse(v)).toEqual(v);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
28
bridge/tests/domain/table.test.ts
Normal file
28
bridge/tests/domain/table.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<ConstructorParameters<typeof Personne>[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<Role>(['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<Role>(['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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
17
bridge/tests/domain/view.test.ts
Normal file
17
bridge/tests/domain/view.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<T> {
|
|
||||||
items: T[];
|
|
||||||
meta: { page: number; per_page: number; total: number; total_pages: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeReadRepo<T extends { id: number }> {
|
|
||||||
constructor(
|
|
||||||
private readonly entityName: string,
|
|
||||||
public store: T[] = [],
|
|
||||||
) {}
|
|
||||||
|
|
||||||
list(): Promise<ListResult<T>> {
|
|
||||||
return Promise.resolve({
|
|
||||||
items: this.store,
|
|
||||||
meta: { page: 1, per_page: 200, total: this.store.length, total_pages: 1 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get(id: number): Promise<T> {
|
|
||||||
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<Attribution> {
|
|
||||||
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<Intervention> {
|
|
||||||
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<Personne> & RepoSet['personnes'];
|
|
||||||
formations: FakeReadRepo<Formation> & RepoSet['formations'];
|
|
||||||
blocs: FakeReadRepo<Bloc> & RepoSet['blocs'];
|
|
||||||
modules: FakeReadRepo<Module> & RepoSet['modules'];
|
|
||||||
attributions: FakeAttributionRepo & RepoSet['attributions'];
|
|
||||||
clients: FakeReadRepo<Client> & RepoSet['clients'];
|
|
||||||
projets: FakeReadRepo<Projet> & RepoSet['projets'];
|
|
||||||
taches: FakeReadRepo<Tache> & 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>('Personne', stores.personnes ?? []),
|
|
||||||
formations: new FakeReadRepo<Formation>('Formation', stores.formations ?? []),
|
|
||||||
blocs: new FakeReadRepo<Bloc>('Bloc', stores.blocs ?? []),
|
|
||||||
modules: new FakeReadRepo<Module>('Module', stores.modules ?? []),
|
|
||||||
attributions: new FakeAttributionRepo(stores.attributions ?? []),
|
|
||||||
clients: new FakeReadRepo<Client>('Client', stores.clients ?? []),
|
|
||||||
projets: new FakeReadRepo<Projet>('Projet', stores.projets ?? []),
|
|
||||||
taches: new FakeReadRepo<Tache>('Tache', stores.taches ?? []),
|
|
||||||
interventions: new FakeInterventionRepo(stores.interventions ?? []),
|
|
||||||
} as unknown as FakeReposBundle;
|
|
||||||
}
|
|
||||||
|
|
@ -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<Role>(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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Test helper : construit une app Hono iso-prod avec un container minimal en memoire.
|
* Test helper : construit une app Hono iso-prod avec un container minimal en
|
||||||
* Pas de testcontainers ici — les routes utilisent les repos qu'on mock dans chaque suite.
|
* 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 { Hono } from 'hono';
|
||||||
import { logger as honoLogger } from 'hono/logger';
|
import { logger as honoLogger } from 'hono/logger';
|
||||||
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
|
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
|
||||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||||
import type { Personne } from '../../src/domain/personne.js';
|
import type { Container, RepoSet } from '../../src/lib/container.js';
|
||||||
import type { Container } from '../../src/lib/container.js';
|
|
||||||
import { setContainer } from '../../src/lib/container.js';
|
import { setContainer } from '../../src/lib/container.js';
|
||||||
import { logger } from '../../src/lib/logger.js';
|
import { logger } from '../../src/lib/logger.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,42 +18,16 @@ import {
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
} from '../../src/middleware/auth.js';
|
} from '../../src/middleware/auth.js';
|
||||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||||
import type { RepoSet, TableIds } from '../../src/repos/baserow-repo.js';
|
import { tablesRoutes } from '../../src/routes/tables.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 { webhooksRoutes } from '../../src/routes/webhooks.js';
|
import { webhooksRoutes } from '../../src/routes/webhooks.js';
|
||||||
|
|
||||||
const FAKE_TABLE_IDS: TableIds = {
|
export const READ_TOKEN = 'brg_read';
|
||||||
personne: 1,
|
export const WRITE_TOKEN = 'brg_write';
|
||||||
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 ADMIN_TOKEN = 'brg_admin';
|
export const ADMIN_TOKEN = 'brg_admin';
|
||||||
|
|
||||||
export const TEST_TOKENS: ApiTokenRecord[] = [
|
export const TEST_TOKENS: ApiTokenRecord[] = [
|
||||||
{
|
{ token: READ_TOKEN, name: 'test-read', scopes: ['read:tables'] },
|
||||||
token: READ_ALL_TOKEN,
|
{ token: WRITE_TOKEN, name: 'test-write', scopes: ['read:tables', 'write:tables'] },
|
||||||
name: 'test-read',
|
|
||||||
scopes: ['read:personnes', 'read:formations', 'read:projets'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
token: WRITE_ALL_TOKEN,
|
|
||||||
name: 'test-write',
|
|
||||||
scopes: ['write:attributions', 'write:interventions'],
|
|
||||||
},
|
|
||||||
{ token: ADMIN_TOKEN, name: 'test-admin', scopes: ['admin:*'] },
|
{ 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',
|
baserowWebhookSecret: 'fake_secret_at_least_16_chars',
|
||||||
docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars',
|
docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars',
|
||||||
bridgeApiTokens: undefined,
|
bridgeApiTokens: undefined,
|
||||||
authStrictMapping: true,
|
|
||||||
rateLimitGlobalMax: 10000,
|
rateLimitGlobalMax: 10000,
|
||||||
rateLimitGlobalWindow: 60,
|
rateLimitGlobalWindow: 60,
|
||||||
rateLimitMutationMax: 10000,
|
rateLimitMutationMax: 10000,
|
||||||
|
|
@ -102,7 +76,6 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
||||||
redis: fakeRedis,
|
redis: fakeRedis,
|
||||||
repos: over.repos,
|
repos: over.repos,
|
||||||
tokens: tokensMap,
|
tokens: tokensMap,
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
oidc: null,
|
oidc: null,
|
||||||
groupsScopesMap: {},
|
groupsScopesMap: {},
|
||||||
logger,
|
logger,
|
||||||
|
|
@ -111,14 +84,6 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP_CACHE = {
|
|
||||||
get: async <T>(_key: string): Promise<T | null> => null,
|
|
||||||
set: async <T>(_key: string, _value: T, _ttl?: number): Promise<void> => {},
|
|
||||||
};
|
|
||||||
const NOOP_FINDER = {
|
|
||||||
findByEmail: async (_email: string): Promise<Personne | null> => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resetTestContainer(): void {
|
export function resetTestContainer(): void {
|
||||||
setContainer(null);
|
setContainer(null);
|
||||||
}
|
}
|
||||||
|
|
@ -139,18 +104,10 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab
|
||||||
tokens: container.tokens,
|
tokens: container.tokens,
|
||||||
oidc: container.oidc,
|
oidc: container.oidc,
|
||||||
groupsScopesMap: container.groupsScopesMap,
|
groupsScopesMap: container.groupsScopesMap,
|
||||||
strictMapping: container.config.authStrictMapping,
|
|
||||||
cache: NOOP_CACHE,
|
|
||||||
finder: NOOP_FINDER,
|
|
||||||
logger,
|
logger,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
v1.route('/personnes', personnesRoutes);
|
v1.route('/tables', tablesRoutes);
|
||||||
v1.route('/formations', formationsRoutes);
|
|
||||||
v1.route('/projets', projetsRoutes);
|
|
||||||
v1.route('/modules', modulesRoutes);
|
|
||||||
v1.route('/interventions', interventionsRoutes);
|
|
||||||
v1.route('/attributions', attributionsRoutes);
|
|
||||||
app.route('/api/v1', v1);
|
app.route('/api/v1', v1);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -3,39 +3,22 @@
|
||||||
* PAS sur /api/health, /api/ready, /api/webhooks/*. Et que l'invalidation
|
* PAS sur /api/health, /api/ready, /api/webhooks/*. Et que l'invalidation
|
||||||
* cache est declenchee apres POST/PATCH/PUT/DELETE sur les routes mutation.
|
* cache est declenchee apres POST/PATCH/PUT/DELETE sur les routes mutation.
|
||||||
*
|
*
|
||||||
* On reconstitue une mini app proche de buildApp() (sans serve()) avec un
|
* R1 — Reecrit pour /api/v1/tables/:id/rows (proxy generique).
|
||||||
* fake Redis qui compte les calls invalidatePattern + checkRateLimit.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Decimal } from 'decimal.js';
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
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 { setContainer } from '../../src/lib/container.js';
|
||||||
|
import { errors } from '../../src/lib/errors.js';
|
||||||
import { logger } from '../../src/lib/logger.js';
|
import { logger } from '../../src/lib/logger.js';
|
||||||
import { type AuthVariables, authMiddleware } from '../../src/middleware/auth.js';
|
import { type AuthVariables, authMiddleware } from '../../src/middleware/auth.js';
|
||||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||||
import { defaultRateLimitKey, rateLimit } from '../../src/middleware/rate-limit.js';
|
import { defaultRateLimitKey, rateLimit } from '../../src/middleware/rate-limit.js';
|
||||||
import { attributionsRoutes } from '../../src/routes/attributions.js';
|
import { tablesRoutes } from '../../src/routes/tables.js';
|
||||||
import { interventionsRoutes } from '../../src/routes/interventions.js';
|
|
||||||
import { modulesRoutes } from '../../src/routes/modules.js';
|
|
||||||
import { personnesRoutes } from '../../src/routes/personnes.js';
|
|
||||||
import { webhooksRoutes } from '../../src/routes/webhooks.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 {
|
class FakeRedis {
|
||||||
public invalidations: string[] = [];
|
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<Row> {
|
||||||
|
if (rowId === 9999) throw errors.notFound('Row', rowId);
|
||||||
|
return new Row({ id: rowId, tableId, fields: { nom: 'X' } });
|
||||||
|
}
|
||||||
|
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
|
||||||
|
return new Row({ id: 1000, tableId, fields });
|
||||||
|
}
|
||||||
|
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
|
||||||
|
return new Row({ id: rowId, tableId, fields });
|
||||||
|
}
|
||||||
|
async delete(_tableId: number, _rowId: number): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
const READ_TOKEN = 'brg_read';
|
const READ_TOKEN = 'brg_read';
|
||||||
const WRITE_TOKEN = 'brg_write';
|
const WRITE_TOKEN = 'brg_write';
|
||||||
|
|
||||||
function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
||||||
const personne = makePersonne({ id: 1, roles: ['formateur'] });
|
const repos: RepoSet = {
|
||||||
const attribution = makeAttribution({ id: 500 });
|
tables: {} as RepoSet['tables'],
|
||||||
const repos = buildFakeRepos({ personnes: [personne], attributions: [attribution] });
|
fields: {} as RepoSet['fields'],
|
||||||
|
views: {} as RepoSet['views'],
|
||||||
|
rows: new FakeRowsRepo() as unknown as RepoSet['rows'],
|
||||||
|
};
|
||||||
|
|
||||||
setContainer({
|
setContainer({
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -81,7 +87,6 @@ function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationM
|
||||||
baserowWebhookSecret: 'fake-secret-at-least-16-chars-long',
|
baserowWebhookSecret: 'fake-secret-at-least-16-chars-long',
|
||||||
docmostWebhookSecret: undefined,
|
docmostWebhookSecret: undefined,
|
||||||
bridgeApiTokens: undefined,
|
bridgeApiTokens: undefined,
|
||||||
authStrictMapping: true,
|
|
||||||
rateLimitGlobalMax: opts.globalMax,
|
rateLimitGlobalMax: opts.globalMax,
|
||||||
rateLimitGlobalWindow: 60,
|
rateLimitGlobalWindow: 60,
|
||||||
rateLimitMutationMax: opts.mutationMax,
|
rateLimitMutationMax: opts.mutationMax,
|
||||||
|
|
@ -92,18 +97,16 @@ function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationM
|
||||||
redis: redis as unknown as RedisCache,
|
redis: redis as unknown as RedisCache,
|
||||||
repos,
|
repos,
|
||||||
tokens: new Map([
|
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,
|
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,
|
oidc: null,
|
||||||
groupsScopesMap: {},
|
groupsScopesMap: {},
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
return repos;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
||||||
|
|
@ -119,20 +122,14 @@ function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; muta
|
||||||
'*',
|
'*',
|
||||||
authMiddleware({
|
authMiddleware({
|
||||||
tokens: new Map([
|
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,
|
WRITE_TOKEN,
|
||||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] },
|
{ token: WRITE_TOKEN, name: 'writer', scopes: ['read:tables', 'write:tables'] },
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
oidc: null,
|
oidc: null,
|
||||||
groupsScopesMap: {},
|
groupsScopesMap: {},
|
||||||
strictMapping: true,
|
|
||||||
cache: {
|
|
||||||
get: async () => null,
|
|
||||||
set: async () => {},
|
|
||||||
},
|
|
||||||
finder: { findByEmail: async (): Promise<Personne | null> => null },
|
|
||||||
logger,
|
logger,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -149,17 +146,14 @@ function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; muta
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
v1.route('/personnes', personnesRoutes);
|
v1.route('/tables', tablesRoutes);
|
||||||
v1.route('/modules', modulesRoutes);
|
|
||||||
v1.route('/interventions', interventionsRoutes);
|
|
||||||
v1.route('/attributions', attributionsRoutes);
|
|
||||||
app.route('/api/v1', v1);
|
app.route('/api/v1', v1);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => setContainer(null));
|
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 () => {
|
it('GET /api/health : pas de rate limit (route publique)', async () => {
|
||||||
const redis = new FakeRedis();
|
const redis = new FakeRedis();
|
||||||
installContainer(redis, { globalMax: 1, mutationMax: 1 });
|
installContainer(redis, { globalMax: 1, mutationMax: 1 });
|
||||||
|
|
@ -197,12 +191,12 @@ describe('Rate limit application sur /api/v1/*', () => {
|
||||||
expect(redis.rateChecks).toHaveLength(0);
|
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();
|
const redis = new FakeRedis();
|
||||||
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
||||||
const app = buildAppWithRateLimit(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}` },
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(200);
|
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 app = buildAppWithRateLimit(redis, { globalMax: 2, mutationMax: 100 });
|
||||||
|
|
||||||
const headers = { Authorization: `Bearer ${READ_TOKEN}` };
|
const headers = { Authorization: `Bearer ${READ_TOKEN}` };
|
||||||
const r1 = 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/personnes', { headers });
|
const r2 = await app.request('/api/v1/tables/5/rows', { headers });
|
||||||
const r3 = await app.request('/api/v1/personnes', { headers });
|
const r3 = await app.request('/api/v1/tables/5/rows', { headers });
|
||||||
|
|
||||||
expect(r1.status).toBe(200);
|
expect(r1.status).toBe(200);
|
||||||
expect(r2.status).toBe(200);
|
expect(r2.status).toBe(200);
|
||||||
expect(r3.status).toBe(429);
|
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();
|
const redis = new FakeRedis();
|
||||||
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
||||||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 });
|
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 });
|
||||||
|
|
||||||
const r = await app.request('/api/v1/attributions/500/heures-realisees', {
|
const r = await app.request('/api/v1/tables/5/rows', {
|
||||||
method: 'PATCH',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||||
'Content-Type': 'application/json',
|
'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.
|
// Deux compteurs Redis distincts : token:writer et token:writer:mut.
|
||||||
const keys = redis.rateChecks.map((c) => c.key);
|
const keys = redis.rateChecks.map((c) => c.key);
|
||||||
expect(keys).toContain('token:writer');
|
expect(keys).toContain('token:writer');
|
||||||
expect(keys).toContain('token:writer:mut');
|
expect(keys).toContain('token:writer:mut');
|
||||||
|
|
||||||
// Cache invalidation : attribution row + list + cascade module + personne.
|
// Cache invalidation generique : juste la table touchee + sa row.
|
||||||
expect(redis.invalidations).toContain('bridge:attribution:list:*');
|
expect(redis.invalidations).toContain('bridge:tables:5:list:*');
|
||||||
expect(redis.invalidations).toContain('bridge:attribution:row:500');
|
expect(redis.invalidations).toContain('bridge:tables:5:views:*');
|
||||||
expect(redis.invalidations).toContain('bridge:module:list:*');
|
expect(redis.invalidations.some((p) => p.startsWith('bridge:tables:5:row:'))).toBe(true);
|
||||||
expect(redis.invalidations).toContain('bridge:personne:list:*');
|
// 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 () => {
|
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 app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 1 });
|
||||||
|
|
||||||
const headers = { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json' };
|
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', {
|
const r1 = await app.request('/api/v1/tables/5/rows', { method: 'POST', headers, body });
|
||||||
method: 'PATCH',
|
const r2 = await app.request('/api/v1/tables/5/rows', { method: 'POST', headers, body });
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const r2 = await app.request('/api/v1/attributions/500/heures-realisees', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(r1.status).toBe(200);
|
expect(r1.status).toBe(201);
|
||||||
expect(r2.status).toBe(429);
|
expect(r2.status).toBe(429);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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
|
* 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 Server, createServer } from 'node:http';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { Decimal } from 'decimal.js';
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { Personne } from '../../src/domain/personne.js';
|
|
||||||
import { logger } from '../../src/lib/logger.js';
|
import { logger } from '../../src/lib/logger.js';
|
||||||
import {
|
import {
|
||||||
type ApiTokenRecord,
|
type ApiTokenRecord,
|
||||||
type AuthVariables,
|
type AuthVariables,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
extractPermissions,
|
||||||
hasScope,
|
hasScope,
|
||||||
parseTokens,
|
parseTokens,
|
||||||
requireScope,
|
requireScope,
|
||||||
|
|
@ -86,57 +88,6 @@ async function signJwt(
|
||||||
return builder.sign(fx.privateKey);
|
return builder.sign(fx.privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fakes pour cache + finder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class FakeCache {
|
|
||||||
store = new Map<string, unknown>();
|
|
||||||
hits = 0;
|
|
||||||
setCalls = 0;
|
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
|
||||||
if (this.store.has(key)) {
|
|
||||||
this.hits += 1;
|
|
||||||
return this.store.get(key) as T;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async set<T>(key: string, value: T, _ttl?: number): Promise<void> {
|
|
||||||
this.setCalls += 1;
|
|
||||||
this.store.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeFinder {
|
|
||||||
byEmail = new Map<string, Personne>();
|
|
||||||
calls = 0;
|
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<Personne | null> {
|
|
||||||
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
|
// App builder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -144,9 +95,6 @@ function makePersonne(opts: {
|
||||||
interface BuildAppOpts {
|
interface BuildAppOpts {
|
||||||
tokens?: ApiTokenRecord[];
|
tokens?: ApiTokenRecord[];
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
strictMapping?: boolean;
|
|
||||||
cache?: FakeCache;
|
|
||||||
finder?: FakeFinder;
|
|
||||||
jwks?: JwksFixture;
|
jwks?: JwksFixture;
|
||||||
groupsScopesMap?: Record<string, string[]>;
|
groupsScopesMap?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
@ -156,9 +104,6 @@ function buildApp(opts: BuildAppOpts) {
|
||||||
const map = new Map<string, ApiTokenRecord>();
|
const map = new Map<string, ApiTokenRecord>();
|
||||||
for (const t of tokens) map.set(t.token, t);
|
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;
|
let verifier: OidcVerifier | null = null;
|
||||||
if (opts.oidcEnabled) {
|
if (opts.oidcEnabled) {
|
||||||
if (!opts.jwks) throw new Error('jwks fixture required when oidcEnabled');
|
if (!opts.jwks) throw new Error('jwks fixture required when oidcEnabled');
|
||||||
|
|
@ -179,9 +124,6 @@ function buildApp(opts: BuildAppOpts) {
|
||||||
tokens: map,
|
tokens: map,
|
||||||
oidc: verifier,
|
oidc: verifier,
|
||||||
groupsScopesMap: opts.groupsScopesMap ?? {},
|
groupsScopesMap: opts.groupsScopesMap ?? {},
|
||||||
strictMapping: opts.strictMapping ?? true,
|
|
||||||
cache,
|
|
||||||
finder,
|
|
||||||
logger,
|
logger,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -190,12 +132,12 @@ function buildApp(opts: BuildAppOpts) {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
return c.json({ 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 }),
|
c.json({ ok: true, scopes: c.get('user').scopes }),
|
||||||
);
|
);
|
||||||
app.get('/protected/needs-admin', requireScope('admin:write'), (c) => c.json({ ok: true }));
|
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', () => {
|
describe('parseTokens', () => {
|
||||||
it('parse JSON valide', () => {
|
it('parse JSON valide', () => {
|
||||||
const map = parseTokens(
|
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');
|
expect(map.get('brg_x')?.name).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
@ -232,21 +174,39 @@ describe('parseTokens', () => {
|
||||||
|
|
||||||
describe('hasScope', () => {
|
describe('hasScope', () => {
|
||||||
it('match exact', () => {
|
it('match exact', () => {
|
||||||
expect(hasScope(new Set(['read:personnes']), 'read:personnes')).toBe(true);
|
expect(hasScope(new Set(['read:tables']), 'read:tables')).toBe(true);
|
||||||
expect(hasScope(new Set(['read:personnes']), 'read:projets')).toBe(false);
|
expect(hasScope(new Set(['read:tables']), 'write:tables')).toBe(false);
|
||||||
});
|
});
|
||||||
it('admin:* couvre tout', () => {
|
it('admin:* couvre tout', () => {
|
||||||
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
|
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
|
||||||
});
|
});
|
||||||
it('prefix wildcard couvre meme prefix', () => {
|
it('prefix wildcard couvre meme prefix', () => {
|
||||||
expect(hasScope(new Set(['read:*']), 'read:personnes')).toBe(true);
|
expect(hasScope(new Set(['read:*']), 'read:tables')).toBe(true);
|
||||||
expect(hasScope(new Set(['read:*']), 'write:personnes')).toBe(false);
|
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[] = [
|
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 () => {
|
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.source).toBe('service-token');
|
||||||
expect(body.user.tokenId).toBe('demo');
|
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 () => {
|
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;
|
let jwks: JwksFixture;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
@ -331,18 +291,13 @@ describe('auth middleware — OIDC actif', () => {
|
||||||
await new Promise<void>((resolve) => jwks.server.close(() => resolve()));
|
await new Promise<void>((resolve) => jwks.server.close(() => resolve()));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cas 4 — JWT valid + email -> Personne -> 200, source=oidc-jwt, roles', async () => {
|
it('cas 4 — JWT valid + claim acadenice_permissions[] -> 200, scopes alimentes directement', async () => {
|
||||||
const finder = new FakeFinder();
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||||
finder.byEmail.set(
|
|
||||||
'jane@acadenice.fr',
|
|
||||||
makePersonne({ id: 42, email: 'jane@acadenice.fr', roles: ['formateur'] }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { app } = buildApp({ oidcEnabled: true, jwks, finder });
|
|
||||||
const token = await signJwt(jwks, {
|
const token = await signJwt(jwks, {
|
||||||
email: 'jane@acadenice.fr',
|
email: 'jane@acadenice.fr',
|
||||||
sub: 'authentik-jane-uuid',
|
sub: 'authentik-jane-uuid',
|
||||||
groups: ['formation-hub-formateurs'],
|
groups: [],
|
||||||
|
acadenice_permissions: ['read:tables', 'write:tables'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request('/protected/me', {
|
const res = await app.request('/protected/me', {
|
||||||
|
|
@ -350,22 +305,80 @@ describe('auth middleware — OIDC actif', () => {
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = (await res.json()) as {
|
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.source).toBe('oidc-jwt');
|
||||||
expect(body.user.personneId).toBe(42);
|
expect(body.user.email).toBe('jane@acadenice.fr');
|
||||||
expect(body.user.roles).toContain('formateur');
|
|
||||||
expect(body.user.sub).toBe('authentik-jane-uuid');
|
expect(body.user.sub).toBe('authentik-jane-uuid');
|
||||||
// Default role->scope mapping pour formateur inclut formation:* style write:attributions
|
expect(body.user.scopes).toContain('read:tables');
|
||||||
expect(body.user.scopes).toContain('write:attributions');
|
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 () => {
|
it('cas 5 — JWT signature invalide -> 401 AUTH_INVALID', async () => {
|
||||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||||
const token = await signJwt(jwks, { email: 'x@y.z' });
|
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 tampered = `${token.slice(0, -16)}AAAAAAAAAAAAAAAA`;
|
||||||
const res = await app.request('/protected/me', {
|
const res = await app.request('/protected/me', {
|
||||||
headers: { Authorization: `Bearer ${tampered}` },
|
headers: { Authorization: `Bearer ${tampered}` },
|
||||||
|
|
@ -402,96 +415,42 @@ describe('auth middleware — OIDC actif', () => {
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cas 9 — JWT email orphelin (mode strict) -> 403 FORBIDDEN', async () => {
|
it('cas 9 — Cookie authToken valid -> 200, source=oidc-cookie', async () => {
|
||||||
const { app, finder } = buildApp({ oidcEnabled: true, jwks, strictMapping: true });
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||||
const token = await signJwt(jwks, {
|
const token = await signJwt(jwks, {
|
||||||
email: 'nobody@acadenice.fr',
|
email: 'cookie@acadenice.fr',
|
||||||
sub: 'authentik-nobody',
|
acadenice_permissions: ['read:tables'],
|
||||||
groups: [],
|
|
||||||
});
|
});
|
||||||
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', {
|
const res = await app.request('/protected/me', {
|
||||||
headers: { Cookie: `authToken=${token}` },
|
headers: { Cookie: `authToken=${token}` },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
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.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 () => {
|
it('cas 10 — requireScope match via permissions claim -> 200', async () => {
|
||||||
const finder = new FakeFinder();
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||||
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'] },
|
|
||||||
});
|
|
||||||
const token = await signJwt(jwks, {
|
const token = await signJwt(jwks, {
|
||||||
email: 'fmt@acadenice.fr',
|
email: 'a@b.c',
|
||||||
groups: ['formation-hub-formateurs'],
|
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}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = (await res.json()) as { ok: boolean; scopes: string[] };
|
const body = (await res.json()) as { ok: boolean; scopes: string[] };
|
||||||
expect(body.ok).toBe(true);
|
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 () => {
|
it('cas 11 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => {
|
||||||
const finder = new FakeFinder();
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||||
finder.byEmail.set(
|
const token = await signJwt(jwks, {
|
||||||
'fmt2@acadenice.fr',
|
email: 'a@b.c',
|
||||||
makePersonne({ id: 2, email: 'fmt2@acadenice.fr', roles: ['formateur'] }),
|
acadenice_permissions: ['read:tables'],
|
||||||
);
|
});
|
||||||
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.
|
|
||||||
const res = await app.request('/protected/needs-admin', {
|
const res = await app.request('/protected/needs-admin', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
@ -499,42 +458,4 @@ describe('auth middleware — OIDC actif', () => {
|
||||||
const body = (await res.json()) as { error: { code: string } };
|
const body = (await res.json()) as { error: { code: string } };
|
||||||
expect(body.error.code).toBe('FORBIDDEN_SCOPE');
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ interface FakeUser {
|
||||||
tokenId?: string;
|
tokenId?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
roles: string[];
|
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
permissions: string[];
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ describe('rateLimit middleware', () => {
|
||||||
{
|
{
|
||||||
source: 'service-token',
|
source: 'service-token',
|
||||||
tokenId: 'svc-A',
|
tokenId: 'svc-A',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
},
|
},
|
||||||
|
|
@ -111,7 +111,7 @@ describe('rateLimit middleware', () => {
|
||||||
{
|
{
|
||||||
source: 'service-token',
|
source: 'service-token',
|
||||||
tokenId: 'svc-B',
|
tokenId: 'svc-B',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
},
|
},
|
||||||
|
|
@ -134,7 +134,7 @@ describe('rateLimit middleware', () => {
|
||||||
const user: FakeUser = {
|
const user: FakeUser = {
|
||||||
source: 'service-token',
|
source: 'service-token',
|
||||||
tokenId: 'svc-C',
|
tokenId: 'svc-C',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
};
|
};
|
||||||
|
|
@ -181,7 +181,7 @@ describe('rateLimit middleware', () => {
|
||||||
tokenId: 'svc-D',
|
tokenId: 'svc-D',
|
||||||
email: 'should-not-be-used@test',
|
email: 'should-not-be-used@test',
|
||||||
sub: 'sub-x',
|
sub: 'sub-x',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
},
|
},
|
||||||
|
|
@ -200,7 +200,7 @@ describe('rateLimit middleware', () => {
|
||||||
source: 'oidc-jwt',
|
source: 'oidc-jwt',
|
||||||
email: 'Foo@Bar.IO',
|
email: 'Foo@Bar.IO',
|
||||||
sub: 'sub-y',
|
sub: 'sub-y',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
},
|
},
|
||||||
|
|
@ -219,7 +219,7 @@ describe('rateLimit middleware', () => {
|
||||||
{
|
{
|
||||||
source: 'oidc-jwt',
|
source: 'oidc-jwt',
|
||||||
sub: 'sub-z',
|
sub: 'sub-z',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
},
|
},
|
||||||
|
|
@ -258,7 +258,7 @@ describe('rateLimit middleware', () => {
|
||||||
const app = buildApp(
|
const app = buildApp(
|
||||||
limiter,
|
limiter,
|
||||||
{ max: 5, window: 60, keyFrom: () => 'custom-key' },
|
{ 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('/');
|
await app.request('/');
|
||||||
|
|
@ -273,7 +273,7 @@ describe('rateLimit middleware', () => {
|
||||||
{
|
{
|
||||||
source: 'service-token',
|
source: 'service-token',
|
||||||
tokenId: 'svc-F',
|
tokenId: 'svc-F',
|
||||||
roles: [],
|
permissions: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import { computeOidcScopes, parseGroupsScopesMap } from '../../src/middleware/scopes.js';
|
||||||
DEFAULT_ROLE_SCOPES,
|
|
||||||
computeOidcScopes,
|
|
||||||
parseGroupsScopesMap,
|
|
||||||
} from '../../src/middleware/scopes.js';
|
|
||||||
|
|
||||||
describe('parseGroupsScopesMap', () => {
|
describe('parseGroupsScopesMap', () => {
|
||||||
it('retourne {} si vide', () => {
|
it('retourne {} si vide', () => {
|
||||||
|
|
@ -31,32 +27,40 @@ describe('parseGroupsScopesMap', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('computeOidcScopes', () => {
|
describe('computeOidcScopes (R1 generique)', () => {
|
||||||
it('union groups + roles + dedup', () => {
|
it('union groups + permissions + dedup', () => {
|
||||||
const scopes = computeOidcScopes(['formation-hub-formateurs'], new Set(['formateur']), {
|
const scopes = computeOidcScopes(['group-formateurs'], ['custom:perm', 'read:tables'], {
|
||||||
'formation-hub-formateurs': ['formation:read', 'admin:custom'],
|
'group-formateurs': ['read:tables', 'admin:custom'],
|
||||||
});
|
});
|
||||||
expect(scopes).toContain('formation:read');
|
expect(scopes).toContain('read:tables');
|
||||||
expect(scopes).toContain('admin:custom');
|
expect(scopes).toContain('admin:custom');
|
||||||
// Vient du DEFAULT_ROLE_SCOPES.formateur
|
expect(scopes).toContain('custom:perm');
|
||||||
expect(scopes).toContain('write:attributions');
|
// Dedup : read:tables apparait une seule fois
|
||||||
|
expect(scopes.filter((s) => s === 'read:tables')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("group inconnu ignore (pas d'erreur)", () => {
|
it("group inconnu ignore (pas d'erreur)", () => {
|
||||||
const scopes = computeOidcScopes(['unknown-group'], new Set(), {});
|
const scopes = computeOidcScopes(['unknown-group'], [], {});
|
||||||
expect(scopes).toEqual([]);
|
expect(scopes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('default mapping admin role -> admin:*', () => {
|
it('aucun group + aucune permission = scopes vides', () => {
|
||||||
const scopes = computeOidcScopes([], new Set(['admin']), {});
|
expect(computeOidcScopes([], [], {})).toEqual([]);
|
||||||
expect(scopes).toContain('admin:*');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aucun group + aucun role = scopes vides', () => {
|
it('permissions explicites sans group fonctionnent (claim direct du JWT)', () => {
|
||||||
expect(computeOidcScopes([], new Set(), {})).toEqual([]);
|
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', () => {
|
it('ignore les permissions non-strings ou vides', () => {
|
||||||
expect(Object.keys(DEFAULT_ROLE_SCOPES)).toHaveLength(5);
|
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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
51
bridge/tests/repos/baserow-fields-repo.test.ts
Normal file
51
bridge/tests/repos/baserow-fields-repo.test.ts
Normal file
|
|
@ -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<string, unknown>
|
||||||
|
> = [],
|
||||||
|
) {}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<number, BaserowRow[]>): BaserowClient {
|
|
||||||
return {
|
|
||||||
listRows: vi.fn(
|
|
||||||
(tableId: number): Promise<BaserowPaginatedResponse> =>
|
|
||||||
Promise.resolve({
|
|
||||||
count: rowsByTable[tableId]?.length ?? 0,
|
|
||||||
next: null,
|
|
||||||
previous: null,
|
|
||||||
results: rowsByTable[tableId] ?? [],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
getRow: vi.fn((tableId: number, rowId: number): Promise<BaserowRow> => {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
133
bridge/tests/repos/baserow-rows-repo.test.ts
Normal file
133
bridge/tests/repos/baserow-rows-repo.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||||
|
return Promise.resolve({ id: 1000, order: '1.0', ...data } as BaserowRow);
|
||||||
|
}
|
||||||
|
updateRow(_tableId: number, rowId: number, data: Record<string, unknown>) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
bridge/tests/repos/baserow-tables-repo.test.ts
Normal file
51
bridge/tests/repos/baserow-tables-repo.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
bridge/tests/repos/baserow-views-repo.test.ts
Normal file
62
bridge/tests/repos/baserow-views-repo.test.ts
Normal file
|
|
@ -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<string, unknown>
|
||||||
|
> = [],
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<typeof buildTestApp>,
|
|
||||||
id: number,
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<typeof buildTestApp>, body: Record<string, unknown>) {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<typeof buildTestApp>,
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
525
bridge/tests/routes/tables.test.ts
Normal file
525
bridge/tests/routes/tables.test.ts
Normal file
|
|
@ -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<number, Table[]> = new Map(),
|
||||||
|
public tablesById: Map<number, Table> = new Map(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(databaseId: number): Promise<Table[]> {
|
||||||
|
this.listCalls.push(databaseId);
|
||||||
|
if (this.failOnList) {
|
||||||
|
throw errors.authInvalid();
|
||||||
|
}
|
||||||
|
return this.tablesByDb.get(databaseId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(tableId: number): Promise<Table> {
|
||||||
|
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<number, Field[]> = new Map()) {}
|
||||||
|
async list(tableId: number): Promise<Field[]> {
|
||||||
|
return this.fieldsByTable.get(tableId) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeViewsRepo {
|
||||||
|
constructor(
|
||||||
|
public viewsByTable: Map<number, View[]> = new Map(),
|
||||||
|
public rowsByView: Map<number, Row[]> = new Map(),
|
||||||
|
) {}
|
||||||
|
async list(tableId: number): Promise<View[]> {
|
||||||
|
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<string, unknown> };
|
||||||
|
public lastUpdate?: { tableId: number; rowId: number; fields: Record<string, unknown> };
|
||||||
|
public lastDelete?: { tableId: number; rowId: number };
|
||||||
|
public nextId = 1000;
|
||||||
|
|
||||||
|
constructor(public rowsByTable: Map<number, Row[]> = 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<Row> {
|
||||||
|
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<string, unknown>): Promise<Row> {
|
||||||
|
this.lastCreate = { tableId, fields };
|
||||||
|
const id = this.nextId++;
|
||||||
|
return new Row({ id, tableId, fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
|
||||||
|
this.lastUpdate = { tableId, rowId, fields };
|
||||||
|
return new Row({ id: rowId, tableId, fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(tableId: number, rowId: number): Promise<void> {
|
||||||
|
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<typeof buildFakeRepos>[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<string, unknown> }>;
|
||||||
|
};
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
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<string, unknown> } };
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,134 +1,80 @@
|
||||||
/**
|
/**
|
||||||
* Tests unit pour invalidateEntity — verifie les patterns generes par entite,
|
* Tests unit pour invalidateTable — verifie les patterns generes (generique
|
||||||
* la cascade rollups parent, et l'idempotence (deux invalidations meme key).
|
* style Notion, plus de cascade rollup metier).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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 {
|
class FakeRedis implements CacheInvalidator {
|
||||||
public patterns: string[] = [];
|
public patterns: string[] = [];
|
||||||
// Map pour simuler des keys persistees (incrementee a chaque set fictif).
|
|
||||||
public callCount = 0;
|
|
||||||
|
|
||||||
async invalidatePattern(pattern: string): Promise<number> {
|
async invalidatePattern(pattern: string): Promise<number> {
|
||||||
this.patterns.push(pattern);
|
this.patterns.push(pattern);
|
||||||
this.callCount++;
|
return 1;
|
||||||
return 1; // un match fictif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('invalidateEntity', () => {
|
describe('invalidateTable', () => {
|
||||||
it('attribution : cascade sur module + personne (rollups RG-01)', async () => {
|
it('avec rowId : invalide list + views + row precis', async () => {
|
||||||
const redis = new FakeRedis();
|
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:tables:42:list:*');
|
||||||
expect(redis.patterns).toContain('bridge:attribution:row:42');
|
expect(redis.patterns).toContain('bridge:tables:42:views:*');
|
||||||
expect(redis.patterns).toContain('bridge:module:row:*');
|
expect(redis.patterns).toContain('bridge:tables:42:row:100');
|
||||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
expect(redis.patterns).toHaveLength(3);
|
||||||
expect(redis.patterns).toContain('bridge:personne:row:*');
|
|
||||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('intervention : cascade sur tache + personne', async () => {
|
it('sans rowId : invalide list + views uniquement', async () => {
|
||||||
const redis = new FakeRedis();
|
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:tables:42:list:*');
|
||||||
expect(redis.patterns).toContain('bridge:intervention:row:100');
|
expect(redis.patterns).toContain('bridge:tables:42:views:*');
|
||||||
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).toHaveLength(2);
|
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();
|
const redis = new FakeRedis();
|
||||||
await invalidateEntity(redis, 'formation', 9);
|
await invalidateTable(redis, 42, 1);
|
||||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
// Aucun pattern d'autre table.
|
||||||
expect(redis.patterns).toContain('bridge:formation:row:9');
|
expect(redis.patterns.every((p) => p.startsWith('bridge:tables:42:'))).toBe(true);
|
||||||
expect(redis.patterns).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('client : pas de cascade parent', async () => {
|
it('idempotent : deux invalidations meme key', async () => {
|
||||||
const redis = new FakeRedis();
|
const redis = new FakeRedis();
|
||||||
await invalidateEntity(redis, 'client', 4);
|
await invalidateTable(redis, 42, 100);
|
||||||
expect(redis.patterns).toContain('bridge:client:list:*');
|
await expect(invalidateTable(redis, 42, 100)).resolves.toBeGreaterThanOrEqual(0);
|
||||||
expect(redis.patterns).toContain('bridge:client:row:4');
|
expect(redis.patterns.filter((p) => p === 'bridge:tables:42:row:100')).toHaveLength(2);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retourne le total des keys invalidees', async () => {
|
it('retourne le total des keys invalidees', async () => {
|
||||||
const redis = new FakeRedis();
|
const redis = new FakeRedis();
|
||||||
const total = await invalidateEntity(redis, 'attribution', 1);
|
const total = await invalidateTable(redis, 42, 1);
|
||||||
// FakeRedis retourne 1 par appel, 6 patterns -> 6.
|
// FakeRedis retourne 1 par appel, 3 patterns.
|
||||||
expect(total).toBe(6);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
48
bridge/tests/unit/config.test.ts
Normal file
48
bridge/tests/unit/config.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
bridge/tests/unit/errors.test.ts
Normal file
70
bridge/tests/unit/errors.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
bridge/tests/unit/http.test.ts
Normal file
108
bridge/tests/unit/http.test.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,22 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Tests handler webhooks Baserow — generique R1 (plus de cascade rollup metier).
|
||||||
|
*/
|
||||||
|
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
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 { handleBaserowEvent } from '../../src/webhooks/baserow-handler.js';
|
||||||
import type { BaserowWebhookPayload } from '../../src/webhooks/types.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 {
|
class FakeRedis {
|
||||||
public calls: string[] = [];
|
public calls: string[] = [];
|
||||||
invalidatePattern(pattern: string): Promise<number> {
|
invalidatePattern(pattern: string): Promise<number> {
|
||||||
|
|
@ -31,143 +22,85 @@ function makePayload(over: Partial<BaserowWebhookPayload> = {}): BaserowWebhookP
|
||||||
return {
|
return {
|
||||||
event_id: 'evt-1',
|
event_id: 'evt-1',
|
||||||
event_type: 'rows.created',
|
event_type: 'rows.created',
|
||||||
table_id: 1,
|
table_id: 42,
|
||||||
items: [{ id: 42 }],
|
items: [{ id: 100 }],
|
||||||
...over,
|
...over,
|
||||||
} as BaserowWebhookPayload;
|
} as BaserowWebhookPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('handleBaserowEvent', () => {
|
describe('handleBaserowEvent (R1 generique)', () => {
|
||||||
it('rows.created sur personne -> invalide list (pas row id)', async () => {
|
it('rows.created sur tableId -> invalide list + views (pas row precis)', async () => {
|
||||||
const redis = new FakeRedis();
|
const redis = new FakeRedis();
|
||||||
const res = await handleBaserowEvent(makePayload(), {
|
const res = await handleBaserowEvent(makePayload(), {
|
||||||
redis: redis as unknown as RedisCache,
|
redis: redis as unknown as RedisCache,
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
logger: silentLogger(),
|
logger: silentLogger(),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe('processed');
|
expect(res.status).toBe('processed');
|
||||||
expect(res.entity).toBe('personne');
|
expect(res.tableId).toBe(42);
|
||||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
expect(redis.calls).toContain('bridge:tables:42:list:*');
|
||||||
expect(redis.calls).not.toContain('bridge:personne:row:42');
|
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();
|
const redis = new FakeRedis();
|
||||||
await handleBaserowEvent(
|
await handleBaserowEvent(
|
||||||
makePayload({ event_type: 'rows.updated', items: [{ id: 42 }, { id: 43 }] }),
|
makePayload({ event_type: 'rows.updated', items: [{ id: 100 }, { id: 101 }] }),
|
||||||
{
|
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||||
redis: redis as unknown as RedisCache,
|
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
logger: silentLogger(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
expect(redis.calls).toContain('bridge:personne:row:42');
|
expect(redis.calls).toContain('bridge:tables:42:row:100');
|
||||||
expect(redis.calls).toContain('bridge:personne:row:43');
|
expect(redis.calls).toContain('bridge:tables:42:row:101');
|
||||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
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();
|
const redis = new FakeRedis();
|
||||||
await handleBaserowEvent(
|
await handleBaserowEvent(
|
||||||
makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 100 }] }),
|
makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 200 }] }),
|
||||||
{
|
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||||
redis: redis as unknown as RedisCache,
|
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
logger: silentLogger(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
expect(redis.calls).toContain('bridge:attribution:row:100');
|
expect(redis.calls).toContain('bridge:tables:5:row:200');
|
||||||
expect(redis.calls).toContain('bridge:module:row:*');
|
|
||||||
expect(redis.calls).toContain('bridge:personne:row:*');
|
|
||||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('intervention -> cascade tache + personne', async () => {
|
it('aucune cascade cross-table : tout reste sous bridge:tables:<tableId>:*', 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 () => {
|
|
||||||
const redis = new FakeRedis();
|
const redis = new FakeRedis();
|
||||||
await handleBaserowEvent(
|
await handleBaserowEvent(
|
||||||
makePayload({ event_type: 'rows.updated', table_id: 7, items: [{ id: 1 }] }),
|
makePayload({ event_type: 'rows.updated', table_id: 7, items: [{ id: 1 }] }),
|
||||||
{
|
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||||
redis: redis as unknown as RedisCache,
|
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
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 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,
|
redis: redis as unknown as RedisCache,
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
logger: silentLogger(),
|
logger: silentLogger(),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe('ignored');
|
expect(res.status).toBe('ignored');
|
||||||
expect(res.entity).toBeNull();
|
expect(res.tableId).toBeNull();
|
||||||
expect(redis.calls).toHaveLength(0);
|
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();
|
const redis = new FakeRedis();
|
||||||
await handleBaserowEvent(makePayload({ event_type: 'rows.created', items: [] }), {
|
await handleBaserowEvent(makePayload({ event_type: 'rows.created', items: [] }), {
|
||||||
redis: redis as unknown as RedisCache,
|
redis: redis as unknown as RedisCache,
|
||||||
tableIds: FAKE_TABLE_IDS,
|
|
||||||
logger: silentLogger(),
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Tests integration des routes /api/webhooks/{baserow,docmost}.
|
* Tests integration des routes /api/webhooks/{baserow,docmost} (R1).
|
||||||
* Pas de vrai Redis : on injecte un fake qui implemente l'API minimale necessaire.
|
* Pas de vrai Redis : on injecte un fake qui implemente l'API minimale.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHmac } from 'node:crypto';
|
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 BASEROW_SECRET = 'baserow-test-secret-32chars-long-ok';
|
||||||
const DOCMOST_SECRET = 'docmost-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 {
|
class FakeRedis {
|
||||||
public seen = new Set<string>();
|
public seen = new Set<string>();
|
||||||
public invalidated: string[] = [];
|
public invalidated: string[] = [];
|
||||||
|
|
@ -55,7 +43,6 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) {
|
||||||
baserowWebhookSecret: BASEROW_SECRET,
|
baserowWebhookSecret: BASEROW_SECRET,
|
||||||
docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined,
|
docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined,
|
||||||
bridgeApiTokens: undefined,
|
bridgeApiTokens: undefined,
|
||||||
authStrictMapping: true,
|
|
||||||
rateLimitGlobalMax: 10000,
|
rateLimitGlobalMax: 10000,
|
||||||
rateLimitGlobalWindow: 60,
|
rateLimitGlobalWindow: 60,
|
||||||
rateLimitMutationMax: 10000,
|
rateLimitMutationMax: 10000,
|
||||||
|
|
@ -67,7 +54,8 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: fake injection
|
// biome-ignore lint/suspicious/noExplicitAny: fake injection
|
||||||
repos: {} as any,
|
repos: {} as any,
|
||||||
tokens: new Map(),
|
tokens: new Map(),
|
||||||
tableIds: TABLE_IDS,
|
oidc: null,
|
||||||
|
groupsScopesMap: {},
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -96,8 +84,8 @@ describe('POST /api/webhooks/baserow', () => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
event_id: 'evt-baserow-1',
|
event_id: 'evt-baserow-1',
|
||||||
event_type: 'rows.created',
|
event_type: 'rows.created',
|
||||||
table_id: 1,
|
table_id: 42,
|
||||||
items: [{ id: 42 }],
|
items: [{ id: 100 }],
|
||||||
});
|
});
|
||||||
const res = await app.request('/api/webhooks/baserow', {
|
const res = await app.request('/api/webhooks/baserow', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -108,10 +96,11 @@ describe('POST /api/webhooks/baserow', () => {
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
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.status).toBe('processed');
|
||||||
expect(json.entity).toBe('personne');
|
expect(json.tableId).toBe(42);
|
||||||
expect(redis.invalidated).toContain('bridge:personne:list:*');
|
expect(redis.invalidated).toContain('bridge:tables:42:list:*');
|
||||||
|
expect(redis.invalidated).toContain('bridge:tables:42:views:*');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('401 AUTH_REQUIRED si header absent', async () => {
|
it('401 AUTH_REQUIRED si header absent', async () => {
|
||||||
|
|
@ -179,15 +168,15 @@ describe('POST /api/webhooks/baserow', () => {
|
||||||
expect(json2.eventId).toBe('evt-dup');
|
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();
|
const redis = new FakeRedis();
|
||||||
installContainer(redis);
|
installContainer(redis);
|
||||||
const app = buildApp();
|
const app = buildApp();
|
||||||
|
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
event_id: 'evt-ignore',
|
event_id: 'evt',
|
||||||
event_type: 'rows.created',
|
event_type: 'rows.created',
|
||||||
table_id: 99999,
|
table_id: 0,
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
const res = await app.request('/api/webhooks/baserow', {
|
const res = await app.request('/api/webhooks/baserow', {
|
||||||
|
|
@ -198,11 +187,7 @@ describe('POST /api/webhooks/baserow', () => {
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(400);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('payload malforme (event_id manquant) -> 400 VALIDATION_ERROR', async () => {
|
it('payload malforme (event_id manquant) -> 400 VALIDATION_ERROR', async () => {
|
||||||
|
|
|
||||||
59
examples/acadenice-formation-hub/README.md
Normal file
59
examples/acadenice-formation-hub/README.md
Normal file
|
|
@ -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.
|
||||||
94
examples/acadenice-formation-hub/example-roles.md
Normal file
94
examples/acadenice-formation-hub/example-roles.md
Normal file
|
|
@ -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`).
|
||||||
157
examples/acadenice-formation-hub/seed-baserow.md
Normal file
157
examples/acadenice-formation-hub/seed-baserow.md
Normal file
|
|
@ -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: <hmac-sha256-hex>`
|
||||||
|
- secret partage via env `BASEROW_WEBHOOK_SECRET` du bridge
|
||||||
|
|
||||||
|
Le bridge invalidera juste `bridge:tables:<tableId>:*` — 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 |
|
||||||
Loading…
Add table
Reference in a new issue