From 97815aba76b36a408e1ebad5efb9197d29fda78b Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 27 Oct 2026 21:00:48 +0330 Subject: [PATCH] fix: harden Excel export (StreamableFile + clearer errors) Co-authored-by: Cursor --- src/export/export.controller.ts | 41 ++++++++++++++++++++++----------- src/export/export.service.ts | 29 ++++++++++++++--------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/export/export.controller.ts b/src/export/export.controller.ts index 2e5d534..bbaeb7f 100644 --- a/src/export/export.controller.ts +++ b/src/export/export.controller.ts @@ -1,5 +1,11 @@ -import { Controller, Get, Res, UseGuards } from '@nestjs/common'; -import type { Response } from 'express'; +import { + Controller, + Get, + InternalServerErrorException, + Logger, + StreamableFile, + UseGuards, +} from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { FullAccessGuard } from '../auth/full-access.guard'; import { ExportService } from './export.service'; @@ -7,19 +13,26 @@ import { ExportService } from './export.service'; @Controller('export') @UseGuards(JwtAuthGuard, FullAccessGuard) export class ExportController { + private readonly logger = new Logger(ExportController.name); + constructor(private readonly exportService: ExportService) {} - @Get('projects.xlsx') - async projectsXlsx(@Res() res: Response) { - const { buffer, filename } = await this.exportService.buildWorkbook(); - res.setHeader( - 'Content-Type', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ); - res.setHeader( - 'Content-Disposition', - `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, - ); - res.send(buffer); + @Get(['projects', 'projects.xlsx']) + async projectsXlsx(): Promise { + try { + const { buffer, filename } = await this.exportService.buildWorkbook(); + return new StreamableFile(buffer, { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + disposition: `attachment; filename="${filename}"`, + length: buffer.length, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.logger.error(`Excel export failed: ${message}`, stack); + throw new InternalServerErrorException( + `خروجی اکسل ناموفق بود: ${message}`, + ); + } } } diff --git a/src/export/export.service.ts b/src/export/export.service.ts index 52b63d0..cf97a29 100644 --- a/src/export/export.service.ts +++ b/src/export/export.service.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@nestjs/common'; -import * as ExcelJS from 'exceljs'; +import { Injectable, Logger } from '@nestjs/common'; +import { Workbook, type Worksheet } from 'exceljs'; import { PrismaService } from '../prisma/prisma.service'; import { formatIranDate, formatIranDateTime, iranDaysUntil, + iranYmd, } from '../common/iran-time.util'; const PRODUCT_LABEL: Record = { @@ -15,9 +16,12 @@ const PRODUCT_LABEL: Record = { @Injectable() export class ExportService { + private readonly logger = new Logger(ExportService.name); + constructor(private readonly prisma: PrismaService) {} async buildWorkbook(): Promise<{ buffer: Buffer; filename: string }> { + this.logger.log('Building projects Excel export…'); const tenants = await this.prisma.tenant.findMany({ orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], include: { @@ -28,7 +32,7 @@ export class ExportService { }, }); - const workbook = new ExcelJS.Workbook(); + const workbook = new Workbook(); workbook.creator = 'SuperAdmin Abshode'; workbook.created = new Date(); @@ -39,15 +43,18 @@ export class ExportService { this.addSmsSheet(workbook, tenants); const arrayBuffer = await workbook.xlsx.writeBuffer(); - const buffer = Buffer.from(arrayBuffer); - const stamp = formatIranDate(new Date()).replace(/\//g, '-'); + const buffer = Buffer.from(arrayBuffer as ArrayBuffer); + const stamp = iranYmd(new Date()); + this.logger.log( + `Excel export ready: ${tenants.length} tenants, ${buffer.length} bytes`, + ); return { buffer, filename: `superadmin-export-${stamp}.xlsx`, }; } - private addProjectsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { + private addProjectsSheet(workbook: Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('پروژه‌ها', { views: [{ rightToLeft: true }], }); @@ -115,7 +122,7 @@ export class ExportService { } } - private addServersSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { + private addServersSheet(workbook: Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('سرورها', { views: [{ rightToLeft: true }], }); @@ -157,7 +164,7 @@ export class ExportService { } } - private addPluginsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { + private addPluginsSheet(workbook: Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('افزونه‌ها', { views: [{ rightToLeft: true }], }); @@ -203,7 +210,7 @@ export class ExportService { } } - private addSupportsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { + private addSupportsSheet(workbook: Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('پشتیبانی', { views: [{ rightToLeft: true }], }); @@ -247,7 +254,7 @@ export class ExportService { } } - private addSmsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { + private addSmsSheet(workbook: Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('پیامک', { views: [{ rightToLeft: true }], }); @@ -310,7 +317,7 @@ export class ExportService { }); } - private styleHeader(sheet: ExcelJS.Worksheet) { + private styleHeader(sheet: Worksheet) { const row = sheet.getRow(1); row.font = { bold: true }; row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };