From 999ce9cee2cd83b686a085d670fa975596908e1b Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Wed, 16 Apr 2025 15:28:45 +0330 Subject: [PATCH] update: the danak services module and add new route to fetch the danak service --- src/app.module.ts | 2 + src/common/enums/message.enum.ts | 44 +-- src/configs/typeorm.config.ts | 2 +- .../danak-services.controller.ts | 6 + .../entities/danak-service.entity.ts | 4 + .../providers/danak-services.service.ts | 117 +------- .../danak-services-review.repository.ts | 40 +++ .../repositories/danak-services.repository.ts | 89 +++++- .../discounts/DTO/create-discount.dto.ts | 74 ++--- .../DTO/discount-search-query.dto.ts | 12 - .../discounts/DTO/toggle-active.dto.ts | 7 + .../discounts/DTO/update-discount.dto.ts | 44 +++ .../discounts/DTO/validate-discount.dto.ts | 11 + src/modules/discounts/discounts.controller.ts | 101 ++++--- src/modules/discounts/discounts.module.ts | 32 ++- .../entities/discount-product.entity.ts | 14 + .../discounts/entities/discount.entity.ts | 44 ++- .../entities/usage-discount.entity.ts | 21 +- .../discounts/enums/discount-status.enum.ts | 5 + .../discounts/enums/discount-type.enum.ts | 9 +- .../discounts/providers/discounts.service.ts | 272 +++++++++--------- .../discount-services.repository.ts | 57 ++++ .../repositories/discount.repository.ts | 12 - .../repositories/discounts.repository.ts | 72 +++++ .../repositories/usage-discount.repository.ts | 12 - .../usage-discounts.repository.ts | 39 +++ .../DTO/apply-discount-invoice.dto.ts | 11 - src/modules/invoices/invoices.module.ts | 4 +- .../subscriptions/subscriptions.controller.ts | 12 +- src/modules/users/entities/user.entity.ts | 9 +- 30 files changed, 713 insertions(+), 465 deletions(-) mode change 100755 => 100644 src/modules/discounts/DTO/create-discount.dto.ts delete mode 100755 src/modules/discounts/DTO/discount-search-query.dto.ts create mode 100644 src/modules/discounts/DTO/toggle-active.dto.ts create mode 100644 src/modules/discounts/DTO/update-discount.dto.ts create mode 100644 src/modules/discounts/DTO/validate-discount.dto.ts mode change 100755 => 100644 src/modules/discounts/discounts.controller.ts mode change 100755 => 100644 src/modules/discounts/discounts.module.ts create mode 100644 src/modules/discounts/entities/discount-product.entity.ts mode change 100755 => 100644 src/modules/discounts/entities/discount.entity.ts mode change 100755 => 100644 src/modules/discounts/entities/usage-discount.entity.ts create mode 100644 src/modules/discounts/enums/discount-status.enum.ts mode change 100755 => 100644 src/modules/discounts/enums/discount-type.enum.ts mode change 100755 => 100644 src/modules/discounts/providers/discounts.service.ts create mode 100644 src/modules/discounts/repositories/discount-services.repository.ts delete mode 100755 src/modules/discounts/repositories/discount.repository.ts create mode 100644 src/modules/discounts/repositories/discounts.repository.ts delete mode 100755 src/modules/discounts/repositories/usage-discount.repository.ts create mode 100644 src/modules/discounts/repositories/usage-discounts.repository.ts delete mode 100755 src/modules/invoices/DTO/apply-discount-invoice.dto.ts diff --git a/src/app.module.ts b/src/app.module.ts index 157a186..8ad7649 100755 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { ContactUsModule } from "./modules/contact-us/contact-us.module"; import { CriticismModule } from "./modules/criticisms/criticisms.module"; import { DanakServicesModule } from "./modules/danak-services/danak-services.module"; import { DashboardModule } from "./modules/dashboards/dashboard.module"; +import { DiscountsModule } from "./modules/discounts/discounts.module"; import { InvoicesModule } from "./modules/invoices/invoices.module"; import { LandingModule } from "./modules/landing/landing.module"; import { LearningModule } from "./modules/learnings/learning.module"; @@ -72,6 +73,7 @@ import { WalletsModule } from "./modules/wallets/wallets.module"; // LoggerModule, LandingModule, ReferralsModule, + DiscountsModule, ], controllers: [], }) diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 64bc9f0..b37d975 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -484,36 +484,20 @@ export const enum LearningMessage { } export const enum DiscountMessage { - CAN_NOT_APPLICABLE = "این کد تخفیف برای این صورت حساب قابل اعمال نیست", - TYPE_REQUIRED = "وارد کردن نوع تخفیف الزامی است", - TYPE_INVALID = "نوع تخفیف نامعتبر است", - CALCULATION_TYPE_REQUIRED = "وارد کردن نوع محاسبه تخفیف الزامی است", - CALCULATION_TYPE_INVALID = "نوع محاسبه تخفیف نامعتبر است", - AMOUNT_REQUIRED = "وارد کردن مقدار تخفیف الزامی است", - AMOUNT_INVALID = "مقدار تخفیف باید عدد باشد", - TITLE_REQUIRED = "وارد کردن مقدار عنوان الزامی است", - TITLE_INVALID = "مقدار عنوان باید یک رشته باشد", - CODE_REQUIRED = "وارد کردن کد تخفیف الزامی است", - CODE_INVALID = "کد تخفیف نامعتبر است", - START_DATE_REQUIRED = "وارد کردن تاریخ شروع الزامی است", - START_DATE_INVALID = "تاریخ شروع نامعتبر است", - END_DATE_REQUIRED = "وارد کردن تاریخ پایان الزامی است", - END_DATE_INVALID = "تاریخ پایان نامعتبر است", - SERVICES_INVALID = "لیست خدمات باید آرایه‌ای از ایدی باشد", - SERVICES_DUPLICATE = "خدمات نباید تکراری باشند", - SERVICES_UUID_INVALID = "ایدی سرویس معتبرنیست", - USER_UUID_INVALID = "ایدی کاربر معتبرنیست", - USER_DUPLICATE = "کاربر نباید تکراری باشند", - USER_INVALID = "لیست کاربرا باید آرایه‌ای از ایدی باشد", - NOT_FOUND = "کد تخفیف با این مشخصات یافت نشد", - CODE_EXIST = "کد تخفیف قبلا ایجاد شده است", - INVOICE_ID_REQUIRED = "شناسه صورت حساب نمی‌تواند خالی باشد", - INVOICE_ID_STRING = "شناسه صورت حساب باید یک رشته باشد", - DISCOUNT_CODE_REQUIRED = "کد تخفیف نمی‌تواند خالی باشد", - DISCOUNT_CODE_STRING = "کد تخفیف باید یک رشته باشد", - DISCOUNT_NOT_FOUND = "کد تخفیف یافت نشد یا غیرفعال است", - DISCOUNT_NOT_APPLICABLE = "این کد تخفیف برای هیچ یک از پلن‌های اشتراک در صورت حساب قابل اعمال نیست", - ALREADY_USED = "این کد تخفیف قبلا استفاده شده است", + TITLE_STRING = "عنوان باید یک رشته باشد", + TITLE_REQUIRED = "عنوان الزامی است", + DISCOUNT_TYPE_REQUIRED = "نوع تخفیف الزامی است", + INVALID_DISCOUNT_TYPE = "نوع تخفیف نامعتبر است", + VALUE_MUST_BE_A_NUMBER = "قیمت باید یک عدد صحیح باشد", + VALUE_MUST_BE_AT_LEAST_0 = "قیمت باید حداقل ۰ باشد", + VALUE_MUST_NOT_EXCEED_100 = "قیمت باید حداکثر ۱۰۰ باشد", + START_DATE_MUST_BE_A_VALID_DATE_STRING = "تاریخ شروع باید یک تاریخ معتبر باشد", + START_DATE_IS_REQUIRED = "تاریخ شروع الزامی است", + END_DATE_MUST_BE_A_VALID_DATE_STRING = "تاریخ پایان باید یک تاریخ معتبر باشد", + END_DATE_IS_REQUIRED = "تاریخ پایان الزامی است", + IS_ACTIVE_MUST_BE_A_BOOLEAN = "وضعیت فعال بودن باید یک بولین باشد", + TARGET_PRODUCTS_MUST_BE_AN_ARRAY = "تعداد محصولات باید یک آرایه باشد", + EACH_TARGET_PRODUCT_MUST_BE_A_STRING = "هر محصول باید یک رشته باشد", } export const enum EmailMessage { diff --git a/src/configs/typeorm.config.ts b/src/configs/typeorm.config.ts index 8d8378f..9391ab2 100755 --- a/src/configs/typeorm.config.ts +++ b/src/configs/typeorm.config.ts @@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions { username: configService.getOrThrow("DB_USER"), password: configService.getOrThrow("DB_PASS"), autoLoadEntities: true, - synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : true, + synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : false, logging: configService.getOrThrow("NODE_ENV") == "production" ? false : true, migrationsTableName: "typeorm_migrations", migrationsRun: false, diff --git a/src/modules/danak-services/danak-services.controller.ts b/src/modules/danak-services/danak-services.controller.ts index ecda947..a0e72c7 100755 --- a/src/modules/danak-services/danak-services.controller.ts +++ b/src/modules/danak-services/danak-services.controller.ts @@ -141,6 +141,12 @@ export class DanakServicesController { return this.danakServicesService.getDanakServiceByIdWithSubs(paramDto.id, isAdmin, userId); } + @ApiOperation({ summary: "get danakService by id public" }) + @Get(":id/public") + getDanakServiceByIdPublic(@Param() paramDto: ParamDto) { + return this.danakServicesService.getDanakServiceByIdPublic(paramDto.id); + } + @ApiOperation({ summary: "update danak service" }) @AuthGuards() @PermissionsDec(PermissionEnum.SERVICES) diff --git a/src/modules/danak-services/entities/danak-service.entity.ts b/src/modules/danak-services/entities/danak-service.entity.ts index dea052c..1ad5b84 100755 --- a/src/modules/danak-services/entities/danak-service.entity.ts +++ b/src/modules/danak-services/entities/danak-service.entity.ts @@ -6,6 +6,7 @@ import { DanakServiceReview } from "./danak-service-review.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Ads } from "../../ads/entities/ads.entity"; import { Announcement } from "../../announcements/entities/announcement.entity"; +import { DiscountService } from "../../discounts/entities/discount-product.entity"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { Ticket } from "../../tickets/entities/ticket.entity"; import { ServicesLanguage } from "../enums/services-language.enum"; @@ -76,6 +77,9 @@ export class DanakService extends BaseEntity { @OneToMany(() => DanakServiceReview, (danakServiceReview) => danakServiceReview.service) reviews: DanakServiceReview[]; + @OneToMany(() => DiscountService, (discountService) => discountService.danakService) + discountServices: DiscountService[]; + @DeleteDateColumn() deletedAt?: Date; } diff --git a/src/modules/danak-services/providers/danak-services.service.ts b/src/modules/danak-services/providers/danak-services.service.ts index ac1f527..63441c9 100755 --- a/src/modules/danak-services/providers/danak-services.service.ts +++ b/src/modules/danak-services/providers/danak-services.service.ts @@ -328,22 +328,7 @@ export class DanakServicesService { /******************************************** */ async searchServices(queryDto: DanakServicesGlobalSearchDto) { - const servicesQuery = this.danakServicesRepository - .createQueryBuilder("service") - .leftJoin("service.category", "category") - .leftJoin("service.subscriptionPlans", "subscriptionPlans") - .select(["service.id", "service.name", "service.description", "service.icon", "category.id", "category.title"]) - .where("service.isActive = :isActive", { isActive: true }) - .andWhere("service.deletedAt IS NULL") - .andWhere("category.isActive = :categoryActive", { categoryActive: true }) - .andWhere("category.deletedAt IS NULL") - .andWhere("subscriptionPlans.isActive = :planActive", { planActive: true }); - - if (queryDto.q) { - servicesQuery.andWhere("service.name ILIKE :searchQuery", { searchQuery: `%${queryDto.q}%` }); - } - - const services = await servicesQuery.getMany(); + const services = await this.danakServicesRepository.searchServices(queryDto); return { services, @@ -381,57 +366,11 @@ export class DanakServicesService { /******************************************** */ async getDanakServiceByIdWithSubs(serviceId: string, isAdmin: boolean, userId: string) { - const serviceQueryBuilder = this.danakServicesRepository - .createQueryBuilder("service") - .where("service.deletedAt IS NULL") - .leftJoin("service.category", "category") - .addSelect(["category.id", "category.title"]) - .leftJoinAndSelect("service.images", "images") - .leftJoinAndSelect("service.subscriptionPlans", "subscriptionPlans") - .leftJoin("service.reviews", "reviews", "reviews.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED }) - .leftJoin("reviews.user", "user") - .addSelect([ - "reviews.id", - "reviews.comment", - "reviews.rating", - "reviews.title", - "reviews.createdAt", - "reviews.status", - "user.id", - "user.firstName", - "user.lastName", - "user.profilePic", - ]) - // .loadRelationCountAndMap("service.reviewCount", "service.reviews") - // .addSelect( - // (subQuery) => - // subQuery - // .select("CAST(AVG(rating) AS DECIMAL(10,2))", "averageRating") - // .from(DanakServiceReview, "r") - // .where("r.serviceId = service.id") - // .andWhere("r.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED }), - // "averageRating", - // ) - .andWhere("service.id = :serviceId", { serviceId }) - .groupBy("service.id,category.id, images.id, subscriptionPlans.id, reviews.id, user.id"); + const danakService = await this.danakServicesRepository.getDanakServiceById(serviceId, isAdmin); - if (!isAdmin) { - serviceQueryBuilder - .andWhere("service.isActive = :isActive", { isActive: true }) - .andWhere("subscriptionPlans.isActive = :planActive", { planActive: true }); - } - - const danakService = await serviceQueryBuilder.getOne(); if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); - const averageRatingResult = await this.danakServiceReviewRepository - .createQueryBuilder("r") - .select("CAST(AVG(r.rating) AS DECIMAL(10,2))", "averageRating") - .where("r.serviceId = :serviceId", { serviceId }) - .andWhere("r.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED }) - .getRawOne(); - - const averageRating = parseFloat(averageRatingResult?.averageRating) || 0; + const averageRating = await this.danakServiceReviewRepository.getAverageRating(danakService.id); const hasSubscription = await this.checkUserPurchasedService(userId, serviceId); @@ -443,25 +382,17 @@ export class DanakServicesService { } /******************************************** */ + async getDanakServiceByIdPublic(serviceId: string) { + const danakService = await this.danakServicesRepository.getDanakServiceById(serviceId, false); + if (!danakService) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); + return { danakService }; + } + /******************************************** */ + async getServiceUsers(serviceId: string) { await this.findServiceById(serviceId); - const users = await this.danakServicesRepository - .createQueryBuilder("service") - .innerJoin("service.subscriptionPlans", "subscriptionPlans") - .innerJoin("subscriptionPlans.userSubscriptions", "userSubscriptions") - .innerJoin("user", "user", "userSubscriptions.userId = user.id") - .select([ - "DISTINCT user.id AS id", - "user.firstName AS firstName", - "user.lastName AS lastName", - "user.email AS email", - "user.profilePic AS profilePic", - ]) - .where("service.id = :serviceId", { serviceId }) - .andWhere("userSubscriptions.status = :status", { status: SubscriptionStatus.ACTIVE }) - .andWhere("userSubscriptions.endDate > NOW()") - .getRawMany(); + const users = await this.danakServicesRepository.getUsersByService(serviceId); return { users }; } @@ -469,31 +400,7 @@ export class DanakServicesService { /******************************************** */ async getDanakServiceReviews(queryDto: DanakServiceReviewQueryDto) { - const { limit, skip } = PaginationUtils(queryDto); - - const reviewsQueryBuilder = this.danakServiceReviewRepository - .createQueryBuilder("review") - .leftJoinAndSelect("review.user", "user") - .leftJoinAndSelect("review.service", "service") - .select([ - "review.id", - "review.title", - "review.comment", - "review.rating", - "review.status", - "review.createdAt", - "user.id", - "user.firstName", - "user.lastName", - "user.profilePic", - "service.id", - "service.name", - ]) - .orderBy("review.createdAt", "DESC") - .skip(skip) - .take(limit); - - const [reviews, count] = await reviewsQueryBuilder.getManyAndCount(); + const [reviews, count] = await this.danakServiceReviewRepository.getDanakServiceReviews(queryDto); return { reviews, diff --git a/src/modules/danak-services/repositories/danak-services-review.repository.ts b/src/modules/danak-services/repositories/danak-services-review.repository.ts index 20e71f5..690ff4d 100755 --- a/src/modules/danak-services/repositories/danak-services-review.repository.ts +++ b/src/modules/danak-services/repositories/danak-services-review.repository.ts @@ -2,11 +2,51 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { DanakServiceReviewQueryDto } from "../DTO/danak-service-review-query.dto"; import { DanakServiceReview } from "../entities/danak-service-review.entity"; +import { ReviewStatus } from "../enums/review-status.enum"; @Injectable() export class DanakServiceReviewRepository extends Repository { constructor(@InjectRepository(DanakServiceReview) danakServiceReviewRepository: Repository) { super(danakServiceReviewRepository.target, danakServiceReviewRepository.manager, danakServiceReviewRepository.queryRunner); } + + async getAverageRating(serviceId: string): Promise { + const result = await this.createQueryBuilder("review") + .select("CAST(AVG(review.rating) AS DECIMAL(10,2))", "averageRating") + .where("review.serviceId = :serviceId", { serviceId }) + .andWhere("review.status = :status", { status: ReviewStatus.APPROVED }) + .getRawOne(); + + return result?.averageRating ? parseFloat(result.averageRating) : 0; + } + + async getDanakServiceReviews(queryDto: DanakServiceReviewQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const reviewsQueryBuilder = this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoinAndSelect("review.service", "service") + .select([ + "review.id", + "review.title", + "review.comment", + "review.rating", + "review.status", + "review.createdAt", + "user.id", + "user.firstName", + "user.lastName", + "user.profilePic", + "service.id", + "service.name", + ]) + .orderBy("review.createdAt", "DESC") + .skip(skip) + .take(limit); + + return reviewsQueryBuilder.getManyAndCount(); + } } diff --git a/src/modules/danak-services/repositories/danak-services.repository.ts b/src/modules/danak-services/repositories/danak-services.repository.ts index 3030c79..a6463ac 100755 --- a/src/modules/danak-services/repositories/danak-services.repository.ts +++ b/src/modules/danak-services/repositories/danak-services.repository.ts @@ -2,9 +2,11 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Brackets, IsNull, Not, Repository } from "typeorm"; +import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; -import { DanakServicesQueryDto, DanakServicesSearchQueryDto } from "../DTO/danak-services-search-query.dto"; +import { DanakServicesGlobalSearchDto, DanakServicesQueryDto, DanakServicesSearchQueryDto } from "../DTO/danak-services-search-query.dto"; import { DanakService } from "../entities/danak-service.entity"; +import { ReviewStatus } from "../enums/review-status.enum"; @Injectable() export class DanakServicesRepository extends Repository { @@ -86,4 +88,89 @@ export class DanakServicesRepository extends Repository { count, }; } + //+******************** + async getDanakServiceById(serviceId: string, isAdmin: boolean) { + const serviceQueryBuilder = this.createQueryBuilder("service") + .where("service.deletedAt IS NULL") + .leftJoin("service.category", "category") + .addSelect(["category.id", "category.title"]) + .leftJoinAndSelect("service.images", "images") + .leftJoinAndSelect("service.subscriptionPlans", "subscriptionPlans") + .leftJoin("service.reviews", "reviews", "reviews.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED }) + .leftJoin("reviews.user", "user") + .addSelect([ + "reviews.id", + "reviews.comment", + "reviews.rating", + "reviews.title", + "reviews.createdAt", + "reviews.status", + "user.id", + "user.firstName", + "user.lastName", + "user.profilePic", + ]) + // .loadRelationCountAndMap("service.reviewCount", "service.reviews") + // .addSelect( + // (subQuery) => + // subQuery + // .select("CAST(AVG(rating) AS DECIMAL(10,2))", "averageRating") + // .from(DanakServiceReview, "r") + // .where("r.serviceId = service.id") + // .andWhere("r.status = :reviewStatus", { reviewStatus: ReviewStatus.APPROVED }), + // "averageRating", + // ) + .andWhere("service.id = :serviceId", { serviceId }) + .groupBy("service.id,category.id, images.id, subscriptionPlans.id, reviews.id, user.id"); + + if (!isAdmin) { + serviceQueryBuilder + .andWhere("service.isActive = :isActive", { isActive: true }) + .andWhere("subscriptionPlans.isActive = :planActive", { planActive: true }); + } + + serviceQueryBuilder.orderBy("subscriptionPlans.price", "ASC"); + + const danakService = await serviceQueryBuilder.getOne(); + + return danakService; + } + //+******************** + async getUsersByService(serviceId: string) { + const users = await this.createQueryBuilder("service") + .innerJoin("service.subscriptionPlans", "subscriptionPlans") + .innerJoin("subscriptionPlans.userSubscriptions", "userSubscriptions") + .innerJoin("user", "user", "userSubscriptions.userId = user.id") + .select([ + "DISTINCT user.id AS id", + "user.firstName AS firstName", + "user.lastName AS lastName", + "user.email AS email", + "user.profilePic AS profilePic", + ]) + .where("service.id = :serviceId", { serviceId }) + .andWhere("userSubscriptions.status = :status", { status: SubscriptionStatus.ACTIVE }) + .andWhere("userSubscriptions.endDate > NOW()") + .getRawMany(); + + return users; + } + + async searchServices(queryDto: DanakServicesGlobalSearchDto) { + const servicesQuery = this.createQueryBuilder("service") + .leftJoin("service.category", "category") + .leftJoin("service.subscriptionPlans", "subscriptionPlans") + .select(["service.id", "service.name", "service.description", "service.icon", "category.id", "category.title"]) + .where("service.isActive = :isActive", { isActive: true }) + .andWhere("service.deletedAt IS NULL") + .andWhere("category.isActive = :categoryActive", { categoryActive: true }) + .andWhere("category.deletedAt IS NULL") + .andWhere("subscriptionPlans.isActive = :planActive", { planActive: true }); + + if (queryDto.q) { + servicesQuery.andWhere("service.name ILIKE :searchQuery", { searchQuery: `%${queryDto.q}%` }); + } + + return servicesQuery.getMany(); + } } diff --git a/src/modules/discounts/DTO/create-discount.dto.ts b/src/modules/discounts/DTO/create-discount.dto.ts old mode 100755 new mode 100644 index da11c54..7fb54be --- a/src/modules/discounts/DTO/create-discount.dto.ts +++ b/src/modules/discounts/DTO/create-discount.dto.ts @@ -1,51 +1,59 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ArrayUnique, IsArray, IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, + ValidateIf, +} from "class-validator"; import { DiscountMessage } from "../../../common/enums/message.enum"; -import { DiscountCalculationType, DiscountType } from "../enums/discount-type.enum"; +import { DiscountType } from "../enums/discount-type.enum"; export class CreateDiscountDto { - @ApiProperty({ description: "title of discount", type: String, example: "discount 1" }) @IsNotEmpty({ message: DiscountMessage.TITLE_REQUIRED }) - @IsString({ message: DiscountMessage.TITLE_INVALID }) + @IsString({ message: DiscountMessage.TITLE_STRING }) + @ApiProperty({ description: "The title of the discount", example: "Summer Sale" }) title: string; - @ApiProperty({ description: "discount type", enum: DiscountType, example: DiscountType.CODE }) - @IsNotEmpty({ message: DiscountMessage.TYPE_REQUIRED }) - @IsEnum(DiscountType, { message: DiscountMessage.TYPE_INVALID }) + @IsNotEmpty({ message: DiscountMessage.DISCOUNT_TYPE_REQUIRED }) + @IsEnum(DiscountType, { message: DiscountMessage.INVALID_DISCOUNT_TYPE }) + @ApiProperty({ description: "The type of discount", example: DiscountType.PERCENTAGE }) type: DiscountType; - @ApiProperty({ description: "calculation type", enum: DiscountCalculationType, example: DiscountCalculationType.FIXED }) - @IsNotEmpty({ message: DiscountMessage.CALCULATION_TYPE_REQUIRED }) - @IsEnum(DiscountCalculationType, { message: DiscountMessage.CALCULATION_TYPE_INVALID }) - calculationType: DiscountCalculationType; + @IsNumber({}, { message: DiscountMessage.VALUE_MUST_BE_A_NUMBER }) + @Min(0, { message: DiscountMessage.VALUE_MUST_BE_AT_LEAST_0 }) + @Max(100, { message: DiscountMessage.VALUE_MUST_NOT_EXCEED_100 }) + @Type(() => Number) + @ApiProperty({ description: "The value of the discount", example: 20 }) + value: number; - @ApiProperty({ description: "amount of discount", type: Number, example: 100 }) - @IsNotEmpty({ message: DiscountMessage.AMOUNT_REQUIRED }) - @IsNumber({}, { message: DiscountMessage.AMOUNT_INVALID }) - amount: number; - - @ApiProperty({ description: "start discount date", type: String, format: "date-time", example: "2025-01-01T00:00:00Z" }) - @IsNotEmpty({ message: DiscountMessage.START_DATE_REQUIRED }) - @IsDateString({}, { message: DiscountMessage.START_DATE_INVALID }) + @IsDateString({}, { message: DiscountMessage.START_DATE_MUST_BE_A_VALID_DATE_STRING }) + @IsNotEmpty({ message: DiscountMessage.START_DATE_IS_REQUIRED }) + @ApiProperty({ description: "The start date of the discount", example: "2023-06-01T00:00:00Z" }) startDate: string; - @ApiProperty({ description: "end discount date", type: String, format: "date-time", example: "2025-12-31T23:59:59Z" }) - @IsNotEmpty({ message: DiscountMessage.END_DATE_REQUIRED }) - @IsDateString({}, { message: DiscountMessage.END_DATE_INVALID }) + @IsDateString({}, { message: DiscountMessage.END_DATE_MUST_BE_A_VALID_DATE_STRING }) + @IsNotEmpty({ message: DiscountMessage.END_DATE_IS_REQUIRED }) + @ApiProperty({ description: "The end date of the discount", example: "2023-06-30T23:59:59Z" }) endDate: string; - @ApiPropertyOptional({ description: "لیست آیدی‌های خدمات", type: [String], example: ["f47ac10b-58cc-4372-a567-0e02b2c3d479"] }) + @IsBoolean({ message: DiscountMessage.IS_ACTIVE_MUST_BE_A_BOOLEAN }) @IsOptional() - @ArrayUnique({ message: DiscountMessage.SERVICES_DUPLICATE }) - @IsArray({ message: DiscountMessage.SERVICES_INVALID }) - @IsUUID("4", { each: true, message: DiscountMessage.SERVICES_UUID_INVALID }) - subscriptionPlanIds?: string[]; + @ApiProperty({ description: "Indicates if the discount is active", example: true }) + isActive?: boolean; - @ApiPropertyOptional({ description: "لیست آیدی‌های کاربران", type: [String], example: ["f47ac10b-58cc-4372-a567-0e02b2c3d479"] }) + @IsArray({ message: "Target products must be an array" }) @IsOptional() - @ArrayUnique({ message: DiscountMessage.USER_DUPLICATE }) - @IsArray({ message: DiscountMessage.USER_INVALID }) - @IsUUID("4", { each: true, message: DiscountMessage.USER_UUID_INVALID }) - userIds?: string[]; + @ValidateIf((o) => o.targetProducts !== null && o.targetProducts !== undefined) + @IsString({ each: true, message: "Each target product must be a string" }) + @ApiProperty({ description: "List of target product IDs", example: ["prod-1", "prod-2"] }) + targetProducts?: string[]; } diff --git a/src/modules/discounts/DTO/discount-search-query.dto.ts b/src/modules/discounts/DTO/discount-search-query.dto.ts deleted file mode 100755 index ea39f3b..0000000 --- a/src/modules/discounts/DTO/discount-search-query.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -// import { ApiPropertyOptional } from "@nestjs/swagger"; -// import { IsOptional, IsString } from "class-validator"; - -// import { PaginationDto } from "../../../common/DTO/pagination.dto"; -// import { FinancialMessage } from "../../../common/enums/message.enum"; - -// export class SearchDiscountsDto extends PaginationDto { -// @IsOptional() -// @IsString({ message: FinancialMessage.SEARCH_QUERY_MUST_BE_A_STRING }) -// @ApiPropertyOptional({ description: "search query", example: "search query" }) -// q: string; -// } diff --git a/src/modules/discounts/DTO/toggle-active.dto.ts b/src/modules/discounts/DTO/toggle-active.dto.ts new file mode 100644 index 0000000..91133ac --- /dev/null +++ b/src/modules/discounts/DTO/toggle-active.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsNotEmpty } from "class-validator"; + +export class ToggleActiveDto { + @IsBoolean() + @IsNotEmpty() + isActive: boolean; +} diff --git a/src/modules/discounts/DTO/update-discount.dto.ts b/src/modules/discounts/DTO/update-discount.dto.ts new file mode 100644 index 0000000..ed0fd90 --- /dev/null +++ b/src/modules/discounts/DTO/update-discount.dto.ts @@ -0,0 +1,44 @@ +import { Type } from "class-transformer"; +import { IsArray, IsBoolean, IsDateString, IsEnum, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from "class-validator"; + +import { DiscountStatus } from "../enums/discount-status.enum"; +import { DiscountType } from "../enums/discount-type.enum"; + +export class UpdateDiscountDto { + @IsString() + @IsOptional() + title?: string; + + @IsEnum(DiscountType) + @IsOptional() + type?: DiscountType; + + @IsEnum(DiscountStatus) + @IsOptional() + status?: DiscountStatus; + + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + @Type(() => Number) + value?: number; + + @IsDateString() + @IsOptional() + startDate?: string; + + @IsDateString() + @IsOptional() + endDate?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsArray() + @IsOptional() + @ValidateIf((o) => o.targetProducts !== null && o.targetProducts !== undefined) + @IsString({ each: true }) + targetProducts?: string[]; +} diff --git a/src/modules/discounts/DTO/validate-discount.dto.ts b/src/modules/discounts/DTO/validate-discount.dto.ts new file mode 100644 index 0000000..904eaaa --- /dev/null +++ b/src/modules/discounts/DTO/validate-discount.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class ValidateDiscountDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsString() + @IsOptional() + productId?: string; +} diff --git a/src/modules/discounts/discounts.controller.ts b/src/modules/discounts/discounts.controller.ts old mode 100755 new mode 100644 index c8a8ba5..96975a5 --- a/src/modules/discounts/discounts.controller.ts +++ b/src/modules/discounts/discounts.controller.ts @@ -1,58 +1,77 @@ -// import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; -// import { ApiOperation, ApiTags } from "@nestjs/swagger"; +// import { Body, Controller, Post } from "@nestjs/common"; +// import { ApiOperation, ApiResponse } from "@nestjs/swagger"; // import { CreateDiscountDto } from "./DTO/create-discount.dto"; -// // import { SearchDiscountsDto } from "./DTO/discount-search-query.dto"; -// import { DiscountService } from "./providers/discounts.service"; +// import { Discount } from "./entities/discount.entity"; +// import { DiscountsService } from "./providers/discounts.service"; +// import { AdminRoute } from "../../common/decorators/admin.decorator"; // import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; -// import { UserDec } from "../../common/decorators/user.decorator"; -// import { ParamDto } from "../../common/DTO/param.dto"; +// import { PermissionsDec } from "../../common/decorators/permission.decorator"; +// import { PermissionEnum } from "../users/enums/permission.enum"; -// @ApiTags("discounts") -// @AuthGuards() // @Controller("discounts") -// export class DiscountController { -// constructor(private readonly discountService: DiscountService) {} - -// //************************************ */ +// @AuthGuards() +// export class DiscountsController { +// constructor(private readonly discountsService: DiscountsService) {} // @ApiOperation({ summary: "Create a new discount" }) +// @AdminRoute() +// @PermissionsDec(PermissionEnum.DISCOUNTS) // @Post() -// async create(@Body() createDiscountDto: CreateDiscountDto) { -// return this.discountService.create(createDiscountDto); +// @ApiResponse({ status: 201, description: "Discount created successfully" }) +// create(@Body() createDiscountDto: CreateDiscountDto): Promise { +// return this.discountsService.create(createDiscountDto); // } -// //************************************ */ +// // @AdminRoute() +// // @PermissionsDec(PermissionEnum.DISCOUNTS) +// // @Get() +// // @ApiOperation({ summary: "Get all discounts" }) +// // findAll(@Query() query: any) {} -// @ApiOperation({ summary: "Retrieve all discounts" }) -// @Get() -// async findAll(@Query() queryDto: SearchDiscountsDto) { -// return this.discountService.findAll(queryDto); -// } +// // @AdminRoute() +// // @PermissionsDec(PermissionEnum.DISCOUNTS) +// // @Get(":id") +// // @ApiOperation({ summary: "Get discount by ID" }) +// // findOne(@Param("id") id: string): Promise { +// // return this.discountsService.findById(id); +// // } -// //************************************ */ +// // @AdminRoute() +// // @PermissionsDec(PermissionEnum.DISCOUNTS) +// // @Get(":id/products") +// // @ApiOperation({ summary: "Get products for a discount" }) +// // getProducts(@Param("id") id: string): Promise { +// // return this.discountsService.getProductsForDiscount(id); +// // } -// @ApiOperation({ summary: "Retrieve a discount by ID" }) -// @Get(":id") -// async findOne(@Param() paramDto: ParamDto) { -// return this.discountService.findOne(paramDto.id); -// } +// // @AdminRoute() +// // @PermissionsDec(PermissionEnum.DISCOUNTS) +// // @Put(":id") +// // @ApiOperation({ summary: "Update discount" }) +// // update(@Param("id") id: string, @Body() updateDiscountDto: UpdateDiscountDto): Promise { +// // return this.discountsService.update(id, updateDiscountDto); +// // } -// //************************************ */ +// // @AdminRoute() +// // @PermissionsDec(PermissionEnum.DISCOUNTS) +// // @Patch(":id/toggle-active") +// // @ApiOperation({ summary: "Toggle discount active state" }) +// // toggleActive(@Param("id") id: string, @Body() toggleActiveDto: ToggleActiveDto): Promise { +// // return this.discountsService.toggleActive(id, toggleActiveDto.isActive); +// // } -// @ApiOperation({ summary: "Retrieve a discounts by userID" }) -// @Get("/user-discounts") -// async findDiscountsByUsers(@Query() queryDto: SearchDiscountsDto, @UserDec("id") userId: string) { -// return this.discountService.findDiscountsByUser(queryDto, userId); -// } +// // @AdminRoute() +// // @PermissionsDec(PermissionEnum.DISCOUNTS) +// // @Delete(":id") +// // @ApiOperation({ summary: "Delete discount" }) +// // remove(@Param("id") id: string): Promise { +// // return this.discountsService.delete(id); +// // } -// //************************************ */ - -// @ApiOperation({ summary: "Toggle active/deactive discount" }) -// @Patch(":id/toggle") -// async toggleActive(@Param() paramDto: ParamDto) { -// return this.discountService.toggleActive(paramDto.id); -// } - -// //************************************ */ +// // @Post("validate") +// // @ApiOperation({ summary: "Validate discount code" }) +// // validateDiscount(@Body() validateDiscountDto: ValidateDiscountDto, @UserDec() user: User): Promise { +// // return this.discountsService.validateDiscount(validateDiscountDto.code, user, validateDiscountDto.productId); +// // } // } diff --git a/src/modules/discounts/discounts.module.ts b/src/modules/discounts/discounts.module.ts old mode 100755 new mode 100644 index bc4d677..c327e04 --- a/src/modules/discounts/discounts.module.ts +++ b/src/modules/discounts/discounts.module.ts @@ -1,16 +1,20 @@ -// import { Module } from "@nestjs/common"; -// import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; -// import { DiscountController } from "./discounts.controller"; -// import { Discount } from "./entities/discount.entity"; -// import { DiscountService } from "./providers/discounts.service"; -// import { DiscountRepository } from "./repositories/discount.repository"; -// import { SubscriptionsModule } from "../subscriptions/subscriptions.module"; -// import { UsersModule } from "../users/users.module"; +// import { DiscountsController } from "./discounts.controller"; +import { UsersModule } from "../users/users.module"; +import { DiscountService } from "./entities/discount-product.entity"; +import { Discount } from "./entities/discount.entity"; +import { UsageDiscount } from "./entities/usage-discount.entity"; +// import { DiscountsService } from "./providers/discounts.service"; +// import { DiscountServicesRepository } from "./repositories/discount-services.repository"; +// import { DiscountRepository } from "./repositories/discounts.repository"; +// import { UsageDiscountRepository } from "./repositories/usage-discounts.repository"; -// @Module({ -// imports: [TypeOrmModule.forFeature([Discount]), SubscriptionsModule, UsersModule], -// providers: [DiscountService, DiscountRepository], -// controllers: [DiscountController], -// }) -// export class DiscountModule {} +@Module({ + imports: [TypeOrmModule.forFeature([Discount, UsageDiscount, DiscountService]), UsersModule], + // controllers: [DiscountsController], + // providers: [DiscountsService, DiscountRepository, UsageDiscountRepository, DiscountServicesRepository], + // exports: [DiscountsService], +}) +export class DiscountsModule {} diff --git a/src/modules/discounts/entities/discount-product.entity.ts b/src/modules/discounts/entities/discount-product.entity.ts new file mode 100644 index 0000000..c2f0df4 --- /dev/null +++ b/src/modules/discounts/entities/discount-product.entity.ts @@ -0,0 +1,14 @@ +import { Entity, ManyToOne } from "typeorm"; + +import { Discount } from "./discount.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { DanakService } from "../../danak-services/entities/danak-service.entity"; + +@Entity() +export class DiscountService extends BaseEntity { + @ManyToOne(() => Discount, (discount) => discount.discountServices, { nullable: false, onDelete: "CASCADE" }) + discount: Discount; + + @ManyToOne(() => DanakService, (danakService) => danakService.discountServices, { nullable: false }) + danakService: DanakService; +} diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts old mode 100755 new mode 100644 index d6cd924..583c195 --- a/src/modules/discounts/entities/discount.entity.ts +++ b/src/modules/discounts/entities/discount.entity.ts @@ -1,34 +1,24 @@ -// eslint-disable-next-line import/no-named-as-default -import Decimal from "decimal.js"; -import { Column, Entity, JoinTable, ManyToMany, OneToMany } from "typeorm"; +import { Column, Entity, OneToMany } from "typeorm"; +import { DiscountService } from "./discount-product.entity"; 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 { DiscountCalculationType, DiscountType } from "../enums/discount-type.enum"; +import { DiscountType } from "../enums/discount-type.enum"; @Entity() export class Discount extends BaseEntity { - @Column({ type: "varchar", length: 200, nullable: false }) + @Column({ type: "varchar", length: 150 }) title: string; - @Column({ type: "enum", enum: DiscountType }) + @Column({ type: "varchar", length: 100, unique: true }) + code: string; + + @Column({ type: "enum", enum: DiscountType, default: DiscountType.PERCENTAGE }) type: DiscountType; - @Column({ type: "enum", enum: DiscountCalculationType }) - calculationType: DiscountCalculationType; - - @Column({ type: "decimal", precision: 16, scale: 2, nullable: false, transformer: new DecimalTransformer() }) - amount: Decimal; - - @Column({ type: "boolean", default: true }) - isActive: boolean; - - @Column({ nullable: true }) - code?: string; + @Column({ type: "decimal", precision: 16, scale: 2, transformer: new DecimalTransformer() }) + value: number; @Column({ type: "timestamptz" }) startDate: Date; @@ -36,16 +26,14 @@ export class Discount extends BaseEntity { @Column({ type: "timestamptz" }) endDate: Date; - @ManyToMany(() => SubscriptionPlan, { nullable: true }) - @JoinTable() - subscriptionPlans: SubscriptionPlan[]; + @Column({ type: "int", default: 0 }) + usedCount: number; - @ManyToMany(() => User, (user) => user.discounts, { nullable: true }) - @JoinTable() - users: User[]; + @Column({ type: "boolean", default: true }) + isActive: boolean; - @OneToMany(() => Invoice, (invoice) => invoice.discount) - invoice: Invoice; + @OneToMany(() => DiscountService, (discountService) => discountService.discount, { cascade: true }) + discountServices: DiscountService[]; @OneToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.discount) usages: UsageDiscount[]; diff --git a/src/modules/discounts/entities/usage-discount.entity.ts b/src/modules/discounts/entities/usage-discount.entity.ts old mode 100755 new mode 100644 index d48fc73..47a436e --- a/src/modules/discounts/entities/usage-discount.entity.ts +++ b/src/modules/discounts/entities/usage-discount.entity.ts @@ -1,18 +1,23 @@ -import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from "typeorm"; +// eslint-disable-next-line import/no-named-as-default +import Decimal from "decimal.js"; +import { Column, Entity, ManyToOne } from "typeorm"; import { Discount } from "./discount.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; import { User } from "../../users/entities/user.entity"; @Entity() export class UsageDiscount extends BaseEntity { - @Column({ type: "timestamptz" }) - usageDate: Date; - - @ManyToOne(() => Discount, (discount) => discount.usages) + @ManyToOne(() => Discount, (discount) => discount.usages, { nullable: false, onDelete: "CASCADE" }) discount: Discount; - @ManyToMany(() => User, (user) => user.usageDiscounts) - @JoinTable() - users: User[]; + @ManyToOne(() => User, { nullable: false, onDelete: "CASCADE" }) + user: User; + + @Column({ type: "decimal", precision: 16, scale: 2, transformer: new DecimalTransformer() }) + appliedAmount: Decimal; + + @Column({ type: "timestamptz", nullable: false }) + usedAt: Date; } diff --git a/src/modules/discounts/enums/discount-status.enum.ts b/src/modules/discounts/enums/discount-status.enum.ts new file mode 100644 index 0000000..b1c970a --- /dev/null +++ b/src/modules/discounts/enums/discount-status.enum.ts @@ -0,0 +1,5 @@ +export enum DiscountStatus { + ACTIVE = "active", + INACTIVE = "inactive", + EXPIRED = "expired", +} diff --git a/src/modules/discounts/enums/discount-type.enum.ts b/src/modules/discounts/enums/discount-type.enum.ts old mode 100755 new mode 100644 index 91a6669..b0e3979 --- a/src/modules/discounts/enums/discount-type.enum.ts +++ b/src/modules/discounts/enums/discount-type.enum.ts @@ -1,9 +1,4 @@ export enum DiscountType { - DIRECT = "DIRECT", - CODE = "CODE", -} - -export enum DiscountCalculationType { - FIXED = "FIXED", - PERCENTAGE = "PERCENTAGE", + PERCENTAGE = 0, + FIXED_AMOUNT = 1, } diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts old mode 100755 new mode 100644 index 6e6f858..8694288 --- a/src/modules/discounts/providers/discounts.service.ts +++ b/src/modules/discounts/providers/discounts.service.ts @@ -1,182 +1,194 @@ -// import { randomBytes } from "node:crypto"; - // import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -// import { In } from "typeorm"; +// // eslint-disable-next-line import/no-named-as-default +// import Decimal from "decimal.js"; -// import { CommonMessage, DiscountMessage, SubscriptionMessage, UserMessage } from "../../../common/enums/message.enum"; -// import { SubscriptionsPlanRepository } from "../../subscriptions/repositories/subscriptions.repository"; -// import { UserRepository } from "../../users/repositories/users.repository"; -// import { PaginationUtils } from "../../utils/providers/pagination.utils"; +// import { User } from "../../users/entities/user.entity"; // import { CreateDiscountDto } from "../DTO/create-discount.dto"; -// import { SearchDiscountsDto } from "../DTO/discount-search-query.dto"; -// import { DiscountRepository } from "../repositories/discount.repository"; +// import { UpdateDiscountDto } from "../DTO/update-discount.dto"; +// import { Discount } from "../entities/discount.entity"; +// import { DiscountType } from "../enums/discount-type.enum"; +// import { DiscountServicesRepository } from "../repositories/discount-services.repository"; +// import { DiscountRepository } from "../repositories/discounts.repository"; +// import { UsageDiscountRepository } from "../repositories/usage-discounts.repository"; // @Injectable() -// export class DiscountService { +// export class DiscountsService { // constructor( // private readonly discountRepository: DiscountRepository, -// private readonly subscriptionPlanRepository: SubscriptionsPlanRepository, -// private readonly userRepository: UserRepository, +// private readonly discountServicesRepository: DiscountServicesRepository, +// private readonly usageDiscountRepository: UsageDiscountRepository, // ) {} -// /******************************************** */ - // async create(createDiscountDto: CreateDiscountDto) { -// const { subscriptionPlanIds, ...discountData } = createDiscountDto; +// const existingDiscount = await this.discountRepository.findByCode(createDiscountDto.title); +// if (existingDiscount) { +// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.ALREADY_EXISTS); +// } -// const code = await this.generateUniqueCouponCode(); +// // Create the discount without product relations +// const { targetProducts, ...discountData } = createDiscountDto; -// const discount = this.discountRepository.create({ +// const discount = await this.discountRepository.create({ // ...discountData, -// code, +// startDate: new Date(createDiscountDto.startDate), +// endDate: new Date(createDiscountDto.endDate), +// status: createDiscountDto.status || DiscountStatus.ACTIVE, +// isActive: createDiscountDto.isActive !== undefined ? createDiscountDto.isActive : true, // }); -// if (createDiscountDto.subscriptionPlanIds && createDiscountDto.subscriptionPlanIds.length) { -// const subscriptionPlans = await this.subscriptionPlanRepository.find({ -// where: { id: In(createDiscountDto.subscriptionPlanIds) }, -// }); - -// if (subscriptionPlans.length !== createDiscountDto.subscriptionPlanIds.length) { -// throw new NotFoundException(SubscriptionMessage.NOT_FOUND); -// } - -// discount.subscriptionPlans = subscriptionPlans; +// // Add product relations if provided +// if (targetProducts && targetProducts.length > 0) { +// await this.discountServicesRepository.createMany(discount.id, targetProducts); // } -// if (createDiscountDto.userIds) { -// const users = await this.userRepository.find({ where: { id: In(createDiscountDto.userIds) } }); - -// if (users.length !== createDiscountDto.userIds.length) { -// throw new NotFoundException(UserMessage.USER_NOT_FOUND); -// } - -// discount.users = users; +// // Fetch the complete discount with relations +// const fullDiscount = await this.discountRepository.findById(discount.id); +// if (!fullDiscount) { +// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); // } - -// await this.discountRepository.save(discount); - -// return { -// message: CommonMessage.CREATED, -// discount, -// }; +// return fullDiscount; // } -// /******************************************** */ +// async findAll(options?: any): Promise<[Discount[], number]> { +// return this.discountRepository.findAll(options); +// } -// async findByCode(code: string) { -// const discount = await this.discountRepository.findOne({ -// where: { code, isActive: true }, -// relations: ["subscriptionPlans"], -// }); +// async findById(id: string): Promise { +// const discount = await this.discountRepository.findById(id); // if (!discount) { -// throw new NotFoundException(DiscountMessage.NOT_FOUND); +// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); // } -// return { discount }; +// return discount; // } -// /******************************************** */ +// async update(id: string, updateDiscountDto: UpdateDiscountDto): Promise { +// // Validate discount exists +// await this.findById(id); -// async findDiscountsByUser(queryDto: SearchDiscountsDto, userId: string) { -// const user = await this.userRepository.findOneBy({ -// id: userId, -// }); -// if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); +// // Handle product relations separately +// const { targetProducts, startDate: startDateStr, endDate: endDateStr, ...otherFields } = updateDiscountDto; -// const { limit, skip } = PaginationUtils(queryDto); +// // Initialize update data with other fields +// const updateData: Partial = { ...otherFields }; -// const queryBuilder = this.discountRepository.createQueryBuilder("discount"); - -// queryBuilder -// .leftJoin("discount.users", "user") -// .where("user.id = :userId", { userId: user.id }) -// .leftJoinAndSelect("discount.subscriptionPlans", "subscriptionPlans") -// .leftJoin("subscriptionPlans.service", "service") -// .addSelect(["service.id", "service.name", "service.title", "service.link", "service.icon", "service.description"]); - -// if (queryDto.q) { -// queryBuilder.andWhere("discount.code ILIKE :q", { q: `%${queryDto.q}%` }); +// // Add date fields if they exist +// if (startDateStr) { +// updateData.startDate = new Date(startDateStr); // } -// queryBuilder.orderBy("discount.createdAt", "DESC").skip(skip).take(limit); - -// const [discounts, count] = await queryBuilder.getManyAndCount(); - -// return { -// discounts, -// count, -// paginate: true, -// }; -// } - -// /******************************************** */ - -// async findAll(queryDto: SearchDiscountsDto) { -// const { limit, skip } = PaginationUtils(queryDto); - -// const queryBuilder = this.discountRepository.createQueryBuilder("discount"); - -// queryBuilder -// .leftJoinAndSelect("discount.subscriptionPlans", "subscriptionPlans") -// .leftJoin("subscriptionPlans.service", "service") -// .addSelect(["service.id", "service.name", "service.title", "service.link", "service.icon", "service.description"]) -// .leftJoinAndSelect("discount.users", "users"); - -// if (queryDto.q) { -// queryBuilder.andWhere("discount.code ILIKE :q", { q: `%${queryDto.q}%` }); +// if (endDateStr) { +// updateData.endDate = new Date(endDateStr); // } -// queryBuilder.orderBy("discount.createdAt", "DESC").skip(skip).take(limit); +// // Update the discount entity +// const updatedDiscount = await this.discountRepository.update(id, updateData); +// if (!updatedDiscount) { +// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); +// } -// const [discounts, count] = await queryBuilder.getManyAndCount(); +// // Handle product relations if provided +// if (targetProducts !== undefined) { +// // Remove existing relations +// await this.discountServicesRepository.deleteByDiscountId(id); -// return { -// discounts, -// count, -// paginate: true, -// }; +// // Add new relations if not empty +// if (targetProducts.length > 0) { +// await this.discountServicesRepository.createMany(id, targetProducts); +// } +// } + +// // Return the updated discount with relations +// return this.findById(id); // } -// /******************************************** */ - -// async findOne(id: string) { -// const discount = await this.discountRepository.findOne({ -// where: { id }, -// relations: ["subscriptionPlans"], -// }); -// if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); -// return { discount }; +// async delete(id: string): Promise { +// const discount = await this.findById(id); +// await this.discountRepository.delete(discount.id); // } -// /******************************************** */ +// async toggleActive(id: string, isActive: boolean): Promise { +// await this.findById(id); +// const updatedDiscount = await this.discountRepository.update(id, { isActive }); +// if (!updatedDiscount) { +// throw new NotFoundException(DISCOUNT_ERROR_MESSAGES.NOT_FOUND); +// } +// return updatedDiscount; +// } + +// async validateDiscount(code: string, user: User, productId?: string): Promise { +// const discount = await this.discountRepository.findValidByCode(code); -// async toggleActive(id: string) { -// const discount = await this.discountRepository.findOne({ where: { id } }); // if (!discount) { -// throw new BadRequestException(DiscountMessage.NOT_FOUND); +// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID); // } -// discount.isActive = !discount.isActive; +// // Check if user has already used this discount +// const usageCount = await this.usageDiscountRepository.countByUserAndDiscount(user.id, discount.id); +// if (usageCount > 0) { +// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.ALREADY_USED); +// } -// const updatedDiscount = await this.discountRepository.save(discount); +// // Check if product is valid for this discount +// if (productId && discount.discountServices && discount.discountServices.length > 0) { +// const hasProduct = await this.discountServicesRepository.hasProductForDiscount(discount.id, productId); +// if (!hasProduct) { +// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID_PRODUCT); +// } +// } -// return { -// message: CommonMessage.UPDATE_SUCCESS, -// updatedDiscount, -// }; +// return discount; // } -// /******************************************** */ +// async applyDiscount(amount: number, discountId: string, productId?: string): Promise { +// const discount = await this.findById(discountId); -// async generateUniqueCouponCode(): Promise { -// const code = this.generateCouponCode(); -// const exists = await this.discountRepository.findOne({ where: { code } }); -// if (exists) return this.generateUniqueCouponCode(); -// return code; +// if (!discount.isValid) { +// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID); +// } + +// // Check if product is valid for this discount +// if (productId && discount.discountServices && discount.discountServices.length > 0) { +// const hasProduct = await this.discountServicesRepository.hasProductForDiscount(discount.id, productId); +// if (!hasProduct) { +// throw new BadRequestException(DISCOUNT_ERROR_MESSAGES.INVALID_PRODUCT); +// } +// } + +// let discountAmount = 0; + +// if (discount.type === DiscountType.PERCENTAGE) { +// discountAmount = (amount * discount.value) / 100; +// } else if (discount.type === DiscountType.FIXED_AMOUNT) { +// discountAmount = discount.value; +// } + +// // Ensure discount doesn't exceed the original amount +// if (discountAmount > amount) { +// discountAmount = amount; +// } + +// return discountAmount; // } -// /******************************************** */ +// async recordDiscountUsage(userId: string, discountId: string, appliedAmount: number): Promise { +// const discount = await this.findById(discountId); -// private generateCouponCode(length = 8) { -// return randomBytes(length).toString("hex").slice(0, length).toUpperCase(); +// // Increment usage count +// await this.discountRepository.update(discount.id, { +// usedCount: discount.usedCount + 1, +// }); + +// // Record usage +// await this.usageDiscountRepository.create({ +// user: { id: userId } as User, +// discount: { id: discountId } as Discount, +// appliedAmount: new Decimal(appliedAmount), +// usedAt: new Date(), +// }); +// } + +// async getProductsForDiscount(discountId: string): Promise { +// const discountServices = await this.discountServicesRepository.findByDiscountId(discountId); +// return discountServices.map((service) => service.danakService.id); // } // } diff --git a/src/modules/discounts/repositories/discount-services.repository.ts b/src/modules/discounts/repositories/discount-services.repository.ts new file mode 100644 index 0000000..6980b89 --- /dev/null +++ b/src/modules/discounts/repositories/discount-services.repository.ts @@ -0,0 +1,57 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { DiscountService } from "../entities/discount-product.entity"; +@Injectable() +export class DiscountServicesRepository extends Repository { + constructor(@InjectRepository(DiscountService) repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } + + async createMany(discountId: string, productIds: string[]): Promise { + const discountServices = productIds.map((productId) => + this.create({ + discount: { id: discountId }, + danakService: { id: productId }, + }), + ); + return this.save(discountServices); + } + + async findByDiscountId(discountId: string): Promise { + return this.find({ + where: { discount: { id: discountId } }, + }); + } + + async findByProductId(productId: string): Promise { + return this.find({ + where: { danakService: { id: productId } }, + relations: ["discount"], + }); + } + + async deleteByDiscountId(discountId: string): Promise { + await this.createQueryBuilder().delete().where("discountId = :discountId", { discountId }).execute(); + } + + async hasProductForDiscount(discountId: string, productId: string): Promise { + const count = await this.count({ + where: { + discount: { id: discountId }, + danakService: { id: productId }, + }, + }); + return count > 0; + } + + async findDiscountIdsForProduct(productId: string): Promise { + const discountServices = await this.find({ + where: { danakService: { id: productId } }, + select: ["discount"], + relations: ["discount"], + }); + return discountServices.map((dp) => dp.discount.id); + } +} diff --git a/src/modules/discounts/repositories/discount.repository.ts b/src/modules/discounts/repositories/discount.repository.ts deleted file mode 100755 index a8f64d1..0000000 --- a/src/modules/discounts/repositories/discount.repository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { Discount } from "../entities/discount.entity"; - -@Injectable() -export class DiscountRepository extends Repository { - constructor(@InjectRepository(Discount) discountRepository: Repository) { - super(discountRepository.target, discountRepository.manager, discountRepository.queryRunner); - } -} diff --git a/src/modules/discounts/repositories/discounts.repository.ts b/src/modules/discounts/repositories/discounts.repository.ts new file mode 100644 index 0000000..853a0f2 --- /dev/null +++ b/src/modules/discounts/repositories/discounts.repository.ts @@ -0,0 +1,72 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Discount } from "../entities/discount.entity"; + +@Injectable() +export class DiscountRepository extends Repository { + constructor(@InjectRepository(Discount) repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } + + async findById(id: string): Promise { + return this.findOne({ + where: { id }, + relations: ["productRelations"], + }); + } + + async findByCode(code: string): Promise { + return this.findOne({ + where: { code }, + relations: ["productRelations"], + }); + } + + async findActiveByCode(code: string): Promise { + return this.findOne({ + where: { + code, + isActive: true, + }, + relations: ["productRelations"], + }); + } + + async findAll(options?: any): Promise<[Discount[], number]> { + const query = this.createQueryBuilder("discount").leftJoinAndSelect("discount.productRelations", "productRelations"); + + if (options?.status) { + query.andWhere("discount.status = :status", { status: options.status }); + } + + if (options?.isActive !== undefined) { + query.andWhere("discount.isActive = :isActive", { isActive: options.isActive }); + } + + if (options?.search) { + query.andWhere("(discount.title ILIKE :search OR discount.code ILIKE :search)", { + search: `%${options.search}%`, + }); + } + + if (options?.productId) { + query.andWhere("EXISTS (SELECT 1 FROM discount_product dp WHERE dp.discountId = discount.id AND dp.productId = :productId)", { + productId: options.productId, + }); + } + + if (options?.sort) { + const [field, order] = options.sort.split(","); + query.orderBy(`discount.${field}`, order.toUpperCase()); + } else { + query.orderBy("discount.createdAt", "DESC"); + } + + return query + .skip(options?.skip || 0) + .take(options?.take || 10) + .getManyAndCount(); + } +} diff --git a/src/modules/discounts/repositories/usage-discount.repository.ts b/src/modules/discounts/repositories/usage-discount.repository.ts deleted file mode 100755 index cbfba96..0000000 --- a/src/modules/discounts/repositories/usage-discount.repository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { UsageDiscount } from "../entities/usage-discount.entity"; - -@Injectable() -export class UsageDiscountRepository extends Repository { - constructor(@InjectRepository(UsageDiscount) usageDiscountRepository: Repository) { - super(usageDiscountRepository.target, usageDiscountRepository.manager, usageDiscountRepository.queryRunner); - } -} diff --git a/src/modules/discounts/repositories/usage-discounts.repository.ts b/src/modules/discounts/repositories/usage-discounts.repository.ts new file mode 100644 index 0000000..b2c91f4 --- /dev/null +++ b/src/modules/discounts/repositories/usage-discounts.repository.ts @@ -0,0 +1,39 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { UsageDiscount } from "../entities/usage-discount.entity"; + +@Injectable() +export class UsageDiscountRepository extends Repository { + constructor(@InjectRepository(UsageDiscount) repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } + + async findByUserAndDiscount(userId: string, discountId: string): Promise { + return this.findOne({ + where: { + user: { id: userId }, + discount: { id: discountId }, + }, + relations: ["user", "discount"], + }); + } + + async countByDiscount(discountId: string): Promise { + return this.count({ + where: { + discount: { id: discountId }, + }, + }); + } + + async countByUserAndDiscount(userId: string, discountId: string): Promise { + return this.count({ + where: { + user: { id: userId }, + discount: { id: discountId }, + }, + }); + } +} diff --git a/src/modules/invoices/DTO/apply-discount-invoice.dto.ts b/src/modules/invoices/DTO/apply-discount-invoice.dto.ts deleted file mode 100755 index 3d13f92..0000000 --- a/src/modules/invoices/DTO/apply-discount-invoice.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsString } from "class-validator"; - -import { DiscountMessage } from "../../../common/enums/message.enum"; - -export class ApplyDiscountDto { - @ApiProperty({ description: "Discount code" }) - @IsString({ message: DiscountMessage.DISCOUNT_CODE_STRING }) - @IsNotEmpty({ message: DiscountMessage.DISCOUNT_CODE_REQUIRED }) - discountCode: string; -} diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index 0fdab38..c32db75 100755 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -10,8 +10,8 @@ import { InvoicesService } from "./providers/invoices.service"; import { InvoicesRepository } from "./repositories/invoices.repository"; import { Discount } from "../discounts/entities/discount.entity"; import { UsageDiscount } from "../discounts/entities/usage-discount.entity"; -import { DiscountRepository } from "../discounts/repositories/discount.repository"; -import { UsageDiscountRepository } from "../discounts/repositories/usage-discount.repository"; +import { DiscountRepository } from "../discounts/repositories/discounts.repository"; +import { UsageDiscountRepository } from "../discounts/repositories/usage-discounts.repository"; import { NotificationModule } from "../notifications/notifications.module"; import { UsersModule } from "../users/users.module"; import { UtilsModule } from "../utils/utils.module"; diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts index 5354dfe..0164a94 100755 --- a/src/modules/subscriptions/subscriptions.controller.ts +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -16,6 +16,7 @@ import { ParamDto } from "../../common/DTO/param.dto"; import { PermissionEnum } from "../users/enums/permission.enum"; @Controller("subscriptions") +@AuthGuards() export class SubscriptionsController { constructor( private readonly subscriptionService: SubscriptionsService, @@ -23,7 +24,6 @@ export class SubscriptionsController { ) {} @ApiOperation({ summary: "Create a subscription plan" }) - @AuthGuards() @PermissionsDec(PermissionEnum.SERVICES) @Post() createSubscription(@Body() createDto: AddSubscriptionsToServiceDto) { @@ -31,7 +31,6 @@ export class SubscriptionsController { } @ApiOperation({ summary: "get all subscription plans" }) - @AuthGuards() @PermissionsDec(PermissionEnum.SERVICES) @Get("service/:serviceId") getServiceSubscriptions(@Param() paramDto: ServiceIdParamDto, @Query() queryDto: ServiceSubsQueryDto) { @@ -39,21 +38,18 @@ export class SubscriptionsController { } @ApiOperation({ summary: "get all user subscriptions ==> user route" }) - @AuthGuards() @Get("user") getUserSubscriptions(@UserDec("id") userId: string, @Query() queryDto: SearchUserSubsQueryDto) { return this.subscriptionService.getUserSubscriptions(userId, queryDto); } @ApiOperation({ summary: "get a user subscription by id ==> user route" }) - @AuthGuards() @Get("user/:id") getUserSubscriptionById(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { return this.subscriptionService.getUserSubscriptionById(paramDto.id, userId); } @ApiOperation({ summary: "toggle status of service subs" }) - @AuthGuards() @PermissionsDec(PermissionEnum.SERVICES) @Post("toggle-status/:id") toggleStatusOfServiceSub(@Param() paramDto: ParamDto) { @@ -61,7 +57,6 @@ export class SubscriptionsController { } @ApiOperation({ summary: "get a subscription plan by id" }) - @AuthGuards() @Get(":id") @PermissionsDec(PermissionEnum.SERVICES) getSubscription(@Param() paramDto: ParamDto) { @@ -69,7 +64,6 @@ export class SubscriptionsController { } @ApiOperation({ summary: "update a subscription plan by id" }) - @AuthGuards() @Patch(":id") //TODO:fix this if needed @PermissionsDec(PermissionEnum.SERVICES) @@ -78,28 +72,24 @@ export class SubscriptionsController { } @ApiOperation({ summary: "subscribe to a service ==> user route" }) - @AuthGuards() @Post(":serviceId/subscribe") subscribe(@Param() paramDto: ServiceIdParamDto, @Body() subscribeDto: SubscribeServiceDto, @UserDec("id") userId: string) { return this.subscriptionService.subscribeToPlan(paramDto.serviceId, subscribeDto, userId); } @ApiOperation({ summary: "add quick access a service ==> user route" }) - @AuthGuards() @Post(":id/add-quick-access") addQuickAccess(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { return this.userQuickAccessService.addQuickAccess(userId, paramDto.id); } @ApiOperation({ summary: "get quick access a service ==> user route" }) - @AuthGuards() @Get("quick-access") getQuickAccess(@UserDec("id") userId: string) { return this.userQuickAccessService.getUserQuickAccess(userId); } @ApiOperation({ summary: "remove quick access a service ==> user route" }) - @AuthGuards() @Delete(":id/remove-quick-access") removeQuickAccess(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { return this.userQuickAccessService.removeQuickAccess(userId, paramDto.id); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 4d84c2b..105ea7d 100755 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -12,8 +12,6 @@ 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 { UsageDiscount } from "../../discounts/entities/usage-discount.entity"; import { Invoice } from "../../invoices/entities/invoice.entity"; import { LearningProgress } from "../../learnings/entities/learning-progress.entity"; import { Notification } from "../../notifications/entities/notification.entity"; @@ -115,17 +113,14 @@ export class User extends BaseEntity { @OneToMany(() => LearningProgress, (learningProgress) => learningProgress.learning) learningProgress: LearningProgress[]; - @ManyToMany(() => Discount, (discount) => discount.subscriptionPlans) - discounts: Discount[]; - @OneToOne(() => RealUser, (realUser) => realUser.user, { cascade: true, nullable: true }) realUser: RealUser; @OneToOne(() => LegalUser, (legalUser) => legalUser.user, { cascade: true, nullable: true }) legalUser: LegalUser; - @ManyToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.users) - usageDiscounts: UsageDiscount[]; + // @ManyToMany(() => UsageDiscount, (usageDiscount) => usageDiscount.users) + // usageDiscounts: UsageDiscount[]; @OneToMany(() => UserAnnouncement, (userAnnouncement) => userAnnouncement.user) userAnnouncements: UserAnnouncement[];