import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreatePersonRequest, PaginatedResponse, PersonDto, 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 { asCustomFields, toJsonCustomFields, } from '../common/custom-fields'; import { type Actor, canReassignOwner, ownerWhere, resolveOwnerId, } from '../common/record-scope'; 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; email: string | null; phone: string | null; mobile: string | null; position: string | null; notes: string | null; mood: PersonMood; 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, ) {} async list( actor: Actor, q?: string, page = 1, limit = 20, opts?: { mood?: PersonMood; sort?: 'name' | 'newest' }, ): Promise> { const where: Prisma.PersonWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), ...(opts?.mood ? { mood: opts.mood } : {}), ...(q?.trim() ? { OR: [ { firstName: { contains: q.trim(), mode: 'insensitive' } }, { lastName: { contains: q.trim(), mode: 'insensitive' } }, { email: { contains: q.trim(), mode: 'insensitive' } }, { mobile: { contains: q.trim() } }, { phone: { 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 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 data: Prisma.PersonUncheckedCreateInput = { organizationId: actor.organizationId, firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), email: normalizeEmail(dto.email), phone: normalizePhone(dto.phone), mobile: normalizePhone(dto.mobile), position: emptyToNull(dto.position), notes: emptyToNull(dto.notes), mood: dto.mood ?? 'newcomer', companyId, ownerId: resolveOwnerId(actor, dto.ownerId), customFields: toJsonCustomFields(dto.customFields), }; await this.assertNoDuplicate(actor.organizationId, data); const row = await this.prisma.person.create({ data, include: personInclude, }); return this.toDto(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 ('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 ('notes' in dto) data.notes = emptyToNull(dto.notes); if (dto.mood !== undefined) data.mood = dto.mood; 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, }); 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 { return { id: row.id, firstName: row.firstName, lastName: row.lastName, fullName: `${row.firstName} ${row.lastName}`.trim(), email: row.email, phone: row.phone, mobile: row.mobile, position: row.position, notes: row.notes, mood: row.mood, 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(), }; } }