Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const STATUSES = [
|
||||
'open',
|
||||
'in_progress',
|
||||
'waiting',
|
||||
'resolved',
|
||||
'closed',
|
||||
] as const;
|
||||
|
||||
const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const;
|
||||
|
||||
export class CreateTicketDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
subject!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STATUSES)
|
||||
status?: (typeof STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PRIORITIES)
|
||||
priority?: (typeof PRIORITIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateTicketDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
subject?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STATUSES)
|
||||
status?: (typeof STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PRIORITIES)
|
||||
priority?: (typeof PRIORITIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
csatScore?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
csatComment?: string | null;
|
||||
}
|
||||
|
||||
export class CreateTicketMessageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(8000)
|
||||
body!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isInternal?: boolean;
|
||||
}
|
||||
|
||||
export class ListTicketsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STATUSES)
|
||||
status?: (typeof STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PRIORITIES)
|
||||
priority?: (typeof PRIORITIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['overdue'])
|
||||
sla?: 'overdue';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateTicketDto,
|
||||
CreateTicketMessageDto,
|
||||
ListTicketsQueryDto,
|
||||
UpdateTicketDto,
|
||||
} from './dto';
|
||||
import { TicketsService } from './tickets.service';
|
||||
|
||||
@Controller('tickets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TicketsController {
|
||||
constructor(private readonly ticketsService: TicketsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Query() query: ListTicketsQueryDto,
|
||||
) {
|
||||
return this.ticketsService.list(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
{
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
priority: query.priority,
|
||||
personId: query.personId,
|
||||
assigneeId: query.assigneeId,
|
||||
sla: query.sla,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 50,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.ticketsService.get(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Body() dto: CreateTicketDto,
|
||||
) {
|
||||
return this.ticketsService.create(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTicketDto,
|
||||
) {
|
||||
return this.ticketsService.update(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.ticketsService.remove(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
id,
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(':id/messages')
|
||||
addMessage(
|
||||
@CurrentUser()
|
||||
user: { userId: string; organizationId: string; role: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateTicketMessageDto,
|
||||
) {
|
||||
return this.ticketsService.addMessage(
|
||||
{
|
||||
userId: user.userId,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
},
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TicketsController } from './tickets.controller';
|
||||
import { TicketsService } from './tickets.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [TicketsController],
|
||||
providers: [TicketsService],
|
||||
exports: [TicketsService],
|
||||
})
|
||||
export class TicketsModule {}
|
||||
@@ -0,0 +1,485 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
) {}
|
||||
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user