Add project board, loyalty club, and if/then automations.

Separate modules for post-sales kanban, person points/tiers, and deal-won/ticket-resolved rules without overlapping the sales pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 09:42:07 +03:30
co-authored by Cursor
parent 2e6c4779ab
commit c2bfa7749d
29 changed files with 3126 additions and 9 deletions
+101
View File
@@ -0,0 +1,101 @@
import { Type } from 'class-transformer';
import {
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateProjectBoardDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
}
export class CreateProjectCardDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@IsString()
stageId?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class UpdateProjectCardDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class MoveProjectCardDto {
@IsString()
stageId!: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
position?: number;
}
@@ -0,0 +1,89 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 {
CreateProjectBoardDto,
CreateProjectCardDto,
MoveProjectCardDto,
UpdateProjectCardDto,
} from './dto';
import { ProjectBoardsService } from './project-boards.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller()
@UseGuards(JwtAuthGuard, RolesGuard)
export class ProjectBoardsController {
constructor(private readonly boards: ProjectBoardsService) {}
@Get('project-boards')
listBoards(@CurrentUser() user: AuthUser) {
return this.boards.list(user.organizationId);
}
@Get('project-boards/board')
board(
@CurrentUser() user: AuthUser,
@Query('boardId') boardId?: string,
) {
return this.boards.getBoard(user.organizationId, boardId, user);
}
@Post('project-boards')
@Roles('owner', 'admin')
createBoard(
@CurrentUser() user: AuthUser,
@Body() dto: CreateProjectBoardDto,
) {
return this.boards.create(user.organizationId, dto.name);
}
@Get('project-cards/:id')
getCard(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.boards.getCard(user.organizationId, id, user);
}
@Post('project-cards')
createCard(
@CurrentUser() user: AuthUser,
@Body() dto: CreateProjectCardDto,
) {
return this.boards.createCard(user.organizationId, user, dto);
}
@Patch('project-cards/:id')
updateCard(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateProjectCardDto,
) {
return this.boards.updateCard(user.organizationId, id, user, dto);
}
@Patch('project-cards/:id/move')
moveCard(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: MoveProjectCardDto,
) {
return this.boards.moveCard(user.organizationId, id, user, dto);
}
@Delete('project-cards/:id')
async removeCard(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.boards.removeCard(user.organizationId, id, user);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectBoardsController } from './project-boards.controller';
import { ProjectBoardsService } from './project-boards.service';
@Module({
imports: [AuthModule],
controllers: [ProjectBoardsController],
providers: [ProjectBoardsService],
exports: [ProjectBoardsService],
})
export class ProjectBoardsModule {}
@@ -0,0 +1,390 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateProjectCardRequest,
MoveProjectCardRequest,
ProjectBoardBoardDto,
ProjectBoardDto,
ProjectCardDto,
UpdateProjectCardRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجام‌شده'] as const;
const cardInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
owner: { select: { id: true, fullName: true } },
} satisfies Prisma.ProjectCardInclude;
type CardActor = Pick<Actor, 'userId' | 'role'>;
@Injectable()
export class ProjectBoardsService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaultBoard(organizationId: string): Promise<ProjectBoardDto> {
const existing = await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
});
if (existing) return this.toBoardDto(existing);
const created = await this.prisma.projectBoard.create({
data: {
organizationId,
name: 'پروژه داخلی',
isDefault: true,
stages: {
create: DEFAULT_STAGES.map((name, position) => ({ name, position })),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toBoardDto(created);
}
async list(organizationId: string): Promise<ProjectBoardDto[]> {
await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.projectBoard.findMany({
where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toBoardDto(row));
}
async create(organizationId: string, name: string): Promise<ProjectBoardDto> {
await this.ensureDefaultBoard(organizationId);
const created = await this.prisma.projectBoard.create({
data: {
organizationId,
name: name.trim(),
isDefault: false,
stages: {
create: DEFAULT_STAGES.map((n, position) => ({ name: n, position })),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toBoardDto(created);
}
async getBoard(
organizationId: string,
boardId?: string,
actor?: CardActor,
): Promise<ProjectBoardBoardDto> {
const board = boardId
? await this.prisma.projectBoard.findFirst({
where: { id: boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
board ??
(await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
const created = await this.ensureDefaultBoard(organizationId);
return this.getBoard(organizationId, created.id, actor);
}
const cards = await this.prisma.projectCard.findMany({
where: {
organizationId,
boardId: resolved.id,
...(actor ? ownerWhere(actor) : {}),
},
include: cardInclude,
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const byStage = new Map<string, ProjectCardDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const card of cards) {
const list = byStage.get(card.stageId);
if (list) list.push(this.toCardDto(card));
}
return {
board: this.toBoardDto(resolved),
stages: resolved.stages.map((stage) => ({
id: stage.id,
name: stage.name,
position: stage.position,
cards: byStage.get(stage.id) ?? [],
})),
};
}
async getCard(
organizationId: string,
id: string,
actor: CardActor,
): Promise<ProjectCardDto> {
const row = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
include: cardInclude,
});
if (!row) throw new NotFoundException('کارت پیدا نشد');
return this.toCardDto(row);
}
async createCard(
organizationId: string,
actor: CardActor,
dto: CreateProjectCardRequest,
): Promise<ProjectCardDto> {
const board =
dto.boardId != null
? await this.prisma.projectBoard.findFirst({
where: { id: dto.boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
board ??
(await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
await this.ensureDefaultBoard(organizationId);
return this.createCard(organizationId, actor, dto);
}
const stage =
(dto.stageId
? resolved.stages.find((s) => s.id === dto.stageId)
: resolved.stages[0]) ?? resolved.stages[0];
if (!stage) throw new BadRequestException('برد مرحله‌ای ندارد');
await this.assertLinks(
organizationId,
actor,
dto.personId,
dto.companyId,
dto.dealId,
);
const maxPos = await this.prisma.projectCard.aggregate({
where: { organizationId, stageId: stage.id },
_max: { position: true },
});
const created = await this.prisma.projectCard.create({
data: {
organizationId,
boardId: resolved.id,
stageId: stage.id,
title: dto.title.trim(),
notes: emptyToNull(dto.notes),
personId: emptyToNull(dto.personId),
companyId: emptyToNull(dto.companyId),
dealId: emptyToNull(dto.dealId),
ownerId: resolveOwnerId(actor, dto.ownerId),
position: (maxPos._max.position ?? -1) + 1,
},
include: cardInclude,
});
return this.toCardDto(created);
}
async updateCard(
organizationId: string,
id: string,
actor: CardActor,
dto: UpdateProjectCardRequest,
): Promise<ProjectCardDto> {
const existing = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
});
if (!existing) throw new NotFoundException('کارت پیدا نشد');
if ('personId' in dto || 'companyId' in dto || 'dealId' in dto) {
await this.assertLinks(
organizationId,
actor,
dto.personId ?? existing.personId,
dto.companyId ?? existing.companyId,
dto.dealId ?? existing.dealId,
);
}
const data: Prisma.ProjectCardUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('ownerId' in dto) {
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
throw new BadRequestException('تغییر مسئول مجاز نیست');
}
data.ownerId = emptyToNull(dto.ownerId ?? null);
}
const row = await this.prisma.projectCard.update({
where: { id },
data,
include: cardInclude,
});
return this.toCardDto(row);
}
async moveCard(
organizationId: string,
id: string,
actor: CardActor,
dto: MoveProjectCardRequest,
): Promise<ProjectCardDto> {
const card = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true, boardId: true },
});
if (!card) throw new NotFoundException('کارت پیدا نشد');
const stage = await this.prisma.projectBoardStage.findFirst({
where: { id: dto.stageId, boardId: card.boardId },
});
if (!stage) throw new BadRequestException('مرحله در این برد معتبر نیست');
const position =
dto.position ??
((
await this.prisma.projectCard.aggregate({
where: { organizationId, stageId: stage.id, NOT: { id } },
_max: { position: true },
})
)._max.position ?? -1) + 1;
const row = await this.prisma.projectCard.update({
where: { id },
data: { stageId: stage.id, position },
include: cardInclude,
});
return this.toCardDto(row);
}
async removeCard(
organizationId: string,
id: string,
actor: CardActor,
): Promise<void> {
const found = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('کارت پیدا نشد');
await this.prisma.projectCard.delete({ where: { id } });
}
toCardDto(row: {
id: string;
title: string;
notes: string | null;
position: number;
boardId: string;
stageId: string;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
owner: { id: string; fullName: string } | null;
}): ProjectCardDto {
return {
id: row.id,
title: row.title,
notes: row.notes,
position: row.position,
boardId: row.boardId,
stageId: row.stageId,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toBoardDto(row: {
id: string;
name: string;
isDefault: boolean;
stages: Array<{ id: string; name: string; position: number }>;
}): ProjectBoardDto {
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
stages: row.stages.map((s) => ({
id: s.id,
name: s.name,
position: s.position,
})),
};
}
private async assertLinks(
organizationId: string,
actor: CardActor,
personId?: string | null,
companyId?: string | null,
dealId?: string | null,
) {
if (personId) {
const p = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!p) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const c = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!c) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const d = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!d) throw new BadRequestException('معامله معتبر نیست');
}
}
}
function emptyToNull(v?: string | null) {
if (v == null) return null;
const t = v.trim();
return t ? t : null;
}