Files
CRM_Montzar/apps/api/src/contacts/people.service.ts
T

394 lines
13 KiB
TypeScript

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<PaginatedResponse<PersonDto>> {
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<PersonImportSourceStatDto[]> {
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<PersonDto> {
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<PersonDto> {
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<PersonDto> {
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<void> {
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(),
};
}
}