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:
@@ -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,
|
||||
|
||||
@@ -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 '/';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user