mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.2): bibliothèque de documents (FileStorage réel) + analytics
- FileStorage : putObject/getObjectStream/removeObject, bucket créé au démarrage — MinIO confiné à son implémentation (règle ESLint intacte) - documents : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis, permission d'édition sur la CIBLE), liste filtrable, téléchargement STREAMÉ par l'API (MinIO jamais exposé), suppression ; e2e : octets téléchargés identiques aux octets envoyés - analytics : GET /analytics/summary dérivé du réel — coûts/mois (mouvements + main-d'œuvre figés), pannes par organe (bilans codés), taux de préventif, durée moyenne de résolution, top équipements - générateur OpenAPI : query params, multipart, réponse binaire (71 ops) - CI : service MinIO (bitnami) sur les jobs api et e2e - test de régression du tri « Interventions récentes » rendu déterministe (positions absolues instables sous 12 suites parallèles) ; 58 tests, 6 runs complets consécutifs verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -77,10 +77,19 @@ jobs:
|
|||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||||
|
minio:
|
||||||
|
image: bitnami/minio:2025
|
||||||
|
env:
|
||||||
|
MINIO_ROOT_USER: siop
|
||||||
|
MINIO_ROOT_PASSWORD: siop-minio
|
||||||
|
ports: ['9000:9000']
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||||
REDIS_URL: redis://localhost:6379
|
REDIS_URL: redis://localhost:6379
|
||||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||||
|
MINIO_ENDPOINT: localhost
|
||||||
|
MINIO_ACCESS_KEY: siop
|
||||||
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
@@ -130,10 +139,19 @@ jobs:
|
|||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||||
|
minio:
|
||||||
|
image: bitnami/minio:2025
|
||||||
|
env:
|
||||||
|
MINIO_ROOT_USER: siop
|
||||||
|
MINIO_ROOT_PASSWORD: siop-minio
|
||||||
|
ports: ['9000:9000']
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||||
REDIS_URL: redis://localhost:6379
|
REDIS_URL: redis://localhost:6379
|
||||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||||
|
MINIO_ENDPOINT: localhost
|
||||||
|
MINIO_ACCESS_KEY: siop
|
||||||
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|||||||
@@ -50,5 +50,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
|
- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
|
||||||
- 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
|
- 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
|
||||||
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
||||||
- 🔄 **R3.2 — reprise ici** : bibliothèque de documents (upload multipart 20 Mo → `FileStorage.putObject`, download streamé par l'API — MinIO jamais exposé, types fermés, rattachement appareil/OT requis) + analytics `GET /analytics/summary` (coûts/mois, pannes par organe via bilans, taux préventif, top équipements) → R3.3 écrans web (stock, fiche pièce, BC, coûts sur fiche OT, statistiques, tiers, bibliothèque + taux dans Personnes) → recette + déploiement + tag.
|
- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
|
||||||
|
- 🔄 **R3.3 — reprise ici** : écrans web fidèles à maquette-r3 (stock + alertes seuil, fiche pièce/mouvements, BC + panneau réception, coûts réels sur fiche OT [consommer/saisir temps], statistiques, tiers, bibliothèque + carte Documents des fiches, taux horaire dans Personnes) → e2e Playwright recette R3 → recette référent + déploiement + tag `release/r3`.
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@nestjs/testing": "^11.1.0",
|
"@nestjs/testing": "^11.1.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
14
apps/api/src/analytics/analytics.controller.ts
Normal file
14
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
|
@Controller('analytics')
|
||||||
|
export class AnalyticsController {
|
||||||
|
constructor(private readonly analytics: AnalyticsService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
@RequirePermission('ANALYTICS', 'view')
|
||||||
|
summary() {
|
||||||
|
return this.analytics.summary();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/analytics/analytics.module.ts
Normal file
9
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AnalyticsController } from './analytics.controller';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AnalyticsController],
|
||||||
|
providers: [AnalyticsService],
|
||||||
|
})
|
||||||
|
export class AnalyticsModule {}
|
||||||
197
apps/api/src/analytics/analytics.service.ts
Normal file
197
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { AnalyticsSummary } from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const debutMois = (decalage: number): Date => {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
|
||||||
|
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
|
||||||
|
@Injectable()
|
||||||
|
export class AnalyticsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async summary(): Promise<AnalyticsSummary> {
|
||||||
|
const depuis12m = debutMois(11);
|
||||||
|
const depuis6m = debutMois(5);
|
||||||
|
|
||||||
|
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { kind: 'CONSUMPTION', createdAt: { gte: depuis6m } },
|
||||||
|
select: { quantity: true, unitPrice: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.laborTime.findMany({
|
||||||
|
where: { createdAt: { gte: depuis6m } },
|
||||||
|
select: { minutes: true, hourlyRate: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: { status: 'DONE', completedAt: { gte: depuis12m } },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { periodKey: { not: null }, createdAt: { gte: depuis12m } },
|
||||||
|
select: { status: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuis12m } },
|
||||||
|
select: { createdAt: true, completedAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.interventionReport.groupBy({
|
||||||
|
by: ['componentConcernedId'],
|
||||||
|
where: {
|
||||||
|
componentConcernedId: { not: null },
|
||||||
|
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuis12m } },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuis12m } },
|
||||||
|
select: {
|
||||||
|
quantity: true,
|
||||||
|
unitPrice: true,
|
||||||
|
workOrder: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
asset: {
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
|
||||||
|
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
|
||||||
|
const parMois = new Map<string, number>();
|
||||||
|
for (let i = 5; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
|
||||||
|
for (const c of consommations) {
|
||||||
|
const cle = cleMois(c.createdAt);
|
||||||
|
if (parMois.has(cle)) {
|
||||||
|
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const l of mainOeuvre) {
|
||||||
|
const cle = cleMois(l.createdAt);
|
||||||
|
if (parMois.has(cle)) {
|
||||||
|
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
|
||||||
|
month,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
}));
|
||||||
|
const moisCourant = cleMois(new Date());
|
||||||
|
const moisPrecedent = cleMois(debutMois(1));
|
||||||
|
|
||||||
|
// Pannes par organe : depuis les bilans codés
|
||||||
|
const labels = await this.prisma.referenceValue.findMany({
|
||||||
|
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
|
||||||
|
});
|
||||||
|
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
|
||||||
|
const failuresByComponent = pannes
|
||||||
|
.map((p) => ({
|
||||||
|
label: labelDe.get(p.componentConcernedId!) ?? '—',
|
||||||
|
count: p._count._all,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
// Top équipements en coût (pièces via OT + main-d'œuvre)
|
||||||
|
const laborParOT = await this.prisma.laborTime.findMany({
|
||||||
|
where: { createdAt: { gte: depuis12m } },
|
||||||
|
select: {
|
||||||
|
minutes: true,
|
||||||
|
hourlyRate: true,
|
||||||
|
workOrder: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
asset: {
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
|
||||||
|
const parAppareil = new Map<string, Cumul>();
|
||||||
|
const cumul = (ref: string, siteName: string): Cumul => {
|
||||||
|
if (!parAppareil.has(ref)) {
|
||||||
|
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
|
||||||
|
}
|
||||||
|
return parAppareil.get(ref)!;
|
||||||
|
};
|
||||||
|
for (const m of topDonnees) {
|
||||||
|
const asset = m.workOrder!.asset;
|
||||||
|
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||||
|
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
|
||||||
|
}
|
||||||
|
for (const l of laborParOT) {
|
||||||
|
const asset = l.workOrder.asset;
|
||||||
|
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||||
|
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
|
||||||
|
}
|
||||||
|
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
|
||||||
|
by: ['assetId'],
|
||||||
|
where: { type: 'CORRECTIVE', createdAt: { gte: depuis12m } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const assetsRefs = await this.prisma.asset.findMany({
|
||||||
|
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
|
||||||
|
select: { id: true, reference: true },
|
||||||
|
});
|
||||||
|
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
|
||||||
|
for (const c of correctivesParAppareil) {
|
||||||
|
const ref = refDe.get(c.assetId);
|
||||||
|
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
|
||||||
|
}
|
||||||
|
const topAssets = [...parAppareil.entries()]
|
||||||
|
.map(([reference, c]) => ({
|
||||||
|
reference,
|
||||||
|
siteName: c.siteName,
|
||||||
|
correctives: c.correctives,
|
||||||
|
partsCost: Math.round(c.partsCost * 100) / 100,
|
||||||
|
laborCost: Math.round(c.laborCost * 100) / 100,
|
||||||
|
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
|
||||||
|
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
|
||||||
|
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
|
||||||
|
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
|
||||||
|
closed12m: { total: totalClos, preventive: preventifsClos },
|
||||||
|
preventiveRate: grilles12m.length
|
||||||
|
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
|
||||||
|
: null,
|
||||||
|
avgResolutionDays: correctifsClos.length
|
||||||
|
? Math.round(
|
||||||
|
(correctifsClos.reduce(
|
||||||
|
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
|
||||||
|
0,
|
||||||
|
) /
|
||||||
|
correctifsClos.length /
|
||||||
|
86400e3) *
|
||||||
|
10,
|
||||||
|
) / 10
|
||||||
|
: null,
|
||||||
|
failuresByComponent,
|
||||||
|
costsByMonth,
|
||||||
|
topAssets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
import { AssetsModule } from './assets/assets.module';
|
import { AssetsModule } from './assets/assets.module';
|
||||||
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
@@ -57,6 +59,8 @@ export class AppModule {
|
|||||||
PartnersModule,
|
PartnersModule,
|
||||||
PartsModule,
|
PartsModule,
|
||||||
PurchaseOrdersModule,
|
PurchaseOrdersModule,
|
||||||
|
DocumentsModule,
|
||||||
|
AnalyticsModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const EnvSchema = z.object({
|
|||||||
MINIO_USE_SSL: z.string().optional(),
|
MINIO_USE_SSL: z.string().optional(),
|
||||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||||
|
MINIO_BUCKET: z.string().default('siop2'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
77
apps/api/src/documents/documents.controller.ts
Normal file
77
apps/api/src/documents/documents.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
|
@Controller('documents')
|
||||||
|
export class DocumentsController {
|
||||||
|
constructor(private readonly documents: DocumentsService) {}
|
||||||
|
|
||||||
|
/** Bibliothèque interne — authentification seule (les cartes « Documents »
|
||||||
|
* des fiches la consomment). */
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('assetId') assetId?: string,
|
||||||
|
@Query('workOrderId') workOrderId?: string,
|
||||||
|
@Query('kind') kind?: string,
|
||||||
|
) {
|
||||||
|
return this.documents.list({ assetId, workOrderId, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
|
||||||
|
)
|
||||||
|
upload(
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
|
||||||
|
return this.documents.upload(
|
||||||
|
{
|
||||||
|
buffer: file.buffer,
|
||||||
|
originalName: file.originalname,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
kind: body.kind ?? 'OTHER',
|
||||||
|
assetId: body.assetId || undefined,
|
||||||
|
workOrderId: body.workOrderId || undefined,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/download')
|
||||||
|
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
|
||||||
|
const { stream, fileName, contentType } = await this.documents.download(id);
|
||||||
|
return new StreamableFile(stream as never, {
|
||||||
|
type: contentType,
|
||||||
|
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.documents.remove(id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/documents/documents.module.ts
Normal file
9
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DocumentsController } from './documents.controller';
|
||||||
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DocumentsController],
|
||||||
|
providers: [DocumentsService],
|
||||||
|
})
|
||||||
|
export class DocumentsModule {}
|
||||||
160
apps/api/src/documents/documents.service.ts
Normal file
160
apps/api/src/documents/documents.service.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
DOCUMENT_CONTENT_TYPES,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
type DocumentDto,
|
||||||
|
type DocumentKind,
|
||||||
|
type DocumentsResponse,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const documentInclude = {
|
||||||
|
asset: { select: { reference: true } },
|
||||||
|
workOrder: { select: { reference: true } },
|
||||||
|
uploadedBy: { select: { displayName: true } },
|
||||||
|
} satisfies Prisma.DocumentInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
|
||||||
|
|
||||||
|
export interface UploadInput {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalName: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
kind: string;
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(filtres: {
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
kind?: string;
|
||||||
|
}): Promise<DocumentsResponse> {
|
||||||
|
const rows = await this.prisma.document.findMany({
|
||||||
|
where: {
|
||||||
|
assetId: filtres.assetId || undefined,
|
||||||
|
workOrderId: filtres.workOrderId || undefined,
|
||||||
|
kind: (filtres.kind as DocumentKind) || undefined,
|
||||||
|
},
|
||||||
|
include: documentInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return { documents: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
|
||||||
|
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
|
||||||
|
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
|
||||||
|
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
|
||||||
|
throw new BadRequestException('Type de document inconnu');
|
||||||
|
}
|
||||||
|
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
|
||||||
|
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
|
||||||
|
}
|
||||||
|
if (input.size > DOCUMENT_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
|
||||||
|
}
|
||||||
|
if (!input.assetId && !input.workOrderId) {
|
||||||
|
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
|
||||||
|
}
|
||||||
|
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
|
||||||
|
|
||||||
|
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
|
||||||
|
await this.storage.putObject(storageKey, input.buffer, input.contentType);
|
||||||
|
const created = await this.prisma.document.create({
|
||||||
|
data: {
|
||||||
|
kind: input.kind as DocumentKind,
|
||||||
|
fileName: input.originalName,
|
||||||
|
storageKey,
|
||||||
|
size: input.size,
|
||||||
|
contentType: input.contentType,
|
||||||
|
assetId: input.assetId,
|
||||||
|
workOrderId: input.workOrderId,
|
||||||
|
uploadedById: user.userId,
|
||||||
|
},
|
||||||
|
include: documentInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(id: string): Promise<{
|
||||||
|
stream: NodeJS.ReadableStream;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
}> {
|
||||||
|
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Document inconnu');
|
||||||
|
return {
|
||||||
|
stream: await this.storage.getObjectStream(doc.storageKey),
|
||||||
|
fileName: doc.fileName,
|
||||||
|
contentType: doc.contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, user: AuthenticatedUser): Promise<void> {
|
||||||
|
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Document inconnu');
|
||||||
|
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
|
||||||
|
await this.prisma.document.delete({ where: { id } });
|
||||||
|
await this.storage.removeObject(doc.storageKey).catch(() => {
|
||||||
|
// le stockage peut être momentanément injoignable — la base fait foi
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCible(
|
||||||
|
assetId: string | undefined,
|
||||||
|
workOrderId: string | undefined,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
right: 'edit',
|
||||||
|
): Promise<void> {
|
||||||
|
if (assetId) {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Appareil inconnu');
|
||||||
|
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
|
||||||
|
throw new ForbiddenException('Droit manquant : ASSETS.edit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (workOrderId) {
|
||||||
|
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
|
||||||
|
if (!ot) throw new BadRequestException('OT inconnu');
|
||||||
|
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
|
||||||
|
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): DocumentDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
fileName: row.fileName,
|
||||||
|
size: row.size,
|
||||||
|
contentType: row.contentType,
|
||||||
|
assetReference: row.asset?.reference ?? null,
|
||||||
|
workOrderReference: row.workOrder?.reference ?? null,
|
||||||
|
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
|
||||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
|
||||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
|
||||||
*/
|
*/
|
||||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||||
|
|
||||||
export interface FileStorage {
|
export interface FileStorage {
|
||||||
/** Lève une exception si le stockage est injoignable. */
|
/** Lève une exception si le stockage est injoignable. */
|
||||||
healthCheck(): Promise<void>;
|
healthCheck(): Promise<void>;
|
||||||
|
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
|
||||||
|
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
|
||||||
|
removeObject(key: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import * as Minio from 'minio';
|
import * as Minio from 'minio';
|
||||||
import { loadEnv } from '../config/env';
|
import { loadEnv } from '../config/env';
|
||||||
import type { FileStorage } from './file-storage';
|
import type { FileStorage } from './file-storage';
|
||||||
|
|
||||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
/** Seul fichier du dépôt autorisé à importer le SDK MinIO (règle ESLint). */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MinioStorageService implements FileStorage {
|
export class MinioStorageService implements FileStorage, OnModuleInit {
|
||||||
|
private readonly logger = new Logger(MinioStorageService.name);
|
||||||
private readonly client: Minio.Client;
|
private readonly client: Minio.Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
|
this.bucket = env.MINIO_BUCKET;
|
||||||
this.client = new Minio.Client({
|
this.client = new Minio.Client({
|
||||||
endPoint: env.MINIO_ENDPOINT,
|
endPoint: env.MINIO_ENDPOINT,
|
||||||
port: env.MINIO_PORT,
|
port: env.MINIO_PORT,
|
||||||
@@ -19,7 +22,34 @@ export class MinioStorageService implements FileStorage {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Le bucket est créé au démarrage — aucune étape manuelle au déploiement. */
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!(await this.client.bucketExists(this.bucket))) {
|
||||||
|
await this.client.makeBucket(this.bucket);
|
||||||
|
this.logger.log(`Bucket « ${this.bucket} » créé.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Le stockage peut être en retard au boot : /health le signalera.
|
||||||
|
this.logger.warn(`Stockage indisponible au démarrage : ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<void> {
|
async healthCheck(): Promise<void> {
|
||||||
await this.client.listBuckets();
|
await this.client.listBuckets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
|
||||||
|
await this.client.putObject(this.bucket, key, body, body.length, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
|
||||||
|
return this.client.getObject(this.bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeObject(key: string): Promise<void> {
|
||||||
|
await this.client.removeObject(this.bucket, key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
|
||||||
|
* refus typés, permission sur la cible) + analytics (dérivé du réel).
|
||||||
|
* Nécessite MinIO (infra locale / service CI).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
const PNG_1PX = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Bibliothèque & analytics (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let nadia: string;
|
||||||
|
let karim: string; // Demandeur : aucune permission d'édition
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `doc-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
nadia = await login('Gestionnaire');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upload → liste filtrée → download identique → suppression', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|
||||||
|
const envoye = await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'CERTIFICATE')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(201);
|
||||||
|
expect(envoye.body.assetReference).toBe('A1');
|
||||||
|
expect(envoye.body.size).toBe(PNG_1PX.length);
|
||||||
|
|
||||||
|
const liste = await http()
|
||||||
|
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const telecharge = await http()
|
||||||
|
.get(`/documents/${envoye.body.id}/download`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.buffer(true)
|
||||||
|
.parse((res, cb) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
|
||||||
|
|
||||||
|
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||||
|
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'OTHER')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'PHOTO')
|
||||||
|
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(400); // aucun rattachement
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'PHOTO')
|
||||||
|
.field('assetId', '00000000-0000-4000-8000-000000000000')
|
||||||
|
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(400);
|
||||||
|
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
|
||||||
|
await http().get('/documents').set(auth(karim)).expect(200);
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(karim))
|
||||||
|
.field('kind', 'PHOTO')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
|
||||||
|
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
|
||||||
|
const s = res.body;
|
||||||
|
expect(s.costsByMonth).toHaveLength(6);
|
||||||
|
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
|
||||||
|
expect(s.monthCost).toBeGreaterThanOrEqual(505);
|
||||||
|
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
|
||||||
|
expect(
|
||||||
|
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
|
||||||
|
).toBe(true);
|
||||||
|
// Top équipements : A1 porte les coûts du seed
|
||||||
|
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
|
||||||
|
expect(s.closed12m.total).toBeGreaterThanOrEqual(1);
|
||||||
|
// Karim (Demandeur, sans ANALYTICS) → 403
|
||||||
|
await http().get('/analytics/summary').set(auth(karim)).expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,19 +140,20 @@ describe('Exploitation (e2e)', () => {
|
|||||||
.set(auth(salma))
|
.set(auth(salma))
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
||||||
const urgencesActives = listeSalma.workOrders.filter(
|
|
||||||
(w: { priority: string; status: string }) =>
|
|
||||||
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
|
|
||||||
).length;
|
|
||||||
expect(position).toBeGreaterThanOrEqual(0);
|
expect(position).toBeGreaterThanOrEqual(0);
|
||||||
// Tolérance : les autres specs Jest créent des OT en parallèle (updatedAt
|
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
|
||||||
// plus récent). L'invariant déterministe : notre clôture précède TOUJOURS
|
// les positions absolues sont donc instables) : notre OT fraîchement
|
||||||
// l'ancien Terminé du seed — plus jamais reléguée en queue de liste.
|
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
|
||||||
expect(position).toBeLessThanOrEqual(urgencesActives + 4);
|
// « par statut », tout En cours passait devant tous les Terminés.
|
||||||
const positionAncienDone = listeSalma.workOrders.findIndex(
|
const annee = new Date().getFullYear();
|
||||||
(w: { reference: string }) => w.reference === `OT-${new Date().getFullYear()}-0332`,
|
const posGrilleSeed = listeSalma.workOrders.findIndex(
|
||||||
|
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
|
||||||
);
|
);
|
||||||
expect(position).toBeLessThan(positionAncienDone);
|
const posAncienDone = listeSalma.workOrders.findIndex(
|
||||||
|
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
|
||||||
|
);
|
||||||
|
expect(position).toBeLessThan(posGrilleSeed);
|
||||||
|
expect(position).toBeLessThan(posAncienDone);
|
||||||
|
|
||||||
// 7. Terminal : plus aucune transition
|
// 7. Terminal : plus aucune transition
|
||||||
await http()
|
await http()
|
||||||
|
|||||||
260
apps/web/src/api/schema.d.ts
vendored
260
apps/web/src/api/schema.d.ts
vendored
@@ -372,6 +372,75 @@ export interface paths {
|
|||||||
patch: operations["updateUser"];
|
patch: operations["updateUser"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/documents": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Bibliothèque (filtrable par appareil ou OT) */
|
||||||
|
get: operations["listDocuments"];
|
||||||
|
put?: never;
|
||||||
|
/** Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis) */
|
||||||
|
post: operations["uploadDocument"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/documents/{id}/download": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Télécharger — streamé par l’API (MinIO jamais exposé) */
|
||||||
|
get: operations["downloadDocument"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/documents/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Supprimer (permission d’édition sur la cible) */
|
||||||
|
delete: operations["deleteDocument"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/analytics/summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements */
|
||||||
|
get: operations["getAnalyticsSummary"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/partners": {
|
"/partners": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1297,6 +1366,62 @@ export interface components {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
hourlyRate?: number | null;
|
hourlyRate?: number | null;
|
||||||
};
|
};
|
||||||
|
DocumentsResponse: {
|
||||||
|
documents: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
kind: "NOTICE" | "CERTIFICATE" | "PHOTO" | "OTHER";
|
||||||
|
fileName: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
assetReference: string | null;
|
||||||
|
workOrderReference: string | null;
|
||||||
|
uploadedByName: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
Document: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
kind: "NOTICE" | "CERTIFICATE" | "PHOTO" | "OTHER";
|
||||||
|
fileName: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
assetReference: string | null;
|
||||||
|
workOrderReference: string | null;
|
||||||
|
uploadedByName: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
AnalyticsSummary: {
|
||||||
|
monthCost: number;
|
||||||
|
previousMonthCost: number;
|
||||||
|
closed12m: {
|
||||||
|
total: number;
|
||||||
|
preventive: number;
|
||||||
|
};
|
||||||
|
preventiveRate: number | null;
|
||||||
|
avgResolutionDays: number | null;
|
||||||
|
failuresByComponent: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
costsByMonth: {
|
||||||
|
month: string;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
topAssets: {
|
||||||
|
reference: string;
|
||||||
|
siteName: string;
|
||||||
|
correctives: number;
|
||||||
|
partsCost: number;
|
||||||
|
laborCost: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
PartnersResponse: {
|
PartnersResponse: {
|
||||||
partners: {
|
partners: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -2647,6 +2772,141 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listDocuments: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
kind?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Liste */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocumentsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
uploadDocument: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": {
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
kind?: string;
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document rangé */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Type/taille refusé ou rattachement manquant */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
downloadDocument: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Fichier */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteDocument: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Supprimé */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getAnalyticsSummary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Synthèse */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnalyticsSummary"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listPartners: {
|
listPartners: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.2 : bibliothèque de documents + analytics
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **`FileStorage` gagne ses vraies opérations** (put/stream/remove ; bucket créé au démarrage — aucune étape manuelle au déploiement) ; MinIO reste confiné à son implémentation (règle ESLint).
|
||||||
|
- **Bibliothèque** : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT **requis**, permission d'édition sur la cible), liste filtrable, **téléchargement streamé par l'API** (MinIO jamais exposé — topologie du runbook respectée), suppression. Test e2e : les octets téléchargés sont IDENTIQUES aux octets envoyés.
|
||||||
|
- **Analytics** (`GET /analytics/summary`, permission ANALYTICS) : tout est dérivé du réel — coûts par mois depuis les mouvements/main-d'œuvre figés, **pannes par organe depuis les bilans codés**, taux de préventif (grilles terminées/générées), durée moyenne de résolution, top équipements en coût.
|
||||||
|
- Générateur OpenAPI étendu (query params, multipart, réponse binaire) — 71 opérations. **CI : service MinIO ajouté** (jobs api + e2e, image bitnami).
|
||||||
|
- **58 tests verts** (92 % / 73,9 %) ; test de régression du tri des « Interventions récentes » rendu **déterministe** (les positions absolues étaient instables sous 12 suites parallèles) — 6 runs complets consécutifs verts.
|
||||||
|
|
||||||
|
**Prochaine étape** : R3.3 — écrans web (stock, fiche pièce, BC, coûts sur fiche OT, statistiques, tiers, bibliothèque, taux dans Personnes), puis recette R3 + déploiement + tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.1 : socle backend de la gestion
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.1 : socle backend de la gestion
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -956,6 +956,205 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/documents": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listDocuments",
|
||||||
|
"summary": "Bibliothèque (filtrable par appareil ou OT)",
|
||||||
|
"tags": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "assetId",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workOrderId",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Liste",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DocumentsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "uploadDocument",
|
||||||
|
"summary": "Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis)",
|
||||||
|
"tags": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"assetId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workOrderId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Document rangé",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Document"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Type/taille refusé ou rattachement manquant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/documents/{id}/download": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "downloadDocument",
|
||||||
|
"summary": "Télécharger — streamé par l’API (MinIO jamais exposé)",
|
||||||
|
"tags": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Fichier"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Inconnu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/documents/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteDocument",
|
||||||
|
"summary": "Supprimer (permission d’édition sur la cible)",
|
||||||
|
"tags": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Supprimé"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Inconnu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/analytics/summary": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAnalyticsSummary",
|
||||||
|
"summary": "Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements",
|
||||||
|
"tags": [
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Synthèse",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AnalyticsSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/partners": {
|
"/partners": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listPartners",
|
"operationId": "listPartners",
|
||||||
@@ -4344,6 +4543,314 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"DocumentsResponse": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"documents": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"NOTICE",
|
||||||
|
"CERTIFICATE",
|
||||||
|
"PHOTO",
|
||||||
|
"OTHER"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"contentType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"assetReference": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"workOrderReference": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uploadedByName": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"kind",
|
||||||
|
"fileName",
|
||||||
|
"size",
|
||||||
|
"contentType",
|
||||||
|
"assetReference",
|
||||||
|
"workOrderReference",
|
||||||
|
"uploadedByName",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"Document": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"NOTICE",
|
||||||
|
"CERTIFICATE",
|
||||||
|
"PHOTO",
|
||||||
|
"OTHER"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"contentType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"assetReference": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"workOrderReference": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uploadedByName": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"kind",
|
||||||
|
"fileName",
|
||||||
|
"size",
|
||||||
|
"contentType",
|
||||||
|
"assetReference",
|
||||||
|
"workOrderReference",
|
||||||
|
"uploadedByName",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"AnalyticsSummary": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"monthCost": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"previousMonthCost": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"closed12m": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"preventive": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"total",
|
||||||
|
"preventive"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"preventiveRate": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"avgResolutionDays": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failuresByComponent": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"label",
|
||||||
|
"count"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"costsByMonth": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"month": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"month",
|
||||||
|
"total"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"topAssets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reference": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"siteName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"correctives": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"partsCost": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"laborCost": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"reference",
|
||||||
|
"siteName",
|
||||||
|
"correctives",
|
||||||
|
"partsCost",
|
||||||
|
"laborCost",
|
||||||
|
"total"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"monthCost",
|
||||||
|
"previousMonthCost",
|
||||||
|
"closed12m",
|
||||||
|
"preventiveRate",
|
||||||
|
"avgResolutionDays",
|
||||||
|
"failuresByComponent",
|
||||||
|
"costsByMonth",
|
||||||
|
"topAssets"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"PartnersResponse": {
|
"PartnersResponse": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ for (const op of API_CONTRACT) {
|
|||||||
operationId: op.operationId,
|
operationId: op.operationId,
|
||||||
summary: op.summary,
|
summary: op.summary,
|
||||||
tags: op.tags,
|
tags: op.tags,
|
||||||
...(op.pathParams?.length
|
...(op.pathParams?.length || op.queryParams?.length
|
||||||
? {
|
? {
|
||||||
parameters: op.pathParams.map((name) => ({
|
parameters: [
|
||||||
|
...(op.pathParams ?? []).map((name) => ({
|
||||||
name,
|
name,
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
required: true,
|
||||||
@@ -50,6 +51,13 @@ for (const op of API_CONTRACT) {
|
|||||||
? { type: 'string', format: 'uuid' }
|
? { type: 'string', format: 'uuid' }
|
||||||
: { type: 'string' },
|
: { type: 'string' },
|
||||||
})),
|
})),
|
||||||
|
...(op.queryParams ?? []).map((q) => ({
|
||||||
|
name: q.name,
|
||||||
|
in: 'query',
|
||||||
|
required: q.required ?? false,
|
||||||
|
schema: { type: 'string' },
|
||||||
|
})),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
|
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
|
||||||
@@ -70,6 +78,29 @@ for (const op of API_CONTRACT) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(op.multipartFields
|
||||||
|
? {
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'multipart/form-data': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: Object.fromEntries(
|
||||||
|
Object.entries(op.multipartFields).map(([name, sorte]) => [
|
||||||
|
name,
|
||||||
|
sorte === 'file'
|
||||||
|
? { type: 'string', format: 'binary' }
|
||||||
|
: { type: 'string' },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
required: ['file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
responses,
|
responses,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ import {
|
|||||||
PortalRequestCreateSchema,
|
PortalRequestCreateSchema,
|
||||||
PortalRequestStatusSchema,
|
PortalRequestStatusSchema,
|
||||||
} from './schemas/portail';
|
} from './schemas/portail';
|
||||||
|
import {
|
||||||
|
AnalyticsSummarySchema,
|
||||||
|
DocumentSchema,
|
||||||
|
DocumentsResponseSchema,
|
||||||
|
} from './schemas/documents';
|
||||||
import {
|
import {
|
||||||
ConsumePartSchema,
|
ConsumePartSchema,
|
||||||
LaborTimeCreateSchema,
|
LaborTimeCreateSchema,
|
||||||
@@ -108,8 +113,14 @@ export interface ApiOperation {
|
|||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
|
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
|
||||||
demoOnly?: boolean;
|
demoOnly?: boolean;
|
||||||
/** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */
|
/** Paramètres de chemin (`{id}` dans path) — `id`/`…Id` = UUID. */
|
||||||
pathParams?: string[];
|
pathParams?: string[];
|
||||||
|
/** Paramètres de requête (?a=…&b=…). */
|
||||||
|
queryParams?: { name: string; required?: boolean }[];
|
||||||
|
/** Upload multipart/form-data : champs déclarés ('file' = binaire). */
|
||||||
|
multipartFields?: Record<string, 'file' | 'string'>;
|
||||||
|
/** Réponse 200 binaire (téléchargement streamé). */
|
||||||
|
binaryResponse?: boolean;
|
||||||
request?: { name: string; schema: z.ZodType };
|
request?: { name: string; schema: z.ZodType };
|
||||||
responses: Record<
|
responses: Record<
|
||||||
number,
|
number,
|
||||||
@@ -436,6 +447,66 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
404: { description: 'Inconnue' },
|
404: { description: 'Inconnue' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ————— R3 · Bibliothèque & analytics —————
|
||||||
|
{
|
||||||
|
operationId: 'listDocuments',
|
||||||
|
method: 'get',
|
||||||
|
path: '/documents',
|
||||||
|
summary: 'Bibliothèque (filtrable par appareil ou OT)',
|
||||||
|
tags: ['documents'],
|
||||||
|
queryParams: [{ name: 'assetId' }, { name: 'workOrderId' }, { name: 'kind' }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'DocumentsResponse', schema: DocumentsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'uploadDocument',
|
||||||
|
method: 'post',
|
||||||
|
path: '/documents',
|
||||||
|
summary: 'Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis)',
|
||||||
|
tags: ['documents'],
|
||||||
|
multipartFields: { file: 'file', kind: 'string', assetId: 'string', workOrderId: 'string' },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Document rangé', name: 'Document', schema: DocumentSchema },
|
||||||
|
400: { description: 'Type/taille refusé ou rattachement manquant' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'downloadDocument',
|
||||||
|
method: 'get',
|
||||||
|
path: '/documents/{id}/download',
|
||||||
|
summary: 'Télécharger — streamé par l’API (MinIO jamais exposé)',
|
||||||
|
tags: ['documents'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
binaryResponse: true,
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Fichier' },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'deleteDocument',
|
||||||
|
method: 'delete',
|
||||||
|
path: '/documents/{id}',
|
||||||
|
summary: 'Supprimer (permission d’édition sur la cible)',
|
||||||
|
tags: ['documents'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
204: { description: 'Supprimé' },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'getAnalyticsSummary',
|
||||||
|
method: 'get',
|
||||||
|
path: '/analytics/summary',
|
||||||
|
summary: 'Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements',
|
||||||
|
tags: ['analytics'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ————— R3 · Gestion (stock, achats, tiers, coûts) —————
|
// ————— R3 · Gestion (stock, achats, tiers, coûts) —————
|
||||||
{
|
{
|
||||||
operationId: 'listPartners',
|
operationId: 'listPartners',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './schemas/exploitation';
|
|||||||
export * from './schemas/preventif';
|
export * from './schemas/preventif';
|
||||||
export * from './schemas/portail';
|
export * from './schemas/portail';
|
||||||
export * from './schemas/gestion';
|
export * from './schemas/gestion';
|
||||||
|
export * from './schemas/documents';
|
||||||
export * from './schemas/auth';
|
export * from './schemas/auth';
|
||||||
export * from './schemas/users';
|
export * from './schemas/users';
|
||||||
export * from './schemas/users-admin';
|
export * from './schemas/users-admin';
|
||||||
|
|||||||
62
packages/shared/src/schemas/documents.ts
Normal file
62
packages/shared/src/schemas/documents.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Bibliothèque de documents (R3) — types fermés, rattachement obligatoire. */
|
||||||
|
|
||||||
|
export const DOCUMENT_KINDS = ['NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER'] as const;
|
||||||
|
export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
|
||||||
|
export const DOCUMENT_KIND_LABELS: Record<DocumentKind, string> = {
|
||||||
|
NOTICE: 'Notice',
|
||||||
|
CERTIFICATE: 'Certificat',
|
||||||
|
PHOTO: 'Photo',
|
||||||
|
OTHER: 'Autre',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOCUMENT_MAX_BYTES = 20 * 1024 * 1024; // 20 Mo
|
||||||
|
export const DOCUMENT_CONTENT_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DocumentSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
kind: z.enum(DOCUMENT_KINDS),
|
||||||
|
fileName: z.string(),
|
||||||
|
size: z.number().int(),
|
||||||
|
contentType: z.string(),
|
||||||
|
assetReference: z.string().nullable(),
|
||||||
|
workOrderReference: z.string().nullable(),
|
||||||
|
uploadedByName: z.string().nullable(),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type DocumentDto = z.infer<typeof DocumentSchema>;
|
||||||
|
|
||||||
|
export const DocumentsResponseSchema = z.object({
|
||||||
|
documents: z.array(DocumentSchema),
|
||||||
|
});
|
||||||
|
export type DocumentsResponse = z.infer<typeof DocumentsResponseSchema>;
|
||||||
|
|
||||||
|
// ————— Analytics (écran Statistiques, maquette R3) —————
|
||||||
|
|
||||||
|
export const AnalyticsSummarySchema = z.object({
|
||||||
|
monthCost: z.number(), // MAD, mois courant (pièces + main-d'œuvre)
|
||||||
|
previousMonthCost: z.number(),
|
||||||
|
closed12m: z.object({ total: z.number().int(), preventive: z.number().int() }),
|
||||||
|
preventiveRate: z.number().nullable(), // grilles terminées / générées (12 mois)
|
||||||
|
avgResolutionDays: z.number().nullable(), // dépannages terminés (12 mois)
|
||||||
|
failuresByComponent: z.array(
|
||||||
|
z.object({ label: z.string(), count: z.number().int() }),
|
||||||
|
),
|
||||||
|
costsByMonth: z.array(z.object({ month: z.string(), total: z.number() })), // 6 derniers
|
||||||
|
topAssets: z.array(
|
||||||
|
z.object({
|
||||||
|
reference: z.string(),
|
||||||
|
siteName: z.string(),
|
||||||
|
correctives: z.number().int(),
|
||||||
|
partsCost: z.number(),
|
||||||
|
laborCost: z.number(),
|
||||||
|
total: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type AnalyticsSummary = z.infer<typeof AnalyticsSummarySchema>;
|
||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -84,6 +84,9 @@ importers:
|
|||||||
'@types/jest':
|
'@types/jest':
|
||||||
specifier: ^29.5.14
|
specifier: ^29.5.14
|
||||||
version: 29.5.14
|
version: 29.5.14
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.0
|
specifier: ^24.0.0
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
@@ -1889,6 +1892,9 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/multer@2.2.0':
|
||||||
|
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
@@ -6346,6 +6352,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/multer@2.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|||||||
Reference in New Issue
Block a user