diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 473e013..fa7b6f9 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -579,6 +579,7 @@ export const enum DiscountMessage { UPDATED = "تخفیف با موفقیت بروزرسانی شد", DEACTIVATED = "تخفیف با موفقیت غیرفعال شد", TARGET_SERVICES_REQUIRED_FOR_DIRECT_DISCOUNT = "برای تخفیف مستقیم باید حداقل یک سرویس انتخاب شود", + USER_ID_MUST_BE_A_UUID = "شناسه کاربر باید یک UUID معتبر باشد", } export const enum EmailMessage { diff --git a/src/modules/announcements/DTO/create-announcement.dto.ts b/src/modules/announcements/DTO/create-announcement.dto.ts index 005dfd2..4342f19 100755 --- a/src/modules/announcements/DTO/create-announcement.dto.ts +++ b/src/modules/announcements/DTO/create-announcement.dto.ts @@ -31,7 +31,7 @@ export class CreateAnnouncementDto { @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: Date; + publishAt: string; @IsNotEmpty({ message: AnnouncementMessage.IMPORTANT_IS_REQUIRED }) @IsBoolean({ message: AnnouncementMessage.IMPORTANT_MUST_BE_BOOLEAN }) diff --git a/src/modules/discounts/DTO/create-discount.dto.ts b/src/modules/discounts/DTO/create-discount.dto.ts index 6b86cfa..8dfeb8b 100755 --- a/src/modules/discounts/DTO/create-discount.dto.ts +++ b/src/modules/discounts/DTO/create-discount.dto.ts @@ -72,6 +72,15 @@ export class CreateDiscountDto { }) targetServices?: string[]; + @IsOptional() + @ValidateIf((o) => !o.direct) + @IsUUID("4", { message: DiscountMessage.USER_ID_MUST_BE_A_UUID }) + @ApiPropertyOptional({ + description: "The ID of the user this discount is specific to. If not provided, the discount will be available to all users", + example: "123e4567-e89b-12d3-a456-426614174000", + }) + userId?: string; + // @IsString() // @IsOptional() // @ApiPropertyOptional({ description: "The code of the discount", example: "SUMMER2023" }) diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts index 639b36d..fa81b58 100755 --- a/src/modules/discounts/entities/discount.entity.ts +++ b/src/modules/discounts/entities/discount.entity.ts @@ -1,10 +1,11 @@ -import { Column, DeleteDateColumn, Entity, OneToMany } from "typeorm"; +import { Column, DeleteDateColumn, Entity, ManyToOne, OneToMany } from "typeorm"; import { UsageDiscount } from "./usage-discount.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; import { Invoice } from "../../invoices/entities/invoice.entity"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; +import { User } from "../../users/entities/user.entity"; import { DiscountApplicationType } from "../enums/discount-application-type.enum"; import { DiscountType } from "../enums/discount-type.enum"; @@ -40,7 +41,9 @@ export class Discount extends BaseEntity { @DeleteDateColumn({ nullable: true }) deletedAt?: Date; - // Relations + @ManyToOne(() => User, (user) => user.discounts, { nullable: true }) + user?: User | null; + @OneToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.discount) usages: UsageDiscount[]; diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts index 7478846..0caee1f 100755 --- a/src/modules/discounts/providers/discounts.service.ts +++ b/src/modules/discounts/providers/discounts.service.ts @@ -5,11 +5,12 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { Queue } from "bullmq"; import dayjs from "dayjs"; import { Decimal } from "decimal.js"; -import { DataSource, QueryRunner } from "typeorm"; +import { DataSource, FindOptionsWhere, QueryRunner } from "typeorm"; import { CommonMessage, DiscountMessage } from "../../../common/enums/message.enum"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { SubscriptionsService } from "../../subscriptions/providers/subscriptions.service"; +import { UsersService } from "../../users/providers/users.service"; import { DISCOUNT } from "../constants"; import { CreateDiscountDto } from "../DTO/create-discount.dto"; import { SearchDiscountQueryDto } from "../DTO/search-discount-query.dto"; @@ -27,6 +28,7 @@ export class DiscountsService { @InjectQueue(DISCOUNT.DISCOUNT_QUEUE_NAME) private readonly discountQueue: Queue, private readonly discountRepository: DiscountRepository, private readonly subscriptionService: SubscriptionsService, + private readonly usersService: UsersService, private readonly dataSource: DataSource, ) {} @@ -34,9 +36,7 @@ export class DiscountsService { // PUBLIC METHODS //====================================== - /** - * Create a new discount - */ + //************************************************************ */ async create(createDiscountDto: CreateDiscountDto) { const queryRunner = this.dataSource.createQueryRunner(); @@ -63,9 +63,7 @@ export class DiscountsService { } } - /** - * Get all discounts for admin panel with pagination - */ + //************************************************************ */ async getDiscountsForAdmin(queryDto: SearchDiscountQueryDto) { const [discounts, count] = await this.discountRepository.findDiscountForAdmin(queryDto); @@ -76,9 +74,7 @@ export class DiscountsService { }; } - /** - * Get a discount by ID - */ + //************************************************************ */ async getDiscountById(id: string) { const discount = await this.discountRepository.findById(id); if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); @@ -86,9 +82,7 @@ export class DiscountsService { return { discount }; } - /** - * Toggle discount active status - */ + //************************************************************ */ async toggleDiscountStatus(id: string) { const discount = await this.discountRepository.findById(id); if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); @@ -102,9 +96,7 @@ export class DiscountsService { }; } - /** - * Update an existing discount - */ + //************************************************************ */ async updateDiscount(id: string, updateDiscountDto: UpdateDiscountDto) { const queryRunner = this.dataSource.createQueryRunner(); @@ -176,9 +168,7 @@ export class DiscountsService { }; } - /** - * Soft delete a discount - */ + //************************************************************ */ async softDeleteDiscount(id: string) { const queryRunner = this.dataSource.createQueryRunner(); try { @@ -211,9 +201,7 @@ export class DiscountsService { } } - /** - * Deactivate a discount and update related subscription plans - */ + //************************************************************ */ async deactivateDiscount(id: string, queryRunner: QueryRunner) { const discount = await queryRunner.manager.findOne(this.discountRepository.target, { where: { id, isActive: true }, @@ -246,13 +234,18 @@ export class DiscountsService { // PRIVATE METHODS //====================================== - /** - * Create a discount entity and handle code generation or direct discount application - */ + //************************************************************ */ + private async createDiscount(createDiscountDto: CreateDiscountDto, queryRunner: QueryRunner) { + const { userId } = createDiscountDto; + if (userId) { + await this.usersService.findOneByIdWithQueryRunner(userId, queryRunner); + } + const discount = queryRunner.manager.create(this.discountRepository.target, { ...createDiscountDto, applicationType: createDiscountDto.direct ? DiscountApplicationType.DIRECT : DiscountApplicationType.CODE_BASED, + user: !createDiscountDto.direct && userId ? { id: userId } : null, }); if (!createDiscountDto.direct) { @@ -269,9 +262,8 @@ export class DiscountsService { return discount; } - /** - * Apply a direct discount to subscription plans - */ + //************************************************************ */ + private async applyDirectDiscount(createDiscountDto: CreateDiscountDto, discount: Discount, queryRunner: QueryRunner) { const subscriptionPlans = await this.subscriptionService.getAllServiceSubscriptions( createDiscountDto.targetServices ?? [], @@ -305,9 +297,7 @@ export class DiscountsService { await queryRunner.manager.save(this.discountRepository.target, discount); } - /** - * Update a direct discount's subscription plans - */ + //************************************************************ */ private async updateDirectDiscount(updateDiscountDto: UpdateDiscountDto, discount: Discount, queryRunner: QueryRunner) { // Get all subscription plans for the target services const subscriptionPlans = await this.subscriptionService.getAllServiceSubscriptions( @@ -380,9 +370,7 @@ export class DiscountsService { return subscriptionPlans; } - /** - * Clear direct discount from plans - */ + //************************************************************ */ private async clearDirectDiscountFromPlans(discount: Discount, queryRunner: QueryRunner) { const subscriptionPlans = await queryRunner.manager.find(SubscriptionPlan, { where: { directDiscount: { id: discount.id } }, @@ -396,9 +384,7 @@ export class DiscountsService { } } - /** - * Generate a discount code for code-based discounts - */ + //************************************************************ */ private async generateDiscountCode(discount: Discount, queryRunner: QueryRunner) { let attempts = 0; let code: string; @@ -415,26 +401,27 @@ export class DiscountsService { discount.code = code; } - /** - * Generate a random discount code - */ + //************************************************************ */ private async generateRandomCode(length: number = 4) { return randomBytes(length).toString("hex").toUpperCase(); } - /** - * Find a discount by code - */ - private async findDiscountByCode(code: string, queryRunner: QueryRunner) { + //************************************************************ */ + private async findDiscountByCode(code: string, queryRunner: QueryRunner, userId?: string) { + const where: FindOptionsWhere = { code, isActive: true }; + + // If userId is provided, only return discounts that are either global or specific to this user + if (userId) { + where.user = { id: userId }; + } + return queryRunner.manager.findOne(this.discountRepository.target, { - where: { code, isActive: true }, + where, withDeleted: false, }); } - /** - * Calculate discount value based on discount type - */ + //************************************************************ */ private calculateDiscountValue(createDiscountDto: CreateDiscountDto): number { if (createDiscountDto.type === DiscountType.PERCENTAGE) return createDiscountDto.value; if (createDiscountDto.type === DiscountType.FIXED_AMOUNT) return createDiscountDto.value; @@ -442,9 +429,7 @@ export class DiscountsService { throw new BadRequestException(DiscountMessage.INVALID_DISCOUNT_TYPE); } - /** - * Validate discount start and end dates - */ + //************************************************************ */ private async validateDates(startDate: string, endDate: string) { if (dayjs(startDate).isAfter(dayjs(endDate))) { throw new BadRequestException(DiscountMessage.START_DATE_MUST_BE_BEFORE_END_DATE); @@ -463,18 +448,14 @@ export class DiscountsService { } } - /** - * Remove discount deactivation job - */ + //************************************************************ */ private async removeDiscountDeactivationJob(discountId: string) { const jobs = await this.discountQueue.getJobs(["delayed", "waiting", "active"]); const jobToRemove = jobs.find((job) => job.name === DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME && job.data.discountId === discountId); if (jobToRemove) await jobToRemove.remove(); } - /** - * Add discount deactivation job - */ + //************************************************************ */ private async addDiscountDeactivationJob(discountId: string, endDate: string) { return this.discountQueue.add( DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME, diff --git a/src/modules/discounts/repositories/discounts.repository.ts b/src/modules/discounts/repositories/discounts.repository.ts index a473820..b47e94f 100755 --- a/src/modules/discounts/repositories/discounts.repository.ts +++ b/src/modules/discounts/repositories/discounts.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { FindOptionsWhere, Repository } from "typeorm"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { SearchDiscountQueryDto } from "../DTO/search-discount-query.dto"; @@ -20,15 +20,27 @@ export class DiscountRepository extends Repository { subscriptionPlans: { service: true, }, + user: true, }, - withDeleted: false, }); } - async findByCode(code: string) { + async findByCode(code: string, userId?: string) { + const where: FindOptionsWhere = { code, isActive: true }; + + if (userId) { + where.user = { id: userId }; + } + return this.findOne({ - where: { code, isActive: true }, + where, withDeleted: false, + relations: { + subscriptionPlans: { + service: true, + }, + user: true, + }, }); } @@ -45,11 +57,30 @@ export class DiscountRepository extends Repository { async findDiscountForAdmin(queryDto: SearchDiscountQueryDto) { const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.createQueryBuilder("discount") - .leftJoin("discount.subscriptionPlans", "plan") - .addSelect(["plan.id", "plan.name", "plan.price", "plan.duration"]) - .leftJoin("plan.service", "service") - .addSelect(["service.id", "service.name"]) - .where("discount.deletedAt IS NULL"); + .leftJoinAndSelect("discount.subscriptionPlans", "subscriptionPlans") + .leftJoinAndSelect("subscriptionPlans.service", "service") + .leftJoinAndSelect("discount.user", "user") + .where("discount.deletedAt IS NULL") + .select([ + "discount.id", + "discount.title", + "discount.type", + "discount.code", + "discount.value", + "discount.startDate", + "discount.endDate", + "discount.isActive", + "discount.applicationType", + "discount.userId", + "discount.createdAt", + "subscriptionPlans.id", + "service.id", + "service.name", + "user.id", + "user.firstName", + "user.lastName", + "user.email", + ]); if (queryDto.q) { queryBuilder.andWhere("discount.title ILIKE :query", { query: `%${queryDto.q}%` }); @@ -61,8 +92,8 @@ export class DiscountRepository extends Repository { async softDeleteDiscount(id: string) { return this.softDelete(id); } - - async findDiscountForUser(queryDto: SearchDiscountQueryDto) { + //************************************************************ */ + async findDiscountForUser(queryDto: SearchDiscountQueryDto, userId?: string) { const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.createQueryBuilder("discount") .where("discount.deletedAt IS NULL") @@ -70,6 +101,7 @@ export class DiscountRepository extends Repository { .andWhere("discount.startDate <= :now", { now: new Date() }) .andWhere("discount.endDate >= :now", { now: new Date() }) .andWhere("discount.applicationType = :type", { type: DiscountApplicationType.CODE_BASED }) + .leftJoinAndSelect("discount.user", "user") .select([ "discount.id", "discount.title", @@ -79,8 +111,17 @@ export class DiscountRepository extends Repository { "discount.startDate", "discount.endDate", "discount.createdAt", + "discount.userId", + "user.id", + "user.firstName", + "user.lastName", + "user.email", ]); + if (userId) { + queryBuilder.andWhere("(discount.userId = :userId OR discount.userId IS NULL)", { userId }); + } + if (queryDto.q) { queryBuilder.andWhere("discount.title ILIKE :query", { query: `%${queryDto.q}%` }); } diff --git a/src/modules/invoices/queue/invoice.processor.ts b/src/modules/invoices/queue/invoice.processor.ts index 0958c08..09ef404 100755 --- a/src/modules/invoices/queue/invoice.processor.ts +++ b/src/modules/invoices/queue/invoice.processor.ts @@ -8,6 +8,9 @@ import { WorkerProcessor } from "../../../common/queues/worker.processor"; import { LoggerService } from "../../logger/logger.service"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum"; +import { SupportPlan } from "../../support-plans/entities/support-plan.entity"; +import { UserSupportPlan } from "../../support-plans/entities/user-support-plan.entity"; +import { UserSupportPlanStatus } from "../../support-plans/enums/user-support-plan-status.enum"; import { User } from "../../users/entities/user.entity"; import { RoleEnum } from "../../users/enums/role.enum"; import { INVOICE } from "../constants"; @@ -210,6 +213,16 @@ export class InvoiceProcessor extends WorkerProcessor { invoiceId: invoice.numericId.toString(), }); } + if (invoice.items[0]?.supportPlan) { + this.logger.log( + `Invoice ${invoice.id} has been cancelled as it is more than 10 days overdue. Support Plan ${invoice.items[0].supportPlan.id} has been cancelled.`, + ); + const userSupportPlan = invoice.items[0].supportPlan; + userSupportPlan.status = UserSupportPlanStatus.INACTIVE; + await queryRunner.manager.save(UserSupportPlan, userSupportPlan); + + await this.applyFreeSupportPlan(invoice, queryRunner); + } this.logger.log(`Invoice ${invoice.id} has been cancelled as it is more than 10 days overdue.`); } //********************************** */ @@ -348,4 +361,39 @@ export class InvoiceProcessor extends WorkerProcessor { await queryRunner.release(); } } + + //********************************** */ + private async applyFreeSupportPlan(invoice: Invoice, queryRunner: QueryRunner) { + const freeSupportPlan = await queryRunner.manager.findOne(SupportPlan, { + where: { isFree: true }, + }); + + if (!freeSupportPlan) throw new Error(`No free support plan found`); + + const startDate = dayjs().toDate(); + const endDate = dayjs().add(freeSupportPlan.duration, "day").toDate(); + + const userSupportPlan = queryRunner.manager.create(UserSupportPlan, { + user: invoice.user, + supportPlan: freeSupportPlan, + startDate, + endDate, + }); + + await queryRunner.manager.save(UserSupportPlan, userSupportPlan); + + const invoiceDueDate = dayjs(userSupportPlan.startDate).add(INVOICE.DUEDATE, "day").toDate(); + + const newInvoice = await this.invoicesService.createInvoiceForSupportPlan( + invoice.user, + freeSupportPlan, + userSupportPlan, + invoiceDueDate, + queryRunner, + ); + + userSupportPlan.status = UserSupportPlanStatus.ACTIVE; + await queryRunner.manager.save(UserSupportPlan, userSupportPlan); + await this.invoicesService.payInvoice(newInvoice.id, invoice.user.id, queryRunner); + } } diff --git a/src/modules/support-plans/providers/support-plans.service.ts b/src/modules/support-plans/providers/support-plans.service.ts index 4c8c78f..98a9bb9 100644 --- a/src/modules/support-plans/providers/support-plans.service.ts +++ b/src/modules/support-plans/providers/support-plans.service.ts @@ -175,7 +175,7 @@ export class SupportPlansService { async getUserSupportPlanOfUser(userId: string) { const userSupportPlan = await this.userSupportPlanRepo.findOne({ where: { user: { id: userId }, status: UserSupportPlanStatus.ACTIVE }, - relations: { supportPlan: true }, + relations: { supportPlan: { features: true } }, }); return { userSupportPlan }; diff --git a/src/modules/tickets/providers/tickets.service.ts b/src/modules/tickets/providers/tickets.service.ts index c782cbb..c1cc968 100755 --- a/src/modules/tickets/providers/tickets.service.ts +++ b/src/modules/tickets/providers/tickets.service.ts @@ -143,16 +143,14 @@ export class TicketsService { async createTicket(createDto: CreateTicketDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + const [user, ticketCategory] = await Promise.all([ this.usersService.findOneByIdWithQueryRunner(userId, queryRunner), - queryRunner.manager.findOne(TicketCategory, { - where: { id: createDto.categoryId }, - relations: { group: { users: true } }, - }), + queryRunner.manager.findOne(TicketCategory, { where: { id: createDto.categoryId }, relations: { group: { users: true } } }), ]); if (!ticketCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND); @@ -161,16 +159,11 @@ export class TicketsService { let service = null; if (createDto.danakServiceId) { - service = await queryRunner.manager.findOneBy(DanakService, { - id: createDto.danakServiceId, - }); + service = await queryRunner.manager.findOneBy(DanakService, { id: createDto.danakServiceId }); if (!service) throw new BadRequestException(TicketMessageEnum.SERVICE_NOT_FOUND); } - const ticketMessage: Partial = { - content: createDto.message, - author: user, - }; + const ticketMessage: Partial = { content: createDto.message, author: user }; if (createDto.attachmentUrls?.length) { ticketMessage.attachments = createDto.attachmentUrls.map((url) => { diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 733d6ce..1b966fd 100755 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -12,6 +12,7 @@ import { BlogComment } from "../../blogs/entities/blog-comment.entity"; import { Blog } from "../../blogs/entities/blog.entity"; import { Criticism } from "../../criticisms/entities/criticism.entity"; import { DanakServiceReview } from "../../danak-services/entities/danak-service-review.entity"; +import { Discount } from "../../discounts/entities/discount.entity"; import { Invoice } from "../../invoices/entities/invoice.entity"; import { LearningProgress } from "../../learnings/entities/learning-progress.entity"; import { Notification } from "../../notifications/entities/notification.entity"; @@ -125,6 +126,9 @@ export class User extends BaseEntity { // @ManyToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.users) // usageDiscounts: UsageDiscount[]; + @OneToMany(() => Discount, (discount) => discount.user) + discounts: Discount[]; + @OneToMany(() => UserAnnouncement, (userAnnouncement) => userAnnouncement.user) userAnnouncements: UserAnnouncement[];