diff --git a/src/modules/ticket/DTO/create-ticket-category.dto.ts b/src/modules/ticket/DTO/create-ticket-category.dto.ts new file mode 100755 index 0000000..ab4dc2e --- /dev/null +++ b/src/modules/ticket/DTO/create-ticket-category.dto.ts @@ -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; +} diff --git a/src/modules/ticket/DTO/create-ticket-message.dto.ts b/src/modules/ticket/DTO/create-ticket-message.dto.ts new file mode 100755 index 0000000..a7543b7 --- /dev/null +++ b/src/modules/ticket/DTO/create-ticket-message.dto.ts @@ -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[]; +} diff --git a/src/modules/ticket/DTO/create-ticket.dto.ts b/src/modules/ticket/DTO/create-ticket.dto.ts new file mode 100755 index 0000000..822c0fb --- /dev/null +++ b/src/modules/ticket/DTO/create-ticket.dto.ts @@ -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; +} diff --git a/src/modules/ticket/DTO/search-ticket-category.dto.ts b/src/modules/ticket/DTO/search-ticket-category.dto.ts new file mode 100755 index 0000000..17e0593 --- /dev/null +++ b/src/modules/ticket/DTO/search-ticket-category.dto.ts @@ -0,0 +1,3 @@ +import { CategoryListSearchQueryDto } from "../../danak-services/DTO/category-search-query.dto"; + +export class SearchTicketCategoryDto extends CategoryListSearchQueryDto {} diff --git a/src/modules/ticket/DTO/search-ticket-query.dto.ts b/src/modules/ticket/DTO/search-ticket-query.dto.ts new file mode 100755 index 0000000..8a53620 --- /dev/null +++ b/src/modules/ticket/DTO/search-ticket-query.dto.ts @@ -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; +} diff --git a/src/modules/ticket/constant/index.ts b/src/modules/ticket/constant/index.ts new file mode 100755 index 0000000..2c24826 --- /dev/null +++ b/src/modules/ticket/constant/index.ts @@ -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 * * *", +}; diff --git a/src/modules/ticket/entities/ticket.entity.ts b/src/modules/ticket/entities/ticket.entity.ts new file mode 100755 index 0000000..d441e26 --- /dev/null +++ b/src/modules/ticket/entities/ticket.entity.ts @@ -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[] +} diff --git a/src/modules/ticket/enums/ticket-status.enum.ts b/src/modules/ticket/enums/ticket-status.enum.ts new file mode 100755 index 0000000..000c0e2 --- /dev/null +++ b/src/modules/ticket/enums/ticket-status.enum.ts @@ -0,0 +1,5 @@ +export enum TicketStatus { + PENDING = "PENDING", + ANSWERED = "ANSWERED", + CLOSED = "CLOSED", +} diff --git a/src/modules/ticket/interfaces/referral-strategy.ts b/src/modules/ticket/interfaces/referral-strategy.ts new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/src/modules/ticket/interfaces/referral-strategy.ts @@ -0,0 +1 @@ + diff --git a/src/modules/ticket/providers/tickets.service.ts b/src/modules/ticket/providers/tickets.service.ts new file mode 100755 index 0000000..f124f82 --- /dev/null +++ b/src/modules/ticket/providers/tickets.service.ts @@ -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 { + 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 = { 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 = { 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 { + 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"); + } +} diff --git a/src/modules/ticket/repositories/tickets-message.repository.ts b/src/modules/ticket/repositories/tickets-message.repository.ts new file mode 100755 index 0000000..b39f23f --- /dev/null +++ b/src/modules/ticket/repositories/tickets-message.repository.ts @@ -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 { + constructor(@InjectRepository(TicketMessage) ticketMessagesRepository: Repository) { + super(ticketMessagesRepository.target, ticketMessagesRepository.manager, ticketMessagesRepository.queryRunner); + } + + async findMessagesByTicketId(ticketId: string): Promise { + 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" }, + }); + } +} diff --git a/src/modules/ticket/repositories/tickets.repository.ts b/src/modules/ticket/repositories/tickets.repository.ts new file mode 100755 index 0000000..5e6b641 --- /dev/null +++ b/src/modules/ticket/repositories/tickets.repository.ts @@ -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 { + constructor(@InjectRepository(Ticket) ticketsRepository: Repository) { + super(ticketsRepository.target, ticketsRepository.manager, ticketsRepository.queryRunner); + } + + async findTicketById(id: string, userId?: string): Promise { + 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(); + } +} diff --git a/src/modules/ticket/tickets.controller.ts b/src/modules/ticket/tickets.controller.ts new file mode 100755 index 0000000..5b6a50a --- /dev/null +++ b/src/modules/ticket/tickets.controller.ts @@ -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); + } +} diff --git a/src/modules/ticket/tickets.module.ts b/src/modules/ticket/tickets.module.ts new file mode 100755 index 0000000..cf86fbb --- /dev/null +++ b/src/modules/ticket/tickets.module.ts @@ -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 {}