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
@@ -0,0 +1,20 @@
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
CREATE TABLE "DeletionArchive" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"model" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"snapshot" JSONB NOT NULL,
"deletedById" TEXT,
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+22
View File
@@ -235,6 +235,9 @@ model Organization {
gamificationBadges GamificationBadge[]
gamificationUserBadges GamificationUserBadge[]
gamificationBonuses GamificationBonus[]
/// month | two_months | three_months | six_months | year | never
deletionRetention String @default("never")
deletionArchives DeletionArchive[]
}
model User {
@@ -294,6 +297,7 @@ model User {
createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator")
routineTaskEvents RoutineTaskEvent[]
routineSettings RoutineUserSettings[]
deletionArchives DeletionArchive[] @relation("DeletionActor")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1859,6 +1863,24 @@ model AuditLog {
@@index([organizationId, entityType, createdAt])
}
/// نسخهٔ قابل بازگشتِ رکوردهای حذف‌شده
model DeletionArchive {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// نام مدل پریزما
model String
entityId String
label String
snapshot Json
deletedById String?
deletedBy User? @relation("DeletionActor", fields: [deletedById], references: [id], onDelete: SetNull)
deletedAt DateTime @default(now())
@@index([organizationId, deletedAt])
@@index([organizationId, model])
}
/// فیلترهای ذخیره‌شده برای لیست‌ها و مسیرهای فروش
/// ستون‌های مخفی هر جدول برای هر کاربر (به‌ازای هر سازمان)
model UserTablePreference {
+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;