import { Inject, Injectable, Logger } from "@nestjs/common"; import { CenterStatus, ScheduleDateStatus, ScheduleStatus, } from "@prisma/client"; import { Role } from "@prisma/client"; import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken, } from "@smatch-corp/nestjs-pothos"; import { DateTimeUtils } from "src/common/utils/datetime.utils"; import { AppConfigService } from "../AppConfig/appconfig.service"; import { Builder } from "../Graphql/graphql.builder"; import { PrismaService } from "../Prisma/prisma.service"; import { ScheduleConfigType } from "./schedule"; import { ScheduleService } from "./schedule.service"; @Injectable() export class ScheduleSchema extends PothosSchema { constructor( @Inject(SchemaBuilderToken) private readonly builder: Builder, private readonly prisma: PrismaService, private readonly scheduleService: ScheduleService, private readonly appConfigService: AppConfigService ) { super(); } @PothosRef() schedule() { return this.builder.prismaObject("Schedule", { description: "A schedule in the system.", fields: (t) => ({ id: t.exposeID("id", { description: "The ID of the schedule.", }), customerId: t.exposeID("customerId", { description: "The ID of the customer the schedule belongs to.", nullable: true, }), managedServiceId: t.exposeID("managedServiceId", { description: "The ID of the managed service the schedule belongs to.", nullable: false, }), orderId: t.exposeID("orderId", { description: "The ID of the order the schedule belongs to.", nullable: true, }), order: t.relation("Order", { description: "The order that belongs to orderId.", nullable: true, }), scheduleStart: t.expose("scheduleStart", { type: "DateTime", nullable: false, }), scheduleEnd: t.expose("scheduleEnd", { type: "DateTime", nullable: false, }), slots: t.exposeIntList("slots", { nullable: false, }), daysOfWeek: t.exposeIntList("daysOfWeek", { nullable: false, }), dates: t.relation("dates", { description: "The dates of the schedule.", }), status: t.expose("status", { type: ScheduleStatus, nullable: false, }), managedService: t.relation("managedService", { description: "The managed service the schedule belongs to.", nullable: false, }), personalMilestone: t.relation("personalMilestone", { description: "The personal milestone of the schedule.", nullable: true, }), }), }); } @PothosRef() scheduleConnection() { return this.builder.simpleObject("ScheduleConnection", { fields: (t) => ({ totalCount: t.int({ nullable: true, }), schedules: t.field({ type: [this.schedule()], }), }), }); } @PothosRef() scheduleSlot() { return this.builder.simpleObject("ScheduleSlot", { fields: (t) => ({ slot: t.string({}), start: t.string({}), end: t.string({}), dayOfWeek: t.int({}), }), }); } @PothosRef() previewSchedule() { return this.builder.simpleObject("PreviewSchedule", { fields: (t) => ({ totalSlots: t.int(), slots: t.field({ type: [this.scheduleSlot()], }), }), }); } @PothosRef() scheduleDate() { return this.builder.prismaObject("ScheduleDate", { description: "A schedule date in the system.", fields: (t) => ({ id: t.exposeID("id", { description: "The ID of the schedule date.", }), scheduleId: t.exposeID("scheduleId", { description: "The ID of the schedule the schedule date belongs to.", }), start: t.expose("start", { type: "DateTime", nullable: false, }), end: t.expose("end", { type: "DateTime", nullable: false, }), status: t.expose("status", { type: ScheduleDateStatus, nullable: false, }), dayOfWeek: t.exposeInt("dayOfWeek", { nullable: false, }), slot: t.exposeInt("slot", { nullable: false, }), serviceId: t.exposeID("serviceId", { nullable: false, }), orderId: t.exposeID("orderId", { nullable: true, }), participantIds: t.exposeStringList("participantIds", { nullable: false, }), maxParticipants: t.exposeInt("maxParticipants", { nullable: false, }), lateStart: t.expose("lateStart", { type: "DateTime", nullable: true, }), collaborationSession: t.relation("CollaborationSession", { description: "The collaboration session of the schedule date.", nullable: true, }), schedule: t.relation("schedule", { description: "The schedule the schedule date belongs to.", }), }), }); } @PothosRef() scheduleConfigInput() { return this.builder.inputType("ScheduleConfigInput", { description: "A schedule config in the system.", fields: (t) => ({ midDayBreakTimeStart: t.string({ required: true, }), midDayBreakTimeEnd: t.string({ required: true, }), slotDuration: t.string({ required: true, }), slotBreakDuration: t.string({ required: true, }), dayStartTime: t.string({ required: true, }), dayEndTime: t.string({ required: true, }), }), }); } @PothosRef() scheduleConfigInputForCenter() { return this.builder.inputType("ScheduleConfigInputForCenter", { fields: (t) => ({ startDate: t.string({ required: true, }), endDate: t.string({ required: true, }), slots: t.intList({ required: true, }), days: t.intList({ required: true, }), }), }); } @Pothos() init(): void { this.builder.queryFields((t) => ({ schedule: t.prismaField({ type: this.schedule(), description: "Retrieve a single schedule by its unique identifier.", args: this.builder.generator.findUniqueArgs("Schedule"), resolve: async (query, _root, args, ctx, _info) => { if (!ctx.me) { throw new Error("User not found"); } // only return schedule belong to center const center = await this.prisma.center.findFirst({ where: { AND: [ { OR: [ { centerOwnerId: ctx.me.id }, { centerMentors: { some: { mentorId: ctx.me.id } } }, ], }, { centerStatus: CenterStatus.APPROVED }, ], }, }); if (!center) { throw new Error("Center not found"); } return await this.prisma.schedule.findUnique({ ...query, where: { id: args.where?.id, managedService: { service: { centerId: center.id } }, }, }); }, }), schedules: t.prismaField({ type: [this.schedule()], args: this.builder.generator.findManyArgs("Schedule"), description: "Retrieve a list of schedules with optional filtering, ordering, and pagination.", resolve: async (query, _root, args, ctx, _info) => { if (!ctx.me) { throw new Error("User not found"); } // use case 1: customer query schedules where customer is participant if (ctx.me.role === Role.CUSTOMER) { const schedules = await this.prisma.schedule.findMany({ ...query, orderBy: args.orderBy ?? undefined, skip: args.skip ?? undefined, take: args.take ?? undefined, where: args.filter ?? undefined, }); return schedules; } // use case 2: center mentor or center owner query schedules where center mentor or center owner is mentor if (ctx.me.role === Role.CENTER_MENTOR) { const center = await this.prisma.center.findFirst({ where: { centerMentors: { some: { mentorId: ctx.me.id, }, }, }, }); if (!center) { throw new Error("Center not found"); } // get all schedules belong to center const schedules = await this.prisma.schedule.findMany({ ...query, skip: args.skip ?? undefined, take: args.take ?? undefined, orderBy: args.orderBy ?? undefined, where: { AND: [ { managedService: { service: { centerId: center.id }, mentorId: ctx.me.id, }, }, ...(args.filter ? [args.filter] : []), ], }, }); return schedules; } // use case 3: Center owner query all schedules belong to center if (ctx.me.role === Role.CENTER_OWNER) { const center = await this.prisma.center.findFirst({ where: { centerOwnerId: ctx.me.id }, }); if (!center) { throw new Error("Center not found"); } const schedules = await this.prisma.schedule.findMany({ ...query, where: { AND: [ { managedService: { service: { centerId: center.id } } }, ...(args.filter ? [args.filter] : []), ], }, orderBy: args.orderBy ?? undefined, skip: args.skip ?? undefined, take: args.take ?? undefined, }); return schedules; } }, }), scheduleDates: t.prismaField({ type: [this.scheduleDate()], description: "Retrieve a list of schedule dates.", args: this.builder.generator.findManyArgs("ScheduleDate"), resolve: async (query, _root, args, ctx, _info) => { if (!ctx.me) { throw new Error("User not found"); } return await this.prisma.scheduleDate.findMany({ ...query, skip: args.skip ?? undefined, take: args.take ?? undefined, orderBy: args.orderBy ?? undefined, where: { AND: [ { participantIds: { has: ctx.me.id } }, ...(args.filter ? [args.filter] : []), ], }, }); }, }), centerPreviewSchedule: t.field({ type: this.previewSchedule(), description: "Preview a schedule for center mentor.", args: { scheduleConfigInput: t.arg({ type: this.scheduleConfigInputForCenter(), required: true, }), }, resolve: async (_parent, args, _context, _info) => { return await this.scheduleService.createSchedulePreviewForCenter( args.scheduleConfigInput ); }, }), adminPreviewSchedule: t.field({ type: this.previewSchedule(), description: "Preview a schedule for admin.", args: { scheduleConfig: t.arg({ type: this.scheduleConfigInput(), }), }, resolve: async (_parent, args, _context, _info) => { // if no scheduleConfig, use default config if (!args.scheduleConfig) { args.scheduleConfig = ( await this.appConfigService.getVisibleConfigs() ).reduce((acc, curr) => { // @ts-ignore acc[curr.key] = curr.value; return acc; }, {} as ScheduleConfigType); } return await this.scheduleService.createSchedulePreviewForSingleDay( args.scheduleConfig ); }, }), })); /* overlapping case 46836288-bb2c-4da6-892b-a559a480cbf8,e9be51fd-2382-4e43-9988-74e76fde4b56,2024-11-22 00:00:00.000,2024-11-02 00:00:00.000,UNPUBLISHED,,"{3,5}",,"{2,4}" d72a864e-2f41-45ab-9c9b-bf0512a31883,e9be51fd-2382-4e43-9988-74e76fde4b56,2024-11-22 00:00:00.000,2024-11-02 00:00:00.000,UNPUBLISHED,,"{3,5}",,"{2,4}" */ this.builder.mutationFields((t) => ({ // Mutations createSchedule: t.prismaField({ type: this.schedule(), description: "Create a new schedule.", args: { schedule: t.arg({ type: this.builder.generator.getCreateInput("Schedule", [ "id", "status", "customerId", "orderId", "dates", ]), required: true, }), }, resolve: async (query, _root, args, ctx, _info) => { if (!ctx.me) { throw new Error("User not found"); } Logger.log("args.schedule", args.schedule); // reject schedule if start date is today or in the past if ( DateTimeUtils.fromDate(args.schedule.scheduleStart as Date).day <= DateTimeUtils.now().day ) { throw new Error("Start date is in the past or today"); } // generate preview and check if there is any overlapping with other schedules date in same service const previewSchedule = await this.scheduleService.createSchedulePreviewForCenter({ startDate: args.schedule.scheduleStart as string, endDate: args.schedule.scheduleEnd as string, slots: args.schedule.slots as number[], days: args.schedule.daysOfWeek as number[], }); const existingScheduleDates = await this.prisma.scheduleDate.findMany( { where: { AND: [ { serviceId: args.schedule.managedService.connect?.id }, { status: { notIn: [ ScheduleDateStatus.COMPLETED, ScheduleDateStatus.MISSING_MENTOR, ScheduleDateStatus.MISSING_CUSTOMER, ScheduleDateStatus.EXPIRED, ], }, }, ], }, } ); // check if there is any overlapping with existing schedule dates in same service using DateTimeUtils const isOverlapping = DateTimeUtils.isOverlaps( previewSchedule.slots.map((slot) => ({ start: DateTimeUtils.fromIsoString(slot.start), end: DateTimeUtils.fromIsoString(slot.end), })), existingScheduleDates.map((date) => ({ start: DateTimeUtils.fromDate(date.start), end: DateTimeUtils.fromDate(date.end), })) ); if (isOverlapping) { Logger.error("Overlapping schedule", "ScheduleSchema"); throw new Error("Overlapping schedule"); } const scheduleDatesInSchedule = await this.prisma.scheduleDate.findMany({ where: { scheduleId: args.schedule.id, }, }); const overlapSchedule = await this.prisma.scheduleDate.findFirst({ where: { AND: [ { participantIds: { has: ctx.me?.id ?? "", }, orderId: { not: null, }, dayOfWeek: { in: Array.isArray(args.schedule.daysOfWeek) ? args.schedule.daysOfWeek : [], }, slot: { in: Array.isArray(args.schedule.slots) ? args.schedule.slots : [], }, scheduleId: { notIn: scheduleDatesInSchedule.map( (scheduleDate) => scheduleDate.scheduleId ), }, }, ], }, }); if (overlapSchedule) { throw new Error("Overlapping schedule"); } const schedule = await this.prisma.schedule.create({ ...query, data: args.schedule, }); // generate schedule dates based on data and config const scheduleDates = await this.scheduleService.generateScheduleDates(schedule); // update schedule with schedule dates return await this.prisma.schedule.update({ ...query, where: { id: schedule.id }, data: { dates: { connect: scheduleDates.map((date) => ({ id: date.id })), }, }, }); }, }), updateScheduleStatus: t.prismaField({ type: this.schedule(), description: "Update a schedule status.", args: { scheduleId: t.arg({ type: "String", required: true, }), status: t.arg({ type: ScheduleStatus, required: true, }), }, resolve: async (query, _root, args, _ctx, _info) => { return await this.prisma.schedule.update({ ...query, where: { id: args.scheduleId }, data: { status: args.status }, }); }, }), })); } }