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
+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,