diff --git a/keys/public.pem b/keys/public.pem new file mode 100644 index 0000000..6120574 --- /dev/null +++ b/keys/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm/oCsqFA8hF9TMv4UX5E +IeDv1KXzI+KFIafZ+gU1IWgEmWP16ee8Vw1+j7Ixl9Dd7Uxc9DhcWRB6ZDwqCYb3 +S3z5zabOIka7wNNBpTEmRKrmV/EzihAw+E96Ksct0PHeN2YFydPimMndBD6VoRWi +WShQD4FklIQKqBYrua0NkuIkFshYLzC3rAbcT8GkLkcbHKrKIlyeucCLwym2nd5U +coI+fKqDB1/3JYEKJRaZgFqthokeFBN4SV9tMaLzeJDk3bNoTAxRtPbtuJ8mVTL9 +P0phzjorGxTxV7SbANtmB3Trm6oIXeCqY40Auy+ZTUEVr+hD4Xxs8TMvjuT8vrMe +VQIDAQAB +-----END PUBLIC KEY----- diff --git a/src/app.module.ts b/src/app.module.ts index 0a21650..11f5936 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { mailerConfig } from "./configs/mailer.config"; import { databaseConfig } from "./configs/mikro-orm.config"; import { rateLimitConfig } from "./configs/rateLimit.config"; import { HTTPLogger } from "./core/middlewares/logger.middleware"; +import { AnnouncementsModule } from "./modules/announcements/announcement.module"; import { AuthModule } from "./modules/auth/auth.module"; import { BusinessesModule } from "./modules/businesses/businesses.module"; import { CompaniesModule } from "./modules/companies/companies.module"; @@ -53,6 +54,7 @@ import { UtilsModule } from "./modules/utils/utils.module"; TicketsModule, InvoicesModule, BusinessesModule, + AnnouncementsModule, ], // providers: [ // { diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index 4d775ff..8b8b7dd 100755 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -3,4 +3,6 @@ export const AUTH_THROTTLE_TTL = 5 * 60 * 1000; export const AUTH_THROTTLE_LIMIT = 5; export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000; export const AUTH__REFRESH_THROTTLE_LIMIT = 10; -export const JWT_STRATEGY_NAME = "jwt_Strategy"; +// +export const CONSOLE_JWT_STRATEGY_NAME = "console_jwt_strategy"; +export const LOCAL_JWT_STRATEGY_NAME = "local_jwt_strategy"; diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5e50805..1d37fcf 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -236,6 +236,8 @@ export const enum AnnouncementMessage { PUBLISH_AT_CANNOT_BE_IN_PAST = "تاریخ انتشار نمی‌تواند در گذشته باشد", USER_ANNOUNCEMENTS_DELETED = "اطلاعیه‌های کاربر با موفقیت حذف شدند", QUEUE_JOBS_DELETED = "وظایف صف با موفقیت حذف شدند", + COMPANY_MUST_BE_ID = "شناسه شرکت اجباری است", + COMPANY_MUST_BE_UUID = "شناسه شرکت باید یک UUID معتبر باشد", } export const enum CriticismMessage { @@ -293,6 +295,8 @@ export const enum TicketMessageEnum { INVALID_TICKET_LIMIT = "محدودیت تعداد تیکت در پلن پشتیبانی شما نامعتبر است", TICKET_LIMIT_EXCEEDED = "شما به محدودیت تعداد تیکت در پلن پشتیبانی خود رسیده‌اید", OPENED = "تیکت با موفقیت باز شد", + CATEGORY_HAS_TICKETS = "دسته بندی دارای تیکت می باشد", + CATEGORY_DELETED = "دسته بندی با موفقیت حذف شد", } export const enum WalletMessage { diff --git a/src/configs/mailer.config.ts b/src/configs/mailer.config.ts index d51a2c3..81ca9cc 100755 --- a/src/configs/mailer.config.ts +++ b/src/configs/mailer.config.ts @@ -20,7 +20,7 @@ export function mailerConfig(): MailerAsyncOptions { }, template: { - dir: process.cwd() + "/src/templates/email", + dir: process.cwd() + "/src/modules/templates/email", adapter: new HandlebarsAdapter(), options: { strict: true, diff --git a/src/modules/announcements/DTO/create-announcement.dto.ts b/src/modules/announcements/DTO/create-announcement.dto.ts new file mode 100755 index 0000000..ce64287 --- /dev/null +++ b/src/modules/announcements/DTO/create-announcement.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean, IsDateString, IsNotEmpty, IsOptional, IsString, Length, MinLength } from "class-validator"; + +import { AnnouncementMessage } from "../../../common/enums/message.enum"; + +export class CreateAnnouncementDto { + @IsNotEmpty({ message: AnnouncementMessage.TITLE_IS_REQUIRED }) + @IsString({ message: AnnouncementMessage.TITLE_STRING }) + @Length(5, 100, { message: AnnouncementMessage.TITLE_LENGTH }) + @ApiProperty({ description: "Title of the announcement", example: "اطلاعیه جدید" }) + title: string; + + @IsNotEmpty({ message: AnnouncementMessage.CONTENT_IS_REQUIRED }) + @IsString({ message: AnnouncementMessage.CONTENT_IS_STRING }) + @MinLength(10, { message: AnnouncementMessage.CONTENT_MUST_BE_LONGER }) + @ApiProperty({ description: "Content of the announcement", example: "اطلاعیه جدید برای کاربران" }) + content: string; + + @IsOptional() + @IsNotEmpty({ message: AnnouncementMessage.PUBLISH_AT_IS_REQUIRED }) + @IsDateString({}, { message: AnnouncementMessage.PUBLISH_AT_MUST_BE_DATE }) + @ApiProperty({ description: "Publish date of the announcement", example: "2023-10-01T10:00:00Z" }) + publishAt: string; + + @IsNotEmpty({ message: AnnouncementMessage.IMPORTANT_IS_REQUIRED }) + @IsBoolean({ message: AnnouncementMessage.IMPORTANT_MUST_BE_BOOLEAN }) + @ApiProperty({ description: "Is this announcement important?", example: false }) + isImportant: boolean; +} diff --git a/src/modules/announcements/DTO/search-announcement-query.dto.ts b/src/modules/announcements/DTO/search-announcement-query.dto.ts new file mode 100755 index 0000000..8f6c42a --- /dev/null +++ b/src/modules/announcements/DTO/search-announcement-query.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsDateString, IsOptional, IsString } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { CriticismMessage } from "../../../common/enums/message.enum"; + +export class SearchAnnouncementQueryDto extends PaginationDto { + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: "since query", example: "2025-01-27" }) + since?: string; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: "publishAt query", example: "2025-01-27" }) + publishAt?: string; + + @IsOptional() + @IsString({ message: CriticismMessage.SEARCH_QUERY_STRING }) + @ApiPropertyOptional({ description: "Search query", example: "search query" }) + q?: string; +} diff --git a/src/modules/announcements/DTO/update-announcement.dto.ts b/src/modules/announcements/DTO/update-announcement.dto.ts new file mode 100755 index 0000000..b5f0579 --- /dev/null +++ b/src/modules/announcements/DTO/update-announcement.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateAnnouncementDto } from "./create-announcement.dto"; + +export class UpdateAnnouncementDto extends PartialType(CreateAnnouncementDto) {} diff --git a/src/modules/announcements/announcement.controller.ts b/src/modules/announcements/announcement.controller.ts new file mode 100755 index 0000000..1a800cf --- /dev/null +++ b/src/modules/announcements/announcement.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common"; +import { ApiProperty } from "@nestjs/swagger"; + +import { CreateAnnouncementDto } from "./DTO/create-announcement.dto"; +import { SearchAnnouncementQueryDto } from "./DTO/search-announcement-query.dto"; +import { UpdateAnnouncementDto } from "./DTO/update-announcement.dto"; +import { AnnouncementService } from "./providers/announcement.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { BusinessDec } from "../../common/decorators/business.decorator"; +import { UserDec } from "../../common/decorators/user.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; +import { Business } from "../businesses/entities/business.entity"; + +@Controller("announcements") +@UseInterceptors(BusinessInterceptor) +@AuthGuards() +export class AnnouncementController { + constructor(private readonly announcementService: AnnouncementService) {} + + @ApiProperty({ description: "Create a new announcement (admin route)" }) + @Post() + create(@Body() createAnnouncementDto: CreateAnnouncementDto, @BusinessDec() business: Business) { + return this.announcementService.createAnnouncement(createAnnouncementDto, business); + } + + @ApiProperty({ description: "Update an announcement (admin route)" }) + @Patch(":id") + update(@Param() paramDto: ParamDto, @Body() updateAnnouncementDto: UpdateAnnouncementDto, @BusinessDec("id") businessId: string) { + return this.announcementService.updateAnnouncement(paramDto.id, updateAnnouncementDto, businessId); + } + + @ApiProperty({ description: "Delete an announcement (admin route)" }) + @Delete(":id") + delete(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.announcementService.deleteAnnouncement(paramDto.id, businessId); + } + + @ApiProperty({ description: "Get all announcements (admin route)" }) + @Get() + getAnnouncements(@Query() queryDto: SearchAnnouncementQueryDto, @BusinessDec("id") businessId: string) { + return this.announcementService.getAnnouncements(queryDto, businessId); + } + + @ApiProperty({ description: "Get all announcements for user " }) + @Get("user") + getAnnouncementsByUser( + @Query() queryDto: SearchAnnouncementQueryDto, + @UserDec("id") userId: string, + @BusinessDec("id") businessId: string, + ) { + return this.announcementService.getAnnouncementsByUser(queryDto, userId, businessId); + } + + @ApiProperty({ description: "Get one announcements with id " }) + @Get(":id") + getAnnouncementById(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) { + return this.announcementService.getAnnouncementById(paramDto.id, userId, businessId); + } + + @ApiProperty({ description: "Get one announcements with id (admin route)" }) + @Get(":id/admin") + getAnnouncementByIdAdmin(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.announcementService.getAnnouncementByIdAdmin(paramDto.id, businessId); + } +} diff --git a/src/modules/announcements/announcement.module.ts b/src/modules/announcements/announcement.module.ts new file mode 100755 index 0000000..bbb4902 --- /dev/null +++ b/src/modules/announcements/announcement.module.ts @@ -0,0 +1,29 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; + +import { AnnouncementController } from "./announcement.controller"; +import { ANNOUNCEMENT } from "./constants"; +import { Announcement } from "./entities/announcement.entity"; +import { AnnouncementService } from "./providers/announcement.service"; +import { AnnouncementProcessor } from "./queue/announcment.processor"; +import { UsersModule } from "../users/users.module"; +import { UserAnnouncement } from "./entities/user-announcement.entity"; +import { BusinessesModule } from "../businesses/businesses.module"; +import { CompaniesModule } from "../companies/companies.module"; +import { NotificationModule } from "../notifications/notifications.module"; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Announcement, UserAnnouncement]), + BullModule.registerQueue({ name: ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME }), + UsersModule, + NotificationModule, + CompaniesModule, + BusinessesModule, + ], + providers: [AnnouncementService, AnnouncementProcessor], + controllers: [AnnouncementController], + exports: [AnnouncementService], +}) +export class AnnouncementsModule {} diff --git a/src/modules/announcements/constants/index.ts b/src/modules/announcements/constants/index.ts new file mode 100755 index 0000000..e415fe7 --- /dev/null +++ b/src/modules/announcements/constants/index.ts @@ -0,0 +1,12 @@ +export const ANNOUNCEMENT = Object.freeze({ + ANNOUNCEMENT_QUEUE_NAME: "announcement", + ANNOUNCEMENT_SEND_JOB_NAME: "sendAnnouncement", + ANNOUNCEMENT_PUBLISH_JOB_NAME: "publishAnnouncement", + // + ANNOUNCEMENT_SEND_JOB_PRIORITY: 1, + ANNOUNCEMENT_SEND_JOB_ATTEMPTS: 3, + ANNOUNCEMENT_SEND_JOB_BACKOFF: 5 * 1000, + ANNOUNCEMENT_SEND_JOB_TIMEOUT: 10000, + // + ANNOUNCEMENT_PUBLISH_JOB_PRIORITY: 1, +}); diff --git a/src/modules/announcements/entities/announcement.entity.ts b/src/modules/announcements/entities/announcement.entity.ts new file mode 100755 index 0000000..4eb9d71 --- /dev/null +++ b/src/modules/announcements/entities/announcement.entity.ts @@ -0,0 +1,33 @@ +import { Cascade, Collection, Entity, EntityRepositoryType, Index, ManyToOne, OneToMany, Property } from "@mikro-orm/core"; + +import { UserAnnouncement } from "./user-announcement.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { Business } from "../../businesses/entities/business.entity"; +import { AnnouncementRepository } from "../repositories/announcement.repository"; + +@Entity({ repository: () => AnnouncementRepository }) +@Index({ properties: ["isPublic", "createdAt"] }) +export class Announcement extends BaseEntity { + @Property({ type: "varchar", length: 100 }) + title!: string; + + @Property({ type: "text" }) + content!: string; + + @Property({ type: "timestamptz", nullable: true, default: null }) + publishAt?: Date; + + @Property({ type: "boolean", default: false }) + isImportant!: boolean; + + @ManyToOne(() => Business, { deleteRule: "cascade", nullable: false }) + business!: Business; + + @OneToMany(() => UserAnnouncement, (userAnnouncement) => userAnnouncement.announcement, { cascade: [Cascade.ALL] }) + userAnnouncements = new Collection(this); + + @Property({ type: "boolean", default: false }) + isPublic!: boolean; + + [EntityRepositoryType]?: AnnouncementRepository; +} diff --git a/src/modules/announcements/entities/user-announcement.entity.ts b/src/modules/announcements/entities/user-announcement.entity.ts new file mode 100755 index 0000000..d00371f --- /dev/null +++ b/src/modules/announcements/entities/user-announcement.entity.ts @@ -0,0 +1,20 @@ +import { Entity, EntityRepositoryType, ManyToOne, Opt, Property } from "@mikro-orm/core"; + +import { Announcement } from "./announcement.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; +import { UserAnnouncementRepository } from "../repositories/user-announcement.repository"; + +@Entity({ repository: () => UserAnnouncementRepository }) +export class UserAnnouncement extends BaseEntity { + @ManyToOne(() => User, { deleteRule: "cascade", nullable: false }) + user!: User; + + @ManyToOne(() => Announcement, { deleteRule: "cascade", nullable: false }) + announcement!: Announcement; + + @Property({ type: "boolean", default: false }) + isRead!: boolean & Opt; + + [EntityRepositoryType]?: UserAnnouncementRepository; +} diff --git a/src/modules/announcements/interfaces/ISendAnnouncement.ts b/src/modules/announcements/interfaces/ISendAnnouncement.ts new file mode 100755 index 0000000..1217c6c --- /dev/null +++ b/src/modules/announcements/interfaces/ISendAnnouncement.ts @@ -0,0 +1,4 @@ +export interface ISendAnnouncement { + announcementId: string; + isPublic: boolean; +} diff --git a/src/modules/announcements/providers/announcement.service.ts b/src/modules/announcements/providers/announcement.service.ts new file mode 100755 index 0000000..ab578bb --- /dev/null +++ b/src/modules/announcements/providers/announcement.service.ts @@ -0,0 +1,235 @@ +import { EntityManager, FilterQuery } from "@mikro-orm/postgresql"; +import { InjectQueue } from "@nestjs/bullmq"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { Queue } from "bullmq"; +import dayjs from "dayjs"; + +import { AnnouncementMessage } from "../../../common/enums/message.enum"; +import { Business } from "../../businesses/entities/business.entity"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { ANNOUNCEMENT } from "../constants"; +import { CreateAnnouncementDto } from "../DTO/create-announcement.dto"; +import { SearchAnnouncementQueryDto } from "../DTO/search-announcement-query.dto"; +import { UpdateAnnouncementDto } from "../DTO/update-announcement.dto"; +import { Announcement } from "../entities/announcement.entity"; +import { UserAnnouncement } from "../entities/user-announcement.entity"; +import { AnnouncementRepository } from "../repositories/announcement.repository"; +import { UserAnnouncementRepository } from "../repositories/user-announcement.repository"; + +@Injectable() +export class AnnouncementService { + constructor( + @InjectQueue(ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME) private readonly announcementQueue: Queue, + private readonly announcementRepository: AnnouncementRepository, + private readonly userAnnouncementRepository: UserAnnouncementRepository, + private readonly em: EntityManager, + ) {} + + /***************************************** */ + + async createAnnouncement(createAnnouncementDto: CreateAnnouncementDto, business: Business) { + const isPublic = true; + + const announcement = this.announcementRepository.create({ + ...createAnnouncementDto, + business, + isPublic: true, + }); + + if (!announcement.publishAt) { + announcement.publishAt = dayjs().toDate(); + } + + if (dayjs(announcement.publishAt).isBefore(dayjs().subtract(1, "day"))) + throw new BadRequestException(AnnouncementMessage.PUBLISH_AT_MUST_BE_FUTURE_DATE); + + await this.em.persistAndFlush(announcement); + + await this.announcementQueue.add( + ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME, + { + announcementId: announcement.id, + isPublic, + }, + { + delay: dayjs(announcement.publishAt).diff(dayjs(), "millisecond"), + attempts: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_BACKOFF }, + }, + ); + + return { + message: AnnouncementMessage.CREATED_AND_SENT_TO_USERS_AFTER_PUBLISH, + announcement, + }; + } + /***************************************** */ + + async getAnnouncements(queryDto: SearchAnnouncementQueryDto, businessId: string) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.announcementRepository.createQueryBuilder("announcement"); + + queryBuilder.andWhere({ business: { id: businessId } }); + + if (queryDto.since) { + queryBuilder.andWhere({ createdAt: { $gte: dayjs(queryDto.since).toDate() } }); + } + + if (queryDto.publishAt) { + queryBuilder.andWhere({ publishAt: { $gte: dayjs(queryDto.publishAt).toDate() } }); + } + + if (queryDto.q) { + queryBuilder.andWhere({ title: { $ilike: `%${queryDto.q}%` } }); + } + + queryBuilder.orderBy({ createdAt: "DESC" }); + + const [announcements, count] = await queryBuilder.offset(skip).limit(limit).getResultAndCount(); + + return { announcements, count, paginate: true }; + } + + /***************************************** */ + + async getAnnouncementsByUser(queryDto: SearchAnnouncementQueryDto, userId: string, businessId: string) { + const { limit, skip } = PaginationUtils(queryDto); + + const where: FilterQuery = { + user: { id: userId }, + }; + + const announcementConditions: FilterQuery = { + business: { id: businessId }, + }; + + if (queryDto.since) { + announcementConditions.createdAt = { $gte: dayjs(queryDto.since).toDate() }; + } + + if (queryDto.publishAt) { + announcementConditions.publishAt = { $gte: dayjs(queryDto.publishAt).toDate() }; + } + + if (queryDto.q) { + announcementConditions.title = { $ilike: `%${queryDto.q}%` }; + } + + where.announcement = announcementConditions; + + const [announcements, count] = await this.userAnnouncementRepository.findAndCount(where, { + populate: ["announcement", "user"], + limit, + offset: skip, + orderBy: { createdAt: "DESC" }, + fields: [ + "id", + "isRead", + "createdAt", + "user.id", + "user.firstName", + "user.lastName", + "announcement.id", + "announcement.title", + "announcement.isImportant", + "announcement.createdAt", + "announcement.publishAt", + "announcement.content", + ], + }); + + return { announcements, count, paginate: true }; + } + + /***************************************** */ + + async getAnnouncementById(id: string, userId: string, businessId: string) { + const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + const userAnnouncement = await this.userAnnouncementRepository.findOne({ announcement: { id }, user: { id: userId } }); + if (!userAnnouncement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + userAnnouncement.isRead = true; + await this.em.flush(); + return { announcement }; + } + + /***************************************** */ + async getAnnouncementByIdAdmin(id: string, businessId: string) { + const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + // + return { + announcement, + }; + } + + //************************************ */ + async countUserAnnouncements(userId: string, businessId: string) { + return this.userAnnouncementRepository.count({ user: { id: userId }, announcement: { business: { id: businessId } }, isRead: false }); + } + //************************************ */ + + async updateAnnouncement(id: string, updateAnnouncementDto: UpdateAnnouncementDto, businessId: string) { + const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + if (updateAnnouncementDto.publishAt) { + if (dayjs(updateAnnouncementDto.publishAt).isBefore(dayjs().subtract(1, "day"))) { + throw new BadRequestException(AnnouncementMessage.PUBLISH_AT_MUST_BE_FUTURE_DATE); + } + } + + if (updateAnnouncementDto.publishAt) { + const jobs = await this.announcementQueue.getJobs(); + const existingJob = jobs.find((job) => job.data.announcementId === id); + + // + if (existingJob) await existingJob.remove(); + + await this.announcementQueue.add( + ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME, + { + announcementId: announcement.id, + isPublic: announcement.isPublic, + }, + { + delay: dayjs(announcement.publishAt).diff(dayjs(), "millisecond"), + attempts: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_BACKOFF }, + }, + ); + } + + this.announcementRepository.assign(announcement, updateAnnouncementDto); + + await this.em.flush(); + + return { + message: AnnouncementMessage.UPDATED_SUCCESSFULLY, + announcement, + }; + } + + //************************************ */ + + async deleteAnnouncement(id: string, businessId: string) { + const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + const jobs = await this.announcementQueue.getJobs(); + const existingJob = jobs.find((job) => job.data.announcementId === id); + if (existingJob) await existingJob.remove(); + + await this.userAnnouncementRepository.nativeDelete({ announcement: { id } }); + + await this.announcementRepository.nativeDelete({ id, business: { id: businessId } }); + await this.em.flush(); + + return { + message: AnnouncementMessage.DELETED_SUCCESSFULLY, + }; + } +} diff --git a/src/modules/announcements/queue/announcment.processor.ts b/src/modules/announcements/queue/announcment.processor.ts new file mode 100755 index 0000000..566b4f6 --- /dev/null +++ b/src/modules/announcements/queue/announcment.processor.ts @@ -0,0 +1,107 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { Processor } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job } from "bullmq"; + +import { WorkerProcessor } from "../../../common/queues/worker.processor"; +import { IAnnouncementNotificationData } from "../../notifications/interfaces/ISendNotificationData"; +import { NotificationQueue } from "../../notifications/queue/notification.queue"; +import { User } from "../../users/entities/user.entity"; +import { UsersService } from "../../users/services/users.service"; +import { ANNOUNCEMENT } from "../constants"; +import { Announcement } from "../entities/announcement.entity"; +import { UserAnnouncement } from "../entities/user-announcement.entity"; +import { ISendAnnouncement } from "../interfaces/ISendAnnouncement"; + +@Processor(ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME) +export class AnnouncementProcessor extends WorkerProcessor { + protected readonly logger = new Logger(AnnouncementProcessor.name); + constructor( + private readonly em: EntityManager, + private readonly notificationQueue: NotificationQueue, + private readonly usersService: UsersService, + ) { + super(); + } + + async process(job: Job, token?: string) { + switch (job.name) { + case ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME: + return await this.sendAnnouncement(job, token); + case ANNOUNCEMENT.ANNOUNCEMENT_PUBLISH_JOB_NAME: + return this.publishAnnouncement(job, token); + default: + this.logger.error(`Unknown job name: ${job.name}`); + return; + } + } + //********************************** */ + private async sendAnnouncement(job: Job, token?: string) { + this.logger.log(`Sending announcement: ${job.data.announcementId} to users. ${token}`); + + const em = this.em.fork(); + + const data = job.data; + + try { + await em.begin(); + // Create a query builder to fetch unique users + const userQueryBuilder = em.createQueryBuilder(User, "u"); + userQueryBuilder.select("u.id").distinct(); + + // Execute the query to get distinct user IDs + const userResult = await userQueryBuilder.execute(); + const userIds = userResult.map((row: { id: string }) => row.id); + + if (userIds.length === 0) { + this.logger.error(`No users found`); + await em.rollback(); + return; + } + + const announcementId = data.announcementId; + this.logger.debug(`Sending announcement to ${userIds.length} users`); + + // Create UserAnnouncement entities + const userAnnouncements = userIds.map((userId: string) => { + const userAnnouncement = new UserAnnouncement(); + userAnnouncement.user = em.getReference(User, userId); + userAnnouncement.announcement = em.getReference(Announcement, announcementId); + userAnnouncement.isRead = false; + return userAnnouncement; + }); + + await em.persistAndFlush(userAnnouncements); + + // Fetch announcement details for notification + const announcement = await em.findOne(Announcement, { id: job.data.announcementId }); + if (!announcement) throw new Error("Announcement not found"); + + // Send notification to each user + for (const userId of userIds) { + const user = await this.usersService.findOneByIdWithEntityManager(userId, em); + const notifData: IAnnouncementNotificationData = { + userPhone: user.phone, + userEmail: user.email, + title: announcement.title, + description: announcement.content.replace(/<[^>]*>/g, ""), + date: announcement.publishAt ?? new Date(), + }; + await this.notificationQueue.addAnnouncementNotification(userId, notifData); + } + + this.logger.log(`Successfully sent announcement ${announcementId} to ${userIds.length} users`); + await em.commit(); + return true; + } catch (error) { + this.logger.error(`Failed to send announcement:`, error instanceof Error ? error.message : "Unknown error"); + await em.rollback(); + throw error; + } + } + //********************************** */ + + private async publishAnnouncement(job: Job, token?: string) { + this.logger.log(`Publishing announcement: ${job.data.announcementId} ${token}`); + } +} diff --git a/src/modules/announcements/repositories/announcement.repository.ts b/src/modules/announcements/repositories/announcement.repository.ts new file mode 100755 index 0000000..8a744a0 --- /dev/null +++ b/src/modules/announcements/repositories/announcement.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { Announcement } from "../entities/announcement.entity"; + +export class AnnouncementRepository extends EntityRepository {} diff --git a/src/modules/announcements/repositories/user-announcement.repository.ts b/src/modules/announcements/repositories/user-announcement.repository.ts new file mode 100755 index 0000000..b5c33af --- /dev/null +++ b/src/modules/announcements/repositories/user-announcement.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { UserAnnouncement } from "../entities/user-announcement.entity"; + +export class UserAnnouncementRepository extends EntityRepository {} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 9f338a9..fc45e80 100755 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -7,14 +7,15 @@ import { jwtConfig } from "../../configs/jwt.config"; import { UtilsModule } from "../utils/utils.module"; import { AuthService } from "./providers/auth.service"; import { TokensService } from "./providers/tokens.service"; -import { JwtStrategy } from "./strategies/jwt.strategy"; import { NotificationModule } from "../notifications/notifications.module"; import { UsersModule } from "../users/users.module"; +import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy"; +import { LocalJwtStrategy } from "./strategies/local-jwt.strategy"; @Module({ imports: [UtilsModule, UsersModule, PassportModule, JwtModule.registerAsync(jwtConfig()), NotificationModule], controllers: [AuthController], - providers: [AuthService, TokensService, JwtStrategy], + providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/src/modules/auth/guards/auth.guard.ts b/src/modules/auth/guards/auth.guard.ts index acc3d99..5027875 100755 --- a/src/modules/auth/guards/auth.guard.ts +++ b/src/modules/auth/guards/auth.guard.ts @@ -3,12 +3,12 @@ import { Reflector } from "@nestjs/core"; import { AuthGuard } from "@nestjs/passport"; import { Observable } from "rxjs"; -import { JWT_STRATEGY_NAME } from "../../../common/constants"; +import { CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants"; import { SKIP_AUTH_KEY } from "../../../common/decorators/skip-auth.decorator"; import { AuthMessage } from "../../../common/enums/message.enum"; @Injectable() -export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) { +export class JwtAuthGuard extends AuthGuard([CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME]) { constructor(private reflector: Reflector) { super(); } diff --git a/src/modules/auth/guards/permission.guard.ts b/src/modules/auth/guards/permission.guard.ts deleted file mode 100755 index 6bcbfe4..0000000 --- a/src/modules/auth/guards/permission.guard.ts +++ /dev/null @@ -1,32 +0,0 @@ -// import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"; -// import { Reflector } from "@nestjs/core"; -// import { FastifyRequest } from "fastify"; - -// import { PERMISSION_KEY } from "../../../common/decorators/permission.decorator"; -// import { AuthMessage } from "../../../common/enums/message.enum"; -// import { PermissionEnum } from "../../users/enums/permission.enum"; - -// @Injectable() -// export class PermissionsGuard implements CanActivate { -// constructor(private reflector: Reflector) {} - -// canActivate(context: ExecutionContext) { -// const requiredPermissions = this.reflector.getAllAndOverride(PERMISSION_KEY, [ -// context.getHandler(), -// context.getClass(), -// ]); - -// if (!requiredPermissions) return true; - -// const request = context.switchToHttp().getRequest(); -// const user = request.user; - -// if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS); - -// const hasPermission = requiredPermissions.every((perm) => user.permissions.includes(perm)); - -// if (!hasPermission) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS); - -// return true; -// } -// } diff --git a/src/modules/auth/interfaces/IToken-payload.ts b/src/modules/auth/interfaces/IToken-payload.ts index fa54a55..c656820 100755 --- a/src/modules/auth/interfaces/IToken-payload.ts +++ b/src/modules/auth/interfaces/IToken-payload.ts @@ -1,6 +1,14 @@ import { RoleEnum } from "../../users/enums/role.enum"; -export interface ITokenPayload { +export interface ILocalTokenPayload { id: string; role: RoleEnum; } + +export interface IConsoleTokenPayload { + id: string; + permissions?: string[]; + isAdmin?: boolean; +} + +export interface ITokenPayload extends ILocalTokenPayload, IConsoleTokenPayload {} diff --git a/src/modules/auth/strategies/console-jwt.strategy.ts b/src/modules/auth/strategies/console-jwt.strategy.ts new file mode 100755 index 0000000..1a61702 --- /dev/null +++ b/src/modules/auth/strategies/console-jwt.strategy.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; + +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +import { CONSOLE_JWT_STRATEGY_NAME } from "../../../common/constants"; +import { IConsoleTokenPayload } from "../interfaces/IToken-payload"; + +@Injectable() +export class ConsoleJwtStrategy extends PassportStrategy(Strategy, CONSOLE_JWT_STRATEGY_NAME) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: readFileSync(`${process.cwd()}/keys/public.pem`, "utf8"), + algorithms: ["RS256"], + }); + } + + async validate(payload: IConsoleTokenPayload) { + return { id: payload.id, permissions: payload.permissions, isAdmin: payload.isAdmin }; + } +} diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/local-jwt.strategy.ts old mode 100755 new mode 100644 similarity index 80% rename from src/modules/auth/strategies/jwt.strategy.ts rename to src/modules/auth/strategies/local-jwt.strategy.ts index d9c7602..27aa915 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/local-jwt.strategy.ts @@ -3,11 +3,11 @@ import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; -import { JWT_STRATEGY_NAME } from "../../../common/constants"; +import { LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants"; import { ITokenPayload } from "../interfaces/IToken-payload"; @Injectable() -export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY_NAME) { +export class LocalJwtStrategy extends PassportStrategy(Strategy, LOCAL_JWT_STRATEGY_NAME) { constructor(configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts index 66e30cc..b775f2a 100644 --- a/src/modules/businesses/entities/business.entity.ts +++ b/src/modules/businesses/entities/business.entity.ts @@ -1,8 +1,12 @@ import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Property, Unique } from "@mikro-orm/core"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { Announcement } from "../../announcements/entities/announcement.entity"; import { Company } from "../../companies/entities/company.entity"; import { Industry } from "../../industries/entities/industry.entity"; +import { Invoice } from "../../invoices/entities/invoice.entity"; +import { TicketCategory } from "../../tickets/entities/ticket-category.entity"; +import { Ticket } from "../../tickets/entities/ticket.entity"; import { BusinessRepository } from "../repositories/business.repository"; @Entity({ repository: () => BusinessRepository }) @@ -23,5 +27,17 @@ export class Business extends BaseEntity { @OneToMany(() => Industry, (industry) => industry.business) industries = new Collection(this); + @OneToMany(() => Invoice, (invoice) => invoice.business) + invoices = new Collection(this); + + @OneToMany(() => TicketCategory, (ticketCategory) => ticketCategory.business) + ticketCategories = new Collection(this); + + @OneToMany(() => Ticket, (ticket) => ticket.business) + tickets = new Collection(this); + + @OneToMany(() => Announcement, (announcement) => announcement.business) + announcements = new Collection(this); + [EntityRepositoryType]?: BusinessRepository; } diff --git a/src/modules/companies/companies.controller.ts b/src/modules/companies/companies.controller.ts index b2159c2..d23ed0f 100644 --- a/src/modules/companies/companies.controller.ts +++ b/src/modules/companies/companies.controller.ts @@ -5,6 +5,7 @@ import { CompanyListQueryDto } from "./DTO/company-list-query.dto"; import { CreateCompanyDto } from "./DTO/create-company.dto"; import { UpdateCompanyDto } from "./DTO/update-company.dto"; import { CompaniesService } from "./services/companies.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { BusinessDec } from "../../common/decorators/business.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; @@ -15,36 +16,42 @@ export class CompaniesController { constructor(private readonly companiesService: CompaniesService) {} @Post() + @AuthGuards() @ApiOperation({ summary: "create company (admin)" }) create(@Body() createCompanyDto: CreateCompanyDto, @BusinessDec() business: Business) { return this.companiesService.create(createCompanyDto, business); } @Patch(":id") + @AuthGuards() @ApiOperation({ summary: "update company (admin)" }) update(@Param() paramDto: ParamDto, @Body() updateCompanyDto: UpdateCompanyDto, @BusinessDec("id") businessId: string) { return this.companiesService.updateCompanyById(paramDto.id, updateCompanyDto, businessId); } @Delete(":id") + @AuthGuards() @ApiOperation({ summary: "delete company (admin)" }) delete(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { return this.companiesService.deleteCompanyById(paramDto.id, businessId); } @Get("list") + @AuthGuards() @ApiOperation({ summary: "get companies list (admin)" }) getCompaniesList(@Query() query: CompanyListQueryDto, @BusinessDec("id") businessId: string) { return this.companiesService.getCompaniesList(query, businessId); } @Get(":id") + @AuthGuards() @ApiOperation({ summary: "get company by id" }) get(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { return this.companiesService.getCompanyById(paramDto.id, businessId); } @Patch(":id/toggle-status") + @AuthGuards() @ApiOperation({ summary: "toggle company status (admin)" }) toggleStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { return this.companiesService.toggleStatus(paramDto.id, businessId); diff --git a/src/modules/industries/industries.controller.ts b/src/modules/industries/industries.controller.ts index 1d931dd..effd307 100644 --- a/src/modules/industries/industries.controller.ts +++ b/src/modules/industries/industries.controller.ts @@ -5,6 +5,7 @@ import { CreateIndustryDto } from "./DTO/create-industry.dto"; import { IndustryListQueryDto } from "./DTO/industry-list-query.dto"; import { UpdateIndustryDto } from "./DTO/update-industry.dto"; import { IndustriesService } from "./services/industries.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { BusinessDec } from "../../common/decorators/business.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; @@ -16,6 +17,7 @@ export class IndustriesController { constructor(private readonly industriesService: IndustriesService) {} @Post() + @AuthGuards() @ApiOperation({ summary: "create industry" }) create(@Body() createIndustryDto: CreateIndustryDto, @BusinessDec() business: Business) { return this.industriesService.create(createIndustryDto, business); @@ -28,24 +30,28 @@ export class IndustriesController { } @Patch(":id") + @AuthGuards() @ApiOperation({ summary: "update industry" }) updateIndustry(@Param() paramDto: ParamDto, @Body() updateIndustryDto: UpdateIndustryDto, @BusinessDec("id") businessId: string) { return this.industriesService.updateIndustry(paramDto.id, updateIndustryDto, businessId); } @Delete(":id") + @AuthGuards() @ApiOperation({ summary: "delete industry" }) deleteIndustry(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { return this.industriesService.deleteIndustry(paramDto.id, businessId); } @Get("list") + @AuthGuards() @ApiOperation({ summary: "get industries list for admin" }) getIndustriesForAdmin(@Query() query: IndustryListQueryDto, @BusinessDec("id") businessId: string) { return this.industriesService.getIndustriesListForAdmin(query, businessId); } @Patch(":id/toggle-status") + @AuthGuards() @ApiOperation({ summary: "toggle industry status" }) toggleStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { return this.industriesService.toggleStatus(paramDto.id, businessId); diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 5218858..77b4abb 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -3,6 +3,7 @@ import { Decimal } from "decimal.js"; import { InvoiceItem } from "./invoice-item.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { Business } from "../../businesses/entities/business.entity"; import { Company } from "../../companies/entities/company.entity"; import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; import { InvoiceStatus } from "../enums/invoice-status.enum"; @@ -10,7 +11,7 @@ import { InvoicesRepository } from "../repositories/invoices.repository"; @Entity({ repository: () => InvoicesRepository }) export class Invoice extends BaseEntity { - @Property({ type: "int", autoincrement: true, persist: false }) + @Property({ type: "int", autoincrement: true }) numericId!: number & Opt; @ManyToOne(() => Company, { nullable: false, deleteRule: "cascade" }) @@ -40,7 +41,7 @@ export class Invoice extends BaseEntity { @Property({ type: "boolean", default: false }) isRecurring: boolean & Opt; - @Enum({ items: () => RecurringPeriodEnum, nativeEnumName: "recurring_period", nullable: true }) + @Enum({ items: () => RecurringPeriodEnum, nullable: true, type: "smallint" }) recurringPeriod?: RecurringPeriodEnum; @Property({ type: "int", default: 0 }) @@ -52,5 +53,8 @@ export class Invoice extends BaseEntity { @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] }) items = new Collection(this); + @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" }) + business!: Business; + [EntityRepositoryType]?: InvoicesRepository; } diff --git a/src/modules/invoices/enums/invoice-status.enum.ts b/src/modules/invoices/enums/invoice-status.enum.ts index 8464722..da9bcbe 100755 --- a/src/modules/invoices/enums/invoice-status.enum.ts +++ b/src/modules/invoices/enums/invoice-status.enum.ts @@ -3,7 +3,6 @@ export enum InvoiceStatus { PENDING = "PENDING", WAIT_PAYMENT = "WAIT_PAYMENT", PAID = "PAID", + ARCHIVED = "ARCHIVED", OVERDUE = "OVERDUE", - EXPIRED = "EXPIRED", - CANCELLED = "CANCELLED", } diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index ea02386..d15dc5d 100755 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common"; import { ApiOperation } from "@nestjs/swagger"; import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; @@ -6,56 +6,70 @@ import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoic import { UpdateInvoiceDto } from "./DTO/update-invoice.dto"; import { InvoicesService } from "./providers/invoices.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { BusinessDec } from "../../common/decorators/business.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; +import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto"; +import { Business } from "../businesses/entities/business.entity"; import { RoleEnum } from "../users/enums/role.enum"; @Controller("invoices") +@UseInterceptors(BusinessInterceptor) @AuthGuards() export class InvoicesController { constructor(private readonly invoiceService: InvoicesService) {} @ApiOperation({ summary: "create an invoice (admin)" }) @Post() - createInvoice(@Body() createDto: CreateInvoiceDto) { - return this.invoiceService.createInvoiceAdmin(createDto); + createInvoice(@Body() createDto: CreateInvoiceDto, @BusinessDec() business: Business) { + return this.invoiceService.createInvoiceAdmin(createDto, business); } @ApiOperation({ summary: "update an invoice (admin)" }) @Patch(":id") - updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto) { - return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto); + updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto, @BusinessDec("id") businessId: string) { + return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto, businessId); } @ApiOperation({ summary: "get all invoices (admin)" }) @Get() - getInvoices(@Query() queryDto: InvoicesSearchQueryDto) { - return this.invoiceService.getInvoices(queryDto); + getInvoices(@Query() queryDto: InvoicesSearchQueryDto, @BusinessDec("id") businessId: string) { + return this.invoiceService.getInvoices(queryDto, businessId); } @ApiOperation({ summary: "get all user invoices" }) @Get("user") - getUserInvoices(@Query() queryDto: UserInvoicesSearchQueryDto, @UserDec("id") userId: string) { - return this.invoiceService.getUserInvoices(queryDto, userId); + getUserInvoices(@Query() queryDto: UserInvoicesSearchQueryDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) { + return this.invoiceService.getUserInvoices(queryDto, userId, businessId); } @ApiOperation({ summary: "get single invoice by Id " }) @Get(":id") - getInvoiceById(@Param() paramDto: ParamDto, @UserDec("role") role: RoleEnum, @UserDec("id") userId: string) { - return this.invoiceService.getInvoiceById(paramDto.id, role, userId); + getInvoiceById( + @Param() paramDto: ParamDto, + @UserDec("role") role: RoleEnum, + @UserDec("id") userId: string, + @BusinessDec("id") businessId: string, + ) { + return this.invoiceService.getInvoiceById(paramDto.id, role, userId, businessId); } @ApiOperation({ summary: "approve request invoice by user" }) @Post(":id/approve/request") - approveRequestInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { - return this.invoiceService.approveInvoiceRequest(paramDto.id, userId); + approveRequestInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) { + return this.invoiceService.approveInvoiceRequest(paramDto.id, userId, businessId); } @ApiOperation({ summary: "approve invoice by user" }) @Patch(":id/approve") - approveInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @Body() verifyOtpDto: VerifyOtpWithUserId) { - return this.invoiceService.approveInvoiceByUser(paramDto.id, userId, verifyOtpDto); + approveInvoiceByUser( + @Param() paramDto: ParamDto, + @UserDec("id") userId: string, + @Body() verifyOtpDto: VerifyOtpWithUserId, + @BusinessDec("id") businessId: string, + ) { + return this.invoiceService.approveInvoiceByUser(paramDto.id, userId, verifyOtpDto, businessId); } // @ApiOperation({ summary: "pay invoice by user" }) diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index b4d0f15..6191206 100755 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -8,6 +8,7 @@ import { Invoice } from "./entities/invoice.entity"; import { InvoicesController } from "./invoices.controller"; import { InvoicesService } from "./providers/invoices.service"; import { InvoiceProcessor } from "./queue/invoice.processor"; +import { BusinessesModule } from "../businesses/businesses.module"; import { CompaniesModule } from "../companies/companies.module"; import { NotificationModule } from "../notifications/notifications.module"; import { UsersModule } from "../users/users.module"; @@ -21,6 +22,7 @@ import { UtilsModule } from "../utils/utils.module"; UtilsModule, NotificationModule, CompaniesModule, + BusinessesModule, ], providers: [InvoicesService, InvoiceProcessor], controllers: [InvoicesController], diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index fde01ad..374fbb5 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -7,9 +7,10 @@ import Decimal from "decimal.js"; import { AuthMessage, InvoiceMessage } from "../../../common/enums/message.enum"; import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto"; +import { Business } from "../../businesses/entities/business.entity"; +import { Company } from "../../companies/entities/company.entity"; import { CompaniesService } from "../../companies/services/companies.service"; 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 { OTPService } from "../../utils/providers/otp.service"; @@ -41,31 +42,7 @@ export class InvoicesService { ///********************************** */ - private validateInvoiceItems(items: InvoiceItemDto[]): void { - if (!items || items.length === 0) throw new BadRequestException(InvoiceMessage.ITEMS_REQUIRED); - - items.forEach((item) => { - if (item.unitPrice <= 0) throw new BadRequestException(InvoiceMessage.UNIT_PRICE_MUST_BE_POSITIVE); - if (item.count <= 0) throw new BadRequestException(InvoiceMessage.COUNT_MUST_BE_POSITIVE); - if (item.discount && (item.discount < 0 || item.discount > 100)) { - throw new BadRequestException(InvoiceMessage.DISCOUNT_MUST_BE_BETWEEN_0_AND_100); - } - }); - } - ///********************************** */ - - private validateRecurringInvoice(createDto: CreateInvoiceDto): void { - if (createDto.isRecurring) { - if (!createDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); - - if (createDto.maxRecurringCycles && createDto.maxRecurringCycles < 1) { - throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE); - } - } - } - ///********************************** */ - - async createInvoiceAdmin(createDto: CreateInvoiceDto) { + async createInvoiceAdmin(createDto: CreateInvoiceDto, business: Business) { const em = this.em.fork(); try { @@ -85,7 +62,7 @@ export class InvoicesService { return invoiceItem; }); - const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); + const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)).round(); const tax = totalPrice.mul(0.1); if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE); @@ -106,6 +83,7 @@ export class InvoicesService { recurringPeriod: createDto.recurringPeriod, maxRecurringCycles: createDto.maxRecurringCycles, currentRecurringCycle: 0, + business, }); await em.persistAndFlush(invoice); @@ -140,13 +118,13 @@ export class InvoicesService { //********************************** */ - async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto) { + async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto, businessId: string) { const em = this.em.fork(); try { await em.begin(); - const invoice = await em.findOne(Invoice, { id: invoiceId }, { populate: ["items"] }); + const invoice = await em.findOne(Invoice, { id: invoiceId, business: { id: businessId } }, { populate: ["items"] }); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); @@ -170,7 +148,7 @@ export class InvoicesService { }); // Calculate total price and tax - const totalPrice = invoiceItems.reduce((sum, item) => sum.add(item.totalPrice), new Decimal(0)); + const totalPrice = invoiceItems.reduce((sum, item) => sum.add(item.totalPrice), new Decimal(0)).round(); const tax = totalPrice.mul(0.1); invoice.totalPrice = totalPrice.add(tax); @@ -199,7 +177,7 @@ export class InvoicesService { } //********************************** */ - async approveInvoiceRequest(invoiceId: string, userId: string) { + async approveInvoiceRequest(invoiceId: string, userId: string, businessId: string) { const em = this.em.fork(); try { @@ -207,7 +185,7 @@ export class InvoicesService { const user = await this.usersService.findOneByIdWithEntityManager(userId, em); - const invoice = await this.validateInvoiceForApproval(invoiceId, userId, em); + const invoice = await this.validateInvoiceForApproval(invoiceId, userId, businessId, em); const existCode = await this.otpService.checkExistOtp(user.phone, "INVOICE_VERIFY"); if (existCode) { @@ -243,7 +221,7 @@ export class InvoicesService { //********************************** */ - async approveInvoiceByUser(invoiceId: string, userId: string, verifyOtpDto: VerifyOtpWithUserId) { + async approveInvoiceByUser(invoiceId: string, userId: string, verifyOtpDto: VerifyOtpWithUserId, businessId: string) { const em = this.em.fork(); try { @@ -256,7 +234,7 @@ export class InvoicesService { const isValid = await this.otpService.verifyOtp(user.phone, code, "INVOICE_VERIFY"); if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP); - const invoice = await this.validateInvoiceForApproval(invoiceId, userId, em); + const invoice = await this.validateInvoiceForApproval(invoiceId, userId, businessId, em); invoice.status = InvoiceStatus.WAIT_PAYMENT; await em.persistAndFlush(invoice); @@ -286,8 +264,12 @@ export class InvoicesService { } //********************************** */ - private async validateInvoiceForApproval(invoiceId: string, userId: string, em: EntityManager): Promise { - const invoice = await em.findOne(Invoice, { id: invoiceId, company: { user: { id: userId } } }, { populate: ["items"] }); + private async validateInvoiceForApproval(invoiceId: string, userId: string, businessId: string, em: EntityManager): Promise { + const invoice = await em.findOne( + Invoice, + { id: invoiceId, company: { user: { id: userId } }, business: { id: businessId } }, + { populate: ["items"] }, + ); if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); @@ -296,7 +278,7 @@ export class InvoicesService { if (invoice.status === InvoiceStatus.PAID) throw new BadRequestException(InvoiceMessage.INVOICE_ALREADY_PAID); if (dayjs().isAfter(dayjs(invoice.dueDate).add(INVOICE.MAX_DAYS_AFTER_OVERDUE, "day"))) { - invoice.status = InvoiceStatus.EXPIRED; + invoice.status = InvoiceStatus.ARCHIVED; await em.persistAndFlush(invoice); throw new BadRequestException(InvoiceMessage.INVOICE_IS_OVERDUE); } @@ -308,21 +290,18 @@ export class InvoicesService { ///********************************** */ - ///********************************** */ + async getInvoices(queryDto: InvoicesSearchQueryDto, businessId: string) { + const [invoices, count] = await this.invoiceRepository.getInvoicesForAdmin(queryDto, businessId); - async getInvoices(queryDto: InvoicesSearchQueryDto) { - const [invoices, count] = await this.invoiceRepository.getInvoicesForAdmin(queryDto); - - // Get unique users that have invoices using MikroORM's query builder - const usersThatHaveInvoices = await this.em - .createQueryBuilder(User, "user") - .select(["user.id", "user.firstName", "user.lastName", "user.email", "user.phone"]) + const companiesThatHaveInvoices = await this.em + .createQueryBuilder(Company, "company") + .select(["company.id", "company.name"]) .distinct() - .join("user.invoices", "invoice") + .join("company.invoices", "invoice") .getResult(); return { - users: usersThatHaveInvoices, + companies: companiesThatHaveInvoices, invoices, count, paginate: true, @@ -330,13 +309,19 @@ export class InvoicesService { } //*********************************** */ - async getInvoiceById(invoiceId: string, role: RoleEnum, userId: string) { + async getInvoiceById(invoiceId: string, role: RoleEnum, userId: string, businessId: string) { let invoice: Invoice | null; - if (role === RoleEnum.ADMIN) { - invoice = await this.invoiceRepository.findOne(invoiceId, { populate: ["items", "company"] }); + if (role === RoleEnum.USER) { + invoice = await this.invoiceRepository.findOne( + { id: invoiceId, company: { user: { id: userId } }, business: { id: businessId } }, + { populate: ["items", "company"] }, + ); } else { - invoice = await this.invoiceRepository.findOne({ id: invoiceId, company: { user: { id: userId } } }, { populate: ["items"] }); + invoice = await this.invoiceRepository.findOne( + { id: invoiceId, business: { id: businessId } }, + { populate: ["items", "company", "business"] }, + ); } if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); @@ -348,13 +333,13 @@ export class InvoicesService { //*********************************** */ - async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string) { + async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string, businessId: string) { const { limit, skip } = PaginationUtils(queryDto); const qb = this.em .createQueryBuilder(Invoice, "invoice") .leftJoinAndSelect("invoice.items", "items") - .where({ company: { user: { id: userId } } }) + .where({ company: { user: { id: userId } }, business: { id: businessId } }) .orderBy({ createdAt: "DESC" }); if (queryDto.status) { @@ -553,4 +538,27 @@ export class InvoicesService { } } //*********************************** */ + + private validateInvoiceItems(items: InvoiceItemDto[]): void { + if (!items || items.length === 0) throw new BadRequestException(InvoiceMessage.ITEMS_REQUIRED); + + items.forEach((item) => { + if (item.unitPrice <= 0) throw new BadRequestException(InvoiceMessage.UNIT_PRICE_MUST_BE_POSITIVE); + if (item.count <= 0) throw new BadRequestException(InvoiceMessage.COUNT_MUST_BE_POSITIVE); + if (item.discount && (item.discount < 0 || item.discount > 100)) { + throw new BadRequestException(InvoiceMessage.DISCOUNT_MUST_BE_BETWEEN_0_AND_100); + } + }); + } + ///********************************** */ + + private validateRecurringInvoice(createDto: CreateInvoiceDto): void { + if (createDto.isRecurring) { + if (!createDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); + + if (createDto.maxRecurringCycles && createDto.maxRecurringCycles < 1) { + throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE); + } + } + } } diff --git a/src/modules/invoices/queue/invoice.processor.ts b/src/modules/invoices/queue/invoice.processor.ts index 7470017..69ba7f4 100755 --- a/src/modules/invoices/queue/invoice.processor.ts +++ b/src/modules/invoices/queue/invoice.processor.ts @@ -21,7 +21,6 @@ export class InvoiceProcessor extends WorkerProcessor { } async process(job: Job, token?: string) { - this.logger.log(job); switch (job.name) { case INVOICE.REMINDER_JOB_NAME: return this.sendBillInvoiceReminder(job, token); @@ -65,7 +64,7 @@ export class InvoiceProcessor extends WorkerProcessor { throw new Error(`Maximum recurring cycles (${invoice.maxRecurringCycles}) reached for invoice ${invoice.id}`); } - if (invoice.status === InvoiceStatus.CANCELLED) throw new Error(`Cannot create recurring invoice for cancelled invoice ${invoice.id}`); + if (invoice.status === InvoiceStatus.ARCHIVED) throw new Error(`Cannot create recurring invoice for archived invoice ${invoice.id}`); } //********************************** */ @@ -87,6 +86,7 @@ export class InvoiceProcessor extends WorkerProcessor { recurringPeriod: invoice.recurringPeriod, maxRecurringCycles: invoice.maxRecurringCycles, currentRecurringCycle: invoice.currentRecurringCycle + 1, + business: invoice.business, }); // Update the original invoice's current recurring cycle @@ -149,10 +149,10 @@ export class InvoiceProcessor extends WorkerProcessor { //********************************** */ private async cancelOverdueInvoice(invoice: Invoice, em: EntityManager) { - invoice.status = InvoiceStatus.CANCELLED; + invoice.status = InvoiceStatus.ARCHIVED; await em.persistAndFlush(invoice); - this.logger.log(`Invoice ${invoice.id} has been cancelled as it is more than 10 days overdue.`); + this.logger.log(`Invoice ${invoice.id} has been archived as it is more than 10 days overdue.`); } //********************************** */ diff --git a/src/modules/invoices/repositories/invoices.repository.ts b/src/modules/invoices/repositories/invoices.repository.ts index 9e73cc9..8e1e300 100755 --- a/src/modules/invoices/repositories/invoices.repository.ts +++ b/src/modules/invoices/repositories/invoices.repository.ts @@ -7,10 +7,10 @@ import { Invoice } from "../entities/invoice.entity"; export class InvoicesRepository extends EntityRepository { // - async getInvoicesForAdmin(queryDto: InvoicesSearchQueryDto) { + async getInvoicesForAdmin(queryDto: InvoicesSearchQueryDto, businessId: string) { const { limit, skip } = PaginationUtils(queryDto); - const where: FilterQuery = {}; + const where: FilterQuery = { business: { id: businessId } }; if (queryDto.q) { where.$or = [ @@ -25,7 +25,7 @@ export class InvoicesRepository extends EntityRepository { } if (queryDto.companyId) { - where.company = queryDto.companyId; + where.company = { id: queryDto.companyId }; } if (queryDto.since || queryDto.to) { diff --git a/src/modules/templates/.gitkeep b/src/modules/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/templates/email/admin-notification.hbs b/src/modules/templates/email/admin-notification.hbs new file mode 100644 index 0000000..45afd47 --- /dev/null +++ b/src/modules/templates/email/admin-notification.hbs @@ -0,0 +1,46 @@ + + + + + {{title}} + + + +
+

