From b5c90e8c2ed440c6612eb3725ca4609736f1a4a9 Mon Sep 17 00:00:00 2001 From: Mahyargdz Date: Sun, 18 May 2025 10:19:16 +0330 Subject: [PATCH] chore: add business logic --- src/common/enums/message.enum.ts | 5 + src/modules/auth/providers/tokens.service.ts | 2 +- .../dashboards/providers/dashboard.service.ts | 52 ----- .../invoices/providers/invoices.service.ts | 1 + .../providers/notifications.service.ts | 1 + .../DTO/subscribe-service.dto.ts | 15 +- src/modules/subscriptions/constants/index.ts | 11 + .../entities/user-subscription.entity.ts | 9 +- .../providers/subscriptions.service.ts | 195 ++++++------------ .../subscriptions/subscriptions.controller.ts | 6 + .../subscriptions/subscriptions.module.ts | 6 + src/modules/utils/providers/email.service.ts | 3 +- 12 files changed, 119 insertions(+), 187 deletions(-) create mode 100644 src/modules/subscriptions/constants/index.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 8f7358a..3f8c80b 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -478,6 +478,11 @@ export const enum SubscriptionMessage { BUSINESS_DESCRIPTION_STRING = "توضیحات کسب و کار باید یک رشته باشد", IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت اشتراک باید یکی از مقادیر ۰ و ۱ باشد", NOT_PURCHASED_CANNOT_REVIEW = "شما این اشتراک را خریداری نکرده‌اید و نمی‌توانید نظر بدهید", + SLUG_REQUIRED = "اسلاگ نمی‌تواند خالی باشد", + SLUG_STRING = "اسلاگ باید یک رشته باشد", + SLUG_LENGTH = "اسلاگ باید بین ۴ تا ۲۵۰ کاراکتر باشد", + SLUG_EXISTS = "این اسلاگ قبلا ثبت شده است", + USER_DOES_NOT_HAVE_ACCESS = "شما دسترسی به این سرویس ندارید", } export const enum InvoiceMessage { diff --git a/src/modules/auth/providers/tokens.service.ts b/src/modules/auth/providers/tokens.service.ts index 9dba7b3..bf262fd 100755 --- a/src/modules/auth/providers/tokens.service.ts +++ b/src/modules/auth/providers/tokens.service.ts @@ -37,7 +37,7 @@ export class TokensService { const accessExpire = this.configService.getOrThrow("ACCESS_TOKEN_EXPIRE"); const refreshExpire = this.configService.getOrThrow("REFRESH_TOKEN_EXPIRE"); - const accessToken = this.jwtService.sign(payload, { expiresIn: `${accessExpire}m` }); + const accessToken = this.jwtService.sign(payload, { expiresIn: `${accessExpire}h` }); const refreshToken = this.jwtService.sign({ id: payload.id }, { expiresIn: `${refreshExpire}d` }); diff --git a/src/modules/dashboards/providers/dashboard.service.ts b/src/modules/dashboards/providers/dashboard.service.ts index 04bb52c..a11ca07 100755 --- a/src/modules/dashboards/providers/dashboard.service.ts +++ b/src/modules/dashboards/providers/dashboard.service.ts @@ -46,24 +46,12 @@ export class DashboardService { const invoicesCount = await this.getInvoicesCount(); const unreadTickets = await this.unreadTickets(); const adsCount = await this.getAdsCount(); - const planRevenueAverage = await this.getPlanRevenueAverage(); - const plansSalesCount = await this.getPlansSalesCount(); - const subscriptionGrowthRate = await this.getSubscriptionGrowthRate(); - const plansSalesCountByMonthsInYear = await this.getPlansSalesCountByMonthsInYear(); - const plansSalesCountByDaysInMonth = await this.getPlansSalesCountByDaysInMonth(); - const plansSalesCountByDaysInWeek = await this.getPlansSalesCountByDaysInWeek(); return { danakServicesCount, customersCount, invoicesCount, unreadTickets, adsCount, - planRevenueAverage, - plansSalesCount, - subscriptionGrowthRate, - plansSalesCountByMonthsInYear, - plansSalesCountByDaysInMonth, - plansSalesCountByDaysInWeek, }; } @@ -103,44 +91,4 @@ export class DashboardService { } //************************************ */ - - private async getPlanRevenueAverage() { - const planRevenueCount = await this.subscriptionsService.getPlanRevenueAverage(); - return planRevenueCount; - } - - //************************************ */ - - private async getPlansSalesCount() { - const plansSalesCount = await this.subscriptionsService.getPlansSalesCount(); - return plansSalesCount; - } - - //************************************ */ - - private async getSubscriptionGrowthRate() { - const subscriptionGrowthRate = await this.subscriptionsService.getSubscriptionGrowthRate(); - return subscriptionGrowthRate; - } - - //************************************ */ - - private async getPlansSalesCountByMonthsInYear() { - const plansSalesCountByMonthsInYear = await this.subscriptionsService.getPlansSalesCountByMonthsInYear(); - return plansSalesCountByMonthsInYear; - } - - //************************************ */ - - private async getPlansSalesCountByDaysInMonth() { - const plansSalesCountByDaysInMonth = await this.subscriptionsService.getPlansSalesCountByDaysInMonth(); - return plansSalesCountByDaysInMonth; - } - - //************************************ */ - - private async getPlansSalesCountByDaysInWeek() { - const plansSalesCountByDaysInWeek = await this.subscriptionsService.getPlansSalesCountByDaysInWeek(); - return plansSalesCountByDaysInWeek; - } } diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 1f2e8a7..250073e 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -578,6 +578,7 @@ export class InvoicesService { await this.walletsService.createSubscriptionTransaction(invoice.totalPrice, userWallet.id, queryRunner); await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUBSCRIPTION_WALLET_TRANSFER); await this.addNotifyAdminForSubscriptionPaymentToQueue(invoice); + // } else if (invoice.items[0]?.supportPlan) { const oldUserSupportPlan = await queryRunner.manager.findOne(UserSupportPlan, { diff --git a/src/modules/notifications/providers/notifications.service.ts b/src/modules/notifications/providers/notifications.service.ts index 5052824..22377f9 100755 --- a/src/modules/notifications/providers/notifications.service.ts +++ b/src/modules/notifications/providers/notifications.service.ts @@ -515,6 +515,7 @@ export class NotificationsService { await this.emailService.sendAdminNotificationEmail({ userEmail: data.userEmail, title: NotificationMessage.NEW_SUBSCRIPTION, + fullName: data.fullName, message, }); } diff --git a/src/modules/subscriptions/DTO/subscribe-service.dto.ts b/src/modules/subscriptions/DTO/subscribe-service.dto.ts index b62e636..0ddc0b1 100755 --- a/src/modules/subscriptions/DTO/subscribe-service.dto.ts +++ b/src/modules/subscriptions/DTO/subscribe-service.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsPhoneNumber, IsString, IsUUID, Length } from "class-validator"; +import { IsArray, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUUID, Length } from "class-validator"; import { SubscriptionMessage } from "../../../common/enums/message.enum"; @@ -25,4 +25,17 @@ export class SubscribeServiceDto { @Length(5, 500, { message: SubscriptionMessage.BUSINESS_DESCRIPTION_LENGTH }) @ApiProperty({ description: "A short description of the business or service", example: "We offer the best subscription plans." }) description: string; + + @IsOptional() + @IsNotEmpty({ message: SubscriptionMessage.SLUG_REQUIRED }) + @IsString({ message: SubscriptionMessage.SLUG_STRING }) + @Length(4, 250, { message: SubscriptionMessage.SLUG_LENGTH }) + @ApiProperty({ description: "Unique slug for public URLs", example: "my-restaurant", required: false }) + slug?: string; + + @IsOptional() + @IsArray() + @IsUUID(4, { each: true }) + @ApiProperty({ description: "Array of user IDs to add as staff", type: [String], required: false }) + staff?: string[]; } diff --git a/src/modules/subscriptions/constants/index.ts b/src/modules/subscriptions/constants/index.ts new file mode 100644 index 0000000..cd38ef6 --- /dev/null +++ b/src/modules/subscriptions/constants/index.ts @@ -0,0 +1,11 @@ +export const SUBSCRIPTIONS = Object.freeze({ + PROVISIONING_QUEUE_PREFIX: "provisioning", + PROVISIONING_QUEUE_NAME: "provisioning", + PROVISIONING_JOB_NAME: "business.created", + + PROVISIONING_JOB_DELAY: 1000, + PROVISIONING_JOB_PRIORITY: 1, // high priority + PROVISIONING_JOB_ATTEMPTS: 3, // retry 3 times + PROVISIONING_JOB_BACKOFF: 5 * 1000, // retry after 5 second + PROVISIONING_JOB_TIMEOUT: 10000, // +}); diff --git a/src/modules/subscriptions/entities/user-subscription.entity.ts b/src/modules/subscriptions/entities/user-subscription.entity.ts index 629e526..adb6197 100755 --- a/src/modules/subscriptions/entities/user-subscription.entity.ts +++ b/src/modules/subscriptions/entities/user-subscription.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, ManyToOne } from "typeorm"; +import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from "typeorm"; import { SubscriptionPlan } from "./subscription.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; @@ -28,6 +28,13 @@ export class UserSubscription extends BaseEntity { @Column({ type: "text", nullable: false }) description: string; + @Column({ type: "varchar", length: 250, unique: true, nullable: true }) + slug: string; + @Column({ type: "enum", enum: SubscriptionStatus, default: SubscriptionStatus.INACTIVE }) status: SubscriptionStatus; + + @ManyToMany(() => User) + @JoinTable() + staff: User[]; } diff --git a/src/modules/subscriptions/providers/subscriptions.service.ts b/src/modules/subscriptions/providers/subscriptions.service.ts index df886eb..6b2fc3b 100755 --- a/src/modules/subscriptions/providers/subscriptions.service.ts +++ b/src/modules/subscriptions/providers/subscriptions.service.ts @@ -1,14 +1,19 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bullmq"; +import { BadRequestException, ForbiddenException, Injectable } from "@nestjs/common"; +import { Queue } from "bullmq"; import dayjs from "dayjs"; import Decimal from "decimal.js"; +import slugify from "slugify"; import { DataSource, In, Not, QueryRunner } from "typeorm"; import { ServiceMessage, SubscriptionMessage } from "../../../common/enums/message.enum"; import { DanakServicesService } from "../../danak-services/providers/danak-services.service"; import { INVOICE } from "../../invoices/constants"; import { InvoicesService } from "../../invoices/providers/invoices.service"; +import { User } from "../../users/entities/user.entity"; import { UsersService } from "../../users/providers/users.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { SUBSCRIPTIONS } from "../constants"; import { AddSubscriptionsToServiceDto } from "../DTO/create-subscription.dto"; import { SearchUserSubsQueryDto } from "../DTO/search-user-subs-query.dto"; import { ServiceSubsQueryDto } from "../DTO/service-subs-query.dto"; @@ -23,6 +28,7 @@ import { UserSubscriptionsRepository } from "../repositories/user-subscriptions. @Injectable() export class SubscriptionsService { constructor( + @InjectQueue(SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME) private readonly provisioningQueue: Queue, private readonly subscriptionsPlanRepository: SubscriptionsPlanRepository, private readonly userSubscriptionsRepository: UserSubscriptionsRepository, private readonly invoicesService: InvoicesService, @@ -196,7 +202,6 @@ export class SubscriptionsService { async subscribeToPlan(serviceId: string, subscribeDto: SubscribeServiceDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); - try { await queryRunner.connect(); await queryRunner.startTransaction(); @@ -210,18 +215,35 @@ export class SubscriptionsService { directDiscount: true, }, }); - if (!plan) throw new BadRequestException(SubscriptionMessage.NOT_FOUND); const startDate = dayjs().toDate(); const endDate = dayjs().add(plan.duration, "day").toDate(); - // - const userSubscription = queryRunner.manager.create(UserSubscription, { ...subscribeDto, user, plan, startDate, endDate }); + let slug = subscribeDto.slug; + if (!slug) { + slug = slugify(subscribeDto.businessName, { lower: true, strict: true }); + } + + const existingSlug = await queryRunner.manager.findOne(UserSubscription, { where: { slug } }); + if (existingSlug) throw new BadRequestException(SubscriptionMessage.SLUG_EXISTS); + + let staff: User[] = []; + if (subscribeDto.staff && subscribeDto.staff.length > 0) { + staff = await this.usersService.findUsersByIds(subscribeDto.staff); + } + + const userSubscription = queryRunner.manager.create(UserSubscription, { + ...subscribeDto, + user, + plan, + startDate, + endDate, + slug, + staff, + }); await queryRunner.manager.save(UserSubscription, userSubscription); - // - const invoiceDueDate = dayjs(userSubscription.startDate).add(INVOICE.DUEDATE, "day").toDate(); const invoice = await this.invoicesService.createInvoiceForSubscription(user, plan, userSubscription, invoiceDueDate, queryRunner); @@ -229,6 +251,8 @@ export class SubscriptionsService { await queryRunner.manager.save(UserSubscription, userSubscription); + await this.addProvisioningJob(userSubscription, plan, user); + await queryRunner.commitTransaction(); return { message: SubscriptionMessage.SUBSCRIBED, @@ -293,134 +317,43 @@ export class SubscriptionsService { } //************************************ */ - - async getPlanRevenueAverage() { - const currentYear = new Date().getFullYear(); - const startOfYear = new Date(currentYear, 0, 1); - const endOfYear = new Date(currentYear, 11, 31); - - const result = await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .leftJoin("userSubscription.plan", "plan") - .select("AVG(plan.price)", "averageRevenue") - .where("userSubscription.startDate >= :startDate", { startDate: startOfYear }) - .andWhere("userSubscription.startDate <= :endDate", { endDate: endOfYear }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .getRawOne(); - - return Number(result.averageRevenue) || 0; - } - - async getPlansSalesCount() { - const currentYear = new Date().getFullYear(); - const startOfYear = new Date(currentYear, 0, 1); - const endOfYear = new Date(currentYear, 11, 31); - - return await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .where("userSubscription.startDate >= :startDate", { startDate: startOfYear }) - .andWhere("userSubscription.startDate <= :endDate", { endDate: endOfYear }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .getCount(); - } - - async getPlansSalesCountByMonthsInYear() { - const now = new Date(); - const startOfYear = new Date(now.getFullYear(), 0, 1); - const endOfYear = new Date(now.getFullYear(), 11, 31); - - const sales = await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .select("EXTRACT(MONTH FROM userSubscription.startDate)", "month") - .addSelect("COUNT(*)", "count") - .where("userSubscription.startDate >= :startDate", { startDate: startOfYear }) - .andWhere("userSubscription.startDate <= :endDate", { endDate: endOfYear }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .groupBy("EXTRACT(MONTH FROM userSubscription.startDate)") - .orderBy("month", "ASC") - .getRawMany(); - - const salesByMonth = Array(12).fill(0); - - sales.forEach((sale) => { - const month = parseInt(sale.month) - 1; - salesByMonth[month] = parseInt(sale.count); + async getUserSubscriptionsForService(userId: string, serviceId: string) { + const userSubscriptions = await this.userSubscriptionsRepository.find({ + where: [ + { user: { id: userId }, plan: { service: { id: serviceId } }, status: SubscriptionStatus.ACTIVE }, + { staff: { id: userId }, plan: { service: { id: serviceId } }, status: SubscriptionStatus.ACTIVE }, + ], + relations: { + plan: { service: true }, + staff: true, + }, }); - return salesByMonth; + if (!userSubscriptions.length) throw new ForbiddenException(SubscriptionMessage.USER_DOES_NOT_HAVE_ACCESS); + + return { workspaces: userSubscriptions }; } - async getPlansSalesCountByDaysInMonth() { - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - const sales = await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .select("DATE(userSubscription.startDate)", "date") - .addSelect("COUNT(*)", "count") - .where("userSubscription.startDate >= :startDate", { startDate: startOfMonth }) - .andWhere("userSubscription.startDate <= :endDate", { endDate: endOfMonth }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .groupBy("DATE(userSubscription.startDate)") - .orderBy("date", "ASC") - .getRawMany(); - - const daysInMonth = endOfMonth.getDate(); - const salesByDay = Array(daysInMonth).fill(0); - - sales.forEach((sale) => { - const day = new Date(sale.date).getDate() - 1; - salesByDay[day] = parseInt(sale.count); - }); - - return salesByDay; + //************************************ */ + async addProvisioningJob(userSubscription: UserSubscription, plan: SubscriptionPlan, user: User) { + await this.provisioningQueue.add( + SUBSCRIPTIONS.PROVISIONING_JOB_NAME, + { + subscriptionId: userSubscription.id, + serviceId: plan.service.id, + serviceName: plan.service.name, + businessName: userSubscription.businessName, + userId: user.id, + slug: userSubscription.slug, + }, + { + priority: SUBSCRIPTIONS.PROVISIONING_JOB_PRIORITY, + delay: SUBSCRIPTIONS.PROVISIONING_JOB_DELAY, + attempts: SUBSCRIPTIONS.PROVISIONING_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: SUBSCRIPTIONS.PROVISIONING_JOB_BACKOFF }, + }, + ); } - async getPlansSalesCountByDaysInWeek() { - const startOfWeek = dayjs().startOf("week").toDate(); - const endOfWeek = dayjs().endOf("week").toDate(); - - const sales = await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .select("DATE(userSubscription.startDate)", "date") - .addSelect("COUNT(*)", "count") - .where("userSubscription.startDate >= :startDate", { startDate: startOfWeek }) - .andWhere("userSubscription.startDate <= :endDate", { endDate: endOfWeek }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .groupBy("DATE(userSubscription.startDate)") - .orderBy("date", "ASC") - .getRawMany(); - - const salesByDay = Array(7).fill(0); - - sales.forEach((sale) => { - const day = (dayjs(sale.date).day() + 1) % 7; - salesByDay[day] = parseInt(sale.count); - }); - - return salesByDay; - } - - async getSubscriptionGrowthRate() { - const currentYear = new Date().getFullYear(); - const lastYear = currentYear - 1; - - const currentYearCount = await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .where("EXTRACT(YEAR FROM userSubscription.startDate) = :year", { year: currentYear }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .getCount(); - - const lastYearCount = await this.userSubscriptionsRepository - .createQueryBuilder("userSubscription") - .where("EXTRACT(YEAR FROM userSubscription.startDate) = :year", { year: lastYear }) - .andWhere("userSubscription.status = :status", { status: SubscriptionStatus.ACTIVE }) - .getCount(); - - if (lastYearCount === 0) return 100; - - const growthRate = ((currentYearCount - lastYearCount) / lastYearCount) * 100; - return Math.round(growthRate * 100) / 100; - } + //************************************ */ } diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts index d2fc5aa..b1fbc31 100755 --- a/src/modules/subscriptions/subscriptions.controller.ts +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -93,4 +93,10 @@ export class SubscriptionsController { removeQuickAccess(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { return this.userQuickAccessService.removeQuickAccess(userId, paramDto.id); } + + @ApiOperation({ summary: "Check if user has access to a service and return all their workspaces for that service (internal use)" }) + @Get("workspaces/:serviceId") + async getUserServiceAccess(@Param() serviceIdParamDto: ServiceIdParamDto, @UserDec("id") userId: string) { + return this.subscriptionService.getUserSubscriptionsForService(userId, serviceIdParamDto.serviceId); + } } diff --git a/src/modules/subscriptions/subscriptions.module.ts b/src/modules/subscriptions/subscriptions.module.ts index 1110078..b5dfdd2 100755 --- a/src/modules/subscriptions/subscriptions.module.ts +++ b/src/modules/subscriptions/subscriptions.module.ts @@ -1,6 +1,8 @@ +import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { SUBSCRIPTIONS } from "./constants"; import { SubscriptionPlan } from "./entities/subscription.entity"; import { UserSubscription } from "./entities/user-subscription.entity"; import { SubscriptionsService } from "./providers/subscriptions.service"; @@ -18,6 +20,10 @@ import { UserQuickAccessRepository } from "./repositories/users-quick-access.rep @Module({ imports: [ TypeOrmModule.forFeature([SubscriptionPlan, UserSubscription, UserQuickAccess]), + BullModule.registerQueue({ + name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME, + prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX, + }), DanakServicesModule, UsersModule, WalletsModule, diff --git a/src/modules/utils/providers/email.service.ts b/src/modules/utils/providers/email.service.ts index 0240223..b0bf7d8 100755 --- a/src/modules/utils/providers/email.service.ts +++ b/src/modules/utils/providers/email.service.ts @@ -154,7 +154,7 @@ export class EmailService { } } - async sendAdminNotificationEmail(data: { userEmail: string; title: string; message: string }) { + async sendAdminNotificationEmail(data: { userEmail: string; title: string; message: string; fullName?: string }) { const emailData = { to: data.userEmail, subject: data.title, @@ -162,6 +162,7 @@ export class EmailService { context: { title: data.title, message: data.message, + fullName: data.fullName, }, };