[Backend] Service gestion documents légaux #8 #69
@ -37,6 +37,7 @@
|
||||
"class-validator": "^0.14.2",
|
||||
"joi": "^18.0.0",
|
||||
"mapped-types": "^0.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
@ -54,6 +55,7 @@
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
|
||||
@ -15,6 +15,7 @@ import { SentryGlobalFilter } from '@sentry/nestjs/setup';
|
||||
import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -51,6 +52,7 @@ import { AppConfigModule } from './modules/config/config.module';
|
||||
EnfantsModule,
|
||||
AuthModule,
|
||||
AppConfigModule,
|
||||
DocumentsLegauxModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
40
backend/src/entities/acceptation-document.entity.ts
Normal file
40
backend/src/entities/acceptation-document.entity.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
import { DocumentLegal } from './document-legal.entity';
|
||||
|
||||
@Entity('acceptations_documents')
|
||||
export class AcceptationDocument {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => Users, { nullable: false, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_utilisateur' })
|
||||
utilisateur: Users;
|
||||
|
||||
@ManyToOne(() => DocumentLegal, { nullable: true })
|
||||
@JoinColumn({ name: 'id_document' })
|
||||
document: DocumentLegal | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||
type_document: 'cgu' | 'privacy';
|
||||
|
||||
@Column({ type: 'integer', nullable: false })
|
||||
version_document: number;
|
||||
|
||||
@CreateDateColumn({ name: 'accepte_le', type: 'timestamptz' })
|
||||
accepteLe: Date;
|
||||
|
||||
@Column({ type: 'inet', nullable: true })
|
||||
ip_address: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
44
backend/src/entities/document-legal.entity.ts
Normal file
44
backend/src/entities/document-legal.entity.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
|
||||
@Entity('documents_legaux')
|
||||
export class DocumentLegal {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||
type: 'cgu' | 'privacy';
|
||||
|
||||
@Column({ type: 'integer', nullable: false })
|
||||
version: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
fichier_nom: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: false })
|
||||
fichier_path: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, nullable: false })
|
||||
fichier_hash: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
actif: boolean;
|
||||
|
||||
@ManyToOne(() => Users, { nullable: true })
|
||||
@JoinColumn({ name: 'televerse_par' })
|
||||
televersePar: Users | null;
|
||||
|
||||
@CreateDateColumn({ name: 'televerse_le', type: 'timestamptz' })
|
||||
televerseLe: Date;
|
||||
|
||||
@Column({ name: 'active_le', type: 'timestamptz', nullable: true })
|
||||
activeLe: Date | null;
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DocumentLegal } from '../../entities/document-legal.entity';
|
||||
import { AcceptationDocument } from '../../entities/acceptation-document.entity';
|
||||
import { DocumentsLegauxService } from './documents-legaux.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DocumentLegal, AcceptationDocument])],
|
||||
providers: [DocumentsLegauxService],
|
||||
exports: [DocumentsLegauxService],
|
||||
})
|
||||
export class DocumentsLegauxModule {}
|
||||
|
||||
209
backend/src/modules/documents-legaux/documents-legaux.service.ts
Normal file
209
backend/src/modules/documents-legaux/documents-legaux.service.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DocumentLegal } from '../../entities/document-legal.entity';
|
||||
import { AcceptationDocument } from '../../entities/acceptation-document.entity';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsLegauxService {
|
||||
private readonly UPLOAD_DIR = '/app/documents/legaux';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DocumentLegal)
|
||||
private docRepo: Repository<DocumentLegal>,
|
||||
@InjectRepository(AcceptationDocument)
|
||||
private acceptationRepo: Repository<AcceptationDocument>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Récupérer les documents actifs (CGU + Privacy)
|
||||
*/
|
||||
async getDocumentsActifs(): Promise<{ cgu: DocumentLegal; privacy: DocumentLegal }> {
|
||||
const cgu = await this.docRepo.findOne({
|
||||
where: { type: 'cgu', actif: true },
|
||||
});
|
||||
|
||||
const privacy = await this.docRepo.findOne({
|
||||
where: { type: 'privacy', actif: true },
|
||||
});
|
||||
|
||||
if (!cgu || !privacy) {
|
||||
throw new NotFoundException('Documents légaux manquants');
|
||||
}
|
||||
|
||||
return { cgu, privacy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploader une nouvelle version d'un document
|
||||
*/
|
||||
async uploadNouvelleVersion(
|
||||
type: 'cgu' | 'privacy',
|
||||
file: Express.Multer.File,
|
||||
userId: string,
|
||||
): Promise<DocumentLegal> {
|
||||
// Validation du type de fichier
|
||||
if (file.mimetype !== 'application/pdf') {
|
||||
throw new BadRequestException('Seuls les fichiers PDF sont acceptés');
|
||||
}
|
||||
|
||||
// Validation de la taille (max 10MB)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
throw new BadRequestException('Le fichier ne doit pas dépasser 10MB');
|
||||
}
|
||||
|
||||
// 1. Calculer la prochaine version
|
||||
const lastDoc = await this.docRepo.findOne({
|
||||
where: { type },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
const nouvelleVersion = (lastDoc?.version || 0) + 1;
|
||||
|
||||
// 2. Calculer le hash SHA-256 du fichier
|
||||
const fileBuffer = file.buffer;
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// 3. Générer le nom de fichier unique
|
||||
const timestamp = Date.now();
|
||||
const fileName = `${type}_v${nouvelleVersion}_${timestamp}.pdf`;
|
||||
const filePath = path.join(this.UPLOAD_DIR, fileName);
|
||||
|
||||
// 4. Créer le répertoire si nécessaire et sauvegarder le fichier
|
||||
await fs.mkdir(this.UPLOAD_DIR, { recursive: true });
|
||||
await fs.writeFile(filePath, fileBuffer);
|
||||
|
||||
// 5. Créer l'entrée en BDD
|
||||
const document = this.docRepo.create({
|
||||
type,
|
||||
version: nouvelleVersion,
|
||||
fichier_nom: file.originalname,
|
||||
fichier_path: filePath,
|
||||
fichier_hash: hash,
|
||||
actif: false, // Pas actif par défaut
|
||||
televersePar: { id: userId } as any,
|
||||
televerseLe: new Date(),
|
||||
});
|
||||
|
||||
return await this.docRepo.save(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activer une version (désactive automatiquement l'ancienne)
|
||||
*/
|
||||
async activerVersion(documentId: string): Promise<void> {
|
||||
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document non trouvé');
|
||||
}
|
||||
|
||||
// Transaction : désactiver l'ancienne version, activer la nouvelle
|
||||
await this.docRepo.manager.transaction(async (manager) => {
|
||||
// Désactiver toutes les versions de ce type
|
||||
await manager.update(
|
||||
DocumentLegal,
|
||||
{ type: document.type, actif: true },
|
||||
{ actif: false },
|
||||
);
|
||||
|
||||
// Activer la nouvelle version
|
||||
await manager.update(
|
||||
DocumentLegal,
|
||||
{ id: documentId },
|
||||
{ actif: true, activeLe: new Date() },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lister toutes les versions d'un type de document
|
||||
*/
|
||||
async listerVersions(type: 'cgu' | 'privacy'): Promise<DocumentLegal[]> {
|
||||
return await this.docRepo.find({
|
||||
where: { type },
|
||||
order: { version: 'DESC' },
|
||||
relations: ['televersePar'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharger un document (retourne le buffer et le nom)
|
||||
*/
|
||||
async telechargerDocument(documentId: string): Promise<{ stream: Buffer; filename: string }> {
|
||||
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document non trouvé');
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(document.fichier_path);
|
||||
|
||||
return {
|
||||
stream: fileBuffer,
|
||||
filename: document.fichier_nom,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new NotFoundException('Fichier introuvable sur le système de fichiers');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier l'intégrité d'un document (hash SHA-256)
|
||||
*/
|
||||
async verifierIntegrite(documentId: string): Promise<boolean> {
|
||||
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document non trouvé');
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(document.fichier_path);
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
return hash === document.fichier_hash;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrer une acceptation de document (lors de l'inscription)
|
||||
*/
|
||||
async enregistrerAcceptation(
|
||||
userId: string,
|
||||
documentId: string,
|
||||
typeDocument: 'cgu' | 'privacy',
|
||||
versionDocument: number,
|
||||
ipAddress: string | null,
|
||||
userAgent: string | null,
|
||||
): Promise<AcceptationDocument> {
|
||||
const acceptation = this.acceptationRepo.create({
|
||||
utilisateur: { id: userId } as any,
|
||||
document: { id: documentId } as any,
|
||||
type_document: typeDocument,
|
||||
version_document: versionDocument,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
return await this.acceptationRepo.save(acceptation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer l'historique des acceptations d'un utilisateur
|
||||
*/
|
||||
async getAcceptationsUtilisateur(userId: string): Promise<AcceptationDocument[]> {
|
||||
return await this.acceptationRepo.find({
|
||||
where: { utilisateur: { id: userId } },
|
||||
order: { accepteLe: 'DESC' },
|
||||
relations: ['document'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
3
backend/src/modules/documents-legaux/index.ts
Normal file
3
backend/src/modules/documents-legaux/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './documents-legaux.module';
|
||||
export * from './documents-legaux.service';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user