{{title}}

+
+
+

{{message}}

+
+ + + \ No newline at end of file diff --git a/src/modules/templates/email/announcement.hbs b/src/modules/templates/email/announcement.hbs new file mode 100644 index 0000000..4c0e284 --- /dev/null +++ b/src/modules/templates/email/announcement.hbs @@ -0,0 +1,24 @@ + + + + + اعلان + + +
+

اعلان

+
+
+

سلام،

+
{{title}}
+
{{description}}
+
تاریخ: {{date}}
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/email-verify.hbs b/src/modules/templates/email/email-verify.hbs new file mode 100755 index 0000000..2ea356a --- /dev/null +++ b/src/modules/templates/email/email-verify.hbs @@ -0,0 +1,48 @@ + + + + + {{title}} + + + +
+ + +

سلام {{fullName}} عزیز,

+ +

به DanakCorp خوش آمدید! لطفاً برای تأیید ایمیل خود روی دکمه زیر کلیک کنید:

+ + + +

اگر دکمه بالا کار نمی‌کند، می‌توانید از لینک زیر استفاده کنید:

+

+ {{link}} +

+ +

+ اگر شما این درخواست را ارسال نکرده‌اید، لطفاً این ایمیل را نادیده بگیرید. +

+ + +
+ + \ No newline at end of file diff --git a/src/modules/templates/email/exam-cancellation.hbs b/src/modules/templates/email/exam-cancellation.hbs new file mode 100644 index 0000000..5f08342 --- /dev/null +++ b/src/modules/templates/email/exam-cancellation.hbs @@ -0,0 +1,23 @@ + + + + + لغو آزمون + + +
+

