Parse large Excel imports asynchronously to avoid gateway 502 timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 18:14:17 +03:30
co-authored by Cursor
parent d3fb92a5e2
commit ac57b8ac33
5 changed files with 270 additions and 60 deletions
@@ -17,11 +17,13 @@ import {
type ExcelPreviewResult,
} from '../common/excel';
import {
beginExcelImportPrepare,
chunkImportMeta,
createExcelImportSession,
destroyExcelImportSession,
getExcelImportPrepareStatus,
getExcelImportSessionCaches,
resolveExcelImportChunk,
type ExcelImportSessionStatus,
} from '../common/excel-import-session';
const ACTIVITY_COLUMNS = [
@@ -161,49 +163,45 @@ export class ActivitiesExcelService {
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
}
/** فقط خواندن و جلسه؛ بدون درج — برای فایل‌های خیلی بزرگ قبل از تکه‌تکه */
async prepare(
/**
* آپلود فوری + پارس در پس‌زمینه (جلوگیری از ۵۰۲ nginx روی فایل‌های بزرگ).
* وضعیت را با prepareStatus پول کنید.
*/
prepare(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let rows: Record<string, string>[];
try {
rows = await readMappedRows(
file,
ACTIVITY_ALIASES,
parseExcelMapping(mappingRaw),
);
} catch (e) {
if (e instanceof BadRequestException) throw e;
result.errors.push({
row: 1,
message: e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود',
});
return result;
): ImportContactsResult {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
const MAX_ROWS = 100_000;
if (rows.length > MAX_ROWS) {
result.errors.push({
row: 1,
message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`,
});
return { ...result, total: rows.length, processed: 0, hasMore: false };
}
const sessionId = createExcelImportSession(actor.organizationId, rows);
const mapping = parseExcelMapping(mappingRaw);
const sessionId = beginExcelImportPrepare(
actor.organizationId,
file.buffer,
(f) => readMappedRows(f, ACTIVITY_ALIASES, mapping),
);
return {
...result,
created: 0,
skipped: 0,
errors: [],
sessionId,
total: rows.length,
status: 'parsing',
total: 0,
parsed: 0,
processed: 0,
hasMore: rows.length > 0,
hasMore: true,
nextOffset: 0,
};
}
prepareStatus(
actor: Actor,
sessionId: string,
): ExcelImportSessionStatus {
return getExcelImportPrepareStatus(sessionId, actor.organizationId);
}
async import(
actor: Actor,
file: Express.Multer.File | undefined,
@@ -113,6 +113,14 @@ export class ActivitiesController {
return this.excelService.prepare(user, file, mapping);
}
@Get('import/prepare/:sessionId')
importPrepareStatus(
@CurrentUser() user: Actor,
@Param('sessionId') sessionId: string,
) {
return this.excelService.prepareStatus(user, sessionId);
}
@Post('import')
@UseInterceptors(excelUpload)
import(