Let automations fire on more CRM events and open the studio from the main nav.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 21:43:17 +03:30
co-authored by Cursor
parent 51684daa61
commit f1aba1b386
20 changed files with 419 additions and 49 deletions
+2 -1
View File
@@ -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],
@@ -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<Actor, 'userId' | 'role'>;
@@ -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<ActivityDto> {
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,
+69 -16
View File
@@ -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 '/';
}
+13 -1
View File
@@ -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',
+2 -1
View File
@@ -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],
+11
View File
@@ -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,
+2
View File
@@ -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],
+47 -4
View File
@@ -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<Actor, 'userId' | 'role'>;
@@ -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,
+2 -1
View File
@@ -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],
@@ -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<LeadFormDto[]> {
@@ -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,
+22 -4
View File
@@ -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);
+12
View File
@@ -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);
}