import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared'; import { CUSTOMER_GROUP_COLORS } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; const NAME_MAX = 80; /** نام گروه برای مقایسه: فاصله‌های اضافه و ی/ک عربی یکسان می‌شوند */ function normalizeGroupName(raw: string): string { return raw .replace(/\u064A/g, '\u06CC') .replace(/\u0643/g, '\u06A9') .replace(/\s+/g, ' ') .trim() .slice(0, NAME_MAX); } /** «الف، ب / ج» → ['الف', 'ب', 'ج'] */ export function splitGroupNames(raw: string | null | undefined): string[] { if (!raw) return []; return [ ...new Set( raw .split(/\s*[،,/]\s*/) .map(normalizeGroupName) .filter(Boolean), ), ]; } export const groupSummarySelect = { id: true, name: true, color: true, } satisfies Prisma.CustomerGroupSelect; @Injectable() export class CustomerGroupsService { constructor(private readonly prisma: PrismaService) {} async list(organizationId: string): Promise { const rows = await this.prisma.customerGroup.findMany({ where: { organizationId }, orderBy: [{ position: 'asc' }, { name: 'asc' }], include: { _count: { select: { people: true, companies: true } }, parent: { select: { name: true } }, assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } }, }, }); return rows.map((r) => this.toDto(r)); } async create( organizationId: string, dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] }, ): Promise { const name = this.cleanName(dto.name); const assigneeIds = dto.assigneeIds !== undefined ? await this.validAssigneeIds(organizationId, dto.assigneeIds) : []; const max = await this.prisma.customerGroup.aggregate({ where: { organizationId }, _max: { position: true }, }); try { await this.prisma.customerGroup.create({ data: { organizationId, name, color: this.cleanColor(dto.color), position: (max._max.position ?? -1) + 1, ...(assigneeIds.length ? { assignees: { connect: assigneeIds.map((id) => ({ id })) } } : {}), }, }); } catch (e) { throw this.mapError(e); } return (await this.list(organizationId)).find((g) => g.name === name)!; } async update( organizationId: string, id: string, dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null; assigneeIds?: string[]; }, ): Promise { await this.require(organizationId, id); const parentId = dto.parentId === undefined ? undefined : dto.parentId?.trim() || null; if (parentId !== undefined) { await this.assertParent(organizationId, id, parentId); } const assigneeIds = dto.assigneeIds !== undefined ? await this.validAssigneeIds(organizationId, dto.assigneeIds) : undefined; try { await this.prisma.customerGroup.update({ where: { id }, data: { ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), ...(parentId !== undefined ? { parentId } : {}), ...(assigneeIds !== undefined ? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } } : {}), }, }); } catch (e) { throw this.mapError(e); } return (await this.list(organizationId)).find((g) => g.id === id)!; } /** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */ private async assertParent( organizationId: string, id: string, parentId: string | null, ) { if (parentId == null) return; if (parentId === id) { throw new BadRequestException('گروه نمی‌تواند زیرمجموعهٔ خودش باشد.'); } const parent = await this.prisma.customerGroup.findFirst({ where: { id: parentId, organizationId }, select: { id: true, parentId: true }, }); if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.'); if (parent.parentId) { throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.'); } const childCount = await this.prisma.customerGroup.count({ where: { organizationId, parentId: id }, }); if (childCount > 0) { throw new BadRequestException('این گروه خودش زیرگروه دارد و نمی‌تواند فرعی شود.'); } } /** مشتری‌ها حذف نمی‌شوند؛ فقط این گروه از رویشان برداشته می‌شود */ async remove(organizationId: string, id: string): Promise { await this.require(organizationId, id); await this.prisma.customerGroup.delete({ where: { id } }); } /** شناسه‌های معتبر همین محیط (بقیه نادیده گرفته می‌شوند) */ async validIds(organizationId: string, ids: string[] | undefined): Promise { const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))]; if (!unique.length) return []; const rows = await this.prisma.customerGroup.findMany({ where: { organizationId, id: { in: unique } }, select: { id: true }, }); if (rows.length !== unique.length) { throw new BadRequestException('یکی از گروه‌های انتخاب‌شده معتبر نیست'); } return rows.map((r) => r.id); } /** گروه‌ها با نام (برای اکسل)؛ نبود → ساخته می‌شود */ async idsForNames(organizationId: string, names: string[]): Promise { const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))]; if (!clean.length) return []; const ids: string[] = []; for (const name of clean) { ids.push((await this.findOrCreateByName(organizationId, name)).id); } return ids; } private async findOrCreateByName(organizationId: string, name: string) { const existing = await this.prisma.customerGroup.findFirst({ where: { organizationId, name: { equals: name, mode: 'insensitive' } }, select: { id: true, tahesabGid: true }, }); if (existing) return existing; const max = await this.prisma.customerGroup.aggregate({ where: { organizationId }, _max: { position: true }, }); try { return await this.prisma.customerGroup.create({ data: { organizationId, name, position: (max._max.position ?? -1) + 1 }, select: { id: true, tahesabGid: true }, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { return this.prisma.customerGroup.findFirstOrThrow({ where: { organizationId, name }, select: { id: true, tahesabGid: true }, }); } throw e; } } /** * گروه متناظر یک گروه ته‌حساب: اول با GID روی همان اتصال، بعد با نام. * با تغییر نام در ته‌حساب گروه تکراری ساخته نمی‌شود. */ async ensureTahesabGroup( organizationId: string, connectionId: string, gid: string | null, rawName: string, ): Promise { const name = normalizeGroupName(rawName); const cleanGid = gid?.trim() || null; if (!name && !cleanGid) return null; if (cleanGid) { const byGid = await this.prisma.customerGroup.findFirst({ where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid }, select: { id: true, name: true }, }); if (byGid) { if (name && byGid.name !== name) { await this.prisma.customerGroup .update({ where: { id: byGid.id }, data: { name } }) .catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی می‌ماند } return byGid.id; } } if (!name) return null; const group = await this.findOrCreateByName(organizationId, name); if (cleanGid && !group.tahesabGid) { await this.prisma.customerGroup.update({ where: { id: group.id }, data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid }, }); } return group.id; } /** گروه‌های دستی شخص (جایگزین کامل). منبع لینک‌هایی که می‌مانند حفظ می‌شود. */ async setPersonGroups(personId: string, groupIds: string[]): Promise { await this.prisma.$transaction([ this.prisma.personGroupLink.deleteMany({ where: { personId, groupId: { notIn: groupIds } }, }), this.prisma.personGroupLink.createMany({ data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })), skipDuplicates: true, }), ]); } async setCompanyGroups(companyId: string, groupIds: string[]): Promise { await this.prisma.$transaction([ this.prisma.companyGroupLink.deleteMany({ where: { companyId, groupId: { notIn: groupIds } }, }), this.prisma.companyGroupLink.createMany({ data: groupIds.map((groupId) => ({ companyId, groupId })), skipDuplicates: true, }), ]); } /** فقط اضافه می‌کند (ایمپورت اکسل) */ async addPersonGroups(personId: string, groupIds: string[]): Promise { if (!groupIds.length) return; await this.prisma.personGroupLink.createMany({ data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })), skipDuplicates: true, }); } async addCompanyGroups(companyId: string, groupIds: string[]): Promise { if (!groupIds.length) return; await this.prisma.companyGroupLink.createMany({ data: groupIds.map((groupId) => ({ companyId, groupId })), skipDuplicates: true, }); } /** * گروهِ آمده از ته‌حساب: گروه ته‌حسابی قبلی برداشته و این یکی گذاشته می‌شود. * گروه‌های دستی دست نمی‌خورند. اگر همین گروه از قبل دستی بوده، دستی می‌ماند. * @returns آیا چیزی عوض شد */ async setPersonTahesabGroup(personId: string, groupId: string | null): Promise { const current = await this.prisma.personGroupLink.findMany({ where: { personId }, select: { groupId: true, source: true }, }); const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId); const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true; if (!staleTahesab.length && alreadyLinked) return false; await this.prisma.$transaction([ ...(staleTahesab.length ? [ this.prisma.personGroupLink.deleteMany({ where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } }, }), ] : []), ...(groupId && !alreadyLinked ? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })] : []), ]); return true; } /** گروهی که موقع «ثبت در ته‌حساب» فرستاده می‌شود: اولین گروه (به ترتیب گروه‌ها) */ async firstGroupName(personId: string): Promise { const link = await this.prisma.personGroupLink.findFirst({ where: { personId }, orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], select: { group: { select: { name: true } } }, }); return link?.group.name ?? null; } private toDto(r: { id: string; name: string; color: string; position: number; tahesabGid: string | null; parentId: string | null; parent?: { name: string } | null; _count: { people: number; companies: number }; assignees: Array<{ id: string; fullName: string }>; }): CustomerGroupDto { return { id: r.id, name: r.name, color: r.color, position: r.position, fromTahesab: Boolean(r.tahesabGid), personCount: r._count.people, companyCount: r._count.companies, parentId: r.parentId, parentName: r.parent?.name ?? null, assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })), }; } private async validAssigneeIds(organizationId: string, ids: string[]): Promise { const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))]; if (!unique.length) return []; const rows = await this.prisma.user.findMany({ where: { id: { in: unique }, isActive: true, memberships: { some: { organizationId, isActive: true } }, }, select: { id: true }, }); if (rows.length !== unique.length) { throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست'); } return rows.map((r) => r.id); } private async require(organizationId: string, id: string) { const row = await this.prisma.customerGroup.findFirst({ where: { id, organizationId }, select: { id: true }, }); if (!row) throw new NotFoundException('گروه پیدا نشد'); } private cleanName(raw: string): string { const name = normalizeGroupName(raw ?? ''); if (!name) throw new BadRequestException('نام گروه لازم است'); return name; } private cleanColor(raw?: string): CustomerGroupColor { return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '') ? (raw as CustomerGroupColor) : 'teal'; } private mapError(e: unknown) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { return new ConflictException('گروهی با این نام وجود دارد'); } return e; } }