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

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:
Corentin JOGUET 2026-05-07 22:12:32 +02:00
parent 0cf6533885
commit 2ed73fa948
81 changed files with 2927 additions and 4985 deletions

View file

@ -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`).

View file

@ -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).

View file

@ -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 });

View file

@ -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';
}
}

View file

@ -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);
}
}

View file

@ -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';
}
}

View 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;
}
}

View file

@ -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);
}
}

View file

@ -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';

View file

@ -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';
}
}

View file

@ -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';
}
}

View file

@ -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;
}
}

View file

@ -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
View 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;
}
}

View file

@ -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>;

View 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;
}
}

View file

@ -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';
}
}

View file

@ -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
View 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;
}
}

View file

@ -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) => {

View file

@ -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;

View file

@ -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,

View file

@ -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;
}

View file

@ -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),

View file

@ -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();
}

View 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,
});
});
}
}

View file

@ -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,
}),
};
}

View 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;
}
}
}

View 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,
});
}
}

View 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)),
},
};
}
}

View file

@ -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,
},
});
});

View file

@ -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),
},
});
});

View file

@ -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,
);
});

View file

@ -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,
);
});

View file

@ -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)),
),
},
},
});
});

View file

@ -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
View 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);
});

View file

@ -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,

View file

@ -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 };
}

View file

@ -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);
});
});

View file

@ -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/);
});
});

View file

@ -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');
});
});

View 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');
});
});

View file

@ -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/);
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View 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' }]);
});
});

View file

@ -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);
});
});

View 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);
});
});

View file

@ -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();
});
});

View 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');
});
});

View file

@ -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;
}

View file

@ -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',
});
}

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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: [],
},

View file

@ -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']);
});
});

View 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();
});
});

View file

@ -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');
});
});

View 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();
});
});

View 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);
});
});

View 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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

View 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);
});
});

View file

@ -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);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View file

@ -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);
});
});

View file

@ -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 () => {

View 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.

View 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`).

View 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 |