Adding SMS automation
This commit is contained in:
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
@@ -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 '/';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user