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