import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreateLeadFormRequest, LeadFormDto, LeadFormFieldInput, LeadFormUtmDto, PublicLeadFormDto, SubmitLeadFormRequest, SubmitLeadFormResponse, UpdateLeadFormRequest, } from '@mento/shared'; import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { canonicalIranMobile, emptyToNull, normalizeEmail, normalizePhone, phoneIdentityKeys, storedPhoneNumber, } from '../contacts/normalize'; import { LeadStagesService } from '../leads/lead-stages.service'; const COVER_CONTENT_TYPES: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', }; const DEFAULT_FIELDS: LeadFormFieldInput[] = [ { key: 'fullName', label: 'نام و نام خانوادگی', fieldType: 'text', required: true, mapTo: 'lead.contactName', placeholder: 'مثلاً علی منتظر', position: 0, }, { key: 'mobile', label: 'شماره تماس', fieldType: 'phone', required: true, mapTo: 'lead.mobile', placeholder: '۰۹۱۲۱۲۳۴۵۶۷', position: 1, }, { key: 'title', label: 'نام فروشگاه / موضوع', fieldType: 'text', required: true, mapTo: 'lead.title', placeholder: 'مثلاً فروشگاه نمونه', position: 2, }, { key: 'message', label: 'توضیحات', fieldType: 'textarea', required: false, 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() export class LeadFormsService { constructor( private readonly prisma: PrismaService, private readonly files: LocalFileStorage, private readonly storage: StorageQuotaService, private readonly automations: AutomationsService, private readonly messageAutomations: MessageAutomationsService, private readonly leadStages: LeadStagesService, ) {} async list(organizationId: string): Promise { const rows = await this.prisma.leadForm.findMany({ where: { organizationId }, include: { fields: { orderBy: { position: 'asc' } }, _count: { select: { submissions: true } }, }, orderBy: { updatedAt: 'desc' }, }); return rows.map((row) => ({ ...this.toDto(row), submissionCount: row._count.submissions, })); } async get(organizationId: string, id: string): Promise { const row = await this.findInOrg(organizationId, id); return this.toDto(row); } async create( organizationId: string, 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); const row = await this.prisma.leadForm.create({ data: { organizationId, name: dto.name.trim(), description: emptyToNull(dto.description ?? null), 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) ?? 'پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.', redirectUrl: emptyToNull(dto.redirectUrl ?? null), assigneeIds: dto.assigneeIds ?? [], submitLabel: dto.submitLabel?.trim() || 'ارسال درخواست', submitColor: dto.submitColor?.trim() || '#14b8a6', fields: { create: fields.map((f, index) => fieldCreateData(f, index)), }, }, include: { fields: { orderBy: { position: 'asc' } } }, }); return this.toDto(row); } async update( organizationId: string, id: string, dto: UpdateLeadFormRequest, ): Promise { await this.findInOrg(organizationId, id); 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); } const data: Prisma.LeadFormUpdateInput = {}; if (dto.name !== undefined) data.name = dto.name.trim(); if ('description' in dto) data.description = emptyToNull(dto.description ?? null); if (dto.submitLabel !== undefined) { data.submitLabel = dto.submitLabel.trim() || 'ارسال درخواست'; } if (dto.submitColor !== undefined) { 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); } if ('redirectUrl' in dto) { data.redirectUrl = emptyToNull(dto.redirectUrl ?? null); } if (dto.assigneeIds !== undefined) data.assigneeIds = dto.assigneeIds; if (dto.fields) { const fields = normalizeFields(dto.fields); await this.prisma.$transaction(async (tx) => { await tx.leadFormField.deleteMany({ where: { formId: id } }); await tx.leadForm.update({ where: { id }, data: { ...data, fields: { create: fields.map((f, index) => fieldCreateData(f, index)), }, }, }); }); } else if (Object.keys(data).length) { await this.prisma.leadForm.update({ where: { id }, data }); } return this.get(organizationId, id); } async remove(organizationId: string, id: string): Promise { const row = await this.findInOrg(organizationId, id); if (row.coverStorageKey) { const bytes = await this.files.sizeOf(row.coverStorageKey); await this.files.remove(row.coverStorageKey); if (bytes) await this.storage.addLocal(organizationId, -bytes); } await this.prisma.leadForm.delete({ where: { id } }); } async getPublic(token: string): Promise { const row = await this.prisma.leadForm.findFirst({ where: { publicToken: token, isActive: true }, include: { fields: { orderBy: { position: 'asc' } } }, }); if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است'); return { 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?v=${row.updatedAt.getTime()}` : null, 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, 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' } } }, }); if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است'); const mapped = mapValues(form.fields, values); const extras = collectExtras(form.fields, values); if (extras) { mapped.notes = [mapped.notes, extras].filter(Boolean).join('\n'); } 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); const actorId = ownerId ?? ( await this.prisma.user.findFirst({ where: { memberships: { some: { organizationId: form.organizationId, role: 'owner' }, }, }, select: { id: true }, }) )?.id; if (!actorId) { throw new BadRequestException('سازمان کاربر فعالی برای ثبت سرنخ ندارد'); } 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, ownerId ?? actorId, ); } 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, }, }); 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 leadCtx = { organizationId: form.organizationId, entityType: 'person' as const, entityId: personId ?? leadId ?? form.id, title, personId, ownerId: ownerId ?? actorId, }; await this.automations.runTrigger('lead_submitted', { ...leadCtx, entityType: personId ? 'person' : leadId ? 'lead' : 'person', sourceId: form.id, sourceType: 'lead_form', leadId, syncLeadAssignee: !ownerId && leadCreated && !!leadId, mobile: mapped.mobile ?? mapped.phone ?? null, formName: form.name, formValues: values, formFields: form.fields .filter((f) => f.fieldType !== 'heading') .map((f) => ({ key: f.key, label: f.label })), }); await this.messageAutomations.onEvent('lead_submitted', leadCtx); return { 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: { in: phoneIdentityKeys(mobile) } }, { phone: { in: phoneIdentityKeys(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, ...(mapped.gender ? { gender: mapped.gender } : {}), 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), gender: mapped.gender ?? 'none', 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, }; } private async upsertPerson( organizationId: string, mapped: MappedValues, ownerId: string, ): Promise { const email = normalizeEmail(mapped.email); const mobile = storedPhoneNumber(mapped.mobile); const phone = storedPhoneNumber(mapped.phone); const or: Prisma.PersonWhereInput[] = []; if (email) or.push({ email }); const mobileKeys = phoneIdentityKeys(mobile); if (mobileKeys.length) { or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); } const existing = or.length ? await this.prisma.person.findFirst({ where: { organizationId, OR: or }, }) : null; if (existing) { await this.prisma.person.update({ where: { id: existing.id }, data: { firstName: mapped.firstName?.trim() || existing.firstName, lastName: mapped.lastName?.trim() || existing.lastName, email: email ?? existing.email, mobile: mobile ?? existing.mobile, phone: phone ?? existing.phone, position: emptyToNull(mapped.position) ?? existing.position, notes: emptyToNull(mapped.notes) ?? existing.notes, ...(mapped.gender ? { gender: mapped.gender } : {}), ownerId: existing.ownerId ?? ownerId, }, }); return existing.id; } const created = await this.prisma.person.create({ data: { organizationId, firstName: (mapped.firstName?.trim() || 'سرنخ').slice(0, 80), lastName: (mapped.lastName?.trim() || 'جدید').slice(0, 80), email, mobile, phone, position: emptyToNull(mapped.position), notes: emptyToNull(mapped.notes), gender: mapped.gender ?? 'none', ownerId, }, }); return created.id; } private async createDeal( form: FormRow, mapped: MappedValues, personId: string | null, actorId: string, ownerId: string | null, ) { const pipeline = (form.pipelineId ? await this.prisma.pipeline.findFirst({ where: { id: form.pipelineId, organizationId: form.organizationId }, include: { stages: { orderBy: { position: 'asc' } } }, }) : null) ?? (await this.prisma.pipeline.findFirst({ where: { organizationId: form.organizationId, isDefault: true }, include: { stages: { orderBy: { position: 'asc' } } }, })); if (!pipeline?.stages.length) { throw new BadRequestException('مسیر فروش فعالی برای ثبت معامله وجود ندارد'); } const stage = pipeline.stages.find((s) => !s.isWon && !s.isLost) ?? pipeline.stages[0]; 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({ where: { organizationId: form.organizationId, stageId: stage.id }, _max: { position: true }, }); await this.prisma.deal.create({ data: { organizationId: form.organizationId, pipelineId: pipeline.id, stageId: stage.id, title: title.slice(0, 200), source: form.source ?? 'فرم وب', notes: emptyToNull(mapped.dealNotes ?? mapped.notes), personId, ownerId: ownerId ?? actorId, position: (maxPos._max.position ?? -1) + 1, status: 'open', currency: 'IRR', }, }); } private async nextAssignee(form: FormRow): Promise { const ids = form.assigneeIds.filter(Boolean); if (!ids.length) return null; const index = form.roundRobinIndex % ids.length; const chosen = ids[index]; await this.prisma.leadForm.update({ where: { id: form.id }, data: { roundRobinIndex: index + 1 }, }); return chosen; } private async findInOrg(organizationId: string, id: string): Promise { const row = await this.prisma.leadForm.findFirst({ where: { id, organizationId }, include: { fields: { orderBy: { position: 'asc' } } }, }); if (!row) throw new NotFoundException('فرم پیدا نشد'); return row; } private async assertPipeline( organizationId: string, pipelineId?: string | null, ) { const id = emptyToNull(pipelineId ?? null); if (!id) return; const found = await this.prisma.pipeline.findFirst({ where: { id, organizationId }, select: { id: true }, }); 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[], ) { if (!assigneeIds?.length) return; const count = await this.prisma.user.count({ where: { id: { in: assigneeIds }, memberships: { some: { organizationId } }, }, }); if (count !== assigneeIds.length) { throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست'); } } async listSubmissions(organizationId: string, id: string) { await this.findInOrg(organizationId, id); const rows = await this.prisma.leadFormSubmission.findMany({ where: { formId: id }, orderBy: { createdAt: 'desc' }, take: 200, }); return rows.map((r) => ({ 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(), })); } async saveCover( organizationId: string, id: string, file: { buffer: Buffer; originalname: string }, ) { if (!file?.buffer) throw new BadRequestException('فایل کاور ارسال نشده'); const row = await this.findInOrg(organizationId, id); const ext = (file.originalname.split('.').pop() || 'jpg') .toLowerCase() .replace(/[^a-z0-9]/g, '') .slice(0, 6); if (!COVER_CONTENT_TYPES[ext]) { throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد'); } const key = `lead-forms/${id}/cover.${ext}`; const prevBytes = row.coverStorageKey ? await this.files.sizeOf(row.coverStorageKey) : 0; const delta = file.buffer.length - prevBytes; if (delta > 0) await this.storage.assertCanAdd(organizationId, delta); await this.files.save(key, file.buffer); if (row.coverStorageKey && row.coverStorageKey !== key) { await this.files.remove(row.coverStorageKey); } await this.prisma.leadForm.update({ where: { id }, data: { coverStorageKey: key }, }); if (delta) await this.storage.addLocal(organizationId, delta); return this.get(organizationId, id); } async removeCover(organizationId: string, id: string) { const row = await this.findInOrg(organizationId, id); if (row.coverStorageKey) { const bytes = await this.files.sizeOf(row.coverStorageKey); await this.files.remove(row.coverStorageKey); if (bytes) await this.storage.addLocal(organizationId, -bytes); } await this.prisma.leadForm.update({ where: { id }, data: { coverStorageKey: null }, }); return this.get(organizationId, id); } coverKey(row: FormRow) { return row.coverStorageKey; } coverContentType(key: string) { const ext = key.split('.').pop()?.toLowerCase() ?? ''; return COVER_CONTENT_TYPES[ext] ?? 'image/jpeg'; } async getByToken(token: string, onlyActive = false) { return this.prisma.leadForm.findFirst({ where: { publicToken: token, ...(onlyActive ? { isActive: true } : {}) }, include: { fields: true }, }); } private toDto(row: FormRow): LeadFormDto { return { id: row.id, name: row.name, 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, assigneeIds: row.assigneeIds, submitLabel: row.submitLabel || 'ارسال درخواست', submitColor: row.submitColor || '#14b8a6', hasCover: Boolean(row.coverStorageKey), fields: row.fields.map((f) => ({ id: f.id, key: f.key, label: f.label, fieldType: f.fieldType, required: f.required, mapTo: f.mapTo, position: f.position, 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(), }; } } type MappedValues = { firstName?: string; lastName?: string; email?: string; mobile?: string; phone?: string; position?: string; dealTitle?: string; dealNotes?: string; notes?: string; leadTitle?: string; contactName?: string; city?: string; region?: string; trade?: string; leadSource?: string; gender?: 'male' | 'female' | 'none'; }; function mapValues( fields: Array<{ key: string; mapTo: string; required: boolean; label: string; fieldType?: string; }>, values: Record, ): MappedValues { const mapped: MappedValues = {}; for (const field of fields) { if (field.fieldType === 'heading') continue; const raw = (values?.[field.key] ?? '').toString().trim(); if (field.required && !raw) { throw new BadRequestException(`فیلد «${field.label}» الزامی است`); } if (!raw) continue; if ( field.mapTo === 'lead.gender' || field.mapTo === 'person.gender' || isGenderQuestionLabel(field.label) ) { const gender = parsePersonGenderAnswer(raw); if (gender) mapped.gender = gender; continue; } switch (field.mapTo) { case 'person.fullName': { 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': mapped.firstName = raw; break; case 'person.lastName': mapped.lastName = raw; break; case 'person.email': mapped.email = raw; break; case 'person.mobile': mapped.mobile = raw; break; case 'person.phone': mapped.phone = raw; break; case 'person.position': mapped.position = raw; break; case 'deal.title': mapped.dealTitle = raw; break; 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; default: break; } } return mapped; } function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] { return fields.map((f, index) => { const key = f.key?.trim() || slugKey(f.label) || `field_${index + 1}`; return { ...f, key: key.slice(0, 64), label: f.label.trim(), fieldType: f.fieldType || 'text', required: f.required ?? false, mapTo: f.mapTo, position: f.position ?? index, }; }); } function fieldCreateData(f: LeadFormFieldInput, index: number) { return { key: f.key!, label: f.label, fieldType: f.fieldType, required: f.required ?? false, mapTo: f.mapTo, position: f.position ?? index, placeholder: emptyToNull(f.placeholder ?? null), options: f.options?.length ? f.options : undefined, }; } function asOptions(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((v) => String(v).trim()).filter(Boolean); } function asStringRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { if (v == null) continue; out[k] = typeof v === 'string' ? v : JSON.stringify(v); } return out; } function collectExtras( fields: Array<{ key: string; mapTo: string; label: string; fieldType: string }>, values: Record, ): string { const lines: string[] = []; for (const field of fields) { if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue; const raw = (values?.[field.key] ?? '').toString().trim(); if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue; lines.push(`${field.label}: ${raw}`); } return lines.join('\n'); } function slugKey(label: string): string { return label .trim() .toLowerCase() .replace(/[^\w\u0600-\u06FF]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 40); } function mobileKey(raw: string | null | undefined): string | null { const canon = canonicalIranMobile(raw); if (canon) return canon; const n = normalizePhone(raw); if (!n) return null; const d = n.replace(/\D/g, ''); return d || null; } 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; }