From 2cc81bb596c6c0911fa876e11d80f6c68b914412 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 13 Oct 2026 22:23:09 +0330 Subject: [PATCH] feat: keep deletions restorable and confirm a pipeline's deals before it is removed Co-authored-by: Cursor --- .../migration.sql | 20 ++ apps/api/prisma/schema.prisma | 22 ++ apps/api/src/app.module.ts | 2 + apps/api/src/deletions/deletion-archive.ts | 302 +++++++++++++++++ apps/api/src/deletions/deletion-context.ts | 25 ++ .../api/src/deletions/deletions.controller.ts | 89 +++++ .../src/deletions/deletions.interceptor.ts | 30 ++ apps/api/src/deletions/deletions.module.ts | 17 + apps/api/src/deletions/deletions.service.ts | 146 +++++++++ .../api/src/pipelines/pipelines.controller.ts | 9 + apps/api/src/pipelines/pipelines.service.ts | 36 ++- apps/api/src/prisma/prisma.service.ts | 29 +- apps/web/src/lib/nav-hubs.ts | 11 +- apps/web/src/router/index.ts | 6 + apps/web/src/views/DeletionArchivesView.vue | 305 ++++++++++++++++++ apps/web/src/views/PipelineSettingsView.vue | 86 +++++ packages/shared/src/index.ts | 29 ++ 17 files changed, 1158 insertions(+), 6 deletions(-) create mode 100644 apps/api/prisma/migrations/20261013210000_deletion_archive/migration.sql create mode 100644 apps/api/src/deletions/deletion-archive.ts create mode 100644 apps/api/src/deletions/deletion-context.ts create mode 100644 apps/api/src/deletions/deletions.controller.ts create mode 100644 apps/api/src/deletions/deletions.interceptor.ts create mode 100644 apps/api/src/deletions/deletions.module.ts create mode 100644 apps/api/src/deletions/deletions.service.ts create mode 100644 apps/web/src/views/DeletionArchivesView.vue diff --git a/apps/api/prisma/migrations/20261013210000_deletion_archive/migration.sql b/apps/api/prisma/migrations/20261013210000_deletion_archive/migration.sql new file mode 100644 index 0000000..8aa8a6c --- /dev/null +++ b/apps/api/prisma/migrations/20261013210000_deletion_archive/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 46bf01b..6122173 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f29fe66..b66bd0d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/deletions/deletion-archive.ts b/apps/api/src/deletions/deletion-archive.ts new file mode 100644 index 0000000..b748b86 --- /dev/null +++ b/apps/api/src/deletions/deletion-archive.ts @@ -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 = { + 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; + 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 Promise>>; + findFirst: (args: unknown) => Promise | null>; + create: (args: unknown) => Promise; + updateMany: (args: unknown) => Promise; + deleteMany: (args: unknown) => Promise; + }>)[delegateName(model)]; +} + +function plain(value: unknown): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} + +export function modelLabel(model: string) { + return MODEL_LABELS[model] ?? model; +} + +export function rowLabel(row: Record) { + 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 { + 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, + depth: number, +): Promise { + 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 | 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 => !!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(base: T) { + return base.$extends({ + query: { + $allModels: { + async delete({ model, args, query }) { + const captured = await snapshotDelete( + base, + model, + 'delete', + args.where as Record | 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 | 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 }, + }); + } +} diff --git a/apps/api/src/deletions/deletion-context.ts b/apps/api/src/deletions/deletion-context.ts new file mode 100644 index 0000000..9db1f30 --- /dev/null +++ b/apps/api/src/deletions/deletion-context.ts @@ -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(); + +export function deletionActor(): DeletionActor | undefined { + return deletionContext.getStore(); +} + +export function skipDeletionArchive(fn: () => Promise): Promise { + const current = deletionContext.getStore(); + return deletionContext.run( + { + userId: current?.userId ?? null, + organizationId: current?.organizationId ?? null, + skip: true, + }, + fn, + ); +} diff --git a/apps/api/src/deletions/deletions.controller.ts b/apps/api/src/deletions/deletions.controller.ts new file mode 100644 index 0000000..2683be4 --- /dev/null +++ b/apps/api/src/deletions/deletions.controller.ts @@ -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); + } +} diff --git a/apps/api/src/deletions/deletions.interceptor.ts b/apps/api/src/deletions/deletions.interceptor.ts new file mode 100644 index 0000000..8f3e72d --- /dev/null +++ b/apps/api/src/deletions/deletions.interceptor.ts @@ -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 { + const req = context.switchToHttp().getRequest(); + 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(); + }); + } +} diff --git a/apps/api/src/deletions/deletions.module.ts b/apps/api/src/deletions/deletions.module.ts new file mode 100644 index 0000000..40b74a6 --- /dev/null +++ b/apps/api/src/deletions/deletions.module.ts @@ -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 {} diff --git a/apps/api/src/deletions/deletions.service.ts b/apps/api/src/deletions/deletions.service.ts new file mode 100644 index 0000000..f6751e9 --- /dev/null +++ b/apps/api/src/deletions/deletions.service.ts @@ -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([ + 'month', + 'two_months', + 'three_months', + 'six_months', + 'year', + 'never', +]); + +const RETENTION_DAYS: Record = { + 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; + } +} diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index bd0d33b..bf395cd 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -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( diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index 572b4fc..521d609 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -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 { + 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, diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index bb6565f..b51c4ee 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -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)[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; diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index 7a2cb7a..d11a609 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -36,7 +36,8 @@ export type NavIconName = | 'plus' | 'user-plus' | 'mail' - | 'phone'; + | 'phone' + | 'trash'; export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber'; @@ -437,6 +438,14 @@ export const NAV_HUBS: NavHubDef[] = [ module: 'audit_logs', admin: true, }, + { + to: '/settings/deletions', + title: 'لاگ حذفیات', + desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل', + icon: 'trash', + tint: 'rose', + admin: true, + }, { to: '/settings/public-api', title: 'API و وب‌هوک', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 9acaeee..d29d09b 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -361,6 +361,12 @@ export const router = createRouter({ component: () => import('@/views/AuditLogsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'settings/deletions', + name: 'deletion-archives', + component: () => import('@/views/DeletionArchivesView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'settings/public-api', name: 'public-api', diff --git a/apps/web/src/views/DeletionArchivesView.vue b/apps/web/src/views/DeletionArchivesView.vue new file mode 100644 index 0000000..ebe8bb2 --- /dev/null +++ b/apps/web/src/views/DeletionArchivesView.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/apps/web/src/views/PipelineSettingsView.vue b/apps/web/src/views/PipelineSettingsView.vue index 856f983..60bf0b0 100644 --- a/apps/web/src/views/PipelineSettingsView.vue +++ b/apps/web/src/views/PipelineSettingsView.vue @@ -20,6 +20,9 @@ const pipelines = ref([]); const name = ref(''); const mergeOpen = ref(false); const mergeSourceId = ref(null); +const deleteTarget = ref(null); +const deleteCountReady = ref(false); +const deleting = ref(false); const canMerge = computed(() => pipelines.value.length >= 2); @@ -67,6 +70,61 @@ async function createPipeline() { } } +function deletePrompt(pipeline: PipelineListItem) { + if (!deleteCountReady.value) return 'در حال شمارش معاملات این مسیر…'; + const count = pipeline.dealCount ?? 0; + const name = pipeline.name; + if (count > 0) { + return `مسیر «${name}» شامل ${toFaDigits(count)} معامله است. با حذف، این معاملات هم پاک می‌شوند. از حذف مطمئن هستید؟`; + } + return `مسیر «${name}» معامله‌ای ندارد. از حذف آن مطمئن هستید؟`; +} + +async function openDelete(pipeline: PipelineListItem) { + deleteTarget.value = pipeline; + deleteCountReady.value = pipeline.dealCount != null; + if (deleteCountReady.value) return; + try { + const { data } = await api.get<{ dealCount: number }>( + `/pipelines/${pipeline.id}/merge-preview`, + ); + if (deleteTarget.value?.id !== pipeline.id) return; + deleteTarget.value = { ...pipeline, dealCount: data.dealCount }; + } catch (e) { + if (deleteTarget.value?.id === pipeline.id) { + error.value = apiError(e, 'شمارش معاملات این مسیر ناموفق بود.'); + deleteTarget.value = null; + } + return; + } finally { + if (deleteTarget.value?.id === pipeline.id) deleteCountReady.value = true; + } +} + +function closeDelete() { + if (deleting.value) return; + deleteTarget.value = null; + deleteCountReady.value = false; +} + +async function confirmDelete() { + const pipeline = deleteTarget.value; + if (!pipeline || deleting.value) return; + deleting.value = true; + error.value = ''; + okMsg.value = ''; + try { + await api.delete(`/pipelines/${pipeline.id}`); + okMsg.value = 'کاریز حذف شد.'; + deleteTarget.value = null; + await load(); + } catch (e) { + error.value = apiError(e, 'حذف کاریز ناموفق بود.'); + } finally { + deleting.value = false; + } +} + function openMerge(pipeline: PipelineListItem) { if (!canMerge.value) return; mergeSourceId.value = pipeline.id; @@ -142,6 +200,9 @@ onMounted(load); > ادغام + + حذف + @@ -153,6 +214,25 @@ onMounted(load); description="اولین مسیر فروش را اضافه کنید." /> + +

{{ deletePrompt(deleteTarget) }}

+ +
+