From 39cb5c25486a943b29615868022630af404e3ab7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 21:34:07 +0330 Subject: [PATCH] feat(public-api): let external sites register a lead with their key Co-authored-by: Cursor --- apps/api/src/leads/leads.service.ts | 92 +++++++++++++++++++ apps/api/src/public-api/api-key-auth.guard.ts | 2 + apps/api/src/public-api/api-keys.service.ts | 5 +- .../public-api/public-api-v1.controller.ts | 70 +++++++++++++- apps/api/src/public-api/public-api.module.ts | 3 +- apps/web/src/views/PublicApiSettingsView.vue | 6 +- 6 files changed, 171 insertions(+), 7 deletions(-) diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index d266139..4203328 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -1170,6 +1170,98 @@ export class LeadsService { }; } + async createFromExternal(input: { + organizationId: string; + siteName: string; + firstName?: string | null; + lastName?: string | null; + mobile: string; + storeName?: string | null; + source?: string | null; + }): Promise<{ + ok: true; + created: boolean; + duplicate: boolean; + leadId: string | null; + }> { + const mobile = mobileKey(input.mobile); + if (!mobile || !/^09\d{9}$/.test(mobile)) { + throw new BadRequestException('شماره موبایل معتبر نیست'); + } + + const contactName = + `${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null; + const storeName = input.storeName?.trim() ?? ''; + const source = emptyToNull(input.source); + const title = storeName || contactName || mobile; + const siteName = input.siteName.trim() || 'سایت بیرونی'; + + const existing = await this.prisma.lead.findFirst({ + where: { organizationId: input.organizationId, mobile, archivedAt: null }, + select: { id: true, source: true, contactName: true }, + }); + if (existing) { + const patch: Prisma.LeadUpdateInput = {}; + if (!existing.source && source) patch.source = source; + if (!existing.contactName && contactName) patch.contactName = contactName; + if (Object.keys(patch).length) { + await this.prisma.lead.update({ where: { id: existing.id }, data: patch }); + } + await this.prisma.leadEvent.create({ + data: { + organizationId: input.organizationId, + leadId: existing.id, + type: 'note', + title: 'درخواست دوباره از سایت', + body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null] + .filter(Boolean) + .join('\n'), + }, + }); + return { ok: true, created: false, duplicate: true, leadId: existing.id }; + } + + const person = await this.prisma.person.findFirst({ + where: { + organizationId: input.organizationId, + OR: [{ mobile }, { phone: mobile }], + }, + select: { id: true }, + }); + if (person) { + return { ok: true, created: false, duplicate: true, leadId: null }; + } + + await this.stages.ensureDefaults(input.organizationId); + const stages = await this.stages.list(input.organizationId); + const stageId = stages[0]?.id; + if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده'); + + const lead = await this.prisma.lead.create({ + data: { + organizationId: input.organizationId, + title, + contactName, + mobile, + source, + notes: `ثبت از سایت: ${siteName}`, + stageId, + }, + }); + await this.prisma.leadEvent.create({ + data: { + organizationId: input.organizationId, + leadId: lead.id, + type: 'created', + title: 'لید از سایت ثبت شد', + body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null] + .filter(Boolean) + .join('\n'), + }, + }); + return { ok: true, created: true, duplicate: false, leadId: lead.id }; + } + async createFromImportRow( organizationId: string, createdById: string | null, diff --git a/apps/api/src/public-api/api-key-auth.guard.ts b/apps/api/src/public-api/api-key-auth.guard.ts index 8448ae0..c535532 100644 --- a/apps/api/src/public-api/api-key-auth.guard.ts +++ b/apps/api/src/public-api/api-key-auth.guard.ts @@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate { organizationId: string; role: string; apiKeyId?: string; + apiKeyName?: string; }; }>(); @@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate { organizationId: auth.organizationId, role: auth.role, apiKeyId: auth.apiKeyId, + apiKeyName: auth.name, }; return true; } diff --git a/apps/api/src/public-api/api-keys.service.ts b/apps/api/src/public-api/api-keys.service.ts index 8a02232..4457ff1 100644 --- a/apps/api/src/public-api/api-keys.service.ts +++ b/apps/api/src/public-api/api-keys.service.ts @@ -63,7 +63,7 @@ export class ApiKeysService { async authenticate( rawKey: string, - ): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> { + ): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> { const key = rawKey.trim(); if (!key.startsWith('mk_') || key.length < 20) { throw new UnauthorizedException('کلید API نامعتبر است'); @@ -71,7 +71,7 @@ export class ApiKeysService { const keyHash = this.hashKey(key); const row = await this.prisma.organizationApiKey.findFirst({ where: { keyHash, revokedAt: null }, - select: { id: true, organizationId: true }, + select: { id: true, organizationId: true, name: true }, }); if (!row) throw new UnauthorizedException('کلید API نامعتبر است'); @@ -83,6 +83,7 @@ export class ApiKeysService { return { organizationId: row.organizationId, apiKeyId: row.id, + name: row.name, role: 'owner', }; } diff --git a/apps/api/src/public-api/public-api-v1.controller.ts b/apps/api/src/public-api/public-api-v1.controller.ts index 371b27c..bc959da 100644 --- a/apps/api/src/public-api/public-api-v1.controller.ts +++ b/apps/api/src/public-api/public-api-v1.controller.ts @@ -1,18 +1,55 @@ import { + Body, Controller, Get, + HttpCode, + HttpException, + HttpStatus, Param, + Post, Query, UseGuards, } from '@nestjs/common'; -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; import { Type } from 'class-transformer'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { buildSearchWhere, ci } from '../common/search'; +import { LeadsService } from '../leads/leads.service'; import { ApiKeyAuthGuard } from './api-key-auth.guard'; +class CreateExternalLeadDto { + @IsOptional() + @IsString() + @MaxLength(80) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + lastName?: string; + + @IsString() + @MinLength(8) + @MaxLength(20) + mobile!: string; + + @IsOptional() + @IsString() + @MaxLength(200) + storeName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + source?: string; +} + +const LEAD_RATE_WINDOW_MS = 60_000; +const LEAD_RATE_MAX = 60; +const leadHits = new Map(); + class ListQueryDto { @IsOptional() @IsString() @@ -35,7 +72,36 @@ class ListQueryDto { @Controller('public/v1') @UseGuards(ApiKeyAuthGuard) export class PublicApiV1Controller { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly leads: LeadsService, + ) {} + + @Post('leads') + @HttpCode(200) + createLead( + @CurrentUser() + user: { organizationId: string; apiKeyId?: string; apiKeyName?: string }, + @Body() body: CreateExternalLeadDto, + ) { + const bucket = user.apiKeyId || user.organizationId; + const now = Date.now(); + const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS); + if (prev.length >= LEAD_RATE_MAX) { + throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS); + } + prev.push(now); + leadHits.set(bucket, prev); + return this.leads.createFromExternal({ + organizationId: user.organizationId, + siteName: user.apiKeyName?.trim() || 'سایت بیرونی', + firstName: body.firstName, + lastName: body.lastName, + mobile: body.mobile, + storeName: body.storeName, + source: body.source, + }); + } @Get('people') async listPeople( diff --git a/apps/api/src/public-api/public-api.module.ts b/apps/api/src/public-api/public-api.module.ts index 10e6437..c3c7513 100644 --- a/apps/api/src/public-api/public-api.module.ts +++ b/apps/api/src/public-api/public-api.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { BillingModule } from '../billing/billing.module'; +import { LeadsModule } from '../leads/leads.module'; import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { ApiKeysController } from './api-keys.controller'; import { ApiKeysService } from './api-keys.service'; @@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller'; import { WebhooksService } from './webhooks.service'; @Module({ - imports: [AuthModule, BillingModule], + imports: [AuthModule, BillingModule, LeadsModule], controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller], providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService], exports: [ApiKeysService, WebhooksService], diff --git a/apps/web/src/views/PublicApiSettingsView.vue b/apps/web/src/views/PublicApiSettingsView.vue index 05319a5..26ccd62 100644 --- a/apps/web/src/views/PublicApiSettingsView.vue +++ b/apps/web/src/views/PublicApiSettingsView.vue @@ -189,14 +189,16 @@ onMounted(() => { X-Api-Key: mk_… یا Authorization: Bearer mk_… - — مسیرها: + — خواندن: /api/public/v1/people و /api/public/v1/deals + — ثبت لید از سایت بیرونی: + POST /api/public/v1/leads

- + ساخت کلید