Files
CRM_Montzar/apps/api/src/tickets/tickets.service.ts
T
MahdiandCursor c2bfa7749d Add project board, loyalty club, and if/then automations.
Separate modules for post-sales kanban, person points/tiers, and deal-won/ticket-resolved rules without overlapping the sales pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:42:07 +03:30

507 lines
16 KiB
TypeScript

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<PaginatedResponse<TicketDto>> {
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<TicketDto> {
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<TicketDto> {
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<TicketDto> {
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<void> {
await this.ensureExists(actor, id);
await this.prisma.ticket.delete({ where: { id } });
}
async addMessage(
actor: Actor,
ticketId: string,
dto: CreateTicketMessageRequest,
): Promise<TicketDto> {
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<string> {
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;
}