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:
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateAutomationRuleDto, UpdateAutomationRuleDto } from './dto';
|
||||
import { AutomationsService } from './automations.service';
|
||||
|
||||
@Controller('automations')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
export class AutomationsController {
|
||||
constructor(private readonly automations: AutomationsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.automations.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateAutomationRuleDto,
|
||||
) {
|
||||
return this.automations.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAutomationRuleDto,
|
||||
) {
|
||||
return this.automations.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.automations.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsController } from './automations.controller';
|
||||
import { AutomationsService } from './automations.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule, LoyaltyModule],
|
||||
controllers: [AutomationsController],
|
||||
providers: [AutomationsService],
|
||||
exports: [AutomationsService],
|
||||
})
|
||||
export class AutomationsModule {}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
AutomationAction,
|
||||
AutomationRuleConfig,
|
||||
AutomationRuleDto,
|
||||
AutomationTrigger,
|
||||
CreateAutomationRuleRequest,
|
||||
UpdateAutomationRuleRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
|
||||
export type AutomationContext = {
|
||||
organizationId: string;
|
||||
entityType: 'deal' | 'ticket';
|
||||
entityId: string;
|
||||
title: string;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly loyalty: LoyaltyService,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
||||
const rows = await this.prisma.automationRule.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateAutomationRuleRequest,
|
||||
): Promise<AutomationRuleDto> {
|
||||
const row = await this.prisma.automationRule.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
trigger: dto.trigger,
|
||||
action: dto.action,
|
||||
isActive: dto.isActive ?? true,
|
||||
config: (dto.config ?? {}) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateAutomationRuleRequest,
|
||||
): Promise<AutomationRuleDto> {
|
||||
const existing = await this.prisma.automationRule.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('قانون پیدا نشد');
|
||||
|
||||
const row = await this.prisma.automationRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}),
|
||||
...(dto.action !== undefined ? { action: dto.action } : {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
...(dto.config !== undefined
|
||||
? { config: dto.config as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const existing = await this.prisma.automationRule.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('قانون پیدا نشد');
|
||||
await this.prisma.automationRule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async runTrigger(
|
||||
trigger: AutomationTrigger,
|
||||
ctx: AutomationContext,
|
||||
): Promise<void> {
|
||||
const rules = await this.prisma.automationRule.findMany({
|
||||
where: {
|
||||
organizationId: ctx.organizationId,
|
||||
isActive: true,
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await this.execute(rule.action as AutomationAction, asConfig(rule.config), ctx);
|
||||
} catch {
|
||||
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(
|
||||
action: AutomationAction,
|
||||
config: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
) {
|
||||
const title =
|
||||
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
|
||||
|
||||
if (action === 'create_activity') {
|
||||
const assigneeId =
|
||||
config.assigneeId?.trim() ||
|
||||
ctx.assigneeId ||
|
||||
ctx.ownerId ||
|
||||
null;
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
type: (config.activityType as never) || 'task',
|
||||
title,
|
||||
description: config.body?.trim() || null,
|
||||
personId: ctx.personId ?? null,
|
||||
companyId: ctx.companyId ?? null,
|
||||
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
|
||||
assigneeId,
|
||||
},
|
||||
});
|
||||
if (assigneeId) {
|
||||
await this.notifications.create({
|
||||
organizationId: ctx.organizationId,
|
||||
userId: assigneeId,
|
||||
type: 'automation',
|
||||
title: 'فعالیت خودکار',
|
||||
body: title,
|
||||
link:
|
||||
ctx.entityType === 'deal'
|
||||
? `/deals/${ctx.entityId}`
|
||||
: `/tickets/${ctx.entityId}`,
|
||||
entityType: ctx.entityType,
|
||||
entityId: ctx.entityId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'create_notification') {
|
||||
const userId =
|
||||
config.notifyUserId?.trim() ||
|
||||
ctx.assigneeId ||
|
||||
ctx.ownerId ||
|
||||
null;
|
||||
if (!userId) return;
|
||||
await this.notifications.create({
|
||||
organizationId: ctx.organizationId,
|
||||
userId,
|
||||
type: 'automation',
|
||||
title,
|
||||
body: config.body?.trim() || ctx.title,
|
||||
link:
|
||||
ctx.entityType === 'deal'
|
||||
? `/deals/${ctx.entityId}`
|
||||
: `/tickets/${ctx.entityId}`,
|
||||
entityType: ctx.entityType,
|
||||
entityId: ctx.entityId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'add_loyalty_points') {
|
||||
const points = Math.max(1, Math.trunc(config.points ?? 100));
|
||||
if (!ctx.personId) return;
|
||||
await this.loyalty.addPointsByOrg(
|
||||
ctx.organizationId,
|
||||
ctx.personId,
|
||||
points,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
trigger: string;
|
||||
action: string;
|
||||
config: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): AutomationRuleDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isActive: row.isActive,
|
||||
trigger: row.trigger as AutomationTrigger,
|
||||
action: row.action as AutomationAction,
|
||||
config: asConfig(row.config),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function asConfig(raw: unknown): AutomationRuleConfig {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
return raw as AutomationRuleConfig;
|
||||
}
|
||||
|
||||
function defaultTitle(action: AutomationAction, entityTitle: string) {
|
||||
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
|
||||
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
||||
return entityTitle;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const TRIGGERS = ['deal_won', 'ticket_resolved'] as const;
|
||||
const ACTIONS = [
|
||||
'create_activity',
|
||||
'create_notification',
|
||||
'add_loyalty_points',
|
||||
] as const;
|
||||
|
||||
export class CreateAutomationRuleDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsIn(TRIGGERS)
|
||||
trigger!: (typeof TRIGGERS)[number];
|
||||
|
||||
@IsIn(ACTIONS)
|
||||
action!: (typeof ACTIONS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateAutomationRuleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TRIGGERS)
|
||||
trigger?: (typeof TRIGGERS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIONS)
|
||||
action?: (typeof ACTIONS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class AutomationConfigDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
notifyUserId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['call', 'meeting', 'task', 'note'])
|
||||
activityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
points?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user