import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreatePersonRequest, PaginatedResponse, PersonDto, PersonImportSourceStatDto, PersonMood, UpdatePersonRequest, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.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 { normalizeTags } from '../common/tags'; import { normalizePersonImportSource, personImportSourceLabel, } from './person-source'; const personInclude = { company: { select: { id: true, name: 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; email: string | null; phone: string | null; mobile: string | null; position: string | null; website: string | null; instagram: string | 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 } | null; owner: { id: string; fullName: string } | null; loyaltyTier: { id: string; name: string; minPoints: number; color: string | null; } | null; }; @Injectable() export class PeopleService { 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, ) {} async list( actor: Actor, q?: string, page = 1, limit = 20, opts?: { mood?: PersonMood; sort?: 'name' | 'newest'; importSource?: string; }, ): Promise> { const sourceFilter = opts?.importSource?.trim() ? normalizePersonImportSource(opts.importSource) : undefined; const where: Prisma.PersonWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), ...(opts?.mood ? { mood: opts.mood } : {}), ...(sourceFilter ? { importSource: sourceFilter } : {}), ...(q?.trim() ? { OR: [ { firstName: { contains: q.trim(), mode: 'insensitive' } }, { lastName: { contains: q.trim(), mode: 'insensitive' } }, { previousFirstName: { contains: q.trim(), mode: 'insensitive' } }, { previousLastName: { contains: q.trim(), mode: 'insensitive' } }, { email: { contains: q.trim(), mode: 'insensitive' } }, { mobile: { contains: q.trim() } }, { phone: { contains: q.trim() } }, { sourceRef: { contains: q.trim() } }, { company: { name: { contains: q.trim(), mode: 'insensitive' } } }, ], } : {}), }; 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 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); } 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), 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); 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 ('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 } }); } 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('شخص تکراری است'); } 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, fullName: `${row.firstName} ${row.lastName}`.trim(), email: row.email, phone: row.phone, mobile: row.mobile, position: row.position, website: row.website, instagram: row.instagram, 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(), }; } }