Files
epess-web-backend/src/Schedule/schedule.schema.ts
Ly Tuan Kiet d36460fc12 refactor: standardize formatting and improve readability in ScheduleSchema
- Reformatted import statements and code structure for consistency and clarity.
- Enhanced field descriptions across the ScheduleSchema to ensure uniformity and better understanding.
- Updated error messages for improved user feedback during scheduling operations.
- Improved the organization of query and mutation fields for better maintainability.

These changes aim to enhance code readability, maintainability, and user experience within the scheduling features.
2025-01-17 21:14:17 +07:00

571 lines
18 KiB
TypeScript

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 },
});
},
}),
}));
}
}