Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,6 +73,8 @@ import { ConfigValidationSchema } from './config/config.validation';
MailModule,
QueueModule,
OrganizationModule,
ExportModule,
CacheModule,
SharingModule,
],
controllers: [AppController],
Expand Down
24 changes: 24 additions & 0 deletions backend/src/cache/cache.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
30 changes: 30 additions & 0 deletions backend/src/cache/cache.service.ts
Original file line number Diff line number Diff line change
@@ -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<T>(key: string): Promise<T | undefined> {
return this.cache.get<T>(key);
}

async set(key: string, value: unknown, ttl?: number): Promise<void> {
await this.cache.set(key, value, ttl);
}

async del(key: string): Promise<void> {
await this.cache.del(key);
}

async wrap<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== undefined && cached !== null) {
return cached;
}
const result = await fn();
await this.set(key, result, ttl);
return result;
}
}
48 changes: 48 additions & 0 deletions backend/src/export/export.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 12 additions & 0 deletions backend/src/export/export.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
95 changes: 95 additions & 0 deletions backend/src/export/export.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DocumentsService } from '../documents/documents.service';
const PDFDocument = require('pdfkit');

Check failure on line 3 in backend/src/export/export.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

A `require()` style import is forbidden
const ExcelJS = require('exceljs');

Check failure on line 4 in backend/src/export/export.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

A `require()` style import is forbidden

@Injectable()
export class ExportService {
constructor(private readonly documentsService: DocumentsService) {}

async exportToPdf(documentId: string): Promise<Buffer> {
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<Buffer>((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<Buffer> {
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);
}
}
18 changes: 15 additions & 3 deletions backend/src/risk-assessment/risk-assessment.service.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -28,9 +29,18 @@ const FLAG_WEIGHTS: Record<RiskFlag, number> = {

@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<RiskResult> {
const cacheKey = `risk_${documentId}`;
const cached = await this.cacheService.get<RiskResult>(cacheKey);
if (cached) {
return cached;
}

const document = await this.documentsService.findById(documentId);
if (!document) {
throw new NotFoundException('Document not found');
Expand All @@ -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<RiskFlag[]> {
Expand Down
16 changes: 14 additions & 2 deletions backend/src/stellar/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('STELLAR_SECRET_KEY');
const horizonUrl =
this.configService.get<string>('STELLAR_HORIZON_URL') ||
Expand Down Expand Up @@ -87,12 +90,21 @@ export class StellarService {
async verifyHash(hash: string): Promise<boolean> {
this.validateHash(hash);

const cacheKey = `stellar_verify_${hash}`;
const cached = await this.cacheService.get<boolean>(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);
Expand Down
Loading