diff --git a/apps/api/prisma/migrations/20260926160000_public_api_webhooks_sms_campaigns/migration.sql b/apps/api/prisma/migrations/20260926160000_public_api_webhooks_sms_campaigns/migration.sql new file mode 100644 index 0000000..de10e3b --- /dev/null +++ b/apps/api/prisma/migrations/20260926160000_public_api_webhooks_sms_campaigns/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e7c38bb..c6f412f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0270b53..a0c5870 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index e2dca54..3a347a4 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -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], diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index eb25ea0..84f6774 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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); } diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 73362db..4468303 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -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, + }); } } diff --git a/apps/api/src/pipelines/pipelines.module.ts b/apps/api/src/pipelines/pipelines.module.ts index 49f6c53..6d1d404 100644 --- a/apps/api/src/pipelines/pipelines.module.ts +++ b/apps/api/src/pipelines/pipelines.module.ts @@ -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], diff --git a/apps/api/src/public-api/api-key-auth.guard.ts b/apps/api/src/public-api/api-key-auth.guard.ts new file mode 100644 index 0000000..8448ae0 --- /dev/null +++ b/apps/api/src/public-api/api-key-auth.guard.ts @@ -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 { + const req = context.switchToHttp().getRequest<{ + headers: Record; + 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; + } +} diff --git a/apps/api/src/public-api/api-keys.controller.ts b/apps/api/src/public-api/api-keys.controller.ts new file mode 100644 index 0000000..704703f --- /dev/null +++ b/apps/api/src/public-api/api-keys.controller.ts @@ -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); + } +} diff --git a/apps/api/src/public-api/api-keys.service.ts b/apps/api/src/public-api/api-keys.service.ts new file mode 100644 index 0000000..8a02232 --- /dev/null +++ b/apps/api/src/public-api/api-keys.service.ts @@ -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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/public-api/public-api-v1.controller.ts b/apps/api/src/public-api/public-api-v1.controller.ts new file mode 100644 index 0000000..de29b0c --- /dev/null +++ b/apps/api/src/public-api/public-api-v1.controller.ts @@ -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(), + })), + }; + } +} diff --git a/apps/api/src/public-api/public-api.module.ts b/apps/api/src/public-api/public-api.module.ts new file mode 100644 index 0000000..10e6437 --- /dev/null +++ b/apps/api/src/public-api/public-api.module.ts @@ -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 {} diff --git a/apps/api/src/public-api/webhooks.controller.ts b/apps/api/src/public-api/webhooks.controller.ts new file mode 100644 index 0000000..ab71371 --- /dev/null +++ b/apps/api/src/public-api/webhooks.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/public-api/webhooks.service.ts b/apps/api/src/public-api/webhooks.service.ts new file mode 100644 index 0000000..42ac0d7 --- /dev/null +++ b/apps/api/src/public-api/webhooks.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): 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, + ): Promise { + 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 { + 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 { + 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(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(), + }; + } +} diff --git a/apps/api/src/sms-campaigns/sms-campaigns.controller.ts b/apps/api/src/sms-campaigns/sms-campaigns.controller.ts new file mode 100644 index 0000000..70ac607 --- /dev/null +++ b/apps/api/src/sms-campaigns/sms-campaigns.controller.ts @@ -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); + } +} diff --git a/apps/api/src/sms-campaigns/sms-campaigns.module.ts b/apps/api/src/sms-campaigns/sms-campaigns.module.ts new file mode 100644 index 0000000..ce830b8 --- /dev/null +++ b/apps/api/src/sms-campaigns/sms-campaigns.module.ts @@ -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 {} diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts new file mode 100644 index 0000000..b23967d --- /dev/null +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 98880c0..4cf7703 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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', diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index e734b87..5180efe 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -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: 'فیلدهای سفارشی', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index b607a43..d3278f2 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', diff --git a/apps/web/src/views/PublicApiSettingsView.vue b/apps/web/src/views/PublicApiSettingsView.vue new file mode 100644 index 0000000..8b48b50 --- /dev/null +++ b/apps/web/src/views/PublicApiSettingsView.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/apps/web/src/views/SmsCampaignsView.vue b/apps/web/src/views/SmsCampaignsView.vue new file mode 100644 index 0000000..4bf7383 --- /dev/null +++ b/apps/web/src/views/SmsCampaignsView.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2e07553..8b70d16 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 = { @@ -176,6 +180,8 @@ export const ORG_MODULE_LABELS: Record = { 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; + +/* ——— 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; + +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; +}