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[]
|
||||
auditLogs AuditLog[]
|
||||
savedViews SavedView[]
|
||||
apiKeys OrganizationApiKey[]
|
||||
webhookEndpoints WebhookEndpoint[]
|
||||
smsCampaigns SmsCampaign[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -1597,3 +1600,98 @@ model SavedView {
|
||||
@@index([organizationId, scope])
|
||||
@@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 { AuditLogsModule } from './audit-logs/audit-logs.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({
|
||||
imports: [
|
||||
@@ -79,6 +81,8 @@ import { SavedViewsModule } from './saved-views/saved-views.module';
|
||||
TahesabModule,
|
||||
AuditLogsModule,
|
||||
SavedViewsModule,
|
||||
PublicApiModule,
|
||||
SmsCampaignsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { PublicApiModule } from '../public-api/public-api.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
@@ -9,7 +10,12 @@ import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoyaltyModule, AutomationsModule, MessageAutomationsModule],
|
||||
imports: [
|
||||
LoyaltyModule,
|
||||
AutomationsModule,
|
||||
MessageAutomationsModule,
|
||||
PublicApiModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
const personInclude = {
|
||||
@@ -72,6 +73,7 @@ export class PeopleService {
|
||||
private readonly loyalty: LoyaltyService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -171,6 +173,13 @@ export class PeopleService {
|
||||
};
|
||||
await this.automations.runTrigger('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({
|
||||
where: { id: row.id },
|
||||
include: personInclude,
|
||||
@@ -221,6 +230,13 @@ export class PeopleService {
|
||||
data,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
@@ -41,6 +42,7 @@ export class DealsService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
@@ -161,6 +163,15 @@ export class DealsService {
|
||||
};
|
||||
await this.automations.runTrigger('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);
|
||||
}
|
||||
@@ -240,7 +251,17 @@ export class DealsService {
|
||||
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(
|
||||
@@ -321,10 +342,20 @@ export class DealsService {
|
||||
if (status === 'won' && deal.status !== 'won') {
|
||||
await this.automations.runTrigger('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') {
|
||||
await this.automations.runTrigger('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 { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { PublicApiModule } from '../public-api/public-api.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,6 +16,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
||||
AuthModule,
|
||||
forwardRef(() => AutomationsModule),
|
||||
MessageAutomationsModule,
|
||||
PublicApiModule,
|
||||
],
|
||||
controllers: [PipelinesController, DealsController],
|
||||
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(
|
||||
() =>
|
||||
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(() =>
|
||||
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
|
||||
@@ -62,6 +63,8 @@ const toolsActive = computed(() =>
|
||||
const settingsActive = computed(() =>
|
||||
pathIn([
|
||||
'/settings/team',
|
||||
'/settings/audit-logs',
|
||||
'/settings/public-api',
|
||||
'/settings/custom-fields',
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
|
||||
@@ -227,6 +227,12 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'send',
|
||||
module: 'message_automations',
|
||||
},
|
||||
{
|
||||
to: '/settings/sms-campaigns',
|
||||
title: 'پیامک گروهی',
|
||||
icon: 'send',
|
||||
module: 'sms_campaigns',
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -247,6 +253,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
admin: true,
|
||||
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',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/public-api',
|
||||
title: 'API و وبهوک',
|
||||
desc: 'کلید عمومی و ارسال رویداد به سرور شما',
|
||||
icon: 'apps-plus',
|
||||
tint: 'sky',
|
||||
module: 'public_api',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/custom-fields',
|
||||
title: 'فیلدهای سفارشی',
|
||||
|
||||
@@ -258,6 +258,18 @@ export const router = createRouter({
|
||||
component: () => import('@/views/AuditLogsView.vue'),
|
||||
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',
|
||||
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"
|
||||
| "faq"
|
||||
| "audit_logs"
|
||||
| "saved_views";
|
||||
| "saved_views"
|
||||
| "public_api"
|
||||
| "sms_campaigns";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
@@ -147,6 +149,8 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"faq",
|
||||
"audit_logs",
|
||||
"saved_views",
|
||||
"public_api",
|
||||
"sms_campaigns",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
@@ -176,6 +180,8 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
faq: "FAQ",
|
||||
audit_logs: "لاگ فعالیت کاربران",
|
||||
saved_views: "فیلترهای ذخیرهشده",
|
||||
public_api: "API و وبهوک",
|
||||
sms_campaigns: "پیامک گروهی",
|
||||
};
|
||||
|
||||
export const ORG_MODULE_GROUPS: Array<{
|
||||
@@ -198,7 +204,11 @@ export const ORG_MODULE_GROUPS: Array<{
|
||||
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
|
||||
{
|
||||
title: "اتوماسیون",
|
||||
keys: ["workflow_automations", "message_automations"],
|
||||
keys: [
|
||||
"workflow_automations",
|
||||
"message_automations",
|
||||
"sms_campaigns",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "ابزارها",
|
||||
@@ -221,6 +231,7 @@ export const ORG_MODULE_GROUPS: Array<{
|
||||
"currencies",
|
||||
"tahesab",
|
||||
"audit_logs",
|
||||
"public_api",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -2969,3 +2980,109 @@ export interface 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