feat: keep deletions restorable and confirm a pipeline's deals before it is removed

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 22:23:09 +03:30
co-authored by Cursor
parent c04582072d
commit 2cc81bb596
17 changed files with 1158 additions and 6 deletions
+2
View File
@@ -49,6 +49,7 @@ import { LeadsModule } from './leads/leads.module';
import { ConversationsModule } from './conversations/conversations.module';
import { RoutinesModule } from './routines/routines.module';
import { GamificationModule } from './gamification/gamification.module';
import { DeletionsModule } from './deletions/deletions.module';
@Module({
imports: [
@@ -105,6 +106,7 @@ import { GamificationModule } from './gamification/gamification.module';
ConversationsModule,
RoutinesModule,
GamificationModule,
DeletionsModule,
],
})
export class AppModule {}
+302
View File
@@ -0,0 +1,302 @@
import { Prisma } from '@prisma/client';
import type { PrismaClient } from '@prisma/client';
import { deletionActor, skipDeletionArchive } from './deletion-context';
const SKIP_MODELS = new Set([
'DeletionArchive',
'AuditLog',
'Notification',
'PortalOtp',
'AutomationCursor',
'MessageAutomationJob',
'AccountingSyncJob',
'LeadImportJob',
'WebhookDelivery',
]);
const MODEL_LABELS: Record<string, string> = {
Person: 'مشتری',
Company: 'شرکت',
Lead: 'لید',
Deal: 'معامله',
Pipeline: 'مسیر فروش',
PipelineStage: 'مرحله مسیر',
Activity: 'فعالیت',
Note: 'یادداشت',
Quote: 'پیش‌فاکتور',
Invoice: 'فاکتور',
Ticket: 'تیکت',
ProjectCard: 'کارت پروژه',
ProjectBoard: 'تخته پروژه',
Product: 'محصول',
RoutineTask: 'کار روتین',
Routine: 'روتین',
LeadForm: 'فرم',
User: 'کاربر',
Team: 'تیم',
};
type Relink = { model: string; field: string; ids: string[] };
type Captured = {
row: Record<string, unknown>;
children: Array<{ model: string; item: Captured }>;
relink: Relink[];
};
type ModelField = {
name: string;
kind: string;
type: string;
isList: boolean;
relationName?: string;
relationFromFields?: string[];
relationOnDelete?: string;
};
type ModelMeta = { name: string; fields: ModelField[] };
function models(): ModelMeta[] {
return Prisma.dmmf.datamodel.models as unknown as ModelMeta[];
}
function delegateName(model: string) {
return model.charAt(0).toLowerCase() + model.slice(1);
}
function delegate(client: PrismaClient, model: string) {
return (client as unknown as Record<string, {
findMany: (args: unknown) => Promise<Array<Record<string, unknown>>>;
findFirst: (args: unknown) => Promise<Record<string, unknown> | null>;
create: (args: unknown) => Promise<unknown>;
updateMany: (args: unknown) => Promise<unknown>;
deleteMany: (args: unknown) => Promise<unknown>;
}>)[delegateName(model)];
}
function plain(value: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
export function modelLabel(model: string) {
return MODEL_LABELS[model] ?? model;
}
export function rowLabel(row: Record<string, unknown>) {
const person = [row.firstName, row.lastName].filter(Boolean).join(' ').trim();
const text =
row.title ||
row.name ||
row.fullName ||
row.subject ||
person ||
row.number ||
row.mobile ||
row.email ||
row.id;
return String(text || 'بدون عنوان').slice(0, 180);
}
function cascadeLinks(parentModel: string) {
const parent = models().find((item) => item.name === parentModel);
if (!parent) return [];
const links: Array<{ model: string; fk: string }> = [];
for (const field of parent.fields) {
if (field.kind !== 'object' || !field.isList) continue;
const child = models().find((item) => item.name === field.type);
const back = child?.fields.find(
(item) =>
item.relationName === field.relationName &&
(item.relationFromFields?.length ?? 0) > 0,
);
if (!back || back.relationOnDelete !== 'Cascade') continue;
if (SKIP_MODELS.has(field.type)) continue;
links.push({ model: field.type, fk: back.relationFromFields![0] });
}
return links;
}
async function setNullRefs(
client: PrismaClient,
parentModel: string,
id: string,
): Promise<Relink[]> {
const refs: Relink[] = [];
for (const model of models()) {
if (SKIP_MODELS.has(model.name)) continue;
for (const field of model.fields) {
if (field.kind !== 'object' || field.type !== parentModel) continue;
if (field.relationOnDelete !== 'SetNull') continue;
const fk = field.relationFromFields?.[0];
if (!fk) continue;
const rows = await delegate(client, model.name).findMany({
where: { [fk]: id },
select: { id: true },
});
if (!rows.length) continue;
refs.push({
model: model.name,
field: fk,
ids: rows.map((row) => String(row.id)),
});
}
}
return refs;
}
async function captureRow(
client: PrismaClient,
model: string,
row: Record<string, unknown>,
depth: number,
): Promise<Captured> {
const id = String(row.id);
const relink = depth > 3 ? [] : await setNullRefs(client, model, id);
const children: Captured['children'] = [];
if (depth < 4) {
for (const link of cascadeLinks(model)) {
const nested = await delegate(client, link.model).findMany({
where: { [link.fk]: id },
});
for (const child of nested) {
children.push({
model: link.model,
item: await captureRow(client, link.model, child, depth + 1),
});
}
}
}
return { row: plain(row), children, relink };
}
async function writeArchives(
client: PrismaClient,
model: string,
captured: Captured[],
) {
const actor = deletionActor();
for (const item of captured) {
const organizationId =
typeof item.row.organizationId === 'string'
? item.row.organizationId
: actor?.organizationId;
if (!organizationId) continue;
const deletedById =
actor?.userId && actor.organizationId === organizationId ? actor.userId : null;
await client.deletionArchive.create({
data: {
organizationId,
model,
entityId: String(item.row.id),
label: rowLabel(item.row),
snapshot: item as unknown as Prisma.InputJsonValue,
deletedById,
},
});
}
}
async function snapshotDelete(
client: PrismaClient,
model: string,
action: 'delete' | 'deleteMany',
where: Record<string, unknown> | undefined,
) {
if (!where || model === 'Organization' || SKIP_MODELS.has(model) || deletionActor()?.skip) {
return [];
}
const rows =
action === 'delete'
? [await delegate(client, model).findFirst({ where })].filter(
(row): row is Record<string, unknown> => !!row,
)
: await delegate(client, model).findMany({ where });
if (!rows.length) return [];
return Promise.all(rows.map((row) => captureRow(client, model, row, 0)));
}
export function withDeletionArchive<T extends PrismaClient>(base: T) {
return base.$extends({
query: {
$allModels: {
async delete({ model, args, query }) {
const captured = await snapshotDelete(
base,
model,
'delete',
args.where as Record<string, unknown> | undefined,
).catch((error) => {
console.error('deletion archive snapshot failed', error);
return [] as Captured[];
});
const result = await query(args);
if (captured.length) {
await writeArchives(base, model, captured).catch((error) => {
console.error('deletion archive write failed', error);
});
}
return result;
},
async deleteMany({ model, args, query }) {
const captured = await snapshotDelete(
base,
model,
'deleteMany',
args.where as Record<string, unknown> | undefined,
).catch((error) => {
console.error('deletion archive snapshot failed', error);
return [] as Captured[];
});
const result = await query(args);
if (captured.length) {
await writeArchives(base, model, captured).catch((error) => {
console.error('deletion archive write failed', error);
});
}
return result;
},
},
},
});
}
export async function restoreArchive(
client: PrismaClient,
snapshot: Captured,
model: string,
) {
await skipDeletionArchive(async () => {
await restoreNode(client, model, snapshot);
});
}
async function restoreNode(client: PrismaClient, model: string, node: Captured) {
const existing = await delegate(client, model).findFirst({
where: { id: node.row.id },
select: { id: true },
});
if (existing) {
throw new Error('این مورد هنوز در سامانه وجود دارد.');
}
try {
await delegate(client, model).create({ data: node.row });
} catch (error) {
const code = (error as { code?: string }).code;
if (code === 'P2002') {
throw new Error('این مورد یا یک مقدار یکتای آن از قبل وجود دارد.');
}
if (code === 'P2003') {
throw new Error('وابستگی این مورد هنوز وجود ندارد. اول مورد اصلی را برگردانید.');
}
throw error;
}
for (const child of node.children) {
await restoreNode(client, child.model, child.item);
}
for (const link of node.relink) {
if (!link.ids.length || SKIP_MODELS.has(link.model)) continue;
await delegate(client, link.model).updateMany({
where: { id: { in: link.ids } },
data: { [link.field]: node.row.id },
});
}
}
@@ -0,0 +1,25 @@
import { AsyncLocalStorage } from 'async_hooks';
export type DeletionActor = {
userId: string | null;
organizationId: string | null;
skip?: boolean;
};
export const deletionContext = new AsyncLocalStorage<DeletionActor>();
export function deletionActor(): DeletionActor | undefined {
return deletionContext.getStore();
}
export function skipDeletionArchive<T>(fn: () => Promise<T>): Promise<T> {
const current = deletionContext.getStore();
return deletionContext.run(
{
userId: current?.userId ?? null,
organizationId: current?.organizationId ?? null,
skip: true,
},
fn,
);
}
@@ -0,0 +1,89 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { DeletionsService } from './deletions.service';
class ListDeletionsQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
class RetentionDto {
@IsIn(['month', 'two_months', 'three_months', 'six_months', 'year', 'never'])
retention!: string;
}
@Controller('deletions')
@UseGuards(JwtAuthGuard, RolesGuard)
export class DeletionsController {
constructor(private readonly deletions: DeletionsService) {}
@Get()
@Roles('owner', 'admin')
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListDeletionsQueryDto,
) {
return this.deletions.list(
user.organizationId,
query.page,
query.limit,
query.q,
);
}
@Patch('settings')
@Roles('owner', 'admin')
updateSettings(
@CurrentUser() user: { organizationId: string },
@Body() dto: RetentionDto,
) {
return this.deletions.updateRetention(user.organizationId, dto.retention);
}
@Post(':id/restore')
@Roles('owner', 'admin')
restore(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.deletions.restore(user.organizationId, id);
}
@Delete(':id')
@Roles('owner', 'admin')
purge(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.deletions.purge(user.organizationId, id);
}
}
@@ -0,0 +1,30 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { deletionContext } from './deletion-context';
type Authed = {
user?: { userId?: string; organizationId?: string };
};
@Injectable()
export class DeletionsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Authed>();
const user = req.user;
return new Observable((subscriber) => {
const subscription = deletionContext.run(
{
userId: user?.userId ?? null,
organizationId: user?.organizationId ?? null,
},
() => next.handle().subscribe(subscriber),
);
return () => subscription.unsubscribe();
});
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { DeletionsController } from './deletions.controller';
import { DeletionsInterceptor } from './deletions.interceptor';
import { DeletionsService } from './deletions.service';
@Module({
controllers: [DeletionsController],
providers: [
DeletionsService,
{
provide: APP_INTERCEPTOR,
useClass: DeletionsInterceptor,
},
],
})
export class DeletionsModule {}
+146
View File
@@ -0,0 +1,146 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import type { DeletionRetention } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { modelLabel, restoreArchive } from './deletion-archive';
const RETENTIONS = new Set<DeletionRetention>([
'month',
'two_months',
'three_months',
'six_months',
'year',
'never',
]);
const RETENTION_DAYS: Record<DeletionRetention, number | null> = {
month: 30,
two_months: 60,
three_months: 90,
six_months: 180,
year: 365,
never: null,
};
@Injectable()
export class DeletionsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string, page = 1, limit = 30, q?: string) {
const take = Math.min(100, Math.max(1, limit));
const skip = (Math.max(1, page) - 1) * take;
const query = q?.trim();
const where = {
organizationId,
...(query
? {
OR: [
{ label: { contains: query, mode: 'insensitive' as const } },
{ model: { contains: query, mode: 'insensitive' as const } },
],
}
: {}),
};
const [rows, total, org] = await Promise.all([
this.prisma.deletionArchive.findMany({
where,
include: { deletedBy: { select: { id: true, fullName: true } } },
orderBy: { deletedAt: 'desc' },
skip,
take,
}),
this.prisma.deletionArchive.count({ where }),
this.prisma.organization.findUnique({
where: { id: organizationId },
select: { deletionRetention: true },
}),
]);
return {
items: rows.map((row) => ({
id: row.id,
model: row.model,
modelLabel: modelLabel(row.model),
entityId: row.entityId,
label: row.label,
deletedById: row.deletedById,
deletedByName: row.deletedBy?.fullName ?? null,
deletedAt: row.deletedAt.toISOString(),
})),
total,
page: Math.max(1, page),
limit: take,
retention: this.retentionOf(org?.deletionRetention),
};
}
async updateRetention(organizationId: string, retention: string) {
if (!RETENTIONS.has(retention as DeletionRetention)) {
throw new BadRequestException('مدت نگهداری معتبر نیست.');
}
await this.prisma.organization.update({
where: { id: organizationId },
data: { deletionRetention: retention },
});
return { retention: retention as DeletionRetention };
}
async restore(organizationId: string, id: string) {
const row = await this.owned(organizationId, id);
try {
await restoreArchive(
this.prisma,
row.snapshot as never,
row.model,
);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message === 'این مورد هنوز در سامانه وجود دارد.') {
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
}
throw new BadRequestException(message || 'بازگردانی ناموفق بود.');
}
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
return { ok: true };
}
async purge(organizationId: string, id: string) {
const row = await this.owned(organizationId, id);
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
return { ok: true };
}
@Cron('15 3 * * *', { timeZone: 'Asia/Tehran' })
async purgeExpired() {
const orgs = await this.prisma.organization.findMany({
select: { id: true, deletionRetention: true },
});
const now = Date.now();
for (const org of orgs) {
const days = RETENTION_DAYS[this.retentionOf(org.deletionRetention)];
if (!days) continue;
const cutoff = new Date(now - days * 24 * 60 * 60 * 1000);
await this.prisma.deletionArchive.deleteMany({
where: { organizationId: org.id, deletedAt: { lt: cutoff } },
});
}
}
private retentionOf(value: string | null | undefined): DeletionRetention {
if (value && RETENTIONS.has(value as DeletionRetention)) {
return value as DeletionRetention;
}
return 'never';
}
private async owned(organizationId: string, id: string) {
const row = await this.prisma.deletionArchive.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('این حذف در فهرست پیدا نشد.');
return row;
}
}
@@ -69,6 +69,15 @@ export class PipelinesController {
return this.pipelinesService.merge(user.organizationId, pipelineId, dto);
}
@Delete(':pipelineId')
@Roles('owner', 'admin')
remove(
@CurrentUser() user: { organizationId: string },
@Param('pipelineId') pipelineId: string,
) {
return this.pipelinesService.remove(user.organizationId, pipelineId);
}
@Post(':pipelineId/stages')
@Roles('owner', 'admin')
createStage(
+35 -1
View File
@@ -107,7 +107,18 @@ export class PipelinesService {
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toPipelineDto(row));
const counts = await this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId },
_count: { _all: true },
});
const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]),
);
return rows.map((row) => ({
...this.toPipelineDto(row),
dealCount: dealCountByPipeline.get(row.id) ?? 0,
}));
}
async create(
@@ -192,6 +203,29 @@ export class PipelinesService {
return this.getOwnedPipelineDto(organizationId, pipeline.id);
}
async remove(organizationId: string, pipelineId: string): Promise<void> {
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
await this.prisma.$transaction(async (tx) => {
await tx.deal.deleteMany({
where: { organizationId, pipelineId: pipeline.id },
});
if (pipeline.isDefault) {
const next = await tx.pipeline.findFirst({
where: { organizationId, id: { not: pipeline.id } },
orderBy: { createdAt: 'asc' },
select: { id: true },
});
if (next) {
await tx.pipeline.update({
where: { id: next.id },
data: { isDefault: true },
});
}
}
await tx.pipeline.delete({ where: { id: pipeline.id } });
});
}
async deleteStage(
organizationId: string,
pipelineId: string,
+25 -4
View File
@@ -1,13 +1,34 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { withDeletionArchive } from '../deletions/deletion-archive';
const base = new PrismaClient();
const client = withDeletionArchive(base);
export interface PrismaService extends PrismaClient {}
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
export class PrismaService implements OnModuleInit, OnModuleDestroy {
constructor() {
return new Proxy(this, {
get: (target, prop, receiver) => {
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
const value = (client as unknown as Record<PropertyKey, unknown>)[prop];
if (typeof value === 'function') {
return (value as (...args: unknown[]) => unknown).bind(client);
}
return value;
},
});
}
async onModuleInit() {
await this.$connect();
await client.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
await client.$disconnect();
}
}
export type PrismaServiceClient = typeof client;