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
@@ -0,0 +1,112 @@
-- CreateEnum
CREATE TYPE "AutomationTrigger" AS ENUM ('deal_won', 'ticket_resolved');
-- CreateEnum
CREATE TYPE "AutomationAction" AS ENUM ('create_activity', 'create_notification', 'add_loyalty_points');
-- CreateTable
CREATE TABLE "ProjectBoard" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectBoard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectBoardStage" (
"id" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectBoardStage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectCard" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"stageId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"notes" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"personId" TEXT,
"companyId" TEXT,
"dealId" TEXT,
"ownerId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectCard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyTier" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"minPoints" INTEGER NOT NULL DEFAULT 0,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyTier_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AutomationRule" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"trigger" "AutomationTrigger" NOT NULL,
"action" "AutomationAction" NOT NULL,
"config" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationRule_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "loyaltyPoints" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Person" ADD COLUMN "loyaltyTierId" TEXT;
-- CreateIndex
CREATE INDEX "ProjectBoard_organizationId_idx" ON "ProjectBoard"("organizationId");
CREATE INDEX "ProjectBoard_organizationId_isDefault_idx" ON "ProjectBoard"("organizationId", "isDefault");
CREATE INDEX "ProjectBoardStage_boardId_position_idx" ON "ProjectBoardStage"("boardId", "position");
CREATE INDEX "ProjectCard_organizationId_idx" ON "ProjectCard"("organizationId");
CREATE INDEX "ProjectCard_boardId_stageId_idx" ON "ProjectCard"("boardId", "stageId");
CREATE INDEX "ProjectCard_stageId_position_idx" ON "ProjectCard"("stageId", "position");
CREATE INDEX "ProjectCard_personId_idx" ON "ProjectCard"("personId");
CREATE INDEX "ProjectCard_companyId_idx" ON "ProjectCard"("companyId");
CREATE INDEX "ProjectCard_dealId_idx" ON "ProjectCard"("dealId");
CREATE INDEX "ProjectCard_ownerId_idx" ON "ProjectCard"("ownerId");
CREATE INDEX "LoyaltyTier_organizationId_position_idx" ON "LoyaltyTier"("organizationId", "position");
CREATE INDEX "LoyaltyTier_organizationId_minPoints_idx" ON "LoyaltyTier"("organizationId", "minPoints");
CREATE INDEX "AutomationRule_organizationId_isActive_trigger_idx" ON "AutomationRule"("organizationId", "isActive", "trigger");
CREATE INDEX "Person_organizationId_loyaltyPoints_idx" ON "Person"("organizationId", "loyaltyPoints");
CREATE INDEX "Person_loyaltyTierId_idx" ON "Person"("loyaltyTierId");
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_loyaltyTierId_fkey" FOREIGN KEY ("loyaltyTierId") REFERENCES "LoyaltyTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectBoard" ADD CONSTRAINT "ProjectBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectBoardStage" ADD CONSTRAINT "ProjectBoardStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ProjectBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ProjectBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ProjectBoardStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectCard" ADD CONSTRAINT "ProjectCard_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LoyaltyTier" ADD CONSTRAINT "LoyaltyTier_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AutomationRule" ADD CONSTRAINT "AutomationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+116
View File
@@ -92,6 +92,17 @@ enum TicketPriority {
urgent
}
enum AutomationTrigger {
deal_won
ticket_resolved
}
enum AutomationAction {
create_activity
create_notification
add_loyalty_points
}
model Organization {
id String @id @default(cuid())
name String
@@ -119,6 +130,10 @@ model Organization {
tickets Ticket[]
faqArticles FaqArticle[]
portalOtps PortalOtp[]
projectBoards ProjectBoard[]
projectCards ProjectCard[]
loyaltyTiers LoyaltyTier[]
automationRules AutomationRule[]
}
model User {
@@ -134,6 +149,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
notes Note[] @relation("NoteAuthor")
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
@@ -170,6 +186,7 @@ model Company {
ownerId String?
owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -194,6 +211,9 @@ model Person {
position String?
notes String?
customFields Json @default("{}")
loyaltyPoints Int @default(0)
loyaltyTierId String?
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -202,6 +222,7 @@ model Person {
ownerId String?
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -209,8 +230,10 @@ model Person {
@@index([organizationId, email])
@@index([organizationId, mobile])
@@index([organizationId, lastName, firstName])
@@index([organizationId, loyaltyPoints])
@@index([companyId])
@@index([ownerId])
@@index([loyaltyTierId])
}
model Pipeline {
@@ -276,6 +299,7 @@ model Deal {
quotes Quote[]
invoices Invoice[]
tickets Ticket[]
projectCards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -736,3 +760,95 @@ model PortalOtp {
@@index([organizationId, mobile, createdAt])
@@index([expiresAt])
}
/// برد کانبان برای کارهای داخلی / پس‌ازفروش (جدا از کاریز فروش)
model ProjectBoard {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isDefault Boolean @default(false)
stages ProjectBoardStage[]
cards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([organizationId, isDefault])
}
model ProjectBoardStage {
id String @id @default(cuid())
boardId String
board ProjectBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
name String
position Int
cards ProjectCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([boardId, position])
}
model ProjectCard {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
boardId String
board ProjectBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
stageId String
stage ProjectBoardStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
title String
notes String?
position Int @default(0)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation("ProjectCardOwner", fields: [ownerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([boardId, stageId])
@@index([stageId, position])
@@index([personId])
@@index([companyId])
@@index([dealId])
@@index([ownerId])
}
model LoyaltyTier {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
minPoints Int @default(0)
position Int @default(0)
color String?
people Person[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, position])
@@index([organizationId, minPoints])
}
model AutomationRule {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isActive Boolean @default(true)
trigger AutomationTrigger
action AutomationAction
/// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType
config Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive, trigger])
}
+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);
}