diff --git a/apps/api/prisma/migrations/20260928140000_lead_form_publish_lead/migration.sql b/apps/api/prisma/migrations/20260928140000_lead_form_publish_lead/migration.sql new file mode 100644 index 0000000..1a8051f --- /dev/null +++ b/apps/api/prisma/migrations/20260928140000_lead_form_publish_lead/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable LeadForm: publish → Lead connection +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "createLead" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadStageId" TEXT; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateMode" TEXT NOT NULL DEFAULT 'update'; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "viewCount" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "startCount" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitCount" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadCreateCount" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateCount" INTEGER NOT NULL DEFAULT 0; + +-- Existing forms: keep person/deal behavior unless already lead-oriented +ALTER TABLE "LeadForm" ALTER COLUMN "createPerson" SET DEFAULT false; +ALTER TABLE "LeadForm" ALTER COLUMN "createDeal" SET DEFAULT false; + +-- AlterTable LeadFormSubmission +ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "leadId" TEXT; +ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'created'; +ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "utm" JSONB; +ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "meta" JSONB; + +CREATE INDEX IF NOT EXISTS "LeadFormSubmission_leadId_idx" ON "LeadFormSubmission"("leadId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5e3a0a0..f73a63b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -793,9 +793,15 @@ model LeadForm { description String? publicToken String @unique @default(cuid()) isActive Boolean @default(true) - createPerson Boolean @default(true) - createDeal Boolean @default(true) + /// ثبت به‌عنوان Lead در CRM (مسیر اصلی انتشار فرم) + createLead Boolean @default(true) + createPerson Boolean @default(false) + createDeal Boolean @default(false) pipelineId String? + /// مرحله اولیه لید (اختیاری؛ وگرنه اولین مرحله) + leadStageId String? + /// skip | update | activity + duplicateMode String @default("update") source String? thankYouMessage String? redirectUrl String? @@ -804,6 +810,11 @@ model LeadForm { submitLabel String @default("ارسال درخواست") submitColor String @default("#14b8a6") coverStorageKey String? + viewCount Int @default(0) + startCount Int @default(0) + submitCount Int @default(0) + leadCreateCount Int @default(0) + duplicateCount Int @default(0) fields LeadFormField[] submissions LeadFormSubmission[] createdAt DateTime @default(now()) @@ -835,9 +846,15 @@ model LeadFormSubmission { form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade) values Json personId String? + leadId String? + /// created | updated | duplicate_skipped | error + status String @default("created") + utm Json? + meta Json? createdAt DateTime @default(now()) @@index([formId, createdAt]) + @@index([leadId]) } model Quote { diff --git a/apps/api/src/lead-forms/dto.ts b/apps/api/src/lead-forms/dto.ts index 62ec1a7..c2944c7 100644 --- a/apps/api/src/lead-forms/dto.ts +++ b/apps/api/src/lead-forms/dto.ts @@ -23,10 +23,21 @@ const MAP_TO = [ 'person.position', 'deal.title', 'deal.notes', + 'lead.title', + 'lead.contactName', + 'lead.mobile', + 'lead.phone', + 'lead.city', + 'lead.region', + 'lead.trade', + 'lead.notes', + 'lead.source', 'notes', 'custom', ] as const; +const DUPLICATE_MODES = ['skip', 'update', 'activity'] as const; + export class LeadFormFieldInputDto { @IsOptional() @IsString() @@ -74,6 +85,10 @@ export class CreateLeadFormDto { @MaxLength(400) description?: string | null; + @IsOptional() + @IsBoolean() + createLead?: boolean; + @IsOptional() @IsBoolean() createPerson?: boolean; @@ -86,6 +101,14 @@ export class CreateLeadFormDto { @IsString() pipelineId?: string | null; + @IsOptional() + @IsString() + leadStageId?: string | null; + + @IsOptional() + @IsIn(DUPLICATE_MODES) + duplicateMode?: (typeof DUPLICATE_MODES)[number]; + @IsOptional() @IsString() @MaxLength(80) @@ -141,6 +164,10 @@ export class UpdateLeadFormDto { @IsBoolean() isActive?: boolean; + @IsOptional() + @IsBoolean() + createLead?: boolean; + @IsOptional() @IsBoolean() createPerson?: boolean; @@ -153,6 +180,14 @@ export class UpdateLeadFormDto { @IsString() pipelineId?: string | null; + @IsOptional() + @IsString() + leadStageId?: string | null; + + @IsOptional() + @IsIn(DUPLICATE_MODES) + duplicateMode?: (typeof DUPLICATE_MODES)[number]; + @IsOptional() @IsString() @MaxLength(80) @@ -192,7 +227,70 @@ export class UpdateLeadFormDto { fields?: LeadFormFieldInputDto[]; } +export class LeadFormUtmDto { + @IsOptional() + @IsString() + @MaxLength(120) + source?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + medium?: string; + + @IsOptional() + @IsString() + @MaxLength(160) + campaign?: string; + + @IsOptional() + @IsString() + @MaxLength(160) + term?: string; + + @IsOptional() + @IsString() + @MaxLength(160) + content?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + landingPage?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + referrer?: string; +} + export class SubmitLeadFormDto { @IsObject() values!: Record; + + @IsOptional() + @ValidateNested() + @Type(() => LeadFormUtmDto) + utm?: LeadFormUtmDto; + + @IsOptional() + @IsString() + @MaxLength(1000) + pageUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + referrer?: string; + + /** honeypot */ + @IsOptional() + @IsString() + @MaxLength(200) + website?: string; +} + +export class LeadFormPublicEventDto { + @IsIn(['view', 'start']) + type!: 'view' | 'start'; } diff --git a/apps/api/src/lead-forms/lead-forms.module.ts b/apps/api/src/lead-forms/lead-forms.module.ts index 41f8b3e..4834555 100644 --- a/apps/api/src/lead-forms/lead-forms.module.ts +++ b/apps/api/src/lead-forms/lead-forms.module.ts @@ -2,12 +2,18 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { AutomationsModule } from '../automations/automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module'; +import { LeadsModule } from '../leads/leads.module'; import { LeadFormsController } from './lead-forms.controller'; import { LeadFormsService } from './lead-forms.service'; import { PublicLeadFormsController } from './public-lead-forms.controller'; @Module({ - imports: [AuthModule, AutomationsModule, MessageAutomationsModule], + imports: [ + AuthModule, + AutomationsModule, + MessageAutomationsModule, + LeadsModule, + ], controllers: [LeadFormsController, PublicLeadFormsController], providers: [LeadFormsService], exports: [LeadFormsService], diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 7053df4..dfceced 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -7,7 +7,9 @@ import type { CreateLeadFormRequest, LeadFormDto, LeadFormFieldInput, + LeadFormUtmDto, PublicLeadFormDto, + SubmitLeadFormRequest, SubmitLeadFormResponse, UpdateLeadFormRequest, } from '@mento/shared'; @@ -21,6 +23,7 @@ import { normalizeEmail, normalizePhone, } from '../contacts/normalize'; +import { LeadStagesService } from '../leads/lead-stages.service'; const DEFAULT_FIELDS: LeadFormFieldInput[] = [ { @@ -28,7 +31,7 @@ const DEFAULT_FIELDS: LeadFormFieldInput[] = [ label: 'نام و نام خانوادگی', fieldType: 'text', required: true, - mapTo: 'person.fullName', + mapTo: 'lead.contactName', placeholder: 'مثلاً علی منتظر', position: 0, }, @@ -37,17 +40,17 @@ const DEFAULT_FIELDS: LeadFormFieldInput[] = [ label: 'شماره تماس', fieldType: 'phone', required: true, - mapTo: 'person.mobile', + mapTo: 'lead.mobile', placeholder: '۰۹۱۲۱۲۳۴۵۶۷', position: 1, }, { - key: 'subject', - label: 'موضوع درخواست', - fieldType: 'select', + key: 'title', + label: 'نام فروشگاه / موضوع', + fieldType: 'text', required: true, - mapTo: 'deal.title', - options: ['مشاوره فروش', 'پشتیبانی', 'همکاری'], + mapTo: 'lead.title', + placeholder: 'مثلاً فروشگاه نمونه', position: 2, }, { @@ -55,12 +58,17 @@ const DEFAULT_FIELDS: LeadFormFieldInput[] = [ label: 'توضیحات', fieldType: 'textarea', required: false, - mapTo: 'deal.notes', + mapTo: 'lead.notes', placeholder: 'شرح درخواست خود را بنویسید', position: 3, }, ]; +/** Simple in-memory rate limit: token+ip → timestamps */ +const submitHits = new Map(); +const RATE_WINDOW_MS = 60_000; +const RATE_MAX = 12; + type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>; @Injectable() @@ -70,6 +78,7 @@ export class LeadFormsService { private readonly files: LocalFileStorage, private readonly automations: AutomationsService, private readonly messageAutomations: MessageAutomationsService, + private readonly leadStages: LeadStagesService, ) {} async list(organizationId: string): Promise { @@ -97,6 +106,7 @@ export class LeadFormsService { dto: CreateLeadFormRequest, ): Promise { await this.assertPipeline(organizationId, dto.pipelineId); + await this.assertLeadStage(organizationId, dto.leadStageId); await this.assertAssignees(organizationId, dto.assigneeIds); const fields = normalizeFields(dto.fields?.length ? dto.fields : DEFAULT_FIELDS); @@ -105,9 +115,12 @@ export class LeadFormsService { organizationId, name: dto.name.trim(), description: emptyToNull(dto.description ?? null), - createPerson: dto.createPerson ?? true, - createDeal: dto.createDeal ?? true, + createLead: dto.createLead ?? true, + createPerson: dto.createPerson ?? false, + createDeal: dto.createDeal ?? false, pipelineId: emptyToNull(dto.pipelineId ?? null), + leadStageId: emptyToNull(dto.leadStageId ?? null), + duplicateMode: normalizeDuplicateMode(dto.duplicateMode), source: emptyToNull(dto.source ?? null) ?? 'فرم وب', thankYouMessage: emptyToNull(dto.thankYouMessage ?? null) ?? @@ -134,6 +147,9 @@ export class LeadFormsService { if ('pipelineId' in dto) { await this.assertPipeline(organizationId, dto.pipelineId); } + if ('leadStageId' in dto) { + await this.assertLeadStage(organizationId, dto.leadStageId); + } if (dto.assigneeIds) { await this.assertAssignees(organizationId, dto.assigneeIds); } @@ -148,9 +164,14 @@ export class LeadFormsService { data.submitColor = dto.submitColor.trim() || '#14b8a6'; } if (dto.isActive !== undefined) data.isActive = dto.isActive; + if (dto.createLead !== undefined) data.createLead = dto.createLead; if (dto.createPerson !== undefined) data.createPerson = dto.createPerson; if (dto.createDeal !== undefined) data.createDeal = dto.createDeal; if ('pipelineId' in dto) data.pipelineId = emptyToNull(dto.pipelineId ?? null); + if ('leadStageId' in dto) data.leadStageId = emptyToNull(dto.leadStageId ?? null); + if (dto.duplicateMode !== undefined) { + data.duplicateMode = normalizeDuplicateMode(dto.duplicateMode); + } if ('source' in dto) data.source = emptyToNull(dto.source ?? null); if ('thankYouMessage' in dto) { data.thankYouMessage = emptyToNull(dto.thankYouMessage ?? null); @@ -196,29 +217,51 @@ export class LeadFormsService { name: row.name, description: row.description, thankYouMessage: row.thankYouMessage, + redirectUrl: row.redirectUrl, submitLabel: row.submitLabel || 'ارسال درخواست', submitColor: row.submitColor || '#14b8a6', hasCover: Boolean(row.coverStorageKey), coverPath: row.coverStorageKey ? `/api/public/forms/${row.publicToken}/cover` : null, - fields: row.fields - .filter((f) => f.fieldType !== 'heading' || true) - .map((f) => ({ - key: f.key, - label: f.label, - fieldType: f.fieldType, - required: f.required, - placeholder: f.placeholder, - options: asOptions(f.options), - })), + fields: row.fields.map((f) => ({ + key: f.key, + label: f.label, + fieldType: f.fieldType, + required: f.required, + placeholder: f.placeholder, + options: asOptions(f.options), + })), }; } + async recordPublicEvent(token: string, type: 'view' | 'start'): Promise<{ ok: true }> { + const form = await this.prisma.leadForm.findFirst({ + where: { publicToken: token, isActive: true }, + select: { id: true }, + }); + if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است'); + const data = + type === 'view' + ? { viewCount: { increment: 1 } } + : { startCount: { increment: 1 } }; + await this.prisma.leadForm.update({ where: { id: form.id }, data }); + return { ok: true }; + } + async submit( token: string, - values: Record, + body: SubmitLeadFormRequest, + clientIp?: string | null, ): Promise { + const values = body.values ?? {}; + if ((body.website ?? '').trim()) { + // honeypot tripped — pretend success + return { ok: true, thankYouMessage: null, redirectUrl: null, outcome: 'created' }; + } + + this.assertRateLimit(token, clientIp); + const form = await this.prisma.leadForm.findFirst({ where: { publicToken: token, isActive: true }, include: { fields: { orderBy: { position: 'asc' } } }, @@ -231,10 +274,10 @@ export class LeadFormsService { mapped.notes = [mapped.notes, extras].filter(Boolean).join('\n'); } - if (form.createPerson) { - if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) { - throw new BadRequestException('نام یا نام خانوادگی لازم است'); - } + const utm = buildUtm(body); + const utmNote = formatUtmNote(utm); + if (utmNote) { + mapped.notes = [mapped.notes, utmNote].filter(Boolean).join('\n'); } const ownerId = await this.nextAssignee(form); @@ -256,7 +299,30 @@ export class LeadFormsService { } let personId: string | null = null; + let leadId: string | null = null; + let status: 'created' | 'updated' | 'duplicate_skipped' = 'created'; + let leadCreated = false; + let wasDuplicate = false; + let message: string | null = null; + + if (form.createLead) { + const result = await this.upsertLead(form, mapped, ownerId ?? actorId, actorId); + leadId = result.leadId; + status = result.status; + leadCreated = result.created; + wasDuplicate = result.duplicate; + message = result.message; + } + if (form.createPerson) { + if (!mapped.firstName?.trim() && !mapped.lastName?.trim() && mapped.contactName) { + const parts = mapped.contactName.split(/\s+/).filter(Boolean); + mapped.firstName = parts[0] || mapped.contactName; + mapped.lastName = parts.slice(1).join(' ') || '—'; + } + if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) { + throw new BadRequestException('نام یا نام خانوادگی لازم است'); + } personId = await this.upsertPerson( form.organizationId, mapped, @@ -264,34 +330,51 @@ export class LeadFormsService { ); } + if (form.createDeal) { + await this.createDeal(form, mapped, personId, actorId, ownerId); + } else if (personId && mapped.notes && !form.createLead) { + await this.prisma.person.update({ + where: { id: personId }, + data: { notes: mapped.notes }, + }); + } + await this.prisma.leadFormSubmission.create({ data: { formId: form.id, values: values as Prisma.InputJsonValue, personId, + leadId, + status, + utm: utm ? (utm as Prisma.InputJsonValue) : undefined, + meta: { + pageUrl: body.pageUrl ?? null, + referrer: body.referrer ?? null, + ip: clientIp ?? null, + } as Prisma.InputJsonValue, }, }); - if (form.createDeal) { - await this.createDeal(form, mapped, personId, actorId, ownerId); - } else if (personId && mapped.notes) { - await this.prisma.person.update({ - where: { id: personId }, - data: { - notes: mapped.notes, - }, - }); - } + await this.prisma.leadForm.update({ + where: { id: form.id }, + data: { + submitCount: { increment: 1 }, + ...(leadCreated ? { leadCreateCount: { increment: 1 } } : {}), + ...(wasDuplicate ? { duplicateCount: { increment: 1 } } : {}), + }, + }); + + const title = + mapped.leadTitle?.trim() || + mapped.contactName?.trim() || + [mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim() || + form.name; - const personName = [mapped.firstName, mapped.lastName] - .filter((p) => p?.trim()) - .join(' ') - .trim(); const leadCtx = { organizationId: form.organizationId, entityType: 'person' as const, - entityId: personId ?? form.id, - title: personName || form.name, + entityId: personId ?? leadId ?? form.id, + title, personId, ownerId: ownerId ?? actorId, }; @@ -302,6 +385,223 @@ export class LeadFormsService { ok: true, thankYouMessage: form.thankYouMessage, redirectUrl: form.redirectUrl, + outcome: status, + leadId, + duplicate: wasDuplicate, + message, + }; + } + + private assertRateLimit(token: string, ip?: string | null) { + const key = `${token}:${ip || 'unknown'}`; + const now = Date.now(); + const prev = (submitHits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS); + if (prev.length >= RATE_MAX) { + throw new BadRequestException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.'); + } + prev.push(now); + submitHits.set(key, prev); + } + + private async upsertLead( + form: FormRow, + mapped: MappedValues, + assigneeId: string, + actorId: string, + ): Promise<{ + leadId: string | null; + status: 'created' | 'updated' | 'duplicate_skipped'; + created: boolean; + duplicate: boolean; + message: string | null; + }> { + await this.leadStages.ensureDefaults(form.organizationId); + const mobile = mobileKey(mapped.mobile); + if (!mobile) { + throw new BadRequestException('شماره موبایل معتبر نیست'); + } + + const mode = normalizeDuplicateMode(form.duplicateMode); + const existingLead = await this.prisma.lead.findFirst({ + where: { organizationId: form.organizationId, mobile }, + }); + + const personDup = !existingLead + ? await this.prisma.person.findFirst({ + where: { + organizationId: form.organizationId, + OR: [{ mobile }, { phone: mobile }], + }, + select: { id: true, firstName: true, lastName: true }, + }) + : null; + + if (personDup && !existingLead) { + // Person exists with this mobile — treat as duplicate for lead creation + if (mode === 'skip') { + return { + leadId: null, + status: 'duplicate_skipped', + created: false, + duplicate: true, + message: `این شماره قبلاً به‌عنوان مشتری ثبت شده است`, + }; + } + // update/activity without a lead: still create a new lead (person is separate) + } + + const title = + (mapped.leadTitle?.trim() || + mapped.contactName?.trim() || + [mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim() || + `سرنخ از ${form.name}`).slice(0, 200); + + const contactName = + emptyToNull(mapped.contactName) ?? + emptyToNull([mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim()) ?? + null; + + const source = + emptyToNull(mapped.leadSource) ?? form.source ?? 'فرم وب'; + + if (existingLead) { + if (mode === 'skip') { + await this.prisma.leadEvent.create({ + data: { + organizationId: form.organizationId, + leadId: existingLead.id, + type: 'form_submit', + title: 'ارسال فرم (تکراری — رد شد)', + body: `فرم «${form.name}» دوباره ارسال شد و طبق تنظیمات رد شد.`, + actorId, + meta: { formId: form.id, mode: 'skip' }, + }, + }); + return { + leadId: existingLead.id, + status: 'duplicate_skipped', + created: false, + duplicate: true, + message: 'این شماره قبلاً ثبت شده است', + }; + } + + if (mode === 'activity') { + await this.prisma.leadEvent.create({ + data: { + organizationId: form.organizationId, + leadId: existingLead.id, + type: 'form_submit', + title: 'ارسال مجدد فرم', + body: [ + `فرم «${form.name}»`, + mapped.notes ? mapped.notes : null, + ] + .filter(Boolean) + .join('\n'), + actorId, + meta: { formId: form.id, mode: 'activity' }, + }, + }); + await this.prisma.lead.update({ + where: { id: existingLead.id }, + data: { lastContactAt: new Date() }, + }); + return { + leadId: existingLead.id, + status: 'updated', + created: false, + duplicate: true, + message: null, + }; + } + + // update mode + await this.prisma.lead.update({ + where: { id: existingLead.id }, + data: { + title: title || existingLead.title, + contactName: contactName ?? existingLead.contactName, + phone: emptyToNull(mapped.phone) ?? existingLead.phone, + city: emptyToNull(mapped.city) ?? existingLead.city, + region: emptyToNull(mapped.region) ?? existingLead.region, + trade: emptyToNull(mapped.trade) ?? existingLead.trade, + source: source ?? existingLead.source, + notes: emptyToNull(mapped.notes) ?? existingLead.notes, + assigneeId: existingLead.assigneeId ?? assigneeId, + lastContactAt: new Date(), + }, + }); + await this.prisma.leadEvent.create({ + data: { + organizationId: form.organizationId, + leadId: existingLead.id, + type: 'form_submit', + title: 'به‌روزرسانی از فرم', + body: `فرم «${form.name}» دوباره ارسال شد و لید به‌روز شد.`, + actorId, + meta: { formId: form.id, mode: 'update' }, + }, + }); + return { + leadId: existingLead.id, + status: 'updated', + created: false, + duplicate: true, + message: null, + }; + } + + const stages = await this.leadStages.list(form.organizationId); + const stageId = + (form.leadStageId && stages.find((s) => s.id === form.leadStageId)?.id) || + stages.find((s) => !s.isConverted && !s.isLost && !s.isUnqualified)?.id || + stages[0]?.id; + if (!stageId) throw new BadRequestException('مرحله‌ای برای لید تعریف نشده'); + + const maxPos = await this.prisma.lead.aggregate({ + where: { organizationId: form.organizationId, stageId }, + _max: { position: true }, + }); + + const created = await this.prisma.lead.create({ + data: { + organizationId: form.organizationId, + title, + contactName, + mobile, + phone: emptyToNull(mapped.phone), + city: emptyToNull(mapped.city), + region: emptyToNull(mapped.region), + trade: emptyToNull(mapped.trade), + source, + notes: emptyToNull(mapped.notes), + stageId, + position: (maxPos._max.position ?? -1) + 1, + assigneeId, + createdById: actorId, + lastContactAt: new Date(), + }, + }); + + await this.prisma.leadEvent.create({ + data: { + organizationId: form.organizationId, + leadId: created.id, + type: 'created', + title: 'لید از فرم وب ساخته شد', + body: `فرم «${form.name}»`, + actorId, + meta: { formId: form.id }, + }, + }); + + return { + leadId: created.id, + status: 'created', + created: true, + duplicate: false, + message: null, }; } @@ -385,7 +685,9 @@ export class LeadFormsService { const title = mapped.dealTitle?.trim() || + mapped.leadTitle?.trim() || [mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim() || + mapped.contactName?.trim() || `سرنخ از ${form.name}`; const maxPos = await this.prisma.deal.aggregate({ @@ -445,6 +747,20 @@ export class LeadFormsService { if (!found) throw new BadRequestException('کاریز انتخاب‌شده معتبر نیست'); } + private async assertLeadStage( + organizationId: string, + leadStageId?: string | null, + ) { + const id = emptyToNull(leadStageId ?? null); + if (!id) return; + await this.leadStages.ensureDefaults(organizationId); + const found = await this.prisma.leadStage.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!found) throw new BadRequestException('مرحله لید انتخاب‌شده معتبر نیست'); + } + private async assertAssignees( organizationId: string, assigneeIds?: string[], @@ -472,6 +788,9 @@ export class LeadFormsService { id: r.id, values: asStringRecord(r.values), personId: r.personId, + leadId: r.leadId, + status: r.status, + utm: r.utm ? asStringRecord(r.utm) : null, createdAt: r.createdAt.toISOString(), })); } @@ -527,9 +846,12 @@ export class LeadFormsService { description: row.description, publicToken: row.publicToken, isActive: row.isActive, + createLead: row.createLead, createPerson: row.createPerson, createDeal: row.createDeal, pipelineId: row.pipelineId, + leadStageId: row.leadStageId, + duplicateMode: row.duplicateMode, source: row.source, thankYouMessage: row.thankYouMessage, redirectUrl: row.redirectUrl, @@ -548,6 +870,11 @@ export class LeadFormsService { placeholder: f.placeholder, options: asOptions(f.options), })), + viewCount: row.viewCount, + startCount: row.startCount, + submitCount: row.submitCount, + leadCreateCount: row.leadCreateCount, + duplicateCount: row.duplicateCount, publicPath: `/f/${row.publicToken}`, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -565,6 +892,12 @@ type MappedValues = { dealTitle?: string; dealNotes?: string; notes?: string; + leadTitle?: string; + contactName?: string; + city?: string; + region?: string; + trade?: string; + leadSource?: string; }; function mapValues( @@ -590,6 +923,7 @@ function mapValues( const parts = raw.split(/\s+/).filter(Boolean); mapped.firstName = parts[0] || raw; mapped.lastName = parts.slice(1).join(' ') || '—'; + mapped.contactName = raw; break; } case 'person.firstName': @@ -616,6 +950,33 @@ function mapValues( case 'deal.notes': mapped.dealNotes = raw; break; + case 'lead.title': + mapped.leadTitle = raw; + break; + case 'lead.contactName': + mapped.contactName = raw; + break; + case 'lead.mobile': + mapped.mobile = raw; + break; + case 'lead.phone': + mapped.phone = raw; + break; + case 'lead.city': + mapped.city = raw; + break; + case 'lead.region': + mapped.region = raw; + break; + case 'lead.trade': + mapped.trade = raw; + break; + case 'lead.notes': + mapped.notes = raw; + break; + case 'lead.source': + mapped.leadSource = raw; + break; case 'notes': mapped.notes = raw; break; @@ -693,3 +1054,49 @@ function slugKey(label: string): string { .replace(/^_+|_+$/g, '') .slice(0, 40); } + +function mobileKey(raw: string | null | undefined): string | null { + const n = normalizePhone(raw); + if (!n) return null; + const d = n.replace(/\D/g, ''); + if (!d) return null; + if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; + if (d.length === 10 && d.startsWith('9')) return `0${d}`; + return d.startsWith('0') ? d : d; +} + +function normalizeDuplicateMode(raw?: string | null): string { + if (raw === 'skip' || raw === 'activity' || raw === 'update') return raw; + return 'update'; +} + +function buildUtm(body: SubmitLeadFormRequest): Record | null { + const u = body.utm ?? ({} as LeadFormUtmDto); + const out: Record = {}; + const put = (k: string, v?: string) => { + const t = (v ?? '').trim(); + if (t) out[k] = t.slice(0, 500); + }; + put('source', u.source); + put('medium', u.medium); + put('campaign', u.campaign); + put('term', u.term); + put('content', u.content); + put('landingPage', u.landingPage || body.pageUrl); + put('referrer', u.referrer || body.referrer); + return Object.keys(out).length ? out : null; +} + +function formatUtmNote(utm: Record | null): string | null { + if (!utm) return null; + const parts = [ + utm.source && `utm_source=${utm.source}`, + utm.medium && `utm_medium=${utm.medium}`, + utm.campaign && `utm_campaign=${utm.campaign}`, + utm.term && `utm_term=${utm.term}`, + utm.content && `utm_content=${utm.content}`, + utm.landingPage && `صفحه=${utm.landingPage}`, + utm.referrer && `ارجاع=${utm.referrer}`, + ].filter(Boolean); + return parts.length ? `UTM: ${parts.join(' | ')}` : null; +} diff --git a/apps/api/src/lead-forms/public-lead-forms.controller.ts b/apps/api/src/lead-forms/public-lead-forms.controller.ts index 7ddb55e..dd9aa42 100644 --- a/apps/api/src/lead-forms/public-lead-forms.controller.ts +++ b/apps/api/src/lead-forms/public-lead-forms.controller.ts @@ -5,11 +5,12 @@ import { NotFoundException, Param, Post, + Req, Res, StreamableFile, } from '@nestjs/common'; -import type { Response } from 'express'; -import { SubmitLeadFormDto } from './dto'; +import type { Request, Response } from 'express'; +import { LeadFormPublicEventDto, SubmitLeadFormDto } from './dto'; import { LeadFormsService } from './lead-forms.service'; import { LocalFileStorage } from '../storage/local-file.storage'; @@ -36,8 +37,32 @@ export class PublicLeadFormsController { return new StreamableFile(this.files.openReadStream(row.coverStorageKey)); } + @Post(':token/event') + event(@Param('token') token: string, @Body() dto: LeadFormPublicEventDto) { + return this.leadFormsService.recordPublicEvent(token, dto.type); + } + @Post(':token/submit') - submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) { - return this.leadFormsService.submit(token, dto.values ?? {}); + submit( + @Param('token') token: string, + @Body() dto: SubmitLeadFormDto, + @Req() req: Request, + ) { + const forwarded = req.headers['x-forwarded-for']; + const ip = + (typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : null) || + req.ip || + null; + return this.leadFormsService.submit( + token, + { + values: dto.values ?? {}, + utm: dto.utm, + pageUrl: dto.pageUrl, + referrer: dto.referrer, + website: dto.website, + }, + ip, + ); } } diff --git a/apps/web/src/views/LeadFormBuilderView.vue b/apps/web/src/views/LeadFormBuilderView.vue index db18ee6..6e49959 100644 --- a/apps/web/src/views/LeadFormBuilderView.vue +++ b/apps/web/src/views/LeadFormBuilderView.vue @@ -6,14 +6,17 @@ import { api } from '@/lib/api'; import UiIcon from '@/components/UiIcon.vue'; import type { LeadFormDto, + LeadFormDuplicateMode, LeadFormFieldDto, LeadFormFieldInput, LeadFormFieldType, LeadFormMapTo, LeadFormSubmissionDto, + LeadStageDto, OrgUserDto, PipelineDto, } from '@mento/shared'; +import { toFaDigits } from '@/lib/jalali'; type Tab = 'edit' | 'settings' | 'share' | 'responses'; type IconName = @@ -30,8 +33,9 @@ const error = ref(''); const ok = ref(''); const tab = ref('edit'); const desktopPreviewOpen = ref(false); +const publishOpen = ref(false); const advancedOpen = ref(false); -const readyOpen = ref('personal'); +const readyOpen = ref('lead'); const editFieldKey = ref(null); const dragKey = ref(null); @@ -42,15 +46,19 @@ const fields = ref([]); const submitLabel = ref('ارسال درخواست'); const submitColor = ref('#14b8a6'); const isActive = ref(true); -const createPerson = ref(true); -const createDeal = ref(true); +const createLead = ref(true); +const createPerson = ref(false); +const createDeal = ref(false); const pipelineId = ref(''); +const leadStageId = ref(''); +const duplicateMode = ref('update'); const source = ref('وب‌سایت'); const thankYouMessage = ref(''); const redirectUrl = ref(''); const assigneeIds = ref([]); const users = ref([]); const pipelines = ref([]); +const leadStages = ref([]); const submissions = ref([]); const coverInput = ref(null); @@ -75,7 +83,20 @@ const typeLabel: Record = Object.fromEntries( const readyGroups = [ { - key: 'personal', title: 'اطلاعات فردی', + key: 'lead', title: 'فیلدهای لید', + items: [ + { label: 'نام فروشگاه / عنوان', fieldType: 'text' as const, mapTo: 'lead.title' as const }, + { label: 'نام مخاطب', fieldType: 'text' as const, mapTo: 'lead.contactName' as const }, + { label: 'موبایل', fieldType: 'phone' as const, mapTo: 'lead.mobile' as const }, + { label: 'تلفن', fieldType: 'phone' as const, mapTo: 'lead.phone' as const }, + { label: 'شهر', fieldType: 'text' as const, mapTo: 'lead.city' as const }, + { label: 'منطقه', fieldType: 'text' as const, mapTo: 'lead.region' as const }, + { label: 'صنف', fieldType: 'text' as const, mapTo: 'lead.trade' as const }, + { label: 'توضیحات', fieldType: 'textarea' as const, mapTo: 'lead.notes' as const }, + ], + }, + { + key: 'personal', title: 'اطلاعات فردی (شخص)', items: [ { label: 'نام و نام خانوادگی', fieldType: 'text' as const, mapTo: 'person.fullName' as const }, { label: 'ایمیل', fieldType: 'email' as const, mapTo: 'person.email' as const }, @@ -85,27 +106,27 @@ const readyGroups = [ { key: 'org', title: 'اطلاعات سازمانی', items: [ - { label: 'نام شرکت', fieldType: 'text' as const, mapTo: 'custom' as const }, + { label: 'نام شرکت', fieldType: 'text' as const, mapTo: 'lead.title' as const }, { label: 'سمت', fieldType: 'text' as const, mapTo: 'person.position' as const }, ], }, - { - key: 'job', title: 'اطلاعات شغلی', - items: [{ label: 'عنوان شغلی', fieldType: 'text' as const, mapTo: 'person.position' as const }], - }, { key: 'market', title: 'سؤالات بازاریابی', items: [{ - label: 'از کجا آشنا شدید؟', fieldType: 'select' as const, mapTo: 'custom' as const, + label: 'از کجا آشنا شدید؟', fieldType: 'select' as const, mapTo: 'lead.source' as const, options: ['سایت', 'اینستاگرام', 'معرفی'], }], }, ]; -const origin = computed(() => window.location.origin); -const publicUrl = computed(() => (form.value ? `${origin.value}${form.value.publicPath}` : '')); +const publicBase = computed(() => { + const env = String(import.meta.env.VITE_LEAD_FORM_PUBLIC_BASE || '').replace(/\/$/, ''); + return env || window.location.origin; +}); +const publicUrl = computed(() => (form.value ? `${publicBase.value}${form.value.publicPath}` : '')); const iframeSnippet = computed( - () => ``, + () => + ``, ); const qrUrl = computed(() => `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(publicUrl.value)}`, @@ -114,6 +135,22 @@ const coverSrc = computed(() => form.value?.hasCover ? `/api/public/forms/${form.value.publicToken}/cover` : '', ); const editingField = computed(() => fields.value.find((f) => f.key === editFieldKey.value) ?? null); +const conversionRate = computed(() => { + const submits = form.value?.submitCount ?? 0; + const leads = form.value?.leadCreateCount ?? 0; + if (!submits) return '۰٪'; + return `${toFaDigits(Math.round((leads / submits) * 100))}٪`; +}); +const crmSummary = computed(() => { + const parts: string[] = []; + if (createLead.value) parts.push('ثبت لید'); + if (createPerson.value) parts.push('شخص'); + if (createDeal.value) parts.push('معامله'); + const stage = leadStages.value.find((s) => s.id === leadStageId.value); + if (stage) parts.push(`مرحله: ${stage.name}`); + if (source.value) parts.push(`منبع: ${source.value}`); + return parts.join(' · ') || 'اتصال CRM تنظیم نشده'; +}); function newKey(label: string) { const base = label.replace(/\s+/g, '_').slice(0, 24) || 'field'; @@ -123,10 +160,10 @@ function newKey(label: string) { } function defaultMap(type: LeadFormFieldType): LeadFormMapTo { - if (type === 'email') return 'person.email'; - if (type === 'phone') return 'person.mobile'; - if (type === 'text') return 'person.fullName'; - if (type === 'textarea') return 'deal.notes'; + if (type === 'email') return 'custom'; + if (type === 'phone') return 'lead.mobile'; + if (type === 'text') return 'lead.contactName'; + if (type === 'textarea') return 'lead.notes'; return 'custom'; } @@ -182,9 +219,12 @@ function applyForm(row: LeadFormDto) { submitLabel.value = row.submitLabel; submitColor.value = row.submitColor; isActive.value = row.isActive; + createLead.value = row.createLead; createPerson.value = row.createPerson; createDeal.value = row.createDeal; pipelineId.value = row.pipelineId ?? ''; + leadStageId.value = row.leadStageId ?? ''; + duplicateMode.value = (row.duplicateMode as LeadFormDuplicateMode) || 'update'; source.value = row.source ?? 'وب‌سایت'; thankYouMessage.value = row.thankYouMessage ?? ''; redirectUrl.value = row.redirectUrl ?? ''; @@ -195,14 +235,16 @@ async function load() { loading.value = true; error.value = ''; try { - const [fRes, uRes, pRes] = await Promise.all([ + const [fRes, uRes, pRes, sRes] = await Promise.all([ api.get(`/lead-forms/${id.value}`), api.get('/users'), api.get('/pipelines'), + api.get('/leads/stages'), ]); applyForm(fRes.data); users.value = uRes.data; pipelines.value = pRes.data; + leadStages.value = sRes.data; } catch { error.value = 'بارگذاری فرم ناموفق بود.'; } finally { @@ -219,9 +261,12 @@ async function save() { name: name.value.trim(), description: description.value.trim() || null, isActive: isActive.value, + createLead: createLead.value, createPerson: createPerson.value, createDeal: createDeal.value, pipelineId: pipelineId.value || null, + leadStageId: leadStageId.value || null, + duplicateMode: duplicateMode.value, source: source.value.trim() || null, thankYouMessage: thankYouMessage.value.trim() || null, redirectUrl: redirectUrl.value.trim() || null, @@ -295,6 +340,9 @@ onMounted(load); پیش‌نمایش + @@ -397,8 +445,9 @@ onMounted(load);
- - + + +
@@ -428,12 +477,21 @@ onMounted(load);
+

اتصال به CRM

+ + - @@ -451,10 +509,25 @@ onMounted(load); {{ u.fullName }} + +

مسیر اختیاری شخص / معامله

+ + +
+