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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-31 12:49:42 +03:30
co-authored by Cursor
parent cbc2e4ee8a
commit d7b5085096
12 changed files with 334 additions and 19 deletions
@@ -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:]]|$)';
+4
View File
@@ -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?
+2
View File
@@ -32,6 +32,8 @@ const MAP_TO = [
'lead.trade',
'lead.notes',
'lead.source',
'lead.gender',
'person.gender',
'notes',
'custom',
] as const;
+17 -1
View File
@@ -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');
}
+5
View File
@@ -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;
+66 -10
View File
@@ -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,
+42 -1
View File
@@ -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);
});
});
+58 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
@@ -7,6 +7,7 @@ import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
@@ -66,8 +67,9 @@ import type {
LeadStageDto,
OrgUserDto,
PaginatedResponse,
PersonGender,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
import { canUseBehavior } from '@/lib/behavior-access';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
@@ -113,6 +115,8 @@ let narrowMq: MediaQueryList | null = null;
const editForm = ref({
title: '',
contactName: '',
gender: 'none' as PersonGender,
showGenderInName: false,
mobile: '',
phone: '',
city: '',
@@ -145,6 +149,30 @@ const answerRows = computed(() =>
noteView.value.rows.filter((row) => !isBirthDateAnswer(row.label)),
);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
const genderLabel = computed(() => {
const gender = lead.value?.gender;
const key = gender === 'male' || gender === 'female' ? gender : 'none';
const label = PERSON_GENDER_LABELS[key];
return lead.value?.showGenderInName && key !== 'none' ? `${label} · در نام` : label;
});
function bareLeadName(value: string) {
const split = splitPersonHonorific(value, '');
return [split.firstName, split.lastName].filter(Boolean).join(' ');
}
watch(
() => editForm.value.gender,
(value) => {
if (value === 'none') editForm.value.showGenderInName = false;
},
);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -186,9 +214,13 @@ function startEdit() {
if (!lead.value) return;
editing.value = true;
moreOpen.value = false;
const gender =
lead.value.gender === 'male' || lead.value.gender === 'female' ? lead.value.gender : 'none';
editForm.value = {
title: lead.value.title,
contactName: lead.value.contactName ?? '',
title: bareLeadName(lead.value.title),
contactName: bareLeadName(lead.value.contactName ?? ''),
gender,
showGenderInName: !!lead.value.showGenderInName && gender !== 'none',
mobile: lead.value.mobile,
phone: lead.value.phone ?? '',
city: lead.value.city ?? '',
@@ -214,6 +246,9 @@ async function saveEdit() {
await api.patch(`/leads/${id.value}`, {
title: editForm.value.title.trim(),
contactName: editForm.value.contactName.trim() || null,
gender: editForm.value.gender,
showGenderInName:
editForm.value.gender !== 'none' && editForm.value.showGenderInName,
mobile: editForm.value.mobile.trim(),
phone: editForm.value.phone.trim() || null,
city: editForm.value.city.trim() || null,
@@ -553,6 +588,24 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-input v-model:value="editForm.contactName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="editForm.gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره می‌شود. اگر نام مخاطب خالی باشد روی عنوان می‌نشیند و با تبدیل به مشتری به ته‌حساب هم می‌رود."
>
<a-checkbox
v-model:checked="editForm.showGenderInName"
:disabled="editForm.gender === 'none'"
>
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="موبایل" required>
<a-input
@@ -666,6 +719,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-descriptions-item label="مالک/مخاطب">
{{ lead.contactName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="جنسیت">{{ genderLabel }}</a-descriptions-item>
<a-descriptions-item label="موبایل">
<span dir="ltr">{{ lead.mobile }}</span>
</a-descriptions-item>
@@ -997,6 +997,7 @@ onMounted(load);
<option value="lead.trade">صنف</option>
<option value="lead.notes">یادداشت لید</option>
<option value="lead.source">منبع لید</option>
<option value="lead.gender">جنسیت</option>
</optgroup>
<optgroup label="شخص">
<option value="person.fullName">نام کامل</option>
@@ -1006,6 +1007,7 @@ onMounted(load);
<option value="person.email">ایمیل</option>
<option value="person.phone">تلفن</option>
<option value="person.position">سمت</option>
<option value="person.gender">جنسیت شخص</option>
</optgroup>
<optgroup label="معامله">
<option value="deal.title">عنوان معامله</option>
+47 -3
View File
@@ -77,9 +77,10 @@ import type {
LeadStageDto,
LeadStatsDto,
OrgUserDto,
PersonGender,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { brand, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
@@ -191,6 +192,8 @@ const editingLeadId = ref<string | null>(null);
const createForm = ref({
title: '',
contactName: '',
gender: 'none' as PersonGender,
showGenderInName: false,
mobile: '',
city: '',
region: '',
@@ -656,12 +659,31 @@ const createModalTitle = computed(() =>
editingLeadId.value ? 'ویرایش لید' : 'ثبت لید جدید',
);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
function bareLeadName(value: string) {
const split = splitPersonHonorific(value, '');
return [split.firstName, split.lastName].filter(Boolean).join(' ');
}
watch(
() => createForm.value.gender,
(value) => {
if (value === 'none') createForm.value.showGenderInName = false;
},
);
function openCreate() {
editingLeadId.value = null;
createError.value = '';
createForm.value = {
title: '',
contactName: '',
gender: 'none',
showGenderInName: false,
mobile: '',
city: '',
region: '',
@@ -693,8 +715,10 @@ function openEdit(lead: LeadDto) {
editingLeadId.value = lead.id;
createError.value = '';
createForm.value = {
title: lead.title,
contactName: lead.contactName ?? '',
title: bareLeadName(lead.title),
contactName: bareLeadName(lead.contactName ?? ''),
gender: lead.gender && lead.gender !== 'none' ? lead.gender : 'none',
showGenderInName: !!lead.showGenderInName && lead.gender !== 'none',
mobile: lead.mobile,
city: lead.city ?? '',
region: lead.region ?? '',
@@ -857,6 +881,8 @@ async function submitCreate() {
title: company || person || mobile,
mobile,
contactName: person || null,
gender: createForm.value.gender,
showGenderInName: createForm.value.gender !== 'none' && createForm.value.showGenderInName,
city: createForm.value.city.trim() || null,
region: createForm.value.region.trim() || null,
source: createForm.value.source.trim() || null,
@@ -1629,6 +1655,24 @@ onBeforeUnmount(() => {
<a-input v-model:value="createForm.contactName" placeholder="مهدی احمدی" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="createForm.gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره می‌شود. اگر نام مخاطب خالی باشد روی عنوان می‌نشیند و با تبدیل به مشتری به ته‌حساب هم می‌رود."
>
<a-checkbox
v-model:checked="createForm.showGenderInName"
:disabled="createForm.gender === 'none'"
>
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="موبایل">
<template #extra>
+11
View File
@@ -3,7 +3,10 @@ export type { PersonGender } from "./person-name";
export {
PERSON_GENDERS,
PERSON_GENDER_LABELS,
applyLeadGenderName,
applyPersonGenderTitle,
isGenderQuestionLabel,
parsePersonGenderAnswer,
splitPersonHonorific,
} from "./person-name";
@@ -2432,6 +2435,8 @@ export type LeadFormMapTo =
| "lead.trade"
| "lead.notes"
| "lead.source"
| "lead.gender"
| "person.gender"
| "notes"
| "custom";
@@ -5470,6 +5475,8 @@ export interface LeadDto {
id: string;
title: string;
contactName: string | null;
gender: PersonGender;
showGenderInName: boolean;
mobile: string;
phone: string | null;
city: string | null;
@@ -5510,6 +5517,8 @@ export interface CreateLeadRequest {
title: string;
mobile: string;
contactName?: string | null;
gender?: PersonGender;
showGenderInName?: boolean;
phone?: string | null;
city?: string | null;
region?: string | null;
@@ -5531,6 +5540,8 @@ export interface UpdateLeadRequest {
title?: string;
mobile?: string;
contactName?: string | null;
gender?: PersonGender;
showGenderInName?: boolean;
phone?: string | null;
city?: string | null;
region?: string | null;
+48
View File
@@ -84,3 +84,51 @@ export function applyPersonGenderTitle(input: {
showGenderInName: show,
};
}
/** پاسخ فیلد جنسیت فرم: مرد، زن، یا هیچکدام. مقدار ناشناس null است. */
export function parsePersonGenderAnswer(raw: string): PersonGender | null {
const text = normalizeFa(raw).toLowerCase();
if (!text) return null;
const has = (words: string[]) => words.some((word) => text === word || text.includes(word));
if (has(["هیچکدام", "none", "نامشخص"])) return "none";
if (has(["female", "woman", "خانم", "زن"])) return "female";
if (has(["male", "man", "آقا", "مرد"])) return "male";
return null;
}
export function isGenderQuestionLabel(label: string): boolean {
return normalizeFa(label).replace(/\s+/g, "").includes("جنسیت");
}
/**
* عنوان روی نام مخاطب لید می‌نشیند.
* اگر نام مخاطب خالی باشد، عنوان لید همان نام شخص است.
*/
export function applyLeadGenderName(input: {
title: string;
contactName?: string | null;
gender?: PersonGender | null;
showGenderInName?: boolean | null;
}): {
title: string;
contactName: string | null;
gender: PersonGender;
showGenderInName: boolean;
} {
const contact = (input.contactName ?? "").trim();
const title = input.title.trim();
const onContact = contact.length > 0;
const named = applyPersonGenderTitle({
firstName: onContact ? contact : title,
lastName: "",
gender: input.gender,
showGenderInName: input.showGenderInName,
});
const full = [named.firstName, named.lastName].filter((part) => part && part !== "—").join(" ");
return {
title: onContact ? title : full || title,
contactName: onContact ? full || null : null,
gender: named.gender,
showGenderInName: named.showGenderInName,
};
}