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,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;
}