fix: keep birthdays and show Shamsi dates

Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 18:04:53 +03:30
co-authored by Cursor
parent 5f4cc47c41
commit cf326d1ed9
34 changed files with 637 additions and 105 deletions
@@ -9,6 +9,7 @@ import {
} from '../common/custom-fields';
import {
formatJalaliDate,
parseBirthDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
@@ -22,6 +23,7 @@ import {
} from '../common/excel-import-session';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
import { normalizeEmail, normalizePhone } from './normalize';
import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = {
@@ -112,6 +114,11 @@ const PERSON_ALIASES: Record<string, string> = {
تاریختولدمشتری: 'birthDate',
تاریختولد1: 'birthDate',
تاریختولد: 'birthDate',
تاریختولدشمسی: 'birthDate',
تاریخمیلادت: 'birthDate',
birthdate: 'birthDate',
dateofbirth: 'birthDate',
bdate: 'birthDate',
منبعاطلاعات: 'source',
نوعمشتری: 'customerType',
type: '_type',
@@ -162,6 +169,7 @@ const PERSON_FIELDS = [
{ key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' },
{ key: 'birthDate', label: 'تاریخ تولد' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
@@ -393,7 +401,7 @@ export class ContactsExcelService {
visibility: 'همه افراد شرکت',
nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt),
birthDate: extra.birthDate ?? '',
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '',
address: p.addresses[0] ?? extra.address ?? '',
@@ -408,7 +416,7 @@ export class ContactsExcelService {
postalCode2: extra.postalCode ?? '',
referrer: extra.referrer ?? p.referrer ?? '',
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
birthDate2: extra.birthDate ?? '',
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
instagram: p.instagram ?? extra.instagram ?? '',
group: extra.group ?? '',
colleagueContact: extra.colleagueContact ?? '',
@@ -578,6 +586,7 @@ export class ContactsExcelService {
position: row.position || undefined,
website: row.website || undefined,
instagram: row.instagram || undefined,
birthDate: parseBirthDate(row.birthDate)?.toISOString() ?? null,
addresses,
notes: row.notes || undefined,
mood: parseMood(row.mood) ?? parseMood(row.group),
@@ -597,6 +606,14 @@ export class ContactsExcelService {
}
} catch (e) {
if (e instanceof ConflictException) {
const birthDate = parseBirthDate(row.birthDate);
if (birthDate) {
await this.people.fillBirthDateIfEmpty(actor.organizationId, {
mobile: normalizePhone(row.mobile),
email: normalizeEmail(row.email),
birthDate,
});
}
result.skipped += 1;
} else {
result.errors.push({
+10
View File
@@ -293,6 +293,11 @@ export class CreatePersonDto {
@MaxLength(200)
instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
@@ -401,6 +406,11 @@ export class UpdatePersonDto {
@MaxLength(200)
instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
+74 -1
View File
@@ -1,7 +1,9 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
type OnModuleInit,
} from '@nestjs/common';
import type {
CreatePersonRequest,
@@ -32,6 +34,7 @@ import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import {
@@ -63,6 +66,7 @@ type PersonRow = {
position: string | null;
website: string | null;
instagram: string | null;
birthDate: Date | null;
referrer: string | null;
addresses: unknown;
notes: string | null;
@@ -83,7 +87,8 @@ type PersonRow = {
};
@Injectable()
export class PeopleService {
export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name);
constructor(
private readonly prisma: PrismaService,
private readonly companies: CompaniesService,
@@ -168,6 +173,71 @@ export class PeopleService {
.sort((a, b) => b.count - a.count);
}
onModuleInit() {
void this.backfillStoredBirthDates();
}
/** تاریخ تولدی که قبلاً در فیلد اضافی دیدار/اکسل یا پروفایل مونتیفای مانده، روی شخص می‌نشیند. */
async backfillStoredBirthDates() {
try {
const fromFields = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
SELECT id,
COALESCE(
NULLIF("customFields"->>'birthDate', ''),
NULLIF("customFields"->>'birthDate2', '')
) AS birth
FROM "Person"
WHERE "birthDate" IS NULL
AND (
COALESCE("customFields"->>'birthDate', '') <> ''
OR COALESCE("customFields"->>'birthDate2', '') <> ''
)
LIMIT 20000
`;
const fromMontify = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
SELECT p.id, m.data->>'birthday' AS birth
FROM "MontifyLink" m
JOIN "Person" p ON p.id = m."entityId"
WHERE m."entityType" = 'customer'
AND p."birthDate" IS NULL
AND COALESCE(m.data->>'birthday', '') <> ''
LIMIT 20000
`;
const seen = new Set<string>();
let filled = 0;
for (const row of [...fromFields, ...fromMontify]) {
if (!row.birth || seen.has(row.id)) continue;
seen.add(row.id);
const birthDate = parseBirthDate(row.birth);
if (!birthDate) continue;
const updated = await this.prisma.person.updateMany({
where: { id: row.id, birthDate: null },
data: { birthDate },
});
filled += updated.count;
}
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از داده‌های ذخیره‌شده پر شد`);
} catch (err) {
this.logger.warn(
`پر کردن تاریخ تولد ذخیره‌شده ناموفق بود: ${err instanceof Error ? err.message : err}`,
);
}
}
async fillBirthDateIfEmpty(
organizationId: string,
input: { mobile?: string | null; email?: string | null; birthDate: Date },
) {
const or: Prisma.PersonWhereInput[] = [];
if (input.mobile) or.push({ mobile: input.mobile });
if (input.email) or.push({ email: input.email });
if (!or.length) return;
await this.prisma.person.updateMany({
where: { organizationId, birthDate: null, OR: or },
data: { birthDate: input.birthDate },
});
}
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -198,6 +268,7 @@ export class PeopleService {
position: emptyToNull(dto.position),
website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram),
birthDate: parseBirthDate(dto.birthDate),
referrer: emptyToNull(dto.referrer),
addresses: toJsonAddresses(dto.addresses),
notes: emptyToNull(dto.notes),
@@ -279,6 +350,7 @@ export class PeopleService {
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
if ('birthDate' in dto) data.birthDate = parseBirthDate(dto.birthDate);
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
@@ -396,6 +468,7 @@ export class PeopleService {
position: row.position,
website: row.website,
instagram: row.instagram,
birthDate: row.birthDate?.toISOString() ?? null,
referrer: row.referrer,
addresses: (() => {
const fromCol = asAddresses(row.addresses);