Import large activity Excel in the browser via JSON chunks to avoid 502.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 18:28:47 +03:30
co-authored by Cursor
parent ac57b8ac33
commit cbfe470f33
11 changed files with 6451 additions and 31 deletions
@@ -19,12 +19,15 @@ import {
import {
beginExcelImportPrepare,
chunkImportMeta,
createExcelImportCacheSession,
destroyExcelImportSession,
getExcelImportPrepareStatus,
getExcelImportSessionCaches,
resolveExcelImportChunk,
type ExcelImportLookupCaches,
type ExcelImportSessionStatus,
} from '../common/excel-import-session';
import type { ImportActivityRowsDto } from './dto';
const ACTIVITY_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
@@ -239,6 +242,71 @@ export class ActivitiesExcelService {
}
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
await this.processActivityRows(
actor,
ctx.slice,
ctx.offset,
result,
caches,
{ skipDuplicateCheck: false },
);
return { ...result, ...chunkImportMeta(ctx) };
}
/**
* ایمپورت تکه‌ای JSON از مرورگر — فایل اکسل روی سرور آپلود/پارس نمی‌شود.
*/
async importRows(
actor: Actor,
dto: ImportActivityRowsDto,
): Promise<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const rows = Array.isArray(dto.rows) ? dto.rows : [];
if (!rows.length) {
result.errors.push({ row: 1, message: 'ردیف ارسال‌شده خالی است' });
return result;
}
if (rows.length > 80) {
result.errors.push({ row: 1, message: 'حداکثر ۸۰ ردیف در هر درخواست' });
return result;
}
const total = Math.max(0, Number(dto.total) || 0);
const offset = Math.max(0, Number(dto.offset) || 0);
let sessionId = (dto.sessionId ?? '').trim();
if (!sessionId) {
sessionId = createExcelImportCacheSession(actor.organizationId);
}
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
await this.processActivityRows(actor, rows, offset, result, caches, {
// برای فایل‌های خیلی بزرگ، بررسی تکراری هر ردیف درخواست را طولانی و ۵۰۲ می‌کند
skipDuplicateCheck: total > 2000,
});
const processed = offset + rows.length;
const hasMore = total > 0 ? processed < total : false;
if (!hasMore) destroyExcelImportSession(sessionId);
return {
...result,
sessionId: hasMore ? sessionId : undefined,
total,
processed,
hasMore,
nextOffset: hasMore ? processed : undefined,
};
}
private async processActivityRows(
actor: Actor,
slice: Array<Record<string, string>>,
offset: number,
result: ImportContactsResult,
caches: ExcelImportLookupCaches,
opts: { skipDuplicateCheck: boolean },
) {
const resolveUser = async (name?: string) => {
const key = (name ?? '').trim().toLowerCase();
if (!key) return null;
@@ -274,9 +342,9 @@ export class ActivitiesExcelService {
return id;
};
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
for (let i = 0; i < slice.length; i++) {
const row = slice[i]!;
const rowNum = offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
continue;
}
@@ -311,20 +379,20 @@ export class ActivitiesExcelService {
const creatorId =
(await resolveUser(row.creator)) ?? actor.userId;
const duplicate = dueAt
? await this.prisma.activity.findFirst({
where: {
organizationId: actor.organizationId,
title,
dueAt,
personId: personId ?? null,
},
select: { id: true },
})
: null;
if (duplicate) {
result.skipped += 1;
continue;
if (!opts.skipDuplicateCheck && dueAt) {
const duplicate = await this.prisma.activity.findFirst({
where: {
organizationId: actor.organizationId,
title,
dueAt,
personId: personId ?? null,
},
select: { id: true },
});
if (duplicate) {
result.skipped += 1;
continue;
}
}
const created = await this.prisma.activity.create({
@@ -346,8 +414,7 @@ export class ActivitiesExcelService {
},
select: { id: true },
});
// M2M مسئولان — اگر مایگریشن روی سرور نباشد، ایمپورت نباید بترکد
if (assigneeIds.length) {
if (assigneeIds.length > 1) {
try {
await this.prisma.activity.update({
where: { id: created.id },
@@ -367,8 +434,6 @@ export class ActivitiesExcelService {
});
}
}
return { ...result, ...chunkImportMeta(ctx) };
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
@@ -24,6 +24,7 @@ import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import {
CreateActivityDto,
ImportActivityRowsDto,
ListActivitiesQueryDto,
UpdateActivityDto,
} from './dto';
@@ -121,6 +122,14 @@ export class ActivitiesController {
return this.excelService.prepareStatus(user, sessionId);
}
@Post('import/rows')
importRows(
@CurrentUser() user: Actor,
@Body() dto: ImportActivityRowsDto,
) {
return this.excelService.importRows(user, dto);
}
@Post('import')
@UseInterceptors(excelUpload)
import(
+22
View File
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
Allow,
IsArray,
IsBoolean,
IsDateString,
@@ -14,6 +15,27 @@ import {
ValidateIf,
} from 'class-validator';
/** تکه‌های JSON از ایمپورت مرورگرگرا */
export class ImportActivityRowsDto {
@IsArray()
@Allow()
rows!: Array<Record<string, string>>;
@Type(() => Number)
@IsInt()
@Min(0)
offset!: number;
@Type(() => Number)
@IsInt()
@Min(0)
total!: number;
@IsOptional()
@IsString()
sessionId?: string;
}
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
const ACTIVITY_PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
@@ -102,6 +102,22 @@ export function createExcelImportSession(
return id;
}
/** فقط کش lookup برای ایمپورت JSON تکه‌ای از مرورگر (بدون نگه داشتن کل فایل) */
export function createExcelImportCacheSession(organizationId: string): string {
purgeExpired();
const id = randomBytes(16).toString('hex');
sessions.set(id, {
organizationId,
rows: [],
expiresAt: Date.now() + SESSION_TTL_MS,
caches: emptyCaches(),
status: 'ready',
total: 0,
parsed: 0,
});
return id;
}
/**
* آپلود را فوری قبول می‌کند و پارس را در پس‌زمینه انجام می‌دهد
* تا پروکسی nginx با ۵۰۲ قطع نکند.