Compare commits
2 commits
460f7effe0
...
2c5665bc44
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c5665bc44 | |||
| 5b2abbc23c |
29 changed files with 2660 additions and 24 deletions
133
bridge/package-lock.json
generated
133
bridge/package-lock.json
generated
|
|
@ -9,14 +9,15 @@
|
|||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.6.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ofetch": "^1.4.0",
|
||||
"pino": "^9.5.0",
|
||||
"zod": "^3.23.8"
|
||||
"@hono/node-server": "^1.19.14",
|
||||
"decimal.js": "^10.6.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"hono": "^4.12.18",
|
||||
"ioredis": "^5.10.1",
|
||||
"ofetch": "^1.5.1",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
|
|
@ -2148,6 +2149,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
||||
|
|
@ -2229,6 +2236,15 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2415,7 +2431,6 @@
|
|||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
|
|
@ -2530,6 +2545,12 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
|
|
@ -2537,6 +2558,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
|
|
@ -2684,6 +2711,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
|
|
@ -2859,6 +2892,15 @@
|
|||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||
|
|
@ -3006,6 +3048,15 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
|
|
@ -3112,7 +3163,6 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
|
|
@ -3207,6 +3257,39 @@
|
|||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^4.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^5.0.0",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"strip-json-comments": "^5.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty/node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
|
|
@ -3341,7 +3424,6 @@
|
|||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
|
|
@ -3560,6 +3642,22 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -3837,6 +3935,18 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
|
@ -4800,7 +4910,6 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
|
|
|
|||
|
|
@ -22,14 +22,15 @@
|
|||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.6.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ofetch": "^1.4.0",
|
||||
"pino": "^9.5.0",
|
||||
"zod": "^3.23.8"
|
||||
"@hono/node-server": "^1.19.14",
|
||||
"decimal.js": "^10.6.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"hono": "^4.12.18",
|
||||
"ioredis": "^5.10.1",
|
||||
"ofetch": "^1.5.1",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
|
|
|
|||
151
bridge/src/adapters/baserow-client.ts
Normal file
151
bridge/src/adapters/baserow-client.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* BaserowClient — wrapper typé de l'API Baserow.
|
||||
* Auth via API token (BASEROW_API_TOKEN), pagination, retry sur erreurs reseau.
|
||||
*/
|
||||
|
||||
import { ofetch } from 'ofetch';
|
||||
import type { Logger } from 'pino';
|
||||
import { errors } from '../lib/errors.js';
|
||||
|
||||
export interface BaserowRow {
|
||||
id: number;
|
||||
order: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BaserowPaginatedResponse<T = BaserowRow> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
export interface BaserowListOptions {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
filter?: Record<string, string | number | boolean>;
|
||||
orderBy?: string;
|
||||
userFieldNames?: boolean;
|
||||
}
|
||||
|
||||
export class BaserowClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(opts: { baseUrl: string; token: string; logger: Logger }) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.token = opts.token;
|
||||
this.logger = opts.logger.child({ adapter: 'baserow' });
|
||||
}
|
||||
|
||||
private fetch<T>(
|
||||
path: string,
|
||||
init?: { method?: string; body?: string; headers?: Record<string, string> },
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
return ofetch<T>(url, {
|
||||
method: init?.method,
|
||||
body: init?.body,
|
||||
headers: {
|
||||
Authorization: `Token ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
retry: 2,
|
||||
retryDelay: 200,
|
||||
timeout: 10_000,
|
||||
onResponseError: ({ response }) => {
|
||||
this.logger.error({ status: response.status, url, body: response._data }, 'baserow error');
|
||||
},
|
||||
}).catch((err: unknown) => {
|
||||
const error = err as { response?: { status?: number } };
|
||||
if (error.response?.status === 401) throw errors.authInvalid();
|
||||
if (error.response?.status === 404) throw errors.notFound('Baserow row', path);
|
||||
if (!error.response) throw errors.baserowDown();
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les rows d'une table avec pagination.
|
||||
* Retourne automatiquement les noms de fields (`user_field_names=true`) pour eviter le mapping field_id.
|
||||
*/
|
||||
async listRows(
|
||||
tableId: number,
|
||||
opts: BaserowListOptions = {},
|
||||
): Promise<BaserowPaginatedResponse> {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
user_field_names: opts.userFieldNames ?? true,
|
||||
size: opts.size ?? 100,
|
||||
page: opts.page ?? 1,
|
||||
};
|
||||
if (opts.search) params.search = opts.search;
|
||||
if (opts.orderBy) params.order_by = opts.orderBy;
|
||||
if (opts.filter) {
|
||||
for (const [key, val] of Object.entries(opts.filter)) {
|
||||
params[`filter__${key}__contains`] = String(val);
|
||||
}
|
||||
}
|
||||
const queryRecord: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
queryRecord[k] = String(v);
|
||||
}
|
||||
const query = new URLSearchParams(queryRecord).toString();
|
||||
return this.fetch<BaserowPaginatedResponse>(`/api/database/rows/table/${tableId}/?${query}`);
|
||||
}
|
||||
|
||||
async getRow(tableId: number, rowId: number, userFieldNames = true): Promise<BaserowRow> {
|
||||
return this.fetch<BaserowRow>(
|
||||
`/api/database/rows/table/${tableId}/${rowId}/?user_field_names=${userFieldNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createRow(
|
||||
tableId: number,
|
||||
data: Record<string, unknown>,
|
||||
userFieldNames = true,
|
||||
): Promise<BaserowRow> {
|
||||
return this.fetch<BaserowRow>(
|
||||
`/api/database/rows/table/${tableId}/?user_field_names=${userFieldNames}`,
|
||||
{ method: 'POST', body: JSON.stringify(data) },
|
||||
);
|
||||
}
|
||||
|
||||
async updateRow(
|
||||
tableId: number,
|
||||
rowId: number,
|
||||
data: Record<string, unknown>,
|
||||
userFieldNames = true,
|
||||
): Promise<BaserowRow> {
|
||||
return this.fetch<BaserowRow>(
|
||||
`/api/database/rows/table/${tableId}/${rowId}/?user_field_names=${userFieldNames}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(data) },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRow(tableId: number, rowId: number): Promise<void> {
|
||||
await this.fetch<void>(`/api/database/rows/table/${tableId}/${rowId}/`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resoud le mapping table_name → table_id pour la database.
|
||||
* Utilise par le bridge au boot pour eviter de coder les ids en dur.
|
||||
*/
|
||||
async resolveTableIds(databaseId: number): Promise<Record<string, number>> {
|
||||
const tables = await this.fetch<Array<{ id: number; name: string }>>(
|
||||
`/api/database/tables/database/${databaseId}/`,
|
||||
);
|
||||
return Object.fromEntries(tables.map((t) => [t.name, t.id]));
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await ofetch(`${this.baseUrl}/api/_health/`, { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
220
bridge/src/adapters/docmost-client.ts
Normal file
220
bridge/src/adapters/docmost-client.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* DocmostClient — wrapper de l'API interne Docmost (endpoints privés AGPL-legal).
|
||||
* Auth par session cookie (POST /api/auth/login → cookie Set-Cookie).
|
||||
*/
|
||||
|
||||
import { ofetch } from 'ofetch';
|
||||
import type { Logger } from 'pino';
|
||||
import { errors } from '../lib/errors.js';
|
||||
|
||||
export interface DocmostUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface DocmostWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
defaultSpaceId: string;
|
||||
}
|
||||
|
||||
export interface DocmostSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
visibility: 'open' | 'private';
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface DocmostPage {
|
||||
id: string;
|
||||
title: string;
|
||||
spaceId: string;
|
||||
parentPageId: string | null;
|
||||
position: number | null;
|
||||
content?: unknown;
|
||||
slugId?: string;
|
||||
}
|
||||
|
||||
export interface DocmostShare {
|
||||
id: string;
|
||||
pageId: string;
|
||||
url?: string;
|
||||
expiresAt?: string;
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
interface DocmostEnvelope<T> {
|
||||
data: T;
|
||||
success?: boolean;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export class DocmostClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly logger: Logger;
|
||||
private cookie: string | null = null;
|
||||
private readonly email: string;
|
||||
private readonly password: string;
|
||||
|
||||
constructor(opts: { baseUrl: string; email: string; password: string; logger: Logger }) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.logger = opts.logger.child({ adapter: 'docmost' });
|
||||
this.email = opts.email;
|
||||
this.password = opts.password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-login + persist session cookie. Re-auth si 401.
|
||||
*/
|
||||
private async ensureAuth(): Promise<void> {
|
||||
if (this.cookie) return;
|
||||
await this.login();
|
||||
}
|
||||
|
||||
private async login(): Promise<void> {
|
||||
const url = `${this.baseUrl}/api/auth/login`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email, password: this.password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.logger.error({ status: response.status }, 'docmost login failed');
|
||||
throw errors.authInvalid();
|
||||
}
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
if (setCookie) {
|
||||
this.cookie = setCookie.split(';')[0] ?? null;
|
||||
}
|
||||
this.logger.debug('docmost login ok');
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
|
||||
await this.ensureAuth();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await ofetch<DocmostEnvelope<T> | T>(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.cookie ? { Cookie: this.cookie } : {}),
|
||||
},
|
||||
body,
|
||||
retry: 1,
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return (response as DocmostEnvelope<T>).data;
|
||||
}
|
||||
return response as T;
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number } };
|
||||
if (error.response?.status === 401) {
|
||||
// Re-auth + retry once
|
||||
this.cookie = null;
|
||||
await this.ensureAuth();
|
||||
return this.post<T>(path, body);
|
||||
}
|
||||
if (error.response?.status === 404) throw errors.notFound('Docmost resource', path);
|
||||
if (!error.response) throw errors.docmostDown();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Workspace ---
|
||||
async getWorkspaceInfo(): Promise<DocmostWorkspace> {
|
||||
return this.post<DocmostWorkspace>('/api/workspace/info');
|
||||
}
|
||||
|
||||
// --- Spaces ---
|
||||
async listSpaces(page = 1, limit = 100): Promise<DocmostSpace[]> {
|
||||
const result = await this.post<{ items: DocmostSpace[] }>('/api/spaces/', {
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
async createSpace(input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug: string;
|
||||
visibility?: 'open' | 'private';
|
||||
}): Promise<DocmostSpace> {
|
||||
return this.post<DocmostSpace>('/api/spaces/create', {
|
||||
name: input.name,
|
||||
description: input.description ?? '',
|
||||
slug: input.slug,
|
||||
visibility: input.visibility ?? 'open',
|
||||
});
|
||||
}
|
||||
|
||||
async addSpaceMember(spaceId: string, userEmails: string[], role = 'writer'): Promise<void> {
|
||||
await this.post('/api/spaces/members/add', { spaceId, userEmails, role });
|
||||
}
|
||||
|
||||
// --- Pages ---
|
||||
async createPage(input: {
|
||||
spaceId: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
format?: 'markdown' | 'json' | 'html';
|
||||
parentPageId?: string;
|
||||
}): Promise<DocmostPage> {
|
||||
return this.post<DocmostPage>('/api/pages/create', {
|
||||
spaceId: input.spaceId,
|
||||
title: input.title,
|
||||
...(input.content ? { content: input.content } : {}),
|
||||
format: input.format ?? 'markdown',
|
||||
...(input.parentPageId ? { parentPageId: input.parentPageId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async getPageInfo(pageId: string): Promise<DocmostPage> {
|
||||
return this.post<DocmostPage>('/api/pages/info', { pageId });
|
||||
}
|
||||
|
||||
async updatePage(input: {
|
||||
pageId: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
}): Promise<DocmostPage> {
|
||||
return this.post<DocmostPage>('/api/pages/update', input);
|
||||
}
|
||||
|
||||
async deletePage(pageId: string): Promise<void> {
|
||||
await this.post('/api/pages/delete', { pageId });
|
||||
}
|
||||
|
||||
// --- Shares ---
|
||||
async createShare(input: {
|
||||
pageId: string;
|
||||
includeSubPages?: boolean;
|
||||
expiresAt?: string;
|
||||
password?: string;
|
||||
}): Promise<DocmostShare> {
|
||||
return this.post<DocmostShare>('/api/shares/create', {
|
||||
pageId: input.pageId,
|
||||
includeSubPages: input.includeSubPages ?? false,
|
||||
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteShare(shareId: string): Promise<void> {
|
||||
await this.post('/api/shares/delete', { shareId });
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await ofetch<{ status: string }>(`${this.baseUrl}/api/health`, {
|
||||
timeout: 3000,
|
||||
});
|
||||
return response.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
bridge/src/adapters/redis-cache.ts
Normal file
119
bridge/src/adapters/redis-cache.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* RedisCache — cache-aside pattern.
|
||||
* Utilise pour : cache rows Baserow, idempotence webhooks, rate limiting.
|
||||
*/
|
||||
|
||||
import IORedis from 'ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
export class RedisCache {
|
||||
private readonly client: Redis;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(opts: { url: string; logger: Logger }) {
|
||||
this.logger = opts.logger.child({ adapter: 'redis' });
|
||||
this.client = new IORedis(opts.url, {
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
enableOfflineQueue: false,
|
||||
});
|
||||
this.client.on('error', (err: Error) => {
|
||||
this.logger.error({ err: err.message }, 'redis error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get + JSON parse.
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const raw = await this.client.get(key);
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
this.logger.warn({ key }, 'cache value not JSON, deleting');
|
||||
await this.client.del(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set with TTL (seconds).
|
||||
*/
|
||||
async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
async del(key: string | string[]): Promise<void> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidation par pattern (ex: 'bridge:personne:*').
|
||||
* Utilise SCAN pour ne pas bloquer Redis.
|
||||
*/
|
||||
async invalidatePattern(pattern: string): Promise<number> {
|
||||
let cursor = '0';
|
||||
let count = 0;
|
||||
do {
|
||||
const [next, keys] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||
cursor = next;
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
count += keys.length;
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotence webhook : retourne true si event_id deja vu, false sinon.
|
||||
*/
|
||||
async checkAndStoreEventId(eventId: string, ttlSeconds = 86400): Promise<boolean> {
|
||||
const result = await this.client.set(
|
||||
`bridge:webhook:event:${eventId}`,
|
||||
'1',
|
||||
'EX',
|
||||
ttlSeconds,
|
||||
'NX',
|
||||
);
|
||||
// result === 'OK' → key was set (event nouveau)
|
||||
// result === null → key existait deja (duplicate)
|
||||
return result === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit sliding window basique.
|
||||
* Retourne true si la requete passe, false si limit atteint.
|
||||
*/
|
||||
async checkRateLimit(key: string, maxRequests: number, windowSeconds: number): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const windowKey = `bridge:rate:${key}`;
|
||||
const multi = this.client.multi();
|
||||
multi.zadd(windowKey, now, `${now}`);
|
||||
multi.zremrangebyscore(windowKey, 0, now - windowSeconds * 1000);
|
||||
multi.zcard(windowKey);
|
||||
multi.expire(windowKey, windowSeconds);
|
||||
const results = await multi.exec();
|
||||
if (!results) return true;
|
||||
const count = results[2]?.[1] as number;
|
||||
return count <= maxRequests;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const pong = await this.client.ping();
|
||||
return pong === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
82
bridge/src/domain/attribution.ts
Normal file
82
bridge/src/domain/attribution.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import type { StatutAttribution } from './types.js';
|
||||
|
||||
export interface AttributionProps {
|
||||
id: number;
|
||||
moduleId: number;
|
||||
personneId: number;
|
||||
heuresAttribuees: Decimal;
|
||||
heuresRealisees?: Decimal;
|
||||
dateDebut?: Date | null;
|
||||
dateFin?: Date | null;
|
||||
statut?: StatutAttribution;
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
|
||||
export class Attribution {
|
||||
public readonly id: number;
|
||||
public readonly moduleId: number;
|
||||
public readonly personneId: number;
|
||||
public heuresAttribuees: Decimal;
|
||||
public heuresRealisees: Decimal;
|
||||
public dateDebut: Date | null;
|
||||
public dateFin: Date | null;
|
||||
public statut: StatutAttribution;
|
||||
|
||||
constructor(props: AttributionProps) {
|
||||
if (props.heuresAttribuees.lte(0)) {
|
||||
throw new Error('heuresAttribuees doit etre > 0');
|
||||
}
|
||||
if (props.heuresRealisees?.lt(0)) {
|
||||
throw new Error('heuresRealisees doit etre >= 0');
|
||||
}
|
||||
if (props.dateDebut && props.dateFin && props.dateFin < props.dateDebut) {
|
||||
throw new Error('dateFin doit etre >= dateDebut');
|
||||
}
|
||||
|
||||
this.id = props.id;
|
||||
this.moduleId = props.moduleId;
|
||||
this.personneId = props.personneId;
|
||||
this.heuresAttribuees = props.heuresAttribuees;
|
||||
this.heuresRealisees = props.heuresRealisees ?? ZERO;
|
||||
this.dateDebut = props.dateDebut ?? null;
|
||||
this.dateFin = props.dateFin ?? null;
|
||||
this.statut = props.statut ?? 'planifie';
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.statut === 'planifie' || this.statut === 'en_cours';
|
||||
}
|
||||
|
||||
demarrer(): void {
|
||||
if (this.statut !== 'planifie') {
|
||||
throw new Error(`Transition invalide ${this.statut} -> en_cours`);
|
||||
}
|
||||
this.statut = 'en_cours';
|
||||
}
|
||||
|
||||
saisirHeuresRealisees(heures: Decimal): void {
|
||||
if (this.statut === 'annule' || this.statut === 'realise') {
|
||||
throw new Error(`Saisie heures impossible sur attribution ${this.statut}`);
|
||||
}
|
||||
if (heures.lt(0)) {
|
||||
throw new Error('heures doit etre >= 0');
|
||||
}
|
||||
this.heuresRealisees = heures;
|
||||
}
|
||||
|
||||
cloturer(): void {
|
||||
if (this.statut !== 'en_cours' && this.statut !== 'planifie') {
|
||||
throw new Error(`Transition invalide ${this.statut} -> realise`);
|
||||
}
|
||||
this.statut = 'realise';
|
||||
}
|
||||
|
||||
annuler(_raison: string): void {
|
||||
if (this.statut === 'realise') {
|
||||
throw new Error('Une attribution realisee ne peut etre annulee');
|
||||
}
|
||||
this.statut = 'annule';
|
||||
}
|
||||
}
|
||||
55
bridge/src/domain/bloc.ts
Normal file
55
bridge/src/domain/bloc.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import type { Module } from './module.js';
|
||||
|
||||
export interface BlocProps {
|
||||
id: number;
|
||||
formationId: number;
|
||||
nom: string;
|
||||
heuresPrevues: Decimal;
|
||||
ordre?: number;
|
||||
modules?: Module[];
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
|
||||
export class Bloc {
|
||||
public readonly id: number;
|
||||
public readonly formationId: number;
|
||||
public nom: string;
|
||||
public heuresPrevues: Decimal;
|
||||
public ordre: number;
|
||||
public readonly modules: Module[];
|
||||
|
||||
constructor(props: BlocProps) {
|
||||
if (props.heuresPrevues.lt(0)) {
|
||||
throw new Error('heuresPrevues doit etre >= 0');
|
||||
}
|
||||
this.id = props.id;
|
||||
this.formationId = props.formationId;
|
||||
this.nom = props.nom;
|
||||
this.heuresPrevues = props.heuresPrevues;
|
||||
this.ordre = props.ordre ?? 0;
|
||||
this.modules = props.modules ?? [];
|
||||
}
|
||||
|
||||
heuresAttribuees(): Decimal {
|
||||
return this.modules.reduce((acc, m) => acc.plus(m.heuresPrevues), ZERO);
|
||||
}
|
||||
|
||||
heuresRestantes(): Decimal {
|
||||
return this.heuresPrevues.minus(this.heuresAttribuees());
|
||||
}
|
||||
|
||||
ajouterModule(module: Module): void {
|
||||
if (this.modules.some((m) => m.id === module.id)) {
|
||||
throw new Error(`Module ${module.id} deja present dans le bloc`);
|
||||
}
|
||||
const total = this.heuresAttribuees().plus(module.heuresPrevues);
|
||||
if (total.gt(this.heuresPrevues)) {
|
||||
throw new Error(
|
||||
`Capacite bloc depassee: ${total.toString()} > ${this.heuresPrevues.toString()}`,
|
||||
);
|
||||
}
|
||||
this.modules.push(module);
|
||||
}
|
||||
}
|
||||
61
bridge/src/domain/client.ts
Normal file
61
bridge/src/domain/client.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { Projet } from './projet.js';
|
||||
import type { StatutClient } from './types.js';
|
||||
|
||||
export interface ClientProps {
|
||||
id: number;
|
||||
nom: string;
|
||||
contactPrincipal?: string | null;
|
||||
contactEmail?: string | null;
|
||||
contactTelephone?: string | null;
|
||||
secteur?: string | null;
|
||||
notes?: string | null;
|
||||
statut?: StatutClient;
|
||||
projets?: Projet[];
|
||||
}
|
||||
|
||||
export class Client {
|
||||
public readonly id: number;
|
||||
public nom: string;
|
||||
public contactPrincipal: string | null;
|
||||
public contactEmail: string | null;
|
||||
public contactTelephone: string | null;
|
||||
public secteur: string | null;
|
||||
public notes: string | null;
|
||||
public statut: StatutClient;
|
||||
public readonly projets: Projet[];
|
||||
|
||||
constructor(props: ClientProps) {
|
||||
this.id = props.id;
|
||||
this.nom = props.nom;
|
||||
this.contactPrincipal = props.contactPrincipal ?? null;
|
||||
this.contactEmail = props.contactEmail ?? null;
|
||||
this.contactTelephone = props.contactTelephone ?? null;
|
||||
this.secteur = props.secteur ?? null;
|
||||
this.notes = props.notes ?? null;
|
||||
this.statut = props.statut ?? 'prospect';
|
||||
this.projets = props.projets ?? [];
|
||||
}
|
||||
|
||||
creerProjet(nom: string, nextId: number): Projet {
|
||||
if (this.statut === 'archive') {
|
||||
throw new Error('Client archive : creation projet impossible');
|
||||
}
|
||||
if (this.projets.some((p) => p.nom === nom)) {
|
||||
throw new Error(`Projet ${nom} existe deja pour ce client`);
|
||||
}
|
||||
const projet = new Projet({
|
||||
id: nextId,
|
||||
clientId: this.id,
|
||||
nom,
|
||||
chargeHeures: new Decimal(0),
|
||||
statut: 'devis',
|
||||
});
|
||||
this.projets.push(projet);
|
||||
return projet;
|
||||
}
|
||||
|
||||
archiver(): void {
|
||||
this.statut = 'archive';
|
||||
}
|
||||
}
|
||||
76
bridge/src/domain/formation.ts
Normal file
76
bridge/src/domain/formation.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import type { Bloc } from './bloc.js';
|
||||
import type { Filiere, StatutFormation } from './types.js';
|
||||
|
||||
export interface FormationProps {
|
||||
id: number;
|
||||
nom: string;
|
||||
filiere?: Filiere | null;
|
||||
heuresTotales: Decimal;
|
||||
statut?: StatutFormation;
|
||||
dateDebut?: Date | null;
|
||||
dateFin?: Date | null;
|
||||
blocs?: Bloc[];
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
|
||||
export class Formation {
|
||||
public readonly id: number;
|
||||
public nom: string;
|
||||
public filiere: Filiere | null;
|
||||
public heuresTotales: Decimal;
|
||||
public statut: StatutFormation;
|
||||
public dateDebut: Date | null;
|
||||
public dateFin: Date | null;
|
||||
public readonly blocs: Bloc[];
|
||||
|
||||
constructor(props: FormationProps) {
|
||||
if (props.heuresTotales.lt(0)) {
|
||||
throw new Error('heuresTotales doit etre >= 0');
|
||||
}
|
||||
if (props.dateDebut && props.dateFin && props.dateFin < props.dateDebut) {
|
||||
throw new Error('dateFin doit etre >= dateDebut');
|
||||
}
|
||||
this.id = props.id;
|
||||
this.nom = props.nom;
|
||||
this.filiere = props.filiere ?? null;
|
||||
this.heuresTotales = props.heuresTotales;
|
||||
this.statut = props.statut ?? 'draft';
|
||||
this.dateDebut = props.dateDebut ?? null;
|
||||
this.dateFin = props.dateFin ?? null;
|
||||
this.blocs = props.blocs ?? [];
|
||||
}
|
||||
|
||||
heuresAttribuees(): Decimal {
|
||||
return this.blocs.reduce((acc, b) => acc.plus(b.heuresPrevues), ZERO);
|
||||
}
|
||||
|
||||
heuresRestantes(): Decimal {
|
||||
return this.heuresTotales.minus(this.heuresAttribuees());
|
||||
}
|
||||
|
||||
activer(): void {
|
||||
if (this.statut === 'archive') {
|
||||
throw new Error('Formation archivee ne peut etre activee');
|
||||
}
|
||||
this.statut = 'actif';
|
||||
}
|
||||
|
||||
archiver(): void {
|
||||
this.statut = 'archive';
|
||||
}
|
||||
|
||||
ajouterBloc(bloc: Bloc): void {
|
||||
if (this.blocs.some((b) => b.id === bloc.id)) {
|
||||
throw new Error(`Bloc ${bloc.id} deja present dans la formation`);
|
||||
}
|
||||
const total = this.heuresAttribuees().plus(bloc.heuresPrevues);
|
||||
if (total.gt(this.heuresTotales)) {
|
||||
throw new Error(
|
||||
`Capacite formation depassee: ${total.toString()} > ${this.heuresTotales.toString()}`,
|
||||
);
|
||||
}
|
||||
this.blocs.push(bloc);
|
||||
}
|
||||
}
|
||||
20
bridge/src/domain/index.ts
Normal file
20
bridge/src/domain/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export * from './types.js';
|
||||
export { Personne } from './personne.js';
|
||||
export type { PersonneProps } from './personne.js';
|
||||
export { Formation } from './formation.js';
|
||||
export type { FormationProps } from './formation.js';
|
||||
export { Bloc } from './bloc.js';
|
||||
export type { BlocProps } from './bloc.js';
|
||||
export { Module } from './module.js';
|
||||
export type { ModuleProps } from './module.js';
|
||||
export { Attribution } from './attribution.js';
|
||||
export type { AttributionProps } from './attribution.js';
|
||||
export { Client } from './client.js';
|
||||
export type { ClientProps } from './client.js';
|
||||
export { Projet } from './projet.js';
|
||||
export type { ProjetProps } from './projet.js';
|
||||
export { Tache } from './tache.js';
|
||||
export type { TacheProps } from './tache.js';
|
||||
export { Intervention } from './intervention.js';
|
||||
export type { InterventionProps } from './intervention.js';
|
||||
export * from './schemas.js';
|
||||
46
bridge/src/domain/intervention.ts
Normal file
46
bridge/src/domain/intervention.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Decimal } from 'decimal.js';
|
||||
import type { StatutIntervention } from './types.js';
|
||||
|
||||
export interface InterventionProps {
|
||||
id: number;
|
||||
tacheId: number;
|
||||
personneId: number;
|
||||
heures: Decimal;
|
||||
date: Date;
|
||||
notes?: string | null;
|
||||
statut?: StatutIntervention;
|
||||
}
|
||||
|
||||
export class Intervention {
|
||||
public readonly id: number;
|
||||
public readonly tacheId: number;
|
||||
public readonly personneId: number;
|
||||
public heures: Decimal;
|
||||
public date: Date;
|
||||
public notes: string | null;
|
||||
public statut: StatutIntervention;
|
||||
|
||||
constructor(props: InterventionProps) {
|
||||
if (props.heures.lte(0)) {
|
||||
throw new Error('heures doit etre > 0');
|
||||
}
|
||||
this.id = props.id;
|
||||
this.tacheId = props.tacheId;
|
||||
this.personneId = props.personneId;
|
||||
this.heures = props.heures;
|
||||
this.date = props.date;
|
||||
this.notes = props.notes ?? null;
|
||||
this.statut = props.statut ?? 'realise';
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.statut === 'planifie' || this.statut === 'realise';
|
||||
}
|
||||
|
||||
annuler(_raison: string): void {
|
||||
if (this.statut === 'annule') {
|
||||
throw new Error('Intervention deja annulee');
|
||||
}
|
||||
this.statut = 'annule';
|
||||
}
|
||||
}
|
||||
123
bridge/src/domain/module.ts
Normal file
123
bridge/src/domain/module.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { Attribution } from './attribution.js';
|
||||
import type { Personne } from './personne.js';
|
||||
import type { StatutModule } from './types.js';
|
||||
|
||||
export interface ModuleProps {
|
||||
id: number;
|
||||
blocId: number;
|
||||
nom: string;
|
||||
heuresPrevues: Decimal;
|
||||
statut?: StatutModule;
|
||||
attributions?: Attribution[];
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
|
||||
export class Module {
|
||||
public readonly id: number;
|
||||
public readonly blocId: number;
|
||||
public nom: string;
|
||||
public heuresPrevues: Decimal;
|
||||
public statut: StatutModule;
|
||||
public readonly attributions: Attribution[];
|
||||
|
||||
constructor(props: ModuleProps) {
|
||||
if (props.heuresPrevues.lt(0)) {
|
||||
throw new Error('heuresPrevues doit etre >= 0');
|
||||
}
|
||||
this.id = props.id;
|
||||
this.blocId = props.blocId;
|
||||
this.nom = props.nom;
|
||||
this.heuresPrevues = props.heuresPrevues;
|
||||
this.statut = props.statut ?? 'a_attribuer';
|
||||
this.attributions = props.attributions ?? [];
|
||||
}
|
||||
|
||||
heuresAttribuees(): Decimal {
|
||||
return this.attributions
|
||||
.filter((a) => a.statut !== 'annule')
|
||||
.reduce((acc, a) => acc.plus(a.heuresAttribuees), ZERO);
|
||||
}
|
||||
|
||||
heuresRealisees(): Decimal {
|
||||
return this.attributions
|
||||
.filter((a) => a.statut !== 'annule')
|
||||
.reduce((acc, a) => acc.plus(a.heuresRealisees), ZERO);
|
||||
}
|
||||
|
||||
heuresRestantes(): Decimal {
|
||||
return this.heuresPrevues.minus(this.heuresAttribuees());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une attribution sur ce module pour `personne`.
|
||||
* RG-01 : `SUM(attributions.heures) + heures <= heuresPrevues`.
|
||||
* Warn si depassement de la capacite formation de la personne (n'echoue pas — decision de gestion).
|
||||
*/
|
||||
creerAttribution(
|
||||
personne: Personne,
|
||||
heures: Decimal,
|
||||
dateDebut: Date | null,
|
||||
dateFin: Date | null,
|
||||
nextId: number,
|
||||
): Attribution {
|
||||
if (!personne.hasRole('formateur')) {
|
||||
throw new Error('Personne doit avoir le role formateur');
|
||||
}
|
||||
if (personne.statut !== 'actif') {
|
||||
throw new Error('Personne inactive : attribution interdite');
|
||||
}
|
||||
if (heures.lte(0)) {
|
||||
throw new Error('heures doit etre > 0');
|
||||
}
|
||||
const total = this.heuresAttribuees().plus(heures);
|
||||
if (total.gt(this.heuresPrevues)) {
|
||||
throw new Error(
|
||||
`RG-01: heures attribuees (${total.toString()}) > heuresPrevues (${this.heuresPrevues.toString()})`,
|
||||
);
|
||||
}
|
||||
if (heures.gt(personne.heuresRestantesFormation())) {
|
||||
logger.warn(
|
||||
{
|
||||
moduleId: this.id,
|
||||
personneId: personne.id,
|
||||
heures: heures.toString(),
|
||||
restantes: personne.heuresRestantesFormation().toString(),
|
||||
},
|
||||
'Attribution depasse capacite formation restante de la personne',
|
||||
);
|
||||
}
|
||||
|
||||
const attribution = new Attribution({
|
||||
id: nextId,
|
||||
moduleId: this.id,
|
||||
personneId: personne.id,
|
||||
heuresAttribuees: heures,
|
||||
dateDebut,
|
||||
dateFin,
|
||||
statut: 'planifie',
|
||||
});
|
||||
this.attributions.push(attribution);
|
||||
personne._appliquerHeuresFormation(heures);
|
||||
if (this.statut === 'a_attribuer') {
|
||||
this.statut = 'attribue';
|
||||
}
|
||||
return attribution;
|
||||
}
|
||||
|
||||
annuler(): void {
|
||||
if (this.statut === 'realise') {
|
||||
throw new Error('Module realise ne peut etre annule');
|
||||
}
|
||||
this.statut = 'annule';
|
||||
}
|
||||
|
||||
cloturer(): void {
|
||||
if (this.statut === 'annule') {
|
||||
throw new Error('Module annule ne peut etre cloture');
|
||||
}
|
||||
this.statut = 'realise';
|
||||
}
|
||||
}
|
||||
165
bridge/src/domain/personne.ts
Normal file
165
bridge/src/domain/personne.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import type { Role, StatutPersonne } from './types.js';
|
||||
|
||||
export interface PersonneProps {
|
||||
id: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
capaciteAnnuelle: Decimal;
|
||||
splitFormationPct: Decimal;
|
||||
splitAgencePct: Decimal;
|
||||
roles: Set<Role>;
|
||||
statut: StatutPersonne;
|
||||
heuresAttribueesFormation?: Decimal;
|
||||
heuresAttribueesAgence?: Decimal;
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
const HUNDRED = new Decimal(100);
|
||||
|
||||
export class Personne {
|
||||
public readonly id: number;
|
||||
public nom: string;
|
||||
public prenom: string;
|
||||
public email: string;
|
||||
public capaciteAnnuelle: Decimal;
|
||||
public splitFormationPct: Decimal;
|
||||
public splitAgencePct: Decimal;
|
||||
public roles: Set<Role>;
|
||||
public statut: StatutPersonne;
|
||||
|
||||
// Rollups projetes depuis l'aggregat — sources = ATTRIBUTION/INTERVENTION cote DB
|
||||
private _heuresAttribueesFormation: Decimal;
|
||||
private _heuresAttribueesAgence: Decimal;
|
||||
|
||||
constructor(props: PersonneProps) {
|
||||
if (props.capaciteAnnuelle.lt(0)) {
|
||||
throw new Error('capaciteAnnuelle doit etre >= 0');
|
||||
}
|
||||
if (props.splitFormationPct.lt(0) || props.splitFormationPct.gt(100)) {
|
||||
throw new Error('splitFormationPct doit etre entre 0 et 100');
|
||||
}
|
||||
if (props.splitAgencePct.lt(0) || props.splitAgencePct.gt(100)) {
|
||||
throw new Error('splitAgencePct doit etre entre 0 et 100');
|
||||
}
|
||||
if (!props.splitFormationPct.plus(props.splitAgencePct).equals(HUNDRED)) {
|
||||
throw new Error('splitFormationPct + splitAgencePct doit egaler 100');
|
||||
}
|
||||
|
||||
this.id = props.id;
|
||||
this.nom = props.nom;
|
||||
this.prenom = props.prenom;
|
||||
this.email = props.email;
|
||||
this.capaciteAnnuelle = props.capaciteAnnuelle;
|
||||
this.splitFormationPct = props.splitFormationPct;
|
||||
this.splitAgencePct = props.splitAgencePct;
|
||||
this.roles = new Set(props.roles);
|
||||
this.statut = props.statut;
|
||||
this._heuresAttribueesFormation = props.heuresAttribueesFormation ?? ZERO;
|
||||
this._heuresAttribueesAgence = props.heuresAttribueesAgence ?? ZERO;
|
||||
}
|
||||
|
||||
get heuresAttribueesFormation(): Decimal {
|
||||
return this._heuresAttribueesFormation;
|
||||
}
|
||||
|
||||
get heuresAttribueesAgence(): Decimal {
|
||||
return this._heuresAttribueesAgence;
|
||||
}
|
||||
|
||||
capaciteFormation(): Decimal {
|
||||
return this.capaciteAnnuelle.times(this.splitFormationPct).div(HUNDRED);
|
||||
}
|
||||
|
||||
capaciteAgence(): Decimal {
|
||||
return this.capaciteAnnuelle.times(this.splitAgencePct).div(HUNDRED);
|
||||
}
|
||||
|
||||
heuresRestantesFormation(): Decimal {
|
||||
return this.capaciteFormation().minus(this._heuresAttribueesFormation);
|
||||
}
|
||||
|
||||
heuresRestantesAgence(): Decimal {
|
||||
return this.capaciteAgence().minus(this._heuresAttribueesAgence);
|
||||
}
|
||||
|
||||
heuresRestantesTotal(): Decimal {
|
||||
return this.capaciteAnnuelle
|
||||
.minus(this._heuresAttribueesFormation)
|
||||
.minus(this._heuresAttribueesAgence);
|
||||
}
|
||||
|
||||
hasRole(role: Role): boolean {
|
||||
return this.roles.has(role);
|
||||
}
|
||||
|
||||
ajouterRole(role: Role): void {
|
||||
this.roles.add(role); // idempotent par construction du Set
|
||||
}
|
||||
|
||||
/**
|
||||
* Le retrait est bloque si la personne porte des allocations actives sur ce role
|
||||
* — sinon on orphelinerait des Attributions/Interventions cote DB.
|
||||
* Le caller fournit les compteurs car la classe ne connait pas ses aggregats children.
|
||||
*/
|
||||
retirerRole(
|
||||
role: Role,
|
||||
opts?: { activeAttributions?: number; activeInterventions?: number },
|
||||
): void {
|
||||
if (role === 'formateur' && (opts?.activeAttributions ?? 0) > 0) {
|
||||
throw new Error(
|
||||
'Impossible de retirer le role formateur : attributions actives encore liees',
|
||||
);
|
||||
}
|
||||
if (role === 'developpeur' && (opts?.activeInterventions ?? 0) > 0) {
|
||||
throw new Error(
|
||||
'Impossible de retirer le role developpeur : interventions actives encore liees',
|
||||
);
|
||||
}
|
||||
this.roles.delete(role);
|
||||
}
|
||||
|
||||
activer(): void {
|
||||
this.statut = 'actif';
|
||||
}
|
||||
|
||||
inactiver(): void {
|
||||
this.statut = 'inactif';
|
||||
}
|
||||
|
||||
/** Mutation interne du rollup formation — appele par les aggregat methods. */
|
||||
_appliquerHeuresFormation(delta: Decimal): void {
|
||||
const next = this._heuresAttribueesFormation.plus(delta);
|
||||
if (next.lt(0)) {
|
||||
throw new Error('heuresAttribueesFormation ne peut pas devenir negatif');
|
||||
}
|
||||
if (next.gt(this.capaciteFormation())) {
|
||||
logger.warn(
|
||||
{
|
||||
personneId: this.id,
|
||||
next: next.toString(),
|
||||
capacite: this.capaciteFormation().toString(),
|
||||
},
|
||||
'Personne en surcharge formation',
|
||||
);
|
||||
}
|
||||
this._heuresAttribueesFormation = next;
|
||||
}
|
||||
|
||||
/** Mutation interne du rollup agence. */
|
||||
_appliquerHeuresAgence(delta: Decimal): void {
|
||||
const next = this._heuresAttribueesAgence.plus(delta);
|
||||
if (next.lt(0)) {
|
||||
throw new Error('heuresAttribueesAgence ne peut pas devenir negatif');
|
||||
}
|
||||
if (next.gt(this.capaciteAgence())) {
|
||||
logger.warn(
|
||||
{ personneId: this.id, next: next.toString(), capacite: this.capaciteAgence().toString() },
|
||||
'Personne en surcharge agence',
|
||||
);
|
||||
}
|
||||
this._heuresAttribueesAgence = next;
|
||||
}
|
||||
}
|
||||
90
bridge/src/domain/projet.ts
Normal file
90
bridge/src/domain/projet.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import type { Formation } from './formation.js';
|
||||
import { Tache } from './tache.js';
|
||||
import type { ProjetType, StatutProjet } from './types.js';
|
||||
|
||||
export interface ProjetProps {
|
||||
id: number;
|
||||
clientId: number;
|
||||
nom: string;
|
||||
type?: ProjetType | null;
|
||||
chargeHeures: Decimal;
|
||||
statut?: StatutProjet;
|
||||
formationId?: number | null;
|
||||
taches?: Tache[];
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
|
||||
export class Projet {
|
||||
public readonly id: number;
|
||||
public readonly clientId: number;
|
||||
public nom: string;
|
||||
public type: ProjetType | null;
|
||||
public chargeHeures: Decimal;
|
||||
public statut: StatutProjet;
|
||||
public formationId: number | null;
|
||||
public readonly taches: Tache[];
|
||||
|
||||
constructor(props: ProjetProps) {
|
||||
if (props.chargeHeures.lt(0)) {
|
||||
throw new Error('chargeHeures doit etre >= 0');
|
||||
}
|
||||
this.id = props.id;
|
||||
this.clientId = props.clientId;
|
||||
this.nom = props.nom;
|
||||
this.type = props.type ?? null;
|
||||
this.chargeHeures = props.chargeHeures;
|
||||
this.statut = props.statut ?? 'devis';
|
||||
this.formationId = props.formationId ?? null;
|
||||
this.taches = props.taches ?? [];
|
||||
}
|
||||
|
||||
heuresAttribuees(): Decimal {
|
||||
return this.taches.reduce((acc, t) => acc.plus(t.chargeHeures), ZERO);
|
||||
}
|
||||
|
||||
heuresRealisees(): Decimal {
|
||||
return this.taches.reduce((acc, t) => acc.plus(t.heuresRealisees()), ZERO);
|
||||
}
|
||||
|
||||
heuresRestantes(): Decimal {
|
||||
return this.chargeHeures.minus(this.heuresRealisees());
|
||||
}
|
||||
|
||||
ajouterTache(titre: string, charge: Decimal, nextId: number): Tache {
|
||||
if (this.statut === 'cloture' || this.statut === 'abandonne') {
|
||||
throw new Error(`Impossible d'ajouter une tache : projet ${this.statut}`);
|
||||
}
|
||||
if (charge.lt(0)) {
|
||||
throw new Error('charge doit etre >= 0');
|
||||
}
|
||||
const tache = new Tache({
|
||||
id: nextId,
|
||||
projetId: this.id,
|
||||
titre,
|
||||
chargeHeures: charge,
|
||||
statut: 'todo',
|
||||
});
|
||||
this.taches.push(tache);
|
||||
return tache;
|
||||
}
|
||||
|
||||
lierFormationPedagogique(formation: Formation): void {
|
||||
this.formationId = formation.id;
|
||||
}
|
||||
|
||||
livrer(): void {
|
||||
if (this.statut !== 'en_cours' && this.statut !== 'devis') {
|
||||
throw new Error(`Transition invalide ${this.statut} -> livre`);
|
||||
}
|
||||
this.statut = 'livre';
|
||||
}
|
||||
|
||||
cloturer(): void {
|
||||
if (this.statut === 'abandonne') {
|
||||
throw new Error('Projet abandonne ne peut etre cloture');
|
||||
}
|
||||
this.statut = 'cloture';
|
||||
}
|
||||
}
|
||||
145
bridge/src/domain/schemas.ts
Normal file
145
bridge/src/domain/schemas.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schemas zod pour validation runtime au boundary HTTP/webhook.
|
||||
* Convention : `decimal` est valide via `z.coerce.number()` puis converti en `Decimal` dans la mapping layer.
|
||||
*/
|
||||
|
||||
export const RoleSchema = z.enum(['formateur', 'developpeur', 'admin', 'direction', 'support']);
|
||||
|
||||
export const FiliereSchema = z.enum(['dev', 'graphisme', 'marketing', 'iot', 'cybersec']);
|
||||
|
||||
export const PrioriteSchema = z.enum(['faible', 'normale', 'haute', 'critique']);
|
||||
|
||||
export const ProjetTypeSchema = z.enum([
|
||||
'site_web',
|
||||
'app_mobile',
|
||||
'api',
|
||||
'infra',
|
||||
'audit',
|
||||
'support',
|
||||
'autre',
|
||||
]);
|
||||
|
||||
export const StatutPersonneSchema = z.enum(['actif', 'inactif']);
|
||||
export const StatutFormationSchema = z.enum(['draft', 'actif', 'termine', 'archive']);
|
||||
export const StatutModuleSchema = z.enum([
|
||||
'a_attribuer',
|
||||
'attribue',
|
||||
'en_cours',
|
||||
'realise',
|
||||
'annule',
|
||||
]);
|
||||
export const StatutAttributionSchema = z.enum(['planifie', 'en_cours', 'realise', 'annule']);
|
||||
export const StatutClientSchema = z.enum(['prospect', 'actif', 'inactif', 'archive']);
|
||||
export const StatutProjetSchema = z.enum(['devis', 'en_cours', 'livre', 'cloture', 'abandonne']);
|
||||
export const StatutTacheSchema = z.enum(['todo', 'in_progress', 'review', 'done', 'abandoned']);
|
||||
export const StatutInterventionSchema = z.enum(['planifie', 'realise', 'annule']);
|
||||
|
||||
export const PersonneSchema = z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(100),
|
||||
prenom: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
telephone: z.string().max(20).optional().nullable(),
|
||||
capaciteAnnuelle: z.coerce.number().nonnegative(),
|
||||
splitFormationPct: z.coerce.number().min(0).max(100),
|
||||
splitAgencePct: z.coerce.number().min(0).max(100),
|
||||
roles: z.array(RoleSchema),
|
||||
statut: StatutPersonneSchema.default('actif'),
|
||||
heuresAttribueesFormation: z.coerce.number().nonnegative().optional(),
|
||||
heuresAttribueesAgence: z.coerce.number().nonnegative().optional(),
|
||||
})
|
||||
.refine((d) => d.splitFormationPct + d.splitAgencePct === 100, {
|
||||
message: 'splits doivent sommer a 100',
|
||||
path: ['splitFormationPct'],
|
||||
});
|
||||
|
||||
export const FormationSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
description: z.string().optional().nullable(),
|
||||
filiere: FiliereSchema.optional().nullable(),
|
||||
heuresTotales: z.coerce.number().nonnegative(),
|
||||
statut: StatutFormationSchema.default('draft'),
|
||||
dateDebut: z.coerce.date().optional().nullable(),
|
||||
dateFin: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
export const BlocSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
formationId: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
heuresPrevues: z.coerce.number().nonnegative(),
|
||||
ordre: z.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
export const ModuleSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
blocId: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
heuresPrevues: z.coerce.number().nonnegative(),
|
||||
statut: StatutModuleSchema.default('a_attribuer'),
|
||||
});
|
||||
|
||||
export const AttributionSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
moduleId: z.number().int().nonnegative(),
|
||||
personneId: z.number().int().nonnegative(),
|
||||
heuresAttribuees: z.coerce.number().positive(),
|
||||
heuresRealisees: z.coerce.number().nonnegative().default(0),
|
||||
dateDebut: z.coerce.date().optional().nullable(),
|
||||
dateFin: z.coerce.date().optional().nullable(),
|
||||
statut: StatutAttributionSchema.default('planifie'),
|
||||
});
|
||||
|
||||
export const ClientSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
contactPrincipal: z.string().max(200).optional().nullable(),
|
||||
contactEmail: z.string().email().optional().nullable(),
|
||||
contactTelephone: z.string().max(20).optional().nullable(),
|
||||
secteur: z.string().max(100).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
statut: StatutClientSchema.default('prospect'),
|
||||
});
|
||||
|
||||
export const ProjetSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
clientId: z.number().int().nonnegative(),
|
||||
nom: z.string().min(1).max(200),
|
||||
type: ProjetTypeSchema.optional().nullable(),
|
||||
chargeHeures: z.coerce.number().nonnegative(),
|
||||
statut: StatutProjetSchema.default('devis'),
|
||||
formationId: z.number().int().nonnegative().optional().nullable(),
|
||||
});
|
||||
|
||||
export const TacheSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
projetId: z.number().int().nonnegative(),
|
||||
titre: z.string().min(1).max(200),
|
||||
chargeHeures: z.coerce.number().nonnegative(),
|
||||
priorite: PrioriteSchema.optional().nullable(),
|
||||
statut: StatutTacheSchema.default('todo'),
|
||||
});
|
||||
|
||||
export const InterventionSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
tacheId: z.number().int().nonnegative(),
|
||||
personneId: z.number().int().nonnegative(),
|
||||
heures: z.coerce.number().positive(),
|
||||
date: z.coerce.date(),
|
||||
notes: z.string().optional().nullable(),
|
||||
statut: StatutInterventionSchema.default('realise'),
|
||||
});
|
||||
|
||||
export type PersonneInput = z.infer<typeof PersonneSchema>;
|
||||
export type FormationInput = z.infer<typeof FormationSchema>;
|
||||
export type BlocInput = z.infer<typeof BlocSchema>;
|
||||
export type ModuleInput = z.infer<typeof ModuleSchema>;
|
||||
export type AttributionInput = z.infer<typeof AttributionSchema>;
|
||||
export type ClientInput = z.infer<typeof ClientSchema>;
|
||||
export type ProjetInput = z.infer<typeof ProjetSchema>;
|
||||
export type TacheInput = z.infer<typeof TacheSchema>;
|
||||
export type InterventionInput = z.infer<typeof InterventionSchema>;
|
||||
90
bridge/src/domain/tache.ts
Normal file
90
bridge/src/domain/tache.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { Intervention } from './intervention.js';
|
||||
import type { Personne } from './personne.js';
|
||||
import type { Priorite, StatutTache } from './types.js';
|
||||
|
||||
export interface TacheProps {
|
||||
id: number;
|
||||
projetId: number;
|
||||
titre: string;
|
||||
chargeHeures: Decimal;
|
||||
priorite?: Priorite | null;
|
||||
statut?: StatutTache;
|
||||
interventions?: Intervention[];
|
||||
}
|
||||
|
||||
const ZERO = new Decimal(0);
|
||||
|
||||
export class Tache {
|
||||
public readonly id: number;
|
||||
public readonly projetId: number;
|
||||
public titre: string;
|
||||
public chargeHeures: Decimal;
|
||||
public priorite: Priorite | null;
|
||||
public statut: StatutTache;
|
||||
public readonly interventions: Intervention[];
|
||||
|
||||
constructor(props: TacheProps) {
|
||||
if (props.chargeHeures.lt(0)) {
|
||||
throw new Error('chargeHeures doit etre >= 0');
|
||||
}
|
||||
this.id = props.id;
|
||||
this.projetId = props.projetId;
|
||||
this.titre = props.titre;
|
||||
this.chargeHeures = props.chargeHeures;
|
||||
this.priorite = props.priorite ?? null;
|
||||
this.statut = props.statut ?? 'todo';
|
||||
this.interventions = props.interventions ?? [];
|
||||
}
|
||||
|
||||
heuresRealisees(): Decimal {
|
||||
return this.interventions
|
||||
.filter((i) => i.statut !== 'annule')
|
||||
.reduce((acc, i) => acc.plus(i.heures), ZERO);
|
||||
}
|
||||
|
||||
creerIntervention(personne: Personne, heures: Decimal, date: Date, nextId: number): Intervention {
|
||||
if (!personne.hasRole('developpeur')) {
|
||||
throw new Error('Personne doit avoir le role developpeur');
|
||||
}
|
||||
if (personne.statut !== 'actif') {
|
||||
throw new Error('Personne inactive : intervention interdite');
|
||||
}
|
||||
if (heures.lte(0)) {
|
||||
throw new Error('heures doit etre > 0');
|
||||
}
|
||||
|
||||
const intervention = new Intervention({
|
||||
id: nextId,
|
||||
tacheId: this.id,
|
||||
personneId: personne.id,
|
||||
heures,
|
||||
date,
|
||||
statut: 'realise',
|
||||
});
|
||||
this.interventions.push(intervention);
|
||||
personne._appliquerHeuresAgence(heures);
|
||||
return intervention;
|
||||
}
|
||||
|
||||
marquerInProgress(): void {
|
||||
if (this.statut === 'done' || this.statut === 'abandoned') {
|
||||
throw new Error(`Transition invalide ${this.statut} -> in_progress`);
|
||||
}
|
||||
this.statut = 'in_progress';
|
||||
}
|
||||
|
||||
marquerReview(): void {
|
||||
if (this.statut !== 'in_progress') {
|
||||
throw new Error(`Transition invalide ${this.statut} -> review`);
|
||||
}
|
||||
this.statut = 'review';
|
||||
}
|
||||
|
||||
marquerDone(): void {
|
||||
if (this.statut === 'abandoned' || this.statut === 'done') {
|
||||
throw new Error(`Transition invalide ${this.statut} -> done`);
|
||||
}
|
||||
this.statut = 'done';
|
||||
}
|
||||
}
|
||||
39
bridge/src/domain/types.ts
Normal file
39
bridge/src/domain/types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Types et value objects partages du domaine.
|
||||
*
|
||||
* Statuts modelises en discriminated unions plutot qu'en enums TS — ESM-friendly,
|
||||
* pas de runtime cost, narrowing precis au point d'usage.
|
||||
*/
|
||||
|
||||
export type Role = 'formateur' | 'developpeur' | 'admin' | 'direction' | 'support';
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
'formateur',
|
||||
'developpeur',
|
||||
'admin',
|
||||
'direction',
|
||||
'support',
|
||||
] as const;
|
||||
|
||||
export type Filiere = 'dev' | 'graphisme' | 'marketing' | 'iot' | 'cybersec';
|
||||
|
||||
export type Priorite = 'faible' | 'normale' | 'haute' | 'critique';
|
||||
|
||||
export type ProjetType =
|
||||
| 'site_web'
|
||||
| 'app_mobile'
|
||||
| 'api'
|
||||
| 'infra'
|
||||
| 'audit'
|
||||
| 'support'
|
||||
| 'autre';
|
||||
|
||||
// Statuts par entite — chacun ferme son cycle de vie
|
||||
export type StatutPersonne = 'actif' | 'inactif';
|
||||
export type StatutFormation = 'draft' | 'actif' | 'termine' | 'archive';
|
||||
export type StatutModule = 'a_attribuer' | 'attribue' | 'en_cours' | 'realise' | 'annule';
|
||||
export type StatutAttribution = 'planifie' | 'en_cours' | 'realise' | 'annule';
|
||||
export type StatutClient = 'prospect' | 'actif' | 'inactif' | 'archive';
|
||||
export type StatutProjet = 'devis' | 'en_cours' | 'livre' | 'cloture' | 'abandonne';
|
||||
export type StatutTache = 'todo' | 'in_progress' | 'review' | 'done' | 'abandoned';
|
||||
export type StatutIntervention = 'planifie' | 'realise' | 'annule';
|
||||
59
bridge/src/lib/errors.ts
Normal file
59
bridge/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Erreurs metier typees pour le bridge.
|
||||
* Chaque erreur a un code stable + statut HTTP + details serializables.
|
||||
*/
|
||||
|
||||
export type ErrorCode =
|
||||
| 'AUTH_REQUIRED'
|
||||
| 'AUTH_INVALID'
|
||||
| 'FORBIDDEN_SCOPE'
|
||||
| 'NOT_FOUND'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'RG_VIOLATION'
|
||||
| 'CONFLICT'
|
||||
| 'RATE_LIMITED'
|
||||
| 'BASEROW_UNAVAILABLE'
|
||||
| 'DOCMOST_UNAVAILABLE'
|
||||
| 'INTERNAL';
|
||||
|
||||
export class BridgeError extends Error {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BridgeError';
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
...(this.details ? { details: this.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const errors = {
|
||||
authRequired: () => new BridgeError('AUTH_REQUIRED', 401, 'Token absent'),
|
||||
authInvalid: () => new BridgeError('AUTH_INVALID', 401, 'Token invalide'),
|
||||
forbidden: (scope: string) =>
|
||||
new BridgeError('FORBIDDEN_SCOPE', 403, `Scope requis : ${scope}`, { scope }),
|
||||
notFound: (entity: string, id: string | number) =>
|
||||
new BridgeError('NOT_FOUND', 404, `${entity} introuvable`, { entity, id }),
|
||||
validation: (issues: unknown) =>
|
||||
new BridgeError('VALIDATION_ERROR', 400, 'Body invalide', { issues }),
|
||||
rgViolation: (rule: string, message: string, details?: Record<string, unknown>) =>
|
||||
new BridgeError('RG_VIOLATION', 422, message, { rule, ...details }),
|
||||
conflict: (message: string, details?: Record<string, unknown>) =>
|
||||
new BridgeError('CONFLICT', 409, message, details),
|
||||
rateLimited: (retryAfter: number) =>
|
||||
new BridgeError('RATE_LIMITED', 429, 'Too many requests', { retry_after: retryAfter }),
|
||||
baserowDown: () => new BridgeError('BASEROW_UNAVAILABLE', 502, 'Baserow API unreachable'),
|
||||
docmostDown: () => new BridgeError('DOCMOST_UNAVAILABLE', 502, 'Docmost API unreachable'),
|
||||
internal: (message: string) => new BridgeError('INTERNAL', 500, message),
|
||||
};
|
||||
110
bridge/tests/domain/attribution.test.ts
Normal file
110
bridge/tests/domain/attribution.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attribution } from '../../src/domain/attribution.js';
|
||||
|
||||
const make = (overrides: Partial<ConstructorParameters<typeof Attribution>[0]> = {}) =>
|
||||
new Attribution({
|
||||
id: 1,
|
||||
moduleId: 10,
|
||||
personneId: 5,
|
||||
heuresAttribuees: new Decimal(20),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Attribution — constructeur', () => {
|
||||
it('cree une attribution valide', () => {
|
||||
const a = make();
|
||||
expect(a.statut).toBe('planifie');
|
||||
expect(a.heuresRealisees.toNumber()).toBe(0);
|
||||
});
|
||||
|
||||
it('throw si heuresAttribuees <= 0', () => {
|
||||
expect(() => make({ heuresAttribuees: new Decimal(0) })).toThrow();
|
||||
expect(() => make({ heuresAttribuees: new Decimal(-5) })).toThrow();
|
||||
});
|
||||
|
||||
it('throw si heuresRealisees < 0', () => {
|
||||
expect(() => make({ heuresRealisees: new Decimal(-1) })).toThrow();
|
||||
});
|
||||
|
||||
it('throw si dateFin < dateDebut', () => {
|
||||
expect(() =>
|
||||
make({ dateDebut: new Date('2026-02-01'), dateFin: new Date('2026-01-01') }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attribution — transitions de statut', () => {
|
||||
it('demarrer: planifie -> en_cours', () => {
|
||||
const a = make();
|
||||
a.demarrer();
|
||||
expect(a.statut).toBe('en_cours');
|
||||
});
|
||||
|
||||
it('demarrer invalide depuis realise', () => {
|
||||
const a = make({ statut: 'realise' });
|
||||
expect(() => a.demarrer()).toThrow();
|
||||
});
|
||||
|
||||
it('cloturer: en_cours -> realise', () => {
|
||||
const a = make({ statut: 'en_cours' });
|
||||
a.cloturer();
|
||||
expect(a.statut).toBe('realise');
|
||||
});
|
||||
|
||||
it('cloturer: planifie -> realise (skip en_cours autorise)', () => {
|
||||
const a = make();
|
||||
a.cloturer();
|
||||
expect(a.statut).toBe('realise');
|
||||
});
|
||||
|
||||
it('cloturer invalide depuis annule', () => {
|
||||
const a = make({ statut: 'annule' });
|
||||
expect(() => a.cloturer()).toThrow();
|
||||
});
|
||||
|
||||
it('annuler depuis planifie OK', () => {
|
||||
const a = make();
|
||||
a.annuler('client desistement');
|
||||
expect(a.statut).toBe('annule');
|
||||
});
|
||||
|
||||
it('annuler invalide depuis realise', () => {
|
||||
const a = make({ statut: 'realise' });
|
||||
expect(() => a.annuler('motif')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attribution — saisirHeuresRealisees', () => {
|
||||
it('cas nominal', () => {
|
||||
const a = make();
|
||||
a.saisirHeuresRealisees(new Decimal(15));
|
||||
expect(a.heuresRealisees.toNumber()).toBe(15);
|
||||
});
|
||||
|
||||
it('throw si heures < 0', () => {
|
||||
const a = make();
|
||||
expect(() => a.saisirHeuresRealisees(new Decimal(-1))).toThrow();
|
||||
});
|
||||
|
||||
it('throw si attribution annulee', () => {
|
||||
const a = make({ statut: 'annule' });
|
||||
expect(() => a.saisirHeuresRealisees(new Decimal(5))).toThrow();
|
||||
});
|
||||
|
||||
it('throw si attribution realisee', () => {
|
||||
const a = make({ statut: 'realise' });
|
||||
expect(() => a.saisirHeuresRealisees(new Decimal(5))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attribution — isActive', () => {
|
||||
it('planifie et en_cours sont actifs', () => {
|
||||
expect(make().isActive()).toBe(true);
|
||||
expect(make({ statut: 'en_cours' }).isActive()).toBe(true);
|
||||
});
|
||||
it('realise et annule ne sont pas actifs', () => {
|
||||
expect(make({ statut: 'realise' }).isActive()).toBe(false);
|
||||
expect(make({ statut: 'annule' }).isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
42
bridge/tests/domain/bloc.test.ts
Normal file
42
bridge/tests/domain/bloc.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Bloc } from '../../src/domain/bloc.js';
|
||||
import { Module } from '../../src/domain/module.js';
|
||||
|
||||
const makeModule = (id: number, h: number) =>
|
||||
new Module({ id, blocId: 1, nom: `M${id}`, heuresPrevues: new Decimal(h) });
|
||||
|
||||
describe('Bloc', () => {
|
||||
it('cree un bloc vide', () => {
|
||||
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) });
|
||||
expect(b.modules.length).toBe(0);
|
||||
expect(b.heuresAttribuees().toNumber()).toBe(0);
|
||||
expect(b.heuresRestantes().toNumber()).toBe(120);
|
||||
});
|
||||
|
||||
it('throw si heuresPrevues < 0', () => {
|
||||
expect(
|
||||
() => new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(-1) }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('ajouterModule rollup correct', () => {
|
||||
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) });
|
||||
b.ajouterModule(makeModule(1, 40));
|
||||
b.ajouterModule(makeModule(2, 60));
|
||||
expect(b.heuresAttribuees().toNumber()).toBe(100);
|
||||
expect(b.heuresRestantes().toNumber()).toBe(20);
|
||||
});
|
||||
|
||||
it('throw si module duplique', () => {
|
||||
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) });
|
||||
b.ajouterModule(makeModule(1, 40));
|
||||
expect(() => b.ajouterModule(makeModule(1, 10))).toThrow(/deja present/);
|
||||
});
|
||||
|
||||
it('throw si capacite bloc depassee', () => {
|
||||
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(50) });
|
||||
b.ajouterModule(makeModule(1, 30));
|
||||
expect(() => b.ajouterModule(makeModule(2, 30))).toThrow(/depassee/);
|
||||
});
|
||||
});
|
||||
36
bridge/tests/domain/client.test.ts
Normal file
36
bridge/tests/domain/client.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { Client } from '../../src/domain/client.js';
|
||||
|
||||
describe('Client', () => {
|
||||
it('cree un client valide', () => {
|
||||
const c = new Client({ id: 1, nom: 'Acme' });
|
||||
expect(c.statut).toBe('prospect');
|
||||
expect(c.projets.length).toBe(0);
|
||||
});
|
||||
|
||||
it('creerProjet cas nominal', () => {
|
||||
const c = new Client({ id: 1, nom: 'Acme' });
|
||||
const p = c.creerProjet('Site corpo', 100);
|
||||
expect(p.id).toBe(100);
|
||||
expect(p.clientId).toBe(1);
|
||||
expect(c.projets.length).toBe(1);
|
||||
});
|
||||
|
||||
it('creerProjet throw si nom duplique', () => {
|
||||
const c = new Client({ id: 1, nom: 'Acme' });
|
||||
c.creerProjet('Site corpo', 100);
|
||||
expect(() => c.creerProjet('Site corpo', 101)).toThrow(/existe deja/);
|
||||
});
|
||||
|
||||
it('creerProjet bloque si client archive', () => {
|
||||
const c = new Client({ id: 1, nom: 'Acme' });
|
||||
c.archiver();
|
||||
expect(() => c.creerProjet('X', 1)).toThrow(/archive/);
|
||||
});
|
||||
|
||||
it('archiver transition', () => {
|
||||
const c = new Client({ id: 1, nom: 'Acme' });
|
||||
c.archiver();
|
||||
expect(c.statut).toBe('archive');
|
||||
});
|
||||
});
|
||||
66
bridge/tests/domain/formation.test.ts
Normal file
66
bridge/tests/domain/formation.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Bloc } from '../../src/domain/bloc.js';
|
||||
import { Formation } from '../../src/domain/formation.js';
|
||||
|
||||
describe('Formation', () => {
|
||||
it('cree une formation valide', () => {
|
||||
const f = new Formation({ id: 1, nom: 'BTS SIO', heuresTotales: new Decimal(1400) });
|
||||
expect(f.statut).toBe('draft');
|
||||
expect(f.heuresRestantes().toNumber()).toBe(1400);
|
||||
});
|
||||
|
||||
it('throw si heuresTotales < 0', () => {
|
||||
expect(() => new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(-1) })).toThrow();
|
||||
});
|
||||
|
||||
it('throw si dateFin < dateDebut', () => {
|
||||
expect(
|
||||
() =>
|
||||
new Formation({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
heuresTotales: new Decimal(100),
|
||||
dateDebut: new Date('2026-09-01'),
|
||||
dateFin: new Date('2026-08-01'),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('activer / archiver transitions', () => {
|
||||
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) });
|
||||
f.activer();
|
||||
expect(f.statut).toBe('actif');
|
||||
f.archiver();
|
||||
expect(f.statut).toBe('archive');
|
||||
});
|
||||
|
||||
it('activer apres archive est interdit', () => {
|
||||
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) });
|
||||
f.archiver();
|
||||
expect(() => f.activer()).toThrow();
|
||||
});
|
||||
|
||||
it('ajouterBloc + heuresRestantes rollup', () => {
|
||||
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(300) });
|
||||
const b1 = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(100) });
|
||||
const b2 = new Bloc({ id: 2, formationId: 1, nom: 'B2', heuresPrevues: new Decimal(150) });
|
||||
f.ajouterBloc(b1);
|
||||
f.ajouterBloc(b2);
|
||||
expect(f.heuresAttribuees().toNumber()).toBe(250);
|
||||
expect(f.heuresRestantes().toNumber()).toBe(50);
|
||||
});
|
||||
|
||||
it('throw si bloc duplique', () => {
|
||||
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(300) });
|
||||
const b = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(100) });
|
||||
f.ajouterBloc(b);
|
||||
expect(() => f.ajouterBloc(b)).toThrow(/deja present/);
|
||||
});
|
||||
|
||||
it('throw si depassement capacite formation', () => {
|
||||
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) });
|
||||
const b = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(150) });
|
||||
expect(() => f.ajouterBloc(b)).toThrow(/depassee/);
|
||||
});
|
||||
});
|
||||
43
bridge/tests/domain/intervention.test.ts
Normal file
43
bridge/tests/domain/intervention.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Intervention } from '../../src/domain/intervention.js';
|
||||
|
||||
const make = (overrides: Partial<ConstructorParameters<typeof Intervention>[0]> = {}) =>
|
||||
new Intervention({
|
||||
id: 1,
|
||||
tacheId: 10,
|
||||
personneId: 5,
|
||||
heures: new Decimal(4),
|
||||
date: new Date('2026-05-01'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Intervention', () => {
|
||||
it('cree une intervention valide', () => {
|
||||
const i = make();
|
||||
expect(i.statut).toBe('realise');
|
||||
expect(i.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('throw si heures <= 0', () => {
|
||||
expect(() => make({ heures: new Decimal(0) })).toThrow();
|
||||
expect(() => make({ heures: new Decimal(-1) })).toThrow();
|
||||
});
|
||||
|
||||
it('annuler depuis realise', () => {
|
||||
const i = make();
|
||||
i.annuler('erreur saisie');
|
||||
expect(i.statut).toBe('annule');
|
||||
expect(i.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('throw si annule deja annulee', () => {
|
||||
const i = make({ statut: 'annule' });
|
||||
expect(() => i.annuler('test')).toThrow();
|
||||
});
|
||||
|
||||
it('isActive false sur annule', () => {
|
||||
expect(make({ statut: 'annule' }).isActive()).toBe(false);
|
||||
expect(make({ statut: 'planifie' }).isActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
150
bridge/tests/domain/module.test.ts
Normal file
150
bridge/tests/domain/module.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Module } from '../../src/domain/module.js';
|
||||
import { Personne } from '../../src/domain/personne.js';
|
||||
import type { Role } from '../../src/domain/types.js';
|
||||
|
||||
const formateurActif = () =>
|
||||
new Personne({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
prenom: 'Y',
|
||||
email: 'x@y.fr',
|
||||
capaciteAnnuelle: new Decimal(1000),
|
||||
splitFormationPct: new Decimal(50),
|
||||
splitAgencePct: new Decimal(50),
|
||||
roles: new Set<Role>(['formateur']),
|
||||
statut: 'actif',
|
||||
});
|
||||
|
||||
describe('Module — constructeur', () => {
|
||||
it('cree un module valide', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
expect(m.statut).toBe('a_attribuer');
|
||||
});
|
||||
|
||||
it('throw si heuresPrevues < 0', () => {
|
||||
expect(
|
||||
() => new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(-1) }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module — creerAttribution happy path', () => {
|
||||
it('cree attribution + maj rollups + statut module', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
const p = formateurActif();
|
||||
const a = m.creerAttribution(p, new Decimal(20), null, null, 100);
|
||||
expect(a.id).toBe(100);
|
||||
expect(m.attributions.length).toBe(1);
|
||||
expect(m.heuresAttribuees().toNumber()).toBe(20);
|
||||
expect(p.heuresAttribueesFormation.toNumber()).toBe(20);
|
||||
expect(m.statut).toBe('attribue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module — creerAttribution validations', () => {
|
||||
it('throw si personne sans role formateur', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
const p = formateurActif();
|
||||
p.retirerRole('formateur');
|
||||
p.ajouterRole('developpeur');
|
||||
expect(() => m.creerAttribution(p, new Decimal(10), null, null, 1)).toThrow(/formateur/);
|
||||
});
|
||||
|
||||
it('throw si personne inactive', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
const p = formateurActif();
|
||||
p.inactiver();
|
||||
expect(() => m.creerAttribution(p, new Decimal(10), null, null, 1)).toThrow(/inactiv/);
|
||||
});
|
||||
|
||||
it('throw si heures <= 0', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
const p = formateurActif();
|
||||
expect(() => m.creerAttribution(p, new Decimal(0), null, null, 1)).toThrow();
|
||||
});
|
||||
|
||||
it('RG-01 violation : SUM(heures) > heuresPrevues', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
const p = formateurActif();
|
||||
m.creerAttribution(p, new Decimal(30), null, null, 1);
|
||||
expect(() => m.creerAttribution(p, new Decimal(15), null, null, 2)).toThrow(/RG-01/);
|
||||
});
|
||||
|
||||
it('warning si heures > capacite formation restante de la personne (ne throw pas)', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(1000) });
|
||||
const p = new Personne({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
prenom: 'Y',
|
||||
email: 'x@y.fr',
|
||||
capaciteAnnuelle: new Decimal(100),
|
||||
splitFormationPct: new Decimal(50),
|
||||
splitAgencePct: new Decimal(50),
|
||||
roles: new Set<Role>(['formateur']),
|
||||
statut: 'actif',
|
||||
});
|
||||
// capacite formation = 50. On attribue 80.
|
||||
const a = m.creerAttribution(p, new Decimal(80), null, null, 1);
|
||||
expect(a.heuresAttribuees.toNumber()).toBe(80);
|
||||
expect(p.heuresAttribueesFormation.toNumber()).toBe(80);
|
||||
});
|
||||
|
||||
it('RG-01 ignore les attributions annulees', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
const p = formateurActif();
|
||||
const a1 = m.creerAttribution(p, new Decimal(40), null, null, 1);
|
||||
a1.annuler('test');
|
||||
// La nouvelle attribution doit etre acceptee maintenant.
|
||||
const a2 = m.creerAttribution(p, new Decimal(20), null, null, 2);
|
||||
expect(a2.heuresAttribuees.toNumber()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module — heuresRealisees / heuresRestantes', () => {
|
||||
it('calcule heures realisees en ignorant annulations', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(50) });
|
||||
const p = formateurActif();
|
||||
const a = m.creerAttribution(p, new Decimal(30), null, null, 1);
|
||||
a.saisirHeuresRealisees(new Decimal(10));
|
||||
expect(m.heuresRealisees().toNumber()).toBe(10);
|
||||
expect(m.heuresRestantes().toNumber()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module — transitions de statut', () => {
|
||||
it('cloturer', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
m.cloturer();
|
||||
expect(m.statut).toBe('realise');
|
||||
});
|
||||
|
||||
it('annuler depuis a_attribuer', () => {
|
||||
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
|
||||
m.annuler();
|
||||
expect(m.statut).toBe('annule');
|
||||
});
|
||||
|
||||
it('annuler bloque depuis realise', () => {
|
||||
const m = new Module({
|
||||
id: 1,
|
||||
blocId: 10,
|
||||
nom: 'M1',
|
||||
heuresPrevues: new Decimal(40),
|
||||
statut: 'realise',
|
||||
});
|
||||
expect(() => m.annuler()).toThrow();
|
||||
});
|
||||
|
||||
it('cloturer bloque depuis annule', () => {
|
||||
const m = new Module({
|
||||
id: 1,
|
||||
blocId: 10,
|
||||
nom: 'M1',
|
||||
heuresPrevues: new Decimal(40),
|
||||
statut: 'annule',
|
||||
});
|
||||
expect(() => m.cloturer()).toThrow();
|
||||
});
|
||||
});
|
||||
177
bridge/tests/domain/personne.test.ts
Normal file
177
bridge/tests/domain/personne.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Personne } from '../../src/domain/personne.js';
|
||||
import type { Role } from '../../src/domain/types.js';
|
||||
|
||||
const baseProps = (overrides: Partial<ConstructorParameters<typeof Personne>[0]> = {}) => ({
|
||||
id: 1,
|
||||
nom: 'Dupont',
|
||||
prenom: 'Pierre',
|
||||
email: 'pierre@acadenice.fr',
|
||||
capaciteAnnuelle: new Decimal(1000),
|
||||
splitFormationPct: new Decimal(60),
|
||||
splitAgencePct: new Decimal(40),
|
||||
roles: new Set<Role>(['formateur']),
|
||||
statut: 'actif' as const,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Personne — constructeur', () => {
|
||||
it('cree une personne valide', () => {
|
||||
const p = new Personne(baseProps());
|
||||
expect(p.id).toBe(1);
|
||||
expect(p.statut).toBe('actif');
|
||||
});
|
||||
|
||||
it('throw si splits != 100', () => {
|
||||
expect(
|
||||
() =>
|
||||
new Personne(
|
||||
baseProps({ splitFormationPct: new Decimal(70), splitAgencePct: new Decimal(40) }),
|
||||
),
|
||||
).toThrow(/100/);
|
||||
});
|
||||
|
||||
it('throw si capaciteAnnuelle < 0', () => {
|
||||
expect(() => new Personne(baseProps({ capaciteAnnuelle: new Decimal(-1) }))).toThrow();
|
||||
});
|
||||
|
||||
it('throw si splitFormationPct hors [0,100]', () => {
|
||||
expect(
|
||||
() =>
|
||||
new Personne(
|
||||
baseProps({ splitFormationPct: new Decimal(-10), splitAgencePct: new Decimal(110) }),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('throw si splitAgencePct hors [0,100]', () => {
|
||||
expect(
|
||||
() =>
|
||||
new Personne(
|
||||
baseProps({ splitFormationPct: new Decimal(150), splitAgencePct: new Decimal(-50) }),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('admin pur capacite=0 splits=0/100 — accepte uniquement si somme=100', () => {
|
||||
const p = new Personne(
|
||||
baseProps({
|
||||
capaciteAnnuelle: new Decimal(0),
|
||||
splitFormationPct: new Decimal(0),
|
||||
splitAgencePct: new Decimal(100),
|
||||
roles: new Set<Role>(['admin']),
|
||||
}),
|
||||
);
|
||||
expect(p.capaciteAnnuelle.toNumber()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Personne — calculs heures restantes', () => {
|
||||
it('cas nominal split 60/40, capacite 1000', () => {
|
||||
const p = new Personne(baseProps());
|
||||
expect(p.capaciteFormation().toNumber()).toBe(600);
|
||||
expect(p.capaciteAgence().toNumber()).toBe(400);
|
||||
expect(p.heuresRestantesFormation().toNumber()).toBe(600);
|
||||
expect(p.heuresRestantesAgence().toNumber()).toBe(400);
|
||||
expect(p.heuresRestantesTotal().toNumber()).toBe(1000);
|
||||
});
|
||||
|
||||
it('avec heures attribuees', () => {
|
||||
const p = new Personne(
|
||||
baseProps({
|
||||
heuresAttribueesFormation: new Decimal(150),
|
||||
heuresAttribueesAgence: new Decimal(100),
|
||||
}),
|
||||
);
|
||||
expect(p.heuresRestantesFormation().toNumber()).toBe(450);
|
||||
expect(p.heuresRestantesAgence().toNumber()).toBe(300);
|
||||
expect(p.heuresRestantesTotal().toNumber()).toBe(750);
|
||||
});
|
||||
|
||||
it('cas zero capacite', () => {
|
||||
const p = new Personne(
|
||||
baseProps({
|
||||
capaciteAnnuelle: new Decimal(0),
|
||||
splitFormationPct: new Decimal(50),
|
||||
splitAgencePct: new Decimal(50),
|
||||
}),
|
||||
);
|
||||
expect(p.heuresRestantesTotal().toNumber()).toBe(0);
|
||||
expect(p.heuresRestantesFormation().toNumber()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Personne — gestion roles', () => {
|
||||
it('ajouterRole idempotent', () => {
|
||||
const p = new Personne(baseProps());
|
||||
p.ajouterRole('formateur');
|
||||
p.ajouterRole('formateur');
|
||||
expect(p.roles.size).toBe(1);
|
||||
});
|
||||
|
||||
it('ajouterRole nouveau', () => {
|
||||
const p = new Personne(baseProps());
|
||||
p.ajouterRole('developpeur');
|
||||
expect(p.hasRole('developpeur')).toBe(true);
|
||||
expect(p.roles.size).toBe(2);
|
||||
});
|
||||
|
||||
it('retirerRole sans attributions actives', () => {
|
||||
const p = new Personne(baseProps());
|
||||
p.retirerRole('formateur');
|
||||
expect(p.hasRole('formateur')).toBe(false);
|
||||
});
|
||||
|
||||
it('retirerRole formateur bloque si attributions actives', () => {
|
||||
const p = new Personne(baseProps());
|
||||
expect(() => p.retirerRole('formateur', { activeAttributions: 2 })).toThrow(/attributions/);
|
||||
});
|
||||
|
||||
it('retirerRole developpeur bloque si interventions actives', () => {
|
||||
const p = new Personne(baseProps({ roles: new Set<Role>(['formateur', 'developpeur']) }));
|
||||
expect(() => p.retirerRole('developpeur', { activeInterventions: 1 })).toThrow(/interventions/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Personne — transitions de statut', () => {
|
||||
it('activer / inactiver', () => {
|
||||
const p = new Personne(baseProps({ statut: 'inactif' }));
|
||||
p.activer();
|
||||
expect(p.statut).toBe('actif');
|
||||
p.inactiver();
|
||||
expect(p.statut).toBe('inactif');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Personne — mutations rollups', () => {
|
||||
it('appliquer heures formation accumule', () => {
|
||||
const p = new Personne(baseProps());
|
||||
p._appliquerHeuresFormation(new Decimal(100));
|
||||
p._appliquerHeuresFormation(new Decimal(50));
|
||||
expect(p.heuresAttribueesFormation.toNumber()).toBe(150);
|
||||
});
|
||||
|
||||
it('appliquer heures agence accumule', () => {
|
||||
const p = new Personne(baseProps());
|
||||
p._appliquerHeuresAgence(new Decimal(80));
|
||||
expect(p.heuresAttribueesAgence.toNumber()).toBe(80);
|
||||
});
|
||||
|
||||
it('throw si rollup formation devient negatif', () => {
|
||||
const p = new Personne(baseProps());
|
||||
expect(() => p._appliquerHeuresFormation(new Decimal(-10))).toThrow();
|
||||
});
|
||||
|
||||
it('throw si rollup agence devient negatif', () => {
|
||||
const p = new Personne(baseProps());
|
||||
expect(() => p._appliquerHeuresAgence(new Decimal(-5))).toThrow();
|
||||
});
|
||||
|
||||
it('warn quand surcharge formation (depasse capacite)', () => {
|
||||
const p = new Personne(baseProps({ capaciteAnnuelle: new Decimal(100) }));
|
||||
// capacite formation = 60. Apply 80 → warn mais accepte.
|
||||
p._appliquerHeuresFormation(new Decimal(80));
|
||||
expect(p.heuresAttribueesFormation.toNumber()).toBe(80);
|
||||
});
|
||||
});
|
||||
112
bridge/tests/domain/projet.test.ts
Normal file
112
bridge/tests/domain/projet.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Formation } from '../../src/domain/formation.js';
|
||||
import { Projet } from '../../src/domain/projet.js';
|
||||
|
||||
describe('Projet', () => {
|
||||
it('cree un projet valide', () => {
|
||||
const p = new Projet({ id: 1, clientId: 5, nom: 'Site corpo', chargeHeures: new Decimal(80) });
|
||||
expect(p.statut).toBe('devis');
|
||||
expect(p.heuresRestantes().toNumber()).toBe(80);
|
||||
});
|
||||
|
||||
it('throw si chargeHeures < 0', () => {
|
||||
expect(
|
||||
() => new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(-1) }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('ajouterTache cas nominal', () => {
|
||||
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
|
||||
const t = p.ajouterTache('Setup repo', new Decimal(2), 100);
|
||||
expect(t.titre).toBe('Setup repo');
|
||||
expect(p.taches.length).toBe(1);
|
||||
expect(p.heuresAttribuees().toNumber()).toBe(2);
|
||||
});
|
||||
|
||||
it('ajouterTache bloque si projet cloture', () => {
|
||||
const p = new Projet({
|
||||
id: 1,
|
||||
clientId: 5,
|
||||
nom: 'X',
|
||||
chargeHeures: new Decimal(80),
|
||||
statut: 'cloture',
|
||||
});
|
||||
expect(() => p.ajouterTache('T', new Decimal(2), 1)).toThrow();
|
||||
});
|
||||
|
||||
it('ajouterTache bloque si projet abandonne', () => {
|
||||
const p = new Projet({
|
||||
id: 1,
|
||||
clientId: 5,
|
||||
nom: 'X',
|
||||
chargeHeures: new Decimal(80),
|
||||
statut: 'abandonne',
|
||||
});
|
||||
expect(() => p.ajouterTache('T', new Decimal(2), 1)).toThrow();
|
||||
});
|
||||
|
||||
it('ajouterTache throw si charge < 0', () => {
|
||||
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
|
||||
expect(() => p.ajouterTache('T', new Decimal(-2), 1)).toThrow();
|
||||
});
|
||||
|
||||
it('lierFormationPedagogique', () => {
|
||||
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
|
||||
const f = new Formation({ id: 42, nom: 'Bootcamp', heuresTotales: new Decimal(700) });
|
||||
p.lierFormationPedagogique(f);
|
||||
expect(p.formationId).toBe(42);
|
||||
});
|
||||
|
||||
it('livrer transitions', () => {
|
||||
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
|
||||
p.livrer();
|
||||
expect(p.statut).toBe('livre');
|
||||
});
|
||||
|
||||
it('livrer depuis en_cours OK', () => {
|
||||
const p = new Projet({
|
||||
id: 1,
|
||||
clientId: 5,
|
||||
nom: 'X',
|
||||
chargeHeures: new Decimal(80),
|
||||
statut: 'en_cours',
|
||||
});
|
||||
p.livrer();
|
||||
expect(p.statut).toBe('livre');
|
||||
});
|
||||
|
||||
it('livrer bloque depuis cloture', () => {
|
||||
const p = new Projet({
|
||||
id: 1,
|
||||
clientId: 5,
|
||||
nom: 'X',
|
||||
chargeHeures: new Decimal(80),
|
||||
statut: 'cloture',
|
||||
});
|
||||
expect(() => p.livrer()).toThrow();
|
||||
});
|
||||
|
||||
it('cloturer transition', () => {
|
||||
const p = new Projet({
|
||||
id: 1,
|
||||
clientId: 5,
|
||||
nom: 'X',
|
||||
chargeHeures: new Decimal(80),
|
||||
statut: 'livre',
|
||||
});
|
||||
p.cloturer();
|
||||
expect(p.statut).toBe('cloture');
|
||||
});
|
||||
|
||||
it('cloturer bloque depuis abandonne', () => {
|
||||
const p = new Projet({
|
||||
id: 1,
|
||||
clientId: 5,
|
||||
nom: 'X',
|
||||
chargeHeures: new Decimal(80),
|
||||
statut: 'abandonne',
|
||||
});
|
||||
expect(() => p.cloturer()).toThrow();
|
||||
});
|
||||
});
|
||||
125
bridge/tests/domain/schemas.test.ts
Normal file
125
bridge/tests/domain/schemas.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AttributionSchema,
|
||||
BlocSchema,
|
||||
ClientSchema,
|
||||
FormationSchema,
|
||||
InterventionSchema,
|
||||
ModuleSchema,
|
||||
PersonneSchema,
|
||||
ProjetSchema,
|
||||
TacheSchema,
|
||||
} from '../../src/domain/schemas.js';
|
||||
|
||||
describe('schemas zod', () => {
|
||||
it('PersonneSchema valide', () => {
|
||||
const r = PersonneSchema.parse({
|
||||
id: 1,
|
||||
nom: 'Doe',
|
||||
prenom: 'John',
|
||||
email: 'john@a.fr',
|
||||
capaciteAnnuelle: '1000',
|
||||
splitFormationPct: 50,
|
||||
splitAgencePct: 50,
|
||||
roles: ['formateur'],
|
||||
});
|
||||
expect(r.statut).toBe('actif');
|
||||
expect(r.capaciteAnnuelle).toBe(1000);
|
||||
});
|
||||
|
||||
it('PersonneSchema rejette splits qui ne somment pas a 100', () => {
|
||||
expect(() =>
|
||||
PersonneSchema.parse({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
prenom: 'Y',
|
||||
email: 'x@y.fr',
|
||||
capaciteAnnuelle: 1000,
|
||||
splitFormationPct: 50,
|
||||
splitAgencePct: 40,
|
||||
roles: ['formateur'],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('PersonneSchema rejette email invalide', () => {
|
||||
expect(() =>
|
||||
PersonneSchema.parse({
|
||||
id: 1,
|
||||
nom: 'X',
|
||||
prenom: 'Y',
|
||||
email: 'not-an-email',
|
||||
capaciteAnnuelle: 1000,
|
||||
splitFormationPct: 50,
|
||||
splitAgencePct: 50,
|
||||
roles: ['formateur'],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('FormationSchema valide avec defaults', () => {
|
||||
const r = FormationSchema.parse({ id: 1, nom: 'BTS', heuresTotales: 500 });
|
||||
expect(r.statut).toBe('draft');
|
||||
});
|
||||
|
||||
it('BlocSchema rejette heuresPrevues negative', () => {
|
||||
expect(() =>
|
||||
BlocSchema.parse({ id: 1, formationId: 1, nom: 'B', heuresPrevues: -5 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('ModuleSchema valide', () => {
|
||||
const r = ModuleSchema.parse({ id: 1, blocId: 1, nom: 'M', heuresPrevues: 20 });
|
||||
expect(r.statut).toBe('a_attribuer');
|
||||
});
|
||||
|
||||
it('AttributionSchema rejette heuresAttribuees = 0', () => {
|
||||
expect(() =>
|
||||
AttributionSchema.parse({ id: 1, moduleId: 1, personneId: 1, heuresAttribuees: 0 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('ClientSchema valide minimal', () => {
|
||||
const r = ClientSchema.parse({ id: 1, nom: 'Acme' });
|
||||
expect(r.statut).toBe('prospect');
|
||||
});
|
||||
|
||||
it('ProjetSchema valide avec formationId', () => {
|
||||
const r = ProjetSchema.parse({
|
||||
id: 1,
|
||||
clientId: 1,
|
||||
nom: 'P',
|
||||
chargeHeures: 100,
|
||||
formationId: 5,
|
||||
});
|
||||
expect(r.formationId).toBe(5);
|
||||
});
|
||||
|
||||
it('TacheSchema valide minimal', () => {
|
||||
const r = TacheSchema.parse({ id: 1, projetId: 1, titre: 'T', chargeHeures: 4 });
|
||||
expect(r.statut).toBe('todo');
|
||||
});
|
||||
|
||||
it('InterventionSchema parse date string', () => {
|
||||
const r = InterventionSchema.parse({
|
||||
id: 1,
|
||||
tacheId: 1,
|
||||
personneId: 1,
|
||||
heures: 3,
|
||||
date: '2026-05-01',
|
||||
});
|
||||
expect(r.date instanceof Date).toBe(true);
|
||||
});
|
||||
|
||||
it('InterventionSchema rejette heures = 0', () => {
|
||||
expect(() =>
|
||||
InterventionSchema.parse({
|
||||
id: 1,
|
||||
tacheId: 1,
|
||||
personneId: 1,
|
||||
heures: 0,
|
||||
date: '2026-05-01',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
121
bridge/tests/domain/tache.test.ts
Normal file
121
bridge/tests/domain/tache.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Personne } from '../../src/domain/personne.js';
|
||||
import { Tache } from '../../src/domain/tache.js';
|
||||
import type { Role } from '../../src/domain/types.js';
|
||||
|
||||
const developpeur = (overrides: Partial<ConstructorParameters<typeof Personne>[0]> = {}) =>
|
||||
new Personne({
|
||||
id: 1,
|
||||
nom: 'Dev',
|
||||
prenom: 'Jane',
|
||||
email: 'jane@acadenice.fr',
|
||||
capaciteAnnuelle: new Decimal(1000),
|
||||
splitFormationPct: new Decimal(20),
|
||||
splitAgencePct: new Decimal(80),
|
||||
roles: new Set<Role>(['developpeur']),
|
||||
statut: 'actif',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Tache — constructeur', () => {
|
||||
it('cree une tache valide', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
expect(t.statut).toBe('todo');
|
||||
});
|
||||
|
||||
it('throw si charge < 0', () => {
|
||||
expect(
|
||||
() => new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(-1) }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tache — creerIntervention', () => {
|
||||
it('happy path', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
const p = developpeur();
|
||||
const i = t.creerIntervention(p, new Decimal(3), new Date('2026-05-01'), 100);
|
||||
expect(i.heures.toNumber()).toBe(3);
|
||||
expect(t.heuresRealisees().toNumber()).toBe(3);
|
||||
expect(p.heuresAttribueesAgence.toNumber()).toBe(3);
|
||||
});
|
||||
|
||||
it('throw si role != developpeur', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
const p = developpeur({ roles: new Set<Role>(['formateur']) });
|
||||
expect(() => t.creerIntervention(p, new Decimal(3), new Date(), 1)).toThrow(/developpeur/);
|
||||
});
|
||||
|
||||
it('throw si heures <= 0', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
const p = developpeur();
|
||||
expect(() => t.creerIntervention(p, new Decimal(0), new Date(), 1)).toThrow();
|
||||
expect(() => t.creerIntervention(p, new Decimal(-1), new Date(), 1)).toThrow();
|
||||
});
|
||||
|
||||
it('throw si personne inactive', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
const p = developpeur({ statut: 'inactif' });
|
||||
expect(() => t.creerIntervention(p, new Decimal(2), new Date(), 1)).toThrow(/inactiv/);
|
||||
});
|
||||
|
||||
it('annulation exclue du rollup heuresRealisees', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
const p = developpeur();
|
||||
const i1 = t.creerIntervention(p, new Decimal(3), new Date(), 1);
|
||||
t.creerIntervention(p, new Decimal(2), new Date(), 2);
|
||||
i1.annuler('test');
|
||||
expect(t.heuresRealisees().toNumber()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tache — transitions de statut', () => {
|
||||
it('marquerInProgress', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
t.marquerInProgress();
|
||||
expect(t.statut).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('marquerReview depuis in_progress', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
t.marquerInProgress();
|
||||
t.marquerReview();
|
||||
expect(t.statut).toBe('review');
|
||||
});
|
||||
|
||||
it('marquerReview invalide depuis todo', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
expect(() => t.marquerReview()).toThrow();
|
||||
});
|
||||
|
||||
it('marquerDone depuis review', () => {
|
||||
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
|
||||
t.marquerInProgress();
|
||||
t.marquerReview();
|
||||
t.marquerDone();
|
||||
expect(t.statut).toBe('done');
|
||||
});
|
||||
|
||||
it('marquerDone bloque depuis done', () => {
|
||||
const t = new Tache({
|
||||
id: 1,
|
||||
projetId: 5,
|
||||
titre: 'T1',
|
||||
chargeHeures: new Decimal(8),
|
||||
statut: 'done',
|
||||
});
|
||||
expect(() => t.marquerDone()).toThrow();
|
||||
});
|
||||
|
||||
it('marquerInProgress bloque depuis abandoned', () => {
|
||||
const t = new Tache({
|
||||
id: 1,
|
||||
projetId: 5,
|
||||
titre: 'T1',
|
||||
chargeHeures: new Decimal(8),
|
||||
statut: 'abandoned',
|
||||
});
|
||||
expect(() => t.marquerInProgress()).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,10 +11,13 @@ export default defineConfig({
|
|||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
lines: 0,
|
||||
functions: 0,
|
||||
branches: 0,
|
||||
statements: 0,
|
||||
// Threshold strict applique uniquement sur src/domain. Adapters et lib seront couverts au bloc 3+.
|
||||
'src/domain/**': {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
passWithNoTests: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue