import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreateTicketMessageRequest, CreateTicketRequest, PaginatedResponse, TicketDto, TicketPriority, UpdateTicketRequest, } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { type Actor, elevated, ticketAccessWhere, } from '../common/record-scope'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; const include = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, assignee: { select: { id: true, fullName: true } }, reporter: { select: { id: true, fullName: true } }, messages: { orderBy: { createdAt: 'asc' as const }, include: { author: { select: { id: true, fullName: true } } }, }, } satisfies Prisma.TicketInclude; type TicketRow = Prisma.TicketGetPayload<{ include: typeof include }>; @Injectable() export class TicketsService { constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly automations: AutomationsService, ) {} async list( actor: Actor, opts: { q?: string; status?: string; priority?: string; personId?: string; assigneeId?: string; sla?: 'overdue'; page?: number; limit?: number; }, ): Promise> { const page = opts.page ?? 1; const limit = opts.limit ?? 50; const now = new Date(); const access = ticketAccessWhere(actor); const where: Prisma.TicketWhereInput = { organizationId: actor.organizationId, ...access, ...(opts.status ? { status: opts.status as never } : {}), ...(opts.priority ? { priority: opts.priority as never } : {}), ...(opts.personId ? { personId: opts.personId } : {}), ...(opts.assigneeId && elevated(actor.role) ? { assigneeId: opts.assigneeId } : {}), ...(opts.sla === 'overdue' ? { slaDueAt: { lt: now }, firstRespondedAt: null, status: { in: ['open', 'in_progress', 'waiting'] }, } : {}), ...(opts.q?.trim() ? { AND: [ access, { OR: [ { subject: { contains: opts.q.trim(), mode: 'insensitive' } }, { number: { contains: opts.q.trim(), mode: 'insensitive' } }, { description: { contains: opts.q.trim(), mode: 'insensitive', }, }, ], }, ], } : {}), }; // When q is set, avoid double-spreading access at top level const finalWhere: Prisma.TicketWhereInput = opts.q?.trim() ? { organizationId: actor.organizationId, ...(opts.status ? { status: opts.status as never } : {}), ...(opts.priority ? { priority: opts.priority as never } : {}), ...(opts.personId ? { personId: opts.personId } : {}), ...(opts.assigneeId && elevated(actor.role) ? { assigneeId: opts.assigneeId } : {}), ...(opts.sla === 'overdue' ? { slaDueAt: { lt: now }, firstRespondedAt: null, status: { in: ['open', 'in_progress', 'waiting'] }, } : {}), AND: [ access, { OR: [ { subject: { contains: opts.q.trim(), mode: 'insensitive' } }, { number: { contains: opts.q.trim(), mode: 'insensitive' } }, { description: { contains: opts.q.trim(), mode: 'insensitive', }, }, ], }, ], } : where; const [rows, total] = await this.prisma.$transaction([ this.prisma.ticket.findMany({ where: finalWhere, include, orderBy: { updatedAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), this.prisma.ticket.count({ where: finalWhere }), ]); return { items: rows.map((row) => this.toDto(row)), total, page, limit, }; } async get(actor: Actor, id: string): Promise { const row = await this.prisma.ticket.findFirst({ where: { id, organizationId: actor.organizationId, ...ticketAccessWhere(actor), }, include, }); if (!row) throw new NotFoundException('تیکت پیدا نشد'); return this.toDto(row); } async create(actor: Actor, dto: CreateTicketRequest): Promise { const personId = emptyToNull(dto.personId ?? null); const companyId = emptyToNull(dto.companyId ?? null); const dealId = emptyToNull(dto.dealId ?? null); let assigneeId = emptyToNull(dto.assigneeId ?? null) ?? actor.userId; await this.assertLinks(actor.organizationId, personId, companyId, dealId); if (assigneeId) { await this.assertUser(actor.organizationId, assigneeId); } if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) { assigneeId = actor.userId; } const number = await this.nextNumber(actor.organizationId); const status = (dto.status as never) || 'open'; const priority = ((dto.priority as TicketPriority) || 'normal') as TicketPriority; const createdAt = new Date(); const row = await this.prisma.ticket.create({ data: { organizationId: actor.organizationId, number, subject: dto.subject.trim(), description: emptyToNull(dto.description ?? null), status, priority: priority as never, personId, companyId, dealId, assigneeId, reporterId: actor.userId, createdAt, slaDueAt: computeSlaDueAt(createdAt, priority), resolvedAt: status === 'resolved' || status === 'closed' ? createdAt : null, ...(dto.description?.trim() ? { messages: { create: { authorId: actor.userId, body: dto.description.trim(), isInternal: false, }, }, } : {}), }, include, }); if (assigneeId && assigneeId !== actor.userId) { await this.notifications.create({ organizationId: actor.organizationId, userId: assigneeId, type: 'ticket_assigned', title: 'تیکت جدید ارجاع شد', body: row.subject, link: `/tickets/${row.id}`, entityType: 'ticket', entityId: row.id, }); } return this.toDto(row); } async update( actor: Actor, id: string, dto: UpdateTicketRequest, ): Promise { const existing = await this.prisma.ticket.findFirst({ where: { id, organizationId: actor.organizationId, ...ticketAccessWhere(actor), }, }); if (!existing) throw new NotFoundException('تیکت پیدا نشد'); if ( 'personId' in dto || 'companyId' in dto || 'dealId' in dto ) { await this.assertLinks( actor.organizationId, emptyToNull(dto.personId ?? null), emptyToNull(dto.companyId ?? null), emptyToNull(dto.dealId ?? null), ); } const data: Prisma.TicketUncheckedUpdateInput = {}; if (dto.subject !== undefined) data.subject = dto.subject.trim(); if ('description' in dto) { data.description = emptyToNull(dto.description ?? null); } if (dto.status !== undefined) { data.status = dto.status as never; if (dto.status === 'resolved' || dto.status === 'closed') { data.resolvedAt = existing.resolvedAt ?? new Date(); } else { data.resolvedAt = null; } } if (dto.priority !== undefined) { data.priority = dto.priority as never; if (!existing.firstRespondedAt) { data.slaDueAt = computeSlaDueAt( existing.createdAt, dto.priority as TicketPriority, ); } } if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('assigneeId' in dto) { const next = emptyToNull(dto.assigneeId ?? null); if (next) await this.assertUser(actor.organizationId, next); if (!elevated(actor.role) && next && next !== actor.userId) { throw new BadRequestException('فقط می‌توانید تیکت را به خودتان ارجاع دهید'); } data.assigneeId = next; if (next && next !== existing.assigneeId && next !== actor.userId) { await this.notifications.create({ organizationId: actor.organizationId, userId: next, type: 'ticket_assigned', title: 'تیکت به شما ارجاع شد', body: existing.subject, link: `/tickets/${existing.id}`, entityType: 'ticket', entityId: existing.id, }); } } const nextStatus = (dto.status ?? existing.status) as string; if ('csatScore' in dto || 'csatComment' in dto) { if (nextStatus !== 'resolved' && nextStatus !== 'closed') { throw new BadRequestException( 'ثبت رضایت فقط برای تیکت حل‌شده یا بسته مجاز است', ); } if (dto.csatScore !== undefined && dto.csatScore !== null) { if (dto.csatScore < 1 || dto.csatScore > 5) { throw new BadRequestException('امتیاز رضایت باید بین ۱ تا ۵ باشد'); } data.csatScore = dto.csatScore; data.csatAt = new Date(); } if ('csatComment' in dto) { data.csatComment = emptyToNull(dto.csatComment ?? null); if (!data.csatAt && (dto.csatComment || existing.csatScore)) { data.csatAt = new Date(); } } } await this.prisma.ticket.update({ where: { id }, data }); const becameResolved = (dto.status === 'resolved' || dto.status === 'closed') && existing.status !== 'resolved' && existing.status !== 'closed'; if (becameResolved) { await this.automations.runTrigger('ticket_resolved', { organizationId: actor.organizationId, entityType: 'ticket', entityId: existing.id, title: existing.subject, personId: existing.personId, companyId: existing.companyId, dealId: existing.dealId, assigneeId: existing.assigneeId, ownerId: existing.assigneeId, }); } return this.get(actor, id); } async remove(actor: Actor, id: string): Promise { await this.ensureExists(actor, id); await this.prisma.ticket.delete({ where: { id } }); } async addMessage( actor: Actor, ticketId: string, dto: CreateTicketMessageRequest, ): Promise { const existing = await this.prisma.ticket.findFirst({ where: { id: ticketId, organizationId: actor.organizationId, ...ticketAccessWhere(actor), }, select: { id: true, firstRespondedAt: true, reporterId: true, }, }); if (!existing) throw new NotFoundException('تیکت پیدا نشد'); const isInternal = dto.isInternal ?? false; await this.prisma.ticketMessage.create({ data: { ticketId, authorId: actor.userId, body: dto.body.trim(), isInternal, }, }); const patch: Prisma.TicketUncheckedUpdateInput = { updatedAt: new Date() }; if ( !isInternal && !existing.firstRespondedAt && actor.userId !== existing.reporterId ) { patch.firstRespondedAt = new Date(); } await this.prisma.ticket.update({ where: { id: ticketId }, data: patch }); return this.get(actor, ticketId); } private async nextNumber(organizationId: string): Promise { const count = await this.prisma.ticket.count({ where: { organizationId } }); return `TK-${String(count + 1).padStart(4, '0')}`; } private async ensureExists(actor: Actor, id: string) { const found = await this.prisma.ticket.findFirst({ where: { id, organizationId: actor.organizationId, ...ticketAccessWhere(actor), }, select: { id: true }, }); if (!found) throw new NotFoundException('تیکت پیدا نشد'); } private async assertUser(organizationId: string, userId: string) { const user = await this.prisma.user.findFirst({ where: { id: userId, organizationId }, select: { id: true }, }); if (!user) throw new BadRequestException('کاربر مسئول معتبر نیست'); } private async assertLinks( organizationId: string, personId: string | null, companyId: string | null, dealId: string | null, ) { if (personId) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, select: { id: true }, }); if (!person) throw new BadRequestException('شخص معتبر نیست'); } if (companyId) { const company = await this.prisma.company.findFirst({ where: { id: companyId, organizationId }, select: { id: true }, }); if (!company) throw new BadRequestException('شرکت معتبر نیست'); } if (dealId) { const deal = await this.prisma.deal.findFirst({ where: { id: dealId, organizationId }, select: { id: true }, }); if (!deal) throw new BadRequestException('معامله معتبر نیست'); } } private toDto(row: TicketRow): TicketDto { const openStatuses = new Set(['open', 'in_progress', 'waiting']); const slaBreached = Boolean( row.slaDueAt && !row.firstRespondedAt && openStatuses.has(row.status) && row.slaDueAt.getTime() < Date.now(), ); return { id: row.id, number: row.number, subject: row.subject, description: row.description, status: row.status, priority: row.priority, person: row.person ? { id: row.person.id, fullName: `${row.person.firstName} ${row.person.lastName}`.trim(), } : null, company: row.company, deal: row.deal, assignee: row.assignee, reporter: row.reporter, resolvedAt: row.resolvedAt?.toISOString() ?? null, slaDueAt: row.slaDueAt?.toISOString() ?? null, firstRespondedAt: row.firstRespondedAt?.toISOString() ?? null, slaBreached, csatScore: row.csatScore, csatComment: row.csatComment, csatAt: row.csatAt?.toISOString() ?? null, messages: row.messages.map((m) => ({ id: m.id, body: m.body, isInternal: m.isInternal, author: m.author, createdAt: m.createdAt.toISOString(), })), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } } function computeSlaDueAt(from: Date, priority: TicketPriority): Date { const hours = TICKET_SLA_HOURS[priority] ?? TICKET_SLA_HOURS.normal; return new Date(from.getTime() + hours * 60 * 60 * 1000); } function emptyToNull(value?: string | null): string | null { if (value == null) return null; const trimmed = value.trim(); return trimmed.length ? trimmed : null; }