import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException, type OnModuleInit, } from '@nestjs/common'; import type { CreatePersonRequest, FilteredIdsResponse, PaginatedResponse, PersonDto, PersonImportSourceStatDto, PersonMood, UpdatePersonRequest, } from '@mento/shared'; import { FILTERED_IDS_MAX } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { CompaniesService } from './companies.service'; import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; import { asAddresses, asCustomFields, toJsonAddresses, toJsonCustomFields, } from '../common/custom-fields'; import { type Actor, canReassignOwner, ownerWhere, resolveOwnerId, } from '../common/record-scope'; import { LoyaltyService } from '../loyalty/loyalty.service'; 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 { SmsService } from '../common/sms.service'; import { selectMonthBirthdays } from './birthday-month'; import { renderSmsText } from '../common/sms-text'; import { normalizeTags } from '../common/tags'; import { buildSearchWhere, ci } from '../common/search'; import { normalizePersonImportSource, personImportSourceLabel, } from './person-source'; const personInclude = { company: { select: { id: true, name: true, province: true, city: true } }, owner: { select: { id: true, fullName: true } }, loyaltyTier: { select: { id: true, name: true, minPoints: true, color: true }, }, } satisfies Prisma.PersonInclude; type PersonRow = { id: string; firstName: string; lastName: string; previousFirstName: string | null; previousLastName: string | null; importSource: string; sourceRef: string | null; previousImportSource: string | null; previousSourceRef: string | null; email: string | null; phone: string | null; mobile: string | null; position: string | null; website: string | null; instagram: string | null; birthDate: Date | null; referrer: string | null; addresses: unknown; notes: string | null; mood: PersonMood; tags: string[]; customFields: unknown; loyaltyPoints: number; createdAt: Date; updatedAt: Date; company: { id: string; name: string; province: string | null; city: string | null; } | null; owner: { id: string; fullName: string } | null; loyaltyTier: { id: string; name: string; minPoints: number; color: string | null; } | null; }; @Injectable() export class PeopleService implements OnModuleInit { private readonly logger = new Logger(PeopleService.name); constructor( private readonly prisma: PrismaService, private readonly companies: CompaniesService, private readonly loyalty: LoyaltyService, private readonly automations: AutomationsService, private readonly messageAutomations: MessageAutomationsService, private readonly webhooks: WebhooksService, private readonly game: GamificationService, private readonly sms: SmsService, private readonly outboundSms: OutboundSmsLogService, ) {} private personListWhere( actor: Actor, q?: string, opts?: { mood?: PersonMood; importSource?: string; province?: string; city?: string; hasMobile?: boolean; }, ): Prisma.PersonWhereInput { const sourceFilter = opts?.importSource?.trim() ? normalizePersonImportSource(opts.importSource) : undefined; const search = buildSearchWhere(q, (t) => [ { firstName: ci(t) }, { lastName: ci(t) }, { previousFirstName: ci(t) }, { previousLastName: ci(t) }, { email: ci(t) }, { mobile: { contains: t } }, { phone: { contains: t } }, { sourceRef: ci(t) }, { previousSourceRef: ci(t) }, { company: { name: ci(t) } }, { company: { province: ci(t) } }, { company: { city: ci(t) } }, ]); const province = opts?.province?.trim(); const city = opts?.city?.trim(); const and: Prisma.PersonWhereInput[] = []; if (search) and.push(search); if (province) { and.push({ OR: [ { company: { province: { contains: province, mode: 'insensitive' } } }, { company: { city: { contains: province, mode: 'insensitive' } } }, ], }); } if (city) { and.push({ OR: [ { company: { city: { contains: city, mode: 'insensitive' } } }, { company: { province: { contains: city, mode: 'insensitive' } } }, ], }); } if (opts?.hasMobile) { and.push({ mobile: { not: null } }, { NOT: { mobile: '' } }); } return { organizationId: actor.organizationId, ...ownerWhere(actor), ...(opts?.mood ? { mood: opts.mood } : {}), ...(sourceFilter ? { importSource: sourceFilter } : {}), ...(and.length ? { AND: and } : {}), }; } async list( actor: Actor, q?: string, page = 1, limit = 20, opts?: { mood?: PersonMood; sort?: 'name' | 'newest'; importSource?: string; province?: string; city?: string; hasMobile?: boolean; }, ): Promise> { const where = this.personListWhere(actor, q, opts); const [rows, total] = await this.prisma.$transaction([ this.prisma.person.findMany({ where, include: personInclude, orderBy: opts?.sort === 'newest' ? [{ createdAt: 'desc' }] : [{ lastName: 'asc' }, { firstName: 'asc' }], skip: (page - 1) * limit, take: limit, }), this.prisma.person.count({ where }), ]); return { items: rows.map((row) => this.toDto(row)), total, page, limit, }; } async listIds( actor: Actor, q?: string, opts?: { mood?: PersonMood; importSource?: string; province?: string; city?: string; hasMobile?: boolean; }, max = FILTERED_IDS_MAX, ): Promise { const where = this.personListWhere(actor, q, opts); const total = await this.prisma.person.count({ where }); if (total > max) { throw new BadRequestException( `تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`, ); } const rows = await this.prisma.person.findMany({ where, select: { id: true }, orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], take: max, }); return { ids: rows.map((r) => r.id), total }; } async sourceStats(actor: Actor): Promise { const rows = await this.prisma.person.groupBy({ by: ['importSource'], where: { organizationId: actor.organizationId, ...ownerWhere(actor), }, _count: { _all: true }, }); return rows .map((row) => ({ source: row.importSource || 'manual', label: personImportSourceLabel(row.importSource || 'manual'), count: row._count._all, })) .sort((a, b) => b.count - a.count); } onModuleInit() { void this.backfillStoredBirthDates(); } /** تاریخ تولدی که قبلاً در فیلد اضافی دیدار/اکسل یا پروفایل مونتیفای مانده، روی شخص می‌نشیند. */ async backfillStoredBirthDates() { try { const fromFields = await this.prisma.$queryRaw>` 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>` 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(); 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 { const row = await this.prisma.person.findFirst({ where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, include: personInclude, }); if (!row) throw new NotFoundException('شخص پیدا نشد'); return this.toDto(row); } async create(actor: Actor, dto: CreatePersonRequest): Promise { const companyId = emptyToNull(dto.companyId ?? null); if (companyId) { await this.companies.ensureInOrg(actor.organizationId, companyId, actor); } const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; const data: Prisma.PersonUncheckedCreateInput = { organizationId: actor.organizationId, firstName: (dto.firstName ?? '').trim(), lastName: (dto.lastName ?? '').trim(), previousFirstName: emptyToNull(dto.previousFirstName), previousLastName: emptyToNull(dto.previousLastName), importSource: normalizePersonImportSource(dto.importSource), sourceRef: emptyToNull(dto.sourceRef), email: normalizeEmail(dto.email), phone: normalizePhone(dto.phone), mobile: normalizePhone(dto.mobile), 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), mood: dto.mood ?? 'newcomer', tags: normalizeTags(dto.tags), companyId, ownerId: resolveOwnerId(actor, dto.ownerId), customFields: toJsonCustomFields(dto.customFields), ...(registeredAt && !Number.isNaN(registeredAt.getTime()) ? { createdAt: registeredAt } : {}), }; await this.assertNoDuplicate(actor.organizationId, data); const row = await this.prisma.person.create({ data, include: personInclude, }); await this.loyalty.applyWelcomePoints(actor.organizationId, row.id); const createdCtx = { organizationId: actor.organizationId, entityType: 'person' as const, entityId: row.id, title: `${row.firstName} ${row.lastName}`.trim(), personId: row.id, companyId: row.companyId, ownerId: row.ownerId, }; await this.automations.runTrigger('person_created', createdCtx); await this.messageAutomations.onEvent('person_created', createdCtx); if (row.importSource === 'manual') { this.game.emit({ organizationId: actor.organizationId, userId: actor.userId, event: 'CUSTOMER_CREATED', entityType: 'person', entityId: row.id, entityTitle: createdCtx.title, facts: { source: row.importSource }, }); } this.webhooks.emit(actor.organizationId, 'person.created', { id: row.id, fullName: createdCtx.title, mobile: row.mobile, email: row.email, companyId: row.companyId, }); const fresh = await this.prisma.person.findFirst({ where: { id: row.id }, include: personInclude, }); return this.toDto(fresh ?? row); } async update( actor: Actor, id: string, dto: UpdatePersonRequest, ): Promise { await this.ensureExists(actor, id); const data: Prisma.PersonUncheckedUpdateInput = {}; if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); if ('previousFirstName' in dto) { data.previousFirstName = emptyToNull(dto.previousFirstName); } if ('previousLastName' in dto) { data.previousLastName = emptyToNull(dto.previousLastName); } if (dto.importSource !== undefined) { data.importSource = normalizePersonImportSource(dto.importSource); } if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('email' in dto) data.email = normalizeEmail(dto.email); if ('phone' in dto) data.phone = normalizePhone(dto.phone); if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); 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); if (dto.mood !== undefined) data.mood = dto.mood; if ('tags' in dto) data.tags = normalizeTags(dto.tags); if ('customFields' in dto) { data.customFields = toJsonCustomFields(dto.customFields); } if ('companyId' in dto) { const nextCompanyId = emptyToNull(dto.companyId ?? null); if (nextCompanyId) { await this.companies.ensureInOrg( actor.organizationId, nextCompanyId, actor, ); } data.companyId = nextCompanyId; } if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { data.ownerId = resolveOwnerId(actor, dto.ownerId); } await this.assertNoDuplicate(actor.organizationId, data, id); const row = await this.prisma.person.update({ where: { id }, data, include: personInclude, }); this.webhooks.emit(actor.organizationId, 'person.updated', { id: row.id, fullName: `${row.firstName} ${row.lastName}`.trim(), mobile: row.mobile, email: row.email, companyId: row.companyId, }); return this.toDto(row); } async remove(actor: Actor, id: string): Promise { await this.ensureExists(actor, id); await this.prisma.person.delete({ where: { id } }); } async removeMany(actor: Actor, ids: string[]): Promise<{ deleted: number }> { const unique = [...new Set(ids.filter(Boolean))]; if (!unique.length) return { deleted: 0 }; const res = await this.prisma.person.deleteMany({ where: { id: { in: unique }, organizationId: actor.organizationId, ...ownerWhere(actor), }, }); return { deleted: res.count }; } private async ensureExists(actor: Actor, id: string) { const found = await this.prisma.person.findFirst({ where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, select: { id: true }, }); if (!found) throw new NotFoundException('شخص پیدا نشد'); } private async assertNoDuplicate( organizationId: string, data: { email?: unknown; mobile?: unknown }, excludeId?: string, ) { const email = typeof data.email === 'string' ? data.email : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null; const or: Prisma.PersonWhereInput[] = []; if (email) or.push({ email }); if (mobile) or.push({ mobile }); if (!or.length) return; const clash = await this.prisma.person.findFirst({ where: { organizationId, OR: or, ...(excludeId ? { NOT: { id: excludeId } } : {}), }, }); if (!clash) return; if (email && clash.email === email) { throw new ConflictException('شخصی با این ایمیل از قبل وجود دارد'); } if (mobile && clash.mobile === mobile) { throw new ConflictException('شخصی با این موبایل از قبل وجود دارد'); } throw new ConflictException('شخص تکراری است'); } async birthdaysThisMonth(actor: Actor) { const rows = await this.prisma.person.findMany({ where: { organizationId: actor.organizationId, ownerId: actor.userId, birthDate: { not: null }, }, select: { id: true, firstName: true, lastName: true, mobile: true, phone: true, birthDate: true, company: { select: { name: true } }, }, }); const selected = selectMonthBirthdays( rows.flatMap((row) => row.birthDate ? [ { id: row.id, firstName: row.firstName, lastName: row.lastName, mobile: row.mobile || row.phone, birthDate: row.birthDate, }, ] : [], ), ); const companyById = new Map(rows.map((row) => [row.id, row.company?.name ?? ''])); const toItem = (row: (typeof selected.items)[number]) => ({ id: row.id, firstName: row.firstName, lastName: row.lastName, fullName: `${row.firstName} ${row.lastName}`.trim(), mobile: row.mobile, company: companyById.get(row.id) ?? '', day: row.day, }); return { month: selected.month, monthName: selected.monthName, today: selected.today, total: selected.total, preview: selected.preview.map(toItem), items: selected.items.map(toItem), }; } async sendBirthdaySms(actor: Actor, personId: string, text: string) { const body = text.trim(); if (!body || body.length > 500) { throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); } const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId: actor.organizationId }, select: { id: true, firstName: true, lastName: true, mobile: true, phone: true, ownerId: true, birthDate: true, company: { select: { name: true } }, }, }); if (!person) throw new NotFoundException('مشتری پیدا نشد'); if (person.ownerId !== actor.userId) { throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.'); } if (!person.birthDate) { throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.'); } const inMonth = selectMonthBirthdays( [ { id: person.id, firstName: person.firstName, lastName: person.lastName, mobile: person.mobile || person.phone, birthDate: person.birthDate, }, ], ); if (!inMonth.total) { throw new BadRequestException('تولد این مشتری در ماه جاری نیست.'); } const mobile = person.mobile || person.phone; if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); const fullName = `${person.firstName} ${person.lastName}`.trim(); const message = renderSmsText(body, { name: fullName, phone: mobile, title: fullName, company: person.company?.name ?? '', }); if (!message) throw new BadRequestException('متن پیامک خالی است.'); const result = await this.sms.sendSmsDetailed(mobile, message); await this.outboundSms.record({ organizationId: actor.organizationId, personId: person.id, mobile, body: message, source: 'manual', status: result.ok ? 'sent' : 'failed', error: result.ok ? null : result.reason, actorUserId: actor.userId, }); if (!result.ok) { throw new ServiceUnavailableException( result.reason === 'SMS not configured' ? 'ارسال پیامک روی سرور فعال نیست.' : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', ); } return { ok: true }; } async sendSms(actor: Actor, personId: string, text: string) { const body = text.trim(); if (!body || body.length > 500) { throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); } const person = await this.get(actor, personId); const mobile = person.mobile || person.phone; if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); const message = renderSmsText(body, { name: person.fullName, phone: mobile, title: person.fullName, company: person.company?.name ?? '', }); if (!message) throw new BadRequestException('متن پیامک خالی است.'); const result = await this.sms.sendSmsDetailed(mobile, message); await this.outboundSms.record({ organizationId: actor.organizationId, personId: person.id, mobile, body: message, source: 'manual', status: result.ok ? 'sent' : 'failed', error: result.ok ? null : result.reason, actorUserId: actor.userId, }); if (!result.ok) { throw new ServiceUnavailableException( result.reason === 'SMS not configured' ? 'ارسال پیامک روی سرور فعال نیست.' : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', ); } return { ok: true }; } listSmsHistory(actor: Actor, personId: string, limit?: number) { return this.get(actor, personId).then(() => this.outboundSms.historyForPerson(actor.organizationId, personId, limit), ); } private toDto(row: PersonRow): PersonDto { const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim(); const importSource = row.importSource || 'manual'; return { id: row.id, firstName: row.firstName, lastName: row.lastName, previousFirstName: row.previousFirstName, previousLastName: row.previousLastName, previousFullName: previousFullName || null, importSource, sourceRef: row.sourceRef, previousImportSource: row.previousImportSource, previousSourceRef: row.previousSourceRef, fullName: `${row.firstName} ${row.lastName}`.trim(), email: row.email, phone: row.phone, mobile: row.mobile, position: row.position, website: row.website, instagram: row.instagram, birthDate: row.birthDate?.toISOString() ?? null, referrer: row.referrer, addresses: (() => { const fromCol = asAddresses(row.addresses); if (fromCol.length) return fromCol; const extra = asCustomFields(row.customFields); return asAddresses( [extra.address, extra.homeAddress, extra.address2] .map((v) => (typeof v === 'string' ? v : '')) .filter(Boolean), ); })(), notes: row.notes, mood: row.mood, tags: row.tags ?? [], customFields: asCustomFields(row.customFields), loyaltyPoints: row.loyaltyPoints ?? 0, loyaltyTier: row.loyaltyTier, company: row.company, owner: row.owner, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } }