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
+6
View File
@@ -21,6 +21,9 @@ import { TicketsModule } from './tickets/tickets.module';
import { FaqModule } from './faq/faq.module';
import { PortalModule } from './portal/portal.module';
import { ReportsModule } from './reports/reports.module';
import { ProjectBoardsModule } from './project-boards/project-boards.module';
import { LoyaltyModule } from './loyalty/loyalty.module';
import { AutomationsModule } from './automations/automations.module';
@Module({
imports: [
@@ -49,6 +52,9 @@ import { ReportsModule } from './reports/reports.module';
FaqModule,
PortalModule,
ReportsModule,
ProjectBoardsModule,
LoyaltyModule,
AutomationsModule,
],
})
export class AppModule {}
@@ -0,0 +1,54 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
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 { CreateAutomationRuleDto, UpdateAutomationRuleDto } from './dto';
import { AutomationsService } from './automations.service';
@Controller('automations')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('owner', 'admin')
export class AutomationsController {
constructor(private readonly automations: AutomationsService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.automations.list(user.organizationId);
}
@Post()
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateAutomationRuleDto,
) {
return this.automations.create(user.organizationId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateAutomationRuleDto,
) {
return this.automations.update(user.organizationId, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.automations.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsController } from './automations.controller';
import { AutomationsService } from './automations.service';
@Module({
imports: [AuthModule, NotificationsModule, LoyaltyModule],
controllers: [AutomationsController],
providers: [AutomationsService],
exports: [AutomationsService],
})
export class AutomationsModule {}
@@ -0,0 +1,223 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
AutomationAction,
AutomationRuleConfig,
AutomationRuleDto,
AutomationTrigger,
CreateAutomationRuleRequest,
UpdateAutomationRuleRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service';
export type AutomationContext = {
organizationId: string;
entityType: 'deal' | 'ticket';
entityId: string;
title: string;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
ownerId?: string | null;
assigneeId?: string | null;
};
@Injectable()
export class AutomationsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly loyalty: LoyaltyService,
) {}
async list(organizationId: string): Promise<AutomationRuleDto[]> {
const rows = await this.prisma.automationRule.findMany({
where: { organizationId },
orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }],
});
return rows.map((r) => this.toDto(r));
}
async create(
organizationId: string,
dto: CreateAutomationRuleRequest,
): Promise<AutomationRuleDto> {
const row = await this.prisma.automationRule.create({
data: {
organizationId,
name: dto.name.trim(),
trigger: dto.trigger,
action: dto.action,
isActive: dto.isActive ?? true,
config: (dto.config ?? {}) as Prisma.InputJsonValue,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateAutomationRuleRequest,
): Promise<AutomationRuleDto> {
const existing = await this.prisma.automationRule.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
const row = await this.prisma.automationRule.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}),
...(dto.action !== undefined ? { action: dto.action } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.config !== undefined
? { config: dto.config as Prisma.InputJsonValue }
: {}),
},
});
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.automationRule.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
await this.prisma.automationRule.delete({ where: { id } });
}
async runTrigger(
trigger: AutomationTrigger,
ctx: AutomationContext,
): Promise<void> {
const rules = await this.prisma.automationRule.findMany({
where: {
organizationId: ctx.organizationId,
isActive: true,
trigger,
},
});
for (const rule of rules) {
try {
await this.execute(rule.action as AutomationAction, asConfig(rule.config), ctx);
} catch {
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
}
}
}
private async execute(
action: AutomationAction,
config: AutomationRuleConfig,
ctx: AutomationContext,
) {
const title =
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
if (action === 'create_activity') {
const assigneeId =
config.assigneeId?.trim() ||
ctx.assigneeId ||
ctx.ownerId ||
null;
await this.prisma.activity.create({
data: {
organizationId: ctx.organizationId,
type: (config.activityType as never) || 'task',
title,
description: config.body?.trim() || null,
personId: ctx.personId ?? null,
companyId: ctx.companyId ?? null,
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
assigneeId,
},
});
if (assigneeId) {
await this.notifications.create({
organizationId: ctx.organizationId,
userId: assigneeId,
type: 'automation',
title: 'فعالیت خودکار',
body: title,
link:
ctx.entityType === 'deal'
? `/deals/${ctx.entityId}`
: `/tickets/${ctx.entityId}`,
entityType: ctx.entityType,
entityId: ctx.entityId,
});
}
return;
}
if (action === 'create_notification') {
const userId =
config.notifyUserId?.trim() ||
ctx.assigneeId ||
ctx.ownerId ||
null;
if (!userId) return;
await this.notifications.create({
organizationId: ctx.organizationId,
userId,
type: 'automation',
title,
body: config.body?.trim() || ctx.title,
link:
ctx.entityType === 'deal'
? `/deals/${ctx.entityId}`
: `/tickets/${ctx.entityId}`,
entityType: ctx.entityType,
entityId: ctx.entityId,
});
return;
}
if (action === 'add_loyalty_points') {
const points = Math.max(1, Math.trunc(config.points ?? 100));
if (!ctx.personId) return;
await this.loyalty.addPointsByOrg(
ctx.organizationId,
ctx.personId,
points,
);
}
}
private toDto(row: {
id: string;
name: string;
isActive: boolean;
trigger: string;
action: string;
config: unknown;
createdAt: Date;
updatedAt: Date;
}): AutomationRuleDto {
return {
id: row.id,
name: row.name,
isActive: row.isActive,
trigger: row.trigger as AutomationTrigger,
action: row.action as AutomationAction,
config: asConfig(row.config),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function asConfig(raw: unknown): AutomationRuleConfig {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
return raw as AutomationRuleConfig;
}
function defaultTitle(action: AutomationAction, entityTitle: string) {
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
return entityTitle;
}
+97
View File
@@ -0,0 +1,97 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const TRIGGERS = ['deal_won', 'ticket_resolved'] as const;
const ACTIONS = [
'create_activity',
'create_notification',
'add_loyalty_points',
] as const;
export class CreateAutomationRuleDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsIn(TRIGGERS)
trigger!: (typeof TRIGGERS)[number];
@IsIn(ACTIONS)
action!: (typeof ACTIONS)[number];
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class UpdateAutomationRuleDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsIn(TRIGGERS)
trigger?: (typeof TRIGGERS)[number];
@IsOptional()
@IsIn(ACTIONS)
action?: (typeof ACTIONS)[number];
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class AutomationConfigDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
body?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
assigneeId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
notifyUserId?: string | null;
@IsOptional()
@IsIn(['call', 'meeting', 'task', 'note'])
activityType?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
points?: number;
}
+12
View File
@@ -27,6 +27,9 @@ import {
const personInclude = {
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
} satisfies Prisma.PersonInclude;
type PersonRow = {
@@ -39,10 +42,17 @@ type PersonRow = {
position: string | null;
notes: string | null;
customFields: unknown;
loyaltyPoints: number;
createdAt: Date;
updatedAt: Date;
company: { id: string; name: string } | null;
owner: { id: string; fullName: string } | null;
loyaltyTier: {
id: string;
name: string;
minPoints: number;
color: string | null;
} | null;
};
@Injectable()
@@ -229,6 +239,8 @@ export class PeopleService {
position: row.position,
notes: row.notes,
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
company: row.company,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
+60
View File
@@ -0,0 +1,60 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateLoyaltyTierDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@Type(() => Number)
@IsInt()
@Min(0)
minPoints!: number;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(32)
color?: string | null;
}
export class UpdateLoyaltyTierDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
minPoints?: number;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(32)
color?: string | null;
}
export class AdjustLoyaltyPointsDto {
@Type(() => Number)
@IsNumber()
delta!: number;
@IsOptional()
@IsString()
@MaxLength(200)
reason?: string;
}
@@ -0,0 +1,73 @@
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 type { Actor } from '../common/record-scope';
import {
AdjustLoyaltyPointsDto,
CreateLoyaltyTierDto,
UpdateLoyaltyTierDto,
} from './dto';
import { LoyaltyService } from './loyalty.service';
@Controller('loyalty')
@UseGuards(JwtAuthGuard, RolesGuard)
export class LoyaltyController {
constructor(private readonly loyalty: LoyaltyService) {}
@Get('tiers')
listTiers(@CurrentUser() user: Actor) {
return this.loyalty.listTiers(user.organizationId);
}
@Post('tiers')
@Roles('owner', 'admin')
createTier(
@CurrentUser() user: Actor,
@Body() dto: CreateLoyaltyTierDto,
) {
return this.loyalty.createTier(user.organizationId, dto);
}
@Patch('tiers/:id')
@Roles('owner', 'admin')
updateTier(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateLoyaltyTierDto,
) {
return this.loyalty.updateTier(user.organizationId, id, dto);
}
@Delete('tiers/:id')
@Roles('owner', 'admin')
async deleteTier(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.loyalty.deleteTier(user.organizationId, id);
return { ok: true };
}
@Get('members')
listMembers(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.loyalty.listMembers(user, q);
}
@Post('members/:personId/adjust')
adjust(
@CurrentUser() user: Actor,
@Param('personId') personId: string,
@Body() dto: AdjustLoyaltyPointsDto,
) {
return this.loyalty.adjustPoints(user, personId, dto.delta);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({
imports: [AuthModule],
controllers: [LoyaltyController],
providers: [LoyaltyService],
exports: [LoyaltyService],
})
export class LoyaltyModule {}
+266
View File
@@ -0,0 +1,266 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateLoyaltyTierRequest,
LoyaltyMemberDto,
LoyaltyTierDto,
UpdateLoyaltyTierRequest,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#b87333' },
{ name: 'نقره‌ای', minPoints: 500, color: '#9aa0a6' },
{ name: 'طلایی', minPoints: 2000, color: '#d4af37' },
] as const;
@Injectable()
export class LoyaltyService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaultTiers(organizationId: string): Promise<void> {
const count = await this.prisma.loyaltyTier.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.loyaltyTier.createMany({
data: DEFAULT_TIERS.map((t, position) => ({
organizationId,
name: t.name,
minPoints: t.minPoints,
color: t.color,
position,
})),
});
}
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
await this.ensureDefaultTiers(organizationId);
const rows = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: [{ minPoints: 'asc' }, { position: 'asc' }],
include: { _count: { select: { people: true } } },
});
return rows.map((r) => ({
id: r.id,
name: r.name,
minPoints: r.minPoints,
position: r.position,
color: r.color,
memberCount: r._count.people,
}));
}
async createTier(
organizationId: string,
dto: CreateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
await this.ensureDefaultTiers(organizationId);
const maxPos = await this.prisma.loyaltyTier.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.loyaltyTier.create({
data: {
organizationId,
name: dto.name.trim(),
minPoints: Math.max(0, Math.floor(dto.minPoints)),
color: emptyToNull(dto.color),
position: (maxPos._max.position ?? -1) + 1,
},
});
await this.recomputeAllTiers(organizationId);
return {
id: row.id,
name: row.name,
minPoints: row.minPoints,
position: row.position,
color: row.color,
memberCount: 0,
};
}
async updateTier(
organizationId: string,
id: string,
dto: UpdateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
const existing = await this.prisma.loyaltyTier.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('سطح پیدا نشد');
const row = await this.prisma.loyaltyTier.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.minPoints !== undefined
? { minPoints: Math.max(0, Math.floor(dto.minPoints)) }
: {}),
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
},
include: { _count: { select: { people: true } } },
});
await this.recomputeAllTiers(organizationId);
return {
id: row.id,
name: row.name,
minPoints: row.minPoints,
position: row.position,
color: row.color,
memberCount: row._count.people,
};
}
async deleteTier(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.loyaltyTier.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('سطح پیدا نشد');
await this.prisma.person.updateMany({
where: { organizationId, loyaltyTierId: id },
data: { loyaltyTierId: null },
});
await this.prisma.loyaltyTier.delete({ where: { id } });
await this.recomputeAllTiers(organizationId);
}
async listMembers(
actor: Actor,
q?: string,
): Promise<LoyaltyMemberDto[]> {
await this.ensureDefaultTiers(actor.organizationId);
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
],
}
: {}),
},
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
take: 200,
});
return rows.map((r) => ({
id: r.id,
fullName: `${r.firstName} ${r.lastName}`.trim(),
mobile: r.mobile,
email: r.email,
loyaltyPoints: r.loyaltyPoints,
loyaltyTier: r.loyaltyTier,
company: r.company,
}));
}
async adjustPoints(
actor: Actor,
personId: string,
delta: number,
): Promise<LoyaltyMemberDto> {
if (!Number.isFinite(delta) || delta === 0) {
throw new BadRequestException('مقدار امتیاز نامعتبر است');
}
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const next = Math.max(0, person.loyaltyPoints + Math.trunc(delta));
const tierId = await this.resolveTierId(actor.organizationId, next);
const updated = await this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
});
return {
id: updated.id,
fullName: `${updated.firstName} ${updated.lastName}`.trim(),
mobile: updated.mobile,
email: updated.email,
loyaltyPoints: updated.loyaltyPoints,
loyaltyTier: updated.loyaltyTier,
company: updated.company,
};
}
/** برای اتوماسیون — بدون ACL مالکیت */
async addPointsByOrg(
organizationId: string,
personId: string,
points: number,
): Promise<void> {
if (!personId || !points) return;
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) return;
const next = Math.max(0, person.loyaltyPoints + Math.trunc(points));
const tierId = await this.resolveTierId(organizationId, next);
await this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
});
}
async resolveTierId(
organizationId: string,
points: number,
): Promise<string | null> {
await this.ensureDefaultTiers(organizationId);
const tier = await this.prisma.loyaltyTier.findFirst({
where: { organizationId, minPoints: { lte: points } },
orderBy: { minPoints: 'desc' },
});
return tier?.id ?? null;
}
private async recomputeAllTiers(organizationId: string) {
const people = await this.prisma.person.findMany({
where: { organizationId },
select: { id: true, loyaltyPoints: true },
});
const tiers = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: { minPoints: 'desc' },
});
for (const p of people) {
const tier = tiers.find((t) => t.minPoints <= p.loyaltyPoints) ?? null;
await this.prisma.person.update({
where: { id: p.id },
data: { loyaltyTierId: tier?.id ?? null },
});
}
}
}
function emptyToNull(v?: string | null) {
if (v == null) return null;
const t = v.trim();
return t ? t : null;
}
+18
View File
@@ -15,6 +15,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { PipelinesService } from './pipelines.service';
import { toJsonCustomFields } from '../common/custom-fields';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import {
type Actor,
canReassignOwner,
@@ -37,6 +38,7 @@ export class DealsService {
private readonly prisma: PrismaService,
private readonly pipelines: PipelinesService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
) {}
async get(
@@ -240,6 +242,9 @@ export class DealsService {
pipelineId: true,
stageId: true,
ownerId: true,
status: true,
personId: true,
companyId: true,
},
});
if (!deal) throw new NotFoundException('معامله پیدا نشد');
@@ -285,6 +290,19 @@ export class DealsService {
});
}
if (status === 'won' && deal.status !== 'won') {
await this.automations.runTrigger('deal_won', {
organizationId,
entityType: 'deal',
entityId: deal.id,
title: deal.title,
personId: deal.personId,
companyId: deal.companyId,
dealId: deal.id,
ownerId: deal.ownerId,
});
}
return this.pipelines.toDealDto(row);
}
+3 -2
View File
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { DealsController } from './deals.controller';
import { DealsService } from './deals.service';
import { PipelinesController } from './pipelines.controller';
import { PipelinesService } from './pipelines.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AuthModule } from '../auth/auth.module';
import { AutomationsModule } from '../automations/automations.module';
@Module({
imports: [NotificationsModule, AuthModule],
imports: [NotificationsModule, AuthModule, forwardRef(() => AutomationsModule)],
controllers: [PipelinesController, DealsController],
providers: [PipelinesService, DealsService],
exports: [PipelinesService, DealsService],
+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;
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, forwardRef(() => AutomationsModule)],
controllers: [TicketsController],
providers: [TicketsService],
exports: [TicketsService],
+21
View File
@@ -20,6 +20,7 @@ import {
ticketAccessWhere,
} from '../common/record-scope';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -40,6 +41,7 @@ export class TicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
) {}
async list(
@@ -325,6 +327,25 @@ export class TicketsService {
}
await this.prisma.ticket.update({ where: { id }, data });
const becameResolved =
(dto.status === 'resolved' || dto.status === 'closed') &&
existing.status !== 'resolved' &&
existing.status !== 'closed';
if (becameResolved) {
await this.automations.runTrigger('ticket_resolved', {
organizationId: actor.organizationId,
entityType: 'ticket',
entityId: existing.id,
title: existing.subject,
personId: existing.personId,
companyId: existing.companyId,
dealId: existing.dealId,
assigneeId: existing.assigneeId,
ownerId: existing.assigneeId,
});
}
return this.get(actor, id);
}