Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
7.7 KiB
TypeScript
261 lines
7.7 KiB
TypeScript
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<PaginatedResponse<PersonDto>> {
|
|
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<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 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<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 ('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<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 {
|
|
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(),
|
|
};
|
|
}
|
|
}
|