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>
507 lines
16 KiB
TypeScript
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;
|
|
}
|