Compare commits

...

2 Commits

Author SHA1 Message Date
98082187b5 Merge pull request '[Backend] Service gestion documents légaux #8' (#69) from feature/8-service-documents-legaux into master
Merge pull request #69: Service gestion documents légaux

Ticket #8 complété
2025-11-30 14:51:36 +00:00
1fb8c33cbf feat(backend): service gestion documents légaux #8
- Création entité DocumentLegal
- Création entité AcceptationDocument
- Création DocumentsLegauxService avec méthodes:
  * getDocumentsActifs()
  * uploadNouvelleVersion() (avec hash SHA-256)
  * activerVersion() (transaction)
  * listerVersions()
  * telechargerDocument()
  * verifierIntegrite()
  * enregistrerAcceptation()
  * getAcceptationsUtilisateur()
- Création DocumentsLegauxModule
- Intégration dans AppModule
- Ajout dépendances multer + @types/multer

Réf: docs/22_DOCUMENTS-LEGAUX.md
2025-11-30 15:50:38 +01:00
7 changed files with 313 additions and 0 deletions

View File

@ -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",

View File

@ -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: [

View 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;
}

View 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;
}

View File

@ -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 {}

View 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'],
});
}
}

View File

@ -0,0 +1,3 @@
export * from './documents-legaux.module';
export * from './documents-legaux.service';