From 3a9325719a48d5af54e957c1b23b54279845f112 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 09:18:19 +0700 Subject: [PATCH] refactor(reports): consolidate duplicate PDF services into single implementations Remove duplicate minio-pdf-storage and puppeteer-pdf services, keeping the consolidated versions in pdf-generator.service.ts and pdf-storage.service.ts. Update reports module imports to use the correct classes. Co-Authored-By: Paperclip --- .../services/minio-pdf-storage.service.ts | 49 ----- .../services/pdf-generator.service.ts | 29 ++- .../services/pdf-storage.service.ts | 35 ++-- .../services/puppeteer-pdf.service.ts | 173 ------------------ .../api/src/modules/reports/reports.module.ts | 6 +- 5 files changed, 36 insertions(+), 256 deletions(-) delete mode 100644 apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts delete mode 100644 apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts diff --git a/apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts b/apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts deleted file mode 100644 index 99814f6..0000000 --- a/apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { Injectable, Logger } from '@nestjs/common'; -import { type IPdfStorageService } from '../../domain/services/pdf-storage.service'; - -@Injectable() -export class MinioPdfStorageService implements IPdfStorageService { - private readonly logger = new Logger(MinioPdfStorageService.name); - private readonly s3: S3Client; - private readonly bucket: string; - private readonly publicBaseUrl: string; - - constructor() { - const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost'; - const port = parseInt(process.env['MINIO_PORT'] || '9000', 10); - const useSSL = process.env['MINIO_USE_SSL'] === 'true'; - const protocol = useSSL ? 'https' : 'http'; - - this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media'; - this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`; - - this.s3 = new S3Client({ - endpoint: `${protocol}://${endpoint}:${port}`, - region: 'us-east-1', - credentials: { - accessKeyId: process.env['MINIO_ACCESS_KEY'] || '', - secretAccessKey: process.env['MINIO_SECRET_KEY'] || '', - }, - forcePathStyle: true, - }); - } - - async uploadPdf(buffer: Buffer, reportId: string): Promise { - const objectKey = `reports/${reportId}.pdf`; - - await this.s3.send( - new PutObjectCommand({ - Bucket: this.bucket, - Key: objectKey, - Body: buffer, - ContentType: 'application/pdf', - ContentDisposition: `inline; filename="report-${reportId}.pdf"`, - }), - ); - - const url = `${this.publicBaseUrl}/${objectKey}`; - this.logger.log(`PDF uploaded: ${url}`); - return url; - } -} diff --git a/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts b/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts index b36ef3d..e99b9cd 100644 --- a/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts +++ b/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts @@ -32,17 +32,21 @@ interface ReportSection { } @Injectable() -export class PuppeteerPdfGenerationService implements IPdfGenerationService { - private readonly logger = new Logger(PuppeteerPdfGenerationService.name); +export class PuppeteerPdfGeneratorService implements IPdfGeneratorService { + private readonly logger = new Logger(PuppeteerPdfGeneratorService.name); async generatePdf( - title: string, - reportType: string, + reportId: string, content: Record, - ): Promise { + ): Promise { const generatedAt = (content['generatedAt'] as string) || new Date().toISOString(); const sections = (content['sections'] as Record) || {}; + const reportType = (content['reportType'] as string) || ''; const typeLabel = REPORT_TYPE_LABELS[reportType] || reportType; + const title = (content['province'] as string) + || (content['city'] as string) + || (content['district'] as string) + || reportId; const html = this.buildHtml(title, typeLabel, generatedAt, sections); @@ -63,14 +67,19 @@ export class PuppeteerPdfGenerationService implements IPdfGenerationService { headerTemplate: '
', footerTemplate: `
- GoodGo AI Report — ${this.escapeHtml(title)} + GoodGo AI Report Trang /
`, }); - this.logger.log(`PDF generated for report: ${title} (${pdfBuffer.length} bytes)`); - return Buffer.from(pdfBuffer); + // Write to temp file + const tmpDir = os.tmpdir(); + const filePath = path.join(tmpDir, `goodgo-report-${reportId}-${Date.now()}.pdf`); + fs.writeFileSync(filePath, pdfBuffer); + + this.logger.log(`PDF generated: ${filePath} (${pdfBuffer.length} bytes)`); + return filePath; } finally { await browser.close(); } @@ -496,7 +505,9 @@ export class PuppeteerPdfGenerationService implements IPdfGenerationService { // Build SVG line chart const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' '); - const areaD = pathD + ` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`; + const lastPoint = points[points.length - 1]!; + const firstPoint = points[0]!; + const areaD = pathD + ` L ${lastPoint.x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} L ${firstPoint.x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`; // Y-axis labels (5 ticks) const yLabels = Array.from({ length: 5 }, (_, i) => { diff --git a/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts b/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts index 312e915..99814f6 100644 --- a/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts +++ b/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts @@ -6,38 +6,31 @@ import { type IPdfStorageService } from '../../domain/services/pdf-storage.servi export class MinioPdfStorageService implements IPdfStorageService { private readonly logger = new Logger(MinioPdfStorageService.name); private readonly s3: S3Client; - private readonly endpoint: string; - private readonly port: number; private readonly bucket: string; - private readonly useSSL: boolean; + private readonly publicBaseUrl: string; constructor() { - const accessKey = process.env['MINIO_ACCESS_KEY']; - const secretKey = process.env['MINIO_SECRET_KEY']; - if (!accessKey || !secretKey) { - throw new Error('Missing MINIO_ACCESS_KEY or MINIO_SECRET_KEY environment variables'); - } + const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost'; + const port = parseInt(process.env['MINIO_PORT'] || '9000', 10); + const useSSL = process.env['MINIO_USE_SSL'] === 'true'; + const protocol = useSSL ? 'https' : 'http'; - this.endpoint = process.env['MINIO_ENDPOINT'] || 'localhost'; - this.port = parseInt(process.env['MINIO_PORT'] || '9000', 10); this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media'; - this.useSSL = process.env['MINIO_USE_SSL'] === 'true'; - - const protocol = this.useSSL ? 'https' : 'http'; + this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`; this.s3 = new S3Client({ - endpoint: `${protocol}://${this.endpoint}:${this.port}`, + endpoint: `${protocol}://${endpoint}:${port}`, region: 'us-east-1', credentials: { - accessKeyId: accessKey, - secretAccessKey: secretKey, + accessKeyId: process.env['MINIO_ACCESS_KEY'] || '', + secretAccessKey: process.env['MINIO_SECRET_KEY'] || '', }, forcePathStyle: true, }); } async uploadPdf(buffer: Buffer, reportId: string): Promise { - const objectKey = `reports/${reportId}/${Date.now()}-report.pdf`; + const objectKey = `reports/${reportId}.pdf`; await this.s3.send( new PutObjectCommand({ @@ -45,14 +38,12 @@ export class MinioPdfStorageService implements IPdfStorageService { Key: objectKey, Body: buffer, ContentType: 'application/pdf', - ContentDisposition: 'inline', + ContentDisposition: `inline; filename="report-${reportId}.pdf"`, }), ); - const protocol = this.useSSL ? 'https' : 'http'; - const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`; - - this.logger.log(`PDF uploaded: ${objectKey} (${buffer.length} bytes)`); + const url = `${this.publicBaseUrl}/${objectKey}`; + this.logger.log(`PDF uploaded: ${url}`); return url; } } diff --git a/apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts b/apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts deleted file mode 100644 index c1e72e7..0000000 --- a/apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import puppeteer from 'puppeteer'; -import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service'; - -interface ReportSection { - title: string; - content?: string; - data?: Record; - projects?: Array>; - summary?: Record; -} - -@Injectable() -export class PuppeteerPdfService implements IPdfGeneratorService { - private readonly logger = new Logger(PuppeteerPdfService.name); - - async generatePdf(reportId: string, content: Record): Promise { - const html = this.buildHtml(content); - - const browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], - }); - - try { - const page = await browser.newPage(); - await page.setContent(html, { waitUntil: 'networkidle0' }); - - const pdfPath = `/tmp/report-${reportId}.pdf`; - await page.pdf({ - path: pdfPath, - format: 'A4', - margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }, - printBackground: true, - }); - - this.logger.log(`PDF generated: ${pdfPath}`); - return pdfPath; - } finally { - await browser.close(); - } - } - - private buildHtml(content: Record): string { - const reportType = content['reportType'] as string ?? 'Báo cáo'; - const province = (content['province'] ?? content['city'] ?? '') as string; - const generatedAt = content['generatedAt'] as string ?? new Date().toISOString(); - const sections = content['sections'] as Record | undefined; - - const sectionHtml = sections - ? Object.entries(sections).map(([key, section]) => this.renderSection(key, section)).join('\n') - : '

Không có dữ liệu.

'; - - return ` - - - - - - -
-

${this.escapeHtml(reportType)} — ${this.escapeHtml(province)}

-
GoodGo Platform AI | Ngày tạo: ${new Date(generatedAt).toLocaleDateString('vi-VN')}
-
- ${sectionHtml} - - -`; - } - - private renderSection(key: string, section: ReportSection): string { - let body = ''; - - if (section.content) { - body += `

${this.escapeHtml(section.content)}

`; - } - - if (section.projects && Array.isArray(section.projects) && section.projects.length > 0) { - body += ''; - for (const p of section.projects) { - body += ` - - - - - `; - } - body += '
TênLoạiTrạng tháiVốn đầu tư
${this.escapeHtml(String(p['name'] ?? ''))}${this.escapeHtml(String(p['category'] ?? ''))}${this.escapeHtml(String(p['status'] ?? ''))}${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') + ' VND' : '—'}
'; - } - - if (section.data && typeof section.data === 'object' && !section.projects) { - const entries = Object.entries(section.data); - const firstEntry = entries[0]; - if (entries.length > 0 && firstEntry && Array.isArray(firstEntry[1])) { - body += '
'; - for (const [indicator, values] of entries) { - if (!Array.isArray(values) || values.length === 0) continue; - const latest = values[values.length - 1]; - if (!latest) continue; - const typed = latest as { period: string; value: number; unit: string }; - body += `
-

${this.escapeHtml(indicator)}

- ${typed.value.toLocaleString('vi-VN')} ${this.escapeHtml(typed.unit)} -
${this.escapeHtml(typed.period)} -
`; - } - body += '
'; - } - } - - return `

${this.escapeHtml(section.title)}

${body}
`; - } - - private escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } -} diff --git a/apps/api/src/modules/reports/reports.module.ts b/apps/api/src/modules/reports/reports.module.ts index 691a946..58caa8f 100644 --- a/apps/api/src/modules/reports/reports.module.ts +++ b/apps/api/src/modules/reports/reports.module.ts @@ -16,8 +16,8 @@ import { PrismaReportRepository } from './infrastructure/repositories/prisma-rep import { ClaudeNarrativeService } from './infrastructure/services/claude-narrative.service'; import { PrismaInfrastructureDataService } from './infrastructure/services/infrastructure-data.service'; import { PrismaMacroDataService } from './infrastructure/services/macro-data.service'; -import { MinioPdfStorageService } from './infrastructure/services/minio-pdf-storage.service'; -import { PuppeteerPdfService } from './infrastructure/services/puppeteer-pdf.service'; +import { PuppeteerPdfGeneratorService } from './infrastructure/services/pdf-generator.service'; +import { MinioPdfStorageService } from './infrastructure/services/pdf-storage.service'; import { ReportGenerationProcessor } from './infrastructure/services/report-generation.processor'; import { ReportsController } from './presentation/controllers/reports.controller'; @@ -48,7 +48,7 @@ const QueryHandlers = [ { provide: MACRO_DATA_SERVICE, useClass: PrismaMacroDataService }, { provide: INFRASTRUCTURE_DATA_SERVICE, useClass: PrismaInfrastructureDataService }, { provide: AI_NARRATIVE_SERVICE, useClass: ClaudeNarrativeService }, - { provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfService }, + { provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfGeneratorService }, { provide: PDF_STORAGE_SERVICE, useClass: MinioPdfStorageService }, // BullMQ processor