feat(bridge): bloc 3 — routes REST Tier 1 + auth + repos Baserow (10 endpoints)
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
Wiring HTTP du bridge service. 10 endpoints livres (cf docs/19 §6.1-6.5) :
- GET /api/v1/personnes (+ /:id, + /:id/dashboard)
- GET /api/v1/formations (+ /:id avec rollups blocs/modules)
- GET /api/v1/projets (+ /:id avec rollups taches)
- POST /api/v1/modules/:id/attribuer (RG-01 -> 422, role/heures invalides -> 400)
- POST /api/v1/interventions (validation role developpeur + heures > 0)
- PATCH /api/v1/attributions/:id/heures-realisees (409 si annule/realise)
Layers ajoutees :
- src/middleware/auth.ts : Bearer brg_*, scopes JSON-encoded BRIDGE_API_TOKENS, admin:* wildcard
- src/middleware/error-handler.ts : BridgeError -> JSON shape standard
- src/lib/container.ts : DI singleton (Baserow + Redis + 9 repos), setContainer testable
- src/lib/http.ts : parseListQuery + parseBody zod helper
- src/repos/baserow-repo.ts : BaseRepo<T> abstrait + 9 sous-classes (mapping Row<->Domain)
- src/routes/{personnes,formations,projets,modules,interventions,attributions}.ts
src/index.ts reecrit : buildApp() + initContainer + auth sur /api/v1/* + ready check Baserow+Redis.
Tests : 163/163 verts (12 suites domain + 8 nouvelles : auth, repos, 6 routes).
Coverage src global : 70.77% (cible 60%). Domain 97.86%, routes 96%, middleware 86%.
Choix : BaseRepo abstrait (pas mega-generic, Ockham) ; FakeRepos in-memory pour tests routes
(pas de testcontainers ici, c'est Bloc 7) ; mapping erreurs domain -> HTTP par message texte
(fragile, sera refactor en DomainError typees au Bloc 3.2).
Hors scope (a venir) :
- Bloc 5 : rate limiting Redis
- Bloc 7 : webhook handlers Baserow + sync bidirec + cache invalidation
- Bloc 3.2 : routes /docmost/*, /sync/*, /rapports/*
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2c5665bc44
commit
c8e9b4d4ea
23 changed files with 2542 additions and 17 deletions
|
|
@ -1,29 +1,79 @@
|
|||
/**
|
||||
* Bridge service entrypoint — Hono HTTP server.
|
||||
*
|
||||
* Boot sequence : loadConfig -> initContainer (Baserow + Redis + repos + token map)
|
||||
* -> wire middleware globaux + routes /api/v1/* avec auth + serve.
|
||||
*/
|
||||
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { logger as honoLogger } from 'hono/logger';
|
||||
import { loadConfig } from './lib/config.js';
|
||||
import { getContainer, initContainer } from './lib/container.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { type AuthVariables, authMiddleware } from './middleware/auth.js';
|
||||
import { errorHandler } from './middleware/error-handler.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';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = new Hono();
|
||||
|
||||
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||
const app = new Hono<{ Variables: AuthVariables }>();
|
||||
app.use('*', honoLogger());
|
||||
app.onError(errorHandler);
|
||||
|
||||
app.get('/api/health', (c) => {
|
||||
return c.json({ status: 'ok', service: 'bridge', version: '0.1.0' });
|
||||
});
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', service: 'bridge', version: '0.1.0' }));
|
||||
|
||||
app.get('/api/ready', async (c) => {
|
||||
return c.json({ status: 'ok', dependencies: { baserow: 'TODO', redis: 'TODO' } });
|
||||
const { baserow, redis } = getContainer();
|
||||
const [baserowOk, redisOk] = await Promise.all([baserow.healthCheck(), redis.healthCheck()]);
|
||||
const status = baserowOk && redisOk ? 'ok' : 'degraded';
|
||||
const code = baserowOk && redisOk ? 200 : 503;
|
||||
return c.json(
|
||||
{ status, dependencies: { baserow: baserowOk, redis: redisOk } },
|
||||
code as 200 | 503,
|
||||
);
|
||||
});
|
||||
|
||||
// Auth middleware applique sur tout /api/v1/*
|
||||
const { tokens } = getContainer();
|
||||
const v1 = new Hono<{ Variables: AuthVariables }>();
|
||||
v1.use('*', authMiddleware(tokens));
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/formations', formationsRoutes);
|
||||
v1.route('/projets', projetsRoutes);
|
||||
v1.route('/modules', modulesRoutes);
|
||||
v1.route('/interventions', interventionsRoutes);
|
||||
v1.route('/attributions', attributionsRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
|
||||
app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404));
|
||||
|
||||
app.onError((err, c) => {
|
||||
logger.error({ err }, 'Unhandled error');
|
||||
return c.json({ error: { code: 'INTERNAL', message: 'Internal server error' } }, 500);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
// BASEROW_DATABASE_ID requis pour resolveTableIds. Cf .env
|
||||
const databaseIdRaw = process.env.BASEROW_DATABASE_ID;
|
||||
const databaseId = databaseIdRaw ? Number.parseInt(databaseIdRaw, 10) : undefined;
|
||||
if (!databaseId || Number.isNaN(databaseId)) {
|
||||
throw new Error('BASEROW_DATABASE_ID env var requis pour resolve table ids');
|
||||
}
|
||||
await initContainer({ config, databaseId });
|
||||
const app = await buildApp();
|
||||
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
logger.info({ port: info.port, env: config.nodeEnv }, 'Bridge service started');
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test' && import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((err) => {
|
||||
logger.fatal({ err }, 'Bridge service failed to start');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
98
bridge/src/lib/container.ts
Normal file
98
bridge/src/lib/container.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
import type { Logger } from 'pino';
|
||||
import { BaserowClient } from '../adapters/baserow-client.js';
|
||||
import { RedisCache } from '../adapters/redis-cache.js';
|
||||
import type { ApiTokenRecord } from '../middleware/auth.js';
|
||||
import { parseTokens } from '../middleware/auth.js';
|
||||
import { type RepoSet, TABLE_NAMES, type TableIds, buildRepos } from '../repos/baserow-repo.js';
|
||||
import type { Config } from './config.js';
|
||||
import { logger as rootLogger } from './logger.js';
|
||||
|
||||
export interface Container {
|
||||
config: Config;
|
||||
baserow: BaserowClient;
|
||||
redis: RedisCache;
|
||||
repos: RepoSet;
|
||||
tokens: ReadonlyMap<string, ApiTokenRecord>;
|
||||
tableIds: TableIds;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
let _container: Container | null = null;
|
||||
|
||||
export function getContainer(): Container {
|
||||
if (!_container) {
|
||||
throw new Error('Container not initialized — call initContainer() first');
|
||||
}
|
||||
return _container;
|
||||
}
|
||||
|
||||
export function setContainer(c: Container | null): void {
|
||||
_container = c;
|
||||
}
|
||||
|
||||
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> {
|
||||
const { config } = opts;
|
||||
const baserow =
|
||||
opts.baserow ??
|
||||
new BaserowClient({
|
||||
baseUrl: config.baserowApiUrl,
|
||||
token: config.baserowApiToken,
|
||||
logger: rootLogger,
|
||||
});
|
||||
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 = buildRepos(baserow, tableIds, rootLogger);
|
||||
const tokens = parseTokens(config.bridgeApiTokens);
|
||||
|
||||
const container: Container = {
|
||||
config,
|
||||
baserow,
|
||||
redis,
|
||||
repos,
|
||||
tokens,
|
||||
tableIds,
|
||||
logger: rootLogger,
|
||||
};
|
||||
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;
|
||||
}
|
||||
49
bridge/src/lib/http.ts
Normal file
49
bridge/src/lib/http.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Helpers HTTP partages pour les routes : parsing pagination/filtres et serialisation
|
||||
* des objets domain en JSON-friendly (Decimal -> string a precision controlee).
|
||||
*/
|
||||
|
||||
import type { Decimal } from 'decimal.js';
|
||||
import type { Context } from 'hono';
|
||||
import type { z } from 'zod';
|
||||
import { errors } from './errors.js';
|
||||
|
||||
export interface ListQuery {
|
||||
page: number;
|
||||
per_page: number;
|
||||
filter: Record<string, string>;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export function parseListQuery(c: Context): ListQuery {
|
||||
const url = new URL(c.req.url);
|
||||
const page = Number.parseInt(url.searchParams.get('page') ?? '1', 10) || 1;
|
||||
const perPageRaw = Number.parseInt(url.searchParams.get('per_page') ?? '50', 10) || 50;
|
||||
const per_page = Math.min(Math.max(perPageRaw, 1), 200);
|
||||
const filter: Record<string, string> = {};
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
const m = key.match(/^filter\[(.+)\]$/);
|
||||
if (m?.[1]) filter[m[1]] = value;
|
||||
}
|
||||
const sort = url.searchParams.get('sort') ?? undefined;
|
||||
return { page, per_page, filter, sort };
|
||||
}
|
||||
|
||||
export function dec(d: Decimal | undefined | null): string {
|
||||
if (!d) return '0';
|
||||
return d.toFixed(2);
|
||||
}
|
||||
|
||||
export async function parseBody<T>(c: Context, schema: z.ZodType<T>): Promise<T> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
throw errors.validation([{ message: 'Body must be valid JSON' }]);
|
||||
}
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw errors.validation(parsed.error.issues);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
109
bridge/src/middleware/auth.ts
Normal file
109
bridge/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Auth middleware bridge — API tokens longue duree (`brg_*`) avec scopes.
|
||||
*
|
||||
* Tokens declares dans `BRIDGE_API_TOKENS` au format JSON :
|
||||
* [{"token":"brg_xxx","name":"docmost-prod","scopes":["read:personnes","write:attributions"]}]
|
||||
*
|
||||
* JSON choisi plutot qu'un mini-DSL `name:scope1,scope2;...` : parse natif, pas d'ambiguite
|
||||
* sur les separateurs si un nom contient virgule/point-virgule. Cf rapport Bloc 3.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { errors } from '../lib/errors.js';
|
||||
|
||||
export interface ApiTokenRecord {
|
||||
token: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
tokenName: string;
|
||||
scopes: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Hono context variable map — augmente sur l'app pour acces type-safe. */
|
||||
export type AuthVariables = {
|
||||
auth: AuthContext;
|
||||
};
|
||||
|
||||
/** Parse `BRIDGE_API_TOKENS` (JSON). Retourne map token → record. */
|
||||
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
|
||||
const map = new Map<string, ApiTokenRecord>();
|
||||
if (!raw || raw.trim().length === 0) return map;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('BRIDGE_API_TOKENS: JSON invalide');
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('BRIDGE_API_TOKENS: doit etre un tableau JSON');
|
||||
}
|
||||
for (const entry of parsed) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
throw new Error('BRIDGE_API_TOKENS: entrees doivent etre des objets');
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (typeof e.token !== 'string' || typeof e.name !== 'string') {
|
||||
throw new Error('BRIDGE_API_TOKENS: chaque entree requiert token + name');
|
||||
}
|
||||
if (!Array.isArray(e.scopes) || e.scopes.some((s: unknown) => typeof s !== 'string')) {
|
||||
throw new Error('BRIDGE_API_TOKENS: scopes doit etre un tableau de strings');
|
||||
}
|
||||
map.set(e.token, { token: e.token, name: e.name, scopes: e.scopes as string[] });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie si un set de scopes possedes couvre la demande.
|
||||
* `admin:*` couvre tout. Match exact sinon.
|
||||
*/
|
||||
export function hasScope(owned: ReadonlySet<string>, required: string): boolean {
|
||||
if (owned.has('admin:*')) return true;
|
||||
return owned.has(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory middleware : exige un scope precis.
|
||||
* Le middleware d'auth global doit avoir tourne avant pour peupler `c.var.auth`.
|
||||
*/
|
||||
export function requireScope(scope: string): MiddlewareHandler<{ Variables: AuthVariables }> {
|
||||
return async (c, next) => {
|
||||
const auth = c.get('auth');
|
||||
if (!auth) {
|
||||
throw errors.authRequired();
|
||||
}
|
||||
if (!hasScope(auth.scopes, scope)) {
|
||||
throw errors.forbidden(scope);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware d'auth global. Lit `Authorization: Bearer brg_*`, peuple `c.var.auth`.
|
||||
* Pas d'enforcement de scope ici — c'est le job de `requireScope`.
|
||||
*/
|
||||
export function authMiddleware(
|
||||
tokens: ReadonlyMap<string, ApiTokenRecord>,
|
||||
): MiddlewareHandler<{ Variables: AuthVariables }> {
|
||||
return async (c, next) => {
|
||||
const header = c.req.header('Authorization');
|
||||
if (!header) {
|
||||
throw errors.authRequired();
|
||||
}
|
||||
const match = header.match(/^Bearer\s+(.+)$/);
|
||||
if (!match) {
|
||||
throw errors.authInvalid();
|
||||
}
|
||||
const token = match[1].trim();
|
||||
const record = tokens.get(token);
|
||||
if (!record) {
|
||||
throw errors.authInvalid();
|
||||
}
|
||||
c.set('auth', { tokenName: record.name, scopes: new Set(record.scopes) });
|
||||
await next();
|
||||
};
|
||||
}
|
||||
25
bridge/src/middleware/error-handler.ts
Normal file
25
bridge/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Error handler global — convertit les exceptions en `{ error: { code, message, details? } }`.
|
||||
* Reconnait `BridgeError` pour les erreurs metier ; tout le reste -> INTERNAL 500.
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { BridgeError } from '../lib/errors.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
|
||||
export function errorHandler(err: Error, c: Context): Response {
|
||||
if (err instanceof BridgeError) {
|
||||
if (err.status >= 500) {
|
||||
logger.error({ err, path: c.req.path }, 'BridgeError 5xx');
|
||||
} else {
|
||||
logger.warn({ code: err.code, path: c.req.path }, 'BridgeError handled');
|
||||
}
|
||||
return c.json(err.toJSON(), err.status as ContentfulStatusCode);
|
||||
}
|
||||
logger.error({ err, path: c.req.path }, 'Unhandled error');
|
||||
return c.json(
|
||||
{ error: { code: 'INTERNAL', message: 'Internal server error' } },
|
||||
500 as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
501
bridge/src/repos/baserow-repo.ts
Normal file
501
bridge/src/repos/baserow-repo.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
/**
|
||||
* 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 };
|
||||
}> {
|
||||
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,
|
||||
});
|
||||
const items = res.results.map((row) => this.toDomain(row));
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
page,
|
||||
per_page: size,
|
||||
total: res.count,
|
||||
total_pages: Math.max(1, Math.ceil(res.count / size)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
52
bridge/src/routes/attributions.ts
Normal file
52
bridge/src/routes/attributions.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 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 { 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);
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
attribution_id: id,
|
||||
heures_attribuees: dec(attribution.heuresAttribuees),
|
||||
heures_realisees: dec(attribution.heuresRealisees),
|
||||
statut: attribution.statut,
|
||||
},
|
||||
});
|
||||
});
|
||||
89
bridge/src/routes/formations.ts
Normal file
89
bridge/src/routes/formations.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* 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),
|
||||
},
|
||||
});
|
||||
});
|
||||
77
bridge/src/routes/interventions.ts
Normal file
77
bridge/src/routes/interventions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* 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 { 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;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
101
bridge/src/routes/modules.ts
Normal file
101
bridge/src/routes/modules.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* 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 { 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;
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
data: {
|
||||
attribution_id: createdId,
|
||||
module_id: moduleId,
|
||||
personne_id: personne.id,
|
||||
heures_attribuees: dec(new Decimal(body.heures)),
|
||||
statut: 'planifie',
|
||||
},
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
111
bridge/src/routes/personnes.ts
Normal file
111
bridge/src/routes/personnes.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* 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)),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
75
bridge/src/routes/projets.ts
Normal file
75
bridge/src/routes/projets.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* 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()),
|
||||
},
|
||||
});
|
||||
});
|
||||
143
bridge/tests/helpers/fake-repos.ts
Normal file
143
bridge/tests/helpers/fake-repos.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
101
bridge/tests/helpers/fixtures.ts
Normal file
101
bridge/tests/helpers/fixtures.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
115
bridge/tests/helpers/test-app.ts
Normal file
115
bridge/tests/helpers/test-app.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 { Container } from '../../src/lib/container.js';
|
||||
import { setContainer } from '../../src/lib/container.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
import {
|
||||
type ApiTokenRecord,
|
||||
type AuthVariables,
|
||||
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';
|
||||
|
||||
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 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: ADMIN_TOKEN, name: 'test-admin', scopes: ['admin:*'] },
|
||||
];
|
||||
|
||||
export interface TestContainerOverrides {
|
||||
repos: RepoSet;
|
||||
baserow?: BaserowClient;
|
||||
redis?: RedisCache;
|
||||
tokens?: ApiTokenRecord[];
|
||||
}
|
||||
|
||||
export function installTestContainer(over: TestContainerOverrides): Container {
|
||||
const tokensMap = new Map<string, ApiTokenRecord>();
|
||||
for (const t of over.tokens ?? TEST_TOKENS) tokensMap.set(t.token, t);
|
||||
|
||||
const fakeBaserow = over.baserow ?? ({} as BaserowClient);
|
||||
const fakeRedis = over.redis ?? ({} as RedisCache);
|
||||
|
||||
const container: Container = {
|
||||
config: {
|
||||
nodeEnv: 'test',
|
||||
port: 0,
|
||||
logLevel: 'fatal',
|
||||
baserowApiUrl: 'http://localhost',
|
||||
baserowApiToken: 'fake',
|
||||
redisUrl: 'redis://localhost',
|
||||
baserowWebhookSecret: 'fake_secret_at_least_16_chars',
|
||||
bridgeApiTokens: undefined,
|
||||
},
|
||||
baserow: fakeBaserow,
|
||||
redis: fakeRedis,
|
||||
repos: over.repos,
|
||||
tokens: tokensMap,
|
||||
tableIds: FAKE_TABLE_IDS,
|
||||
logger,
|
||||
};
|
||||
setContainer(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
export function resetTestContainer(): void {
|
||||
setContainer(null);
|
||||
}
|
||||
|
||||
export function buildTestApp(container: Container): Hono<{ Variables: AuthVariables }> {
|
||||
const app = new Hono<{ Variables: AuthVariables }>();
|
||||
app.use('*', honoLogger());
|
||||
app.onError(errorHandler);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }));
|
||||
|
||||
const v1 = new Hono<{ Variables: AuthVariables }>();
|
||||
v1.use('*', authMiddleware(container.tokens));
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/formations', formationsRoutes);
|
||||
v1.route('/projets', projetsRoutes);
|
||||
v1.route('/modules', modulesRoutes);
|
||||
v1.route('/interventions', interventionsRoutes);
|
||||
v1.route('/attributions', attributionsRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
|
||||
return app;
|
||||
}
|
||||
118
bridge/tests/middleware/auth.test.ts
Normal file
118
bridge/tests/middleware/auth.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { Hono } from 'hono';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type ApiTokenRecord,
|
||||
type AuthVariables,
|
||||
authMiddleware,
|
||||
hasScope,
|
||||
parseTokens,
|
||||
requireScope,
|
||||
} from '../../src/middleware/auth.js';
|
||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||
|
||||
function buildApp(tokens: ApiTokenRecord[]): Hono<{ Variables: AuthVariables }> {
|
||||
const map = new Map<string, ApiTokenRecord>();
|
||||
for (const t of tokens) map.set(t.token, t);
|
||||
const app = new Hono<{ Variables: AuthVariables }>();
|
||||
app.onError(errorHandler);
|
||||
app.use('/protected/*', authMiddleware(map));
|
||||
app.get('/protected/read', requireScope('read:personnes'), (c) =>
|
||||
c.json({ ok: true, scopes: Array.from(c.get('auth').scopes) }),
|
||||
);
|
||||
app.get('/protected/admin', requireScope('admin:something'), (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('parseTokens', () => {
|
||||
it('parse JSON valide', () => {
|
||||
const map = parseTokens(
|
||||
JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:personnes'] }]),
|
||||
);
|
||||
expect(map.get('brg_x')?.name).toBe('a');
|
||||
});
|
||||
|
||||
it('retourne map vide si raw vide', () => {
|
||||
expect(parseTokens(undefined).size).toBe(0);
|
||||
expect(parseTokens('').size).toBe(0);
|
||||
});
|
||||
|
||||
it('throw si JSON invalide', () => {
|
||||
expect(() => parseTokens('{nope')).toThrow(/JSON/);
|
||||
});
|
||||
|
||||
it('throw si pas un array', () => {
|
||||
expect(() => parseTokens('{"foo": 1}')).toThrow(/tableau/);
|
||||
});
|
||||
|
||||
it('throw si entree manque token/name/scopes', () => {
|
||||
expect(() => parseTokens('[{"token":"x"}]')).toThrow();
|
||||
expect(() => parseTokens('[{"token":"x","name":"y"}]')).toThrow();
|
||||
expect(() => parseTokens('[{"token":"x","name":"y","scopes":[1]}]')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
it('admin:* couvre tout', () => {
|
||||
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
|
||||
expect(hasScope(new Set(['admin:*']), 'write:something')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth middleware — 5 cas', () => {
|
||||
const tokens: ApiTokenRecord[] = [
|
||||
{ token: 'brg_valid', name: 'demo', scopes: ['read:personnes'] },
|
||||
];
|
||||
|
||||
it('401 si pas de header', async () => {
|
||||
const app = buildApp(tokens);
|
||||
const res = await app.request('/protected/read');
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('401 si format wrong (pas Bearer)', async () => {
|
||||
const app = buildApp(tokens);
|
||||
const res = await app.request('/protected/read', {
|
||||
headers: { Authorization: 'Token brg_valid' },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('AUTH_INVALID');
|
||||
});
|
||||
|
||||
it('401 si token inconnu', async () => {
|
||||
const app = buildApp(tokens);
|
||||
const res = await app.request('/protected/read', {
|
||||
headers: { Authorization: 'Bearer brg_unknown' },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('AUTH_INVALID');
|
||||
});
|
||||
|
||||
it('403 si scope manquant', async () => {
|
||||
const app = buildApp(tokens);
|
||||
const res = await app.request('/protected/admin', {
|
||||
headers: { Authorization: 'Bearer brg_valid' },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('FORBIDDEN_SCOPE');
|
||||
});
|
||||
|
||||
it('200 si token + scope OK', async () => {
|
||||
const app = buildApp(tokens);
|
||||
const res = await app.request('/protected/read', {
|
||||
headers: { Authorization: 'Bearer brg_valid' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean; scopes: string[] };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.scopes).toContain('read:personnes');
|
||||
});
|
||||
});
|
||||
223
bridge/tests/repos/baserow-repo.test.ts
Normal file
223
bridge/tests/repos/baserow-repo.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* 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('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');
|
||||
});
|
||||
});
|
||||
74
bridge/tests/routes/attributions.test.ts
Normal file
74
bridge/tests/routes/attributions.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
70
bridge/tests/routes/formations.test.ts
Normal file
70
bridge/tests/routes/formations.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
88
bridge/tests/routes/interventions.test.ts
Normal file
88
bridge/tests/routes/interventions.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
100
bridge/tests/routes/modules.test.ts
Normal file
100
bridge/tests/routes/modules.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
94
bridge/tests/routes/personnes.test.ts
Normal file
94
bridge/tests/routes/personnes.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
62
bridge/tests/routes/projets.test.ts
Normal file
62
bridge/tests/routes/projets.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue