This commit is contained in:
2026-01-10 15:02:13 +03:30
parent 7cc6464f31
commit aa23bf5614
14 changed files with 1177 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsNotEmpty, IsString, IsUUID, Length } from "class-validator";
import { TicketMessageEnum } from "../../../common/enums/message.enum";
export class CreateTicketCategoryDto {
@IsNotEmpty({ message: TicketMessageEnum.TITLE_REQUIRED })
@IsString({ message: TicketMessageEnum.TITLE_STRING })
@Length(3, 150, { message: TicketMessageEnum.TITLE_LENGTH })
@ApiProperty({ description: "Title of the ticket category", example: "فنی", minLength: 3, maxLength: 150 })
title: string;
@IsNotEmpty({ message: TicketMessageEnum.DESCRIPTION_REQUIRED })
@IsString({ message: TicketMessageEnum.DESCRIPTION_STRING })
@Length(3, 500, { message: TicketMessageEnum.DESCRIPTION_LENGTH })
@ApiProperty({ description: "Description of the ticket category", example: "درخواست‌های فنی", minLength: 3, maxLength: 500 })
description: string;
@IsNotEmpty({ message: TicketMessageEnum.USER_GROUP_ID_NOT_EMPTY })
@IsUUID("4", { message: TicketMessageEnum.USER_GROUP_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "User group ID", example: "123e4567-e89b-12d3-a456-426614174000" })
userGroupId: string;
@IsNotEmpty({ message: TicketMessageEnum.IS_ACTIVE_REQUIRED })
@IsBoolean({ message: TicketMessageEnum.IS_ACTIVE_SHOULD_BE_BOOLEAN })
@ApiProperty({ description: "Is active", example: true })
isActive: boolean;
}
+18
View File
@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { ArrayMinSize, IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";
import { TicketMessageEnum } from "../../../common/enums/message.enum";
export class CreateTicketMessageDto {
@IsNotEmpty({ message: TicketMessageEnum.CONTENT_REQUIRED })
@IsString({ message: TicketMessageEnum.CONTENT_STRING })
@ApiProperty({ description: "the content of message", example: "test message" })
content: string;
@IsOptional()
@IsNotEmpty({ message: TicketMessageEnum.ATTACHMENT_REQUIRED })
@ArrayMinSize(1, { message: TicketMessageEnum.ATTACHMENT_REQUIRED })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { each: true, message: TicketMessageEnum.ATTACHMENT_SHOULD_BE_URL })
@ApiProperty({ description: "attachment url", example: ["https://example.com/test.png"] })
attachmentUrls?: string[];
}
+55
View File
@@ -0,0 +1,55 @@
import { ApiProperty } from "@nestjs/swagger";
import { ArrayMinSize, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, IsUrl, Length } from "class-validator";
import { TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { TicketPriority } from "../enums/ticket-priority.enum";
export class CreateTicketDto {
// @IsNotEmpty({ message: TicketMessageEnum.TITLE_REQUIRED })
// @IsString({ message: TicketMessageEnum.TITLE_STRING })
// @Length(3, 150, { message: TicketMessageEnum.TITLE_LENGTH })
// @ApiProperty({ description: "The ticket title", example: "Test title" })
// title: string;
@IsNotEmpty({ message: TicketMessageEnum.SUBJECT_REQUIRED })
@IsString({ message: TicketMessageEnum.SUBJECT_STRING })
@Length(3, 150, { message: TicketMessageEnum.SUBJECT_LENGTH })
@ApiProperty({ description: "Subject of the ticket", example: "Subject" })
subject: string;
@IsNotEmpty({ message: TicketMessageEnum.PRIORITY_REQUIRED })
@IsEnum(TicketPriority, { message: TicketMessageEnum.PRIORITY_INVALID })
@ApiProperty({ enum: TicketPriority, example: TicketPriority.LOW, description: "LOW, MEDIUM, HIGH" })
priority: TicketPriority;
@IsOptional()
@IsNotEmpty({ message: TicketMessageEnum.DANAK_SERVICE_ID_REQUIRED })
@IsUUID("4", { message: TicketMessageEnum.DANAK_SERVICE_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Service ID related to the ticket", example: "550e8400-e29b-41d4-a716-446655440000" })
danakServiceId?: string;
@IsNotEmpty({ message: TicketMessageEnum.CATEGORY_ID_REQUIRED })
@IsUUID("4", { message: TicketMessageEnum.CATEGORY_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Category ID of the ticket", example: "123e4567-e89b-12d3-a456-426614174000" })
categoryId: string;
@IsNotEmpty({ message: TicketMessageEnum.MESSAGE_REQUIRED })
@IsString({ message: TicketMessageEnum.MESSAGE_STRING })
@Length(5, 5000, { message: TicketMessageEnum.MESSAGE_LENGTH })
@ApiProperty({ description: "The message content of the ticket", example: "This is a detailed description of the issue." })
message: string;
@IsOptional()
@IsNotEmpty({ message: TicketMessageEnum.ATTACHMENT_REQUIRED })
@ArrayMinSize(1, { message: TicketMessageEnum.ATTACHMENT_REQUIRED })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { each: true, message: TicketMessageEnum.ATTACHMENT_SHOULD_BE_URL })
@ApiProperty({ description: "attachment url", example: ["https://example.com/test.png"] })
attachmentUrls?: string[];
}
export class CreateTicketByAdminDto extends CreateTicketDto {
@IsNotEmpty({ message: UserMessage.USER_ID_REQUIRED })
@IsUUID("4", { message: UserMessage.USER_ID_SHOULD_BE_A_UUID })
@ApiProperty({ description: "User ID of the ticket", example: "123e4567-e89b-12d3-a456-426614174000" })
userId: string;
}
+3
View File
@@ -0,0 +1,3 @@
import { CategoryListSearchQueryDto } from "../../danak-services/DTO/category-search-query.dto";
export class SearchTicketCategoryDto extends CategoryListSearchQueryDto {}
+18
View File
@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsOptional, IsUUID } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
import { UserMessage } from "../../../common/enums/message.enum";
import { TicketStatus } from "../enums/ticket-status.enum";
export class SearchTicketQueryDto extends PaginationDto {
@IsOptional()
@IsEnum(TicketStatus)
@ApiPropertyOptional({ enum: TicketStatus, description: "ticket status", example: TicketStatus.PENDING })
status?: TicketStatus;
@IsOptional()
@IsUUID("4", { message: UserMessage.USER_ID_SHOULD_BE_A_UUID })
@ApiPropertyOptional({ description: "user id", example: "123e4567-e89b-12d3-a456-426614174000" })
userId?: string;
}
+9
View File
@@ -0,0 +1,9 @@
export const REFERRAL_STRATEGY = "referralStrategy";
export const TICKET = {
QUEUE_NAME: "ticket",
AUTO_CLOSE_JOB_NAME: "auto-close-inactive",
AUTO_CLOSE_DAYS: 10,
AUTO_CLOSE_PRIORITY: 1,
AUTO_CLOSE_REPEAT_PATTERN: "0 1 * * *",
};
+40
View File
@@ -0,0 +1,40 @@
import {
Entity,
Index,
ManyToOne,
OneToMany,
Property,
Collection,
Cascade,
Enum,
} from '@mikro-orm/core';
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../user/entities/user.entity";
import { TicketStatus } from "../enums/ticket-status.enum";
import { product } from 'src/modules/product/entities/product.entity';
import { Admin } from 'src/modules/admin/entities/admin.entity';
@Entity()
export class Ticket extends BaseEntity {
@Property({ type: "bigint", primary: true })
id: number;
@Enum(() => TicketStatus)
status: TicketStatus;
// TODO : create relation
@Property({type:'json'})
products: product | null;
@ManyToOne(() => Admin)
admin: Admin | null;
@ManyToOne(() => User,)
user: User;
@ManyToOne(() => Ticket)
parent: Ticket[];
@Property({ type: 'json' })
attachments: string[]
}
+5
View File
@@ -0,0 +1,5 @@
export enum TicketStatus {
PENDING = "PENDING",
ANSWERED = "ANSWERED",
CLOSED = "CLOSED",
}
+1
View File
@@ -0,0 +1 @@
+639
View File
@@ -0,0 +1,639 @@
import { InjectQueue } from "@nestjs/bullmq";
import { BadRequestException, HttpException, Injectable, InternalServerErrorException, Logger, OnModuleInit } from "@nestjs/common";
import { Queue } from "bullmq";
import dayjs from "dayjs";
import { DataSource, MoreThanOrEqual, Not, QueryRunner } from "typeorm";
import { TICKET } from "../constant";
import { ReferralService } from "./referral.service";
import { IUserIpAndHeaders } from "../../../common/decorators/user.decorator";
import { ParamDto } from "../../../common/DTO/param.dto";
import { CommonMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { UserType } from "../../access-logs/enums/user-type.enum";
import { AccessLogService } from "../../access-logs/providers/access-log.service";
import { DanakService } from "../../danak-services/entities/danak-service.entity";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { SupportPlanFeatureKey } from "../../support-plans/enums/support-plan-feature-key.enum";
import { SupportPlansService } from "../../support-plans/providers/support-plans.service";
import { User } from "../../users/entities/user.entity";
import { AdminsService } from "../../users/providers/admins.service";
import { UsersService } from "../../users/providers/users.service";
import { CreateTicketCategoryDto } from "../DTO/create-ticket-category.dto";
import { CreateTicketMessageDto } from "../DTO/create-ticket-message.dto";
import { CreateTicketByAdminDto, CreateTicketDto } from "../DTO/create-ticket.dto";
import { ReferTicketDto } from "../DTO/refer-ticket.dto";
import { SearchTicketCategoryDto } from "../DTO/search-ticket-category.dto";
import { SearchTicketQueryDto } from "../DTO/search-ticket-query.dto";
import { UpdateTicketCategoryDto } from "../DTO/update-ticket-category.dto";
import { TicketCategory } from "../entities/ticket-category.entity";
import { TicketMessageAttachment } from "../entities/ticket-message-attachment";
import { TicketMessage } from "../entities/ticket-message.entity";
import { Ticket } from "../entities/ticket.entity";
import { TicketStatus } from "../enums/ticket-status.enum";
import { TicketCategoryRepository } from "../repositories/tickets-category.repository";
import { TicketMessagesRepository } from "../repositories/tickets-message.repository";
import { TicketsRepository } from "../repositories/tickets.repository";
@Injectable()
export class TicketsService implements OnModuleInit {
private readonly logger = new Logger(TicketsService.name);
constructor(
@InjectQueue(TICKET.QUEUE_NAME) private readonly ticketQueue: Queue,
private readonly ticketsRepository: TicketsRepository,
private readonly ticketsCategoryRepository: TicketCategoryRepository,
private readonly ticketMessagesRepository: TicketMessagesRepository,
private readonly usersService: UsersService,
private readonly adminsService: AdminsService,
private readonly notificationQueue: NotificationQueue,
private readonly referralService: ReferralService,
private readonly dataSource: DataSource,
private readonly supportPlansService: SupportPlansService,
private readonly accessLogService: AccessLogService,
) {}
async onModuleInit() {
await this.scheduleAutoCloseJob();
}
//******************************** */
async getTicketCategories() {
const ticketCategories = await this.ticketsCategoryRepository.find({});
return {
ticketCategories,
};
}
//******************************** */
async getCategoriesList(queryDto: SearchTicketCategoryDto) {
const [categories, count] = await this.ticketsCategoryRepository.findCategoriesList(queryDto);
return { categories, count, paginate: true };
}
//******************************** */
async createTicketCategory(createDto: CreateTicketCategoryDto, userIpAndHeaders: IUserIpAndHeaders) {
const { userGroup } = await this.usersService.getUserGroupById(createDto.userGroupId);
const existCategory = await this.ticketsCategoryRepository.findCategoryByTitle(createDto.title);
if (existCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_EXIST);
const ticketCategory = this.ticketsCategoryRepository.create({
...createDto,
group: userGroup,
});
await this.ticketsCategoryRepository.save(ticketCategory);
await this.accessLogService.logCreate("TicketCategory", UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets/categories",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin created ticket category: ${createDto.title}`,
metadata: {
categoryId: ticketCategory.id,
categoryTitle: ticketCategory.title,
},
});
return {
message: TicketMessageEnum.CATEGORY_CREATED,
};
}
//******************************** */
async updateCategory(paramDto: ParamDto, updateCategoryDto: UpdateTicketCategoryDto, userIpAndHeaders: IUserIpAndHeaders) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id);
if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
if (updateCategoryDto.title) {
const { title } = updateCategoryDto;
const existCategory = await this.ticketsCategoryRepository.findOneBy({
title,
id: Not(paramDto.id),
});
if (existCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_EXIST);
}
await this.ticketsCategoryRepository.save({
...category,
...updateCategoryDto,
});
await this.accessLogService.logUpdate("TicketCategory", paramDto.id, UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets/categories",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin updated ticket category: ${category.title}`,
metadata: {
categoryId: category.id,
categoryTitle: category.title,
},
oldValues: JSON.stringify({ ...category }),
newValues: JSON.stringify({ ...updateCategoryDto }),
});
return {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//******************************** */
async toggleCategoryStatus(paramDto: ParamDto, userIpAndHeaders: IUserIpAndHeaders) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id);
if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
//
category.isActive = !category.isActive;
await this.ticketsCategoryRepository.save(category);
//
await this.accessLogService.logUpdate("TicketCategory", paramDto.id, UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets/categories",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin toggled ticket category status: ${category.title}`,
metadata: {
categoryId: category.id,
categoryTitle: category.title,
isActive: category.isActive,
},
});
return {
message: CommonMessage.UPDATE_SUCCESS,
isActive: category.isActive,
};
}
//******************************** */
private async checkTicketLimit(userId: string, queryRunner: QueryRunner): Promise<void> {
const { userSupportPlan } = await this.supportPlansService.getUserSupportPlanOfUser(userId);
if (!userSupportPlan) throw new BadRequestException(TicketMessageEnum.NO_ACTIVE_SUPPORT_PLAN);
const ticketLimitFeature = userSupportPlan.supportPlan.features.find(
(feature) => feature.featureKey === SupportPlanFeatureKey.TICKET_LIMIT,
);
if (!ticketLimitFeature) throw new BadRequestException(TicketMessageEnum.TICKET_LIMIT_NOT_FOUND);
const ticketLimit = parseInt(ticketLimitFeature.featureValue);
if (isNaN(ticketLimit)) return;
const today = dayjs().startOf("day").toDate();
const ticketCount = await queryRunner.manager.count(this.ticketsRepository.target, {
where: {
user: { id: userId },
createdAt: MoreThanOrEqual(today),
},
});
if (ticketCount >= ticketLimit) throw new BadRequestException(TicketMessageEnum.TICKET_LIMIT_EXCEEDED);
}
//******************************** */
async createTicket(createDto: CreateTicketDto, userId: string) {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const [user, ticketCategory] = await Promise.all([
this.usersService.findOneByIdWithQueryRunner(userId, queryRunner),
queryRunner.manager.findOne(TicketCategory, { where: { id: createDto.categoryId }, relations: { group: { users: true } } }),
]);
if (!ticketCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
await this.checkTicketLimit(userId, queryRunner);
let service = null;
if (createDto.danakServiceId) {
service = await queryRunner.manager.findOneBy(DanakService, { id: createDto.danakServiceId });
if (!service) throw new BadRequestException(TicketMessageEnum.SERVICE_NOT_FOUND);
}
const ticketMessage: Partial<TicketMessage> = { content: createDto.message, author: user };
if (createDto.attachmentUrls?.length) {
ticketMessage.attachments = createDto.attachmentUrls.map((url) => {
const attachment = new TicketMessageAttachment();
attachment.attachmentUrl = url;
return attachment;
});
}
const ticket = queryRunner.manager.create(Ticket, {
...createDto,
user,
category: ticketCategory,
messages: [ticketMessage],
danakService: service,
});
await queryRunner.manager.save(Ticket, ticket);
await this.assignTicketAndNotify(ticket, ticketCategory, user, queryRunner);
await queryRunner.commitTransaction();
return {
message: TicketMessageEnum.CREATED,
ticketId: ticket.id,
};
} catch (error) {
this.logger.error("Error in create ticket transaction", error);
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//******************************** */
async createTicketByAdmin(createDto: CreateTicketByAdminDto, userIpAndHeaders: IUserIpAndHeaders) {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const [user, admin, ticketCategory] = await Promise.all([
this.usersService.findOneByIdWithQueryRunner(createDto.userId, queryRunner),
this.usersService.findOneByIdWithQueryRunner(userIpAndHeaders.userId, queryRunner),
queryRunner.manager.findOne(TicketCategory, { where: { id: createDto.categoryId }, relations: { group: { users: true } } }),
]);
if (!ticketCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
const ticketMessage: Partial<TicketMessage> = { content: createDto.message, author: admin };
const ticket = queryRunner.manager.create(Ticket, {
...createDto,
user,
category: ticketCategory,
messages: [ticketMessage],
assignedTo: admin,
});
await queryRunner.manager.save(Ticket, ticket);
await this.notificationQueue.addTicketNotification(user.id, {
ticketId: ticket.numericId.toString(),
subject: ticket.subject,
date: ticket.createdAt,
userPhone: user.phone,
userEmail: user.email,
});
await this.accessLogService.logCreate("Ticket", UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin created ticket: ${ticket.subject}`,
metadata: {
ticketId: ticket.id,
ticketSubject: ticket.subject,
},
});
await queryRunner.commitTransaction();
return {
message: TicketMessageEnum.CREATED,
ticketId: ticket.id,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//******************************** */
async getTickets(queryDto: SearchTicketQueryDto, userId: string) {
const [tickets, count] = await this.ticketsRepository.findTicketsList(queryDto, userId);
return { tickets, count, paginate: true };
}
//******************************** */
async getTicketForAdmin(queryDto: SearchTicketQueryDto, adminId: string) {
const isSuperAdmin = await this.usersService.isSuperAdmin(adminId);
const [tickets, count] = await this.ticketsRepository.findTicketsListForAdmin(queryDto, adminId, isSuperAdmin);
return { tickets, count, paginate: true };
}
//******************************** */
async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, isAdmin: boolean) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const ticket = await this.findTicket(queryRunner, ticketId, userId, isAdmin);
if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException(TicketMessageEnum.TICKET_CLOSED);
const isSuperAdmin = await this.usersService.isSuperAdmin(userId);
if (isAdmin && !isSuperAdmin && ticket.assignedTo?.id !== userId)
throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER);
const user = await queryRunner.manager.findOneBy(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
const ticketMessage = queryRunner.manager.create(TicketMessage, {
content: createDto.content,
author: user,
ticket,
});
await queryRunner.manager.save(TicketMessage, ticketMessage);
if (createDto.attachmentUrls?.length) {
await this.saveAttachments(queryRunner, createDto.attachmentUrls, ticketMessage);
}
this.updateTicketStatus(ticket, isAdmin);
await queryRunner.manager.save(Ticket, ticket);
if (isAdmin) {
await this.sendNotificationForAdminResponse(ticket, user);
}
await queryRunner.commitTransaction();
return {
message: TicketMessageEnum.MESSAGE_CREATED,
ticketMessage,
};
} catch (error) {
await queryRunner.rollbackTransaction();
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(TicketMessageEnum.ERROR_IN_TICKET_MSG_CREATION);
} finally {
await queryRunner.release();
}
}
//******************************** */
async getTicketMessages(ticketId: string, userId: string, isAdmin: boolean) {
let ticket: null | Ticket = null;
//
if (isAdmin) {
ticket = await this.ticketsRepository.findTicketById(ticketId);
} else {
ticket = await this.ticketsRepository.findTicketById(ticketId, userId);
}
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
const messages = await this.ticketMessagesRepository.findMessagesByTicketId(ticketId);
const userSupportPlan = await this.supportPlansService.getUserSupportPlanOfUser(ticket.user.id);
return {
ticket,
messages,
supportPlan: userSupportPlan,
};
}
//******************************** */
async closeTicketByUser(ticketId: string, isAdmin: boolean, userIpAndHeaders: IUserIpAndHeaders) {
let ticket: null | Ticket = null;
if (isAdmin) {
ticket = await this.ticketsRepository.findTicketById(ticketId);
} else {
ticket = await this.ticketsRepository.findTicketById(ticketId, userIpAndHeaders.userId);
}
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
ticket.status = TicketStatus.CLOSED;
await this.ticketsRepository.save(ticket);
await this.accessLogService.logUpdate("Ticket", ticketId, UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin closed ticket: ${ticket.subject}`,
metadata: {
ticketId: ticket.id,
ticketSubject: ticket.subject,
},
});
return {
message: TicketMessageEnum.CLOSED,
};
}
//******************************** * /
async openTicketByAdmin(ticketId: string, userIpAndHeaders: IUserIpAndHeaders) {
const ticket = await this.ticketsRepository.findTicketById(ticketId);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
ticket.status = TicketStatus.PENDING;
await this.ticketsRepository.save(ticket);
await this.accessLogService.logUpdate("Ticket", ticketId, UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin opened ticket: ${ticket.subject}`,
metadata: {
ticketId: ticket.id,
ticketSubject: ticket.subject,
},
});
return {
message: TicketMessageEnum.OPENED,
};
}
//******************************** */
async referTicket(ticketId: string, referDto: ReferTicketDto, userIpAndHeaders: IUserIpAndHeaders) {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const isSuperAdmin = await this.usersService.isSuperAdmin(userIpAndHeaders.userId);
// if (!isSuperAdmin) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER);
console.log("isSuperAdmin", isSuperAdmin);
const ticket = await this.ticketsRepository.findTicketById(ticketId);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
const admin = await this.usersService.findOneByIdWithQueryRunner(referDto.adminId, queryRunner);
ticket.assignedTo = admin;
await queryRunner.manager.save(this.ticketsRepository.target, ticket);
await this.notificationQueue.addAssignTicketNotificationForAdmin(admin.id, {
ticketId: ticket.numericId.toString(),
subject: ticket.subject,
date: ticket.createdAt,
userPhone: admin.phone,
userEmail: admin.email,
user: `${ticket.user.firstName} ${ticket.user.lastName}`,
});
await queryRunner.commitTransaction();
await this.accessLogService.logUpdate("Ticket", ticketId, UserType.ADMIN, {
user: { id: userIpAndHeaders.userId },
endpoint: "/tickets",
requestId: userIpAndHeaders.headers["x-request-id"],
ipAddress: userIpAndHeaders.ip,
userAgent: userIpAndHeaders.headers["user-agent"],
actionDescription: `Admin referred ticket: ${ticket.subject}`,
metadata: {
ticketId: ticket.id,
ticketSubject: ticket.subject,
},
});
return {
message: TicketMessageEnum.REFERRED,
ticket,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//******************************** */
async getUnreadTickets() {
const unreadTickets = await this.ticketsRepository.count({
where: { status: TicketStatus.PENDING },
});
return unreadTickets;
}
//******************************** */
async countUserTickets(userId: string) {
const ticketCount = await this.ticketsRepository.count({
where: { user: { id: userId }, status: Not(TicketStatus.CLOSED) },
});
return ticketCount;
}
//***************************** */
private async assignTicketAndNotify(ticket: Ticket, ticketCategory: TicketCategory, user: User, queryRunner: QueryRunner) {
const assignedUser = await this.referralService.assignAdmin(ticketCategory.group);
//
ticket.assignedTo = assignedUser;
await queryRunner.manager.save(Ticket, ticket);
await this.notificationQueue.addAssignTicketNotificationForAdmin(assignedUser.id, {
ticketId: ticket.numericId.toString(),
subject: ticket.subject,
date: ticket.createdAt,
userPhone: user.phone,
userEmail: user.email,
user: `${user.firstName} ${user.lastName}`,
});
await this.notificationQueue.addTicketNotification(user.id, {
ticketId: ticket.numericId.toString(),
subject: ticket.subject,
date: ticket.createdAt,
userPhone: user.phone,
userEmail: user.email,
});
await this.notifySuperAdminsForTicket(queryRunner, ticket);
}
//***************************** */
private async findTicket(queryRunner: QueryRunner, ticketId: string, userId: string, isAdmin: boolean): Promise<Ticket> {
const ticket = isAdmin
? await queryRunner.manager.findOne(Ticket, {
where: { id: ticketId },
relations: { user: true, assignedTo: true },
})
: await queryRunner.manager.findOne(Ticket, {
where: { id: ticketId, user: { id: userId } },
relations: { user: true, assignedTo: true },
});
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
return ticket;
}
//***************************** */
private updateTicketStatus(ticket: Ticket, isAdmin: boolean): void {
if (isAdmin && ticket.status === TicketStatus.PENDING) {
ticket.status = TicketStatus.ANSWERED;
} else if (!isAdmin && ticket.status === TicketStatus.ANSWERED) {
ticket.status = TicketStatus.PENDING;
}
}
//***************************** */
private async saveAttachments(queryRunner: QueryRunner, attachmentUrls: string[], ticketMessage: TicketMessage) {
const attachments = attachmentUrls.map((url) =>
queryRunner.manager.create(TicketMessageAttachment, {
attachmentUrl: url,
ticketMessage,
}),
);
await queryRunner.manager.save(TicketMessageAttachment, attachments);
}
//***************************** */
private async sendNotificationForAdminResponse(ticket: Ticket, admin: User) {
await this.notificationQueue.addAnswerTicketNotification(ticket.user.id, {
ticketId: ticket.numericId.toString(),
subject: ticket.subject,
date: ticket.createdAt,
userPhone: admin.phone,
userEmail: admin.email,
});
}
//***************************** */
private async notifySuperAdminsForTicket(queryRunner: QueryRunner, ticket: Ticket) {
const superAdmins = await this.adminsService.getSuperAdmins(queryRunner);
for (const admin of superAdmins) {
await this.notificationQueue.addNewTicketGlobalNotification(admin.id, {
ticketId: ticket.numericId.toString(),
ticketSubject: ticket.subject,
userPhone: admin.phone,
userEmail: admin.email,
fullName: `${ticket.user.firstName} ${ticket.user.lastName}`,
});
}
}
//***************************** */
async scheduleAutoCloseJob() {
await this.ticketQueue.add(
TICKET.AUTO_CLOSE_JOB_NAME,
{},
{
repeat: { pattern: TICKET.AUTO_CLOSE_REPEAT_PATTERN },
priority: TICKET.AUTO_CLOSE_PRIORITY,
},
);
this.logger.log("Scheduled auto-close-inactive job for tickets");
}
}
@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { TicketMessage } from "../entities/ticket-message.entity";
@Injectable()
export class TicketMessagesRepository extends Repository<TicketMessage> {
constructor(@InjectRepository(TicketMessage) ticketMessagesRepository: Repository<TicketMessage>) {
super(ticketMessagesRepository.target, ticketMessagesRepository.manager, ticketMessagesRepository.queryRunner);
}
async findMessagesByTicketId(ticketId: string): Promise<TicketMessage[]> {
return this.find({
relations: {
author: {
roles: true,
},
attachments: true,
// ticket: true,
},
select: {
id: true,
content: true,
attachments: {
id: true,
attachmentUrl: true,
},
createdAt: true,
author: {
id: true,
firstName: true,
lastName: true,
roles: {
id: true,
name: true,
},
},
},
where: { ticket: { id: ticketId } },
order: { createdAt: "ASC" },
});
}
}
+119
View File
@@ -0,0 +1,119 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
import { SearchTicketQueryDto } from "../DTO/search-ticket-query.dto";
import { Ticket } from "../entities/ticket.entity";
import { TicketStatus } from "../enums/ticket-status.enum";
@Injectable()
export class TicketsRepository extends Repository<Ticket> {
constructor(@InjectRepository(Ticket) ticketsRepository: Repository<Ticket>) {
super(ticketsRepository.target, ticketsRepository.manager, ticketsRepository.queryRunner);
}
async findTicketById(id: string, userId?: string): Promise<Ticket | null> {
return this.findOne({
where: { id, ...(userId && { user: { id: userId } }) },
relations: {
user: true,
category: true,
assignedTo: true,
danakService: true,
},
select: {
id: true,
numericId: true,
danakService: {
id: true,
name: true,
},
subject: true,
priority: true,
status: true,
createdAt: true,
user: {
id: true,
firstName: true,
lastName: true,
},
assignedTo: {
id: true,
firstName: true,
lastName: true,
},
category: {
id: true,
title: true,
},
},
});
}
//******************************** */
async findTicketsList(queryDto: SearchTicketQueryDto, userId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const queryBuilder = this.createQueryBuilder("ticket")
.leftJoinAndSelect("ticket.category", "category")
.leftJoinAndSelect("ticket.danakService", "danakService")
.where("ticket.user.id = :userId", { userId })
.orderBy("ticket.createdAt", "DESC")
.select([
"ticket.id",
"ticket.subject",
"ticket.status",
"ticket.priority",
"ticket.numericId",
"ticket.createdAt",
"ticket.updatedAt",
"category.id",
"category.title",
"danakService.id",
"danakService.name",
])
.skip(skip)
.take(limit);
if (queryDto.status) {
queryBuilder.andWhere("ticket.status = :status", {
status: queryDto.status,
});
} else {
queryBuilder.andWhere("ticket.status != :closedStatus", {
closedStatus: TicketStatus.CLOSED,
});
}
return queryBuilder.getManyAndCount();
}
async findTicketsListForAdmin(queryDto: SearchTicketQueryDto, adminId: string, isSuperAdmin: boolean) {
const { limit, skip } = PaginationUtils(queryDto);
const queryBuilder = this.createQueryBuilder("ticket")
.leftJoinAndSelect("ticket.category", "category")
.leftJoinAndSelect("ticket.user", "user")
.leftJoinAndSelect("ticket.assignedTo", "assignedTo")
.leftJoinAndSelect("ticket.danakService", "danakService")
.orderBy("ticket.createdAt", "DESC");
if (!isSuperAdmin) {
queryBuilder.andWhere("ticket.assignedToId = :adminId", { adminId });
}
if (queryDto.status) {
queryBuilder.andWhere("ticket.status = :status", {
status: queryDto.status,
});
}
if (queryDto.userId) {
queryBuilder.andWhere("user.id = :userId", { userId: queryDto.userId });
}
return queryBuilder.skip(skip).take(limit).getManyAndCount();
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CreateTicketCategoryDto } from "./DTO/create-ticket-category.dto";
import { CreateTicketMessageDto } from "./DTO/create-ticket-message.dto";
import { CreateTicketByAdminDto, CreateTicketDto } from "./DTO/create-ticket.dto";
import { ReferTicketDto } from "./DTO/refer-ticket.dto";
import { SearchTicketCategoryDto } from "./DTO/search-ticket-category.dto";
import { SearchTicketQueryDto } from "./DTO/search-ticket-query.dto";
import { UpdateTicketCategoryDto } from "./DTO/update-ticket-category.dto";
import { TicketsService } from "./providers/tickets.service";
import { AdminRoute } from "../../common/decorators/admin.decorator";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { PermissionsDec } from "../../common/decorators/permission.decorator";
import { IUserIpAndHeaders, UserDec, UserIpAndHeaders } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { PermissionEnum } from "../users/enums/permission.enum";
@Controller("tickets")
@ApiTags("Tickets")
@AuthGuards()
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
//----------------- ticket categories -------------------
@ApiOperation({ summary: "Create ticket category => admin route" })
@PermissionsDec(PermissionEnum.TICKETS)
@Post("category")
createTicketCategory(@Body() createDto: CreateTicketCategoryDto, @UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders) {
return this.ticketsService.createTicketCategory(createDto, userIpAndHeaders);
}
@ApiOperation({ summary: "Update ticket category => admin route" })
@PermissionsDec(PermissionEnum.TICKETS)
@Patch("category/:id")
updateCategory(
@Param() paramDto: ParamDto,
@Body() updateCategoryDto: UpdateTicketCategoryDto,
@UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders,
) {
return this.ticketsService.updateCategory(paramDto, updateCategoryDto, userIpAndHeaders);
}
@ApiOperation({ summary: "Get ticket categories " })
@Get("categories")
getTicketCategories() {
return this.ticketsService.getTicketCategories();
}
@ApiOperation({ summary: "Get ticket categories list => admin route" })
@AdminRoute()
@PermissionsDec(PermissionEnum.TICKETS)
@Get("categories-list")
getCategoriesList(@Query() queryDto: SearchTicketCategoryDto) {
return this.ticketsService.getCategoriesList(queryDto);
}
@ApiOperation({ summary: "toggle status of categories => admin route" })
@AdminRoute()
@HttpCode(HttpStatus.OK)
@PermissionsDec(PermissionEnum.TICKETS)
@Post("category/toggle-status/:id")
toggleCategoryStatus(@Param() paramDto: ParamDto, @UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders) {
return this.ticketsService.toggleCategoryStatus(paramDto, userIpAndHeaders);
}
//----------------- ticket and ticket messages -------------------
@ApiOperation({ summary: "create ticket ==> user route" })
@Post()
createTicket(@Body() createDto: CreateTicketDto, @UserDec("id") userId: string) {
return this.ticketsService.createTicket(createDto, userId);
}
@ApiOperation({ summary: "create ticket by admin" })
@AdminRoute()
@PermissionsDec(PermissionEnum.TICKETS)
@Post("admin")
createTicketByAdmin(@Body() createDto: CreateTicketByAdminDto, @UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders) {
return this.ticketsService.createTicketByAdmin(createDto, userIpAndHeaders);
}
@ApiOperation({ summary: "Get all tickets of user ==> user route" })
@Get()
getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") userId: string) {
return this.ticketsService.getTickets(queryDto, userId);
}
@ApiOperation({ summary: "Get all tickets ==> admin route" })
@AdminRoute()
@PermissionsDec(PermissionEnum.TICKETS)
@Get("admin")
getAllTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") adminId: string) {
return this.ticketsService.getTicketForAdmin(queryDto, adminId);
}
@ApiOperation({ summary: "create ticket messages" })
@Post(":id/messages")
createTicketMessage(
@Param() paramDto: ParamDto,
@Body() createDto: CreateTicketMessageDto,
@UserDec("id") userId: string,
@UserDec("isAdmin") isAdmin: boolean,
) {
return this.ticketsService.createTicketMessage(paramDto.id, createDto, userId, isAdmin);
}
@ApiOperation({ summary: "Get all ticket messages of user" })
@Get(":id/messages")
getTicketMessages(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @UserDec("isAdmin") isAdmin: boolean) {
return this.ticketsService.getTicketMessages(paramDto.id, userId, isAdmin);
}
@ApiOperation({ summary: "close ticket by user or admin" })
@HttpCode(HttpStatus.OK)
@Post(":id/close")
closedTicketByUser(
@Param() paramDto: ParamDto,
@UserDec("isAdmin") isAdmin: boolean,
@UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders,
) {
return this.ticketsService.closeTicketByUser(paramDto.id, isAdmin, userIpAndHeaders);
}
@ApiOperation({ summary: "open ticket by admin" })
@AdminRoute()
@PermissionsDec(PermissionEnum.TICKETS)
@Post(":id/open")
openTicketByAdmin(@Param() paramDto: ParamDto, @UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders) {
return this.ticketsService.openTicketByAdmin(paramDto.id, userIpAndHeaders);
}
@ApiOperation({ summary: "referer ticket ==> admin route" })
@AdminRoute()
@PermissionsDec(PermissionEnum.TICKETS)
@Post(":id/refer")
referTicket(@Param() paramDto: ParamDto, @Body() referDto: ReferTicketDto, @UserIpAndHeaders() userIpAndHeaders: IUserIpAndHeaders) {
return this.ticketsService.referTicket(paramDto.id, referDto, userIpAndHeaders);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { TICKET } from "./constant";
import { TicketCategory } from "./entities/ticket-category.entity";
import { TicketMessage } from "./entities/ticket-message.entity";
import { Ticket } from "./entities/ticket.entity";
import { ReferralStrategyFactory } from "./providers/referral-strategy.factory";
import { TicketsService } from "./providers/tickets.service";
import { TicketCategoryRepository } from "./repositories/tickets-category.repository";
import { TicketMessagesRepository } from "./repositories/tickets-message.repository";
import { TicketsRepository } from "./repositories/tickets.repository";
import { TicketsController } from "./tickets.controller";
import { AccessLogsModule } from "../access-logs/access-logs.module";
import { UsersModule } from "../users/users.module";
import { TicketMessageAttachment } from "./entities/ticket-message-attachment";
import { TicketMessageAttachmentsRepository } from "./repositories/ticket-message-attachment.repository";
import { DanakServicesModule } from "../danak-services/danak-services.module";
import { NotificationModule } from "../notifications/notifications.module";
import { ReferralService } from "./providers/referral.service";
import { GroupStrategy } from "./strategies/group.strategy";
import { RoundRobinStrategy } from "./strategies/round-robin.strategy";
import { SupportPlansModule } from "../support-plans/support-plans.module";
import { TicketProcessor } from "./queue/ticket.processor";
import { LoggerModule } from "../logger/logger.module";
@Module({
imports: [
TypeOrmModule.forFeature([Ticket, TicketCategory, TicketMessageAttachment, TicketMessage]),
AccessLogsModule,
BullModule.registerQueue({ name: TICKET.QUEUE_NAME }),
UsersModule,
DanakServicesModule,
NotificationModule,
SupportPlansModule,
LoggerModule,
],
providers: [
GroupStrategy,
RoundRobinStrategy,
ReferralStrategyFactory,
{
provide: ReferralService,
useFactory: (strategyFactory: ReferralStrategyFactory) => new ReferralService(strategyFactory),
inject: [ReferralStrategyFactory],
},
TicketsService,
TicketsRepository,
TicketCategoryRepository,
TicketMessagesRepository,
TicketMessageAttachmentsRepository,
TicketProcessor,
],
controllers: [TicketsController],
exports: [TicketsService],
})
export class TicketsModule {}