Add public API keys, outbound webhooks, and bulk SMS campaigns with settings UI and org modules.

This commit is contained in:
2026-09-26 16:12:29 +03:30
parent ccddd009e3
commit ef600d149d
23 changed files with 2434 additions and 5 deletions
@@ -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;
+98
View File
@@ -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])
}
+4
View File
@@ -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 {}
+7 -1
View File
@@ -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],
+16
View File
@@ -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);
}
+32 -1
View File
@@ -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);
}
}
+111
View File
@@ -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 };
}
}
+296
View File
@@ -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(),
};
}
}
+4 -1
View File
@@ -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',
+24
View File
@@ -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: 'فیلدهای سفارشی',
+12
View File
@@ -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>
+350
View File
@@ -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>
+119 -2
View File
@@ -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;
}