fix: bug in create company

This commit is contained in:
Mahyargdz
2025-05-12 12:01:24 +03:30
parent 474193ef4e
commit f18295b628
27 changed files with 1069 additions and 13 deletions
+2
View File
@@ -18,6 +18,7 @@ import { AuthModule } from "./modules/auth/auth.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { IndustriesModule } from "./modules/industries/industries.module";
import { NotificationModule } from "./modules/notifications/notifications.module";
import { TicketsModule } from "./modules/tickets/tickets.module";
import { UploaderModule } from "./modules/uploader/uploader.module";
import { UsersModule } from "./modules/users/users.module";
import { UtilsModule } from "./modules/utils/utils.module";
@@ -46,6 +47,7 @@ import { UtilsModule } from "./modules/utils/utils.module";
IndustriesModule,
CompaniesModule,
UploaderModule,
TicketsModule,
],
})
export class AppModule implements NestModule {
+2 -2
View File
@@ -259,8 +259,8 @@ export const enum TicketMessageEnum {
PRIORITY_INVALID = "اولویت تیکت باید یکی از مقادیر LOW، MEDIUM یا HIGH باشد",
CATEGORY_ID_REQUIRED = "شناسه دسته‌بندی نمی‌تواند خالی باشد",
CATEGORY_ID_SHOULD_BE_UUID = "شناسه دسته‌بندی باید یک UUID معتبر باشد",
DANAK_SERVICE_ID_REQUIRED = "شناسه سرویس داناک اجباری است",
DANAK_SERVICE_ID_SHOULD_BE_UUID = "شناسه سرویس داناک باید یک UUID معتبر باشد",
COMPANY_ID_REQUIRED = "شناسه شرکت اجباری است",
COMPANY_ID_SHOULD_BE_UUID = "شناسه شرکت باید یک UUID معتبر باشد",
MESSAGE_REQUIRED = "متن پیام تیکت اجباری است",
MESSAGE_STRING = "متن پیام باید یک رشته باشد",
MESSAGE_LENGTH = "متن پیام باید بین ۵ تا ۵۰۰ کاراکتر باشد",
@@ -1,4 +1,4 @@
import { Collection, EntityManager } from "@mikro-orm/postgresql";
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable } from "@nestjs/common";
import dayjs from "dayjs";
@@ -28,7 +28,7 @@ export class CompaniesService {
async create(createDto: CreateCompanyDto) {
const em = this.em.fork();
try {
em.begin();
await em.begin();
const industry = await em.findOne(Industry, { id: createDto.industryId, deletedAt: null });
if (!industry) throw new BadRequestException(IndustryMessage.INDUSTRY_NOT_FOUND);
@@ -44,33 +44,48 @@ export class CompaniesService {
const hashedPassword = await this.passwordService.hashPassword(createDto.password);
const user = await this.usersService.createUser(createDto, hashedPassword, em);
const company = em.create(Company, { ...createDto, industry, status: CompanyStatus.APPROVED, user });
const products = createDto.products.map((productData) => {
const product = new CompanyProduct();
product.title = productData.title;
product.imagesUrl = productData.imagesUrl;
return product;
});
const products = createDto.products.map((productData) => em.create(CompanyProduct, { ...productData, company }));
company.products = new Collection(products);
const services = createDto.services.map((serviceData) => {
const service = new CompanyService();
service.title = serviceData.title;
service.imagesUrl = serviceData.imagesUrl;
return service;
});
const services = createDto.services.map((serviceData) => em.create(CompanyService, { ...serviceData, company }));
company.services = new Collection(services);
const company = em.create(Company, {
...createDto,
industry,
status: CompanyStatus.APPROVED,
products,
services,
user,
});
await em.persistAndFlush(company);
await em.commit();
return {
message: CompanyMessage.COMPANY_CREATED_SUCCESSFULLY,
company,
user,
company: company.name,
};
} catch (error) {
await em.rollback();
throw error;
}
}
//-----------------------------------
async updateCompanyById(id: string, updateDto: UpdateCompanyDto) {
const em = this.em.fork();
try {
em.begin();
await em.begin();
const company = await em.findOne(Company, { id, deletedAt: null }, { populate: ["user"] });
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { CreateIndustryDto } from "./DTO/create-industry.dto";
import { IndustryListQueryDto } from "./DTO/industry-list-query.dto";
@@ -11,31 +12,37 @@ export class IndustriesController {
constructor(private readonly industriesService: IndustriesService) {}
@Post()
@ApiOperation({ summary: "create industry" })
create(@Body() createIndustryDto: CreateIndustryDto) {
return this.industriesService.create(createIndustryDto);
}
@Get(":id")
@ApiOperation({ summary: "get industry by id" })
getIndustryById(@Param() paramDto: ParamDto) {
return this.industriesService.getIndustriesById(paramDto.id);
}
@Patch(":id")
@ApiOperation({ summary: "update industry" })
updateIndustry(@Param() paramDto: ParamDto, @Body() updateIndustryDto: UpdateIndustryDto) {
return this.industriesService.updateIndustry(paramDto.id, updateIndustryDto);
}
@Delete(":id")
@ApiOperation({ summary: "delete industry" })
deleteIndustry(@Param() paramDto: ParamDto) {
return this.industriesService.deleteIndustry(paramDto.id);
}
@Get("list")
@ApiOperation({ summary: "get industries list for admin" })
getIndustriesForAdmin(@Query() query: IndustryListQueryDto) {
return this.industriesService.getIndustriesListForAdmin(query);
}
@Patch(":id/toggle-status")
@ApiOperation({ summary: "toggle industry status" })
toggleStatus(@Param() paramDto: ParamDto) {
return this.industriesService.toggleStatus(paramDto.id);
}
+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[];
}
+42
View File
@@ -0,0 +1,42 @@
import { ApiProperty } from "@nestjs/swagger";
import { ArrayMinSize, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, IsUrl, Length } from "class-validator";
import { TicketMessageEnum } from "../../../common/enums/message.enum";
import { TicketPriority } from "../enums/ticket-priority.enum";
export class CreateTicketDto {
@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.COMPANY_ID_REQUIRED })
@IsUUID("7", { message: TicketMessageEnum.COMPANY_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Company ID related to the ticket", example: "550e8400-e29b-41d4-a716-446655440000" })
companyId?: 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, 500, { 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[];
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsUUID } from "class-validator";
import { UserMessage } from "../../../common/enums/message.enum";
export class ReferTicketDto {
@IsNotEmpty({ message: UserMessage.ADMIN_ID_REQUIRED })
@IsUUID("4", { message: UserMessage.ADMIN_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "admin id", example: "123e4567-e89b-12d3-a456-426614174000" })
adminId: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
export class SearchTicketCategoryDto extends PaginationDto {
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ description: "Filter by active status", example: true })
isActive?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: "Search query for title", example: "technical" })
q?: string;
}
+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;
}
+5
View File
@@ -0,0 +1,5 @@
import { OmitType, PartialType } from "@nestjs/swagger";
import { CreateTicketCategoryDto } from "./create-ticket-category.dto";
export class UpdateTicketCategoryDto extends PartialType(OmitType(CreateTicketCategoryDto, ["userGroupId"])) {}
+1
View File
@@ -0,0 +1 @@
export const REFERRAL_STRATEGY = "referralStrategy";
+19
View File
@@ -0,0 +1,19 @@
import { Collection, Entity, OneToMany, Property } from "@mikro-orm/core";
import { Ticket } from "./ticket.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
@Entity()
export class TicketCategory extends BaseEntity {
@Property({ type: "varchar", length: 150, unique: true })
title!: string;
@Property({ type: "text", nullable: true })
description?: string;
@Property({ type: "boolean", default: true })
isActive: boolean = true;
@OneToMany(() => Ticket, (ticket) => ticket.category)
tickets = new Collection<Ticket>(this);
}
+13
View File
@@ -0,0 +1,13 @@
import { Entity, ManyToOne, Property } from "@mikro-orm/core";
import { TicketMessage } from "./ticket-message.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
@Entity()
export class TicketMessageAttachment extends BaseEntity {
@Property({ type: "varchar", length: 150, nullable: true })
attachmentUrl?: string;
@ManyToOne({ entity: () => TicketMessage, deleteRule: "cascade", nullable: false })
ticketMessage!: TicketMessage;
}
+21
View File
@@ -0,0 +1,21 @@
import { Cascade, Collection, Entity, ManyToOne, OneToMany, Property } from "@mikro-orm/core";
import { TicketMessageAttachment } from "./ticket-message-attachment";
import { Ticket } from "./ticket.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
@Entity()
export class TicketMessage extends BaseEntity {
@Property({ type: "text" })
content!: string;
@OneToMany(() => TicketMessageAttachment, (attachment) => attachment.ticketMessage, { cascade: [Cascade.ALL] })
attachments = new Collection<TicketMessageAttachment>(this);
@ManyToOne(() => Ticket, { deleteRule: "cascade", nullable: false })
ticket!: Ticket;
@ManyToOne(() => User, { deleteRule: "cascade", nullable: false })
author!: User;
}
+39
View File
@@ -0,0 +1,39 @@
import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core";
import { TicketCategory } from "./ticket-category.entity";
import { TicketMessage } from "./ticket-message.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Company } from "../../companies/entities/company.entity";
import { User } from "../../users/entities/user.entity";
import { TicketPriority } from "../enums/ticket-priority.enum";
import { TicketStatus } from "../enums/ticket-status.enum";
@Entity()
export class Ticket extends BaseEntity {
@Property({ type: "int", autoincrement: true, persist: false })
numericId!: number & Opt;
@Property({ type: "varchar", length: 150 })
subject!: string;
@Enum({ items: () => TicketStatus, nativeEnumName: "ticket_status", default: TicketStatus.PENDING })
status: TicketStatus & Opt;
@Enum({ items: () => TicketPriority, nativeEnumName: "ticket_priority" })
priority!: TicketPriority;
@ManyToOne(() => Company, { deleteRule: "set null", nullable: true })
company?: Company;
@ManyToOne(() => User, { deleteRule: "set null", nullable: true })
assignedTo?: User;
@ManyToOne(() => User, { deleteRule: "cascade", nullable: false })
user!: User;
@ManyToOne(() => TicketCategory, { deleteRule: "set null", nullable: true })
category?: TicketCategory;
@OneToMany(() => TicketMessage, (message) => message.ticket, { cascade: [Cascade.ALL] })
messages = new Collection<TicketMessage>(this);
}
+5
View File
@@ -0,0 +1,5 @@
export enum TicketPriority {
LOW = "LOW",
MEDIUM = "MEDIUM",
HIGH = "HIGH",
}
+5
View File
@@ -0,0 +1,5 @@
export enum TicketStatus {
PENDING = "PENDING",
ANSWERED = "ANSWERED",
CLOSED = "CLOSED",
}
+401
View File
@@ -0,0 +1,401 @@
import { Collection, EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, HttpException, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ParamDto } from "../../../common/DTO/param.dto";
import { CommonMessage, CompanyMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { Company } from "../../companies/entities/company.entity";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { User } from "../../users/entities/user.entity";
import { RoleEnum } from "../../users/enums/role.enum";
import { UsersService } from "../../users/services/users.service";
import { CreateTicketCategoryDto } from "../DTO/create-ticket-category.dto";
import { CreateTicketMessageDto } from "../DTO/create-ticket-message.dto";
import { 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 {
private readonly logger = new Logger(TicketsService.name);
constructor(
private readonly ticketsRepository: TicketsRepository,
private readonly ticketsCategoryRepository: TicketCategoryRepository,
private readonly ticketMessagesRepository: TicketMessagesRepository,
private readonly usersService: UsersService,
private readonly notificationQueue: NotificationQueue,
private readonly em: EntityManager,
) {}
//******************************** */
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) {
const existCategory = await this.ticketsCategoryRepository.findCategoryByTitle(createDto.title);
if (existCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_EXIST);
const ticketCategory = this.ticketsCategoryRepository.create({
...createDto,
});
await this.em.persistAndFlush(ticketCategory);
return {
message: TicketMessageEnum.CATEGORY_CREATED,
ticketCategory,
};
}
//******************************** */
async updateCategory(paramDto: ParamDto, updateCategoryDto: UpdateTicketCategoryDto) {
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.findOne({ title, id: { $ne: paramDto.id } });
if (existCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_EXIST);
}
this.ticketsCategoryRepository.assign(category, updateCategoryDto);
await this.em.flush();
return {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//******************************** */
async toggleCategoryStatus(paramDto: ParamDto) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id);
if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
//
category.isActive = !category.isActive;
await this.em.flush();
//
return {
message: CommonMessage.UPDATE_SUCCESS,
isActive: category.isActive,
};
}
//******************************** */
async createTicket(createDto: CreateTicketDto, userId: string) {
const em = this.em.fork();
try {
await em.begin();
const [user, ticketCategory] = await Promise.all([
this.usersService.findOneByIdWithEntityManager(userId, em),
em.findOne(TicketCategory, { id: createDto.categoryId }),
]);
if (!ticketCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
let company = null;
if (createDto.companyId) {
company = await em.findOne(Company, { id: createDto.companyId });
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
}
const ticketMessage = new TicketMessage();
ticketMessage.content = createDto.message;
ticketMessage.author = user;
if (createDto.attachmentUrls?.length) {
const attachments = createDto.attachmentUrls.map((url) => {
const attachment = new TicketMessageAttachment();
attachment.attachmentUrl = url;
return attachment;
});
ticketMessage.attachments = new Collection<TicketMessageAttachment>(ticketMessage, attachments);
}
const ticket = em.create(Ticket, {
...createDto,
user,
category: ticketCategory,
messages: [ticketMessage],
company,
});
await em.persistAndFlush(ticket);
await this.assignTicketAndNotify(ticket, user, em);
await em.commit();
return {
message: TicketMessageEnum.CREATED,
ticketId: ticket.id,
};
} catch (error) {
this.logger.error("Error in create ticket transaction", error);
await em.rollback();
throw error;
}
}
//******************************** */
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 [tickets, count] = await this.ticketsRepository.findTicketsListForAdmin(queryDto, adminId);
return { tickets, count, paginate: true };
}
//******************************** */
async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, role: RoleEnum) {
const em = this.em.fork();
try {
await em.begin();
const ticket = await this.findTicket(em, ticketId, userId, role === RoleEnum.ADMIN);
if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException(TicketMessageEnum.TICKET_CLOSED);
if (role === RoleEnum.ADMIN && ticket.assignedTo?.id !== userId)
throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER);
const user = await em.findOne(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
const ticketMessage = em.create(TicketMessage, {
content: createDto.content,
author: user,
ticket,
});
await em.persistAndFlush(ticketMessage);
if (createDto.attachmentUrls?.length) {
await this.saveAttachments(em, createDto.attachmentUrls, ticketMessage);
}
this.updateTicketStatus(ticket, role === RoleEnum.ADMIN);
await em.persistAndFlush(ticket);
if (role === RoleEnum.ADMIN) {
await this.sendNotificationForAdminResponse(ticket, user);
}
await em.commit();
return {
message: TicketMessageEnum.MESSAGE_CREATED,
ticketMessage,
};
} catch (error) {
await em.rollback();
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(TicketMessageEnum.ERROR_IN_TICKET_MSG_CREATION);
}
}
//******************************** */
async getTicketMessages(ticketId: string, userId: string, role: RoleEnum) {
let ticket = null;
//
if (role === RoleEnum.ADMIN) {
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);
return {
ticket,
messages,
};
}
//******************************** */
async closeTicketByUser(ticketId: string, userId: string, role: RoleEnum) {
let ticket = null;
if (role === RoleEnum.ADMIN) {
ticket = await this.ticketsRepository.findTicketById(ticketId);
} else {
ticket = await this.ticketsRepository.findTicketById(ticketId, userId);
}
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
ticket.status = TicketStatus.CLOSED;
await this.em.flush();
return {
message: TicketMessageEnum.CLOSED,
};
}
//******************************** * /
async openTicketByAdmin(ticketId: string) {
const ticket = await this.ticketsRepository.findTicketById(ticketId);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
ticket.status = TicketStatus.PENDING;
await this.em.flush();
return {
message: TicketMessageEnum.OPENED,
};
}
//******************************** */
async referTicket(ticketId: string, userId: string, referDto: ReferTicketDto) {
const em = this.em.fork();
try {
await em.begin();
console.log(userId, "admin id");
const ticket = await this.ticketsRepository.findTicketById(ticketId);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
const admin = await this.usersService.findOneByIdWithEntityManager(referDto.adminId, em);
ticket.assignedTo = admin;
await em.flush();
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 em.commit();
return {
message: TicketMessageEnum.REFERRED,
ticket,
};
} catch (error) {
await em.rollback();
throw error;
}
}
//******************************** */
async getUnreadTickets() {
const unreadTickets = await this.ticketsRepository.count({
status: TicketStatus.PENDING,
});
return unreadTickets;
}
//******************************** */
async countUserTickets(userId: string) {
const ticketCount = await this.ticketsRepository.count({
user: { id: userId },
status: { $ne: TicketStatus.CLOSED },
});
return ticketCount;
}
//***************************** */
private async assignTicketAndNotify(ticket: Ticket, user: User, em: EntityManager) {
const assignedUser = await this.usersService.findAdminWithLeastTickets();
//
if (assignedUser) {
ticket.assignedTo = assignedUser;
await em.persistAndFlush(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,
});
}
//***************************** */
private async findTicket(em: EntityManager, ticketId: string, userId: string, isAdmin: boolean): Promise<Ticket> {
const ticket = isAdmin
? await em.findOne(Ticket, { id: ticketId, user: { id: userId } }, { populate: ["user", "assignedTo"] })
: await em.findOne(Ticket, { id: ticketId, user: { id: userId } }, { populate: ["user", "assignedTo"] });
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(em: EntityManager, attachmentUrls: string[], ticketMessage: TicketMessage) {
const attachments = attachmentUrls.map((url) =>
em.create(TicketMessageAttachment, {
attachmentUrl: url,
ticketMessage,
}),
);
await em.persistAndFlush(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,
});
}
//***************************** */
}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/core";
import { TicketMessageAttachment } from "../entities/ticket-message-attachment";
export class TicketMessageAttachmentsRepository extends EntityRepository<TicketMessageAttachment> {}
@@ -0,0 +1,57 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
import { SearchTicketCategoryDto } from "../DTO/search-ticket-category.dto";
import { TicketCategory } from "../entities/ticket-category.entity";
export class TicketCategoryRepository extends EntityRepository<TicketCategory> {
//
async findCategoryByTitle(title: string): Promise<TicketCategory | null> {
return this.findOne({ title });
}
async findCategoryById(id: string): Promise<TicketCategory | null> {
return this.findOne({ id });
}
async findCategoriesList(queryDto: SearchTicketCategoryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const qb = this.createQueryBuilder("category")
.leftJoinAndSelect("category.group", "group")
.select(["category.*", "group.id", "group.name"]);
// Add ticket count
qb.addSelect(
this.em
.createQueryBuilder(TicketCategory, "c")
.count("t.id")
.leftJoin("c.tickets", "t")
.where("c.id = category.id")
.as("ticketsCount"),
);
// Add users count
qb.addSelect(
this.em
.createQueryBuilder(TicketCategory, "c")
.count("u.id")
.leftJoin("c.group", "g")
.leftJoin("g.users", "u")
.where("c.id = category.id")
.as("usersCount"),
);
if (queryDto.isActive !== undefined) {
qb.andWhere({ isActive: queryDto.isActive });
}
if (queryDto.q) {
qb.andWhere({ title: { $ilike: `%${queryDto.q}%` } });
}
const [items, total] = await qb.offset(skip).limit(limit).getResultAndCount();
return [items, total];
}
}
@@ -0,0 +1,30 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { TicketMessage } from "../entities/ticket-message.entity";
export class TicketMessagesRepository extends EntityRepository<TicketMessage> {
//
async findMessagesByTicketId(ticketId: string) {
return this.find(
{ ticket: { id: ticketId } },
{
populate: ["author", "author.role", "attachments"],
fields: [
"id",
"content",
"createdAt",
"updatedAt",
"ticket",
"author.id",
"author.firstName",
"author.lastName",
"author.role.id",
"author.role.name",
"attachments.id",
"attachments.attachmentUrl",
],
orderBy: { createdAt: "ASC" },
},
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { EntityRepository, FilterQuery } from "@mikro-orm/postgresql";
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";
export class TicketsRepository extends EntityRepository<Ticket> {
async findTicketById(id: string, userId?: string) {
return this.findOne(
{
id,
...(userId && { user: { id: userId } }),
},
{
populate: ["user", "category", "assignedTo", "company"],
fields: [
"id",
"numericId",
"subject",
"priority",
"status",
"createdAt",
"user.id",
"user.firstName",
"user.lastName",
"assignedTo.id",
"assignedTo.firstName",
"assignedTo.lastName",
"company.id",
"company.name",
"category.id",
"category.title",
],
},
);
}
//******************************** */
async findTicketsList(queryDto: SearchTicketQueryDto, userId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<Ticket> = { user: { id: userId } };
if (queryDto.status) {
where.status = queryDto.status;
} else {
where.status = { $ne: TicketStatus.CLOSED };
}
return this.findAndCount(where, {
populate: ["category", "company"],
fields: [
"id",
"subject",
"status",
"priority",
"numericId",
"createdAt",
"updatedAt",
"category.id",
"category.title",
"company.id",
"company.name",
],
orderBy: { createdAt: "DESC" },
limit,
offset: skip,
});
}
//******************************** */
async findTicketsListForAdmin(queryDto: SearchTicketQueryDto, adminId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<Ticket> = {};
where.assignedTo = { id: adminId };
if (queryDto.status) {
where.status = queryDto.status;
}
if (queryDto.userId) {
where.user = { id: queryDto.userId };
}
return this.findAndCount(where, {
populate: ["category", "user", "assignedTo", "company"],
fields: [
"id",
"subject",
"status",
"priority",
"numericId",
"createdAt",
"updatedAt",
"category.id",
"category.title",
"user.id",
"user.firstName",
"user.lastName",
"assignedTo.id",
"assignedTo.firstName",
"assignedTo.lastName",
"company.id",
"company.name",
],
orderBy: { createdAt: "DESC" },
limit,
offset: skip,
});
}
}
+118
View File
@@ -0,0 +1,118 @@
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 { 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 { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { RoleEnum } from "../users/enums/role.enum";
@Controller("tickets")
@ApiTags("Tickets")
@AuthGuards()
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
//----------------- ticket categories -------------------
@ApiOperation({ summary: "Create ticket category => admin route" })
@Post("category")
createTicketCategory(@Body() createDto: CreateTicketCategoryDto) {
return this.ticketsService.createTicketCategory(createDto);
}
@ApiOperation({ summary: "Update ticket category => admin route" })
@Patch("category/:id")
updateCategory(@Param() paramDto: ParamDto, @Body() updateCategoryDto: UpdateTicketCategoryDto) {
return this.ticketsService.updateCategory(paramDto, updateCategoryDto);
}
@ApiOperation({ summary: "Get ticket categories " })
@Get("categories")
//
getTicketCategories() {
return this.ticketsService.getTicketCategories();
}
@ApiOperation({ summary: "Get ticket categories list => admin route" })
@AdminRoute()
@Get("categories-list")
getCategoriesList(@Query() queryDto: SearchTicketCategoryDto) {
return this.ticketsService.getCategoriesList(queryDto);
}
@ApiOperation({ summary: "toggle status of categories => admin route" })
@AdminRoute()
@HttpCode(HttpStatus.OK)
@Post("category/toggle-status/:id")
toggleCategoryStatus(@Param() paramDto: ParamDto) {
return this.ticketsService.toggleCategoryStatus(paramDto);
}
//----------------- 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: "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()
@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("role") role: RoleEnum,
) {
return this.ticketsService.createTicketMessage(paramDto.id, createDto, userId, role);
}
@ApiOperation({ summary: "Get all ticket messages of user" })
@Get(":id/messages")
getTicketMessages(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @UserDec("role") role: RoleEnum) {
return this.ticketsService.getTicketMessages(paramDto.id, userId, role);
}
@ApiOperation({ summary: "close ticket by user or admin" })
@HttpCode(HttpStatus.OK)
@Post(":id/close")
closedTicketByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @UserDec("role") role: RoleEnum) {
return this.ticketsService.closeTicketByUser(paramDto.id, userId, role);
}
@ApiOperation({ summary: "open ticket by admin" })
@AdminRoute()
@Post(":id/open")
openTicketByAdmin(@Param() paramDto: ParamDto) {
return this.ticketsService.openTicketByAdmin(paramDto.id);
}
@ApiOperation({ summary: "referer ticket ==> admin route" })
@AdminRoute()
@Post(":id/refer")
referTicket(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @Body() referDto: ReferTicketDto) {
return this.ticketsService.referTicket(paramDto.id, userId, referDto);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { TicketCategory } from "./entities/ticket-category.entity";
import { TicketMessage } from "./entities/ticket-message.entity";
import { Ticket } from "./entities/ticket.entity";
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 { UsersModule } from "../users/users.module";
import { TicketMessageAttachment } from "./entities/ticket-message-attachment";
import { TicketMessageAttachmentsRepository } from "./repositories/ticket-message-attachment.repository";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationModule } from "../notifications/notifications.module";
@Module({
imports: [
MikroOrmModule.forFeature([Ticket, TicketCategory, TicketMessageAttachment, TicketMessage]),
UsersModule,
NotificationModule,
CompaniesModule,
],
providers: [TicketsService, TicketsRepository, TicketCategoryRepository, TicketMessagesRepository, TicketMessageAttachmentsRepository],
controllers: [TicketsController],
exports: [TicketsService],
})
export class TicketsModule {}
@@ -43,7 +43,46 @@ export class UsersService {
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return { user };
}
/*******************************/
async findOneByIdWithEntityManager(id: string, em: EntityManager) {
const user = await em.findOne(User, { id }, { populate: ["role"] });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return user;
}
/*******************************/
async findAdminWithLeastTickets() {
// // Find all admins with their assigned tickets
// const admins = await this.userRepository.find(
// { role: { name: RoleEnum.ADMIN } },
// { populate: ['role', 'assignedTickets'] }
// );
// if (!admins.length) return null;
// // Find the admin with the fewest assigned tickets
// return admins.reduce((minAdmin, admin) =>
// (minAdmin.assignedTickets.length <= admin.assignedTickets.length ? minAdmin : admin)
// );
const result = await this.em.getConnection().execute(
`
SELECT u.*
FROM "user" u
INNER JOIN "role" r ON u.role_id = r.id
WHERE r.name = ?
ORDER BY (
SELECT COUNT(*) FROM "ticket" t WHERE t.assigned_to_id = u.id
) ASC
LIMIT 1
`,
[RoleEnum.ADMIN],
);
// If you want a User entity, load it by id:
if (result.length === 0) return null;
return this.userRepository.findOne({ id: result[0].id }, { populate: ["role"] });
}
/*******************************/
async createUser(registerDto: CompleteRegistrationDto | CreateCompanyDto, hashedPassword: string, em: EntityManager) {
+2 -1
View File
@@ -1,12 +1,13 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { Role } from "./entities/role.entity";
import { User } from "./entities/user.entity";
import { UsersService } from "./services/users.service";
import { UsersController } from "./users.controller";
@Module({
imports: [MikroOrmModule.forFeature([User])],
imports: [MikroOrmModule.forFeature([User, Role])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],