لغو آزمون

+
+
+

سلام،

+

آزمون زیر با موفقیت لغو شد:

+
{{examTitle}}
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/invoice.hbs b/src/modules/templates/email/invoice.hbs new file mode 100644 index 0000000..6f581a7 --- /dev/null +++ b/src/modules/templates/email/invoice.hbs @@ -0,0 +1,40 @@ + + + + + فاکتور + + +
+

فاکتور

+
+
+

سلام،

+

جزئیات فاکتور شما به شرح زیر است:

+
+
+ مبلغ: + {{price}} تومان +
+
+ تاریخ ایجاد: + {{createDate}} +
+
+ تاریخ سررسید: + {{dueDate}} +
+
+ شناسه فاکتور: + {{invoiceId}} +
+
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/login.hbs b/src/modules/templates/email/login.hbs new file mode 100644 index 0000000..1b2ddcc --- /dev/null +++ b/src/modules/templates/email/login.hbs @@ -0,0 +1,23 @@ + + + + + ورود به سیستم + + +
+

ورود به سیستم

+
+
+

سلام،

+

ورود به سیستم در تاریخ و ساعت زیر انجام شد:

+
{{date}}
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/payment-cancellation.hbs b/src/modules/templates/email/payment-cancellation.hbs new file mode 100644 index 0000000..5d6a040 --- /dev/null +++ b/src/modules/templates/email/payment-cancellation.hbs @@ -0,0 +1,23 @@ + + + + + لغو پرداخت + + +
+

