diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5f29d0e..6bda8aa 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -601,4 +601,7 @@ export const enum BusinessMessage { DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد", DOMAIN_VERIFICATION_METHOD_INVALID = "روش تایید دامنه نامعتبر است", DOMAIN_ALREADY_VERIFIED = "این دامنه قبلا تایید شده است", + DOMAIN_REQUIRED = "دامنه مورد نیاز است", + DOMAIN_MUST_BE_STRING = "دامنه باید یک رشته باشد", + STAFF_NOT_FOUND = "کاربر ادمین یافت نشد", } diff --git a/src/main.ts b/src/main.ts index 8899319..61d2630 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { getSwaggerDocument } from "./configs/swagger.config"; import { HttpExceptionFilter } from "./core/filters/http-exception.filters"; import { PaginationInterceptor } from "./core/interceptors/pagination.interceptor"; import { ResponseInterceptor } from "./core/interceptors/response.interceptor"; + async function bootstrap() { const logger = new Logger("APP"); diff --git a/src/modules/businesses/DTO/business-domain.dto.ts b/src/modules/businesses/DTO/business-domain.dto.ts index 608b0b6..5692919 100644 --- a/src/modules/businesses/DTO/business-domain.dto.ts +++ b/src/modules/businesses/DTO/business-domain.dto.ts @@ -1,14 +1,11 @@ -import { IsNotEmpty, IsOptional, IsString, Matches } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, Matches } from "class-validator"; +import { BusinessMessage } from "../../../common/enums/message.enum"; export class UpdateBusinessDomainDto { - @IsNotEmpty() - @IsString() + @IsNotEmpty({ message: BusinessMessage.DOMAIN_REQUIRED }) + @IsString({ message: BusinessMessage.DOMAIN_MUST_BE_STRING }) @Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { message: "Domain must be a valid domain name (e.g., example.com)" }) + @ApiProperty({ description: "دامنه بیسنس", example: "example.com" }) domain: string; } - -export class VerifyDomainDto { - @IsOptional() - @IsString() - businessId?: string; -} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index be317f1..7dbb72d 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Get, Param, Post } from "@nestjs/common"; -import { ApiOperation } from "@nestjs/swagger"; +import { ApiHeader, ApiOperation } from "@nestjs/swagger"; import { UpdateBusinessDomainDto } from "./DTO/business-domain.dto"; import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto"; @@ -10,6 +10,7 @@ import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { BusinessDec } from "../../common/decorators/business.decorator"; @Controller("business") +@ApiHeader({ name: "x-business-id", description: "Business ID" }) export class BusinessesController { constructor( private readonly businessesService: BusinessesService, diff --git a/src/modules/businesses/entities/business-staff.entity.ts b/src/modules/businesses/entities/business-staff.entity.ts new file mode 100644 index 0000000..6edf829 --- /dev/null +++ b/src/modules/businesses/entities/business-staff.entity.ts @@ -0,0 +1,25 @@ +import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core"; + +import { Business } from "./business.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { BusinessStaffRepository } from "../repositories/business-staff.repository"; + +@Entity({ repository: () => BusinessStaffRepository }) +export class BusinessStaff extends BaseEntity { + @Property({ type: "uuid", nullable: false }) + danakUserId: string; + + @Property({ type: "varchar", length: 255, nullable: false }) + fullName: string; + + @Property({ type: "varchar", length: 255, nullable: false }) + email: string; + + @Property({ type: "varchar", length: 255, nullable: false }) + phone: string; + + @ManyToOne(() => Business, { deleteRule: "cascade", nullable: false }) + business: Business; + + [EntityRepositoryType]?: BusinessStaffRepository; +} diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts index 7a57a89..332b0aa 100644 --- a/src/modules/businesses/entities/business.entity.ts +++ b/src/modules/businesses/entities/business.entity.ts @@ -1,5 +1,6 @@ import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Opt, Property, Unique } from "@mikro-orm/core"; +import { BusinessStaff } from "./business-staff.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Announcement } from "../../announcements/entities/announcement.entity"; import { Company } from "../../companies/entities/company.entity"; @@ -67,5 +68,8 @@ export class Business extends BaseEntity { @OneToMany(() => User, (user) => user.business) users = new Collection(this); + @OneToMany(() => BusinessStaff, (businessStaff) => businessStaff.business) + businessStaffs = new Collection(this); + [EntityRepositoryType]?: BusinessRepository; } diff --git a/src/modules/businesses/queue/business-provisioning.processor.ts b/src/modules/businesses/queue/business-provisioning.processor.ts index 9234d53..898b96d 100644 --- a/src/modules/businesses/queue/business-provisioning.processor.ts +++ b/src/modules/businesses/queue/business-provisioning.processor.ts @@ -6,6 +6,7 @@ import { Job } from "bullmq"; import { WorkerProcessor } from "../../../common/queues/worker.processor"; import { SUBSCRIPTIONS } from "../constant"; +import { BusinessStaff } from "../entities/business-staff.entity"; import { Business } from "../entities/business.entity"; import { IBusinessProvisioningJob } from "../interfaces/IBusiness"; @@ -29,7 +30,7 @@ export class BusinessProvisioningProcessor extends WorkerProcessor { private async handleBusinessCreated(job: Job) { const em = this.em.fork(); - const { subscriptionId, serviceId, serviceName, businessName, slug } = job.data; + const { subscriptionId, serviceId, serviceName, businessName, slug, userId } = job.data; // Only act if the event is for this service if (serviceId !== SUBSCRIPTIONS.SERVICE_ID) { @@ -50,5 +51,20 @@ export class BusinessProvisioningProcessor extends WorkerProcessor { } else { this.logger.debug(`Business already exists for subscription ${subscriptionId}`); } + + const user = await em.findOne(BusinessStaff, { danakUserId: userId }); + if (!user) { + const user = em.create(BusinessStaff, { + danakUserId: userId, + fullName: `${businessName} Admin`, + email: `${slug}@${businessName}.com`, + phone: "09123456789", + business: business, + }); + await em.persistAndFlush(user); + this.logger.log(`Created business staff for subscription ${subscriptionId}`); + } else { + this.logger.debug(`Business staff already exists for subscription ${subscriptionId}`); + } } } diff --git a/src/modules/businesses/repositories/business-staff.repository.ts b/src/modules/businesses/repositories/business-staff.repository.ts new file mode 100644 index 0000000..a0f6299 --- /dev/null +++ b/src/modules/businesses/repositories/business-staff.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/core"; + +import { BusinessStaff } from "../entities/business-staff.entity"; + +export class BusinessStaffRepository extends EntityRepository {} diff --git a/src/modules/businesses/services/businesses.service.ts b/src/modules/businesses/services/businesses.service.ts index 4b77c69..3b4ffa0 100644 --- a/src/modules/businesses/services/businesses.service.ts +++ b/src/modules/businesses/services/businesses.service.ts @@ -1,11 +1,16 @@ +import { EntityManager } from "@mikro-orm/postgresql"; import { BadRequestException, Injectable } from "@nestjs/common"; import { BusinessMessage } from "../../../common/enums/message.enum"; +import { BusinessStaff } from "../entities/business-staff.entity"; import { BusinessRepository } from "../repositories/business.repository"; @Injectable() export class BusinessesService { - constructor(private readonly businessRepository: BusinessRepository) {} + constructor( + private readonly businessRepository: BusinessRepository, + private readonly em: EntityManager, + ) {} async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) { const business = await this.businessRepository.findOne({ danakSubscriptionId, deletedAt: null }); @@ -27,4 +32,32 @@ export class BusinessesService { return business; } + + /*******************************/ + + async findAdminWithLeastTickets() { + // First query to get the ID of the business staff with the least tickets + const result = await this.em.getConnection().execute( + ` + SELECT bs.id + FROM business_staff bs + WHERE bs.deleted_at IS NULL + ORDER BY ( + SELECT COUNT(*) FROM ticket t WHERE t.assigned_to_id = bs.id + ) ASC + LIMIT 1 + `, + ); + + if (result.length === 0) return null; + + return this.em.findOne(BusinessStaff, { id: result[0].id }); + } + + /*******************************/ + async findOneByIdWithEntityManager(staffId: string, em: EntityManager) { + const staff = await em.findOne(BusinessStaff, { id: staffId }); + if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND); + return staff; + } } diff --git a/src/modules/companies/companies.controller.ts b/src/modules/companies/companies.controller.ts index 7a1f6f9..c51c9b1 100644 --- a/src/modules/companies/companies.controller.ts +++ b/src/modules/companies/companies.controller.ts @@ -72,6 +72,13 @@ export class CompaniesController { return this.companiesService.toggleStatus(paramDto.id, businessId); } + @Get("user") + @AuthGuards() + @ApiOperation({ summary: "get user companies" }) + getUserCompany(@UserDec("id") userId: string, @BusinessDec("id") businessId: string) { + return this.companiesService.getUserCompany(userId, businessId); + } + @Post("requests") @AuthGuards() @ApiOperation({ summary: "submit company registration request (user)" }) diff --git a/src/modules/companies/services/companies.service.ts b/src/modules/companies/services/companies.service.ts index 6c6f843..b8815cb 100644 --- a/src/modules/companies/services/companies.service.ts +++ b/src/modules/companies/services/companies.service.ts @@ -171,7 +171,7 @@ export class CompaniesService { { id, deletedAt: null, isActive: true, status: CompanyStatus.APPROVED, business: { id: businessId } }, { populate: ["industry", "user", "products", "services"], - fields: ["*", "industry.id", "industry.title", "user.id"], + fields: ["*", "industry.id", "industry.title", "user.id", "user.firstName", "user.lastName", "user.phone", "user.email"], }, ); if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); @@ -222,6 +222,20 @@ export class CompaniesService { } //----------------------------------- + async getUserCompany(userId: string, businessId: string) { + const company = await this.companyRepository.findOne( + { user: { id: userId }, business: { id: businessId }, deletedAt: null }, + { + populate: ["industry", "user", "products", "services"], + fields: ["*", "industry.id", "industry.title", "user.id", "user.firstName", "user.lastName", "user.phone", "user.email"], + }, + ); + if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); + return { company }; + } + + //----------------------------------- + async findOneByIdWithEntityManager(id: string, em: EntityManager) { const company = await em.findOne(Company, { id, deletedAt: null }, { populate: ["user"] }); if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); @@ -288,10 +302,13 @@ export class CompaniesService { if (role === RoleEnum.USER) { request = await this.companyRequestRepository.findOne( { id, business: { id: businessId }, user: { id: userId }, deletedAt: null }, - { populate: ["company"] }, + { populate: ["company", "company.services", "company.products", "user"] }, ); } else { - request = await this.companyRequestRepository.findOne({ id, business: { id: businessId }, deletedAt: null }, { populate: ["company"] }); + request = await this.companyRequestRepository.findOne( + { id, business: { id: businessId }, deletedAt: null }, + { populate: ["company", "company.services", "company.products", "business", "user"] }, + ); } if (!request) throw new BadRequestException(CompanyMessage.COMPANY_REQUEST_NOT_FOUND); @@ -303,7 +320,7 @@ export class CompaniesService { async getCompanyRequestsUser(userId: string, businessId: string) { const requests = await this.companyRequestRepository.findOne( { user: { id: userId }, business: { id: businessId }, deletedAt: null }, - { populate: ["company"], orderBy: { createdAt: "DESC" } }, + { populate: ["company", "company.services", "company.products", "user"], orderBy: { createdAt: "DESC" } }, ); return { requests }; } diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index 86e0edc..4e562f8 100755 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -4,7 +4,6 @@ import { ApiOperation } from "@nestjs/swagger"; import { SearchNotificationQueryDto } from "./DTO/search-notification-query.dto"; import { NotificationsService } from "./providers/notifications.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; -import { Pagination } from "../../common/decorators/pagination.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; @@ -16,7 +15,6 @@ export class NotificationController { //********************* */ @ApiOperation({ summary: "all notifications by user" }) - @Pagination() @Get() getAllNotifications(@UserDec("id") userId: string, @Query() queryDto: SearchNotificationQueryDto) { return this.notificationService.getAllNotifications(queryDto, userId); diff --git a/src/modules/notifications/providers/notifications.service.ts b/src/modules/notifications/providers/notifications.service.ts index 34fcdc2..0d8c386 100755 --- a/src/modules/notifications/providers/notifications.service.ts +++ b/src/modules/notifications/providers/notifications.service.ts @@ -83,7 +83,8 @@ export class NotificationsService { //************************ */ async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) { - return await this.notificationRepository.getAllNotifications(queryDto, recipientId); + const [notifications, count] = await this.notificationRepository.getAllNotifications(queryDto, recipientId); + return { notifications, count, paginate: true }; } //************************ */ diff --git a/src/modules/notifications/repositories/notifications.repository.ts b/src/modules/notifications/repositories/notifications.repository.ts index 6577ea8..c0ea9c6 100755 --- a/src/modules/notifications/repositories/notifications.repository.ts +++ b/src/modules/notifications/repositories/notifications.repository.ts @@ -8,7 +8,7 @@ export class NotificationRepository extends EntityRepository { async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) { const { skip, limit } = PaginationUtils(queryDto); - const qb = this.qb("notification").where({ recipientId }); + const qb = this.qb("notification").where({ recipient: { id: recipientId } }); if (queryDto.isRead !== undefined) { qb.andWhere({ isRead: queryDto.isRead === 1 }); diff --git a/src/modules/tickets/entities/ticket-message.entity.ts b/src/modules/tickets/entities/ticket-message.entity.ts index ffb0f06..3392023 100755 --- a/src/modules/tickets/entities/ticket-message.entity.ts +++ b/src/modules/tickets/entities/ticket-message.entity.ts @@ -3,6 +3,7 @@ import { Cascade, Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany import { TicketMessageAttachment } from "./ticket-message-attachment"; import { Ticket } from "./ticket.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { BusinessStaff } from "../../businesses/entities/business-staff.entity"; import { User } from "../../users/entities/user.entity"; import { TicketMessagesRepository } from "../repositories/tickets-message.repository"; @@ -18,7 +19,10 @@ export class TicketMessage extends BaseEntity { ticket!: Ticket; @ManyToOne(() => User, { deleteRule: "cascade", nullable: true }) - author!: User | null; + author?: User | null; + + @ManyToOne(() => BusinessStaff, { deleteRule: "cascade", nullable: true }) + externalAuthor?: BusinessStaff | null; [EntityRepositoryType]?: TicketMessagesRepository; } diff --git a/src/modules/tickets/entities/ticket.entity.ts b/src/modules/tickets/entities/ticket.entity.ts index 651f0df..37f3a92 100755 --- a/src/modules/tickets/entities/ticket.entity.ts +++ b/src/modules/tickets/entities/ticket.entity.ts @@ -3,6 +3,7 @@ import { Cascade, Collection, Entity, EntityRepositoryType, Enum, ManyToOne, One import { TicketCategory } from "./ticket-category.entity"; import { TicketMessage } from "./ticket-message.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { BusinessStaff } from "../../businesses/entities/business-staff.entity"; import { Business } from "../../businesses/entities/business.entity"; import { Company } from "../../companies/entities/company.entity"; import { User } from "../../users/entities/user.entity"; @@ -27,8 +28,8 @@ export class Ticket extends BaseEntity { @ManyToOne(() => Company, { deleteRule: "set null", nullable: true }) company?: Company; - @ManyToOne(() => User, { deleteRule: "set null", nullable: true }) - assignedTo?: User; + @ManyToOne(() => BusinessStaff, { deleteRule: "set null", nullable: true }) + assignedTo?: BusinessStaff; @ManyToOne(() => User, { deleteRule: "cascade", nullable: false }) user!: User; diff --git a/src/modules/tickets/providers/tickets.service.ts b/src/modules/tickets/providers/tickets.service.ts index 7f94b3e..7de97af 100755 --- a/src/modules/tickets/providers/tickets.service.ts +++ b/src/modules/tickets/providers/tickets.service.ts @@ -2,8 +2,10 @@ 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 { BusinessMessage, CommonMessage, CompanyMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum"; +import { BusinessStaff } from "../../businesses/entities/business-staff.entity"; import { Business } from "../../businesses/entities/business.entity"; +import { BusinessesService } from "../../businesses/services/businesses.service"; import { Company } from "../../companies/entities/company.entity"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { User } from "../../users/entities/user.entity"; @@ -34,6 +36,7 @@ export class TicketsService { private readonly ticketsCategoryRepository: TicketCategoryRepository, private readonly ticketMessagesRepository: TicketMessagesRepository, private readonly usersService: UsersService, + private readonly businessesService: BusinessesService, private readonly notificationQueue: NotificationQueue, private readonly em: EntityManager, ) {} @@ -209,14 +212,20 @@ export class TicketsService { // if (role !== RoleEnum.ADMIN && ticket.assignedTo?.id !== userId) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER); let user = null; + let externalAuthor = null; if (role === RoleEnum.USER) { user = await em.findOne(User, { id: userId }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + } else { + externalAuthor = await em.findOne(BusinessStaff, { id: userId }); + if (!externalAuthor) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND); + await this.sendNotificationForAdminResponse(ticket, externalAuthor); } const ticketMessage = em.create(TicketMessage, { content: createDto.content, author: user, + externalAuthor, ticket, }); await em.persistAndFlush(ticketMessage); @@ -228,13 +237,6 @@ export class TicketsService { this.updateTicketStatus(ticket, role === RoleEnum.ADMIN); await em.persistAndFlush(ticket); - if (role !== RoleEnum.USER) { - //TODO Fix this - console.log(user, "user"); - // await this.sendNotificationForAdminResponse(ticket, user); - console.log(this.sendNotificationForAdminResponse, "sendNotificationForAdminResponse"); - } - await em.commit(); return { message: TicketMessageEnum.MESSAGE_CREATED, @@ -310,7 +312,7 @@ export class TicketsService { const ticket = await this.ticketsRepository.findTicketById(ticketId, businessId); if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); - const admin = await this.usersService.findOneByIdWithEntityManager(referDto.adminId, em); + const admin = await this.businessesService.findOneByIdWithEntityManager(referDto.adminId, em); ticket.assignedTo = admin; await em.flush(); @@ -355,7 +357,7 @@ export class TicketsService { //***************************** */ private async assignTicketAndNotify(ticket: Ticket, user: User, em: EntityManager) { - const assignedUser = await this.usersService.findAdminWithLeastTickets(); + const assignedUser = await this.businessesService.findAdminWithLeastTickets(); // if (assignedUser) { @@ -412,7 +414,7 @@ export class TicketsService { } //***************************** */ - private async sendNotificationForAdminResponse(ticket: Ticket, admin: User) { + private async sendNotificationForAdminResponse(ticket: Ticket, admin: BusinessStaff) { await this.notificationQueue.addAnswerTicketNotification(ticket.user.id, { ticketId: ticket.numericId.toString(), subject: ticket.subject, @@ -421,6 +423,4 @@ export class TicketsService { userEmail: admin.email, }); } - - //***************************** */ } diff --git a/src/modules/tickets/repositories/tickets.repository.ts b/src/modules/tickets/repositories/tickets.repository.ts index 9565207..6345b44 100755 --- a/src/modules/tickets/repositories/tickets.repository.ts +++ b/src/modules/tickets/repositories/tickets.repository.ts @@ -26,8 +26,7 @@ export class TicketsRepository extends EntityRepository { "user.firstName", "user.lastName", "assignedTo.id", - "assignedTo.firstName", - "assignedTo.lastName", + "assignedTo.fullName", "company.id", "company.name", "category.id", @@ -97,8 +96,7 @@ export class TicketsRepository extends EntityRepository { "user.firstName", "user.lastName", "assignedTo.id", - "assignedTo.firstName", - "assignedTo.lastName", + "assignedTo.fullName", "company.id", "company.name", ], diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index ffb1310..5f9e10d 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -50,39 +50,7 @@ export class UsersService { 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, business: Business, em: EntityManager) {