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