لغو پرداخت

+
+
+

سلام،

+

پرداخت مبلغ زیر به دلیل عدم پرداخت در زمان مقرر لغو شد:

+
{{amount}} تومان
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/payment-reminder.hbs b/src/modules/templates/email/payment-reminder.hbs new file mode 100644 index 0000000..89b691a --- /dev/null +++ b/src/modules/templates/email/payment-reminder.hbs @@ -0,0 +1,23 @@ + + + + + یادآوری پرداخت + + +
+

یادآوری پرداخت

+
+
+

سلام،

+

لطفاً نسبت به پرداخت مبلغ زیر اقدام فرمایید:

+
{{amount}} تومان
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/ticket.hbs b/src/modules/templates/email/ticket.hbs new file mode 100644 index 0000000..12e6d58 --- /dev/null +++ b/src/modules/templates/email/ticket.hbs @@ -0,0 +1,42 @@ + + + + + تیکت پشتیبانی + + +
+

تیکت پشتیبانی

+
+
+

سلام،

+

جزئیات تیکت پشتیبانی شما به شرح زیر است:

+
+
+ موضوع: + {{subject}} +
+
+ تاریخ: + {{date}} +
+
+ شناسه تیکت: + {{ticketId}} +
+ {{#if user}} +
+ کارشناس: + {{user}} +
+ {{/if}} +
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/templates/email/wallet.hbs b/src/modules/templates/email/wallet.hbs new file mode 100644 index 0000000..7009407 --- /dev/null +++ b/src/modules/templates/email/wallet.hbs @@ -0,0 +1,40 @@ + + + + + اعلان کیف پول + + +
+

اعلان کیف پول

+
+
+

سلام،

+

جزئیات تراکنش کیف پول شما به شرح زیر است:

+
+
+ مبلغ تراکنش: +
{{amount}} تومان
+
+
+ موجودی فعلی: +
{{balance}} تومان
+
+
+ تاریخ: + {{date}} +
+
+ دلیل: + {{reason}} +
+
+

با تشکر

+
+
+

این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.

+
+ + \ No newline at end of file diff --git a/src/modules/tickets/DTO/create-ticket-category.dto.ts b/src/modules/tickets/DTO/create-ticket-category.dto.ts index ab4dc2e..eaedf64 100755 --- a/src/modules/tickets/DTO/create-ticket-category.dto.ts +++ b/src/modules/tickets/DTO/create-ticket-category.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsNotEmpty, IsString, IsUUID, Length } from "class-validator"; +import { IsBoolean, IsNotEmpty, IsString, Length } from "class-validator"; import { TicketMessageEnum } from "../../../common/enums/message.enum"; @@ -16,11 +16,6 @@ export class CreateTicketCategoryDto { @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 }) diff --git a/src/modules/tickets/DTO/update-ticket-category.dto.ts b/src/modules/tickets/DTO/update-ticket-category.dto.ts index 068cfaa..fc4be3a 100755 --- a/src/modules/tickets/DTO/update-ticket-category.dto.ts +++ b/src/modules/tickets/DTO/update-ticket-category.dto.ts @@ -1,5 +1,5 @@ -import { OmitType, PartialType } from "@nestjs/swagger"; +import { PartialType } from "@nestjs/swagger"; import { CreateTicketCategoryDto } from "./create-ticket-category.dto"; -export class UpdateTicketCategoryDto extends PartialType(OmitType(CreateTicketCategoryDto, ["userGroupId"])) {} +export class UpdateTicketCategoryDto extends PartialType(CreateTicketCategoryDto) {} diff --git a/src/modules/tickets/entities/ticket-category.entity.ts b/src/modules/tickets/entities/ticket-category.entity.ts index 1295736..ca465af 100755 --- a/src/modules/tickets/entities/ticket-category.entity.ts +++ b/src/modules/tickets/entities/ticket-category.entity.ts @@ -1,9 +1,11 @@ -import { Collection, Entity, OneToMany, Property } from "@mikro-orm/core"; +import { Collection, Entity, EntityRepositoryType, Formula, ManyToOne, OneToMany, Property } from "@mikro-orm/core"; import { Ticket } from "./ticket.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { Business } from "../../businesses/entities/business.entity"; +import { TicketCategoryRepository } from "../repositories/tickets-category.repository"; -@Entity() +@Entity({ repository: () => TicketCategoryRepository }) export class TicketCategory extends BaseEntity { @Property({ type: "varchar", length: 150, unique: true }) title!: string; @@ -14,6 +16,14 @@ export class TicketCategory extends BaseEntity { @Property({ type: "boolean", default: true }) isActive: boolean = true; + @Formula((alias) => `(SELECT COUNT(*)::int FROM ticket WHERE ticket.category_id = ${alias}.id)`, { persist: false }) + ticketCount?: number; + @OneToMany(() => Ticket, (ticket) => ticket.category) tickets = new Collection(this); + + @ManyToOne(() => Business, { deleteRule: "cascade", nullable: false }) + business!: Business; + + [EntityRepositoryType]?: TicketCategoryRepository; } diff --git a/src/modules/tickets/entities/ticket-message-attachment.ts b/src/modules/tickets/entities/ticket-message-attachment.ts index d397962..1f2c2e2 100755 --- a/src/modules/tickets/entities/ticket-message-attachment.ts +++ b/src/modules/tickets/entities/ticket-message-attachment.ts @@ -1,13 +1,16 @@ -import { Entity, ManyToOne, Property } from "@mikro-orm/core"; +import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core"; import { TicketMessage } from "./ticket-message.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { TicketMessageAttachmentsRepository } from "../repositories/ticket-message-attachment.repository"; -@Entity() +@Entity({ repository: () => TicketMessageAttachmentsRepository }) export class TicketMessageAttachment extends BaseEntity { @Property({ type: "varchar", length: 150, nullable: true }) attachmentUrl?: string; @ManyToOne({ entity: () => TicketMessage, deleteRule: "cascade", nullable: false }) ticketMessage!: TicketMessage; + + [EntityRepositoryType]?: TicketMessageAttachmentsRepository; } diff --git a/src/modules/tickets/entities/ticket-message.entity.ts b/src/modules/tickets/entities/ticket-message.entity.ts index 54cb217..a8a3631 100755 --- a/src/modules/tickets/entities/ticket-message.entity.ts +++ b/src/modules/tickets/entities/ticket-message.entity.ts @@ -1,11 +1,12 @@ -import { Cascade, Collection, Entity, ManyToOne, OneToMany, Property } from "@mikro-orm/core"; +import { Cascade, Collection, Entity, EntityRepositoryType, 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"; +import { TicketMessagesRepository } from "../repositories/tickets-message.repository"; -@Entity() +@Entity({ repository: () => TicketMessagesRepository }) export class TicketMessage extends BaseEntity { @Property({ type: "text" }) content!: string; @@ -18,4 +19,6 @@ export class TicketMessage extends BaseEntity { @ManyToOne(() => User, { deleteRule: "cascade", nullable: false }) author!: User; + + [EntityRepositoryType]?: TicketMessagesRepository; } diff --git a/src/modules/tickets/entities/ticket.entity.ts b/src/modules/tickets/entities/ticket.entity.ts index b31184f..2e799a8 100755 --- a/src/modules/tickets/entities/ticket.entity.ts +++ b/src/modules/tickets/entities/ticket.entity.ts @@ -1,14 +1,16 @@ -import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core"; +import { Cascade, Collection, Entity, EntityRepositoryType, 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 { Business } from "../../businesses/entities/business.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"; +import { TicketsRepository } from "../repositories/tickets.repository"; -@Entity() +@Entity({ repository: () => TicketsRepository }) export class Ticket extends BaseEntity { @Property({ type: "int", autoincrement: true, persist: false }) numericId!: number & Opt; @@ -34,6 +36,11 @@ export class Ticket extends BaseEntity { @ManyToOne(() => TicketCategory, { deleteRule: "set null", nullable: true }) category?: TicketCategory; + @ManyToOne(() => Business, { deleteRule: "cascade", nullable: false }) + business!: Business; + @OneToMany(() => TicketMessage, (message) => message.ticket, { cascade: [Cascade.ALL] }) messages = new Collection(this); + + [EntityRepositoryType]?: TicketsRepository; } diff --git a/src/modules/tickets/providers/tickets.service.ts b/src/modules/tickets/providers/tickets.service.ts index 92abcf6..bcdbda6 100755 --- a/src/modules/tickets/providers/tickets.service.ts +++ b/src/modules/tickets/providers/tickets.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, HttpException, Injectable, InternalServerErrorExce import { ParamDto } from "../../../common/DTO/param.dto"; import { CommonMessage, CompanyMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum"; +import { Business } from "../../businesses/entities/business.entity"; import { Company } from "../../companies/entities/company.entity"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { User } from "../../users/entities/user.entity"; @@ -38,26 +39,27 @@ export class TicketsService { ) {} //******************************** */ - async getTicketCategories() { - const ticketCategories = await this.ticketsCategoryRepository.find({}); + async getTicketCategories(businessId: string) { + const ticketCategories = await this.ticketsCategoryRepository.find({ business: { id: businessId } }); return { ticketCategories, }; } //******************************** */ - async getCategoriesList(queryDto: SearchTicketCategoryDto) { - const [categories, count] = await this.ticketsCategoryRepository.findCategoriesList(queryDto); + async getCategoriesList(queryDto: SearchTicketCategoryDto, businessId: string) { + const [categories, count] = await this.ticketsCategoryRepository.findCategoriesList(queryDto, businessId); return { categories, count, paginate: true }; } //******************************** */ - async createTicketCategory(createDto: CreateTicketCategoryDto) { - const existCategory = await this.ticketsCategoryRepository.findCategoryByTitle(createDto.title); + async createTicketCategory(createDto: CreateTicketCategoryDto, business: Business) { + const existCategory = await this.ticketsCategoryRepository.findCategoryByTitle(createDto.title, business.id); if (existCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_EXIST); const ticketCategory = this.ticketsCategoryRepository.create({ ...createDto, + business, }); await this.em.persistAndFlush(ticketCategory); @@ -69,8 +71,8 @@ export class TicketsService { } //******************************** */ - async updateCategory(paramDto: ParamDto, updateCategoryDto: UpdateTicketCategoryDto) { - const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id); + async updateCategory(paramDto: ParamDto, updateCategoryDto: UpdateTicketCategoryDto, businessId: string) { + const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id, businessId); if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND); if (updateCategoryDto.title) { @@ -88,9 +90,25 @@ export class TicketsService { message: CommonMessage.UPDATE_SUCCESS, }; } + //******************************** */ - async toggleCategoryStatus(paramDto: ParamDto) { - const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id); + async deleteCategory(paramDto: ParamDto, businessId: string) { + const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id, businessId); + if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND); + + if (category.tickets.length > 0) throw new BadRequestException(TicketMessageEnum.CATEGORY_HAS_TICKETS); + + category.deletedAt = new Date(); + await this.em.flush(); + + return { + message: TicketMessageEnum.CATEGORY_DELETED, + }; + } + + //******************************** */ + async toggleCategoryStatus(paramDto: ParamDto, businessId: string) { + const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id, businessId); if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND); // category.isActive = !category.isActive; @@ -104,7 +122,7 @@ export class TicketsService { //******************************** */ - async createTicket(createDto: CreateTicketDto, userId: string) { + async createTicket(createDto: CreateTicketDto, userId: string, business: Business) { const em = this.em.fork(); try { @@ -142,6 +160,7 @@ export class TicketsService { category: ticketCategory, messages: [ticketMessage], company, + business, }); await em.persistAndFlush(ticket); @@ -163,27 +182,27 @@ export class TicketsService { //******************************** */ - async getTickets(queryDto: SearchTicketQueryDto, userId: string) { - const [tickets, count] = await this.ticketsRepository.findTicketsList(queryDto, userId); + async getTickets(queryDto: SearchTicketQueryDto, userId: string, businessId: string) { + const [tickets, count] = await this.ticketsRepository.findTicketsList(queryDto, userId, businessId); return { tickets, count, paginate: true }; } //******************************** */ - async getTicketForAdmin(queryDto: SearchTicketQueryDto, adminId: string) { - const [tickets, count] = await this.ticketsRepository.findTicketsListForAdmin(queryDto, adminId); + async getTicketForAdmin(queryDto: SearchTicketQueryDto, adminId: string, businessId: string) { + const [tickets, count] = await this.ticketsRepository.findTicketsListForAdmin(queryDto, adminId, businessId); return { tickets, count, paginate: true }; } //******************************** */ - async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, role: RoleEnum) { + async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, role: RoleEnum, businessId: string) { const em = this.em.fork(); try { await em.begin(); - const ticket = await this.findTicket(em, ticketId, userId, role === RoleEnum.ADMIN); + const ticket = await this.findTicket(em, ticketId, businessId, userId, role === RoleEnum.ADMIN); if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException(TicketMessageEnum.TICKET_CLOSED); if (role === RoleEnum.ADMIN && ticket.assignedTo?.id !== userId) @@ -224,13 +243,13 @@ export class TicketsService { //******************************** */ - async getTicketMessages(ticketId: string, userId: string, role: RoleEnum) { + async getTicketMessages(ticketId: string, userId: string, role: RoleEnum, businessId: string) { let ticket = null; // if (role === RoleEnum.ADMIN) { - ticket = await this.ticketsRepository.findTicketById(ticketId); + ticket = await this.ticketsRepository.findTicketById(ticketId, businessId); } else { - ticket = await this.ticketsRepository.findTicketById(ticketId, userId); + ticket = await this.ticketsRepository.findTicketById(ticketId, businessId, userId); } if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); @@ -243,13 +262,13 @@ export class TicketsService { //******************************** */ - async closeTicketByUser(ticketId: string, userId: string, role: RoleEnum) { + async closeTicketByUser(ticketId: string, userId: string, role: RoleEnum, businessId: string) { let ticket = null; if (role === RoleEnum.ADMIN) { - ticket = await this.ticketsRepository.findTicketById(ticketId); + ticket = await this.ticketsRepository.findTicketById(ticketId, businessId); } else { - ticket = await this.ticketsRepository.findTicketById(ticketId, userId); + ticket = await this.ticketsRepository.findTicketById(ticketId, businessId, userId); } if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); @@ -262,8 +281,8 @@ export class TicketsService { }; } //******************************** * / - async openTicketByAdmin(ticketId: string) { - const ticket = await this.ticketsRepository.findTicketById(ticketId); + async openTicketByAdmin(ticketId: string, businessId: string) { + const ticket = await this.ticketsRepository.findTicketById(ticketId, businessId); if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); ticket.status = TicketStatus.PENDING; @@ -275,14 +294,14 @@ export class TicketsService { } //******************************** */ - async referTicket(ticketId: string, userId: string, referDto: ReferTicketDto) { + async referTicket(ticketId: string, userId: string, referDto: ReferTicketDto, businessId: string) { const em = this.em.fork(); try { await em.begin(); console.log(userId, "admin id"); - const ticket = await this.ticketsRepository.findTicketById(ticketId); + 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); @@ -357,10 +376,14 @@ export class TicketsService { } //***************************** */ - private async findTicket(em: EntityManager, ticketId: string, userId: string, isAdmin: boolean): Promise { + private async findTicket(em: EntityManager, ticketId: string, businessId: string, userId: string, isAdmin: boolean): Promise { 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"] }); + ? await em.findOne(Ticket, { id: ticketId, user: { id: userId }, business: { id: businessId } }, { populate: ["user", "assignedTo"] }) + : await em.findOne( + Ticket, + { id: ticketId, user: { id: userId }, business: { id: businessId } }, + { populate: ["user", "assignedTo"] }, + ); if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); return ticket; diff --git a/src/modules/tickets/repositories/tickets-category.repository.ts b/src/modules/tickets/repositories/tickets-category.repository.ts index 431b7b2..30b600e 100755 --- a/src/modules/tickets/repositories/tickets-category.repository.ts +++ b/src/modules/tickets/repositories/tickets-category.repository.ts @@ -6,41 +6,18 @@ import { TicketCategory } from "../entities/ticket-category.entity"; export class TicketCategoryRepository extends EntityRepository { // - async findCategoryByTitle(title: string): Promise { - return this.findOne({ title }); + async findCategoryByTitle(title: string, businessId: string): Promise { + return this.findOne({ title, business: { id: businessId }, deletedAt: null }, { populate: ["tickets"] }); } - async findCategoryById(id: string): Promise { - return this.findOne({ id }); + async findCategoryById(id: string, businessId: string): Promise { + return this.findOne({ id, business: { id: businessId }, deletedAt: null }, { populate: ["tickets"] }); } - async findCategoriesList(queryDto: SearchTicketCategoryDto) { + async findCategoriesList(queryDto: SearchTicketCategoryDto, businessId: string) { 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"), - ); + const qb = this.createQueryBuilder("category").where({ business: { id: businessId }, deletedAt: null }); if (queryDto.isActive !== undefined) { qb.andWhere({ isActive: queryDto.isActive }); diff --git a/src/modules/tickets/repositories/tickets.repository.ts b/src/modules/tickets/repositories/tickets.repository.ts index 7509233..8ece86c 100755 --- a/src/modules/tickets/repositories/tickets.repository.ts +++ b/src/modules/tickets/repositories/tickets.repository.ts @@ -6,11 +6,12 @@ import { Ticket } from "../entities/ticket.entity"; import { TicketStatus } from "../enums/ticket-status.enum"; export class TicketsRepository extends EntityRepository { - async findTicketById(id: string, userId?: string) { + async findTicketById(id: string, businessId: string, userId?: string) { return this.findOne( { id, ...(userId && { user: { id: userId } }), + business: { id: businessId }, }, { populate: ["user", "category", "assignedTo", "company"], @@ -37,10 +38,10 @@ export class TicketsRepository extends EntityRepository { } //******************************** */ - async findTicketsList(queryDto: SearchTicketQueryDto, userId: string) { + async findTicketsList(queryDto: SearchTicketQueryDto, userId: string, businessId: string) { const { limit, skip } = PaginationUtils(queryDto); - const where: FilterQuery = { user: { id: userId } }; + const where: FilterQuery = { user: { id: userId }, business: { id: businessId } }; if (queryDto.status) { where.status = queryDto.status; } else { @@ -68,10 +69,10 @@ export class TicketsRepository extends EntityRepository { }); } //******************************** */ - async findTicketsListForAdmin(queryDto: SearchTicketQueryDto, adminId: string) { + async findTicketsListForAdmin(queryDto: SearchTicketQueryDto, adminId: string, businessId: string) { const { limit, skip } = PaginationUtils(queryDto); - const where: FilterQuery = {}; + const where: FilterQuery = { business: { id: businessId } }; where.assignedTo = { id: adminId }; diff --git a/src/modules/tickets/tickets.controller.ts b/src/modules/tickets/tickets.controller.ts index 87ba6e8..140d776 100755 --- a/src/modules/tickets/tickets.controller.ts +++ b/src/modules/tickets/tickets.controller.ts @@ -1,5 +1,5 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common"; +import { ApiOperation } from "@nestjs/swagger"; import { CreateTicketCategoryDto } from "./DTO/create-ticket-category.dto"; import { CreateTicketMessageDto } from "./DTO/create-ticket-message.dto"; @@ -11,71 +11,79 @@ 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 { BusinessDec } from "../../common/decorators/business.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; +import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; +import { Business } from "../businesses/entities/business.entity"; import { RoleEnum } from "../users/enums/role.enum"; @Controller("tickets") -@ApiTags("Tickets") +@UseInterceptors(BusinessInterceptor) @AuthGuards() export class TicketsController { constructor(private readonly ticketsService: TicketsService) {} //----------------- ticket categories ------------------- - @ApiOperation({ summary: "Create ticket category => admin route" }) + @ApiOperation({ summary: "Create ticket category (admin)" }) @Post("category") - createTicketCategory(@Body() createDto: CreateTicketCategoryDto) { - return this.ticketsService.createTicketCategory(createDto); + createTicketCategory(@Body() createDto: CreateTicketCategoryDto, @BusinessDec() business: Business) { + return this.ticketsService.createTicketCategory(createDto, business); } - @ApiOperation({ summary: "Update ticket category => admin route" }) + @ApiOperation({ summary: "Update ticket category (admin)" }) @Patch("category/:id") - updateCategory(@Param() paramDto: ParamDto, @Body() updateCategoryDto: UpdateTicketCategoryDto) { - return this.ticketsService.updateCategory(paramDto, updateCategoryDto); + updateCategory(@Param() paramDto: ParamDto, @Body() updateCategoryDto: UpdateTicketCategoryDto, @BusinessDec("id") businessId: string) { + return this.ticketsService.updateCategory(paramDto, updateCategoryDto, businessId); + } + + @ApiOperation({ summary: "Delete ticket category (admin)" }) + @Delete("category/:id") + deleteCategory(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.ticketsService.deleteCategory(paramDto, businessId); } @ApiOperation({ summary: "Get ticket categories " }) @Get("categories") - // - getTicketCategories() { - return this.ticketsService.getTicketCategories(); + getTicketCategories(@BusinessDec("id") businessId: string) { + return this.ticketsService.getTicketCategories(businessId); } - @ApiOperation({ summary: "Get ticket categories list => admin route" }) + @ApiOperation({ summary: "Get ticket categories list (admin)" }) @AdminRoute() @Get("categories-list") - getCategoriesList(@Query() queryDto: SearchTicketCategoryDto) { - return this.ticketsService.getCategoriesList(queryDto); + getCategoriesList(@Query() queryDto: SearchTicketCategoryDto, @BusinessDec("id") businessId: string) { + return this.ticketsService.getCategoriesList(queryDto, businessId); } - @ApiOperation({ summary: "toggle status of categories => admin route" }) + @ApiOperation({ summary: "toggle status of categories (admin)" }) @AdminRoute() @HttpCode(HttpStatus.OK) @Post("category/toggle-status/:id") - toggleCategoryStatus(@Param() paramDto: ParamDto) { - return this.ticketsService.toggleCategoryStatus(paramDto); + toggleCategoryStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.ticketsService.toggleCategoryStatus(paramDto, businessId); } //----------------- ticket and ticket messages ------------------- - @ApiOperation({ summary: "create ticket ==> user route" }) + @ApiOperation({ summary: "create ticket (user)" }) @Post() - createTicket(@Body() createDto: CreateTicketDto, @UserDec("id") userId: string) { - return this.ticketsService.createTicket(createDto, userId); + createTicket(@Body() createDto: CreateTicketDto, @UserDec("id") userId: string, @BusinessDec() business: Business) { + return this.ticketsService.createTicket(createDto, userId, business); } - @ApiOperation({ summary: "Get all tickets of user ==> user route" }) + @ApiOperation({ summary: "Get all tickets of user (user)" }) @Get() - getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") userId: string) { - return this.ticketsService.getTickets(queryDto, userId); + getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) { + return this.ticketsService.getTickets(queryDto, userId, businessId); } - @ApiOperation({ summary: "Get all tickets ==> admin route" }) + @ApiOperation({ summary: "Get all tickets (admin)" }) @AdminRoute() @Get("admin") - getAllTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") adminId: string) { - return this.ticketsService.getTicketForAdmin(queryDto, adminId); + getAllTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") adminId: string, @BusinessDec("id") businessId: string) { + return this.ticketsService.getTicketForAdmin(queryDto, adminId, businessId); } @ApiOperation({ summary: "create ticket messages" }) @@ -85,34 +93,50 @@ export class TicketsController { @Body() createDto: CreateTicketMessageDto, @UserDec("id") userId: string, @UserDec("role") role: RoleEnum, + @BusinessDec("id") businessId: string, ) { - return this.ticketsService.createTicketMessage(paramDto.id, createDto, userId, role); + return this.ticketsService.createTicketMessage(paramDto.id, createDto, userId, role, businessId); } @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); + getTicketMessages( + @Param() paramDto: ParamDto, + @UserDec("id") userId: string, + @UserDec("role") role: RoleEnum, + @BusinessDec("id") businessId: string, + ) { + return this.ticketsService.getTicketMessages(paramDto.id, userId, role, businessId); } @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); + closedTicketByUser( + @Param() paramDto: ParamDto, + @UserDec("id") userId: string, + @UserDec("role") role: RoleEnum, + @BusinessDec("id") businessId: string, + ) { + return this.ticketsService.closeTicketByUser(paramDto.id, userId, role, businessId); } @ApiOperation({ summary: "open ticket by admin" }) @AdminRoute() @Post(":id/open") - openTicketByAdmin(@Param() paramDto: ParamDto) { - return this.ticketsService.openTicketByAdmin(paramDto.id); + openTicketByAdmin(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.ticketsService.openTicketByAdmin(paramDto.id, businessId); } @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); + referTicket( + @Param() paramDto: ParamDto, + @UserDec("id") userId: string, + @Body() referDto: ReferTicketDto, + @BusinessDec("id") businessId: string, + ) { + return this.ticketsService.referTicket(paramDto.id, userId, referDto, businessId); } } diff --git a/src/modules/tickets/tickets.module.ts b/src/modules/tickets/tickets.module.ts index b6af522..7b5d8ab 100755 --- a/src/modules/tickets/tickets.module.ts +++ b/src/modules/tickets/tickets.module.ts @@ -5,13 +5,10 @@ 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 { BusinessesModule } from "../businesses/businesses.module"; import { CompaniesModule } from "../companies/companies.module"; import { NotificationModule } from "../notifications/notifications.module"; @@ -21,8 +18,9 @@ import { NotificationModule } from "../notifications/notifications.module"; UsersModule, NotificationModule, CompaniesModule, + BusinessesModule, ], - providers: [TicketsService, TicketsRepository, TicketCategoryRepository, TicketMessagesRepository, TicketMessageAttachmentsRepository], + providers: [TicketsService], controllers: [TicketsController], exports: [TicketsService], }) diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index f9e97c9..2c2193b 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -3,6 +3,7 @@ import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, Opt, Pr import { RefreshToken } from "./refresh-token.entity"; import { Role } from "./role.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { UserAnnouncement } from "../../announcements/entities/user-announcement.entity"; import { Company } from "../../companies/entities/company.entity"; import { UserRepository } from "../repositories/user.repository"; @@ -56,5 +57,8 @@ export class User extends BaseEntity { @OneToMany(() => Company, (company) => company.user) companies = new Collection(this); + @OneToMany(() => UserAnnouncement, (userAnnouncement) => userAnnouncement.user) + userAnnouncements = new Collection(this); + [EntityRepositoryType]?: UserRepository; } diff --git a/src/modules/utils/providers/international.utils.ts b/src/modules/utils/providers/international.utils.ts index cb2026e..00fe4b2 100755 --- a/src/modules/utils/providers/international.utils.ts +++ b/src/modules/utils/providers/international.utils.ts @@ -2,6 +2,20 @@ export function numberFormat(number: number, locale: string = "fa-IR") { return Intl.NumberFormat(locale).format(number); } -export function dateFormat(date: Date, locale: string = "fa-IR") { - return new Intl.DateTimeFormat(locale).format(date); +export function dateFormat(date: Date | string | number, locale: string = "fa-IR") { + try { + // Convert to Date object if it's a string or number + const dateObj = date instanceof Date ? date : new Date(date); + + // Check if the date is valid + if (isNaN(dateObj.getTime())) { + console.warn("Invalid date provided to dateFormat:", date); + return "Invalid Date"; + } + + return new Intl.DateTimeFormat(locale).format(dateObj); + } catch (error) { + console.error("Error formatting date:", error); + return "Invalid Date"; + } }