fix: harden Excel export (StreamableFile + clearer errors)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 21:00:48 +03:30
co-authored by Cursor
parent 9b11617eab
commit 97815aba76
2 changed files with 45 additions and 25 deletions
+25 -12
View File
@@ -1,5 +1,11 @@
import { Controller, Get, Res, UseGuards } from '@nestjs/common'; import {
import type { Response } from 'express'; Controller,
Get,
InternalServerErrorException,
Logger,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { FullAccessGuard } from '../auth/full-access.guard'; import { FullAccessGuard } from '../auth/full-access.guard';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@@ -7,19 +13,26 @@ import { ExportService } from './export.service';
@Controller('export') @Controller('export')
@UseGuards(JwtAuthGuard, FullAccessGuard) @UseGuards(JwtAuthGuard, FullAccessGuard)
export class ExportController { export class ExportController {
private readonly logger = new Logger(ExportController.name);
constructor(private readonly exportService: ExportService) {} constructor(private readonly exportService: ExportService) {}
@Get('projects.xlsx') @Get(['projects', 'projects.xlsx'])
async projectsXlsx(@Res() res: Response) { async projectsXlsx(): Promise<StreamableFile> {
try {
const { buffer, filename } = await this.exportService.buildWorkbook(); const { buffer, filename } = await this.exportService.buildWorkbook();
res.setHeader( return new StreamableFile(buffer, {
'Content-Type', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'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}`,
); );
res.setHeader( }
'Content-Disposition',
`attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
);
res.send(buffer);
} }
} }
+18 -11
View File
@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import * as ExcelJS from 'exceljs'; import { Workbook, type Worksheet } from 'exceljs';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
formatIranDate, formatIranDate,
formatIranDateTime, formatIranDateTime,
iranDaysUntil, iranDaysUntil,
iranYmd,
} from '../common/iran-time.util'; } from '../common/iran-time.util';
const PRODUCT_LABEL: Record<string, string> = { const PRODUCT_LABEL: Record<string, string> = {
@@ -15,9 +16,12 @@ const PRODUCT_LABEL: Record<string, string> = {
@Injectable() @Injectable()
export class ExportService { export class ExportService {
private readonly logger = new Logger(ExportService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async buildWorkbook(): Promise<{ buffer: Buffer; filename: string }> { async buildWorkbook(): Promise<{ buffer: Buffer; filename: string }> {
this.logger.log('Building projects Excel export…');
const tenants = await this.prisma.tenant.findMany({ const tenants = await this.prisma.tenant.findMany({
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
include: { include: {
@@ -28,7 +32,7 @@ export class ExportService {
}, },
}); });
const workbook = new ExcelJS.Workbook(); const workbook = new Workbook();
workbook.creator = 'SuperAdmin Abshode'; workbook.creator = 'SuperAdmin Abshode';
workbook.created = new Date(); workbook.created = new Date();
@@ -39,15 +43,18 @@ export class ExportService {
this.addSmsSheet(workbook, tenants); this.addSmsSheet(workbook, tenants);
const arrayBuffer = await workbook.xlsx.writeBuffer(); const arrayBuffer = await workbook.xlsx.writeBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer as ArrayBuffer);
const stamp = formatIranDate(new Date()).replace(/\//g, '-'); const stamp = iranYmd(new Date());
this.logger.log(
`Excel export ready: ${tenants.length} tenants, ${buffer.length} bytes`,
);
return { return {
buffer, buffer,
filename: `superadmin-export-${stamp}.xlsx`, filename: `superadmin-export-${stamp}.xlsx`,
}; };
} }
private addProjectsSheet(workbook: ExcelJS.Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) { private addProjectsSheet(workbook: Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) {
const sheet = workbook.addWorksheet('پروژه‌ها', { const sheet = workbook.addWorksheet('پروژه‌ها', {
views: [{ rightToLeft: true }], views: [{ rightToLeft: true }],
}); });
@@ -115,7 +122,7 @@ export class ExportService {
} }
} }
private addServersSheet(workbook: ExcelJS.Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) { private addServersSheet(workbook: Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) {
const sheet = workbook.addWorksheet('سرورها', { const sheet = workbook.addWorksheet('سرورها', {
views: [{ rightToLeft: true }], views: [{ rightToLeft: true }],
}); });
@@ -157,7 +164,7 @@ export class ExportService {
} }
} }
private addPluginsSheet(workbook: ExcelJS.Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) { private addPluginsSheet(workbook: Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) {
const sheet = workbook.addWorksheet('افزونه‌ها', { const sheet = workbook.addWorksheet('افزونه‌ها', {
views: [{ rightToLeft: true }], views: [{ rightToLeft: true }],
}); });
@@ -203,7 +210,7 @@ export class ExportService {
} }
} }
private addSupportsSheet(workbook: ExcelJS.Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) { private addSupportsSheet(workbook: Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) {
const sheet = workbook.addWorksheet('پشتیبانی', { const sheet = workbook.addWorksheet('پشتیبانی', {
views: [{ rightToLeft: true }], views: [{ rightToLeft: true }],
}); });
@@ -247,7 +254,7 @@ export class ExportService {
} }
} }
private addSmsSheet(workbook: ExcelJS.Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) { private addSmsSheet(workbook: Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) {
const sheet = workbook.addWorksheet('پیامک', { const sheet = workbook.addWorksheet('پیامک', {
views: [{ rightToLeft: true }], views: [{ rightToLeft: true }],
}); });
@@ -310,7 +317,7 @@ export class ExportService {
}); });
} }
private styleHeader(sheet: ExcelJS.Worksheet) { private styleHeader(sheet: Worksheet) {
const row = sheet.getRow(1); const row = sheet.getRow(1);
row.font = { bold: true }; row.font = { bold: true };
row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };