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 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) {
@Get(['projects', 'projects.xlsx'])
async projectsXlsx(): Promise<StreamableFile> {
try {
const { buffer, filename } = await this.exportService.buildWorkbook();
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
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}`,
);
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 * 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<string, string> = {
@@ -15,9 +16,12 @@ const PRODUCT_LABEL: Record<string, string> = {
@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<ReturnType<ExportService['load']>>) {
private addProjectsSheet(workbook: Workbook, tenants: Awaited<ReturnType<ExportService['load']>>) {
const sheet = workbook.addWorksheet('پروژه‌ها', {
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('سرورها', {
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('افزونه‌ها', {
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('پشتیبانی', {
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('پیامک', {
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 };