Add public API keys, outbound webhooks, and bulk SMS campaigns with settings UI and org modules.
This commit is contained in:
+93
@@ -0,0 +1,93 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrganizationApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"prefix" TEXT NOT NULL,
|
||||||
|
"keyHash" TEXT NOT NULL,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OrganizationApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "WebhookEndpoint" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"secret" TEXT NOT NULL,
|
||||||
|
"events" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "WebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"endpointId" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"statusCode" INTEGER,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SmsCampaign" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
"scheduledAt" TIMESTAMP(3),
|
||||||
|
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sentCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"skippedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsCampaign_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SmsCampaignRecipient" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"campaignId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"mobile" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"error" TEXT,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsCampaignRecipient_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "OrganizationApiKey_keyHash_key" ON "OrganizationApiKey"("keyHash");
|
||||||
|
CREATE INDEX "OrganizationApiKey_organizationId_revokedAt_idx" ON "OrganizationApiKey"("organizationId", "revokedAt");
|
||||||
|
CREATE INDEX "OrganizationApiKey_prefix_idx" ON "OrganizationApiKey"("prefix");
|
||||||
|
CREATE INDEX "WebhookEndpoint_organizationId_isActive_idx" ON "WebhookEndpoint"("organizationId", "isActive");
|
||||||
|
CREATE INDEX "WebhookDelivery_organizationId_createdAt_idx" ON "WebhookDelivery"("organizationId", "createdAt");
|
||||||
|
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
|
||||||
|
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
|
||||||
|
CREATE INDEX "SmsCampaign_organizationId_status_scheduledAt_idx" ON "SmsCampaign"("organizationId", "status", "scheduledAt");
|
||||||
|
CREATE INDEX "SmsCampaignRecipient_campaignId_status_idx" ON "SmsCampaignRecipient"("campaignId", "status");
|
||||||
|
|
||||||
|
ALTER TABLE "OrganizationApiKey" ADD CONSTRAINT "OrganizationApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "WebhookEndpoint" ADD CONSTRAINT "WebhookEndpoint_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SmsCampaign" ADD CONSTRAINT "SmsCampaign_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SmsCampaignRecipient" ADD CONSTRAINT "SmsCampaignRecipient_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "SmsCampaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -194,6 +194,9 @@ model Organization {
|
|||||||
cheques Cheque[]
|
cheques Cheque[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
savedViews SavedView[]
|
savedViews SavedView[]
|
||||||
|
apiKeys OrganizationApiKey[]
|
||||||
|
webhookEndpoints WebhookEndpoint[]
|
||||||
|
smsCampaigns SmsCampaign[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -1597,3 +1600,98 @@ model SavedView {
|
|||||||
@@index([organizationId, scope])
|
@@index([organizationId, scope])
|
||||||
@@index([organizationId, ownerUserId])
|
@@index([organizationId, ownerUserId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// کلید API عمومی برای یکپارچهسازی خارجی
|
||||||
|
model OrganizationApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
/// پیشوند قابلنمایش (مثلاً mk_ab12)
|
||||||
|
prefix String
|
||||||
|
/// SHA-256 هش کلید کامل
|
||||||
|
keyHash String
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([keyHash])
|
||||||
|
@@index([organizationId, revokedAt])
|
||||||
|
@@index([prefix])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// وبهوک خروجی سازمان
|
||||||
|
model WebhookEndpoint {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
url String
|
||||||
|
secret String
|
||||||
|
events String[] @default([])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
deliveries WebhookDelivery[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([organizationId, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WebhookDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
endpointId String
|
||||||
|
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
|
||||||
|
event String
|
||||||
|
payload Json
|
||||||
|
status String @default("pending")
|
||||||
|
statusCode Int?
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastError String?
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
deliveredAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([organizationId, createdAt])
|
||||||
|
@@index([status, nextAttemptAt])
|
||||||
|
@@index([endpointId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// کمپین پیامک گروهی
|
||||||
|
model SmsCampaign {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
body String
|
||||||
|
/// draft | queued | sending | done | cancelled
|
||||||
|
status String @default("draft")
|
||||||
|
scheduledAt DateTime?
|
||||||
|
totalCount Int @default(0)
|
||||||
|
sentCount Int @default(0)
|
||||||
|
failedCount Int @default(0)
|
||||||
|
skippedCount Int @default(0)
|
||||||
|
createdById String?
|
||||||
|
recipients SmsCampaignRecipient[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([organizationId, status, scheduledAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsCampaignRecipient {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
campaignId String
|
||||||
|
campaign SmsCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
personId String?
|
||||||
|
mobile String?
|
||||||
|
/// pending | sent | failed | skipped
|
||||||
|
status String @default("pending")
|
||||||
|
error String?
|
||||||
|
sentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([campaignId, status])
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { PlatformModule } from './platform/platform.module';
|
|||||||
import { BillingModule } from './billing/billing.module';
|
import { BillingModule } from './billing/billing.module';
|
||||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||||
import { SavedViewsModule } from './saved-views/saved-views.module';
|
import { SavedViewsModule } from './saved-views/saved-views.module';
|
||||||
|
import { PublicApiModule } from './public-api/public-api.module';
|
||||||
|
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -79,6 +81,8 @@ import { SavedViewsModule } from './saved-views/saved-views.module';
|
|||||||
TahesabModule,
|
TahesabModule,
|
||||||
AuditLogsModule,
|
AuditLogsModule,
|
||||||
SavedViewsModule,
|
SavedViewsModule,
|
||||||
|
PublicApiModule,
|
||||||
|
SmsCampaignsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
|
import { PublicApiModule } from '../public-api/public-api.module';
|
||||||
import { CompaniesController } from './companies.controller';
|
import { CompaniesController } from './companies.controller';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { PeopleController } from './people.controller';
|
import { PeopleController } from './people.controller';
|
||||||
@@ -9,7 +10,12 @@ import { PeopleService } from './people.service';
|
|||||||
import { ContactsExcelService } from './contacts-excel.service';
|
import { ContactsExcelService } from './contacts-excel.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LoyaltyModule, AutomationsModule, MessageAutomationsModule],
|
imports: [
|
||||||
|
LoyaltyModule,
|
||||||
|
AutomationsModule,
|
||||||
|
MessageAutomationsModule,
|
||||||
|
PublicApiModule,
|
||||||
|
],
|
||||||
controllers: [PeopleController, CompaniesController],
|
controllers: [PeopleController, CompaniesController],
|
||||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||||
exports: [PeopleService, CompaniesService],
|
exports: [PeopleService, CompaniesService],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||||
|
import { WebhooksService } from '../public-api/webhooks.service';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
|
|
||||||
const personInclude = {
|
const personInclude = {
|
||||||
@@ -72,6 +73,7 @@ export class PeopleService {
|
|||||||
private readonly loyalty: LoyaltyService,
|
private readonly loyalty: LoyaltyService,
|
||||||
private readonly automations: AutomationsService,
|
private readonly automations: AutomationsService,
|
||||||
private readonly messageAutomations: MessageAutomationsService,
|
private readonly messageAutomations: MessageAutomationsService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -171,6 +173,13 @@ export class PeopleService {
|
|||||||
};
|
};
|
||||||
await this.automations.runTrigger('person_created', createdCtx);
|
await this.automations.runTrigger('person_created', createdCtx);
|
||||||
await this.messageAutomations.onEvent('person_created', createdCtx);
|
await this.messageAutomations.onEvent('person_created', createdCtx);
|
||||||
|
this.webhooks.emit(actor.organizationId, 'person.created', {
|
||||||
|
id: row.id,
|
||||||
|
fullName: createdCtx.title,
|
||||||
|
mobile: row.mobile,
|
||||||
|
email: row.email,
|
||||||
|
companyId: row.companyId,
|
||||||
|
});
|
||||||
const fresh = await this.prisma.person.findFirst({
|
const fresh = await this.prisma.person.findFirst({
|
||||||
where: { id: row.id },
|
where: { id: row.id },
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
@@ -221,6 +230,13 @@ export class PeopleService {
|
|||||||
data,
|
data,
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
});
|
});
|
||||||
|
this.webhooks.emit(actor.organizationId, 'person.updated', {
|
||||||
|
id: row.id,
|
||||||
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||||
|
mobile: row.mobile,
|
||||||
|
email: row.email,
|
||||||
|
companyId: row.companyId,
|
||||||
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { toJsonCustomFields } from '../common/custom-fields';
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||||
|
import { WebhooksService } from '../public-api/webhooks.service';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
canReassignOwner,
|
canReassignOwner,
|
||||||
@@ -41,6 +42,7 @@ export class DealsService {
|
|||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly automations: AutomationsService,
|
private readonly automations: AutomationsService,
|
||||||
private readonly messageAutomations: MessageAutomationsService,
|
private readonly messageAutomations: MessageAutomationsService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
@@ -161,6 +163,15 @@ export class DealsService {
|
|||||||
};
|
};
|
||||||
await this.automations.runTrigger('deal_created', createdCtx);
|
await this.automations.runTrigger('deal_created', createdCtx);
|
||||||
await this.messageAutomations.onEvent('deal_created', createdCtx);
|
await this.messageAutomations.onEvent('deal_created', createdCtx);
|
||||||
|
this.webhooks.emit(organizationId, 'deal.created', {
|
||||||
|
id: created.id,
|
||||||
|
title: created.title,
|
||||||
|
status: created.status,
|
||||||
|
amount: created.amount == null ? null : Number(created.amount),
|
||||||
|
currency: created.currency,
|
||||||
|
stageId: created.stageId,
|
||||||
|
pipelineId: created.pipelineId,
|
||||||
|
});
|
||||||
|
|
||||||
return this.pipelines.toDealDto(created);
|
return this.pipelines.toDealDto(created);
|
||||||
}
|
}
|
||||||
@@ -240,7 +251,17 @@ export class DealsService {
|
|||||||
await tx.deal.update({ where: { id }, data });
|
await tx.deal.update({ where: { id }, data });
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.get(organizationId, id, actor);
|
const updated = await this.get(organizationId, id, actor);
|
||||||
|
this.webhooks.emit(organizationId, 'deal.updated', {
|
||||||
|
id: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
status: updated.status,
|
||||||
|
amount: updated.amount,
|
||||||
|
currency: updated.currency,
|
||||||
|
stageId: updated.stageId,
|
||||||
|
pipelineId: updated.pipelineId,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async move(
|
async move(
|
||||||
@@ -321,10 +342,20 @@ export class DealsService {
|
|||||||
if (status === 'won' && deal.status !== 'won') {
|
if (status === 'won' && deal.status !== 'won') {
|
||||||
await this.automations.runTrigger('deal_won', ctx);
|
await this.automations.runTrigger('deal_won', ctx);
|
||||||
await this.messageAutomations.onEvent('deal_won', ctx);
|
await this.messageAutomations.onEvent('deal_won', ctx);
|
||||||
|
this.webhooks.emit(organizationId, 'deal.won', {
|
||||||
|
id: deal.id,
|
||||||
|
title: deal.title,
|
||||||
|
stageId: dto.stageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (status === 'lost' && deal.status !== 'lost') {
|
if (status === 'lost' && deal.status !== 'lost') {
|
||||||
await this.automations.runTrigger('deal_lost', ctx);
|
await this.automations.runTrigger('deal_lost', ctx);
|
||||||
await this.messageAutomations.onEvent('deal_lost', ctx);
|
await this.messageAutomations.onEvent('deal_lost', ctx);
|
||||||
|
this.webhooks.emit(organizationId, 'deal.lost', {
|
||||||
|
id: deal.id,
|
||||||
|
title: deal.title,
|
||||||
|
stageId: dto.stageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
|
import { PublicApiModule } from '../public-api/public-api.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -15,6 +16,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
forwardRef(() => AutomationsModule),
|
forwardRef(() => AutomationsModule),
|
||||||
MessageAutomationsModule,
|
MessageAutomationsModule,
|
||||||
|
PublicApiModule,
|
||||||
],
|
],
|
||||||
controllers: [PipelinesController, DealsController],
|
controllers: [PipelinesController, DealsController],
|
||||||
providers: [PipelinesService, DealsService, DealsExcelService],
|
providers: [PipelinesService, DealsService, DealsExcelService],
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyAuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKeys: ApiKeysService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = context.switchToHttp().getRequest<{
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
role: string;
|
||||||
|
apiKeyId?: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const header =
|
||||||
|
req.headers['x-api-key'] ??
|
||||||
|
(typeof req.headers.authorization === 'string' &&
|
||||||
|
req.headers.authorization.toLowerCase().startsWith('bearer mk_')
|
||||||
|
? req.headers.authorization.slice(7).trim()
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
const raw = Array.isArray(header) ? header[0] : header;
|
||||||
|
if (!raw?.trim()) throw new UnauthorizedException('کلید API لازم است');
|
||||||
|
|
||||||
|
const auth = await this.apiKeys.authenticate(raw.trim());
|
||||||
|
await this.billing.requireActiveForOrganization(auth.organizationId);
|
||||||
|
await this.billing.requireModule(auth.organizationId, 'public_api');
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
userId: `api-key:${auth.apiKeyId}`,
|
||||||
|
organizationId: auth.organizationId,
|
||||||
|
role: auth.role,
|
||||||
|
apiKeyId: auth.apiKeyId,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
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 { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
|
class CreateApiKeyDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api-keys')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('public_api')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(private readonly apiKeys: ApiKeysService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: { organizationId: string }) {
|
||||||
|
return this.apiKeys.list(user.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Body() dto: CreateApiKeyDto,
|
||||||
|
) {
|
||||||
|
return this.apiKeys.create(user.organizationId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
revoke(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.apiKeys.revoke(user.organizationId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import type {
|
||||||
|
CreateOrganizationApiKeyRequest,
|
||||||
|
CreateOrganizationApiKeyResponse,
|
||||||
|
OrganizationApiKeyDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeysService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(organizationId: string): Promise<OrganizationApiKeyDto[]> {
|
||||||
|
const rows = await this.prisma.organizationApiKey.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => this.toDto(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
organizationId: string,
|
||||||
|
dto: CreateOrganizationApiKeyRequest,
|
||||||
|
): Promise<CreateOrganizationApiKeyResponse> {
|
||||||
|
const name = dto.name?.trim();
|
||||||
|
if (!name) throw new BadRequestException('نام کلید الزامی است');
|
||||||
|
|
||||||
|
const rawKey = `mk_${randomBytes(24).toString('hex')}`;
|
||||||
|
const prefix = rawKey.slice(0, 10);
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
|
||||||
|
const row = await this.prisma.organizationApiKey.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
name,
|
||||||
|
prefix,
|
||||||
|
keyHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...this.toDto(row), rawKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(organizationId: string, id: string): Promise<OrganizationApiKeyDto> {
|
||||||
|
const existing = await this.prisma.organizationApiKey.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('کلید پیدا نشد');
|
||||||
|
if (existing.revokedAt) return this.toDto(existing);
|
||||||
|
|
||||||
|
const row = await this.prisma.organizationApiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
rawKey: string,
|
||||||
|
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
|
||||||
|
const key = rawKey.trim();
|
||||||
|
if (!key.startsWith('mk_') || key.length < 20) {
|
||||||
|
throw new UnauthorizedException('کلید API نامعتبر است');
|
||||||
|
}
|
||||||
|
const keyHash = this.hashKey(key);
|
||||||
|
const row = await this.prisma.organizationApiKey.findFirst({
|
||||||
|
where: { keyHash, revokedAt: null },
|
||||||
|
select: { id: true, organizationId: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
|
||||||
|
|
||||||
|
await this.prisma.organizationApiKey.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
apiKeyId: row.id,
|
||||||
|
role: 'owner',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
hashKey(raw: string): string {
|
||||||
|
return createHash('sha256').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
revokedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): OrganizationApiKeyDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
prefix: row.prefix,
|
||||||
|
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||||
|
revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||||
|
|
||||||
|
class ListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('public/v1')
|
||||||
|
@UseGuards(ApiKeyAuthGuard)
|
||||||
|
export class PublicApiV1Controller {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('people')
|
||||||
|
async listPeople(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query() query: ListQueryDto,
|
||||||
|
) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const where = {
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
...(query.q?.trim()
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ firstName: { contains: query.q.trim(), mode: 'insensitive' as const } },
|
||||||
|
{ lastName: { contains: query.q.trim(), mode: 'insensitive' as const } },
|
||||||
|
{ mobile: { contains: query.q.trim() } },
|
||||||
|
{ email: { contains: query.q.trim(), mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
this.prisma.person.count({ where }),
|
||||||
|
this.prisma.person.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
mobile: true,
|
||||||
|
phone: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
items: items.map((p) => ({
|
||||||
|
...p,
|
||||||
|
fullName: `${p.firstName} ${p.lastName}`.trim(),
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('people/:id')
|
||||||
|
async getPerson(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const p = await this.prisma.person.findFirst({
|
||||||
|
where: { id, organizationId: user.organizationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
mobile: true,
|
||||||
|
phone: true,
|
||||||
|
notes: true,
|
||||||
|
tags: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!p) return null;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
fullName: `${p.firstName} ${p.lastName}`.trim(),
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('deals')
|
||||||
|
async listDeals(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query() query: ListQueryDto,
|
||||||
|
) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const where = {
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
...(query.q?.trim()
|
||||||
|
? {
|
||||||
|
title: { contains: query.q.trim(), mode: 'insensitive' as const },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
this.prisma.deal.count({ where }),
|
||||||
|
this.prisma.deal.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
amount: true,
|
||||||
|
currency: true,
|
||||||
|
source: true,
|
||||||
|
stageId: true,
|
||||||
|
pipelineId: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
items: items.map((d) => ({
|
||||||
|
...d,
|
||||||
|
amount: d.amount == null ? null : Number(d.amount),
|
||||||
|
createdAt: d.createdAt.toISOString(),
|
||||||
|
updatedAt: d.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||||
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { PublicApiV1Controller } from './public-api-v1.controller';
|
||||||
|
import { WebhooksController } from './webhooks.controller';
|
||||||
|
import { WebhooksService } from './webhooks.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, BillingModule],
|
||||||
|
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
|
||||||
|
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
|
||||||
|
exports: [ApiKeysService, WebhooksService],
|
||||||
|
})
|
||||||
|
export class PublicApiModule {}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
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 { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
|
import { WebhooksService } from './webhooks.service';
|
||||||
|
|
||||||
|
class CreateWebhookDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
events!: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
secret?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateWebhookDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
events?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
secret?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('webhooks')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('public_api')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(private readonly webhooks: WebhooksService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: { organizationId: string }) {
|
||||||
|
return this.webhooks.listEndpoints(user.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('deliveries')
|
||||||
|
deliveries(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query('endpointId') endpointId?: string,
|
||||||
|
) {
|
||||||
|
return this.webhooks.listDeliveries(user.organizationId, endpointId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Body() dto: CreateWebhookDto,
|
||||||
|
) {
|
||||||
|
return this.webhooks.createEndpoint(user.organizationId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateWebhookDto,
|
||||||
|
) {
|
||||||
|
return this.webhooks.updateEndpoint(user.organizationId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
await this.webhooks.removeEndpoint(user.organizationId, id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { createHmac, randomBytes } from 'crypto';
|
||||||
|
import type {
|
||||||
|
CreateWebhookEndpointRequest,
|
||||||
|
UpdateWebhookEndpointRequest,
|
||||||
|
WebhookDeliveryDto,
|
||||||
|
WebhookEndpointDto,
|
||||||
|
WebhookEvent,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { WEBHOOK_EVENTS } from '@mento/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebhooksService {
|
||||||
|
private readonly logger = new Logger(WebhooksService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async listEndpoints(organizationId: string): Promise<WebhookEndpointDto[]> {
|
||||||
|
const rows = await this.prisma.webhookEndpoint.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => this.toEndpointDto(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEndpoint(
|
||||||
|
organizationId: string,
|
||||||
|
dto: CreateWebhookEndpointRequest,
|
||||||
|
): Promise<WebhookEndpointDto & { secret: string }> {
|
||||||
|
const name = dto.name?.trim();
|
||||||
|
const url = dto.url?.trim();
|
||||||
|
if (!name) throw new BadRequestException('نام وبهوک الزامی است');
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) {
|
||||||
|
throw new BadRequestException('آدرس وبهوک باید با http یا https شروع شود');
|
||||||
|
}
|
||||||
|
const events = this.normalizeEvents(dto.events);
|
||||||
|
if (!events.length) throw new BadRequestException('حداقل یک رویداد انتخاب کنید');
|
||||||
|
|
||||||
|
const secret = dto.secret?.trim() || randomBytes(24).toString('hex');
|
||||||
|
const row = await this.prisma.webhookEndpoint.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
secret,
|
||||||
|
events,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...this.toEndpointDto(row), secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEndpoint(
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateWebhookEndpointRequest,
|
||||||
|
): Promise<WebhookEndpointDto> {
|
||||||
|
const existing = await this.prisma.webhookEndpoint.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('وبهوک پیدا نشد');
|
||||||
|
|
||||||
|
const data: Prisma.WebhookEndpointUpdateInput = {};
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('نام وبهوک الزامی است');
|
||||||
|
data.name = name;
|
||||||
|
}
|
||||||
|
if (dto.url !== undefined) {
|
||||||
|
const url = dto.url.trim();
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) {
|
||||||
|
throw new BadRequestException('آدرس وبهوک نامعتبر است');
|
||||||
|
}
|
||||||
|
data.url = url;
|
||||||
|
}
|
||||||
|
if (dto.events !== undefined) {
|
||||||
|
const events = this.normalizeEvents(dto.events);
|
||||||
|
if (!events.length) throw new BadRequestException('حداقل یک رویداد انتخاب کنید');
|
||||||
|
data.events = events;
|
||||||
|
}
|
||||||
|
if (dto.secret !== undefined && dto.secret.trim()) {
|
||||||
|
data.secret = dto.secret.trim();
|
||||||
|
}
|
||||||
|
if (dto.isActive !== undefined) data.isActive = Boolean(dto.isActive);
|
||||||
|
|
||||||
|
const row = await this.prisma.webhookEndpoint.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return this.toEndpointDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeEndpoint(organizationId: string, id: string): Promise<void> {
|
||||||
|
const existing = await this.prisma.webhookEndpoint.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('وبهوک پیدا نشد');
|
||||||
|
await this.prisma.webhookEndpoint.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDeliveries(
|
||||||
|
organizationId: string,
|
||||||
|
endpointId?: string,
|
||||||
|
): Promise<WebhookDeliveryDto[]> {
|
||||||
|
const rows = await this.prisma.webhookDelivery.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
...(endpointId ? { endpointId } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
event: row.event,
|
||||||
|
status: row.status,
|
||||||
|
statusCode: row.statusCode,
|
||||||
|
attempts: row.attempts,
|
||||||
|
lastError: row.lastError,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
deliveredAt: row.deliveredAt?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget enqueue for domain events */
|
||||||
|
emit(
|
||||||
|
organizationId: string,
|
||||||
|
event: WebhookEvent,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
void this.enqueue(organizationId, event, data).catch((err) => {
|
||||||
|
this.logger.warn(`webhook enqueue failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(
|
||||||
|
organizationId: string,
|
||||||
|
event: WebhookEvent,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const endpoints = await this.prisma.webhookEndpoint.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
isActive: true,
|
||||||
|
events: { has: event },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!endpoints.length) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: randomBytes(8).toString('hex'),
|
||||||
|
event,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.webhookDelivery.createMany({
|
||||||
|
data: endpoints.map((ep) => ({
|
||||||
|
organizationId,
|
||||||
|
endpointId: ep.id,
|
||||||
|
event,
|
||||||
|
payload: payload as Prisma.InputJsonValue,
|
||||||
|
status: 'pending',
|
||||||
|
nextAttemptAt: new Date(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('*/1 * * * *')
|
||||||
|
async processDue(): Promise<number> {
|
||||||
|
const now = new Date();
|
||||||
|
const pending = await this.prisma.webhookDelivery.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['pending', 'failed'] },
|
||||||
|
nextAttemptAt: { lte: now },
|
||||||
|
attempts: { lt: 5 },
|
||||||
|
},
|
||||||
|
include: { endpoint: true },
|
||||||
|
orderBy: { nextAttemptAt: 'asc' },
|
||||||
|
take: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
for (const row of pending) {
|
||||||
|
if (!row.endpoint.isActive) {
|
||||||
|
await this.prisma.webhookDelivery.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { status: 'cancelled', lastError: 'endpoint inactive' },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.deliver(row.id);
|
||||||
|
done += 1;
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliver(id: string): Promise<void> {
|
||||||
|
const row = await this.prisma.webhookDelivery.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { endpoint: true },
|
||||||
|
});
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const body = JSON.stringify(row.payload);
|
||||||
|
const signature = createHmac('sha256', row.endpoint.secret)
|
||||||
|
.update(body)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(row.endpoint.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Mento-Event': row.event,
|
||||||
|
'X-Mento-Signature': `sha256=${signature}`,
|
||||||
|
'User-Agent': 'MentoCRM-Webhook/1.0',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await this.prisma.webhookDelivery.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'delivered',
|
||||||
|
statusCode: res.status,
|
||||||
|
attempts: row.attempts + 1,
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errText = await res.text().catch(() => '');
|
||||||
|
await this.fail(row.id, row.attempts + 1, res.status, errText.slice(0, 400));
|
||||||
|
} catch (err) {
|
||||||
|
await this.fail(row.id, row.attempts + 1, null, String(err).slice(0, 400));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fail(
|
||||||
|
id: string,
|
||||||
|
attempts: number,
|
||||||
|
statusCode: number | null,
|
||||||
|
lastError: string,
|
||||||
|
) {
|
||||||
|
const delayMin = Math.min(60, 2 ** Math.min(attempts, 5));
|
||||||
|
await this.prisma.webhookDelivery.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: attempts >= 5 ? 'dead' : 'failed',
|
||||||
|
statusCode,
|
||||||
|
attempts,
|
||||||
|
lastError,
|
||||||
|
nextAttemptAt: new Date(Date.now() + delayMin * 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEvents(events: string[]): string[] {
|
||||||
|
const allowed = new Set<string>(WEBHOOK_EVENTS);
|
||||||
|
return [...new Set((events ?? []).map((e) => e.trim()).filter((e) => allowed.has(e)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEndpointDto(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): WebhookEndpointDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
url: row.url,
|
||||||
|
events: row.events,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
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 { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
|
import { SmsCampaignsService } from './sms-campaigns.service';
|
||||||
|
|
||||||
|
class CreateSmsCampaignDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(900)
|
||||||
|
body!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
personIds!: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsDateString()
|
||||||
|
scheduledAt?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
queueNow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
limit?: number = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('sms-campaigns')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('sms_campaigns')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
export class SmsCampaignsController {
|
||||||
|
constructor(private readonly campaigns: SmsCampaignsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query() query: ListQueryDto,
|
||||||
|
) {
|
||||||
|
return this.campaigns.list(
|
||||||
|
user.organizationId,
|
||||||
|
query.page ?? 1,
|
||||||
|
query.limit ?? 20,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
get(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.campaigns.get(user.organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser()
|
||||||
|
user: { organizationId: string; userId: string; role: string },
|
||||||
|
@Body() dto: CreateSmsCampaignDto,
|
||||||
|
) {
|
||||||
|
return this.campaigns.create(
|
||||||
|
{
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
userId: user.userId,
|
||||||
|
role: user.role as 'owner' | 'admin' | 'sales' | 'support',
|
||||||
|
},
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/queue')
|
||||||
|
queue(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.campaigns.queue(user.organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
cancel(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.campaigns.cancel(user.organizationId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { SmsService } from '../common/sms.service';
|
||||||
|
import { SmsCampaignsController } from './sms-campaigns.controller';
|
||||||
|
import { SmsCampaignsService } from './sms-campaigns.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, BillingModule],
|
||||||
|
controllers: [SmsCampaignsController],
|
||||||
|
providers: [SmsCampaignsService, SmsService],
|
||||||
|
exports: [SmsCampaignsService],
|
||||||
|
})
|
||||||
|
export class SmsCampaignsModule {}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import type {
|
||||||
|
CreateSmsCampaignRequest,
|
||||||
|
SmsCampaignDto,
|
||||||
|
SmsCampaignListResponse,
|
||||||
|
SmsCampaignStatus,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SmsService } from '../common/sms.service';
|
||||||
|
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsCampaignsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
organizationId: string,
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<SmsCampaignListResponse> {
|
||||||
|
const where = { organizationId };
|
||||||
|
const [total, rows] = await Promise.all([
|
||||||
|
this.prisma.smsCampaign.count({ where }),
|
||||||
|
this.prisma.smsCampaign.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
items: rows.map((row) => this.toDto(row)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(organizationId: string, id: string): Promise<SmsCampaignDto> {
|
||||||
|
const row = await this.prisma.smsCampaign.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('کمپین پیدا نشد');
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
actor: Actor,
|
||||||
|
dto: CreateSmsCampaignRequest,
|
||||||
|
): Promise<SmsCampaignDto> {
|
||||||
|
const name = dto.name?.trim();
|
||||||
|
const body = dto.body?.trim();
|
||||||
|
if (!name) throw new BadRequestException('نام کمپین الزامی است');
|
||||||
|
if (!body || body.length < 2) {
|
||||||
|
throw new BadRequestException('متن پیامک کوتاه است');
|
||||||
|
}
|
||||||
|
const personIds = [...new Set((dto.personIds ?? []).filter(Boolean))];
|
||||||
|
if (!personIds.length) {
|
||||||
|
throw new BadRequestException('حداقل یک مخاطب انتخاب کنید');
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = await this.prisma.person.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: personIds },
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...ownerWhere(actor),
|
||||||
|
},
|
||||||
|
select: { id: true, mobile: true },
|
||||||
|
});
|
||||||
|
if (!people.length) throw new BadRequestException('مخاطب معتبری پیدا نشد');
|
||||||
|
|
||||||
|
const queueNow = Boolean(dto.queueNow);
|
||||||
|
const scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null;
|
||||||
|
if (scheduledAt && Number.isNaN(scheduledAt.getTime())) {
|
||||||
|
throw new BadRequestException('زمانبندی نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: SmsCampaignStatus =
|
||||||
|
queueNow || scheduledAt ? 'queued' : 'draft';
|
||||||
|
|
||||||
|
const campaign = await this.prisma.smsCampaign.create({
|
||||||
|
data: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
name,
|
||||||
|
body,
|
||||||
|
status,
|
||||||
|
scheduledAt: queueNow ? new Date() : scheduledAt,
|
||||||
|
totalCount: people.length,
|
||||||
|
createdById: actor.userId,
|
||||||
|
recipients: {
|
||||||
|
create: people.map((p) => ({
|
||||||
|
personId: p.id,
|
||||||
|
mobile: p.mobile,
|
||||||
|
status: p.mobile ? 'pending' : 'skipped',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipped = people.filter((p) => !p.mobile).length;
|
||||||
|
if (skipped) {
|
||||||
|
await this.prisma.smsCampaign.update({
|
||||||
|
where: { id: campaign.id },
|
||||||
|
data: { skippedCount: skipped },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.get(actor.organizationId, campaign.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queue(organizationId: string, id: string): Promise<SmsCampaignDto> {
|
||||||
|
const row = await this.prisma.smsCampaign.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('کمپین پیدا نشد');
|
||||||
|
if (row.status !== 'draft' && row.status !== 'cancelled') {
|
||||||
|
throw new BadRequestException('این کمپین قابل صفبندی نیست');
|
||||||
|
}
|
||||||
|
await this.prisma.smsCampaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'queued',
|
||||||
|
scheduledAt: row.scheduledAt ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(organizationId: string, id: string): Promise<SmsCampaignDto> {
|
||||||
|
const row = await this.prisma.smsCampaign.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('کمپین پیدا نشد');
|
||||||
|
if (row.status === 'done') {
|
||||||
|
throw new BadRequestException('کمپین تمامشده قابل لغو نیست');
|
||||||
|
}
|
||||||
|
await this.prisma.smsCampaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'cancelled' },
|
||||||
|
});
|
||||||
|
return this.get(organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('*/1 * * * *')
|
||||||
|
async processDue(): Promise<number> {
|
||||||
|
if (!this.sms.isConfigured()) return 0;
|
||||||
|
const now = new Date();
|
||||||
|
const campaigns = await this.prisma.smsCampaign.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'queued',
|
||||||
|
OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
await this.processCampaign(campaign.id);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processCampaign(campaignId: string): Promise<void> {
|
||||||
|
await this.prisma.smsCampaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: { status: 'sending' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaign = await this.prisma.smsCampaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
if (!campaign) return;
|
||||||
|
|
||||||
|
const batch = await this.prisma.smsCampaignRecipient.findMany({
|
||||||
|
where: { campaignId, status: 'pending' },
|
||||||
|
take: 40,
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const recipient of batch) {
|
||||||
|
if (!recipient.mobile) {
|
||||||
|
await this.prisma.smsCampaignRecipient.update({
|
||||||
|
where: { id: recipient.id },
|
||||||
|
data: { status: 'skipped', error: 'موبایل ندارد' },
|
||||||
|
});
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await this.sms.sendSmsDetailed(
|
||||||
|
recipient.mobile,
|
||||||
|
campaign.body,
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
await this.prisma.smsCampaignRecipient.update({
|
||||||
|
where: { id: recipient.id },
|
||||||
|
data: { status: 'sent', sentAt: new Date(), error: null },
|
||||||
|
});
|
||||||
|
sent += 1;
|
||||||
|
} else {
|
||||||
|
await this.prisma.smsCampaignRecipient.update({
|
||||||
|
where: { id: recipient.id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
error: result.reason?.slice(0, 200) ?? 'ارسال ناموفق',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await this.prisma.smsCampaignRecipient.count({
|
||||||
|
where: { campaignId, status: 'pending' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.smsCampaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: {
|
||||||
|
sentCount: { increment: sent },
|
||||||
|
failedCount: { increment: failed },
|
||||||
|
skippedCount: { increment: skipped },
|
||||||
|
status: remaining > 0 ? 'queued' : 'done',
|
||||||
|
scheduledAt: remaining > 0 ? new Date() : campaign.scheduledAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
status: string;
|
||||||
|
scheduledAt: Date | null;
|
||||||
|
totalCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): SmsCampaignDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
body: row.body,
|
||||||
|
status: row.status as SmsCampaignStatus,
|
||||||
|
scheduledAt: row.scheduledAt?.toISOString() ?? null,
|
||||||
|
totalCount: row.totalCount,
|
||||||
|
sentCount: row.sentCount,
|
||||||
|
failedCount: row.failedCount,
|
||||||
|
skippedCount: row.skippedCount,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,8 @@ const supportActive = computed(() => pathIn(['/tickets']));
|
|||||||
const automationsActive = computed(
|
const automationsActive = computed(
|
||||||
() =>
|
() =>
|
||||||
route.path.startsWith('/settings/automations') ||
|
route.path.startsWith('/settings/automations') ||
|
||||||
route.path.startsWith('/settings/message-automations'),
|
route.path.startsWith('/settings/message-automations') ||
|
||||||
|
route.path.startsWith('/settings/sms-campaigns'),
|
||||||
);
|
);
|
||||||
const toolsActive = computed(() =>
|
const toolsActive = computed(() =>
|
||||||
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
|
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
|
||||||
@@ -62,6 +63,8 @@ const toolsActive = computed(() =>
|
|||||||
const settingsActive = computed(() =>
|
const settingsActive = computed(() =>
|
||||||
pathIn([
|
pathIn([
|
||||||
'/settings/team',
|
'/settings/team',
|
||||||
|
'/settings/audit-logs',
|
||||||
|
'/settings/public-api',
|
||||||
'/settings/custom-fields',
|
'/settings/custom-fields',
|
||||||
'/settings/currencies',
|
'/settings/currencies',
|
||||||
'/settings/tahesab',
|
'/settings/tahesab',
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
icon: 'send',
|
icon: 'send',
|
||||||
module: 'message_automations',
|
module: 'message_automations',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/sms-campaigns',
|
||||||
|
title: 'پیامک گروهی',
|
||||||
|
icon: 'send',
|
||||||
|
module: 'sms_campaigns',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -247,6 +253,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
admin: true,
|
admin: true,
|
||||||
module: 'message_automations',
|
module: 'message_automations',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/sms-campaigns',
|
||||||
|
title: 'پیامک گروهی',
|
||||||
|
desc: 'ارسال انبوه به مخاطبان انتخابشده',
|
||||||
|
icon: 'send',
|
||||||
|
tint: 'mint',
|
||||||
|
admin: true,
|
||||||
|
module: 'sms_campaigns',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -344,6 +359,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
module: 'audit_logs',
|
module: 'audit_logs',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/public-api',
|
||||||
|
title: 'API و وبهوک',
|
||||||
|
desc: 'کلید عمومی و ارسال رویداد به سرور شما',
|
||||||
|
icon: 'apps-plus',
|
||||||
|
tint: 'sky',
|
||||||
|
module: 'public_api',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/custom-fields',
|
to: '/settings/custom-fields',
|
||||||
title: 'فیلدهای سفارشی',
|
title: 'فیلدهای سفارشی',
|
||||||
|
|||||||
@@ -258,6 +258,18 @@ export const router = createRouter({
|
|||||||
component: () => import('@/views/AuditLogsView.vue'),
|
component: () => import('@/views/AuditLogsView.vue'),
|
||||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/public-api',
|
||||||
|
name: 'public-api',
|
||||||
|
component: () => import('@/views/PublicApiSettingsView.vue'),
|
||||||
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/sms-campaigns',
|
||||||
|
name: 'sms-campaigns',
|
||||||
|
component: () => import('@/views/SmsCampaignsView.vue'),
|
||||||
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/custom-fields',
|
path: 'settings/custom-fields',
|
||||||
name: 'custom-fields',
|
name: 'custom-fields',
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type {
|
||||||
|
CreateOrganizationApiKeyResponse,
|
||||||
|
CreateWebhookEndpointResponse,
|
||||||
|
OrganizationApiKeyDto,
|
||||||
|
WebhookDeliveryDto,
|
||||||
|
WebhookEndpointDto,
|
||||||
|
WebhookEvent,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { WEBHOOK_EVENTS } from '@mento/shared';
|
||||||
|
import {
|
||||||
|
UiButton,
|
||||||
|
UiCard,
|
||||||
|
UiEmptyState,
|
||||||
|
UiField,
|
||||||
|
UiInput,
|
||||||
|
UiPageHeader,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const ok = ref('');
|
||||||
|
|
||||||
|
const keys = ref<OrganizationApiKeyDto[]>([]);
|
||||||
|
const endpoints = ref<WebhookEndpointDto[]>([]);
|
||||||
|
const deliveries = ref<WebhookDeliveryDto[]>([]);
|
||||||
|
const newKeyName = ref('');
|
||||||
|
const createdRawKey = ref('');
|
||||||
|
const createdWebhookSecret = ref('');
|
||||||
|
|
||||||
|
const whName = ref('');
|
||||||
|
const whUrl = ref('');
|
||||||
|
const whEvents = ref<string[]>(['person.created', 'deal.created']);
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<WebhookEvent, string> = {
|
||||||
|
'person.created': 'ایجاد شخص',
|
||||||
|
'person.updated': 'ویرایش شخص',
|
||||||
|
'deal.created': 'ایجاد معامله',
|
||||||
|
'deal.updated': 'ویرایش معامله',
|
||||||
|
'deal.won': 'برد معامله',
|
||||||
|
'deal.lost': 'شکست معامله',
|
||||||
|
'invoice.paid': 'پرداخت فاکتور',
|
||||||
|
'activity.created': 'ایجاد فعالیت',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const [keysRes, epRes, delRes] = await Promise.all([
|
||||||
|
api.get<OrganizationApiKeyDto[]>('/api-keys'),
|
||||||
|
api.get<WebhookEndpointDto[]>('/webhooks'),
|
||||||
|
api.get<WebhookDeliveryDto[]>('/webhooks/deliveries'),
|
||||||
|
]);
|
||||||
|
keys.value = keysRes.data;
|
||||||
|
endpoints.value = epRes.data;
|
||||||
|
deliveries.value = delRes.data;
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||||
|
error.value = 'ماژول API و وبهوک برای این محیط فعال نیست.';
|
||||||
|
} else {
|
||||||
|
error.value = 'بارگذاری تنظیمات ناموفق بود.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createKey() {
|
||||||
|
if (!newKeyName.value.trim()) {
|
||||||
|
error.value = 'نام کلید را وارد کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
|
createdRawKey.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<CreateOrganizationApiKeyResponse>(
|
||||||
|
'/api-keys',
|
||||||
|
{ name: newKeyName.value.trim() },
|
||||||
|
);
|
||||||
|
createdRawKey.value = data.rawKey;
|
||||||
|
newKeyName.value = '';
|
||||||
|
ok.value = 'کلید ساخته شد. همین الان کپی کنید؛ دوباره نمایش داده نمیشود.';
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
error.value = 'ساخت کلید ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeKey(id: string) {
|
||||||
|
if (!confirm('این کلید باطل شود؟')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api-keys/${id}`);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
error.value = 'باطلسازی کلید ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEvent(ev: string) {
|
||||||
|
if (whEvents.value.includes(ev)) {
|
||||||
|
whEvents.value = whEvents.value.filter((e) => e !== ev);
|
||||||
|
} else {
|
||||||
|
whEvents.value = [...whEvents.value, ev];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWebhook() {
|
||||||
|
if (!whName.value.trim() || !whUrl.value.trim() || !whEvents.value.length) {
|
||||||
|
error.value = 'نام، آدرس و حداقل یک رویداد لازم است.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
createdWebhookSecret.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<CreateWebhookEndpointResponse>('/webhooks', {
|
||||||
|
name: whName.value.trim(),
|
||||||
|
url: whUrl.value.trim(),
|
||||||
|
events: whEvents.value,
|
||||||
|
});
|
||||||
|
whName.value = '';
|
||||||
|
whUrl.value = '';
|
||||||
|
createdWebhookSecret.value = data.secret;
|
||||||
|
ok.value = 'وبهوک ذخیره شد. secret را همین الان کپی کنید.';
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
error.value = 'ذخیره وبهوک ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWebhook(id: string) {
|
||||||
|
if (!confirm('وبهوک حذف شود؟')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/webhooks/${id}`);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
error.value = 'حذف وبهوک ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ok.value = 'کپی شد.';
|
||||||
|
} catch {
|
||||||
|
error.value = 'کپی در کلیپبورد ممکن نشد.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<UiPageHeader
|
||||||
|
title="API و وبهوک"
|
||||||
|
description="کلید عمومی برای یکپارچهسازی و ارسال رویداد به سرور شما"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||||
|
<UiIcon name="refresh" />
|
||||||
|
بروزرسانی
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</UiPageHeader>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||||
|
|
||||||
|
<UiCard title="کلیدهای API">
|
||||||
|
<p class="hint">
|
||||||
|
هدر درخواست:
|
||||||
|
<code>X-Api-Key: mk_…</code>
|
||||||
|
یا
|
||||||
|
<code>Authorization: Bearer mk_…</code>
|
||||||
|
— مسیرها:
|
||||||
|
<code>/api/public/v1/people</code>
|
||||||
|
و
|
||||||
|
<code>/api/public/v1/deals</code>
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<UiField label="نام کلید">
|
||||||
|
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" />
|
||||||
|
</UiField>
|
||||||
|
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="createdRawKey" class="raw-key">
|
||||||
|
<code>{{ createdRawKey }}</code>
|
||||||
|
<UiButton variant="secondary" @click="copyText(createdRawKey)">کپی</UiButton>
|
||||||
|
</div>
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!loading && !keys.length"
|
||||||
|
title="کلیدی نیست"
|
||||||
|
description="برای دسترسی خارجی یک کلید بسازید."
|
||||||
|
/>
|
||||||
|
<div v-else class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>نام</th>
|
||||||
|
<th>پیشوند</th>
|
||||||
|
<th>آخرین استفاده</th>
|
||||||
|
<th>وضعیت</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="k in keys" :key="k.id">
|
||||||
|
<td>{{ k.name }}</td>
|
||||||
|
<td><code>{{ k.prefix }}…</code></td>
|
||||||
|
<td>
|
||||||
|
{{
|
||||||
|
k.lastUsedAt
|
||||||
|
? formatJalaliNumeric(new Date(k.lastUsedAt))
|
||||||
|
: '—'
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>{{ k.revokedAt ? 'باطل' : 'فعال' }}</td>
|
||||||
|
<td>
|
||||||
|
<UiButton
|
||||||
|
v-if="!k.revokedAt"
|
||||||
|
variant="secondary"
|
||||||
|
@click="revokeKey(k.id)"
|
||||||
|
>
|
||||||
|
باطلسازی
|
||||||
|
</UiButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
|
<UiCard title="وبهوکهای خروجی">
|
||||||
|
<div class="form-grid">
|
||||||
|
<UiField label="نام">
|
||||||
|
<UiInput v-model="whName" placeholder="مثلاً n8n" />
|
||||||
|
</UiField>
|
||||||
|
<UiField label="آدرس URL">
|
||||||
|
<UiInput v-model="whUrl" placeholder="https://example.com/hook" />
|
||||||
|
</UiField>
|
||||||
|
</div>
|
||||||
|
<div class="events">
|
||||||
|
<button
|
||||||
|
v-for="ev in WEBHOOK_EVENTS"
|
||||||
|
:key="ev"
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: whEvents.includes(ev) }"
|
||||||
|
@click="toggleEvent(ev)"
|
||||||
|
>
|
||||||
|
{{ EVENT_LABELS[ev] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<UiButton :loading="saving" @click="createWebhook">افزودن وبهوک</UiButton>
|
||||||
|
<div v-if="createdWebhookSecret" class="raw-key">
|
||||||
|
<span>HMAC secret:</span>
|
||||||
|
<code>{{ createdWebhookSecret }}</code>
|
||||||
|
<UiButton variant="secondary" @click="copyText(createdWebhookSecret)">کپی</UiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!loading && !endpoints.length"
|
||||||
|
title="وبهوکی نیست"
|
||||||
|
description="رویدادهای CRM به آدرس شما POST میشوند."
|
||||||
|
/>
|
||||||
|
<div v-else class="list">
|
||||||
|
<article v-for="ep in endpoints" :key="ep.id" class="item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ ep.name }}</strong>
|
||||||
|
<p class="muted">{{ ep.url }}</p>
|
||||||
|
<p class="muted">
|
||||||
|
{{ ep.events.map((e) => EVENT_LABELS[e as WebhookEvent] ?? e).join('، ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UiButton variant="secondary" @click="removeWebhook(ep.id)">حذف</UiButton>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
|
<UiCard title="آخرین ارسالهای وبهوک">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!deliveries.length"
|
||||||
|
title="ارسالی ثبت نشده"
|
||||||
|
description="پس از وقوع رویدادها اینجا دیده میشوند."
|
||||||
|
/>
|
||||||
|
<div v-else class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>زمان</th>
|
||||||
|
<th>رویداد</th>
|
||||||
|
<th>وضعیت</th>
|
||||||
|
<th>کد</th>
|
||||||
|
<th>تلاش</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="d in deliveries" :key="d.id">
|
||||||
|
<td>{{ formatJalaliNumeric(new Date(d.createdAt)) }}</td>
|
||||||
|
<td>{{ EVENT_LABELS[d.event as WebhookEvent] ?? d.event }}</td>
|
||||||
|
<td>{{ d.status }}</td>
|
||||||
|
<td>{{ d.statusCode ?? '—' }}</td>
|
||||||
|
<td>{{ toFaDigits(d.attempts) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.raw-key {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f0fdfa;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.raw-key code {
|
||||||
|
word-break: break-all;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.events {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.chip.on {
|
||||||
|
background: #f0fdfa;
|
||||||
|
border-color: #99f6e4;
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: right;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.row,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type {
|
||||||
|
PaginatedResponse,
|
||||||
|
PersonDto,
|
||||||
|
SmsCampaignDto,
|
||||||
|
SmsCampaignListResponse,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
UiButton,
|
||||||
|
UiCard,
|
||||||
|
UiEmptyState,
|
||||||
|
UiField,
|
||||||
|
UiInput,
|
||||||
|
UiPageHeader,
|
||||||
|
UiTextarea,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const ok = ref('');
|
||||||
|
const campaigns = ref<SmsCampaignDto[]>([]);
|
||||||
|
const people = ref<PersonDto[]>([]);
|
||||||
|
const selectedIds = ref<string[]>([]);
|
||||||
|
const name = ref('');
|
||||||
|
const body = ref('');
|
||||||
|
const queueNow = ref(true);
|
||||||
|
const peopleQ = ref('');
|
||||||
|
|
||||||
|
const filteredPeople = computed(() => {
|
||||||
|
const q = peopleQ.value.trim();
|
||||||
|
if (!q) return people.value.slice(0, 80);
|
||||||
|
return people.value
|
||||||
|
.filter((p) => {
|
||||||
|
const hay = `${p.fullName} ${p.mobile ?? ''} ${p.email ?? ''}`.toLowerCase();
|
||||||
|
return hay.includes(q.toLowerCase());
|
||||||
|
})
|
||||||
|
.slice(0, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
draft: 'پیشنویس',
|
||||||
|
queued: 'در صف',
|
||||||
|
sending: 'در حال ارسال',
|
||||||
|
done: 'تمامشده',
|
||||||
|
cancelled: 'لغو شده',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const [campRes, peopleRes] = await Promise.all([
|
||||||
|
api.get<SmsCampaignListResponse>('/sms-campaigns'),
|
||||||
|
api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||||
|
params: { page: 1, limit: 200 },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
campaigns.value = campRes.data.items;
|
||||||
|
people.value = peopleRes.data.items;
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||||
|
error.value = 'ماژول پیامک گروهی برای این محیط فعال نیست.';
|
||||||
|
} else {
|
||||||
|
error.value = 'بارگذاری ناموفق بود.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePerson(id: string) {
|
||||||
|
if (selectedIds.value.includes(id)) {
|
||||||
|
selectedIds.value = selectedIds.value.filter((x) => x !== id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value = [...selectedIds.value, id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVisible() {
|
||||||
|
const ids = filteredPeople.value.map((p) => p.id);
|
||||||
|
selectedIds.value = [...new Set([...selectedIds.value, ...ids])];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCampaign() {
|
||||||
|
if (!name.value.trim() || body.value.trim().length < 2) {
|
||||||
|
error.value = 'نام و متن پیامک را کامل کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedIds.value.length) {
|
||||||
|
error.value = 'حداقل یک مخاطب انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
|
try {
|
||||||
|
await api.post('/sms-campaigns', {
|
||||||
|
name: name.value.trim(),
|
||||||
|
body: body.value.trim(),
|
||||||
|
personIds: selectedIds.value,
|
||||||
|
queueNow: queueNow.value,
|
||||||
|
});
|
||||||
|
name.value = '';
|
||||||
|
body.value = '';
|
||||||
|
selectedIds.value = [];
|
||||||
|
ok.value = queueNow.value
|
||||||
|
? 'کمپین در صف ارسال قرار گرفت.'
|
||||||
|
: 'کمپین بهصورت پیشنویس ذخیره شد.';
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||||
|
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ساخت کمپین ناموفق بود.');
|
||||||
|
} else {
|
||||||
|
error.value = 'ساخت کمپین ناموفق بود.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queue(id: string) {
|
||||||
|
try {
|
||||||
|
await api.post(`/sms-campaigns/${id}/queue`);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
error.value = 'صفبندی ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel(id: string) {
|
||||||
|
try {
|
||||||
|
await api.post(`/sms-campaigns/${id}/cancel`);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
error.value = 'لغو ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<UiPageHeader
|
||||||
|
title="پیامک گروهی"
|
||||||
|
description="ارسال انبوه پیامک به مخاطبان انتخابشده"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||||
|
<UiIcon name="refresh" />
|
||||||
|
بروزرسانی
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</UiPageHeader>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||||
|
|
||||||
|
<UiCard title="کمپین جدید">
|
||||||
|
<div class="form-grid">
|
||||||
|
<UiField label="نام کمپین">
|
||||||
|
<UiInput v-model="name" placeholder="مثلاً اطلاعرسانی جشنواره" />
|
||||||
|
</UiField>
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="queueNow" type="checkbox" />
|
||||||
|
ارسال فوری پس از ذخیره
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<UiField label="متن پیامک">
|
||||||
|
<UiTextarea
|
||||||
|
v-model="body"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="متن پیامک را بنویسید…"
|
||||||
|
/>
|
||||||
|
</UiField>
|
||||||
|
|
||||||
|
<div class="people-head">
|
||||||
|
<UiField label="جستجوی مخاطب">
|
||||||
|
<UiInput v-model="peopleQ" placeholder="نام یا موبایل…" />
|
||||||
|
</UiField>
|
||||||
|
<UiButton variant="secondary" @click="selectVisible">
|
||||||
|
انتخاب نتایج
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
<p class="muted">
|
||||||
|
انتخابشده: {{ toFaDigits(selectedIds.length) }} نفر
|
||||||
|
</p>
|
||||||
|
<div class="people-list">
|
||||||
|
<label v-for="p in filteredPeople" :key="p.id" class="person">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.includes(p.id)"
|
||||||
|
@change="togglePerson(p.id)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{{ p.fullName }}</strong>
|
||||||
|
<small>{{ p.mobile || 'بدون موبایل' }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<UiButton :loading="saving" @click="createCampaign">ساخت کمپین</UiButton>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
|
<UiCard title="کمپینها">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!loading && !campaigns.length"
|
||||||
|
title="کمپینی نیست"
|
||||||
|
description="اولین کمپین پیامک گروهی را بسازید."
|
||||||
|
/>
|
||||||
|
<div v-else class="campaigns">
|
||||||
|
<article v-for="c in campaigns" :key="c.id" class="campaign">
|
||||||
|
<div>
|
||||||
|
<strong>{{ c.name }}</strong>
|
||||||
|
<p class="muted">{{ c.body }}</p>
|
||||||
|
<p class="meta">
|
||||||
|
{{ statusLabel[c.status] ?? c.status }}
|
||||||
|
· کل {{ toFaDigits(c.totalCount) }}
|
||||||
|
· ارسال {{ toFaDigits(c.sentCount) }}
|
||||||
|
· ناموفق {{ toFaDigits(c.failedCount) }}
|
||||||
|
· ردشده {{ toFaDigits(c.skippedCount) }}
|
||||||
|
· {{ formatJalaliNumeric(new Date(c.createdAt)) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<UiButton
|
||||||
|
v-if="c.status === 'draft' || c.status === 'cancelled'"
|
||||||
|
variant="secondary"
|
||||||
|
@click="queue(c.id)"
|
||||||
|
>
|
||||||
|
صف ارسال
|
||||||
|
</UiButton>
|
||||||
|
<UiButton
|
||||||
|
v-if="c.status === 'queued' || c.status === 'sending' || c.status === 'draft'"
|
||||||
|
variant="secondary"
|
||||||
|
@click="cancel(c.id)"
|
||||||
|
>
|
||||||
|
لغو
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</UiCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
min-height: var(--control-h);
|
||||||
|
}
|
||||||
|
.people-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.people-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.person {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.person:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.person span {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.person small {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.campaigns {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.campaign {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.muted,
|
||||||
|
.meta {
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.form-grid,
|
||||||
|
.people-head,
|
||||||
|
.campaign {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -118,7 +118,9 @@ export type OrgModuleKey =
|
|||||||
| "team_settings"
|
| "team_settings"
|
||||||
| "faq"
|
| "faq"
|
||||||
| "audit_logs"
|
| "audit_logs"
|
||||||
| "saved_views";
|
| "saved_views"
|
||||||
|
| "public_api"
|
||||||
|
| "sms_campaigns";
|
||||||
|
|
||||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||||
"customers",
|
"customers",
|
||||||
@@ -147,6 +149,8 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
|||||||
"faq",
|
"faq",
|
||||||
"audit_logs",
|
"audit_logs",
|
||||||
"saved_views",
|
"saved_views",
|
||||||
|
"public_api",
|
||||||
|
"sms_campaigns",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||||
@@ -176,6 +180,8 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
|||||||
faq: "FAQ",
|
faq: "FAQ",
|
||||||
audit_logs: "لاگ فعالیت کاربران",
|
audit_logs: "لاگ فعالیت کاربران",
|
||||||
saved_views: "فیلترهای ذخیرهشده",
|
saved_views: "فیلترهای ذخیرهشده",
|
||||||
|
public_api: "API و وبهوک",
|
||||||
|
sms_campaigns: "پیامک گروهی",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ORG_MODULE_GROUPS: Array<{
|
export const ORG_MODULE_GROUPS: Array<{
|
||||||
@@ -198,7 +204,11 @@ export const ORG_MODULE_GROUPS: Array<{
|
|||||||
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
|
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
|
||||||
{
|
{
|
||||||
title: "اتوماسیون",
|
title: "اتوماسیون",
|
||||||
keys: ["workflow_automations", "message_automations"],
|
keys: [
|
||||||
|
"workflow_automations",
|
||||||
|
"message_automations",
|
||||||
|
"sms_campaigns",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "ابزارها",
|
title: "ابزارها",
|
||||||
@@ -221,6 +231,7 @@ export const ORG_MODULE_GROUPS: Array<{
|
|||||||
"currencies",
|
"currencies",
|
||||||
"tahesab",
|
"tahesab",
|
||||||
"audit_logs",
|
"audit_logs",
|
||||||
|
"public_api",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -2969,3 +2980,109 @@ export interface CreateSavedViewRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateSavedViewRequest = Partial<CreateSavedViewRequest>;
|
export type UpdateSavedViewRequest = Partial<CreateSavedViewRequest>;
|
||||||
|
|
||||||
|
/* ——— API عمومی و وبهوک ——— */
|
||||||
|
|
||||||
|
export const WEBHOOK_EVENTS = [
|
||||||
|
"person.created",
|
||||||
|
"person.updated",
|
||||||
|
"deal.created",
|
||||||
|
"deal.updated",
|
||||||
|
"deal.won",
|
||||||
|
"deal.lost",
|
||||||
|
"invoice.paid",
|
||||||
|
"activity.created",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
|
||||||
|
|
||||||
|
export interface OrganizationApiKeyDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrganizationApiKeyRequest {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrganizationApiKeyResponse extends OrganizationApiKeyDto {
|
||||||
|
/** فقط یکبار هنگام ساخت برگردانده میشود */
|
||||||
|
rawKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookEndpointDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWebhookEndpointRequest {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
secret?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط هنگام ساخت؛ secret دوباره برنمیگردد */
|
||||||
|
export interface CreateWebhookEndpointResponse extends WebhookEndpointDto {
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateWebhookEndpointRequest = Partial<CreateWebhookEndpointRequest>;
|
||||||
|
|
||||||
|
export interface WebhookDeliveryDto {
|
||||||
|
id: string;
|
||||||
|
event: string;
|
||||||
|
status: string;
|
||||||
|
statusCode: number | null;
|
||||||
|
attempts: number;
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
deliveredAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— پیامک گروهی ——— */
|
||||||
|
|
||||||
|
export type SmsCampaignStatus =
|
||||||
|
| "draft"
|
||||||
|
| "queued"
|
||||||
|
| "sending"
|
||||||
|
| "done"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export interface SmsCampaignDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
status: SmsCampaignStatus;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
totalCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSmsCampaignRequest {
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
personIds: string[];
|
||||||
|
scheduledAt?: string | null;
|
||||||
|
/** اگر true باشد بلافاصله صف میشود */
|
||||||
|
queueNow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsCampaignListResponse {
|
||||||
|
items: SmsCampaignDto[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user