- Updated the logic for checking overlapping schedules by querying existing schedule dates for the same user, improving accuracy in overlap detection. - Simplified the overlap validation process by consolidating checks into a single function, enhancing code clarity and maintainability. - This change aims to strengthen the integrity of scheduling operations and provide clearer error messages for users regarding scheduling conflicts.
569 lines
18 KiB
TypeScript
569 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");
|
|
}
|
|
|
|
// check if scheduleDate have overlap with other scheduleDate of same user by query all scheduleDate of same user
|
|
const existingScheduleDatesOfSameUser =
|
|
await this.prisma.scheduleDate.findMany({
|
|
where: {
|
|
schedule: {
|
|
managedService: {
|
|
mentorId: ctx.me.id,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const hasOverlap = DateTimeUtils.isOverlaps(
|
|
previewSchedule.slots.map((slot) => ({
|
|
start: DateTimeUtils.fromIsoString(slot.start),
|
|
end: DateTimeUtils.fromIsoString(slot.end),
|
|
})),
|
|
existingScheduleDatesOfSameUser.map((date) => ({
|
|
start: DateTimeUtils.fromDate(date.start),
|
|
end: DateTimeUtils.fromDate(date.end),
|
|
}))
|
|
);
|
|
if (hasOverlap) {
|
|
throw new Error(
|
|
"Schedule date has overlap with existing schedule dates"
|
|
);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
},
|
|
}),
|
|
}));
|
|
}
|
|
}
|