From d7b5085096321d0e9d292de5764468e3337c9662 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 31 Oct 2026 12:49:42 +0330 Subject: [PATCH] feat(leads): keep gender on leads and form submissions A lead can carry the same honorific as a person, and a form that asks gender writes it onto the lead. Co-authored-by: Cursor --- .../20261031120000_lead_gender/migration.sql | 32 ++++++++ apps/api/prisma/schema.prisma | 4 + apps/api/src/lead-forms/dto.ts | 2 + apps/api/src/lead-forms/lead-forms.service.ts | 18 ++++- apps/api/src/leads/dto.ts | 5 ++ apps/api/src/leads/leads.service.ts | 76 ++++++++++++++++--- apps/api/test/person-name.test.ts | 43 ++++++++++- apps/web/src/views/LeadDetailView.vue | 62 ++++++++++++++- apps/web/src/views/LeadFormBuilderView.vue | 2 + apps/web/src/views/LeadsView.vue | 50 +++++++++++- packages/shared/src/index.ts | 11 +++ packages/shared/src/person-name.ts | 48 ++++++++++++ 12 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 apps/api/prisma/migrations/20261031120000_lead_gender/migration.sql diff --git a/apps/api/prisma/migrations/20261031120000_lead_gender/migration.sql b/apps/api/prisma/migrations/20261031120000_lead_gender/migration.sql new file mode 100644 index 0000000..70fa0d1 --- /dev/null +++ b/apps/api/prisma/migrations/20261031120000_lead_gender/migration.sql @@ -0,0 +1,32 @@ +ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false; + +-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید +UPDATE "Lead" +SET "gender" = 'male', "showGenderInName" = true +WHERE regexp_replace( + regexp_replace( + btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END), + 'ي', + 'ی', + 'g' + ), + 'ك', + 'ک', + 'g' +) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)'; + +UPDATE "Lead" +SET "gender" = 'female', "showGenderInName" = true +WHERE "gender" = 'none' + AND regexp_replace( + regexp_replace( + btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END), + 'ي', + 'ی', + 'g' + ), + 'ك', + 'ک', + 'g' + ) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1879be3..744e6b7 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -3023,6 +3023,10 @@ model Lead { title String /// نام مالک یا مسئول contactName String? + /// male | female | none + gender String @default("none") + /// عنوان «جناب آقای» / «سرکار خانم» داخل نام مخاطب + showGenderInName Boolean @default(false) mobile String phone String? city String? diff --git a/apps/api/src/lead-forms/dto.ts b/apps/api/src/lead-forms/dto.ts index c2944c7..853c0a7 100644 --- a/apps/api/src/lead-forms/dto.ts +++ b/apps/api/src/lead-forms/dto.ts @@ -32,6 +32,8 @@ const MAP_TO = [ 'lead.trade', 'lead.notes', 'lead.source', + 'lead.gender', + 'person.gender', 'notes', 'custom', ] as const; diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 97d41bc..abb46cd 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -13,6 +13,7 @@ import type { 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'; @@ -548,6 +549,7 @@ export class LeadFormsService { 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(), }, @@ -596,6 +598,7 @@ export class LeadFormsService { trade: emptyToNull(mapped.trade), source, notes: emptyToNull(mapped.notes), + gender: mapped.gender ?? 'none', stageId, position: (maxPos._max.position ?? -1) + 1, assigneeId, @@ -655,6 +658,7 @@ export class LeadFormsService { 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, }, }); @@ -671,6 +675,7 @@ export class LeadFormsService { phone, position: emptyToNull(mapped.position), notes: emptyToNull(mapped.notes), + gender: mapped.gender ?? 'none', ownerId, }, }); @@ -926,6 +931,7 @@ type MappedValues = { region?: string; trade?: string; leadSource?: string; + gender?: 'male' | 'female' | 'none'; }; function mapValues( @@ -946,6 +952,15 @@ function mapValues( 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); @@ -1069,7 +1084,8 @@ function collectExtras( for (const field of fields) { if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue; const raw = (values?.[field.key] ?? '').toString().trim(); - if (raw) lines.push(`${field.label}: ${raw}`); + if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue; + lines.push(`${field.label}: ${raw}`); } return lines.join('\n'); } diff --git a/apps/api/src/leads/dto.ts b/apps/api/src/leads/dto.ts index 7dddc42..ed8fd9a 100644 --- a/apps/api/src/leads/dto.ts +++ b/apps/api/src/leads/dto.ts @@ -8,6 +8,7 @@ import { MaxLength, MinLength, } from 'class-validator'; +import { PERSON_GENDERS } from '@mento/shared'; export class LeadSmsDto { @IsString() @@ -26,6 +27,8 @@ export class CreateLeadDto { mobile!: string; @IsOptional() @IsString() contactName?: string; + @IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number]; + @IsOptional() @IsBoolean() showGenderInName?: boolean; @IsOptional() @IsString() phone?: string; @IsOptional() @IsString() city?: string; @IsOptional() @IsString() region?: string; @@ -47,6 +50,8 @@ export class UpdateLeadDto { @IsOptional() @IsString() @MinLength(1) title?: string; @IsOptional() @IsString() @MinLength(5) mobile?: string; @IsOptional() @IsString() contactName?: string | null; + @IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number]; + @IsOptional() @IsBoolean() showGenderInName?: boolean; @IsOptional() @IsString() phone?: string | null; @IsOptional() @IsString() city?: string | null; @IsOptional() @IsString() region?: string | null; diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index d75ed7d..3879374 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -20,6 +20,7 @@ import type { PaginatedResponse, } from '@mento/shared'; import { FILTERED_IDS_MAX } from '@mento/shared'; +import { applyLeadGenderName, applyPersonGenderTitle } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { renderSmsText } from '../common/sms-text'; @@ -341,6 +342,8 @@ export class LeadsService { title: string; mobile: string; contactName?: string | null; + gender?: string | null; + showGenderInName?: boolean | null; phone?: string | null; city?: string | null; region?: string | null; @@ -382,11 +385,22 @@ export class LeadsService { } const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; + const named = applyLeadGenderName({ + title: dto.title, + contactName: dto.contactName, + gender: + dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none' + ? dto.gender + : undefined, + showGenderInName: dto.showGenderInName, + }); const row = await this.prisma.lead.create({ data: { organizationId: actor.organizationId, - title: dto.title.trim(), - contactName: emptyToNull(dto.contactName), + title: named.title, + contactName: named.contactName, + gender: named.gender, + showGenderInName: named.showGenderInName, mobile, phone: emptyToNull(dto.phone), city: emptyToNull(dto.city), @@ -461,12 +475,41 @@ export class LeadsService { } } + const touchName = + typeof dto.title === 'string' || + dto.contactName !== undefined || + dto.gender !== undefined || + dto.showGenderInName !== undefined; + const named = touchName + ? applyLeadGenderName({ + title: typeof dto.title === 'string' ? dto.title : existing.title, + contactName: + dto.contactName !== undefined + ? (dto.contactName as string | null) + : existing.contactName, + gender: + dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none' + ? dto.gender + : existing.gender === 'male' || existing.gender === 'female' + ? existing.gender + : 'none', + showGenderInName: + typeof dto.showGenderInName === 'boolean' + ? dto.showGenderInName + : existing.showGenderInName, + }) + : null; + const data: Prisma.LeadUpdateInput = { - ...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}), - mobile, - ...(dto.contactName !== undefined - ? { contactName: emptyToNull(dto.contactName as string) } + ...(named + ? { + title: named.title, + contactName: named.contactName, + gender: named.gender, + showGenderInName: named.showGenderInName, + } : {}), + mobile, ...(dto.phone !== undefined ? { phone: emptyToNull(dto.phone as string) } : {}), @@ -877,13 +920,24 @@ export class LeadsService { ? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim() : (lead.contactName || lead.title).trim(); const nameParts = rawName.split(/\s+/).filter(Boolean); - const firstName = nameParts[0] || lead.title; - const lastName = nameParts.slice(1).join(' ') || '—'; + const bareFirst = nameParts[0] || lead.title; + const bareLast = nameParts.slice(1).join(' '); + const named = applyPersonGenderTitle({ + firstName: bareFirst, + lastName: bareLast, + gender: + lead.gender === 'male' || lead.gender === 'female' || lead.gender === 'none' + ? lead.gender + : 'none', + showGenderInName: lead.showGenderInName, + }); person = await this.prisma.person.create({ data: { organizationId: actor.organizationId, - firstName, - lastName, + firstName: named.firstName, + lastName: named.lastName, + gender: named.gender, + showGenderInName: named.showGenderInName, mobile: lead.mobile, phone: lead.phone, instagram: lead.instagram, @@ -1202,6 +1256,8 @@ export class LeadsService { id: row.id, title: row.title, contactName: row.contactName, + gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none', + showGenderInName: row.showGenderInName, mobile: row.mobile, phone: row.phone, city: row.city, diff --git a/apps/api/test/person-name.test.ts b/apps/api/test/person-name.test.ts index c448e3d..804df41 100644 --- a/apps/api/test/person-name.test.ts +++ b/apps/api/test/person-name.test.ts @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { applyPersonGenderTitle, splitPersonHonorific } from '@mento/shared'; +import { + applyLeadGenderName, + applyPersonGenderTitle, + isGenderQuestionLabel, + parsePersonGenderAnswer, + splitPersonHonorific, +} from '@mento/shared'; describe('عنوان جنسیت در نام شخص', () => { it('ی عربی جناب آقای را مرد می‌خواند و عنوان معیار را می‌گذارد', () => { @@ -99,3 +105,38 @@ describe('عنوان جنسیت در نام شخص', () => { assert.equal(`${next.firstName} ${next.lastName}`, 'جناب آقای محمد انتظاری'); }); }); + +describe('جنسیت لید و پاسخ فرم', () => { + it('عنوان را روی نام مخاطب می‌گذارد و عنوان لید را دست نمی‌زند', () => { + const named = applyLeadGenderName({ + title: 'فروشگاه الماس', + contactName: 'مریم رضایی', + gender: 'female', + showGenderInName: true, + }); + assert.equal(named.title, 'فروشگاه الماس'); + assert.equal(named.contactName, 'سرکار خانم مریم رضایی'); + assert.equal(named.gender, 'female'); + assert.equal(named.showGenderInName, true); + }); + + it('اگر نام مخاطب خالی باشد عنوان لید پیشوند می‌گیرد', () => { + const named = applyLeadGenderName({ + title: 'آرش انتظاری', + contactName: ' ', + gender: 'male', + showGenderInName: true, + }); + assert.equal(named.title, 'جناب آقای آرش انتظاری'); + assert.equal(named.contactName, null); + }); + + it('پاسخ فرم مرد و زن و هیچکدام را می‌خواند و برچسب جنسیت را می‌شناسد', () => { + assert.equal(parsePersonGenderAnswer('مرد'), 'male'); + assert.equal(parsePersonGenderAnswer('خانم'), 'female'); + assert.equal(parsePersonGenderAnswer('هیچکدام'), 'none'); + assert.equal(parsePersonGenderAnswer('نمی‌دانم'), null); + assert.equal(isGenderQuestionLabel('جنسیت شما'), true); + assert.equal(isGenderQuestionLabel('نام'), false); + }); +}); diff --git a/apps/web/src/views/LeadDetailView.vue b/apps/web/src/views/LeadDetailView.vue index 1ddbbd3..b714f09 100644 --- a/apps/web/src/views/LeadDetailView.vue +++ b/apps/web/src/views/LeadDetailView.vue @@ -1,5 +1,5 @@