- 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.
571 lines
18 KiB
TypeScript
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 },
|
|
});
|
|
},
|
|
}),
|
|
}));
|
|
}
|
|
}
|