Add Didar gap features: lost-reason reports, audit logs, tags, card assignees, and saved-views API.

Ignore Graphify local artifacts and expose new modules in superadmin.
This commit is contained in:
2026-09-26 14:59:48 +03:30
parent 35a618d0af
commit 6463275c3d
30 changed files with 1406 additions and 21 deletions
@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { SavedViewsService } from './saved-views.service';
const SCOPES = [
'deals',
'people',
'companies',
'activities',
'project_cards',
] as const;
class CreateSavedViewDto {
@IsString()
@MaxLength(120)
name!: string;
@IsIn(SCOPES)
scope!: (typeof SCOPES)[number];
@IsObject()
filters!: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isShared?: boolean;
}
class UpdateSavedViewDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsIn(SCOPES)
scope?: (typeof SCOPES)[number];
@IsOptional()
@IsObject()
filters?: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isShared?: boolean;
}
@Controller('saved-views')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('saved_views')
export class SavedViewsController {
constructor(private readonly savedViews: SavedViewsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string; userId: string },
@Query('scope') scope?: string,
) {
return this.savedViews.list(user.organizationId, user.userId, scope);
}
@Post()
create(
@CurrentUser() user: { organizationId: string; userId: string },
@Body() dto: CreateSavedViewDto,
) {
return this.savedViews.create(user.organizationId, user.userId, dto);
}
@Patch(':id')
update(
@CurrentUser()
user: { organizationId: string; userId: string; role: string },
@Param('id') id: string,
@Body() dto: UpdateSavedViewDto,
) {
const isAdmin = user.role === 'owner' || user.role === 'admin';
return this.savedViews.update(
user.organizationId,
user.userId,
id,
dto,
isAdmin,
);
}
@Delete(':id')
async remove(
@CurrentUser()
user: { organizationId: string; userId: string; role: string },
@Param('id') id: string,
) {
const isAdmin = user.role === 'owner' || user.role === 'admin';
await this.savedViews.remove(
user.organizationId,
user.userId,
id,
isAdmin,
);
return { ok: true };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { SavedViewsController } from './saved-views.controller';
import { SavedViewsService } from './saved-views.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [SavedViewsController],
providers: [SavedViewsService],
exports: [SavedViewsService],
})
export class SavedViewsModule {}
@@ -0,0 +1,148 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateSavedViewRequest,
SavedViewDto,
SavedViewScope,
UpdateSavedViewRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const SCOPES: SavedViewScope[] = [
'deals',
'people',
'companies',
'activities',
'project_cards',
];
@Injectable()
export class SavedViewsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
userId: string,
scope?: string,
): Promise<SavedViewDto[]> {
const rows = await this.prisma.savedView.findMany({
where: {
organizationId,
...(scope ? { scope } : {}),
OR: [{ isShared: true }, { ownerUserId: userId }],
},
include: { owner: { select: { id: true, fullName: true } } },
orderBy: [{ updatedAt: 'desc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
userId: string,
dto: CreateSavedViewRequest,
): Promise<SavedViewDto> {
const scope = this.assertScope(dto.scope);
const name = dto.name?.trim();
if (!name) throw new BadRequestException('نام فیلتر الزامی است');
const row = await this.prisma.savedView.create({
data: {
organizationId,
ownerUserId: userId,
name,
scope,
filters: (dto.filters ?? {}) as Prisma.InputJsonValue,
isShared: Boolean(dto.isShared),
},
include: { owner: { select: { id: true, fullName: true } } },
});
return this.toDto(row);
}
async update(
organizationId: string,
userId: string,
id: string,
dto: UpdateSavedViewRequest,
isAdmin: boolean,
): Promise<SavedViewDto> {
const existing = await this.prisma.savedView.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
if (!isAdmin && existing.ownerUserId !== userId) {
throw new BadRequestException('فقط مالک فیلتر می‌تواند آن را ویرایش کند');
}
const data: Prisma.SavedViewUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام فیلتر الزامی است');
data.name = name;
}
if (dto.scope !== undefined) data.scope = this.assertScope(dto.scope);
if (dto.filters !== undefined) {
data.filters = dto.filters as Prisma.InputJsonValue;
}
if (dto.isShared !== undefined) data.isShared = Boolean(dto.isShared);
const row = await this.prisma.savedView.update({
where: { id },
data,
include: { owner: { select: { id: true, fullName: true } } },
});
return this.toDto(row);
}
async remove(
organizationId: string,
userId: string,
id: string,
isAdmin: boolean,
): Promise<void> {
const existing = await this.prisma.savedView.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
if (!isAdmin && existing.ownerUserId !== userId) {
throw new BadRequestException('فقط مالک فیلتر می‌تواند آن را حذف کند');
}
await this.prisma.savedView.delete({ where: { id } });
}
private assertScope(scope: string): SavedViewScope {
if (!SCOPES.includes(scope as SavedViewScope)) {
throw new BadRequestException('محدوده فیلتر معتبر نیست');
}
return scope as SavedViewScope;
}
private toDto(row: {
id: string;
name: string;
scope: string;
filters: unknown;
isShared: boolean;
createdAt: Date;
updatedAt: Date;
owner: { id: string; fullName: string } | null;
}): SavedViewDto {
return {
id: row.id,
name: row.name,
scope: row.scope as SavedViewScope,
filters:
row.filters && typeof row.filters === 'object'
? (row.filters as Record<string, unknown>)
: {},
isShared: row.isShared,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}