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)** :
|
||||
- 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_TOKEN=
|
||||
|
||||
# Docmost API
|
||||
DOCMOST_API_URL=http://docmost:3000/api
|
||||
DOCMOST_API_TOKEN=
|
||||
# Docmost API (optionnel — pas utilise par le bridge generique R1)
|
||||
# DOCMOST_API_URL=http://docmost:3000/api
|
||||
# DOCMOST_API_TOKEN=
|
||||
|
||||
# Redis (cache + idempotence webhooks + lookup Personne)
|
||||
# Redis (cache + idempotence webhooks + rate limit)
|
||||
REDIS_URL=redis://docmost-redis:6379
|
||||
|
||||
# Webhooks Baserow signature secret (HMAC-SHA256, header X-Baserow-Signature)
|
||||
|
|
@ -22,11 +22,11 @@ BASEROW_WEBHOOK_SECRET=
|
|||
|
||||
# Webhooks Docmost signature secret (HMAC-SHA256, header X-Docmost-Signature)
|
||||
# Stub Bloc 7b — handlers metier viennent en Bloc 8 (Tiptap node-views)
|
||||
DOCMOST_WEBHOOK_SECRET=
|
||||
# DOCMOST_WEBHOOK_SECRET=
|
||||
|
||||
# Auth tokens bridge — JSON serialise (Phase 2 simple)
|
||||
# Format: [{"token":"brg_xxx","name":"label","scopes":["read:personnes",...]}]
|
||||
# Phase 3 : migration vers DB dediee
|
||||
# Format: [{"token":"brg_xxx","name":"label","scopes":["read:tables",...]}]
|
||||
# Scopes generiques R1 : read:tables, write:tables, admin:*
|
||||
BRIDGE_API_TOKENS=
|
||||
|
||||
# Authentik OIDC (optional — laisse vide pour mode local-only avec service tokens)
|
||||
|
|
@ -34,8 +34,11 @@ BRIDGE_API_TOKENS=
|
|||
# AUTHENTIK_ISSUER=https://auth.acadenice.com/application/o/formation-hub/
|
||||
# AUTHENTIK_JWKS_URI=https://auth.acadenice.com/application/o/formation-hub/jwks/
|
||||
# AUTHENTIK_AUDIENCE=formation-hub-bridge
|
||||
# AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]}
|
||||
# AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement)
|
||||
# Mapping group Authentik -> scopes bridge (optionnel).
|
||||
# AUTH_GROUPS_SCOPES_MAP={"acadenice-admins":["admin:*"],"acadenice-formateurs":["read:tables","write:tables"]}
|
||||
#
|
||||
# R1 generique : le bridge lit aussi le claim JWT `acadenice_permissions[]`
|
||||
# qui alimente directement les scopes (alimente cote DocAdenice par le RBAC R2).
|
||||
|
||||
# Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/*
|
||||
# (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense).
|
||||
|
|
|
|||
|
|
@ -131,7 +131,9 @@ export class BaserowClient {
|
|||
|
||||
/**
|
||||
* Resoud le mapping table_name → table_id pour la database.
|
||||
* Utilise par le bridge au boot pour eviter de coder les ids en dur.
|
||||
* REQUIERT un JWT user — un DB token (`Token brg_*`) ne peut PAS appeler
|
||||
* cette route. Si l'appel echoue avec 401, on renvoie une erreur claire.
|
||||
* Pour DB token, configurer manuellement les ids cote consumer.
|
||||
*/
|
||||
async resolveTableIds(databaseId: number): Promise<Record<string, number>> {
|
||||
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]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
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 { Personne } from './personne.js';
|
||||
export type { PersonneProps } from './personne.js';
|
||||
export { Formation } from './formation.js';
|
||||
export type { FormationProps } from './formation.js';
|
||||
export { Bloc } from './bloc.js';
|
||||
export type { BlocProps } from './bloc.js';
|
||||
export { Module } from './module.js';
|
||||
export type { ModuleProps } from './module.js';
|
||||
export { Attribution } from './attribution.js';
|
||||
export type { AttributionProps } from './attribution.js';
|
||||
export { Client } from './client.js';
|
||||
export type { ClientProps } from './client.js';
|
||||
export { Projet } from './projet.js';
|
||||
export type { ProjetProps } from './projet.js';
|
||||
export { Tache } from './tache.js';
|
||||
export type { TacheProps } from './tache.js';
|
||||
export { Intervention } from './intervention.js';
|
||||
export type { InterventionProps } from './intervention.js';
|
||||
export { Table } from './table.js';
|
||||
export type { TableProps } from './table.js';
|
||||
export { Row } from './row.js';
|
||||
export type { RowProps } from './row.js';
|
||||
export { Field } from './field.js';
|
||||
export type { FieldProps } from './field.js';
|
||||
export { View } from './view.js';
|
||||
export type { ViewType, ViewProps } from './view.js';
|
||||
export * from './schemas.js';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* 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 ProjetTypeSchema = z.enum([
|
||||
'site_web',
|
||||
'app_mobile',
|
||||
'api',
|
||||
'infra',
|
||||
'audit',
|
||||
'support',
|
||||
'autre',
|
||||
export const ViewTypeSchema = z.union([
|
||||
z.enum(['grid', 'kanban', 'calendar', 'gallery', 'form']),
|
||||
z.string().min(1),
|
||||
]);
|
||||
|
||||
export const StatutPersonneSchema = z.enum(['actif', 'inactif']);
|
||||
export const StatutFormationSchema = z.enum(['draft', 'actif', 'termine', 'archive']);
|
||||
export const StatutModuleSchema = z.enum([
|
||||
'a_attribuer',
|
||||
'attribue',
|
||||
'en_cours',
|
||||
'realise',
|
||||
'annule',
|
||||
]);
|
||||
export const StatutAttributionSchema = z.enum(['planifie', 'en_cours', 'realise', 'annule']);
|
||||
export const StatutClientSchema = z.enum(['prospect', 'actif', 'inactif', 'archive']);
|
||||
export const StatutProjetSchema = z.enum(['devis', 'en_cours', 'livre', 'cloture', 'abandonne']);
|
||||
export const StatutTacheSchema = z.enum(['todo', 'in_progress', 'review', 'done', 'abandoned']);
|
||||
export const StatutInterventionSchema = z.enum(['planifie', 'realise', 'annule']);
|
||||
|
||||
export const PersonneSchema = z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(100),
|
||||
prenom: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
telephone: z.string().max(20).optional().nullable(),
|
||||
capaciteAnnuelle: z.coerce.number().nonnegative(),
|
||||
splitFormationPct: z.coerce.number().min(0).max(100),
|
||||
splitAgencePct: z.coerce.number().min(0).max(100),
|
||||
roles: z.array(RoleSchema),
|
||||
statut: StatutPersonneSchema.default('actif'),
|
||||
heuresAttribueesFormation: z.coerce.number().nonnegative().optional(),
|
||||
heuresAttribueesAgence: z.coerce.number().nonnegative().optional(),
|
||||
})
|
||||
.refine((d) => d.splitFormationPct + d.splitAgencePct === 100, {
|
||||
message: 'splits doivent sommer a 100',
|
||||
path: ['splitFormationPct'],
|
||||
});
|
||||
|
||||
export const FormationSchema = z.object({
|
||||
export const ViewSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
description: z.string().optional().nullable(),
|
||||
filiere: FiliereSchema.optional().nullable(),
|
||||
heuresTotales: z.coerce.number().nonnegative(),
|
||||
statut: StatutFormationSchema.default('draft'),
|
||||
dateDebut: z.coerce.date().optional().nullable(),
|
||||
dateFin: z.coerce.date().optional().nullable(),
|
||||
name: z.string().min(1),
|
||||
type: ViewTypeSchema,
|
||||
tableId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const BlocSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
formationId: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
heuresPrevues: z.coerce.number().nonnegative(),
|
||||
ordre: z.number().int().nonnegative().default(0),
|
||||
export const TableSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1),
|
||||
databaseId: z.number().int().positive(),
|
||||
fields: z.array(FieldSchema).optional(),
|
||||
orderIndex: z.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
export const ModuleSchema = z.object({
|
||||
export const RowSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
blocId: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
heuresPrevues: z.coerce.number().nonnegative(),
|
||||
statut: StatutModuleSchema.default('a_attribuer'),
|
||||
tableId: z.number().int().positive(),
|
||||
fields: z.record(z.unknown()),
|
||||
createdOn: z.coerce.date().nullable().optional(),
|
||||
updatedOn: z.coerce.date().nullable().optional(),
|
||||
order: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const AttributionSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
moduleId: z.number().int().nonnegative(),
|
||||
personneId: z.number().int().nonnegative(),
|
||||
heuresAttribuees: z.coerce.number().positive(),
|
||||
heuresRealisees: z.coerce.number().nonnegative().default(0),
|
||||
dateDebut: z.coerce.date().optional().nullable(),
|
||||
dateFin: z.coerce.date().optional().nullable(),
|
||||
statut: StatutAttributionSchema.default('planifie'),
|
||||
});
|
||||
/**
|
||||
* Body d'un create/update row : juste un Record<string, unknown>. Le bridge
|
||||
* proxie tel quel vers Baserow qui appliquera ses propres validations
|
||||
* (types de champs, contraintes, formules read-only, etc.).
|
||||
*/
|
||||
export const RowFieldsSchema = z.record(z.unknown());
|
||||
|
||||
export const ClientSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
contactPrincipal: z.string().max(200).optional().nullable(),
|
||||
contactEmail: z.string().email().optional().nullable(),
|
||||
contactTelephone: z.string().max(20).optional().nullable(),
|
||||
secteur: z.string().max(100).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
statut: StatutClientSchema.default('prospect'),
|
||||
});
|
||||
|
||||
export const ProjetSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
clientId: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
type: ProjetTypeSchema.optional().nullable(),
|
||||
chargeHeures: z.coerce.number().nonnegative(),
|
||||
statut: StatutProjetSchema.default('devis'),
|
||||
formationId: z.number().int().nonnegative().optional().nullable(),
|
||||
});
|
||||
|
||||
export const TacheSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
projetId: z.number().int().nonnegative(),
|
||||
titre: z.string().min(1).max(200),
|
||||
chargeHeures: z.coerce.number().nonnegative(),
|
||||
priorite: PrioriteSchema.optional().nullable(),
|
||||
statut: StatutTacheSchema.default('todo'),
|
||||
});
|
||||
|
||||
export const InterventionSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
tacheId: z.number().int().nonnegative(),
|
||||
personneId: z.number().int().nonnegative(),
|
||||
heures: z.coerce.number().positive(),
|
||||
date: z.coerce.date(),
|
||||
notes: z.string().optional().nullable(),
|
||||
statut: StatutInterventionSchema.default('realise'),
|
||||
});
|
||||
|
||||
export type PersonneInput = z.infer<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>;
|
||||
export type FieldInput = z.infer<typeof FieldSchema>;
|
||||
export type ViewInput = z.infer<typeof ViewSchema>;
|
||||
export type TableInput = z.infer<typeof TableSchema>;
|
||||
export type RowInput = z.infer<typeof RowSchema>;
|
||||
export type RowFieldsInput = z.infer<typeof RowFieldsSchema>;
|
||||
|
|
|
|||
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.
|
||||
*
|
||||
* Boot sequence : loadConfig -> initContainer (Baserow + Redis + repos + token map)
|
||||
* -> wire middleware globaux + routes /api/v1/* avec auth + serve.
|
||||
* Boot sequence : loadConfig -> initContainer -> wire middleware globaux +
|
||||
* routes /api/v1/tables/* avec auth + serve.
|
||||
*
|
||||
* R1 — Plus de routes metier. Le bridge expose un proxy generique
|
||||
* `/api/v1/tables/*` style Notion. Le metier vit cote consumer.
|
||||
*/
|
||||
|
||||
import { serve } from '@hono/node-server';
|
||||
|
|
@ -14,12 +17,7 @@ import { logger } from './lib/logger.js';
|
|||
import { type AuthVariables, authMiddleware } from './middleware/auth.js';
|
||||
import { errorHandler } from './middleware/error-handler.js';
|
||||
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
|
||||
import { attributionsRoutes } from './routes/attributions.js';
|
||||
import { formationsRoutes } from './routes/formations.js';
|
||||
import { interventionsRoutes } from './routes/interventions.js';
|
||||
import { modulesRoutes } from './routes/modules.js';
|
||||
import { personnesRoutes } from './routes/personnes.js';
|
||||
import { projetsRoutes } from './routes/projets.js';
|
||||
import { tablesRoutes } from './routes/tables.js';
|
||||
import { webhooksRoutes } from './routes/webhooks.js';
|
||||
|
||||
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||
|
|
@ -52,9 +50,6 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
|||
tokens: ctn.tokens,
|
||||
oidc: ctn.oidc,
|
||||
groupsScopesMap: ctn.groupsScopesMap,
|
||||
strictMapping: ctn.config.authStrictMapping,
|
||||
cache: ctn.redis,
|
||||
finder: ctn.repos.personnes,
|
||||
logger: ctn.logger,
|
||||
}),
|
||||
);
|
||||
|
|
@ -82,12 +77,7 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
|||
}
|
||||
await next();
|
||||
});
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/formations', formationsRoutes);
|
||||
v1.route('/projets', projetsRoutes);
|
||||
v1.route('/modules', modulesRoutes);
|
||||
v1.route('/interventions', interventionsRoutes);
|
||||
v1.route('/attributions', attributionsRoutes);
|
||||
v1.route('/tables', tablesRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
|
||||
app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404));
|
||||
|
|
@ -97,22 +87,7 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
|||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
// Soit BASEROW_TABLE_IDS={"personne":609,...} (preferred — DB tokens n'ont pas
|
||||
// acces a /api/database/tables/database/:id/), soit BASEROW_DATABASE_ID + un JWT
|
||||
// user (Phase 3+). Cf doc 19 §5.
|
||||
const tableIdsRaw = process.env.BASEROW_TABLE_IDS;
|
||||
const databaseIdRaw = process.env.BASEROW_DATABASE_ID;
|
||||
let initOpts: Parameters<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);
|
||||
await initContainer({ config });
|
||||
const app = await buildApp();
|
||||
|
||||
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
|
||||
* va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler.ts`.
|
||||
* MAIS la latence webhook est variable (ms a quelques secondes selon la conf
|
||||
* Baserow + reseau) — entre l'ecriture et l'arrivee du webhook, une lecture
|
||||
* concurrente peut servir une valeur stale. L'invalidation immediate cote write
|
||||
* ferme cette fenetre et evite la double-source-of-truth temporaire.
|
||||
* Quand une route REST `/api/v1/tables/:tableId/rows*` mute Baserow, Baserow
|
||||
* va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler`.
|
||||
* MAIS la latence webhook est variable — entre l'ecriture et l'arrivee du
|
||||
* webhook, une lecture concurrente peut servir une valeur stale.
|
||||
* L'invalidation immediate cote write ferme cette fenetre.
|
||||
*
|
||||
* Volontairement pas de coordination avec le webhook : si les deux invalidations
|
||||
* tombent (write local puis webhook), `invalidatePattern` est idempotent (un
|
||||
* pattern qui ne matche rien retourne 0, pas d'erreur).
|
||||
* Pas de cascade cross-table : c'est volontaire. Le bridge ne connait pas le
|
||||
* graphe de relations entre les tables (link_row, formula, lookup) — c'est
|
||||
* Baserow qui le sait. Les rollups cross-table emettent leurs propres
|
||||
* webhooks naturellement, donc l'invalidation cascade au fil de l'eau.
|
||||
*
|
||||
* Pattern keyspace : `bridge:tables:<tableId>:row:<rowId>`,
|
||||
* `bridge:tables:<tableId>:list:*`,
|
||||
* `bridge:tables:<tableId>:views:*`.
|
||||
*/
|
||||
|
||||
import type { TableName } from '../repos/baserow-repo.js';
|
||||
|
||||
export interface CacheInvalidator {
|
||||
invalidatePattern: (pattern: string) => Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide le cache local pour une entite + cascade sur les rollups parents.
|
||||
* Mirror de la logique webhook (`buildInvalidationPatterns`) — duplique
|
||||
* volontairement ici plutot que d'extraire car les contextes sont differents
|
||||
* (event_type webhook vs intent route).
|
||||
*
|
||||
* Si `id` fourni : invalide la row precise + la liste. Sinon : juste la liste
|
||||
* (utile sur les creates ou on n'a pas encore l'id parent a invalider).
|
||||
* Invalide le cache local pour une table. Si `rowId` fourni : invalide la row
|
||||
* precise + la liste + les vues. Sinon : juste la liste + les vues.
|
||||
*/
|
||||
export async function invalidateEntity(
|
||||
export async function invalidateTable(
|
||||
redis: CacheInvalidator,
|
||||
entity: TableName,
|
||||
id?: number,
|
||||
tableId: number,
|
||||
rowId?: number,
|
||||
): Promise<number> {
|
||||
const patterns: string[] = [`bridge:${entity}:list:*`];
|
||||
if (typeof id === 'number') {
|
||||
patterns.push(`bridge:${entity}:row:${id}`);
|
||||
}
|
||||
|
||||
// Cascade rollups parent : aligned avec webhooks/baserow-handler.ts.
|
||||
switch (entity) {
|
||||
case 'attribution':
|
||||
patterns.push('bridge:module:row:*', 'bridge:module:list:*');
|
||||
patterns.push('bridge:personne:row:*', 'bridge:personne:list:*');
|
||||
break;
|
||||
case 'intervention':
|
||||
patterns.push('bridge:tache:row:*', 'bridge:tache:list:*');
|
||||
patterns.push('bridge:personne:row:*', 'bridge:personne:list:*');
|
||||
break;
|
||||
case 'module':
|
||||
patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*');
|
||||
patterns.push('bridge:formation:row:*', 'bridge:formation:list:*');
|
||||
break;
|
||||
case 'bloc':
|
||||
patterns.push('bridge:formation:row:*', 'bridge:formation:list:*');
|
||||
break;
|
||||
case 'tache':
|
||||
patterns.push('bridge:projet:row:*', 'bridge:projet:list:*');
|
||||
break;
|
||||
case 'projet':
|
||||
patterns.push('bridge:client:row:*', 'bridge:client:list:*');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
const patterns: string[] = [
|
||||
`bridge:tables:${tableId}:list:*`,
|
||||
`bridge:tables:${tableId}:views:*`,
|
||||
];
|
||||
if (typeof rowId === 'number') {
|
||||
patterns.push(`bridge:tables:${tableId}:row:${rowId}`);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ const ConfigSchema = z.object({
|
|||
authentikAudience: z.string().min(1).optional(),
|
||||
// JSON serialise group->scopes ; parse fait dans le middleware auth.
|
||||
authGroupsScopesMap: z.string().optional(),
|
||||
// Si false : un JWT OIDC valide dont l'email n'a pas de Personne attache passe quand meme
|
||||
// (scopes derives uniquement des groups Authentik). Defaut strict.
|
||||
authStrictMapping: z.coerce.boolean().default(true),
|
||||
// Rate limiting (Bloc 5). Global s'applique sur tout /api/v1/* ; mutation s'ajoute
|
||||
// sur POST/PATCH/PUT/DELETE et est volontairement plus strict pour proteger
|
||||
// contre les bursts buggy / scripts mal configures.
|
||||
|
|
@ -52,7 +49,6 @@ export function loadConfig(): Config {
|
|||
authentikJwksUri: process.env.AUTHENTIK_JWKS_URI,
|
||||
authentikAudience: process.env.AUTHENTIK_AUDIENCE,
|
||||
authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP,
|
||||
authStrictMapping: process.env.AUTH_STRICT_MAPPING,
|
||||
rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX,
|
||||
rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW,
|
||||
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
* DI container — initialise les dependances une seule fois au boot et expose
|
||||
* un singleton typed pour les routes. Pour les tests, `setContainer` permet
|
||||
* d'injecter un mock complet sans toucher a `getContainer`.
|
||||
*
|
||||
* R1 — Plus de TableIds metier. Le bridge expose `/tables` generique. Les ids
|
||||
* sont passes en query/path — c'est le consumer qui sait quelles tables
|
||||
* exister dans sa Baserow.
|
||||
*/
|
||||
|
||||
import type { Logger } from 'pino';
|
||||
|
|
@ -11,18 +15,27 @@ import type { ApiTokenRecord } from '../middleware/auth.js';
|
|||
import { parseTokens } from '../middleware/auth.js';
|
||||
import { OidcVerifier } from '../middleware/oidc-verifier.js';
|
||||
import { type GroupsScopesMap, parseGroupsScopesMap } from '../middleware/scopes.js';
|
||||
import { type RepoSet, TABLE_NAMES, type TableIds, buildRepos } from '../repos/baserow-repo.js';
|
||||
import { BaserowFieldsRepo } from '../repos/baserow-fields-repo.js';
|
||||
import { BaserowRowsRepo } from '../repos/baserow-rows-repo.js';
|
||||
import { BaserowTablesRepo } from '../repos/baserow-tables-repo.js';
|
||||
import { BaserowViewsRepo } from '../repos/baserow-views-repo.js';
|
||||
import type { Config } from './config.js';
|
||||
import { isOidcEnabled } from './config.js';
|
||||
import { logger as rootLogger } from './logger.js';
|
||||
|
||||
export interface RepoSet {
|
||||
tables: BaserowTablesRepo;
|
||||
rows: BaserowRowsRepo;
|
||||
fields: BaserowFieldsRepo;
|
||||
views: BaserowViewsRepo;
|
||||
}
|
||||
|
||||
export interface Container {
|
||||
config: Config;
|
||||
baserow: BaserowClient;
|
||||
redis: RedisCache;
|
||||
repos: RepoSet;
|
||||
tokens: ReadonlyMap<string, ApiTokenRecord>;
|
||||
tableIds: TableIds;
|
||||
/** Null si mode OIDC desactive (vars Authentik manquantes). */
|
||||
oidc: OidcVerifier | null;
|
||||
groupsScopesMap: GroupsScopesMap;
|
||||
|
|
@ -44,12 +57,9 @@ export function setContainer(c: Container | null): void {
|
|||
|
||||
export interface InitOptions {
|
||||
config: Config;
|
||||
/** Pour tests : skip le resolveTableIds reseau. */
|
||||
tableIds?: TableIds;
|
||||
/** Pour tests : injecter une implem de Baserow/Redis. */
|
||||
baserow?: BaserowClient;
|
||||
redis?: RedisCache;
|
||||
databaseId?: number;
|
||||
}
|
||||
|
||||
export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||
|
|
@ -63,18 +73,13 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
|||
});
|
||||
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
|
||||
|
||||
let tableIds: TableIds;
|
||||
if (opts.tableIds) {
|
||||
tableIds = opts.tableIds;
|
||||
} else {
|
||||
if (typeof opts.databaseId !== 'number') {
|
||||
throw new Error('initContainer: databaseId requis si tableIds non fourni');
|
||||
}
|
||||
const resolved = await baserow.resolveTableIds(opts.databaseId);
|
||||
tableIds = pickTableIds(resolved);
|
||||
}
|
||||
const repos: RepoSet = {
|
||||
tables: new BaserowTablesRepo({ client: baserow, logger: rootLogger }),
|
||||
rows: new BaserowRowsRepo({ client: baserow, logger: rootLogger }),
|
||||
fields: new BaserowFieldsRepo({ client: baserow, logger: rootLogger }),
|
||||
views: new BaserowViewsRepo({ client: baserow, logger: rootLogger }),
|
||||
};
|
||||
|
||||
const repos = buildRepos(baserow, tableIds, rootLogger);
|
||||
const tokens = parseTokens(config.bridgeApiTokens);
|
||||
const groupsScopesMap = parseGroupsScopesMap(config.authGroupsScopesMap);
|
||||
|
||||
|
|
@ -100,7 +105,6 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
|||
redis,
|
||||
repos,
|
||||
tokens,
|
||||
tableIds,
|
||||
oidc,
|
||||
groupsScopesMap,
|
||||
logger: rootLogger,
|
||||
|
|
@ -108,16 +112,3 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
|||
setContainer(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 :
|
||||
*
|
||||
* 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`)
|
||||
* pour utilisateurs Docmost. Active uniquement si `AUTHENTIK_ISSUER` +
|
||||
* `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set dans la config.
|
||||
* pour utilisateurs Docmost/DocAdenice. Active uniquement si
|
||||
* `AUTHENTIK_ISSUER` + `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set.
|
||||
*
|
||||
* R1 — Plus de lookup `PersonneRepo.findByEmail` : le bridge est generique,
|
||||
* il ne connait pas la table Personne. Le mapping email -> permissions
|
||||
* metier est entierement cote DocAdenice (R2 RBAC dynamique). Les scopes
|
||||
* effectifs viennent de :
|
||||
* - groups Authentik via `groupsScopesMap`
|
||||
* - claim JWT `acadenice_permissions[]` (R2) — fallback vide si absent
|
||||
*
|
||||
* Ordre de detection :
|
||||
* - Header `Authorization`: si commence par `brg_` (apres ApiKey/Bearer) -> service token
|
||||
* - Header `Authorization: Bearer <jwt>` (commence par `eyJ`) -> JWT OIDC
|
||||
* - `Authorization: <ApiKey|Bearer> brg_*` -> service token
|
||||
* - `Authorization: Bearer <jwt>` (commence par `eyJ`) -> JWT OIDC
|
||||
* - Cookie `authToken` -> JWT OIDC
|
||||
* - Sinon -> 401 AUTH_REQUIRED
|
||||
*
|
||||
* Pourquoi un seul middleware au lieu de deux ? Une seule passe = pas de doute
|
||||
* sur l'ordre de priorite, et les routes /api/v1/* n'ont pas a savoir quelle
|
||||
* source d'identite a ete utilisee.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import type { Logger } from 'pino';
|
||||
import type { Personne } from '../domain/personne.js';
|
||||
import type { Role } from '../domain/types.js';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import type { OidcVerifier } from './oidc-verifier.js';
|
||||
import { extractEmail, extractGroups } from './oidc-verifier.js';
|
||||
|
|
@ -46,24 +47,22 @@ export interface AuthenticatedUser {
|
|||
email?: string;
|
||||
/** Pour OIDC : sub claim (id stable Authentik). */
|
||||
sub?: string;
|
||||
/** Si lookup PersonneRepo a reussi. */
|
||||
personneId?: number;
|
||||
/** Roles formation-hub deduits via Personne (vide pour service tokens). */
|
||||
roles: Role[];
|
||||
/** Groups Authentik bruts (vide pour service tokens). */
|
||||
groups: string[];
|
||||
/** Scopes effectifs : union (groups->scopes) + (roles->scopes) + token.scopes. */
|
||||
/** Permissions explicites du JWT (claim `acadenice_permissions[]`). Vide si absent. */
|
||||
permissions: string[];
|
||||
/** Scopes effectifs : union (groups->scopes) + permissions + token.scopes. */
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/** Hono context variables — `auth` reste pour compat ; `user` est la nouvelle source d'identite. */
|
||||
/** Hono context variables. `auth` reste pour compat. */
|
||||
export type AuthVariables = {
|
||||
auth: { tokenName: string; scopes: ReadonlySet<string> };
|
||||
user: AuthenticatedUser;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service tokens (Bloc 3 inchange — JSON parsing tolere ApiKey/Bearer)
|
||||
// Service tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
|
||||
|
|
@ -94,11 +93,10 @@ export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord
|
|||
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 {
|
||||
if (owned.has('admin:*')) return true;
|
||||
if (owned.has(required)) return true;
|
||||
// Wildcard suffix (`prefix:*` -> couvre `prefix:foo`, `prefix:bar`)
|
||||
const colonIdx = required.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const prefixWildcard = `${required.slice(0, colonIdx)}:*`;
|
||||
|
|
@ -126,65 +124,18 @@ export function requireScope(scope: string): MiddlewareHandler<{ Variables: Auth
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personne lookup avec cache Redis (sha256 email pour eviter PII en clair)
|
||||
// Helpers extraction claims OIDC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PersonneByEmailCache {
|
||||
get: <T>(key: string) => Promise<T | null>;
|
||||
set: <T>(key: string, value: T, ttlSeconds?: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PersonneFinder {
|
||||
findByEmail: (email: string) => Promise<Personne | null>;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Extrait `acadenice_permissions` d'un payload JWT. C'est le claim que
|
||||
* DocAdenice (R2) attachera au token via le RBAC dynamique. Tolerant : accepte
|
||||
* un tableau de strings, ignore les valeurs non-strings ou vides.
|
||||
*/
|
||||
export function extractPermissions(payload: Record<string, unknown>): string[] {
|
||||
const raw = payload.acadenice_permissions;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -197,11 +148,6 @@ export interface AuthMiddlewareOptions {
|
|||
oidc: OidcVerifier | null;
|
||||
/** Map groups Authentik -> scopes. */
|
||||
groupsScopesMap: GroupsScopesMap;
|
||||
/** Si true et email orphelin (pas de Personne) -> 403. Sinon -> autorise avec scopes des groups uniquement. */
|
||||
strictMapping: boolean;
|
||||
/** Cache Redis (peut etre RedisCache ou n'importe quel impl compatible). */
|
||||
cache: PersonneByEmailCache;
|
||||
finder: PersonneFinder;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +170,7 @@ function parseAuthHeader(header: string | undefined): ParsedHeader {
|
|||
export function authMiddleware(
|
||||
opts: AuthMiddlewareOptions,
|
||||
): MiddlewareHandler<{ Variables: AuthVariables }> {
|
||||
const { tokens, oidc, groupsScopesMap, strictMapping, cache, finder, logger } = opts;
|
||||
const { tokens, oidc, groupsScopesMap, logger } = opts;
|
||||
|
||||
return async (c, next) => {
|
||||
const headerRaw = c.req.header('Authorization');
|
||||
|
|
@ -241,8 +187,8 @@ export function authMiddleware(
|
|||
const user: AuthenticatedUser = {
|
||||
source: 'service-token',
|
||||
tokenId: record.name,
|
||||
roles: [],
|
||||
groups: [],
|
||||
permissions: [],
|
||||
scopes,
|
||||
};
|
||||
c.set('user', user);
|
||||
|
|
@ -282,36 +228,18 @@ export function authMiddleware(
|
|||
const email = extractEmail(verified.payload);
|
||||
const sub = typeof verified.payload.sub === 'string' ? verified.payload.sub : undefined;
|
||||
const groups = extractGroups(verified.payload);
|
||||
const permissions = extractPermissions(verified.payload as Record<string, unknown>);
|
||||
|
||||
let personneId: number | undefined;
|
||||
let roles: Role[] = [];
|
||||
if (email) {
|
||||
const found = await lookupPersonneByEmail(email, finder, cache, logger);
|
||||
if (found) {
|
||||
personneId = found.id;
|
||||
roles = found.roles;
|
||||
} else if (strictMapping) {
|
||||
logger.warn({ email, sub }, 'OIDC user not found in Personne (strict mode)');
|
||||
throw errors.forbiddenIdentity('Aucune Personne formation-hub liee a cet email', {
|
||||
email,
|
||||
});
|
||||
}
|
||||
} else if (strictMapping) {
|
||||
throw errors.forbiddenIdentity('JWT sans email exploitable', {});
|
||||
}
|
||||
|
||||
const scopes = computeOidcScopes(groups, new Set(roles), groupsScopesMap);
|
||||
const scopes = computeOidcScopes(groups, permissions, groupsScopesMap);
|
||||
const user: AuthenticatedUser = {
|
||||
source: source ?? 'oidc-jwt',
|
||||
email: email ?? undefined,
|
||||
sub,
|
||||
personneId,
|
||||
roles,
|
||||
groups,
|
||||
permissions,
|
||||
scopes,
|
||||
};
|
||||
c.set('user', user);
|
||||
// Compat : c.var.auth pour les rares endpoints Bloc 3 qui le lisent encore.
|
||||
c.set('auth', {
|
||||
tokenName: email ?? sub ?? 'oidc-anonymous',
|
||||
scopes: new Set(scopes),
|
||||
|
|
|
|||
|
|
@ -1,31 +1,19 @@
|
|||
/**
|
||||
* Mapping groupes Authentik + roles formation-hub vers scopes bridge.
|
||||
* Mapping groupes Authentik vers scopes bridge.
|
||||
*
|
||||
* Sources de scopes pour un utilisateur OIDC :
|
||||
* R1 generique : plus de mapping role formation-hub. Les sources de scopes
|
||||
* pour un utilisateur OIDC sont :
|
||||
* 1. groups Authentik (mappes via `AUTH_GROUPS_SCOPES_MAP` JSON)
|
||||
* 2. roles formation-hub portes par la Personne (mappes via DEFAULT_ROLE_SCOPES)
|
||||
* 3. union des deux
|
||||
* 2. claim direct `acadenice_permissions[]` du JWT (alimente cote DocAdenice
|
||||
* R2 par le RBAC dynamique). Lu par le middleware auth, pas ici.
|
||||
*
|
||||
* Le default role-scope mapping est volontairement conservateur : seul `admin`
|
||||
* obtient `admin:*`. Les autres roles reçoivent le strict necessaire pour leur travail.
|
||||
* Les permissions metier (Formateur, Developpeur, Admin, Direction, Support)
|
||||
* vivent maintenant dans les exemples (`examples/acadenice-formation-hub/`)
|
||||
* et sont declarees cote DocAdenice — le bridge n'en sait rien.
|
||||
*/
|
||||
|
||||
import type { Role } from '../domain/types.js';
|
||||
|
||||
export type GroupsScopesMap = Record<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 {
|
||||
if (!raw || raw.trim().length === 0) return {};
|
||||
let parsed: unknown;
|
||||
|
|
@ -48,14 +36,16 @@ export function parseGroupsScopesMap(raw: string | undefined): GroupsScopesMap {
|
|||
}
|
||||
|
||||
/**
|
||||
* Calcule l'union des scopes pour un user OIDC.
|
||||
* - groups Authentik : si pas de mapping fourni, fallback sur le nom de groupe
|
||||
* qui matche un Role connu (ex: `formation-hub-formateurs` -> formateur).
|
||||
* - roles formation-hub : DEFAULT_ROLE_SCOPES.
|
||||
* Calcule l'union des scopes pour un user OIDC :
|
||||
* - groups Authentik via le mapping configure
|
||||
* - permissions explicites (claim `acadenice_permissions[]` ou equivalent)
|
||||
*
|
||||
* Si rien ne matche : tableau vide. C'est OK — le middleware d'auth attache
|
||||
* juste l'identite, et `requireScope` rejettera les routes protegees.
|
||||
*/
|
||||
export function computeOidcScopes(
|
||||
groups: string[],
|
||||
roles: ReadonlySet<Role>,
|
||||
permissions: string[],
|
||||
groupsMap: GroupsScopesMap,
|
||||
): string[] {
|
||||
const scopes = new Set<string>();
|
||||
|
|
@ -65,8 +55,8 @@ export function computeOidcScopes(
|
|||
for (const s of direct) scopes.add(s);
|
||||
}
|
||||
}
|
||||
for (const role of roles) {
|
||||
for (const s of DEFAULT_ROLE_SCOPES[role] ?? []) scopes.add(s);
|
||||
for (const p of permissions) {
|
||||
if (typeof p === 'string' && p.length > 0) scopes.add(p);
|
||||
}
|
||||
return Array.from(scopes).sort();
|
||||
}
|
||||
|
|
|
|||
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();
|
||||
|
||||
webhooksRoutes.post('/baserow', async (c) => {
|
||||
const { config, redis, tableIds, logger } = getContainer();
|
||||
const { config, redis, logger } = getContainer();
|
||||
|
||||
const signature = c.req.header(BASEROW_SIGNATURE_HEADER);
|
||||
if (!signature) {
|
||||
|
|
@ -58,12 +58,12 @@ webhooksRoutes.post('/baserow', async (c) => {
|
|||
return c.json({ status: 'duplicate', eventId: payload.event_id }, 200);
|
||||
}
|
||||
|
||||
const result = await handleBaserowEvent(payload, { redis, tableIds, logger });
|
||||
const result = await handleBaserowEvent(payload, { redis, logger });
|
||||
return c.json(
|
||||
{
|
||||
status: result.status,
|
||||
eventId: payload.event_id,
|
||||
entity: result.entity,
|
||||
tableId: result.tableId,
|
||||
invalidatedKeys: result.invalidatedKeys,
|
||||
},
|
||||
200,
|
||||
|
|
|
|||
|
|
@ -1,88 +1,53 @@
|
|||
/**
|
||||
* Handler webhooks Baserow.
|
||||
* Handler webhooks Baserow — generique style Notion.
|
||||
*
|
||||
* Pipeline : payload deja valide (zod) + idempotence verifiee en amont (route).
|
||||
* Ici on mappe table_id -> entite domain et on invalide les caches Redis
|
||||
* pertinents. Le recalcul des agregations parent (modules, formations, projets)
|
||||
* est differe au prochain GET (cache miss -> repo -> fresh data) — voir doc 19
|
||||
* §8 cache strategy.
|
||||
* Ici on invalide les caches Redis associes a la table touchee. Plus de
|
||||
* cascade rollup metier : si l'utilisateur a configure des formules/lookups
|
||||
* cross-table cote Baserow, elles emettront leurs propres webhooks
|
||||
* naturellement (chaque table touchee declenche son propre event).
|
||||
*
|
||||
* Le handler ne sait pas quelle table c'est metier-parlant — il invalide
|
||||
* juste `bridge:tables:<tableId>:*`.
|
||||
*/
|
||||
|
||||
import type { Logger } from 'pino';
|
||||
import type { RedisCache } from '../adapters/redis-cache.js';
|
||||
import type { TableIds, TableName } from '../repos/baserow-repo.js';
|
||||
import type { BaserowEventType, BaserowWebhookPayload } from './types.js';
|
||||
|
||||
export interface BaserowHandlerDeps {
|
||||
redis: RedisCache;
|
||||
tableIds: TableIds;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface BaserowHandleResult {
|
||||
status: 'processed' | 'ignored';
|
||||
entity: TableName | null;
|
||||
tableId: number | null;
|
||||
invalidatedKeys: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse map id -> table name (mappe juste les tables qu'on suit).
|
||||
* Construit une fois par appel — taille fixe (9 entries), cout negligeable.
|
||||
*/
|
||||
function findEntityByTableId(tableIds: TableIds, tableId: number): TableName | null {
|
||||
for (const [name, id] of Object.entries(tableIds) as [TableName, number][]) {
|
||||
if (id === tableId) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns d'invalidation cache. Hierarchie :
|
||||
* - cache row precis (`bridge:<entity>:row:<id>`) si update/delete
|
||||
* - cache liste (`bridge:<entity>:list:*`) toujours
|
||||
* - cache parent (rollups) si l'entite est enfant d'un agregat
|
||||
* Patterns d'invalidation cache pour une table.
|
||||
* - `list:*` : toutes les listes paginees / filtrees
|
||||
* - `views:*` : toutes les rows fetched via une view
|
||||
* - `row:<id>` : la row precise (si update/delete avec items)
|
||||
*/
|
||||
function buildInvalidationPatterns(
|
||||
entity: TableName,
|
||||
tableId: number,
|
||||
eventType: BaserowEventType,
|
||||
itemIds: number[],
|
||||
): string[] {
|
||||
const patterns: string[] = [`bridge:${entity}:list:*`];
|
||||
const patterns: string[] = [
|
||||
`bridge:tables:${tableId}:list:*`,
|
||||
`bridge:tables:${tableId}:views:*`,
|
||||
];
|
||||
|
||||
if (eventType === 'rows.updated' || eventType === 'rows.deleted') {
|
||||
for (const id of itemIds) {
|
||||
patterns.push(`bridge:${entity}:row:${id}`);
|
||||
patterns.push(`bridge:tables:${tableId}:row:${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cascade rollups parent. Les rollups sont calcules cote Baserow (formules)
|
||||
// mais notre cache row du parent peut contenir des heures aggregees stale.
|
||||
switch (entity) {
|
||||
case 'attribution':
|
||||
patterns.push('bridge:module:row:*', 'bridge:module:list:*');
|
||||
patterns.push('bridge:personne:row:*', 'bridge:personne:list:*');
|
||||
break;
|
||||
case 'intervention':
|
||||
patterns.push('bridge:tache:row:*', 'bridge:tache:list:*');
|
||||
patterns.push('bridge:personne:row:*', 'bridge:personne:list:*');
|
||||
break;
|
||||
case 'module':
|
||||
patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*');
|
||||
patterns.push('bridge:formation:row:*', 'bridge:formation:list:*');
|
||||
break;
|
||||
case 'bloc':
|
||||
patterns.push('bridge:formation:row:*', 'bridge:formation:list:*');
|
||||
break;
|
||||
case 'tache':
|
||||
patterns.push('bridge:projet:row:*', 'bridge:projet:list:*');
|
||||
break;
|
||||
case 'projet':
|
||||
patterns.push('bridge:client:row:*', 'bridge:client:list:*');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
|
|
@ -90,18 +55,16 @@ export async function handleBaserowEvent(
|
|||
payload: BaserowWebhookPayload,
|
||||
deps: BaserowHandlerDeps,
|
||||
): Promise<BaserowHandleResult> {
|
||||
const entity = findEntityByTableId(deps.tableIds, payload.table_id);
|
||||
|
||||
if (!entity) {
|
||||
if (!Number.isFinite(payload.table_id) || payload.table_id <= 0) {
|
||||
deps.logger.warn(
|
||||
{ tableId: payload.table_id, eventId: payload.event_id, eventType: payload.event_type },
|
||||
'baserow webhook: table_id inconnu, ignore',
|
||||
'baserow webhook: table_id invalide, ignore',
|
||||
);
|
||||
return { status: 'ignored', entity: null, invalidatedKeys: 0 };
|
||||
return { status: 'ignored', tableId: null, invalidatedKeys: 0 };
|
||||
}
|
||||
|
||||
const itemIds = payload.items.map((i) => i.id);
|
||||
const patterns = buildInvalidationPatterns(entity, payload.event_type, itemIds);
|
||||
const patterns = buildInvalidationPatterns(payload.table_id, payload.event_type, itemIds);
|
||||
|
||||
let total = 0;
|
||||
for (const pattern of patterns) {
|
||||
|
|
@ -113,7 +76,7 @@ export async function handleBaserowEvent(
|
|||
{
|
||||
eventId: payload.event_id,
|
||||
eventType: payload.event_type,
|
||||
entity,
|
||||
tableId: payload.table_id,
|
||||
itemIds,
|
||||
patternsApplied: patterns.length,
|
||||
keysInvalidated: total,
|
||||
|
|
@ -121,5 +84,5 @@ export async function handleBaserowEvent(
|
|||
'baserow webhook processed',
|
||||
);
|
||||
|
||||
return { status: 'processed', entity, invalidatedKeys: total };
|
||||
return { status: 'processed', tableId: payload.table_id, invalidatedKeys: total };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
AttributionSchema,
|
||||
BlocSchema,
|
||||
ClientSchema,
|
||||
FormationSchema,
|
||||
InterventionSchema,
|
||||
ModuleSchema,
|
||||
PersonneSchema,
|
||||
ProjetSchema,
|
||||
TacheSchema,
|
||||
FieldSchema,
|
||||
RowFieldsSchema,
|
||||
RowSchema,
|
||||
TableSchema,
|
||||
ViewSchema,
|
||||
} from '../../src/domain/schemas.js';
|
||||
|
||||
describe('schemas zod', () => {
|
||||
it('PersonneSchema valide', () => {
|
||||
const r = PersonneSchema.parse({
|
||||
describe('FieldSchema', () => {
|
||||
it('valide un field minimal', () => {
|
||||
const r = FieldSchema.parse({ id: 1, name: 'nom', type: 'text' });
|
||||
expect(r.primary).toBe(false);
|
||||
expect(r.options).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejette name vide', () => {
|
||||
expect(() => FieldSchema.parse({ id: 1, name: '', type: 'text' })).toThrow();
|
||||
});
|
||||
|
||||
it('accepte type custom (Baserow expose tout)', () => {
|
||||
const r = FieldSchema.parse({ id: 1, name: 'x', type: 'rollup' });
|
||||
expect(r.type).toBe('rollup');
|
||||
});
|
||||
|
||||
it('options accept Record<string, unknown>', () => {
|
||||
const r = FieldSchema.parse({
|
||||
id: 1,
|
||||
nom: 'Doe',
|
||||
prenom: 'John',
|
||||
email: 'john@a.fr',
|
||||
capaciteAnnuelle: '1000',
|
||||
splitFormationPct: 50,
|
||||
splitAgencePct: 50,
|
||||
roles: ['formateur'],
|
||||
name: 'statut',
|
||||
type: 'single_select',
|
||||
options: { select_options: [{ id: 1 }] },
|
||||
});
|
||||
expect(r.statut).toBe('actif');
|
||||
expect(r.capaciteAnnuelle).toBe(1000);
|
||||
});
|
||||
|
||||
it('PersonneSchema rejette splits qui ne somment pas a 100', () => {
|
||||
expect(() =>
|
||||
PersonneSchema.parse({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
prenom: 'Y',
|
||||
email: 'x@y.fr',
|
||||
capaciteAnnuelle: 1000,
|
||||
splitFormationPct: 50,
|
||||
splitAgencePct: 40,
|
||||
roles: ['formateur'],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('PersonneSchema rejette email invalide', () => {
|
||||
expect(() =>
|
||||
PersonneSchema.parse({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
prenom: 'Y',
|
||||
email: 'not-an-email',
|
||||
capaciteAnnuelle: 1000,
|
||||
splitFormationPct: 50,
|
||||
splitAgencePct: 50,
|
||||
roles: ['formateur'],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('FormationSchema valide avec defaults', () => {
|
||||
const r = FormationSchema.parse({ id: 1, nom: 'BTS', heuresTotales: 500 });
|
||||
expect(r.statut).toBe('draft');
|
||||
});
|
||||
|
||||
it('BlocSchema rejette heuresPrevues negative', () => {
|
||||
expect(() =>
|
||||
BlocSchema.parse({ id: 1, formationId: 1, nom: 'B', heuresPrevues: -5 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('ModuleSchema valide', () => {
|
||||
const r = ModuleSchema.parse({ id: 1, blocId: 1, nom: 'M', heuresPrevues: 20 });
|
||||
expect(r.statut).toBe('a_attribuer');
|
||||
});
|
||||
|
||||
it('AttributionSchema rejette heuresAttribuees = 0', () => {
|
||||
expect(() =>
|
||||
AttributionSchema.parse({ id: 1, moduleId: 1, personneId: 1, heuresAttribuees: 0 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('ClientSchema valide minimal', () => {
|
||||
const r = ClientSchema.parse({ id: 1, nom: 'Acme' });
|
||||
expect(r.statut).toBe('prospect');
|
||||
});
|
||||
|
||||
it('ProjetSchema valide avec formationId', () => {
|
||||
const r = ProjetSchema.parse({
|
||||
id: 1,
|
||||
clientId: 1,
|
||||
nom: 'P',
|
||||
chargeHeures: 100,
|
||||
formationId: 5,
|
||||
});
|
||||
expect(r.formationId).toBe(5);
|
||||
});
|
||||
|
||||
it('TacheSchema valide minimal', () => {
|
||||
const r = TacheSchema.parse({ id: 1, projetId: 1, titre: 'T', chargeHeures: 4 });
|
||||
expect(r.statut).toBe('todo');
|
||||
});
|
||||
|
||||
it('InterventionSchema parse date string', () => {
|
||||
const r = InterventionSchema.parse({
|
||||
id: 1,
|
||||
tacheId: 1,
|
||||
personneId: 1,
|
||||
heures: 3,
|
||||
date: '2026-05-01',
|
||||
});
|
||||
expect(r.date instanceof Date).toBe(true);
|
||||
});
|
||||
|
||||
it('InterventionSchema rejette heures = 0', () => {
|
||||
expect(() =>
|
||||
InterventionSchema.parse({
|
||||
id: 1,
|
||||
tacheId: 1,
|
||||
personneId: 1,
|
||||
heures: 0,
|
||||
date: '2026-05-01',
|
||||
}),
|
||||
).toThrow();
|
||||
expect(r.options).toEqual({ select_options: [{ id: 1 }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewSchema', () => {
|
||||
it('valide une view grid', () => {
|
||||
const r = ViewSchema.parse({ id: 1, name: 'Tous', type: 'grid', tableId: 5 });
|
||||
expect(r.type).toBe('grid');
|
||||
});
|
||||
|
||||
it('accepte un type custom', () => {
|
||||
const r = ViewSchema.parse({ id: 1, name: 'X', type: 'weird', tableId: 5 });
|
||||
expect(r.type).toBe('weird');
|
||||
});
|
||||
|
||||
it('rejette tableId negatif', () => {
|
||||
expect(() => ViewSchema.parse({ id: 1, name: 'X', type: 'grid', tableId: 0 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TableSchema', () => {
|
||||
it('valide une table minimale', () => {
|
||||
const r = TableSchema.parse({ id: 1, name: 'Personne', databaseId: 5 });
|
||||
expect(r.orderIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('rejette id <= 0', () => {
|
||||
expect(() => TableSchema.parse({ id: 0, name: 'x', databaseId: 1 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RowSchema', () => {
|
||||
it('valide une row avec fields opaques', () => {
|
||||
const r = RowSchema.parse({
|
||||
id: 1,
|
||||
tableId: 5,
|
||||
fields: { nom: 'x', heures: 40 },
|
||||
});
|
||||
expect(r.fields.heures).toBe(40);
|
||||
});
|
||||
|
||||
it('id 0 est accepte (NEW row temp client-side)', () => {
|
||||
const r = RowSchema.parse({ id: 0, tableId: 1, fields: {} });
|
||||
expect(r.id).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RowFieldsSchema', () => {
|
||||
it('accepte n importe quel record', () => {
|
||||
expect(RowFieldsSchema.parse({ a: 1, b: 'x', c: null })).toEqual({ a: 1, b: 'x', c: null });
|
||||
});
|
||||
|
||||
it('rejette un non-objet', () => {
|
||||
expect(() => RowFieldsSchema.parse([1, 2])).toThrow();
|
||||
expect(() => RowFieldsSchema.parse('foo')).toThrow();
|
||||
});
|
||||
|
||||
it('accepte un objet vide (PATCH partiel possible)', () => {
|
||||
expect(RowFieldsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it('accepte des valeurs nested arbitraires (link_row, select, formula result)', () => {
|
||||
const v = {
|
||||
link: [{ id: 1, value: 'X' }],
|
||||
select: { id: 5, value: 'actif', color: 'green' },
|
||||
formula_result: 42.5,
|
||||
tags: ['a', 'b'],
|
||||
};
|
||||
expect(RowFieldsSchema.parse(v)).toEqual(v);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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.
|
||||
* Pas de testcontainers ici — les routes utilisent les repos qu'on mock dans chaque suite.
|
||||
* Test helper : construit une app Hono iso-prod avec un container minimal en
|
||||
* memoire, puis expose les routes generiques /api/v1/tables/*.
|
||||
*
|
||||
* R1 — Pas de tableIds metier. Les repos sont injectes via overrides.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { logger as honoLogger } from 'hono/logger';
|
||||
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import type { Personne } from '../../src/domain/personne.js';
|
||||
import type { Container } from '../../src/lib/container.js';
|
||||
import type { Container, RepoSet } from '../../src/lib/container.js';
|
||||
import { setContainer } from '../../src/lib/container.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
import {
|
||||
|
|
@ -17,42 +18,16 @@ import {
|
|||
authMiddleware,
|
||||
} from '../../src/middleware/auth.js';
|
||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||
import type { RepoSet, TableIds } from '../../src/repos/baserow-repo.js';
|
||||
import { attributionsRoutes } from '../../src/routes/attributions.js';
|
||||
import { formationsRoutes } from '../../src/routes/formations.js';
|
||||
import { interventionsRoutes } from '../../src/routes/interventions.js';
|
||||
import { modulesRoutes } from '../../src/routes/modules.js';
|
||||
import { personnesRoutes } from '../../src/routes/personnes.js';
|
||||
import { projetsRoutes } from '../../src/routes/projets.js';
|
||||
import { tablesRoutes } from '../../src/routes/tables.js';
|
||||
import { webhooksRoutes } from '../../src/routes/webhooks.js';
|
||||
|
||||
const FAKE_TABLE_IDS: TableIds = {
|
||||
personne: 1,
|
||||
formation: 2,
|
||||
bloc: 3,
|
||||
module: 4,
|
||||
attribution: 5,
|
||||
client: 6,
|
||||
projet: 7,
|
||||
tache: 8,
|
||||
intervention: 9,
|
||||
};
|
||||
|
||||
export const READ_ALL_TOKEN = 'brg_read_all';
|
||||
export const WRITE_ALL_TOKEN = 'brg_write_all';
|
||||
export const READ_TOKEN = 'brg_read';
|
||||
export const WRITE_TOKEN = 'brg_write';
|
||||
export const ADMIN_TOKEN = 'brg_admin';
|
||||
|
||||
export const TEST_TOKENS: ApiTokenRecord[] = [
|
||||
{
|
||||
token: READ_ALL_TOKEN,
|
||||
name: 'test-read',
|
||||
scopes: ['read:personnes', 'read:formations', 'read:projets'],
|
||||
},
|
||||
{
|
||||
token: WRITE_ALL_TOKEN,
|
||||
name: 'test-write',
|
||||
scopes: ['write:attributions', 'write:interventions'],
|
||||
},
|
||||
{ token: READ_TOKEN, name: 'test-read', scopes: ['read:tables'] },
|
||||
{ token: WRITE_TOKEN, name: 'test-write', scopes: ['read:tables', 'write:tables'] },
|
||||
{ token: ADMIN_TOKEN, name: 'test-admin', scopes: ['admin:*'] },
|
||||
];
|
||||
|
||||
|
|
@ -92,7 +67,6 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
baserowWebhookSecret: 'fake_secret_at_least_16_chars',
|
||||
docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars',
|
||||
bridgeApiTokens: undefined,
|
||||
authStrictMapping: true,
|
||||
rateLimitGlobalMax: 10000,
|
||||
rateLimitGlobalWindow: 60,
|
||||
rateLimitMutationMax: 10000,
|
||||
|
|
@ -102,7 +76,6 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
redis: fakeRedis,
|
||||
repos: over.repos,
|
||||
tokens: tokensMap,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
oidc: null,
|
||||
groupsScopesMap: {},
|
||||
logger,
|
||||
|
|
@ -111,14 +84,6 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
return container;
|
||||
}
|
||||
|
||||
const NOOP_CACHE = {
|
||||
get: async <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 {
|
||||
setContainer(null);
|
||||
}
|
||||
|
|
@ -139,18 +104,10 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab
|
|||
tokens: container.tokens,
|
||||
oidc: container.oidc,
|
||||
groupsScopesMap: container.groupsScopesMap,
|
||||
strictMapping: container.config.authStrictMapping,
|
||||
cache: NOOP_CACHE,
|
||||
finder: NOOP_FINDER,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/formations', formationsRoutes);
|
||||
v1.route('/projets', projetsRoutes);
|
||||
v1.route('/modules', modulesRoutes);
|
||||
v1.route('/interventions', interventionsRoutes);
|
||||
v1.route('/attributions', attributionsRoutes);
|
||||
v1.route('/tables', tablesRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -3,39 +3,22 @@
|
|||
* PAS sur /api/health, /api/ready, /api/webhooks/*. Et que l'invalidation
|
||||
* cache est declenchee apres POST/PATCH/PUT/DELETE sur les routes mutation.
|
||||
*
|
||||
* On reconstitue une mini app proche de buildApp() (sans serve()) avec un
|
||||
* fake Redis qui compte les calls invalidatePattern + checkRateLimit.
|
||||
* R1 — Reecrit pour /api/v1/tables/:id/rows (proxy generique).
|
||||
*/
|
||||
|
||||
import { Decimal } from 'decimal.js';
|
||||
import { Hono } from 'hono';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import type { Personne } from '../../src/domain/personne.js';
|
||||
import { Row } from '../../src/domain/row.js';
|
||||
import type { RepoSet } from '../../src/lib/container.js';
|
||||
import { setContainer } from '../../src/lib/container.js';
|
||||
import { errors } from '../../src/lib/errors.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
import { type AuthVariables, authMiddleware } from '../../src/middleware/auth.js';
|
||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||
import { defaultRateLimitKey, rateLimit } from '../../src/middleware/rate-limit.js';
|
||||
import { attributionsRoutes } from '../../src/routes/attributions.js';
|
||||
import { interventionsRoutes } from '../../src/routes/interventions.js';
|
||||
import { modulesRoutes } from '../../src/routes/modules.js';
|
||||
import { personnesRoutes } from '../../src/routes/personnes.js';
|
||||
import { tablesRoutes } from '../../src/routes/tables.js';
|
||||
import { webhooksRoutes } from '../../src/routes/webhooks.js';
|
||||
import { buildFakeRepos } from '../helpers/fake-repos.js';
|
||||
import { makeAttribution, makePersonne } from '../helpers/fixtures.js';
|
||||
|
||||
const TABLE_IDS = {
|
||||
personne: 1,
|
||||
formation: 2,
|
||||
bloc: 3,
|
||||
module: 4,
|
||||
attribution: 5,
|
||||
client: 6,
|
||||
projet: 7,
|
||||
tache: 8,
|
||||
intervention: 9,
|
||||
} as const;
|
||||
|
||||
class FakeRedis {
|
||||
public invalidations: string[] = [];
|
||||
|
|
@ -62,13 +45,36 @@ class FakeRedis {
|
|||
}
|
||||
}
|
||||
|
||||
class FakeRowsRepo {
|
||||
async list(tableId: number) {
|
||||
return {
|
||||
items: [new Row({ id: 1, tableId, fields: { nom: 'X' } })],
|
||||
meta: { page: 1, per_page: 50, total: 1, total_pages: 1 },
|
||||
};
|
||||
}
|
||||
async get(tableId: number, rowId: number): Promise<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 WRITE_TOKEN = 'brg_write';
|
||||
|
||||
function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
||||
const personne = makePersonne({ id: 1, roles: ['formateur'] });
|
||||
const attribution = makeAttribution({ id: 500 });
|
||||
const repos = buildFakeRepos({ personnes: [personne], attributions: [attribution] });
|
||||
const repos: RepoSet = {
|
||||
tables: {} as RepoSet['tables'],
|
||||
fields: {} as RepoSet['fields'],
|
||||
views: {} as RepoSet['views'],
|
||||
rows: new FakeRowsRepo() as unknown as RepoSet['rows'],
|
||||
};
|
||||
|
||||
setContainer({
|
||||
config: {
|
||||
|
|
@ -81,7 +87,6 @@ function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationM
|
|||
baserowWebhookSecret: 'fake-secret-at-least-16-chars-long',
|
||||
docmostWebhookSecret: undefined,
|
||||
bridgeApiTokens: undefined,
|
||||
authStrictMapping: true,
|
||||
rateLimitGlobalMax: opts.globalMax,
|
||||
rateLimitGlobalWindow: 60,
|
||||
rateLimitMutationMax: opts.mutationMax,
|
||||
|
|
@ -92,18 +97,16 @@ function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationM
|
|||
redis: redis as unknown as RedisCache,
|
||||
repos,
|
||||
tokens: new Map([
|
||||
[READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }],
|
||||
[READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:tables'] }],
|
||||
[
|
||||
WRITE_TOKEN,
|
||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] },
|
||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['read:tables', 'write:tables'] },
|
||||
],
|
||||
]),
|
||||
tableIds: TABLE_IDS,
|
||||
oidc: null,
|
||||
groupsScopesMap: {},
|
||||
logger,
|
||||
});
|
||||
return repos;
|
||||
}
|
||||
|
||||
function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
||||
|
|
@ -119,20 +122,14 @@ function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; muta
|
|||
'*',
|
||||
authMiddleware({
|
||||
tokens: new Map([
|
||||
[READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }],
|
||||
[READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:tables'] }],
|
||||
[
|
||||
WRITE_TOKEN,
|
||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] },
|
||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['read:tables', 'write:tables'] },
|
||||
],
|
||||
]),
|
||||
oidc: null,
|
||||
groupsScopesMap: {},
|
||||
strictMapping: true,
|
||||
cache: {
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
},
|
||||
finder: { findByEmail: async (): Promise<Personne | null> => null },
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
|
@ -149,17 +146,14 @@ function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; muta
|
|||
}
|
||||
await next();
|
||||
});
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/modules', modulesRoutes);
|
||||
v1.route('/interventions', interventionsRoutes);
|
||||
v1.route('/attributions', attributionsRoutes);
|
||||
v1.route('/tables', tablesRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => setContainer(null));
|
||||
|
||||
describe('Rate limit application sur /api/v1/*', () => {
|
||||
describe('Rate limit + cache invalidation sur /api/v1/*', () => {
|
||||
it('GET /api/health : pas de rate limit (route publique)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 1, mutationMax: 1 });
|
||||
|
|
@ -197,12 +191,12 @@ describe('Rate limit application sur /api/v1/*', () => {
|
|||
expect(redis.rateChecks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('GET /api/v1/personnes : rate limit consomme', async () => {
|
||||
it('GET /api/v1/tables/5/rows : rate limit consomme', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 });
|
||||
|
||||
const r = await app.request('/api/v1/personnes', {
|
||||
const r = await app.request('/api/v1/tables/5/rows', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
|
|
@ -216,41 +210,42 @@ describe('Rate limit application sur /api/v1/*', () => {
|
|||
const app = buildAppWithRateLimit(redis, { globalMax: 2, mutationMax: 100 });
|
||||
|
||||
const headers = { Authorization: `Bearer ${READ_TOKEN}` };
|
||||
const r1 = await app.request('/api/v1/personnes', { headers });
|
||||
const r2 = await app.request('/api/v1/personnes', { headers });
|
||||
const r3 = await app.request('/api/v1/personnes', { headers });
|
||||
const r1 = await app.request('/api/v1/tables/5/rows', { headers });
|
||||
const r2 = await app.request('/api/v1/tables/5/rows', { headers });
|
||||
const r3 = await app.request('/api/v1/tables/5/rows', { headers });
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r3.status).toBe(429);
|
||||
});
|
||||
|
||||
it('PATCH attribution : applique mutation rate limit + invalide le cache', async () => {
|
||||
it('POST row : applique mutation rate limit + invalide le cache de la table', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 });
|
||||
|
||||
const r = await app.request('/api/v1/attributions/500/heures-realisees', {
|
||||
method: 'PATCH',
|
||||
const r = await app.request('/api/v1/tables/5/rows', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ heures_realisees: 4 }),
|
||||
body: JSON.stringify({ nom: 'X', heures: 40 }),
|
||||
});
|
||||
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.status).toBe(201);
|
||||
|
||||
// Deux compteurs Redis distincts : token:writer et token:writer:mut.
|
||||
const keys = redis.rateChecks.map((c) => c.key);
|
||||
expect(keys).toContain('token:writer');
|
||||
expect(keys).toContain('token:writer:mut');
|
||||
|
||||
// Cache invalidation : attribution row + list + cascade module + personne.
|
||||
expect(redis.invalidations).toContain('bridge:attribution:list:*');
|
||||
expect(redis.invalidations).toContain('bridge:attribution:row:500');
|
||||
expect(redis.invalidations).toContain('bridge:module:list:*');
|
||||
expect(redis.invalidations).toContain('bridge:personne:list:*');
|
||||
// Cache invalidation generique : juste la table touchee + sa row.
|
||||
expect(redis.invalidations).toContain('bridge:tables:5:list:*');
|
||||
expect(redis.invalidations).toContain('bridge:tables:5:views:*');
|
||||
expect(redis.invalidations.some((p) => p.startsWith('bridge:tables:5:row:'))).toBe(true);
|
||||
// Pas de cascade cross-table.
|
||||
expect(redis.invalidations.every((p) => p.startsWith('bridge:tables:5:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('mutation au-dela de mutationMax -> 429 meme si globalMax pas atteint', async () => {
|
||||
|
|
@ -259,20 +254,12 @@ describe('Rate limit application sur /api/v1/*', () => {
|
|||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 1 });
|
||||
|
||||
const headers = { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json' };
|
||||
const body = JSON.stringify({ heures_realisees: new Decimal(1).toNumber() });
|
||||
const body = JSON.stringify({ nom: 'X' });
|
||||
|
||||
const r1 = await app.request('/api/v1/attributions/500/heures-realisees', {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const r2 = await app.request('/api/v1/attributions/500/heures-realisees', {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const r1 = await app.request('/api/v1/tables/5/rows', { method: 'POST', headers, body });
|
||||
const r2 = await app.request('/api/v1/tables/5/rows', { method: 'POST', headers, body });
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.status).toBe(201);
|
||||
expect(r2.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
/**
|
||||
* Tests integration auth middleware — dual mode service-token + OIDC.
|
||||
* Tests integration auth middleware (R1 generique) — dual mode service-token + OIDC.
|
||||
*
|
||||
* R1 — Plus de lookup PersonneRepo, plus de roles formation-hub. Le claim
|
||||
* `acadenice_permissions[]` du JWT alimente directement les scopes.
|
||||
*
|
||||
* Strategie JWKS : mini serveur HTTP local qui expose un /.well-known/jwks.json
|
||||
* avec une cle RSA generee a la volee via `jose.generateKeyPair`. Plus realiste
|
||||
|
|
@ -8,16 +11,15 @@
|
|||
|
||||
import { type Server, createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { Decimal } from 'decimal.js';
|
||||
import { Hono } from 'hono';
|
||||
import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Personne } from '../../src/domain/personne.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
import {
|
||||
type ApiTokenRecord,
|
||||
type AuthVariables,
|
||||
authMiddleware,
|
||||
extractPermissions,
|
||||
hasScope,
|
||||
parseTokens,
|
||||
requireScope,
|
||||
|
|
@ -86,57 +88,6 @@ async function signJwt(
|
|||
return builder.sign(fx.privateKey);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes pour cache + finder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeCache {
|
||||
store = new Map<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -144,9 +95,6 @@ function makePersonne(opts: {
|
|||
interface BuildAppOpts {
|
||||
tokens?: ApiTokenRecord[];
|
||||
oidcEnabled: boolean;
|
||||
strictMapping?: boolean;
|
||||
cache?: FakeCache;
|
||||
finder?: FakeFinder;
|
||||
jwks?: JwksFixture;
|
||||
groupsScopesMap?: Record<string, string[]>;
|
||||
}
|
||||
|
|
@ -156,9 +104,6 @@ function buildApp(opts: BuildAppOpts) {
|
|||
const map = new Map<string, ApiTokenRecord>();
|
||||
for (const t of tokens) map.set(t.token, t);
|
||||
|
||||
const cache = opts.cache ?? new FakeCache();
|
||||
const finder = opts.finder ?? new FakeFinder();
|
||||
|
||||
let verifier: OidcVerifier | null = null;
|
||||
if (opts.oidcEnabled) {
|
||||
if (!opts.jwks) throw new Error('jwks fixture required when oidcEnabled');
|
||||
|
|
@ -179,9 +124,6 @@ function buildApp(opts: BuildAppOpts) {
|
|||
tokens: map,
|
||||
oidc: verifier,
|
||||
groupsScopesMap: opts.groupsScopesMap ?? {},
|
||||
strictMapping: opts.strictMapping ?? true,
|
||||
cache,
|
||||
finder,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
|
@ -190,12 +132,12 @@ function buildApp(opts: BuildAppOpts) {
|
|||
const user = c.get('user');
|
||||
return c.json({ user });
|
||||
});
|
||||
app.get('/protected/needs-formation-read', requireScope('formation:read'), (c) =>
|
||||
app.get('/protected/needs-read-tables', requireScope('read:tables'), (c) =>
|
||||
c.json({ ok: true, scopes: c.get('user').scopes }),
|
||||
);
|
||||
app.get('/protected/needs-admin', requireScope('admin:write'), (c) => c.json({ ok: true }));
|
||||
|
||||
return { app, cache, finder };
|
||||
return { app };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -205,7 +147,7 @@ function buildApp(opts: BuildAppOpts) {
|
|||
describe('parseTokens', () => {
|
||||
it('parse JSON valide', () => {
|
||||
const map = parseTokens(
|
||||
JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:personnes'] }]),
|
||||
JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:tables'] }]),
|
||||
);
|
||||
expect(map.get('brg_x')?.name).toBe('a');
|
||||
});
|
||||
|
|
@ -232,21 +174,39 @@ describe('parseTokens', () => {
|
|||
|
||||
describe('hasScope', () => {
|
||||
it('match exact', () => {
|
||||
expect(hasScope(new Set(['read:personnes']), 'read:personnes')).toBe(true);
|
||||
expect(hasScope(new Set(['read:personnes']), 'read:projets')).toBe(false);
|
||||
expect(hasScope(new Set(['read:tables']), 'read:tables')).toBe(true);
|
||||
expect(hasScope(new Set(['read:tables']), 'write:tables')).toBe(false);
|
||||
});
|
||||
it('admin:* couvre tout', () => {
|
||||
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
|
||||
});
|
||||
it('prefix wildcard couvre meme prefix', () => {
|
||||
expect(hasScope(new Set(['read:*']), 'read:personnes')).toBe(true);
|
||||
expect(hasScope(new Set(['read:*']), 'write:personnes')).toBe(false);
|
||||
expect(hasScope(new Set(['read:*']), 'read:tables')).toBe(true);
|
||||
expect(hasScope(new Set(['read:*']), 'write:tables')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth middleware — service tokens (mode local)', () => {
|
||||
describe('extractPermissions', () => {
|
||||
it('retourne le claim acadenice_permissions[] si present', () => {
|
||||
expect(extractPermissions({ acadenice_permissions: ['a', 'b'] })).toEqual(['a', 'b']);
|
||||
});
|
||||
it('retourne [] si claim absent', () => {
|
||||
expect(extractPermissions({})).toEqual([]);
|
||||
});
|
||||
it('retourne [] si claim pas un array', () => {
|
||||
expect(extractPermissions({ acadenice_permissions: 'foo' })).toEqual([]);
|
||||
});
|
||||
it('filtre les valeurs non-strings et vides', () => {
|
||||
expect(extractPermissions({ acadenice_permissions: ['ok', 1, '', null, 'good'] })).toEqual([
|
||||
'ok',
|
||||
'good',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth middleware — service tokens', () => {
|
||||
const tokens: ApiTokenRecord[] = [
|
||||
{ token: 'brg_valid', name: 'demo', scopes: ['formation:read', 'admin:write'] },
|
||||
{ token: 'brg_valid', name: 'demo', scopes: ['read:tables', 'admin:write'] },
|
||||
];
|
||||
|
||||
it('cas 1 — service token valid via Bearer -> 200 + source=service-token', async () => {
|
||||
|
|
@ -260,7 +220,7 @@ describe('auth middleware — service tokens (mode local)', () => {
|
|||
};
|
||||
expect(body.user.source).toBe('service-token');
|
||||
expect(body.user.tokenId).toBe('demo');
|
||||
expect(body.user.scopes).toContain('formation:read');
|
||||
expect(body.user.scopes).toContain('read:tables');
|
||||
});
|
||||
|
||||
it('service token valid via ApiKey scheme -> 200', async () => {
|
||||
|
|
@ -320,7 +280,7 @@ describe('auth middleware — OIDC desactive + JWT envoye', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('auth middleware — OIDC actif', () => {
|
||||
describe('auth middleware — OIDC actif (R1 generique)', () => {
|
||||
let jwks: JwksFixture;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -331,18 +291,13 @@ describe('auth middleware — OIDC actif', () => {
|
|||
await new Promise<void>((resolve) => jwks.server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('cas 4 — JWT valid + email -> Personne -> 200, source=oidc-jwt, roles', async () => {
|
||||
const finder = new FakeFinder();
|
||||
finder.byEmail.set(
|
||||
'jane@acadenice.fr',
|
||||
makePersonne({ id: 42, email: 'jane@acadenice.fr', roles: ['formateur'] }),
|
||||
);
|
||||
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks, finder });
|
||||
it('cas 4 — JWT valid + claim acadenice_permissions[] -> 200, scopes alimentes directement', async () => {
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'jane@acadenice.fr',
|
||||
sub: 'authentik-jane-uuid',
|
||||
groups: ['formation-hub-formateurs'],
|
||||
groups: [],
|
||||
acadenice_permissions: ['read:tables', 'write:tables'],
|
||||
});
|
||||
|
||||
const res = await app.request('/protected/me', {
|
||||
|
|
@ -350,22 +305,80 @@ describe('auth middleware — OIDC actif', () => {
|
|||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
user: { source: string; personneId: number; roles: string[]; scopes: string[]; sub: string };
|
||||
user: {
|
||||
source: string;
|
||||
scopes: string[];
|
||||
permissions: string[];
|
||||
sub: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
expect(body.user.source).toBe('oidc-jwt');
|
||||
expect(body.user.personneId).toBe(42);
|
||||
expect(body.user.roles).toContain('formateur');
|
||||
expect(body.user.email).toBe('jane@acadenice.fr');
|
||||
expect(body.user.sub).toBe('authentik-jane-uuid');
|
||||
// Default role->scope mapping pour formateur inclut formation:* style write:attributions
|
||||
expect(body.user.scopes).toContain('write:attributions');
|
||||
expect(body.user.scopes).toContain('read:tables');
|
||||
expect(body.user.scopes).toContain('write:tables');
|
||||
expect(body.user.permissions).toEqual(['read:tables', 'write:tables']);
|
||||
});
|
||||
|
||||
it('cas 4b — JWT sans claim acadenice_permissions[] : auth OK mais scopes vides', async () => {
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'jane@acadenice.fr',
|
||||
sub: 'sub-x',
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { user: { permissions: string[]; scopes: string[] } };
|
||||
expect(body.user.permissions).toEqual([]);
|
||||
expect(body.user.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('cas 4c — JWT avec groups Authentik mappes -> scopes via groupsScopesMap', async () => {
|
||||
const { app } = buildApp({
|
||||
oidcEnabled: true,
|
||||
jwks,
|
||||
groupsScopesMap: { 'role-formateur': ['read:tables'] },
|
||||
});
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'fmt@acadenice.fr',
|
||||
groups: ['role-formateur'],
|
||||
});
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { user: { scopes: string[]; groups: string[] } };
|
||||
expect(body.user.scopes).toContain('read:tables');
|
||||
expect(body.user.groups).toContain('role-formateur');
|
||||
});
|
||||
|
||||
it('cas 4d — union groups + permissions explicites', async () => {
|
||||
const { app } = buildApp({
|
||||
oidcEnabled: true,
|
||||
jwks,
|
||||
groupsScopesMap: { 'role-x': ['read:tables'] },
|
||||
});
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'x@acadenice.fr',
|
||||
groups: ['role-x'],
|
||||
acadenice_permissions: ['write:tables'],
|
||||
});
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = (await res.json()) as { user: { scopes: string[] } };
|
||||
expect(body.user.scopes).toContain('read:tables');
|
||||
expect(body.user.scopes).toContain('write:tables');
|
||||
});
|
||||
|
||||
it('cas 5 — JWT signature invalide -> 401 AUTH_INVALID', async () => {
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||
const token = await signJwt(jwks, { email: 'x@y.z' });
|
||||
// Tamper signature : remplace les 16 derniers chars par des '0' base64url valides.
|
||||
// Garantit que la signature change vraiment (un seul char flip peut tomber sur
|
||||
// une variation base64 qui code la meme valeur binaire).
|
||||
const tampered = `${token.slice(0, -16)}AAAAAAAAAAAAAAAA`;
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${tampered}` },
|
||||
|
|
@ -402,96 +415,42 @@ describe('auth middleware — OIDC actif', () => {
|
|||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('cas 9 — JWT email orphelin (mode strict) -> 403 FORBIDDEN', async () => {
|
||||
const { app, finder } = buildApp({ oidcEnabled: true, jwks, strictMapping: true });
|
||||
it('cas 9 — Cookie authToken valid -> 200, source=oidc-cookie', async () => {
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'nobody@acadenice.fr',
|
||||
sub: 'authentik-nobody',
|
||||
groups: [],
|
||||
email: 'cookie@acadenice.fr',
|
||||
acadenice_permissions: ['read:tables'],
|
||||
});
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('FORBIDDEN');
|
||||
expect(finder.calls).toBe(1);
|
||||
});
|
||||
|
||||
it('mode permissif : email orphelin -> 200 avec scopes des groups uniquement', async () => {
|
||||
const { app } = buildApp({
|
||||
oidcEnabled: true,
|
||||
jwks,
|
||||
strictMapping: false,
|
||||
groupsScopesMap: { 'formation-hub-formateurs': ['formation:read'] },
|
||||
});
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'nobody@acadenice.fr',
|
||||
groups: ['formation-hub-formateurs'],
|
||||
});
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
user: { scopes: string[]; personneId?: number; roles: string[] };
|
||||
};
|
||||
expect(body.user.scopes).toContain('formation:read');
|
||||
expect(body.user.personneId).toBeUndefined();
|
||||
expect(body.user.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('cas 10 — Cookie authToken valid -> 200, source=oidc-cookie', async () => {
|
||||
const finder = new FakeFinder();
|
||||
finder.byEmail.set(
|
||||
'cookie@acadenice.fr',
|
||||
makePersonne({ id: 7, email: 'cookie@acadenice.fr', roles: ['developpeur'] }),
|
||||
);
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks, finder });
|
||||
const token = await signJwt(jwks, { email: 'cookie@acadenice.fr', groups: [] });
|
||||
const res = await app.request('/protected/me', {
|
||||
headers: { Cookie: `authToken=${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { user: { source: string; personneId: number } };
|
||||
const body = (await res.json()) as { user: { source: string; email: string } };
|
||||
expect(body.user.source).toBe('oidc-cookie');
|
||||
expect(body.user.personneId).toBe(7);
|
||||
expect(body.user.email).toBe('cookie@acadenice.fr');
|
||||
});
|
||||
|
||||
it('cas 11 — requireScope match via groups Authentik -> 200', async () => {
|
||||
const finder = new FakeFinder();
|
||||
finder.byEmail.set(
|
||||
'fmt@acadenice.fr',
|
||||
makePersonne({ id: 1, email: 'fmt@acadenice.fr', roles: [] }),
|
||||
);
|
||||
const { app } = buildApp({
|
||||
oidcEnabled: true,
|
||||
jwks,
|
||||
finder,
|
||||
groupsScopesMap: { 'formation-hub-formateurs': ['formation:read'] },
|
||||
});
|
||||
it('cas 10 — requireScope match via permissions claim -> 200', async () => {
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'fmt@acadenice.fr',
|
||||
groups: ['formation-hub-formateurs'],
|
||||
email: 'a@b.c',
|
||||
acadenice_permissions: ['read:tables'],
|
||||
});
|
||||
const res = await app.request('/protected/needs-formation-read', {
|
||||
const res = await app.request('/protected/needs-read-tables', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean; scopes: string[] };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.scopes).toContain('formation:read');
|
||||
expect(body.scopes).toContain('read:tables');
|
||||
});
|
||||
|
||||
it('cas 12 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => {
|
||||
const finder = new FakeFinder();
|
||||
finder.byEmail.set(
|
||||
'fmt2@acadenice.fr',
|
||||
makePersonne({ id: 2, email: 'fmt2@acadenice.fr', roles: ['formateur'] }),
|
||||
);
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks, finder });
|
||||
const token = await signJwt(jwks, { email: 'fmt2@acadenice.fr', groups: [] });
|
||||
// formateur n'a pas admin:write par defaut.
|
||||
it('cas 11 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => {
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks });
|
||||
const token = await signJwt(jwks, {
|
||||
email: 'a@b.c',
|
||||
acadenice_permissions: ['read:tables'],
|
||||
});
|
||||
const res = await app.request('/protected/needs-admin', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
|
@ -499,42 +458,4 @@ describe('auth middleware — OIDC actif', () => {
|
|||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('FORBIDDEN_SCOPE');
|
||||
});
|
||||
|
||||
it("cache hit : 2 requetes consecutives ne tapent qu'une fois le repo", async () => {
|
||||
const finder = new FakeFinder();
|
||||
finder.byEmail.set(
|
||||
'cached@acadenice.fr',
|
||||
makePersonne({ id: 99, email: 'cached@acadenice.fr', roles: ['developpeur'] }),
|
||||
);
|
||||
const cache = new FakeCache();
|
||||
const { app } = buildApp({ oidcEnabled: true, jwks, finder, cache });
|
||||
const token = await signJwt(jwks, { email: 'cached@acadenice.fr', groups: [] });
|
||||
|
||||
const res1 = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
const res2 = await app.request('/protected/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res2.status).toBe(200);
|
||||
expect(finder.calls).toBe(1);
|
||||
expect(cache.hits).toBe(1);
|
||||
});
|
||||
|
||||
it('cache miss persist : email inexistant => second appel hit cache', async () => {
|
||||
const finder = new FakeFinder();
|
||||
const cache = new FakeCache();
|
||||
const { app } = buildApp({
|
||||
oidcEnabled: true,
|
||||
jwks,
|
||||
finder,
|
||||
cache,
|
||||
strictMapping: false,
|
||||
});
|
||||
const token = await signJwt(jwks, { email: 'ghost@acadenice.fr', groups: [] });
|
||||
await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||
await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||
expect(finder.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ interface FakeUser {
|
|||
tokenId?: string;
|
||||
email?: string;
|
||||
sub?: string;
|
||||
roles: string[];
|
||||
groups: string[];
|
||||
permissions: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ describe('rateLimit middleware', () => {
|
|||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-A',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
|
|
@ -111,7 +111,7 @@ describe('rateLimit middleware', () => {
|
|||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-B',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
|
|
@ -134,7 +134,7 @@ describe('rateLimit middleware', () => {
|
|||
const user: FakeUser = {
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-C',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
};
|
||||
|
|
@ -181,7 +181,7 @@ describe('rateLimit middleware', () => {
|
|||
tokenId: 'svc-D',
|
||||
email: 'should-not-be-used@test',
|
||||
sub: 'sub-x',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
|
|
@ -200,7 +200,7 @@ describe('rateLimit middleware', () => {
|
|||
source: 'oidc-jwt',
|
||||
email: 'Foo@Bar.IO',
|
||||
sub: 'sub-y',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
|
|
@ -219,7 +219,7 @@ describe('rateLimit middleware', () => {
|
|||
{
|
||||
source: 'oidc-jwt',
|
||||
sub: 'sub-z',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
|
|
@ -258,7 +258,7 @@ describe('rateLimit middleware', () => {
|
|||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 5, window: 60, keyFrom: () => 'custom-key' },
|
||||
{ source: 'service-token', tokenId: 'svc-E', roles: [], groups: [], scopes: [] },
|
||||
{ source: 'service-token', tokenId: 'svc-E', permissions: [], groups: [], scopes: [] },
|
||||
);
|
||||
|
||||
await app.request('/');
|
||||
|
|
@ -273,7 +273,7 @@ describe('rateLimit middleware', () => {
|
|||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-F',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_ROLE_SCOPES,
|
||||
computeOidcScopes,
|
||||
parseGroupsScopesMap,
|
||||
} from '../../src/middleware/scopes.js';
|
||||
import { computeOidcScopes, parseGroupsScopesMap } from '../../src/middleware/scopes.js';
|
||||
|
||||
describe('parseGroupsScopesMap', () => {
|
||||
it('retourne {} si vide', () => {
|
||||
|
|
@ -31,32 +27,40 @@ describe('parseGroupsScopesMap', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('computeOidcScopes', () => {
|
||||
it('union groups + roles + dedup', () => {
|
||||
const scopes = computeOidcScopes(['formation-hub-formateurs'], new Set(['formateur']), {
|
||||
'formation-hub-formateurs': ['formation:read', 'admin:custom'],
|
||||
describe('computeOidcScopes (R1 generique)', () => {
|
||||
it('union groups + permissions + dedup', () => {
|
||||
const scopes = computeOidcScopes(['group-formateurs'], ['custom:perm', 'read:tables'], {
|
||||
'group-formateurs': ['read:tables', 'admin:custom'],
|
||||
});
|
||||
expect(scopes).toContain('formation:read');
|
||||
expect(scopes).toContain('read:tables');
|
||||
expect(scopes).toContain('admin:custom');
|
||||
// Vient du DEFAULT_ROLE_SCOPES.formateur
|
||||
expect(scopes).toContain('write:attributions');
|
||||
expect(scopes).toContain('custom:perm');
|
||||
// Dedup : read:tables apparait une seule fois
|
||||
expect(scopes.filter((s) => s === 'read:tables')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("group inconnu ignore (pas d'erreur)", () => {
|
||||
const scopes = computeOidcScopes(['unknown-group'], new Set(), {});
|
||||
const scopes = computeOidcScopes(['unknown-group'], [], {});
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('default mapping admin role -> admin:*', () => {
|
||||
const scopes = computeOidcScopes([], new Set(['admin']), {});
|
||||
expect(scopes).toContain('admin:*');
|
||||
it('aucun group + aucune permission = scopes vides', () => {
|
||||
expect(computeOidcScopes([], [], {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('aucun group + aucun role = scopes vides', () => {
|
||||
expect(computeOidcScopes([], new Set(), {})).toEqual([]);
|
||||
it('permissions explicites sans group fonctionnent (claim direct du JWT)', () => {
|
||||
const scopes = computeOidcScopes([], ['read:tables', 'write:tables'], {});
|
||||
expect(scopes).toContain('read:tables');
|
||||
expect(scopes).toContain('write:tables');
|
||||
});
|
||||
|
||||
it('DEFAULT_ROLE_SCOPES couvre les 5 roles', () => {
|
||||
expect(Object.keys(DEFAULT_ROLE_SCOPES)).toHaveLength(5);
|
||||
it('ignore les permissions non-strings ou vides', () => {
|
||||
const scopes = computeOidcScopes([], ['ok', '', 'also-ok'], {});
|
||||
expect(scopes).toEqual(['also-ok', 'ok']);
|
||||
});
|
||||
|
||||
it('output trie alphabetiquement (stabilite)', () => {
|
||||
const scopes = computeOidcScopes(['g'], ['z', 'a'], { g: ['m', 'b'] });
|
||||
expect(scopes).toEqual(['a', 'b', 'm', 'z']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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,
|
||||
* la cascade rollups parent, et l'idempotence (deux invalidations meme key).
|
||||
* Tests unit pour invalidateTable — verifie les patterns generes (generique
|
||||
* style Notion, plus de cascade rollup metier).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type CacheInvalidator, invalidateEntity } from '../../src/lib/cache.js';
|
||||
import { type CacheInvalidator, invalidateTable } from '../../src/lib/cache.js';
|
||||
|
||||
class FakeRedis implements CacheInvalidator {
|
||||
public patterns: string[] = [];
|
||||
// Map pour simuler des keys persistees (incrementee a chaque set fictif).
|
||||
public callCount = 0;
|
||||
|
||||
async invalidatePattern(pattern: string): Promise<number> {
|
||||
this.patterns.push(pattern);
|
||||
this.callCount++;
|
||||
return 1; // un match fictif
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
describe('invalidateEntity', () => {
|
||||
it('attribution : cascade sur module + personne (rollups RG-01)', async () => {
|
||||
describe('invalidateTable', () => {
|
||||
it('avec rowId : invalide list + views + row precis', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'attribution', 42);
|
||||
await invalidateTable(redis, 42, 100);
|
||||
|
||||
expect(redis.patterns).toContain('bridge:attribution:list:*');
|
||||
expect(redis.patterns).toContain('bridge:attribution:row:42');
|
||||
expect(redis.patterns).toContain('bridge:module:row:*');
|
||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:row:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
||||
expect(redis.patterns).toContain('bridge:tables:42:list:*');
|
||||
expect(redis.patterns).toContain('bridge:tables:42:views:*');
|
||||
expect(redis.patterns).toContain('bridge:tables:42:row:100');
|
||||
expect(redis.patterns).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('intervention : cascade sur tache + personne', async () => {
|
||||
it('sans rowId : invalide list + views uniquement', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'intervention', 100);
|
||||
await invalidateTable(redis, 42);
|
||||
|
||||
expect(redis.patterns).toContain('bridge:intervention:list:*');
|
||||
expect(redis.patterns).toContain('bridge:intervention:row:100');
|
||||
expect(redis.patterns).toContain('bridge:tache:row:*');
|
||||
expect(redis.patterns).toContain('bridge:tache:list:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:row:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
||||
});
|
||||
|
||||
it('module : cascade sur bloc + formation', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'module', 7);
|
||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
||||
expect(redis.patterns).toContain('bridge:module:row:7');
|
||||
expect(redis.patterns).toContain('bridge:bloc:row:*');
|
||||
expect(redis.patterns).toContain('bridge:bloc:list:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:row:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
||||
});
|
||||
|
||||
it('bloc : cascade formation seulement', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'bloc', 3);
|
||||
expect(redis.patterns).toContain('bridge:bloc:list:*');
|
||||
expect(redis.patterns).toContain('bridge:bloc:row:3');
|
||||
expect(redis.patterns).toContain('bridge:formation:row:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
||||
// Pas de cascade modules au-dessus.
|
||||
expect(redis.patterns).not.toContain('bridge:module:list:*');
|
||||
});
|
||||
|
||||
it('tache : cascade projet', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'tache', 8);
|
||||
expect(redis.patterns).toContain('bridge:tache:list:*');
|
||||
expect(redis.patterns).toContain('bridge:tache:row:8');
|
||||
expect(redis.patterns).toContain('bridge:projet:row:*');
|
||||
expect(redis.patterns).toContain('bridge:projet:list:*');
|
||||
});
|
||||
|
||||
it('projet : cascade client', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'projet', 5);
|
||||
expect(redis.patterns).toContain('bridge:projet:list:*');
|
||||
expect(redis.patterns).toContain('bridge:projet:row:5');
|
||||
expect(redis.patterns).toContain('bridge:client:row:*');
|
||||
expect(redis.patterns).toContain('bridge:client:list:*');
|
||||
});
|
||||
|
||||
it('personne : pas de cascade parent (entite racine)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'personne', 1);
|
||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:row:1');
|
||||
expect(redis.patterns).toContain('bridge:tables:42:list:*');
|
||||
expect(redis.patterns).toContain('bridge:tables:42:views:*');
|
||||
expect(redis.patterns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('formation : pas de cascade parent (entite racine)', async () => {
|
||||
it('pas de cascade cross-table (style Notion : webhook par table)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'formation', 9);
|
||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:row:9');
|
||||
expect(redis.patterns).toHaveLength(2);
|
||||
await invalidateTable(redis, 42, 1);
|
||||
// Aucun pattern d'autre table.
|
||||
expect(redis.patterns.every((p) => p.startsWith('bridge:tables:42:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('client : pas de cascade parent', async () => {
|
||||
it('idempotent : deux invalidations meme key', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'client', 4);
|
||||
expect(redis.patterns).toContain('bridge:client:list:*');
|
||||
expect(redis.patterns).toContain('bridge:client:row:4');
|
||||
expect(redis.patterns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sans id : invalide juste la liste + cascade (cas create avant id connu)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'attribution');
|
||||
|
||||
expect(redis.patterns).toContain('bridge:attribution:list:*');
|
||||
expect(redis.patterns).not.toContain('bridge:attribution:row:undefined');
|
||||
// Cascade toujours appliquee meme sans id.
|
||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
||||
});
|
||||
|
||||
it('idempotent : deux invalidations meme key ne throw pas', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'attribution', 42);
|
||||
await expect(invalidateEntity(redis, 'attribution', 42)).resolves.toBeGreaterThanOrEqual(0);
|
||||
// Les patterns sont appeles deux fois, c'est attendu.
|
||||
expect(redis.patterns.filter((p) => p === 'bridge:attribution:row:42')).toHaveLength(2);
|
||||
await invalidateTable(redis, 42, 100);
|
||||
await expect(invalidateTable(redis, 42, 100)).resolves.toBeGreaterThanOrEqual(0);
|
||||
expect(redis.patterns.filter((p) => p === 'bridge:tables:42:row:100')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retourne le total des keys invalidees', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const total = await invalidateEntity(redis, 'attribution', 1);
|
||||
// FakeRedis retourne 1 par appel, 6 patterns -> 6.
|
||||
expect(total).toBe(6);
|
||||
const total = await invalidateTable(redis, 42, 1);
|
||||
// FakeRedis retourne 1 par appel, 3 patterns.
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
|
||||
it('tableId 0 ou negatif : pattern construit tel quel (le caller doit valider)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateTable(redis, 0);
|
||||
expect(redis.patterns).toContain('bridge:tables:0:list:*');
|
||||
});
|
||||
|
||||
it('rowId numerique 0 inclus comme cle valide', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateTable(redis, 5, 0);
|
||||
expect(redis.patterns).toContain('bridge:tables:5:row:0');
|
||||
});
|
||||
|
||||
it('plusieurs invalidations consecutives sur differentes tables sont independantes', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateTable(redis, 1, 100);
|
||||
await invalidateTable(redis, 2, 200);
|
||||
expect(redis.patterns).toContain('bridge:tables:1:row:100');
|
||||
expect(redis.patterns).toContain('bridge:tables:2:row:200');
|
||||
expect(redis.patterns.filter((p) => p.startsWith('bridge:tables:1:'))).toHaveLength(3);
|
||||
expect(redis.patterns.filter((p) => p.startsWith('bridge:tables:2:'))).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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 { describe, expect, it } from 'vitest';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import type { TableIds } from '../../src/repos/baserow-repo.js';
|
||||
import { handleBaserowEvent } from '../../src/webhooks/baserow-handler.js';
|
||||
import type { BaserowWebhookPayload } from '../../src/webhooks/types.js';
|
||||
|
||||
const FAKE_TABLE_IDS: TableIds = {
|
||||
personne: 1,
|
||||
formation: 2,
|
||||
bloc: 3,
|
||||
module: 4,
|
||||
attribution: 5,
|
||||
client: 6,
|
||||
projet: 7,
|
||||
tache: 8,
|
||||
intervention: 9,
|
||||
};
|
||||
|
||||
class FakeRedis {
|
||||
public calls: string[] = [];
|
||||
invalidatePattern(pattern: string): Promise<number> {
|
||||
|
|
@ -31,143 +22,85 @@ function makePayload(over: Partial<BaserowWebhookPayload> = {}): BaserowWebhookP
|
|||
return {
|
||||
event_id: 'evt-1',
|
||||
event_type: 'rows.created',
|
||||
table_id: 1,
|
||||
items: [{ id: 42 }],
|
||||
table_id: 42,
|
||||
items: [{ id: 100 }],
|
||||
...over,
|
||||
} as BaserowWebhookPayload;
|
||||
}
|
||||
|
||||
describe('handleBaserowEvent', () => {
|
||||
it('rows.created sur personne -> invalide list (pas row id)', async () => {
|
||||
describe('handleBaserowEvent (R1 generique)', () => {
|
||||
it('rows.created sur tableId -> invalide list + views (pas row precis)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const res = await handleBaserowEvent(makePayload(), {
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
});
|
||||
expect(res.status).toBe('processed');
|
||||
expect(res.entity).toBe('personne');
|
||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
||||
expect(redis.calls).not.toContain('bridge:personne:row:42');
|
||||
expect(res.tableId).toBe(42);
|
||||
expect(redis.calls).toContain('bridge:tables:42:list:*');
|
||||
expect(redis.calls).toContain('bridge:tables:42:views:*');
|
||||
expect(redis.calls).not.toContain('bridge:tables:42:row:100');
|
||||
});
|
||||
|
||||
it('rows.updated sur personne -> invalide list + row precis', async () => {
|
||||
it('rows.updated -> invalide list + views + row precis', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', items: [{ id: 42 }, { id: 43 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
makePayload({ event_type: 'rows.updated', items: [{ id: 100 }, { id: 101 }] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:personne:row:42');
|
||||
expect(redis.calls).toContain('bridge:personne:row:43');
|
||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
||||
expect(redis.calls).toContain('bridge:tables:42:row:100');
|
||||
expect(redis.calls).toContain('bridge:tables:42:row:101');
|
||||
expect(redis.calls).toContain('bridge:tables:42:list:*');
|
||||
expect(redis.calls).toContain('bridge:tables:42:views:*');
|
||||
});
|
||||
|
||||
it('rows.deleted sur attribution -> cascade module + personne', async () => {
|
||||
it('rows.deleted -> invalide list + views + row precis', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 100 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
makePayload({ event_type: 'rows.deleted', table_id: 5, items: [{ id: 200 }] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:attribution:row:100');
|
||||
expect(redis.calls).toContain('bridge:module:row:*');
|
||||
expect(redis.calls).toContain('bridge:personne:row:*');
|
||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
||||
expect(redis.calls).toContain('bridge:tables:5:row:200');
|
||||
});
|
||||
|
||||
it('intervention -> cascade tache + personne', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', table_id: 9, items: [{ id: 1 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:tache:row:*');
|
||||
expect(redis.calls).toContain('bridge:personne:row:*');
|
||||
});
|
||||
|
||||
it('module -> cascade bloc + formation', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', table_id: 4, items: [{ id: 1 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:bloc:row:*');
|
||||
expect(redis.calls).toContain('bridge:formation:row:*');
|
||||
});
|
||||
|
||||
it('bloc -> cascade formation', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', table_id: 3, items: [{ id: 1 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:formation:row:*');
|
||||
});
|
||||
|
||||
it('tache -> cascade projet', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', table_id: 8, items: [{ id: 1 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:projet:row:*');
|
||||
});
|
||||
|
||||
it('projet -> cascade client', async () => {
|
||||
it('aucune cascade cross-table : tout reste sous bridge:tables:<tableId>:*', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', table_id: 7, items: [{ id: 1 }] }),
|
||||
{
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
},
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(redis.calls).toContain('bridge:client:row:*');
|
||||
expect(redis.calls.every((p) => p.startsWith('bridge:tables:7:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('table_id inconnu -> ignored, aucune invalidation', async () => {
|
||||
it('table_id <= 0 -> ignored, aucune invalidation', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const res = await handleBaserowEvent(makePayload({ table_id: 99999 }), {
|
||||
const res = await handleBaserowEvent(makePayload({ table_id: 0 }), {
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
});
|
||||
expect(res.status).toBe('ignored');
|
||||
expect(res.entity).toBeNull();
|
||||
expect(res.tableId).toBeNull();
|
||||
expect(redis.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rows.created sans items -> invalide list, pas de row precis', async () => {
|
||||
it('rows.created sans items -> invalide list + views, pas de row precis', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(makePayload({ event_type: 'rows.created', items: [] }), {
|
||||
redis: redis as unknown as RedisCache,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger: silentLogger(),
|
||||
});
|
||||
expect(redis.calls).toContain('bridge:personne:list:*');
|
||||
expect(redis.calls).toContain('bridge:tables:42:list:*');
|
||||
expect(redis.calls).toContain('bridge:tables:42:views:*');
|
||||
expect(redis.calls.some((p) => p.includes(':row:'))).toBe(false);
|
||||
});
|
||||
|
||||
it('renvoie le total des keys invalidees', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const res = await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', items: [{ id: 1 }, { id: 2 }] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
// 4 patterns : list, views, row:1, row:2 → 4 keys.
|
||||
expect(res.invalidatedKeys).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Tests integration des routes /api/webhooks/{baserow,docmost}.
|
||||
* Pas de vrai Redis : on injecte un fake qui implemente l'API minimale necessaire.
|
||||
* Tests integration des routes /api/webhooks/{baserow,docmost} (R1).
|
||||
* Pas de vrai Redis : on injecte un fake qui implemente l'API minimale.
|
||||
*/
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
|
@ -15,18 +15,6 @@ import { webhooksRoutes } from '../../src/routes/webhooks.js';
|
|||
const BASEROW_SECRET = 'baserow-test-secret-32chars-long-ok';
|
||||
const DOCMOST_SECRET = 'docmost-test-secret-32chars-long-ok';
|
||||
|
||||
const TABLE_IDS = {
|
||||
personne: 1,
|
||||
formation: 2,
|
||||
bloc: 3,
|
||||
module: 4,
|
||||
attribution: 5,
|
||||
client: 6,
|
||||
projet: 7,
|
||||
tache: 8,
|
||||
intervention: 9,
|
||||
} as const;
|
||||
|
||||
class FakeRedis {
|
||||
public seen = new Set<string>();
|
||||
public invalidated: string[] = [];
|
||||
|
|
@ -55,7 +43,6 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) {
|
|||
baserowWebhookSecret: BASEROW_SECRET,
|
||||
docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined,
|
||||
bridgeApiTokens: undefined,
|
||||
authStrictMapping: true,
|
||||
rateLimitGlobalMax: 10000,
|
||||
rateLimitGlobalWindow: 60,
|
||||
rateLimitMutationMax: 10000,
|
||||
|
|
@ -67,7 +54,8 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) {
|
|||
// biome-ignore lint/suspicious/noExplicitAny: fake injection
|
||||
repos: {} as any,
|
||||
tokens: new Map(),
|
||||
tableIds: TABLE_IDS,
|
||||
oidc: null,
|
||||
groupsScopesMap: {},
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
|
@ -96,8 +84,8 @@ describe('POST /api/webhooks/baserow', () => {
|
|||
const body = JSON.stringify({
|
||||
event_id: 'evt-baserow-1',
|
||||
event_type: 'rows.created',
|
||||
table_id: 1,
|
||||
items: [{ id: 42 }],
|
||||
table_id: 42,
|
||||
items: [{ id: 100 }],
|
||||
});
|
||||
const res = await app.request('/api/webhooks/baserow', {
|
||||
method: 'POST',
|
||||
|
|
@ -108,10 +96,11 @@ describe('POST /api/webhooks/baserow', () => {
|
|||
body,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { status: string; entity: string };
|
||||
const json = (await res.json()) as { status: string; tableId: number };
|
||||
expect(json.status).toBe('processed');
|
||||
expect(json.entity).toBe('personne');
|
||||
expect(redis.invalidated).toContain('bridge:personne:list:*');
|
||||
expect(json.tableId).toBe(42);
|
||||
expect(redis.invalidated).toContain('bridge:tables:42:list:*');
|
||||
expect(redis.invalidated).toContain('bridge:tables:42:views:*');
|
||||
});
|
||||
|
||||
it('401 AUTH_REQUIRED si header absent', async () => {
|
||||
|
|
@ -179,15 +168,15 @@ describe('POST /api/webhooks/baserow', () => {
|
|||
expect(json2.eventId).toBe('evt-dup');
|
||||
});
|
||||
|
||||
it('table inconnue -> 200 status ignored, aucune invalidation', async () => {
|
||||
it('table_id 0 -> 400 (validation zod : table_id positif)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis);
|
||||
const app = buildApp();
|
||||
|
||||
const body = JSON.stringify({
|
||||
event_id: 'evt-ignore',
|
||||
event_id: 'evt',
|
||||
event_type: 'rows.created',
|
||||
table_id: 99999,
|
||||
table_id: 0,
|
||||
items: [],
|
||||
});
|
||||
const res = await app.request('/api/webhooks/baserow', {
|
||||
|
|
@ -198,11 +187,7 @@ describe('POST /api/webhooks/baserow', () => {
|
|||
},
|
||||
body,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { status: string; entity: string | null };
|
||||
expect(json.status).toBe('ignored');
|
||||
expect(json.entity).toBeNull();
|
||||
expect(redis.invalidated).toHaveLength(0);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('payload malforme (event_id manquant) -> 400 VALIDATION_ERROR', async () => {
|
||||
|
|
|
|||
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