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
-
+
ساخت کلید