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;
+10 -1
View File
@@ -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 و وب‌هوک',
+6
View File
@@ -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',
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import type { DeletionArchiveListResponse, DeletionRetention } from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { UiButton, UiCard, UiEmptyState, UiModal, UiPageHeader } from '@/components/ui';
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
{ value: 'month', label: 'یک ماه' },
{ value: 'two_months', label: 'دو ماه' },
{ value: 'three_months', label: 'سه ماه' },
{ value: 'six_months', label: 'شش ماه' },
{ value: 'year', label: 'یک سال' },
{ value: 'never', label: 'هرگز' },
];
const loading = ref(false);
const savingRetention = ref(false);
const busyId = ref('');
const error = ref('');
const okMsg = ref('');
const q = ref('');
const page = ref(1);
const retention = ref<DeletionRetention>('never');
const data = ref<DeletionArchiveListResponse | null>(null);
const purgeId = ref<string | null>(null);
const totalPages = computed(() => {
if (!data.value) return 1;
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
});
const purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (!e.response || e.response.status === 404) return fallback;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
}
return fallback;
}
async function load(resetPage = false) {
if (resetPage) page.value = 1;
loading.value = true;
error.value = '';
try {
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
params: { q: q.value.trim() || undefined, page: page.value, limit: 30 },
});
data.value = res;
retention.value = res.retention;
} catch (e) {
error.value = apiError(e, 'بارگذاری لاگ حذف ناموفق بود.');
} finally {
loading.value = false;
}
}
async function saveRetention() {
savingRetention.value = true;
error.value = '';
okMsg.value = '';
try {
await api.patch('/deletions/settings', { retention: retention.value });
okMsg.value = 'مدت نگهداری ذخیره شد.';
} catch (e) {
error.value = apiError(e, 'ذخیره مدت نگهداری ناموفق بود.');
} finally {
savingRetention.value = false;
}
}
async function restore(id: string) {
busyId.value = id;
error.value = '';
okMsg.value = '';
try {
await api.post(`/deletions/${id}/restore`);
okMsg.value = 'مورد برگردانده شد.';
await load();
} catch (e) {
error.value = apiError(e, 'بازگردانی ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function confirmPurge() {
const id = purgeId.value;
if (!id) return;
busyId.value = id;
error.value = '';
okMsg.value = '';
try {
await api.delete(`/deletions/${id}`);
purgeId.value = null;
okMsg.value = 'این مورد برای همیشه پاک شد.';
await load();
} catch (e) {
error.value = apiError(e, 'حذف کامل ناموفق بود.');
} finally {
busyId.value = '';
}
}
function go(delta: number) {
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
void load();
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="page">
<UiPageHeader
title="لاگ حذفیات"
lead="هر حذف اینجا می‌ماند. می‌توانید برگردانید یا برای همیشه پاک کنید."
/>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<UiCard>
<div class="retention">
<label for="deletion-retention">حذف خودکار از این فهرست</label>
<select id="deletion-retention" v-model="retention">
<option v-for="option in RETENTION_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<UiButton size="sm" :loading="savingRetention" @click="saveRetention">ذخیره</UiButton>
</div>
<p class="hint">
تا پایان این مدت می‌توان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین می‌رود.
</p>
</UiCard>
<form class="search" @submit.prevent="load(true)">
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<UiEmptyState
v-else-if="!error && !data?.items.length"
title="حذفی ثبت نشده."
description="از این به بعد حذف‌ها با نام کاربر و زمان اینجا دیده می‌شوند."
/>
<div v-else-if="data?.items.length" class="list">
<article v-for="item in data.items" :key="item.id" class="row">
<div class="info">
<strong>{{ item.label }}</strong>
<span>{{ item.modelLabel }}</span>
<span>
{{ item.deletedByName || 'سیستم' }}
·
{{ formatJalaliDateTime(new Date(item.deletedAt)) }}
</span>
</div>
<div class="ops">
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item.id)">
بازگردانی
</UiButton>
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
حذف کامل
</UiButton>
</div>
</article>
</div>
<div v-if="data && data.total > data.limit" class="pager">
<UiButton size="sm" variant="ghost" :disabled="page <= 1" @click="go(-1)">قبلی</UiButton>
<span>{{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}</span>
<UiButton size="sm" variant="ghost" :disabled="page >= totalPages" @click="go(1)">بعدی</UiButton>
</div>
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
<p v-if="purgeItem" class="confirm">
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
</p>
<template #footer>
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.retention {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.retention label {
font-weight: 700;
}
.retention select,
.search input {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f172a;
font: inherit;
padding: 0.45rem 0.7rem;
min-width: 0;
}
.hint,
.muted {
margin: 0.55rem 0 0;
color: #64748b;
font-size: 0.85rem;
}
.search {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.search input {
flex: 1 1 12rem;
}
.list {
display: grid;
gap: 0.65rem;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.8rem 0.9rem;
background: #fff;
border: 1px solid #e7eef2;
border-radius: 14px;
min-width: 0;
}
.info {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.info strong,
.info span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info span {
color: #64748b;
font-size: 0.82rem;
}
.ops {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pager {
display: flex;
align-items: center;
gap: 0.6rem;
}
.confirm {
margin: 0;
line-height: 1.8;
}
.ok {
margin: 0;
color: #0f766e;
}
.error {
margin: 0;
color: #b91c1c;
}
</style>
@@ -20,6 +20,9 @@ const pipelines = ref<PipelineListItem[]>([]);
const name = ref('');
const mergeOpen = ref(false);
const mergeSourceId = ref<string | null>(null);
const deleteTarget = ref<PipelineListItem | null>(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);
>
ادغام
</UiButton>
<UiButton size="sm" variant="danger" @click="openDelete(pipeline)">
حذف
</UiButton>
</div>
</div>
</UiCard>
@@ -153,6 +214,25 @@ onMounted(load);
description="اولین مسیر فروش را اضافه کنید."
/>
<UiModal
:open="!!deleteTarget"
title="حذف کاریز"
@close="closeDelete"
>
<p v-if="deleteTarget" class="confirm">{{ deletePrompt(deleteTarget) }}</p>
<template #footer>
<UiButton variant="ghost" :disabled="deleting" @click="closeDelete">انصراف</UiButton>
<UiButton
variant="danger"
:loading="deleting"
:disabled="!deleteCountReady"
@click="confirmDelete"
>
حذف کاریز
</UiButton>
</template>
</UiModal>
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
<PipelineMergeWizard
:open="mergeOpen"
@@ -242,6 +322,12 @@ onMounted(load);
font-size: 0.85rem;
}
.confirm {
margin: 0;
line-height: 1.8;
color: #0f172a;
}
.ok {
color: #0f766e;
margin: 0;
+29
View File
@@ -930,6 +930,8 @@ export interface PipelineDto {
name: string;
isDefault: boolean;
stages: PipelineStageDto[];
/** تعداد معاملات؛ در فهرست کاریزها پر می‌شود */
dealCount?: number;
}
export interface DealDto {
@@ -4011,6 +4013,33 @@ export interface AuditLogListResponse {
limit: number;
}
export type DeletionRetention =
| 'month'
| 'two_months'
| 'three_months'
| 'six_months'
| 'year'
| 'never';
export interface DeletionArchiveDto {
id: string;
model: string;
modelLabel: string;
entityId: string;
label: string;
deletedById: string | null;
deletedByName: string | null;
deletedAt: string;
}
export interface DeletionArchiveListResponse {
items: DeletionArchiveDto[];
total: number;
page: number;
limit: number;
retention: DeletionRetention;
}
/* ——— فیلترهای ذخیره‌شده ——— */
export type SavedViewScope =