Adding SMS automation

This commit is contained in:
2026-09-13 19:11:47 +03:30
parent bd63d2ab76
commit 45b0d46cfe
26 changed files with 1724 additions and 31 deletions
@@ -0,0 +1,84 @@
CREATE TYPE "MessageAutomationTrigger" AS ENUM (
'person_created',
'lead_submitted',
'deal_created',
'deal_stage_changed',
'deal_won',
'deal_lost',
'ticket_created',
'ticket_resolved',
'invoice_issued',
'invoice_paid',
'activity_created',
'activity_done'
);
CREATE TYPE "MessageAutomationDelayUnit" AS ENUM ('minutes', 'hours', 'days');
CREATE TYPE "MessageAutomationChannel" AS ENUM ('app', 'sms', 'both');
CREATE TYPE "MessageAutomationTarget" AS ENUM ('staff', 'person', 'both');
CREATE TYPE "MessageAutomationJobStatus" AS ENUM ('pending', 'sent', 'cancelled', 'failed');
CREATE TABLE "MessageAutomationRule" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"trigger" "MessageAutomationTrigger" NOT NULL,
"startDelayValue" INTEGER NOT NULL DEFAULT 0,
"startDelayUnit" "MessageAutomationDelayUnit" NOT NULL DEFAULT 'minutes',
"repeatCount" INTEGER NOT NULL DEFAULT 1,
"intervalValue" INTEGER NOT NULL DEFAULT 1,
"intervalUnit" "MessageAutomationDelayUnit" NOT NULL DEFAULT 'days',
"notifyTarget" "MessageAutomationTarget" NOT NULL DEFAULT 'person',
"channel" "MessageAutomationChannel" NOT NULL DEFAULT 'sms',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MessageAutomationRule_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MessageAutomationJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"ruleId" TEXT NOT NULL,
"trigger" "MessageAutomationTrigger" NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"entityTitle" TEXT NOT NULL,
"personId" TEXT,
"ownerId" TEXT,
"assigneeId" TEXT,
"attemptIndex" INTEGER NOT NULL,
"sendAt" TIMESTAMP(3) NOT NULL,
"status" "MessageAutomationJobStatus" NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sentAt" TIMESTAMP(3),
CONSTRAINT "MessageAutomationJob_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "MessageAutomationRule_organizationId_isActive_trigger_idx"
ON "MessageAutomationRule"("organizationId", "isActive", "trigger");
CREATE UNIQUE INDEX "MessageAutomationJob_ruleId_entityId_attemptIndex_key"
ON "MessageAutomationJob"("ruleId", "entityId", "attemptIndex");
CREATE INDEX "MessageAutomationJob_status_sendAt_idx"
ON "MessageAutomationJob"("status", "sendAt");
CREATE INDEX "MessageAutomationJob_organizationId_entityType_entityId_status_idx"
ON "MessageAutomationJob"("organizationId", "entityType", "entityId", "status");
ALTER TABLE "MessageAutomationRule"
ADD CONSTRAINT "MessageAutomationRule_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MessageAutomationJob"
ADD CONSTRAINT "MessageAutomationJob_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MessageAutomationJob"
ADD CONSTRAINT "MessageAutomationJob_ruleId_fkey"
FOREIGN KEY ("ruleId") REFERENCES "MessageAutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+89
View File
@@ -176,6 +176,8 @@ model Organization {
loyaltyRewards LoyaltyReward[]
loyaltyRedemptions LoyaltyRedemption[]
automationRules AutomationRule[]
messageAutomationRules MessageAutomationRule[]
messageAutomationJobs MessageAutomationJob[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
accountingConnections AccountingConnection[]
@@ -1083,6 +1085,93 @@ model AutomationRule {
@@index([organizationId, isActive, trigger])
}
enum MessageAutomationTrigger {
person_created
lead_submitted
deal_created
deal_stage_changed
deal_won
deal_lost
ticket_created
ticket_resolved
invoice_issued
invoice_paid
activity_created
activity_done
}
enum MessageAutomationDelayUnit {
minutes
hours
days
}
enum MessageAutomationChannel {
app
sms
both
}
enum MessageAutomationTarget {
staff
person
both
}
enum MessageAutomationJobStatus {
pending
sent
cancelled
failed
}
model MessageAutomationRule {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isActive Boolean @default(true)
trigger MessageAutomationTrigger
startDelayValue Int @default(0)
startDelayUnit MessageAutomationDelayUnit @default(minutes)
repeatCount Int @default(1)
intervalValue Int @default(1)
intervalUnit MessageAutomationDelayUnit @default(days)
notifyTarget MessageAutomationTarget @default(person)
channel MessageAutomationChannel @default(sms)
title String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
jobs MessageAutomationJob[]
@@index([organizationId, isActive, trigger])
}
model MessageAutomationJob {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
ruleId String
rule MessageAutomationRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
trigger MessageAutomationTrigger
entityType String
entityId String
entityTitle String
personId String?
ownerId String?
assigneeId String?
attemptIndex Int
sendAt DateTime
status MessageAutomationJobStatus @default(pending)
createdAt DateTime @default(now())
sentAt DateTime?
@@unique([ruleId, entityId, attemptIndex])
@@index([status, sendAt])
@@index([organizationId, entityType, entityId, status])
}
/// چالش OTP برای ثبت‌نام / ورود پنل
model AuthChallenge {
id String @id @default(cuid())
+2 -1
View File
@@ -3,9 +3,10 @@ import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@Module({
imports: [NotificationsModule, AutomationsModule],
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
controllers: [ActivitiesController],
providers: [ActivitiesService],
exports: [ActivitiesService],
+19 -3
View File
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
@@ -55,6 +56,7 @@ export class ActivitiesService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
) {}
async summary(
@@ -211,6 +213,18 @@ export class ActivitiesService {
});
}
await this.messageAutomations.onEvent('activity_created', {
organizationId,
entityType: 'activity',
entityId: row.id,
title: row.title,
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
dealId: row.deal?.id ?? null,
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
return this.toDto(row);
}
@@ -377,9 +391,9 @@ export class ActivitiesService {
assignee: { id: string } | null;
},
) {
await this.automations.runTrigger('activity_done', {
const ctx = {
organizationId,
entityType: 'activity',
entityType: 'activity' as const,
entityId: row.id,
title: row.title,
personId: row.person?.id ?? null,
@@ -387,7 +401,9 @@ export class ActivitiesService {
dealId: row.deal?.id ?? null,
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
};
await this.automations.runTrigger('activity_done', ctx);
await this.messageAutomations.onEvent('activity_done', ctx);
}
private async ensureExists(
+2
View File
@@ -25,6 +25,7 @@ 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';
import { MessageAutomationsModule } from './message-automations/message-automations.module';
import { AiModule } from './ai/ai.module';
import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
@@ -65,6 +66,7 @@ import { FinanceModule } from './finance/finance.module';
ProjectBoardsModule,
LoyaltyModule,
AutomationsModule,
MessageAutomationsModule,
MemoryModule,
TahesabModule,
],
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
@@ -8,7 +9,7 @@ import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
@Module({
imports: [LoyaltyModule, AutomationsModule],
imports: [LoyaltyModule, AutomationsModule, MessageAutomationsModule],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService],
+7 -3
View File
@@ -26,6 +26,7 @@ import {
} from '../common/record-scope';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
const personInclude = {
company: { select: { id: true, name: true } },
@@ -66,6 +67,7 @@ export class PeopleService {
private readonly companies: CompaniesService,
private readonly loyalty: LoyaltyService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
) {}
async list(
@@ -151,15 +153,17 @@ export class PeopleService {
include: personInclude,
});
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
await this.automations.runTrigger('person_created', {
const createdCtx = {
organizationId: actor.organizationId,
entityType: 'person',
entityType: 'person' as const,
entityId: row.id,
title: `${row.firstName} ${row.lastName}`.trim(),
personId: row.id,
companyId: row.companyId,
ownerId: row.ownerId,
});
};
await this.automations.runTrigger('person_created', createdCtx);
await this.messageAutomations.onEvent('person_created', createdCtx);
const fresh = await this.prisma.person.findFirst({
where: { id: row.id },
include: personInclude,
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { FinanceModule } from '../finance/finance.module';
import { InventoryModule } from '../inventory/inventory.module';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
imports: [AutomationsModule, InventoryModule, FinanceModule],
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
+12 -6
View File
@@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { InventoryService } from '../inventory/inventory.service';
import { CommissionsService } from '../finance/commissions.service';
@@ -47,6 +48,7 @@ export class InvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly inventory: InventoryService,
private readonly commissions: CommissionsService,
) {}
@@ -539,16 +541,18 @@ export class InvoicesService {
if (invoice.status === 'draft') {
await this.fireIssued(organizationId, row);
}
await this.automations.runTrigger('invoice_paid', {
const paidCtx = {
organizationId,
entityType: 'invoice',
entityType: 'invoice' as const,
entityId: row.id,
title: row.title || row.number,
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
dealId: row.deal?.id ?? null,
ownerId: row.owner?.id ?? null,
});
};
await this.automations.runTrigger('invoice_paid', paidCtx);
await this.messageAutomations.onEvent('invoice_paid', paidCtx);
return row;
}
@@ -657,16 +661,18 @@ export class InvoicesService {
}
private async fireIssued(organizationId: string, row: InvoiceDto) {
await this.automations.runTrigger('invoice_issued', {
const ctx = {
organizationId,
entityType: 'invoice',
entityType: 'invoice' as const,
entityId: row.id,
title: row.title || row.number,
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
dealId: row.deal?.id ?? null,
ownerId: row.owner?.id ?? null,
});
};
await this.automations.runTrigger('invoice_issued', ctx);
await this.messageAutomations.onEvent('invoice_issued', ctx);
}
private async ensureExists(
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { LeadFormsController } from './lead-forms.controller';
import { LeadFormsService } from './lead-forms.service';
import { PublicLeadFormsController } from './public-lead-forms.controller';
@Module({
imports: [AuthModule, AutomationsModule],
imports: [AuthModule, AutomationsModule, MessageAutomationsModule],
controllers: [LeadFormsController, PublicLeadFormsController],
providers: [LeadFormsService],
exports: [LeadFormsService],
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
emptyToNull,
normalizeEmail,
@@ -68,6 +69,7 @@ export class LeadFormsService {
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
) {}
async list(organizationId: string): Promise<LeadFormDto[]> {
@@ -281,14 +283,16 @@ export class LeadFormsService {
.filter((p) => p?.trim())
.join(' ')
.trim();
await this.automations.runTrigger('lead_submitted', {
const leadCtx = {
organizationId: form.organizationId,
entityType: 'person',
entityType: 'person' as const,
entityId: personId ?? form.id,
title: personName || form.name,
personId,
ownerId: ownerId ?? actorId,
});
};
await this.automations.runTrigger('lead_submitted', leadCtx);
await this.messageAutomations.onEvent('lead_submitted', leadCtx);
return {
ok: true,
@@ -0,0 +1,58 @@
import {
applyMessageTemplate,
buildSendAts,
cancelTriggersFor,
clipSms,
delayMs,
} from '../message-automation.util';
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
function testDelay() {
assert(delayMs(0, 'minutes') === 0, 'zero delay');
assert(delayMs(2, 'hours') === 2 * 3_600_000, '2 hours');
assert(delayMs(2, 'days') === 2 * 86_400_000, '2 days');
}
function testSendAts() {
const now = new Date('2026-09-13T12:00:00.000Z');
const once = buildSendAts(now, 0, 'minutes', 1, 2, 'days');
assert(once.length === 1, 'one send');
assert(once[0].getTime() === now.getTime(), 'immediate first');
const three = buildSendAts(now, 1, 'hours', 3, 2, 'days');
assert(three.length === 3, 'three sends');
assert(three[0].getTime() === now.getTime() + 3_600_000, 'start + 1h');
assert(
three[1].getTime() === three[0].getTime() + 2 * 86_400_000,
'second + 2 days',
);
assert(
three[2].getTime() === three[0].getTime() + 4 * 86_400_000,
'third + 4 days',
);
}
function testCancelMap() {
assert(
cancelTriggersFor('invoice_paid').includes('invoice_issued'),
'paid cancels issued',
);
assert(cancelTriggersFor('person_created').length === 0, 'no cancel on create');
}
function testTemplate() {
assert(
applyMessageTemplate('سلام {title}', 'فاکتور ۱۲') === 'سلام فاکتور ۱۲',
'title var',
);
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip');
}
testDelay();
testSendAts();
testCancelMap();
testTemplate();
console.log('message-automation checks passed');
+149
View File
@@ -0,0 +1,149 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import {
MESSAGE_AUTOMATION_TRIGGERS,
type MessageAutomationChannel,
type MessageAutomationDelayUnit,
type MessageAutomationTarget,
type MessageAutomationTrigger,
} from '@mento/shared';
const UNITS = ['minutes', 'hours', 'days'] as const;
const CHANNELS = ['app', 'sms', 'both'] as const;
const TARGETS = ['staff', 'person', 'both'] as const;
export class CreateMessageAutomationRuleDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsIn(MESSAGE_AUTOMATION_TRIGGERS)
trigger!: MessageAutomationTrigger;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(365)
startDelayValue?: number;
@IsOptional()
@IsIn(UNITS)
startDelayUnit?: MessageAutomationDelayUnit;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
repeatCount?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
intervalValue?: number;
@IsOptional()
@IsIn(UNITS)
intervalUnit?: MessageAutomationDelayUnit;
@IsOptional()
@IsIn(TARGETS)
notifyTarget?: MessageAutomationTarget;
@IsOptional()
@IsIn(CHANNELS)
channel?: MessageAutomationChannel;
@IsString()
@MinLength(2)
@MaxLength(120)
title!: string;
@IsString()
@MinLength(2)
@MaxLength(500)
body!: string;
}
export class UpdateMessageAutomationRuleDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsIn(MESSAGE_AUTOMATION_TRIGGERS)
trigger?: MessageAutomationTrigger;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(365)
startDelayValue?: number;
@IsOptional()
@IsIn(UNITS)
startDelayUnit?: MessageAutomationDelayUnit;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
repeatCount?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
intervalValue?: number;
@IsOptional()
@IsIn(UNITS)
intervalUnit?: MessageAutomationDelayUnit;
@IsOptional()
@IsIn(TARGETS)
notifyTarget?: MessageAutomationTarget;
@IsOptional()
@IsIn(CHANNELS)
channel?: MessageAutomationChannel;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
title?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(500)
body?: string;
}
@@ -0,0 +1,66 @@
export type MessageDelayUnit = 'minutes' | 'hours' | 'days';
export const MESSAGE_AUTOMATION_TRIGGERS = [
'person_created',
'lead_submitted',
'deal_created',
'deal_stage_changed',
'deal_won',
'deal_lost',
'ticket_created',
'ticket_resolved',
'invoice_issued',
'invoice_paid',
'activity_created',
'activity_done',
] as const;
export const MAX_MESSAGE_REPEAT = 10;
export const MAX_MESSAGE_SMS = 160;
export function delayMs(value: number, unit: MessageDelayUnit): number {
const n = Math.max(0, Math.trunc(Number(value) || 0));
if (unit === 'minutes') return n * 60_000;
if (unit === 'hours') return n * 3_600_000;
return n * 86_400_000;
}
export function buildSendAts(
now: Date,
startValue: number,
startUnit: MessageDelayUnit,
repeatCount: number,
intervalValue: number,
intervalUnit: MessageDelayUnit,
): Date[] {
const count = Math.min(
MAX_MESSAGE_REPEAT,
Math.max(1, Math.trunc(Number(repeatCount) || 1)),
);
const first = new Date(now.getTime() + delayMs(startValue, startUnit));
const gap = delayMs(Math.max(1, intervalValue), intervalUnit);
return Array.from({ length: count }, (_, i) => new Date(first.getTime() + i * gap));
}
export function applyMessageTemplate(text: string, title: string): string {
return text.replace(/\{title\}/g, title).replace(/\{عنوان\}/g, title);
}
export function clipSms(text: string): string {
const trimmed = text.trim();
if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed;
return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`;
}
/** When this event happens, cancel leftover messages of these earlier triggers. */
export function cancelTriggersFor(
trigger: (typeof MESSAGE_AUTOMATION_TRIGGERS)[number],
): (typeof MESSAGE_AUTOMATION_TRIGGERS)[number][] {
if (trigger === 'invoice_paid') return ['invoice_issued'];
if (trigger === 'ticket_resolved') return ['ticket_created'];
if (trigger === 'deal_won' || trigger === 'deal_lost') {
return ['deal_created', 'deal_stage_changed'];
}
if (trigger === 'activity_done') return ['activity_created'];
return [];
}
@@ -0,0 +1,57 @@
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 {
CreateMessageAutomationRuleDto,
UpdateMessageAutomationRuleDto,
} from './dto';
import { MessageAutomationsService } from './message-automations.service';
@Controller('message-automations')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('owner', 'admin')
export class MessageAutomationsController {
constructor(private readonly messages: MessageAutomationsService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.messages.list(user.organizationId);
}
@Post()
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateMessageAutomationRuleDto,
) {
return this.messages.create(user.organizationId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateMessageAutomationRuleDto,
) {
return this.messages.update(user.organizationId, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.messages.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { MessageAutomationsController } from './message-automations.controller';
import { MessageAutomationsService } from './message-automations.service';
@Module({
imports: [AuthModule, NotificationsModule],
controllers: [MessageAutomationsController],
providers: [MessageAutomationsService, SmsService],
exports: [MessageAutomationsService],
})
export class MessageAutomationsModule {}
@@ -0,0 +1,464 @@
import {
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import type {
CreateMessageAutomationRuleRequest,
MessageAutomationRuleDto,
MessageAutomationTrigger,
UpdateMessageAutomationRuleRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsService } from '../common/sms.service';
import {
applyMessageTemplate,
buildSendAts,
cancelTriggersFor,
clipSms,
} from './message-automation.util';
export type MessageAutomationContext = {
organizationId: string;
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity';
entityId: string;
title: string;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
ownerId?: string | null;
assigneeId?: string | null;
};
type Trigger = MessageAutomationTrigger;
@Injectable()
export class MessageAutomationsService {
private readonly logger = new Logger(MessageAutomationsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly sms: SmsService,
) {}
async list(organizationId: string): Promise<MessageAutomationRuleDto[]> {
const rows = await this.prisma.messageAutomationRule.findMany({
where: { organizationId },
orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateMessageAutomationRuleRequest,
): Promise<MessageAutomationRuleDto> {
const row = await this.prisma.messageAutomationRule.create({
data: this.toData(organizationId, dto),
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateMessageAutomationRuleRequest,
): Promise<MessageAutomationRuleDto> {
const existing = await this.prisma.messageAutomationRule.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
const row = await this.prisma.messageAutomationRule.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}),
...(dto.startDelayValue !== undefined
? { startDelayValue: dto.startDelayValue }
: {}),
...(dto.startDelayUnit !== undefined
? { startDelayUnit: dto.startDelayUnit }
: {}),
...(dto.repeatCount !== undefined ? { repeatCount: dto.repeatCount } : {}),
...(dto.intervalValue !== undefined
? { intervalValue: dto.intervalValue }
: {}),
...(dto.intervalUnit !== undefined
? { intervalUnit: dto.intervalUnit }
: {}),
...(dto.notifyTarget !== undefined
? { notifyTarget: dto.notifyTarget }
: {}),
...(dto.channel !== undefined ? { channel: dto.channel } : {}),
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
},
});
if (dto.isActive === false) {
await this.cancelRuleJobs(id);
}
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.messageAutomationRule.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('قانون پیدا نشد');
await this.prisma.messageAutomationRule.delete({ where: { id } });
}
async onEvent(trigger: Trigger, ctx: MessageAutomationContext): Promise<void> {
try {
await this.cancelForEvent(trigger, ctx);
await this.enqueue(trigger, ctx);
} catch (err) {
this.logger.warn(
`message automation event failed ${trigger}: ${
err instanceof Error ? err.message : 'unknown'
}`,
);
}
}
@Cron('* * * * *')
async handleDue(): Promise<void> {
await this.processDue();
}
async processDue(now: Date = new Date()): Promise<number> {
const jobs = await this.prisma.messageAutomationJob.findMany({
where: { status: 'pending', sendAt: { lte: now } },
include: { rule: true },
orderBy: { sendAt: 'asc' },
take: 80,
});
let sent = 0;
for (const job of jobs) {
try {
if (!job.rule.isActive) {
await this.markCancelled(job.id);
continue;
}
if (await this.isObsolete(job)) {
await this.cancelEntityJobs(
job.organizationId,
job.entityType,
job.entityId,
[job.trigger],
);
continue;
}
await this.deliver(job);
await this.prisma.messageAutomationJob.update({
where: { id: job.id },
data: { status: 'sent', sentAt: new Date() },
});
sent += 1;
} catch (err) {
this.logger.warn(
`message job failed ${job.id}: ${
err instanceof Error ? err.message : 'unknown'
}`,
);
await this.prisma.messageAutomationJob.update({
where: { id: job.id },
data: { status: 'failed' },
});
}
}
return sent;
}
private async enqueue(
trigger: Trigger,
ctx: MessageAutomationContext,
): Promise<void> {
const rules = await this.prisma.messageAutomationRule.findMany({
where: {
organizationId: ctx.organizationId,
isActive: true,
trigger,
},
});
const now = new Date();
for (const rule of rules) {
const times = buildSendAts(
now,
rule.startDelayValue,
rule.startDelayUnit,
rule.repeatCount,
rule.intervalValue,
rule.intervalUnit,
);
try {
await this.prisma.messageAutomationJob.createMany({
data: times.map((sendAt, index) => ({
organizationId: ctx.organizationId,
ruleId: rule.id,
trigger,
entityType: ctx.entityType,
entityId: ctx.entityId,
entityTitle: ctx.title,
personId: ctx.personId ?? null,
ownerId: ctx.ownerId ?? null,
assigneeId: ctx.assigneeId ?? null,
attemptIndex: index + 1,
sendAt,
})),
skipDuplicates: true,
});
} catch (err) {
this.logger.warn(
`enqueue failed ${rule.id}: ${
err instanceof Error ? err.message : 'unknown'
}`,
);
}
}
await this.processDue(now);
}
private async cancelForEvent(
trigger: Trigger,
ctx: MessageAutomationContext,
): Promise<void> {
const triggers = cancelTriggersFor(trigger);
if (!triggers.length) return;
await this.cancelEntityJobs(
ctx.organizationId,
ctx.entityType,
ctx.entityId,
triggers,
);
}
private async cancelEntityJobs(
organizationId: string,
entityType: string,
entityId: string,
triggers: Trigger[],
) {
if (!triggers.length) return;
await this.prisma.messageAutomationJob.updateMany({
where: {
organizationId,
entityType,
entityId,
status: 'pending',
trigger: { in: triggers },
},
data: { status: 'cancelled' },
});
}
private async cancelRuleJobs(ruleId: string) {
await this.prisma.messageAutomationJob.updateMany({
where: { ruleId, status: 'pending' },
data: { status: 'cancelled' },
});
}
private async markCancelled(id: string) {
await this.prisma.messageAutomationJob.update({
where: { id },
data: { status: 'cancelled' },
});
}
private async isObsolete(job: {
organizationId: string;
trigger: string;
entityType: string;
entityId: string;
}): Promise<boolean> {
if (job.trigger === 'invoice_issued' && job.entityType === 'invoice') {
const row = await this.prisma.invoice.findFirst({
where: { id: job.entityId, organizationId: job.organizationId },
select: { status: true },
});
return row?.status === 'paid';
}
if (job.trigger === 'ticket_created' && job.entityType === 'ticket') {
const row = await this.prisma.ticket.findFirst({
where: { id: job.entityId, organizationId: job.organizationId },
select: { status: true },
});
return row?.status === 'resolved' || row?.status === 'closed';
}
if (
(job.trigger === 'deal_created' || job.trigger === 'deal_stage_changed') &&
job.entityType === 'deal'
) {
const row = await this.prisma.deal.findFirst({
where: { id: job.entityId, organizationId: job.organizationId },
select: { status: true },
});
return row?.status === 'won' || row?.status === 'lost';
}
if (job.trigger === 'activity_created' && job.entityType === 'activity') {
const row = await this.prisma.activity.findFirst({
where: { id: job.entityId, organizationId: job.organizationId },
select: { status: true },
});
return row?.status === 'done';
}
return false;
}
private async deliver(job: {
organizationId: string;
entityType: string;
entityId: string;
entityTitle: string;
personId: string | null;
ownerId: string | null;
assigneeId: string | null;
rule: {
title: string;
body: string;
channel: string;
notifyTarget: string;
};
}) {
const title = applyMessageTemplate(job.rule.title, job.entityTitle).slice(
0,
200,
);
const body = applyMessageTemplate(job.rule.body, job.entityTitle);
const smsText = clipSms(body || title);
const wantApp = job.rule.channel === 'app' || job.rule.channel === 'both';
const wantSms = job.rule.channel === 'sms' || job.rule.channel === 'both';
const targetStaff =
job.rule.notifyTarget === 'staff' || job.rule.notifyTarget === 'both';
const targetPerson =
job.rule.notifyTarget === 'person' || job.rule.notifyTarget === 'both';
const link = entityLink(job.entityType, job.entityId);
if (targetStaff) {
const userId = job.assigneeId || job.ownerId;
if (userId && wantApp) {
await this.notifications.create({
organizationId: job.organizationId,
userId,
type: 'automation',
title,
body,
link,
entityType: job.entityType,
entityId: job.entityId,
});
}
if (userId && wantSms) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId: job.organizationId },
select: { mobile: true },
});
await this.sendSms(user?.mobile, smsText);
}
}
if (targetPerson && wantSms) {
const person = job.personId
? await this.prisma.person.findFirst({
where: {
id: job.personId,
organizationId: job.organizationId,
},
select: { mobile: true, phone: true },
})
: null;
await this.sendSms(person?.mobile || person?.phone, smsText);
}
}
private async sendSms(mobile: string | null | undefined, text: string) {
if (!mobile) {
this.logger.warn('Message automation SMS skipped: no mobile');
return;
}
if (!this.sms.isConfigured()) {
this.logger.warn('Message automation SMS skipped: SMS not configured');
return;
}
const result = await this.sms.sendSmsDetailed(mobile, text);
if (!result.ok) {
this.logger.warn(
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
);
}
}
private toData(
organizationId: string,
dto: CreateMessageAutomationRuleRequest,
): Prisma.MessageAutomationRuleUncheckedCreateInput {
return {
organizationId,
name: dto.name.trim(),
isActive: dto.isActive ?? true,
trigger: dto.trigger,
startDelayValue: dto.startDelayValue ?? 0,
startDelayUnit: dto.startDelayUnit ?? 'minutes',
repeatCount: dto.repeatCount ?? 1,
intervalValue: dto.intervalValue ?? 1,
intervalUnit: dto.intervalUnit ?? 'days',
notifyTarget: dto.notifyTarget ?? 'person',
channel: dto.channel ?? 'sms',
title: dto.title.trim(),
body: dto.body.trim(),
};
}
private toDto(row: {
id: string;
name: string;
isActive: boolean;
trigger: string;
startDelayValue: number;
startDelayUnit: string;
repeatCount: number;
intervalValue: number;
intervalUnit: string;
notifyTarget: string;
channel: string;
title: string;
body: string;
createdAt: Date;
updatedAt: Date;
}): MessageAutomationRuleDto {
return {
id: row.id,
name: row.name,
isActive: row.isActive,
trigger: row.trigger as MessageAutomationRuleDto['trigger'],
startDelayValue: row.startDelayValue,
startDelayUnit: row.startDelayUnit as MessageAutomationRuleDto['startDelayUnit'],
repeatCount: row.repeatCount,
intervalValue: row.intervalValue,
intervalUnit: row.intervalUnit as MessageAutomationRuleDto['intervalUnit'],
notifyTarget: row.notifyTarget as MessageAutomationRuleDto['notifyTarget'],
channel: row.channel as MessageAutomationRuleDto['channel'],
title: row.title,
body: row.body,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function entityLink(entityType: string, entityId: string) {
if (entityType === 'deal') return `/deals/${entityId}`;
if (entityType === 'ticket') return `/tickets/${entityId}`;
if (entityType === 'invoice') return `/invoices/${entityId}`;
if (entityType === 'person') return `/contacts/people/${entityId}`;
if (entityType === 'activity') return `/activities/${entityId}`;
return '/';
}
+10 -3
View File
@@ -16,6 +16,7 @@ import { PipelinesService } from './pipelines.service';
import { toJsonCustomFields } from '../common/custom-fields';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
type Actor,
canReassignOwner,
@@ -39,6 +40,7 @@ export class DealsService {
private readonly pipelines: PipelinesService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
) {}
async get(
@@ -147,16 +149,18 @@ export class DealsService {
include: dealInclude,
});
await this.automations.runTrigger('deal_created', {
const createdCtx = {
organizationId,
entityType: 'deal',
entityType: 'deal' as const,
entityId: created.id,
title: created.title,
personId: created.personId,
companyId: created.companyId,
dealId: created.id,
ownerId: created.ownerId,
});
};
await this.automations.runTrigger('deal_created', createdCtx);
await this.messageAutomations.onEvent('deal_created', createdCtx);
return this.pipelines.toDealDto(created);
}
@@ -313,11 +317,14 @@ export class DealsService {
ownerId: deal.ownerId,
};
await this.automations.runTrigger('deal_stage_changed', ctx);
await this.messageAutomations.onEvent('deal_stage_changed', ctx);
if (status === 'won' && deal.status !== 'won') {
await this.automations.runTrigger('deal_won', ctx);
await this.messageAutomations.onEvent('deal_won', ctx);
}
if (status === 'lost' && deal.status !== 'lost') {
await this.automations.runTrigger('deal_lost', ctx);
await this.messageAutomations.onEvent('deal_lost', ctx);
}
}
+7 -1
View File
@@ -6,9 +6,15 @@ import { PipelinesService } from './pipelines.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AuthModule } from '../auth/auth.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@Module({
imports: [NotificationsModule, AuthModule, forwardRef(() => AutomationsModule)],
imports: [
NotificationsModule,
AuthModule,
forwardRef(() => AutomationsModule),
MessageAutomationsModule,
],
controllers: [PipelinesController, DealsController],
providers: [PipelinesService, DealsService],
exports: [PipelinesService, DealsService],
+6 -1
View File
@@ -1,11 +1,16 @@
import { Module, forwardRef } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
@Module({
imports: [NotificationsModule, forwardRef(() => AutomationsModule)],
imports: [
NotificationsModule,
forwardRef(() => AutomationsModule),
MessageAutomationsModule,
],
controllers: [TicketsController],
providers: [TicketsService],
exports: [TicketsService],
+12 -6
View File
@@ -21,6 +21,7 @@ import {
} from '../common/record-scope';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -42,6 +43,7 @@ export class TicketsService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
) {}
async list(
@@ -228,9 +230,9 @@ export class TicketsService {
});
}
await this.automations.runTrigger('ticket_created', {
const createdCtx = {
organizationId: actor.organizationId,
entityType: 'ticket',
entityType: 'ticket' as const,
entityId: row.id,
title: row.subject,
personId: row.personId,
@@ -238,7 +240,9 @@ export class TicketsService {
dealId: row.dealId,
assigneeId: row.assigneeId,
ownerId: row.assigneeId,
});
};
await this.automations.runTrigger('ticket_created', createdCtx);
await this.messageAutomations.onEvent('ticket_created', createdCtx);
return this.toDto(row);
}
@@ -345,9 +349,9 @@ export class TicketsService {
existing.status !== 'resolved' &&
existing.status !== 'closed';
if (becameResolved) {
await this.automations.runTrigger('ticket_resolved', {
const resolvedCtx = {
organizationId: actor.organizationId,
entityType: 'ticket',
entityType: 'ticket' as const,
entityId: existing.id,
title: existing.subject,
personId: existing.personId,
@@ -355,7 +359,9 @@ export class TicketsService {
dealId: existing.dealId,
assigneeId: existing.assigneeId,
ownerId: existing.assigneeId,
});
};
await this.automations.runTrigger('ticket_resolved', resolvedCtx);
await this.messageAutomations.onEvent('ticket_resolved', resolvedCtx);
}
return this.get(actor, id);
+14
View File
@@ -49,6 +49,9 @@ const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(() => route.path.startsWith('/settings/automations'));
const messageAutomationsActive = computed(() =>
route.path.startsWith('/settings/message-automations'),
);
const toolsActive = computed(() =>
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
);
@@ -411,6 +414,17 @@ onUnmounted(() => {
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
<span>اتوماسیون</span>
</RouterLink>
<RouterLink
v-if="canSettings"
to="/settings/message-automations"
class="nav-item"
title="پیام خودکار"
:class="{ 'router-link-active': messageAutomationsActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="mail" :size="collapsed ? 20 : 18" />
<span>پیام خودکار</span>
</RouterLink>
</div>
<div class="nav-sep" aria-hidden="true" />
+10 -1
View File
@@ -28,7 +28,8 @@ export type NavIconName =
| 'chart'
| 'lightbulb'
| 'plus'
| 'user-plus';
| 'user-plus'
| 'mail';
export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber';
@@ -291,6 +292,14 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'lightbulb',
tint: 'rose',
},
{
to: '/settings/message-automations',
title: 'پیام خودکار',
desc: 'پیامک و ناتیف با زمان‌بندی تکرار',
icon: 'mail',
tint: 'sky',
admin: true,
},
],
},
];
+6
View File
@@ -194,6 +194,12 @@ export const router = createRouter({
component: () => import('@/views/AutomationsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/message-automations',
name: 'message-automations',
component: () => import('@/views/MessageAutomationsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/lead-forms',
name: 'lead-forms',
@@ -0,0 +1,507 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateMessageAutomationRuleRequest,
MessageAutomationChannel,
MessageAutomationDelayUnit,
MessageAutomationRuleDto,
MessageAutomationTarget,
MessageAutomationTrigger,
UpdateMessageAutomationRuleRequest,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_CHANNEL_LABELS,
MESSAGE_AUTOMATION_TARGET_LABELS,
MESSAGE_AUTOMATION_TRIGGER_LABELS,
MESSAGE_AUTOMATION_TRIGGERS,
MESSAGE_AUTOMATION_UNIT_LABELS,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<MessageAutomationRuleDto[]>([]);
const editingId = ref<string | null>(null);
const name = ref('');
const isActive = ref(true);
const trigger = ref<MessageAutomationTrigger>('invoice_issued');
const startDelayValue = ref(0);
const startDelayUnit = ref<MessageAutomationDelayUnit>('minutes');
const repeatCount = ref(1);
const intervalValue = ref(2);
const intervalUnit = ref<MessageAutomationDelayUnit>('days');
const notifyTarget = ref<MessageAutomationTarget>('person');
const channel = ref<MessageAutomationChannel>('sms');
const title = ref('');
const body = ref('');
const personNote = computed(
() =>
notifyTarget.value !== 'staff' &&
'ناتیف داخل برنامه برای کارکنان است؛ برای مشتری پیامک ارسال می‌شود.',
);
function resetForm() {
editingId.value = null;
name.value = '';
isActive.value = true;
trigger.value = 'invoice_issued';
startDelayValue.value = 0;
startDelayUnit.value = 'minutes';
repeatCount.value = 1;
intervalValue.value = 2;
intervalUnit.value = 'days';
notifyTarget.value = 'person';
channel.value = 'sms';
title.value = '';
body.value = '';
ok.value = '';
}
function startEdit(row: MessageAutomationRuleDto) {
editingId.value = row.id;
name.value = row.name;
isActive.value = row.isActive;
trigger.value = row.trigger;
startDelayValue.value = row.startDelayValue;
startDelayUnit.value = row.startDelayUnit;
repeatCount.value = row.repeatCount;
intervalValue.value = row.intervalValue;
intervalUnit.value = row.intervalUnit;
notifyTarget.value = row.notifyTarget;
channel.value = row.channel;
title.value = row.title;
body.value = row.body;
ok.value = '';
}
function scheduleLabel(row: MessageAutomationRuleDto) {
const start =
row.startDelayValue === 0
? 'همان لحظه'
: `${row.startDelayValue} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`;
if (row.repeatCount <= 1) return start;
return `${start} · ${row.repeatCount} بار، هر ${row.intervalValue} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<MessageAutomationRuleDto[]>('/message-automations');
items.value = data;
} catch (e) {
error.value = axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری قوانین پیام ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateMessageAutomationRuleRequest {
return {
name: name.value.trim(),
isActive: isActive.value,
trigger: trigger.value,
startDelayValue: startDelayValue.value,
startDelayUnit: startDelayUnit.value,
repeatCount: repeatCount.value,
intervalValue: intervalValue.value,
intervalUnit: intervalUnit.value,
notifyTarget: notifyTarget.value,
channel: channel.value,
title: title.value.trim(),
body: body.value.trim(),
};
}
async function save() {
if (!name.value.trim() || !title.value.trim() || !body.value.trim()) {
error.value = 'عنوان قانون، عنوان پیام و متن را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
const bodyDto: UpdateMessageAutomationRuleRequest = payload();
await api.patch(`/message-automations/${editingId.value}`, bodyDto);
ok.value = 'قانون به‌روز شد.';
} else {
await api.post('/message-automations', payload());
ok.value = 'قانون ساخته شد.';
}
resetForm();
await load();
} catch {
error.value = 'ذخیره قانون ناموفق بود.';
} finally {
saving.value = false;
}
}
async function toggle(row: MessageAutomationRuleDto) {
try {
await api.patch(`/message-automations/${row.id}`, { isActive: !row.isActive });
await load();
} catch {
error.value = 'تغییر وضعیت ناموفق بود.';
}
}
async function remove(row: MessageAutomationRuleDto) {
if (!confirm(`قانون «${row.name}» حذف شود؟`)) return;
try {
await api.delete(`/message-automations/${row.id}`);
if (editingId.value === row.id) resetForm();
await load();
} catch {
error.value = 'حذف قانون ناموفق بود.';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>پیام خودکار</h1>
<p class="lead">
جدا از اتوماسیون گردش کار. فقط برای پیامک و ناتیف: چه اتفاقی بیفتد، چند بار و با چه فاصله‌ای.
</p>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<form class="panel" @submit.prevent="save">
<h2>{{ editingId ? 'ویرایش قانون' : 'قانون جدید' }}</h2>
<label class="field">
عنوان قانون
<input v-model="name" maxlength="120" placeholder="مثلاً تشکر بعد از فاکتور" />
</label>
<label class="field">
چه زمانی شروع شود
<select v-model="trigger">
<option
v-for="item in MESSAGE_AUTOMATION_TRIGGERS"
:key="item"
:value="item"
>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[item] }}
</option>
</select>
</label>
<div class="row-fields">
<label class="field">
شروع ارسال
<input v-model.number="startDelayValue" type="number" min="0" max="365" />
</label>
<label class="field">
واحد شروع
<select v-model="startDelayUnit">
<option value="minutes">دقیقه بعد</option>
<option value="hours">ساعت بعد</option>
<option value="days">روز بعد</option>
</select>
</label>
<label class="field">
تعداد ارسال
<input v-model.number="repeatCount" type="number" min="1" max="10" />
</label>
</div>
<div v-if="repeatCount > 1" class="row-fields">
<label class="field">
فاصله بین پیام‌ها
<input v-model.number="intervalValue" type="number" min="1" max="365" />
</label>
<label class="field">
واحد فاصله
<select v-model="intervalUnit">
<option value="minutes">دقیقه</option>
<option value="hours">ساعت</option>
<option value="days">روز</option>
</select>
</label>
</div>
<p class="hint">
صفر یعنی همان لحظه. برای چندباره: مثلاً ۳ بار، هر ۲ روز.
</p>
<div class="row-fields">
<label class="field">
برای چه کسی
<select v-model="notifyTarget">
<option value="person">مشتری</option>
<option value="staff">مسئول پرونده</option>
<option value="both">مشتری و مسئول</option>
</select>
</label>
<label class="field">
از چه راهی
<select v-model="channel">
<option value="sms">پیامک</option>
<option value="app">ناتیف داخل برنامه</option>
<option value="both">پیامک و ناتیف</option>
</select>
</label>
</div>
<p v-if="personNote" class="hint">{{ personNote }}</p>
<label class="field">
عنوان پیام
<input v-model="title" maxlength="120" placeholder="عنوان ناتیف یا ابتدای پیامک" />
</label>
<label class="field">
متن پیام
<textarea
v-model="body"
maxlength="500"
placeholder="می‌توانید {title} را برای نام همان رکورد بگذارید"
/>
</label>
<label class="check">
<input v-model="isActive" type="checkbox" />
قانون فعال باشد
</label>
<div class="form-actions">
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? '…' : editingId ? 'ذخیره تغییرات' : 'ساخت قانون' }}
</button>
<button v-if="editingId" class="btn ghost" type="button" @click="resetForm">
انصراف
</button>
</div>
</form>
<div class="list">
<article v-for="row in items" :key="row.id" class="card" :class="{ off: !row.isActive }">
<header>
<strong>{{ row.name }}</strong>
<button
class="switch"
type="button"
:aria-pressed="row.isActive"
@click="toggle(row)"
>
{{ row.isActive ? 'فعال' : 'خاموش' }}
</button>
</header>
<p>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[row.trigger] }}
·
{{ scheduleLabel(row) }}
</p>
<p class="muted">
{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}
·
{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}
</p>
<p class="preview">{{ row.body }}</p>
<div class="card-actions">
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
<button class="btn danger" type="button" @click="remove(row)">حذف</button>
</div>
</article>
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 760px;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
}
.lead,
.muted,
.hint,
.empty {
margin: 0;
color: #64748b;
}
.hint {
font-size: 0.82rem;
}
.panel,
.list {
display: grid;
gap: 0.75rem;
}
.panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
padding: 1.1rem;
}
.field {
display: grid;
gap: 0.35rem;
color: #64748b;
font-size: 0.9rem;
}
.row-fields {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.row-fields .field {
flex: 1;
min-width: 8rem;
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
width: 100%;
box-sizing: border-box;
color: #0f172a;
}
textarea {
min-height: 5.5rem;
padding: 0.65rem 0.85rem;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
color: #334155;
}
.form-actions,
.card-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: #14b8a6;
color: #fff;
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.danger {
background: #fff;
color: #b91c1c;
border-color: #fecaca;
}
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.35rem;
}
.card.off {
opacity: 0.7;
}
.card header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.card p {
margin: 0;
}
.preview {
color: #334155;
font-size: 0.88rem;
}
.switch {
border: 0;
background: #ecfdf8;
color: #0f766e;
font: inherit;
font-weight: 800;
font-size: 0.78rem;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
}
.card.off .switch {
background: #f1f5f9;
color: #64748b;
}
.error {
margin: 0;
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
</style>
+116
View File
@@ -2245,6 +2245,122 @@ export interface PersonAssetsDto {
fetchedAt: string;
}
// —— پیام خودکار (جدا از اتوماسیون گردش کار) ——
export type MessageAutomationTrigger =
| "person_created"
| "lead_submitted"
| "deal_created"
| "deal_stage_changed"
| "deal_won"
| "deal_lost"
| "ticket_created"
| "ticket_resolved"
| "invoice_issued"
| "invoice_paid"
| "activity_created"
| "activity_done";
export type MessageAutomationDelayUnit = "minutes" | "hours" | "days";
export type MessageAutomationChannel = "app" | "sms" | "both";
export type MessageAutomationTarget = "staff" | "person" | "both";
export const MESSAGE_AUTOMATION_TRIGGERS = [
"person_created",
"lead_submitted",
"deal_created",
"deal_stage_changed",
"deal_won",
"deal_lost",
"ticket_created",
"ticket_resolved",
"invoice_issued",
"invoice_paid",
"activity_created",
"activity_done",
] as const satisfies readonly MessageAutomationTrigger[];
export const MESSAGE_AUTOMATION_TRIGGER_LABELS: Record<
MessageAutomationTrigger,
string
> = {
person_created: "مخاطب جدید ساخته شد",
lead_submitted: "فرم سرنخ ارسال شد",
deal_created: "معامله جدید ساخته شد",
deal_stage_changed: "مرحله معامله عوض شد",
deal_won: "معامله برنده شد",
deal_lost: "معامله باخت",
ticket_created: "تیکت جدید ساخته شد",
ticket_resolved: "تیکت حل شد",
invoice_issued: "فاکتور صادر شد",
invoice_paid: "پرداخت روی فاکتور ثبت شد",
activity_created: "فعالیت ثبت شد",
activity_done: "فعالیت تمام شد",
};
export const MESSAGE_AUTOMATION_UNIT_LABELS: Record<
MessageAutomationDelayUnit,
string
> = {
minutes: "دقیقه",
hours: "ساعت",
days: "روز",
};
export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
MessageAutomationChannel,
string
> = {
app: "ناتیف داخل برنامه",
sms: "پیامک",
both: "پیامک و ناتیف",
};
export const MESSAGE_AUTOMATION_TARGET_LABELS: Record<
MessageAutomationTarget,
string
> = {
staff: "مسئول پرونده",
person: "مشتری",
both: "مشتری و مسئول",
};
export interface MessageAutomationRuleDto {
id: string;
name: string;
isActive: boolean;
trigger: MessageAutomationTrigger;
startDelayValue: number;
startDelayUnit: MessageAutomationDelayUnit;
repeatCount: number;
intervalValue: number;
intervalUnit: MessageAutomationDelayUnit;
notifyTarget: MessageAutomationTarget;
channel: MessageAutomationChannel;
title: string;
body: string;
createdAt: string;
updatedAt: string;
}
export interface CreateMessageAutomationRuleRequest {
name: string;
isActive?: boolean;
trigger: MessageAutomationTrigger;
startDelayValue?: number;
startDelayUnit?: MessageAutomationDelayUnit;
repeatCount?: number;
intervalValue?: number;
intervalUnit?: MessageAutomationDelayUnit;
notifyTarget?: MessageAutomationTarget;
channel?: MessageAutomationChannel;
title: string;
body: string;
}
export type UpdateMessageAutomationRuleRequest =
Partial<CreateMessageAutomationRuleRequest>;
export { brand, APP_NAME, APP_NAME_FA, brandSuggestionFa } from "./brand";
export type { Brand } from "./brand";
export const API_PREFIX = "api";