From c2bfa7749d7524f05948ea0e4cf4b0474c3e8f5d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 30 Aug 2026 09:42:07 +0330 Subject: [PATCH] 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 --- .../migration.sql | 112 +++++ apps/api/prisma/schema.prisma | 116 ++++++ apps/api/src/app.module.ts | 6 + .../src/automations/automations.controller.ts | 54 +++ .../api/src/automations/automations.module.ts | 14 + .../src/automations/automations.service.ts | 223 ++++++++++ apps/api/src/automations/dto.ts | 97 +++++ apps/api/src/contacts/people.service.ts | 12 + apps/api/src/loyalty/dto.ts | 60 +++ apps/api/src/loyalty/loyalty.controller.ts | 73 ++++ apps/api/src/loyalty/loyalty.module.ts | 12 + apps/api/src/loyalty/loyalty.service.ts | 266 ++++++++++++ apps/api/src/pipelines/deals.service.ts | 18 + apps/api/src/pipelines/pipelines.module.ts | 5 +- apps/api/src/project-boards/dto.ts | 101 +++++ .../project-boards.controller.ts | 89 ++++ .../project-boards/project-boards.module.ts | 12 + .../project-boards/project-boards.service.ts | 390 +++++++++++++++++ apps/api/src/tickets/tickets.module.ts | 5 +- apps/api/src/tickets/tickets.service.ts | 21 + apps/web/src/layouts/AppLayout.vue | 3 + apps/web/src/router/index.ts | 26 ++ .../web/src/views/AutomationsSettingsView.vue | 347 ++++++++++++++++ apps/web/src/views/LoyaltyClubView.vue | 341 +++++++++++++++ apps/web/src/views/PersonFormView.vue | 13 + apps/web/src/views/ProjectBoardView.vue | 391 ++++++++++++++++++ apps/web/src/views/ProjectCardFormView.vue | 165 ++++++++ docs/وضعیت-پیشرفت.md | 11 +- packages/shared/src/index.ts | 152 +++++++ 29 files changed, 3126 insertions(+), 9 deletions(-) create mode 100644 apps/api/prisma/migrations/20260830090000_project_loyalty_automation/migration.sql create mode 100644 apps/api/src/automations/automations.controller.ts create mode 100644 apps/api/src/automations/automations.module.ts create mode 100644 apps/api/src/automations/automations.service.ts create mode 100644 apps/api/src/automations/dto.ts create mode 100644 apps/api/src/loyalty/dto.ts create mode 100644 apps/api/src/loyalty/loyalty.controller.ts create mode 100644 apps/api/src/loyalty/loyalty.module.ts create mode 100644 apps/api/src/loyalty/loyalty.service.ts create mode 100644 apps/api/src/project-boards/dto.ts create mode 100644 apps/api/src/project-boards/project-boards.controller.ts create mode 100644 apps/api/src/project-boards/project-boards.module.ts create mode 100644 apps/api/src/project-boards/project-boards.service.ts create mode 100644 apps/web/src/views/AutomationsSettingsView.vue create mode 100644 apps/web/src/views/LoyaltyClubView.vue create mode 100644 apps/web/src/views/ProjectBoardView.vue create mode 100644 apps/web/src/views/ProjectCardFormView.vue diff --git a/apps/api/prisma/migrations/20260830090000_project_loyalty_automation/migration.sql b/apps/api/prisma/migrations/20260830090000_project_loyalty_automation/migration.sql new file mode 100644 index 0000000..170391e --- /dev/null +++ b/apps/api/prisma/migrations/20260830090000_project_loyalty_automation/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1fd12f4..ee137b2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e15b431..ae24869 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/automations/automations.controller.ts b/apps/api/src/automations/automations.controller.ts new file mode 100644 index 0000000..a56e5a3 --- /dev/null +++ b/apps/api/src/automations/automations.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/automations/automations.module.ts b/apps/api/src/automations/automations.module.ts new file mode 100644 index 0000000..106d878 --- /dev/null +++ b/apps/api/src/automations/automations.module.ts @@ -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 {} diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts new file mode 100644 index 0000000..730f0f2 --- /dev/null +++ b/apps/api/src/automations/automations.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/automations/dto.ts b/apps/api/src/automations/dto.ts new file mode 100644 index 0000000..139d0f1 --- /dev/null +++ b/apps/api/src/automations/dto.ts @@ -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; +} + +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; +} + +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; +} diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index d04b147..dc76bad 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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(), diff --git a/apps/api/src/loyalty/dto.ts b/apps/api/src/loyalty/dto.ts new file mode 100644 index 0000000..61c0ce7 --- /dev/null +++ b/apps/api/src/loyalty/dto.ts @@ -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; +} diff --git a/apps/api/src/loyalty/loyalty.controller.ts b/apps/api/src/loyalty/loyalty.controller.ts new file mode 100644 index 0000000..50e1b71 --- /dev/null +++ b/apps/api/src/loyalty/loyalty.controller.ts @@ -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); + } +} diff --git a/apps/api/src/loyalty/loyalty.module.ts b/apps/api/src/loyalty/loyalty.module.ts new file mode 100644 index 0000000..d432750 --- /dev/null +++ b/apps/api/src/loyalty/loyalty.module.ts @@ -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 {} diff --git a/apps/api/src/loyalty/loyalty.service.ts b/apps/api/src/loyalty/loyalty.service.ts new file mode 100644 index 0000000..f4fb00b --- /dev/null +++ b/apps/api/src/loyalty/loyalty.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 18cd909..3c8035e 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -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); } diff --git a/apps/api/src/pipelines/pipelines.module.ts b/apps/api/src/pipelines/pipelines.module.ts index 80c526b..f59a2f8 100644 --- a/apps/api/src/pipelines/pipelines.module.ts +++ b/apps/api/src/pipelines/pipelines.module.ts @@ -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], diff --git a/apps/api/src/project-boards/dto.ts b/apps/api/src/project-boards/dto.ts new file mode 100644 index 0000000..d1c2d43 --- /dev/null +++ b/apps/api/src/project-boards/dto.ts @@ -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; +} diff --git a/apps/api/src/project-boards/project-boards.controller.ts b/apps/api/src/project-boards/project-boards.controller.ts new file mode 100644 index 0000000..8b9a993 --- /dev/null +++ b/apps/api/src/project-boards/project-boards.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/project-boards/project-boards.module.ts b/apps/api/src/project-boards/project-boards.module.ts new file mode 100644 index 0000000..43426e0 --- /dev/null +++ b/apps/api/src/project-boards/project-boards.module.ts @@ -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 {} diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts new file mode 100644 index 0000000..2880148 --- /dev/null +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -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; + +@Injectable() +export class ProjectBoardsService { + constructor(private readonly prisma: PrismaService) {} + + async ensureDefaultBoard(organizationId: string): Promise { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/tickets/tickets.module.ts b/apps/api/src/tickets/tickets.module.ts index 56f1b1b..e6d61a5 100644 --- a/apps/api/src/tickets/tickets.module.ts +++ b/apps/api/src/tickets/tickets.module.ts @@ -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], diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index 71edf41..52e6099 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -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); } diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 8d29258..12700eb 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -190,6 +190,8 @@ onUnmounted(() => { فعالیت‌ها تقویم فعالیت‌ها کاریز فروش + کاریز پروژه + باشگاه مشتریان هدف و پیش‌بینی گزارشات تیکت‌ها @@ -199,6 +201,7 @@ onUnmounted(() => { یادداشت‌ها