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:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user