diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9430e2d..eeb6856 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,7 +13,9 @@ import { I18nModule } from './i18n/i18n.module'; import { HealthModule } from './health/health.module'; import { buildWinstonOptions } from './common/logger.config'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; +import { CacheModule } from './cache/cache.module'; import { DocumentsModule } from './documents/documents.module'; +import { ExportModule } from './export/export.module'; import { ExternalValidationModule } from './external-validation/external-validation.module'; import { MailModule } from './mail/mail.module'; import { OrganizationModule } from './organization/organization.module'; @@ -71,6 +73,8 @@ import { ConfigValidationSchema } from './config/config.validation'; MailModule, QueueModule, OrganizationModule, + ExportModule, + CacheModule, SharingModule, ], controllers: [AppController], diff --git a/backend/src/cache/cache.module.ts b/backend/src/cache/cache.module.ts new file mode 100644 index 0000000..f945696 --- /dev/null +++ b/backend/src/cache/cache.module.ts @@ -0,0 +1,24 @@ +import { CacheModule as NestCacheModule, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import * as redisStore from 'cache-manager-redis-store'; +import { CacheService } from './cache.service'; + +@Module({ + imports: [ + NestCacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + isGlobal: true, + useFactory: (config: ConfigService) => ({ + store: redisStore, + host: config.get('REDIS_HOST') || '127.0.0.1', + port: parseInt(config.get('REDIS_PORT') || '6379'), + password: config.get('REDIS_PASSWORD') || undefined, + ttl: 300, + }), + }), + ], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/backend/src/cache/cache.service.ts b/backend/src/cache/cache.service.ts new file mode 100644 index 0000000..bad7fb8 --- /dev/null +++ b/backend/src/cache/cache.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; + +@Injectable() +export class CacheService { + constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} + + async get(key: string): Promise { + return this.cache.get(key); + } + + async set(key: string, value: unknown, ttl?: number): Promise { + await this.cache.set(key, value, ttl); + } + + async del(key: string): Promise { + await this.cache.del(key); + } + + async wrap(key: string, fn: () => Promise, ttl?: number): Promise { + const cached = await this.get(key); + if (cached !== undefined && cached !== null) { + return cached; + } + const result = await fn(); + await this.set(key, result, ttl); + return result; + } +} diff --git a/backend/src/export/export.controller.ts b/backend/src/export/export.controller.ts new file mode 100644 index 0000000..696f21e --- /dev/null +++ b/backend/src/export/export.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Post, Param, Body, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; +import { ExportService } from './export.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { IsIn, IsUUID } from 'class-validator'; + +class ExportFormatDto { + @IsUUID() + id: string; + + @IsIn(['pdf', 'excel']) + format: 'pdf' | 'excel'; +} + +@Controller('export') +@UseGuards(JwtAuthGuard) +export class ExportController { + constructor(private readonly exportService: ExportService) {} + + @Get('documents/:id/pdf') + async exportPdf(@Param('id') id: string, @Res() res: Response) { + const buffer = await this.exportService.exportToPdf(id); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="document-${id}.pdf"`, + }); + res.send(buffer); + } + + @Get('documents/:id/excel') + async exportExcel(@Param('id') id: string, @Res() res: Response) { + const buffer = await this.exportService.exportToExcel(id); + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="document-${id}.xlsx"`, + }); + res.send(buffer); + } + + @Post('documents/:id') + async exportDocument(@Param('id') id: string, @Body() body: ExportFormatDto) { + if (body.format === 'pdf') { + return this.exportService.exportToPdf(id); + } + return this.exportService.exportToExcel(id); + } +} diff --git a/backend/src/export/export.module.ts b/backend/src/export/export.module.ts new file mode 100644 index 0000000..5c521e6 --- /dev/null +++ b/backend/src/export/export.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ExportService } from './export.service'; +import { ExportController } from './export.controller'; +import { DocumentsModule } from '../documents/documents.module'; + +@Module({ + imports: [DocumentsModule], + providers: [ExportService], + controllers: [ExportController], + exports: [ExportService], +}) +export class ExportModule {} diff --git a/backend/src/export/export.service.ts b/backend/src/export/export.service.ts new file mode 100644 index 0000000..b6ba0c5 --- /dev/null +++ b/backend/src/export/export.service.ts @@ -0,0 +1,95 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DocumentsService } from '../documents/documents.service'; +const PDFDocument = require('pdfkit'); +const ExcelJS = require('exceljs'); + +@Injectable() +export class ExportService { + constructor(private readonly documentsService: DocumentsService) {} + + async exportToPdf(documentId: string): Promise { + const document = await this.documentsService.findById(documentId); + if (!document) { + throw new NotFoundException('Document not found'); + } + + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + + return new Promise((resolve, reject) => { + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + doc.fontSize(20).text('Document Export', { align: 'center' }); + doc.moveDown(); + + doc.fontSize(14).text('Title:'); + doc.fontSize(12).text(document.title); + doc.moveDown(); + + doc.fontSize(14).text('File Hash:'); + doc.fontSize(12).text(document.fileHash); + doc.moveDown(); + + doc.fontSize(14).text('Status:'); + doc.fontSize(12).text(document.status); + doc.moveDown(); + + doc.fontSize(14).text('Risk Score:'); + doc.fontSize(12).text(document.riskScore != null ? String(document.riskScore) : 'N/A'); + doc.moveDown(); + + doc.fontSize(14).text('Risk Flags:'); + doc.fontSize(12).text( + document.riskFlags && document.riskFlags.length > 0 ? document.riskFlags.join(', ') : 'None', + ); + doc.moveDown(); + + doc.fontSize(14).text('Created At:'); + doc.fontSize(12).text(document.createdAt.toISOString()); + doc.moveDown(); + + doc.fontSize(14).text('Updated At:'); + doc.fontSize(12).text(document.updatedAt.toISOString()); + + doc.end(); + }); + } + + async exportToExcel(documentId: string): Promise { + const document = await this.documentsService.findById(documentId); + if (!document) { + throw new NotFoundException('Document not found'); + } + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SMALDA'; + const worksheet = workbook.addWorksheet('Document'); + + worksheet.columns = [ + { header: 'Field', key: 'field', width: 20 }, + { header: 'Value', key: 'value', width: 60 }, + ]; + + const rows = [ + { field: 'ID', value: document.id }, + { field: 'Title', value: document.title }, + { field: 'File Hash', value: document.fileHash }, + { field: 'File Size', value: String(document.fileSize) }, + { field: 'MIME Type', value: document.mimeType }, + { field: 'Status', value: document.status }, + { field: 'Risk Score', value: document.riskScore != null ? String(document.riskScore) : 'N/A' }, + { field: 'Risk Flags', value: document.riskFlags ? document.riskFlags.join(', ') : 'None' }, + { field: 'Created At', value: document.createdAt.toISOString() }, + { field: 'Updated At', value: document.updatedAt.toISOString() }, + ]; + + rows.forEach((row) => worksheet.addRow(row)); + + worksheet.getRow(1).font = { bold: true }; + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } +} diff --git a/backend/src/risk-assessment/risk-assessment.service.ts b/backend/src/risk-assessment/risk-assessment.service.ts index 71b25f5..af6aa85 100644 --- a/backend/src/risk-assessment/risk-assessment.service.ts +++ b/backend/src/risk-assessment/risk-assessment.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { DocumentsService } from '../documents/documents.service'; import { Document } from '../documents/entities/document.entity'; +import { CacheService } from '../cache/cache.service'; export enum RiskFlag { MISSING_PARCEL_ID = 'MISSING_PARCEL_ID', @@ -28,9 +29,18 @@ const FLAG_WEIGHTS: Record = { @Injectable() export class RiskAssessmentService { - constructor(private readonly documentsService: DocumentsService) {} + constructor( + private readonly documentsService: DocumentsService, + @Inject(CacheService) private readonly cacheService: CacheService, + ) {} async assessDocument(documentId: string): Promise { + const cacheKey = `risk_${documentId}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return cached; + } + const document = await this.documentsService.findById(documentId); if (!document) { throw new NotFoundException('Document not found'); @@ -41,7 +51,9 @@ export class RiskAssessmentService { await this.documentsService.updateRisk(documentId, score, flags); - return { score, flags }; + const result = { score, flags }; + await this.cacheService.set(cacheKey, result, 300); + return result; } private async detectFlags(document: Document): Promise { diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts index e098c46..bc64d57 100644 --- a/backend/src/stellar/stellar.service.ts +++ b/backend/src/stellar/stellar.service.ts @@ -22,7 +22,10 @@ export class StellarService { private readonly accountId: string; private readonly SHA256_HASH_REGEX = /^[a-f0-9]{64}$/i; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + @Inject(CacheService) private readonly cacheService: CacheService, + ) { const secretKey = this.configService.get('STELLAR_SECRET_KEY'); const horizonUrl = this.configService.get('STELLAR_HORIZON_URL') || @@ -87,12 +90,21 @@ export class StellarService { async verifyHash(hash: string): Promise { this.validateHash(hash); + const cacheKey = `stellar_verify_${hash}`; + const cached = await this.cacheService.get(cacheKey); + if (cached !== undefined) { + return cached; + } + try { const key = this.buildDataKey(hash); const account = await this.server.loadAccount(this.accountId); - return key in account.data_attr; + const result = key in account.data_attr; + await this.cacheService.set(cacheKey, result, 600); + return result; } catch (error) { if (error?.response?.status === 404) { + await this.cacheService.set(cacheKey, false, 600); return false; } this.logger.error('Failed to verify document hash', error);