From f1aba1b386fade0533f407c308803b625972f8e0 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 9 Sep 2026 21:43:17 +0330 Subject: [PATCH] Let automations fire on more CRM events and open the studio from the main nav. Co-authored-by: Cursor --- .../migration.sql | 19 +++++ .../migration.sql | 21 +++++ apps/api/prisma/schema.prisma | 9 ++ apps/api/src/activities/activities.module.ts | 3 +- apps/api/src/activities/activities.service.ts | 40 +++++++++ .../src/automations/automations.service.ts | 85 +++++++++++++++---- apps/api/src/automations/dto.ts | 14 ++- apps/api/src/contacts/contacts.module.ts | 3 +- apps/api/src/contacts/people.service.ts | 11 +++ apps/api/src/invoices/invoices.module.ts | 2 + apps/api/src/invoices/invoices.service.ts | 51 ++++++++++- apps/api/src/lead-forms/lead-forms.module.ts | 3 +- apps/api/src/lead-forms/lead-forms.service.ts | 15 ++++ apps/api/src/pipelines/deals.service.ts | 26 +++++- apps/api/src/tickets/tickets.service.ts | 12 +++ .../automations/AutomationCanvas.vue | 2 + .../automations/AutomationInspector.vue | 14 ++- apps/web/src/layouts/AppLayout.vue | 16 +++- .../web/src/views/AutomationsSettingsView.vue | 65 +++++++++++--- packages/shared/src/index.ts | 57 ++++++++++++- 20 files changed, 419 insertions(+), 49 deletions(-) create mode 100644 apps/api/prisma/migrations/20260909183000_automation_more_triggers/migration.sql create mode 100644 apps/api/prisma/migrations/20260909211500_automation_invoice_activity_triggers/migration.sql diff --git a/apps/api/prisma/migrations/20260909183000_automation_more_triggers/migration.sql b/apps/api/prisma/migrations/20260909183000_automation_more_triggers/migration.sql new file mode 100644 index 0000000..15d4230 --- /dev/null +++ b/apps/api/prisma/migrations/20260909183000_automation_more_triggers/migration.sql @@ -0,0 +1,19 @@ +CREATE TYPE "AutomationTrigger_new" AS ENUM ( + 'person_created', + 'lead_submitted', + 'deal_created', + 'deal_stage_changed', + 'deal_won', + 'deal_lost', + 'ticket_created', + 'ticket_resolved', + 'invoice_paid' +); + +ALTER TABLE "AutomationRule" + ALTER COLUMN "trigger" TYPE "AutomationTrigger_new" + USING ("trigger"::text::"AutomationTrigger_new"); + +ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old"; +ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger"; +DROP TYPE "AutomationTrigger_old"; diff --git a/apps/api/prisma/migrations/20260909211500_automation_invoice_activity_triggers/migration.sql b/apps/api/prisma/migrations/20260909211500_automation_invoice_activity_triggers/migration.sql new file mode 100644 index 0000000..72972e1 --- /dev/null +++ b/apps/api/prisma/migrations/20260909211500_automation_invoice_activity_triggers/migration.sql @@ -0,0 +1,21 @@ +CREATE TYPE "AutomationTrigger_new" AS ENUM ( + 'person_created', + 'lead_submitted', + 'deal_created', + 'deal_stage_changed', + 'deal_won', + 'deal_lost', + 'ticket_created', + 'ticket_resolved', + 'invoice_issued', + 'invoice_paid', + 'activity_done' +); + +ALTER TABLE "AutomationRule" + ALTER COLUMN "trigger" TYPE "AutomationTrigger_new" + USING ("trigger"::text::"AutomationTrigger_new"); + +ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old"; +ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger"; +DROP TYPE "AutomationTrigger_old"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c5ae895..f1985b3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -108,8 +108,17 @@ enum TicketPriority { } enum AutomationTrigger { + person_created + lead_submitted + deal_created + deal_stage_changed deal_won + deal_lost + ticket_created ticket_resolved + invoice_issued + invoice_paid + activity_done } enum AutomationAction { diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts index f705229..ac8d99b 100644 --- a/apps/api/src/activities/activities.module.ts +++ b/apps/api/src/activities/activities.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { ActivitiesController } from './activities.controller'; import { ActivitiesService } from './activities.service'; import { NotificationsModule } from '../notifications/notifications.module'; +import { AutomationsModule } from '../automations/automations.module'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AutomationsModule], controllers: [ActivitiesController], providers: [ActivitiesService], exports: [ActivitiesService], diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index d21aa5d..da527bf 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -14,6 +14,7 @@ import type { import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { AutomationsService } from '../automations/automations.service'; import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope'; type ActivityActor = Pick; @@ -53,6 +54,7 @@ export class ActivitiesService { constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, + private readonly automations: AutomationsService, ) {} async summary( @@ -245,11 +247,18 @@ export class ActivitiesService { dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined; } + const existing = await this.prisma.activity.findFirst({ + where: { id, organizationId }, + select: { status: true }, + }); const row = await this.prisma.activity.update({ where: { id }, data, include, }); + if (dto.status === 'done' && existing?.status !== 'done') { + await this.fireDone(organizationId, row); + } return this.toDto(row); } @@ -259,11 +268,18 @@ export class ActivitiesService { actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, id, actor); + const existing = await this.prisma.activity.findFirst({ + where: { id, organizationId }, + select: { status: true }, + }); const row = await this.prisma.activity.update({ where: { id }, data: { status: 'done', completedAt: new Date() }, include, }); + if (existing?.status !== 'done') { + await this.fireDone(organizationId, row); + } return this.toDto(row); } @@ -350,6 +366,30 @@ export class ActivitiesService { } } + private async fireDone( + organizationId: string, + row: { + id: string; + title: string; + person: { id: string } | null; + company: { id: string } | null; + deal: { id: string } | null; + assignee: { id: string } | null; + }, + ) { + await this.automations.runTrigger('activity_done', { + 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, + }); + } + private async ensureExists( organizationId: string, id: string, diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 3fff50d..292a4bf 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -32,7 +32,7 @@ import { SmsService } from '../common/sms.service'; export type AutomationContext = { organizationId: string; - entityType: 'deal' | 'ticket'; + entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity'; entityId: string; title: string; personId?: string | null; @@ -182,15 +182,65 @@ export class AutomationsService { ticketPriority: null, }; } - const ticket = await this.prisma.ticket.findFirst({ - where: { id: ctx.entityId, organizationId: ctx.organizationId }, - select: { priority: true, personId: true, companyId: true }, - }); + if (ctx.entityType === 'ticket') { + const ticket = await this.prisma.ticket.findFirst({ + where: { id: ctx.entityId, organizationId: ctx.organizationId }, + select: { priority: true, personId: true, companyId: true }, + }); + return { + amount: null, + hasPerson: Boolean(ticket?.personId ?? ctx.personId), + hasCompany: Boolean(ticket?.companyId ?? ctx.companyId), + ticketPriority: ticket?.priority ?? null, + }; + } + if (ctx.entityType === 'invoice') { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: ctx.entityId, organizationId: ctx.organizationId }, + select: { + personId: true, + companyId: true, + discountAmount: true, + taxPercent: true, + lineItems: { select: { quantity: true, unitPrice: true } }, + }, + }); + let amount: number | null = null; + if (invoice) { + const subtotal = invoice.lineItems.reduce( + (sum, item) => sum + Number(item.quantity) * Number(item.unitPrice), + 0, + ); + const discount = Number(invoice.discountAmount ?? 0); + const taxPercent = Number(invoice.taxPercent ?? 0); + amount = Math.abs( + subtotal - discount + Math.max(0, subtotal - discount) * (taxPercent / 100), + ); + } + return { + amount, + hasPerson: Boolean(invoice?.personId ?? ctx.personId), + hasCompany: Boolean(invoice?.companyId ?? ctx.companyId), + ticketPriority: null, + }; + } + if (ctx.entityType === 'activity') { + const activity = await this.prisma.activity.findFirst({ + where: { id: ctx.entityId, organizationId: ctx.organizationId }, + select: { personId: true, companyId: true, dealId: true }, + }); + return { + amount: null, + hasPerson: Boolean(activity?.personId ?? ctx.personId), + hasCompany: Boolean(activity?.companyId ?? ctx.companyId), + ticketPriority: null, + }; + } return { amount: null, - hasPerson: Boolean(ticket?.personId ?? ctx.personId), - hasCompany: Boolean(ticket?.companyId ?? ctx.companyId), - ticketPriority: ticket?.priority ?? null, + hasPerson: Boolean(ctx.personId), + hasCompany: Boolean(ctx.companyId), + ticketPriority: null, }; } @@ -227,10 +277,7 @@ export class AutomationsService { type: 'automation', title: 'فعالیت خودکار', body: title, - link: - ctx.entityType === 'deal' - ? `/deals/${ctx.entityId}` - : `/tickets/${ctx.entityId}`, + link: entityLink(ctx), entityType: ctx.entityType, entityId: ctx.entityId, }); @@ -263,10 +310,7 @@ export class AutomationsService { const wantApp = channel === 'app' || channel === 'both'; const wantSms = channel === 'sms' || channel === 'both'; const smsText = (config.body?.trim() || title).slice(0, 160); - const link = - ctx.entityType === 'deal' - ? `/deals/${ctx.entityId}` - : `/tickets/${ctx.entityId}`; + const link = entityLink(ctx); if (config.notifyTarget === 'person') { if (wantSms) { @@ -413,3 +457,12 @@ function defaultTitle(action: AutomationAction, entityTitle: string) { if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`; return entityTitle; } + +function entityLink(ctx: AutomationContext) { + if (ctx.entityType === 'deal') return `/deals/${ctx.entityId}`; + if (ctx.entityType === 'ticket') return `/tickets/${ctx.entityId}`; + if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`; + if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`; + if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`; + return '/'; +} diff --git a/apps/api/src/automations/dto.ts b/apps/api/src/automations/dto.ts index 139d0f1..fd17423 100644 --- a/apps/api/src/automations/dto.ts +++ b/apps/api/src/automations/dto.ts @@ -12,7 +12,19 @@ import { ValidateIf, } from 'class-validator'; -const TRIGGERS = ['deal_won', 'ticket_resolved'] as const; +const TRIGGERS = [ + 'person_created', + 'lead_submitted', + 'deal_created', + 'deal_stage_changed', + 'deal_won', + 'deal_lost', + 'ticket_created', + 'ticket_resolved', + 'invoice_issued', + 'invoice_paid', + 'activity_done', +] as const; const ACTIONS = [ 'create_activity', 'create_notification', diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index ac0770b..2d64183 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { LoyaltyModule } from '../loyalty/loyalty.module'; +import { AutomationsModule } from '../automations/automations.module'; import { CompaniesController } from './companies.controller'; import { CompaniesService } from './companies.service'; import { PeopleController } from './people.controller'; @@ -7,7 +8,7 @@ import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; @Module({ - imports: [LoyaltyModule], + imports: [LoyaltyModule, AutomationsModule], controllers: [PeopleController, CompaniesController], providers: [PeopleService, CompaniesService, ContactsExcelService], exports: [PeopleService, CompaniesService], diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 82e46ca..be9c490 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -25,6 +25,7 @@ import { resolveOwnerId, } from '../common/record-scope'; import { LoyaltyService } from '../loyalty/loyalty.service'; +import { AutomationsService } from '../automations/automations.service'; const personInclude = { company: { select: { id: true, name: true } }, @@ -64,6 +65,7 @@ export class PeopleService { private readonly prisma: PrismaService, private readonly companies: CompaniesService, private readonly loyalty: LoyaltyService, + private readonly automations: AutomationsService, ) {} async list( @@ -149,6 +151,15 @@ export class PeopleService { include: personInclude, }); await this.loyalty.applyWelcomePoints(actor.organizationId, row.id); + await this.automations.runTrigger('person_created', { + organizationId: actor.organizationId, + entityType: 'person', + entityId: row.id, + title: `${row.firstName} ${row.lastName}`.trim(), + personId: row.id, + companyId: row.companyId, + ownerId: row.ownerId, + }); const fresh = await this.prisma.person.findFirst({ where: { id: row.id }, include: personInclude, diff --git a/apps/api/src/invoices/invoices.module.ts b/apps/api/src/invoices/invoices.module.ts index 058d12f..20a1408 100644 --- a/apps/api/src/invoices/invoices.module.ts +++ b/apps/api/src/invoices/invoices.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AutomationsModule } from '../automations/automations.module'; import { InvoicesController } from './invoices.controller'; import { InvoicesService } from './invoices.service'; @Module({ + imports: [AutomationsModule], controllers: [InvoicesController], providers: [InvoicesService], exports: [InvoicesService], diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index 8cb8be1..5527246 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -16,6 +16,7 @@ import type { 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'; type InvoiceActor = Pick; @@ -37,7 +38,10 @@ type InvoiceRow = Prisma.InvoiceGetPayload<{ include: typeof include }>; @Injectable() export class InvoicesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly automations: AutomationsService, + ) {} async list( organizationId: string, @@ -264,7 +268,11 @@ export class InvoicesService { }, include, }); - return this.toDto(row); + const created = this.toDto(row); + if (created.status !== 'draft' && created.status !== 'canceled') { + await this.fireIssued(organizationId, created); + } + return created; } async update( @@ -374,7 +382,15 @@ export class InvoicesService { await this.recomputeStatus(id); } - return this.get(organizationId, id, actor); + const row = await this.get(organizationId, id, actor); + const leftDraft = + existing.status === 'draft' && + row.status !== 'draft' && + row.status !== 'canceled'; + if (leftDraft) { + await this.fireIssued(organizationId, row); + } + return row; } async remove( @@ -443,7 +459,21 @@ export class InvoicesService { }); await this.recomputeStatus(invoiceId); - return this.get(organizationId, invoiceId, actor); + const row = await this.get(organizationId, invoiceId, actor); + if (invoice.status === 'draft') { + await this.fireIssued(organizationId, row); + } + await this.automations.runTrigger('invoice_paid', { + organizationId, + entityType: 'invoice', + 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, + }); + return row; } async removePayment( @@ -549,6 +579,19 @@ export class InvoicesService { return `FA-${String(count + 1).padStart(4, '0')}`; } + private async fireIssued(organizationId: string, row: InvoiceDto) { + await this.automations.runTrigger('invoice_issued', { + organizationId, + entityType: 'invoice', + 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, + }); + } + private async ensureExists( organizationId: string, id: string, diff --git a/apps/api/src/lead-forms/lead-forms.module.ts b/apps/api/src/lead-forms/lead-forms.module.ts index 4f8f025..1827115 100644 --- a/apps/api/src/lead-forms/lead-forms.module.ts +++ b/apps/api/src/lead-forms/lead-forms.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { AutomationsModule } from '../automations/automations.module'; import { LeadFormsController } from './lead-forms.controller'; import { LeadFormsService } from './lead-forms.service'; import { PublicLeadFormsController } from './public-lead-forms.controller'; @Module({ - imports: [AuthModule], + imports: [AuthModule, AutomationsModule], controllers: [LeadFormsController, PublicLeadFormsController], providers: [LeadFormsService], exports: [LeadFormsService], diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 95830da..60f95a7 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -14,6 +14,7 @@ import type { 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 { emptyToNull, normalizeEmail, @@ -66,6 +67,7 @@ export class LeadFormsService { constructor( private readonly prisma: PrismaService, private readonly files: LocalFileStorage, + private readonly automations: AutomationsService, ) {} async list(organizationId: string): Promise { @@ -275,6 +277,19 @@ export class LeadFormsService { }); } + const personName = [mapped.firstName, mapped.lastName] + .filter((p) => p?.trim()) + .join(' ') + .trim(); + await this.automations.runTrigger('lead_submitted', { + organizationId: form.organizationId, + entityType: 'person', + entityId: personId ?? form.id, + title: personName || form.name, + personId, + ownerId: ownerId ?? actorId, + }); + return { ok: true, thankYouMessage: form.thankYouMessage, diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 3c8035e..9c03492 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -147,6 +147,17 @@ export class DealsService { include: dealInclude, }); + await this.automations.runTrigger('deal_created', { + organizationId, + entityType: 'deal', + entityId: created.id, + title: created.title, + personId: created.personId, + companyId: created.companyId, + dealId: created.id, + ownerId: created.ownerId, + }); + return this.pipelines.toDealDto(created); } @@ -290,17 +301,24 @@ export class DealsService { }); } - if (status === 'won' && deal.status !== 'won') { - await this.automations.runTrigger('deal_won', { + if (deal.stageId !== stage.id) { + const ctx = { organizationId, - entityType: 'deal', + entityType: 'deal' as const, entityId: deal.id, title: deal.title, personId: deal.personId, companyId: deal.companyId, dealId: deal.id, ownerId: deal.ownerId, - }); + }; + await this.automations.runTrigger('deal_stage_changed', ctx); + if (status === 'won' && deal.status !== 'won') { + await this.automations.runTrigger('deal_won', ctx); + } + if (status === 'lost' && deal.status !== 'lost') { + await this.automations.runTrigger('deal_lost', ctx); + } } return this.pipelines.toDealDto(row); diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index 52e6099..1a4be79 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -228,6 +228,18 @@ export class TicketsService { }); } + await this.automations.runTrigger('ticket_created', { + organizationId: actor.organizationId, + entityType: 'ticket', + entityId: row.id, + title: row.subject, + personId: row.personId, + companyId: row.companyId, + dealId: row.dealId, + assigneeId: row.assigneeId, + ownerId: row.assigneeId, + }); + return this.toDto(row); } diff --git a/apps/web/src/components/automations/AutomationCanvas.vue b/apps/web/src/components/automations/AutomationCanvas.vue index 9000d71..486f69f 100644 --- a/apps/web/src/components/automations/AutomationCanvas.vue +++ b/apps/web/src/components/automations/AutomationCanvas.vue @@ -221,6 +221,8 @@ function nodeClass(step: AutomationStep) {