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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user