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:
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 { APP_GUARD } from '@nestjs/core';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { DocumentsModule } from './documents/documents.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
@@ -57,6 +59,8 @@ export class AppModule {
|
||||
PartnersModule,
|
||||
PartsModule,
|
||||
PurchaseOrdersModule,
|
||||
DocumentsModule,
|
||||
AnalyticsModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@ const EnvSchema = z.object({
|
||||
MINIO_USE_SSL: z.string().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
MINIO_BUCKET: z.string().default('siop2'),
|
||||
});
|
||||
|
||||
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é
|
||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
|
||||
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
|
||||
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
|
||||
*/
|
||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||
|
||||
export interface FileStorage {
|
||||
/** Lève une exception si le stockage est injoignable. */
|
||||
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 { loadEnv } from '../config/env';
|
||||
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()
|
||||
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 bucket: string;
|
||||
|
||||
constructor() {
|
||||
const env = loadEnv();
|
||||
this.bucket = env.MINIO_BUCKET;
|
||||
this.client = new Minio.Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user