From 26dd46c7a0890512b6da237d2ddd1fd125886f14 Mon Sep 17 00:00:00 2001 From: Ly Tuan Kiet Date: Mon, 25 Nov 2024 22:06:47 +0700 Subject: [PATCH] update code ngu --- src/Analytic/analytic.module.ts | 13 + src/Analytic/analytic.schema.ts | 458 +++++++++++++++++++++++++++++ src/Cron/cron.service.ts | 2 +- src/Finance/finance.module.ts | 8 - src/Finance/finance.schema.ts | 45 --- src/Graphql/graphql.module.ts | 4 +- src/common/utils/datetime.utils.ts | 15 + 7 files changed, 489 insertions(+), 56 deletions(-) create mode 100644 src/Analytic/analytic.module.ts create mode 100644 src/Analytic/analytic.schema.ts delete mode 100644 src/Finance/finance.module.ts delete mode 100644 src/Finance/finance.schema.ts diff --git a/src/Analytic/analytic.module.ts b/src/Analytic/analytic.module.ts new file mode 100644 index 0000000..75151c9 --- /dev/null +++ b/src/Analytic/analytic.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AnalyticSchema } from './analytic.schema' +import { ServiceSchema } from 'src/Service/service.schema' +import { CenterSchema } from 'src/Center/center.schema' +import { OrderSchema } from 'src/Order/order.schema' +import { PayosModule } from 'src/Payos/payos.module' + +@Module({ + imports: [PayosModule], + providers: [AnalyticSchema, ServiceSchema, CenterSchema, OrderSchema], + exports: [AnalyticSchema, ServiceSchema, CenterSchema, OrderSchema], +}) +export class AnalyticModule {} diff --git a/src/Analytic/analytic.schema.ts b/src/Analytic/analytic.schema.ts new file mode 100644 index 0000000..453581f --- /dev/null +++ b/src/Analytic/analytic.schema.ts @@ -0,0 +1,458 @@ +import { Inject, Injectable } from '@nestjs/common' +import { OrderStatus, Prisma, Role, ServiceStatus } from '@prisma/client' +import { + Pothos, + PothosRef, + PothosSchema, + SchemaBuilderToken, +} from '@smatch-corp/nestjs-pothos' +import { DateTimeUtils } from 'src/common/utils/datetime.utils' +import { Builder } from 'src/Graphql/graphql.builder' +import { PrismaService } from 'src/Prisma/prisma.service' +import { ServiceSchema } from 'src/Service/service.schema' +import { CenterSchema } from 'src/Center/center.schema' +import { OrderSchema } from 'src/Order/order.schema' +@Injectable() +export class AnalyticSchema extends PothosSchema { + constructor( + @Inject(SchemaBuilderToken) private readonly builder: Builder, + private readonly prisma: PrismaService, + private readonly serviceSchema: ServiceSchema, + private readonly centerSchema: CenterSchema, + private readonly orderSchema: OrderSchema, + ) { + super() + } + + @PothosRef() + customerAnalytic() { + return this.builder.simpleObject('CustomerAnalytic', { + description: 'A customer analytic in the system.', + fields: (t) => ({ + userId: t.string({ + description: 'The ID of the user.', + }), + activeServiceCount: t.int({ + description: 'The number of active services.', + }), + totalServiceCount: t.int({ + description: 'The total number of services.', + }), + totalSpent: t.float({ + description: 'The total amount spent.', + }), + updatedAt: t.field({ + type: 'DateTime', + description: 'The date the analytic was last updated.', + }), + }), + }) + } + + @PothosRef() + mentorAnalytic() { + return this.builder.simpleObject('MentorAnalytic', { + description: 'A mentor analytic in the system.', + fields: (t) => ({ + userId: t.string({ + description: 'The ID of the mentor.', + }), + }), + }) + } + + @PothosRef() + centerAnalytic() { + return this.builder.simpleObject('CenterAnalytic', { + description: 'A center analytic in the system.', + fields: (t) => ({ + centerId: t.string({ + description: 'The ID of the center.', + }), + activeMentorCount: t.int({ + description: 'The number of active mentors.', + }), + activeServiceCount: t.int({ + description: 'The number of active services.', + }), + totalServiceCount: t.int({ + description: 'The total number of services.', + }), + revenue: t.int({ + description: 'The total revenue.', + }), + rating: t.float({ + description: 'The average rating.', + nullable: true, + }), + updatedAt: t.field({ + type: 'DateTime', + description: 'The date the analytic was last updated.', + }), + }), + }) + } + + @PothosRef() + platformAnalytic() { + return this.builder.simpleObject('PlatformAnalytic', { + description: 'A platform analytic in the system.', + fields: (t) => ({ + topServices: t.field({ + type: [this.serviceSchema.service()], + description: 'The top services by revenue.', + }), + topCenters: t.field({ + type: [this.centerSchema.center()], + description: 'The top centers by revenue.', + }), + pendingRefunds: t.field({ + type: [this.orderSchema.order()], + description: 'The pending refunds.', + }), + activeCenterCount: t.int({ + description: 'The number of active centers.', + }), + totalCenterCount: t.int({ + description: 'The total number of centers.', + }), + totalUserCount: t.int({ + description: 'The total number of users.', + }), + activeMentorCount: t.int({ + description: 'The number of active mentors.', + }), + totalMentorCount: t.int({ + description: 'The total number of mentors.', + }), + revenue: t.int({ + description: 'The total revenue.', + }), + approvedServiceCount: t.int({ + description: 'The number of approved services.', + }), + rejectedServiceCount: t.int({ + description: 'The number of rejected services.', + }), + updatedAt: t.field({ + type: 'DateTime', + description: 'The date the analytic was last updated.', + }), + }), + }) + } + + @PothosRef() + timeframes() { + return this.builder.enumType('Timeframe', { + values: ['day', 'week', 'month', 'year'], + }) + } + + @PothosRef() + serviceSortBy() { + return this.builder.enumType('ServiceSortBy', { + values: ['order', 'rating'], + }) + } + + @PothosRef() + centerSortBy() { + return this.builder.enumType('CenterSortBy', { + values: ['revenue', 'rating', 'services'], + }) + } + + @Pothos() + init(): void { + this.builder.queryFields((t) => ({ + customerAnalytic: t.field({ + type: this.customerAnalytic(), + description: 'Retrieve a single customer analytic.', + resolve: async (_parent, _args, ctx, _info) => { + if (ctx.isSubscription) throw new Error('Not allowed') + if (!ctx.http.me) throw new Error('Unauthorized') + if (ctx.http.me.role !== Role.CUSTOMER) + throw new Error('Only customers can access this data') + // calculate analytic + const activeServiceCount = await this.prisma.order.count({ + where: { + userId: ctx.http.me.id, + status: OrderStatus.PAID, + schedule: { + dates: { + some: { + end: { + gte: DateTimeUtils.now().toJSDate(), + }, + }, + }, + }, + }, + }) + const totalServiceCount = await this.prisma.order.count({ + where: { + userId: ctx.http.me.id, + }, + }) + const totalSpent = await this.prisma.order.aggregate({ + where: { + userId: ctx.http.me.id, + status: OrderStatus.PAID, + }, + _sum: { + total: true, + }, + }) + return { + userId: ctx.http.me.id, + activeServiceCount: activeServiceCount, + totalServiceCount: totalServiceCount, + totalSpent: totalSpent._sum.total, + updatedAt: DateTimeUtils.now(), + } + }, + }), + mentorAnalytic: t.field({ + type: this.mentorAnalytic(), + description: 'Retrieve a single mentor analytic.', + resolve: async (_parent, _args, ctx, _info) => { + if (ctx.isSubscription) throw new Error('Not allowed') + if (!ctx.http.me) throw new Error('Unauthorized') + if (ctx.http.me.role !== Role.CENTER_MENTOR) + throw new Error('Only center mentors can access this data') + // calculate analytic + return { + userId: ctx.http.me.id, + } + }, + }), + centerAnalytic: t.field({ + type: this.centerAnalytic(), + description: 'Retrieve a single center analytic.', + resolve: async (_parent, _args, ctx, _info) => { + if (ctx.isSubscription) throw new Error('Not allowed') + if (!ctx.http.me) throw new Error('Unauthorized') + if (ctx.http.me.role !== Role.CENTER_OWNER) + throw new Error('Only center owners can access this data') + // get center by owner id + const center = await this.prisma.center.findUnique({ + where: { + centerOwnerId: ctx.http.me.id, + }, + }) + if (!center) throw new Error('Center not found') + // calculate analytic + + // active mentor include center owner + const activeMentorCount = await this.prisma.user.count({ + where: { + center: { + centerOwnerId: ctx.http.me.id, + }, + banned: false, + }, + }) + + const activeServiceCount = await this.prisma.service.count({ + where: { + centerId: center.id, + status: ServiceStatus.APPROVED, + }, + }) + const totalServiceCount = await this.prisma.service.count({ + where: { + centerId: center.id, + }, + }) + // calculate revenue from orders of services in the center and factor in commission percentage + // query all orders of services in the center and calculate actual revenue of each order + // then sum up the revenue + let revenue = 0 + const orders = await this.prisma.order.findMany({ + where: { + service: { centerId: center.id }, + status: OrderStatus.PAID, + }, + }) + for (const order of orders) { + const service = await this.prisma.service.findUnique({ + where: { id: order.serviceId }, + }) + if (!service) continue + const commission = service.commission + const actualRevenue = + (order.total || 0) - (order.total || 0) * commission + revenue += actualRevenue + } + return { + centerId: center.id, + activeMentorCount: activeMentorCount, + activeServiceCount: activeServiceCount, + totalServiceCount: totalServiceCount, + revenue: revenue, + updatedAt: DateTimeUtils.now(), + } + }, + }), + platformAnalytic: t.field({ + type: this.platformAnalytic(), + args: { + take: t.arg({ + type: 'Int', + description: 'The number of services to take.', + required: true, + }), + serviceSortBy: t.arg({ + type: this.serviceSortBy(), + description: 'The field to sort by.', + required: true, + }), + centerSortBy: t.arg({ + type: this.centerSortBy(), + description: 'The field to sort by.', + required: true, + }), + timeframes: t.arg({ + type: this.timeframes(), + description: 'The frame of time Eg day, week, month, year.', + required: true, + }), + }, + description: 'Retrieve a single platform analytic.', + resolve: async (_parent, args, ctx, _info) => { + if (ctx.isSubscription) throw new Error('Not allowed') + if (!ctx.http.me) throw new Error('Unauthorized') + if ( + ctx.http.me.role !== Role.ADMIN && + ctx.http.me.role !== Role.MODERATOR + ) + throw new Error('Only admins and moderators can access this data') + // calculate analytic for services sorted by args.serviceSortBy and args.timeframes + const topServices = await this.prisma.service.findMany({ + where: { + status: ServiceStatus.APPROVED, + }, + orderBy: { + [args.serviceSortBy]: { + _count: Prisma.SortOrder.desc, + }, + }, + take: args.take, + }) + // get top centers by args.centerSortBy + const topCenters = await this.prisma.center.findMany({ + orderBy: { + [args.centerSortBy]: { + _count: Prisma.SortOrder.desc, + }, + }, + take: args.take, + }) + // get pending refunds + const pendingRefunds = await this.prisma.order.findMany({ + where: { + status: OrderStatus.PENDING_REFUND, + }, + }) + // get active center count by center owner not banned and have schedule with dates in the future + const activeCenterCount = await this.prisma.center.count({ + where: { + centerOwner: { + banned: false, + }, + centerMentors: { + some: { + managedService: { + some: { + schedule: { + some: { + dates: { + some: { + end: { + gte: DateTimeUtils.now().toJSDate(), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + // get total center count + const totalCenterCount = await this.prisma.center.count() + // get total user count + const totalUserCount = await this.prisma.user.count() + // get active mentor count + const activeMentorCount = await this.prisma.user.count({ + where: { + role: Role.CENTER_MENTOR, + banned: false, + }, + }) + // get total mentor count + const totalMentorCount = await this.prisma.user.count({ + where: { + role: Role.CENTER_MENTOR, + }, + }) + // get approved service count + const approvedServiceCount = await this.prisma.service.count({ + where: { + status: ServiceStatus.APPROVED, + }, + }) + // get rejected service count + const rejectedServiceCount = await this.prisma.service.count({ + where: { + status: ServiceStatus.REJECTED, + }, + }) + // get revenue + let revenue = 0 + // query all orders of services in all centers in the past args.timeframes and calculate actual revenue of each order by convert commission percentage to float + // convert args.timeframes to number of days + const timeframes = DateTimeUtils.subtractDaysFromTimeframe( + args.timeframes, + ) + const orders = await this.prisma.order.findMany({ + where: { + status: OrderStatus.PAID, + createdAt: { + gte: timeframes.toJSDate(), + }, + }, + }) + for (const order of orders) { + const service = await this.prisma.service.findUnique({ + where: { id: order.serviceId }, + }) + if (!service) continue + const commission = service.commission + const actualRevenue = + (order.total || 0) - (order.total || 0) * commission + revenue += actualRevenue + } + // return analytic + return { + topServices: topServices, + topCenters: topCenters, + pendingRefunds: pendingRefunds, + activeCenterCount: activeCenterCount, + totalCenterCount: totalCenterCount, + totalUserCount: totalUserCount, + activeMentorCount: activeMentorCount, + totalMentorCount: totalMentorCount, + revenue: revenue, + approvedServiceCount: approvedServiceCount, + rejectedServiceCount: rejectedServiceCount, + updatedAt: DateTimeUtils.now(), + } + }, + }), + })) + } +} diff --git a/src/Cron/cron.service.ts b/src/Cron/cron.service.ts index af305a1..9ce211d 100644 --- a/src/Cron/cron.service.ts +++ b/src/Cron/cron.service.ts @@ -80,7 +80,7 @@ export class CronService { } // cron every 1 minute to check payment status where created_at is more than 15 minutes - @Cron(CronExpression.EVERY_MINUTE) + @Cron(CronExpression.EVERY_10_MINUTES) async checkPaymentStatus() { Logger.log('Checking payment status', 'checkPaymentStatus') const payments = await this.prisma.payment.findMany({ diff --git a/src/Finance/finance.module.ts b/src/Finance/finance.module.ts deleted file mode 100644 index 4064d8d..0000000 --- a/src/Finance/finance.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common' -import { FinanceSchema } from './finance.schema' - -@Module({ - providers: [FinanceSchema], - exports: [FinanceSchema], -}) -export class FinanceModule {} diff --git a/src/Finance/finance.schema.ts b/src/Finance/finance.schema.ts deleted file mode 100644 index faa78c2..0000000 --- a/src/Finance/finance.schema.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common' -import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken } from '@smatch-corp/nestjs-pothos' -import { Builder } from 'src/Graphql/graphql.builder' -import { PrismaService } from 'src/Prisma/prisma.service' - -@Injectable() -export class FinanceSchema extends PothosSchema { - constructor( - @Inject(SchemaBuilderToken) private readonly builder: Builder, - private readonly prisma: PrismaService, - ) { - super() - } - - @PothosRef() - finance() { - return this.builder.simpleObject('Finance', { - description: 'A finance in the system.', - fields: (t) => ({ - id: t.string({ - description: 'The ID of the finance.', - }), - amount: t.string({ - description: 'The amount of the finance.', - }), - }), - }) - } - - @PothosRef() - init(): void { - this.builder.queryFields((t) => ({ - finance: t.field({ - type: this.finance(), - description: 'Retrieve a single finance by its unique identifier.', - resolve: async () => { - return { - id: '1', - amount: '100', - } - }, - }), - })) - } -} diff --git a/src/Graphql/graphql.module.ts b/src/Graphql/graphql.module.ts index b7cd46d..12bf0ac 100644 --- a/src/Graphql/graphql.module.ts +++ b/src/Graphql/graphql.module.ts @@ -42,7 +42,7 @@ import { initContextCache } from '@pothos/core' import { RedisPubSub } from 'graphql-redis-subscriptions' import { DocumentModule } from 'src/Document/document.module' import { Context } from 'graphql-ws' -import { FinanceModule } from 'src/Finance/finance.module' +import { AnalyticModule } from 'src/Analytic/analytic.module' import { MeetingRoomModule } from 'src/MeetingRoom/meetingroom.module' @Global() @@ -79,7 +79,7 @@ import { MeetingRoomModule } from 'src/MeetingRoom/meetingroom.module' WorkshopMeetingRoomModule, AdminNoteModule, DocumentModule, - FinanceModule, + AnalyticModule, MeetingRoomModule, PothosModule.forRoot({ builder: { diff --git a/src/common/utils/datetime.utils.ts b/src/common/utils/datetime.utils.ts index cbd0c61..f5048e4 100644 --- a/src/common/utils/datetime.utils.ts +++ b/src/common/utils/datetime.utils.ts @@ -142,4 +142,19 @@ export class DateTimeUtils { static getTimeFromDateTime(dateTime: DateTime): TimeType { return this.toTime(`${dateTime.hour}:${dateTime.minute}:${dateTime.second}`) } + + static subtractDaysFromTimeframe(timeframe: string): DateTime { + // convert timeframe to number of days: 'day' -> 1, 'week' -> 7, 'month' -> 30, 'year' -> 365 + const daysMap: Record = { + day: 1, + week: 7, + month: 30, + year: 365, + } + const days = daysMap[timeframe.toLowerCase()] + if (days === undefined) { + throw new Error(`Invalid timeframe: ${timeframe}`) + } + return DateTime.now().minus({ days }) + } }