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(),
};
}
}