diff --git a/src/controllers/schedule.ts b/src/controllers/schedule.ts new file mode 100644 index 0000000..c7b4032 --- /dev/null +++ b/src/controllers/schedule.ts @@ -0,0 +1,324 @@ +import { NextFunction, Response } from "express"; +import { Request } from "express-jwt"; +import Joi from "joi"; +import { ILogObj, Logger } from "tslog"; +import { ReturnHelper } from "../helpers/express/return"; +import { Language } from "../langs/lang"; +import { ScheduleModel } from "../model/schedule"; +import CommonHelper from "../helpers/common"; + +const log: Logger = new Logger({ + name: "[ScheduleController]", + type: "pretty", +}); + +export class ScheduleController { + static async list(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['filter'] = { description: '', in: 'query', type: 'string' } + #swagger.parameters['limit'] = { in: 'query', required: true, type: 'number' } + #swagger.parameters['page'] = { in: 'query', required: true, type: 'number' } + #swagger.parameters['with_deleted'] = { in: 'query', required: true, type: 'boolean' } + #swagger.parameters['order_field'] = { in: 'query', required: true, type: 'string' } + #swagger.parameters['order_direction'] = { in: 'query', required: true, schema: { '@enum': ['ASC', 'DESC'] } } + #swagger.responses[200] = { + description: 'Schedule series paginated list', + schema: { + status: true, + code: 200, + message: 'success', + data: { count: 1, page: 1, total_count: 1, list: [{ id: 'uuid-string', title: 'Practice Schedule' }] } + } + } + */ + try { + const schema = Joi.object().keys({ + filter: Joi.string().allow("").optional().label("Filter"), + page: Joi.number().required().min(1).label("Page"), + limit: Joi.number().required().min(1).label("Limit"), + with_deleted: Joi.bool().required().label("With Deleted"), + order_field: Joi.string().required().label("Order Field"), + order_direction: Joi.string().allow("asc", "desc").required().label("Order Direction"), + }); + + const param: { + filter?: string; + page: number; + limit: number; + with_deleted: boolean; + order_field: string; + order_direction: "asc" | "desc"; + } = await schema.validateAsync(req.query); + + const filterObj = param.filter && param.filter !== "" ? JSON.parse(param.filter) : {}; + const query = await ScheduleModel.listSeries(filterObj); + const orderDirection = param.order_direction.toUpperCase() as "ASC" | "DESC"; + + const offset = (param.page - 1) * param.limit; + const resCount = query; + const resList = query + .orderBy("ScheduleSeries." + param.order_field, orderDirection) + .offset(offset) + .limit(param.limit); + + if (param.with_deleted) { + resCount.withDeleted(); + resList.withDeleted(); + } + + const currentPage = param.page; + const totalCountData = await resCount.getCount(); + const listData = await resList.getMany(); + const countData = CommonHelper.countObject(listData); + + return ReturnHelper.successResponselist(res, 200, Language.lang.success_view, countData, currentPage, totalCountData, listData); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } + + static async options(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['filter'] = { in: 'query', required: false, type: 'string' } + #swagger.responses[200] = { + description: 'Schedule options', + schema: { + status: true, + code: 200, + message: 'success', + data: { + doctors: [{ id: 'uuid-string', title: 'Doctor Name' }], + rooms: [{ id: 'uuid-string', title: 'Room Name' }] + } + } + } + */ + try { + const schema = Joi.object().keys({ + filter: Joi.string().allow("").optional().label("Filter"), + }); + + const param: { filter?: string } = await schema.validateAsync(req.query); + const filterObj = param.filter && param.filter !== "" ? JSON.parse(param.filter) : {}; + const result = await ScheduleModel.getScheduleOptions(filterObj); + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_view, result); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } + + static async calendar(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['start'] = { in: 'query', required: true, type: 'string' } + #swagger.parameters['end'] = { in: 'query', required: true, type: 'string' } + #swagger.parameters['filter'] = { in: 'query', required: false, type: 'string' } + #swagger.responses[200] = { + description: 'Schedule calendar resources and events', + schema: { + status: true, + code: 200, + message: 'success', + data: { + resources: [{ + id: 'room-1:doctor-1', + roomId: 'room-1', + roomName: 'Room 1', + doctorId: 'doctor-1', + doctorName: 'Dr A' + }], + events: [{ + id: 'occ:series-101:2026-03-20', + seriesId: 'series-101', + resourceId: 'room-1:doctor-1', + title: 'Practice', + startAt: '2026-03-20T09:00:00+07:00', + endAt: '2026-03-20T12:00:00+07:00', + isRecurring: true, + isException: false, + status: 'confirmed' + }] + } + } + } + */ + try { + const schema = Joi.object().keys({ + start: Joi.string().required().label("Start"), + end: Joi.string().required().label("End"), + filter: Joi.string().allow("").optional().label("Filter"), + }); + + const param: { start: string; end: string; filter?: string } = await schema.validateAsync(req.query); + const filterObj = param.filter && param.filter !== "" ? JSON.parse(param.filter) : {}; + const result = await ScheduleModel.getScheduleCalendarRange(param.start, param.end, filterObj); + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_view, result); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } + + static async detail(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['id'] = { in: 'path', required: true, type: 'string' } + #swagger.responses[200] = { + description: 'Schedule series detail', + schema: { + status: true, + code: 200, + message: 'success', + data: { + series: { id: 'uuid-string', title: 'Practice Schedule' }, + exceptions: [{ id: 'uuid-string', occurrence_date: '2026-03-24' }] + } + } + } + */ + try { + const schema = Joi.object().keys({ + id: Joi.string().uuid().required().label("Series ID"), + }); + + const param: { id: string } = await schema.validateAsync(req.params); + const result = await ScheduleModel.getSeriesById(param.id); + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_view, result); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } + + static async create(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/scheduleSeriesCreate" } } } } + #swagger.responses[200] = { + description: 'Schedule series created', + schema: { status: true, code: 200, message: 'success', data: { id: 'uuid-string' } } + } + */ + try { + const schema = Joi.object().keys({ + roomId: Joi.string().uuid().required(), + doctorId: Joi.string().uuid().required(), + title: Joi.string().max(256).required(), + timezone: Joi.string().max(64).required(), + startDate: Joi.string().required(), + untilDate: Joi.string().allow("", null).optional(), + startTimeLocal: Joi.string().required(), + endTimeLocal: Joi.string().required(), + recurrence: Joi.object({ + type: Joi.string().valid("daily", "weekly", "monthly").required(), + interval: Joi.number().min(1).optional(), + daysOfWeek: Joi.array().items(Joi.string().valid("SU", "MO", "TU", "WE", "TH", "FR", "SA")).optional(), + }) + .allow(null) + .optional(), + }); + + const param = await schema.validateAsync(req.body); + const actor = req.auth?.data?.name || "system"; + const result = await ScheduleModel.createSeries(param, actor); + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_insert, result); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_insert, err.message); + } + } + + static async update(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['id'] = { in: 'path', required: true, type: 'string' } + #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/scheduleSeriesUpdate" } } } } + #swagger.responses[200] = { + description: 'Schedule series updated', + schema: { status: true, code: 200, message: 'success', data: { id: 'uuid-string' } } + } + */ + try { + req.body.id = req.params["id"]; + const schema = Joi.object().keys({ + id: Joi.string().uuid().required(), + roomId: Joi.string().uuid().required(), + doctorId: Joi.string().uuid().required(), + title: Joi.string().max(256).required(), + timezone: Joi.string().max(64).required(), + startDate: Joi.string().required(), + untilDate: Joi.string().allow("", null).optional(), + startTimeLocal: Joi.string().required(), + endTimeLocal: Joi.string().required(), + recurrence: Joi.object({ + type: Joi.string().valid("daily", "weekly", "monthly").required(), + interval: Joi.number().min(1).optional(), + daysOfWeek: Joi.array().items(Joi.string().valid("SU", "MO", "TU", "WE", "TH", "FR", "SA")).optional(), + }) + .allow(null) + .optional(), + }); + + const param = await schema.validateAsync(req.body); + const actor = req.auth?.data?.name || "system"; + const result = await ScheduleModel.updateSeries(param.id, param, actor); + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_update, result); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_update, err.message); + } + } + + static async createException(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Schedule'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['id'] = { in: 'path', required: true, type: 'string' } + #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/scheduleExceptionCreate" } } } } + #swagger.responses[200] = { + description: 'Schedule exception created or updated', + schema: { status: true, code: 200, message: 'success', data: { id: 'uuid-string' } } + } + */ + try { + req.body.id = req.params["id"]; + const schema = Joi.object().keys({ + id: Joi.string().uuid().required(), + occurrenceDate: Joi.string().required(), + isCancelled: Joi.boolean().optional(), + overrideRoomId: Joi.string().uuid().allow("", null).optional(), + overrideDoctorId: Joi.string().uuid().allow("", null).optional(), + overrideTitle: Joi.string().max(256).allow("", null).optional(), + overrideStartAt: Joi.string().allow("", null).optional(), + overrideEndAt: Joi.string().allow("", null).optional(), + }); + + const param = await schema.validateAsync(req.body); + const actor = req.auth?.data?.name || "system"; + const result = await ScheduleModel.createSeriesException(param.id, param, actor); + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_insert, result); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_insert, err.message); + } + } +} diff --git a/src/helpers/orm.ts b/src/helpers/orm.ts index c36e73b..cfebf88 100644 --- a/src/helpers/orm.ts +++ b/src/helpers/orm.ts @@ -1,7 +1,7 @@ import config from 'config'; import { DataSource } from "typeorm"; import { ILogObj, Logger } from 'tslog'; -import { RoomStock,Administrativu, Aldeia, District, Menu, Munisipo, Suco, Application, Nationality, Diagnosis, DiagnosticProcedure, Room, ServiceType, ServiceClass, Service, ServiceFare, ServicePackage, Department, DoctorSchedule, MeasurementUnit, ReferenceRange, QueueMonitoring, ExaminationDetail, ExaminationType, User, UserRole, HrmsEmployee, HrmsDepartment, HrmsPosition, HrmsShift, HospitalInformation, ItemOrigin, ItemType, ItemTypeDetail, Unit, ItemCategory, ItemStatus, UsageInstructions, UsageTime, ItemClass, ItemClassDetail, GenericName, Factory, Supplier, FactoryToSupplier, ItemMaster, ItemGroup, PharmacyInfo, ItemPrice, SellingPricePercentage, PatientGroup, PatientGuarantor, InitialStock, DoctorItem, DoctorItemPackage, UserToRoom, RoomToPharmacy, PlanningVerificator } from 'entity' +import { RoomStock,Administrativu, Aldeia, District, Menu, Munisipo, Suco, Application, Nationality, Diagnosis, DiagnosticProcedure, Room, ServiceType, ServiceClass, Service, ServiceFare, ServicePackage, Department, DoctorSchedule, MeasurementUnit, ReferenceRange, QueueMonitoring, ExaminationDetail, ExaminationType, User, UserRole, HrmsEmployee, HrmsDepartment, HrmsPosition, HrmsShift, HospitalInformation, ItemOrigin, ItemType, ItemTypeDetail, Unit, ItemCategory, ItemStatus, UsageInstructions, UsageTime, ItemClass, ItemClassDetail, GenericName, Factory, Supplier, FactoryToSupplier, ItemMaster, ItemGroup, PharmacyInfo, ItemPrice, SellingPricePercentage, PatientGroup, PatientGuarantor, InitialStock, DoctorItem, DoctorItemPackage, UserToRoom, RoomToPharmacy, PlanningVerificator, ScheduleSeries, ScheduleException } from 'entity' export class OrmHelper { static DB: DataSource = null @@ -47,7 +47,9 @@ export class OrmHelper { UserToRoom, RoomToPharmacy, PlanningVerificator, - RoomStock + RoomStock, + ScheduleSeries, + ScheduleException ], subscribers: [], migrations: [], diff --git a/src/model/schedule.ts b/src/model/schedule.ts new file mode 100644 index 0000000..b0dfa83 --- /dev/null +++ b/src/model/schedule.ts @@ -0,0 +1,424 @@ +import { Room, ScheduleException, ScheduleSeries, Status, User } from "entity"; +import { SelectQueryBuilder } from "typeorm"; +import CommonHelper from "../helpers/common"; +import { OrmHelper } from "../helpers/orm"; + +type RecurrenceInput = { + type: "daily" | "weekly" | "monthly"; + interval?: number; + daysOfWeek?: string[]; +}; + +type ScheduleSeriesPayload = { + roomId: string; + doctorId: string; + title: string; + timezone: string; + startDate: string; + untilDate?: string | null; + startTimeLocal: string; + endTimeLocal: string; + recurrence?: RecurrenceInput | null; +}; + +type ScheduleExceptionPayload = { + occurrenceDate: string; + isCancelled?: boolean; + overrideRoomId?: string | null; + overrideDoctorId?: string | null; + overrideTitle?: string | null; + overrideStartAt?: string | null; + overrideEndAt?: string | null; +}; + +export class ScheduleModel { + static async listSeries(filterObj = {}): Promise> { + const repo = OrmHelper.DB.getRepository(ScheduleSeries); + let whereAttr: string[] = []; + let whereVal: any = {}; + if (filterObj && Object.keys(filterObj).length > 0) { + const filterResult = CommonHelper.handleQueryFilter(filterObj); + whereAttr = [...whereAttr, ...filterResult.whereAttr]; + whereVal = { ...whereVal, ...filterResult.whereVal }; + } + + let query = repo + .createQueryBuilder("ScheduleSeries") + .leftJoinAndSelect("ScheduleSeries.doctor", "Doctor") + .leftJoinAndSelect("ScheduleSeries.room", "Room"); + if (whereAttr.length !== 0) { + query = query.where(whereAttr.join(" and "), whereVal); + } + return query; + } + + static async getScheduleCalendarRange(start: string, end: string, filterObj = {}): Promise { + const startDate = new Date(start); + const endDate = new Date(end); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + throw new Error("Invalid start or end date"); + } + + const rangeStart = ScheduleModel.startOfDay(startDate); + const rangeEnd = ScheduleModel.endOfDay(endDate); + if (rangeStart > rangeEnd) { + throw new Error("Start date must be before end date"); + } + + const seriesQuery = await ScheduleModel.listSeries(filterObj); + seriesQuery + .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) }) + .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { + rangeStartDate: ScheduleModel.formatYmd(rangeStart), + }) + .andWhere("ScheduleSeries.deleted_at IS NULL"); + + const seriesList: ScheduleSeries[] = await seriesQuery.getMany(); + if (seriesList.length === 0) return { resources: [], events: [] }; + + const seriesIds = seriesList.map((series) => series.id); + const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException); + const exceptions = await exceptionRepo + .createQueryBuilder("ScheduleException") + .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom") + .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor") + .where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds }) + .andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", { + rangeStart: ScheduleModel.formatYmd(rangeStart), + rangeEnd: ScheduleModel.formatYmd(rangeEnd), + }) + .andWhere("ScheduleException.deleted_at IS NULL") + .getMany(); + + const exceptionMap = new Map(); + for (const ex of exceptions) { + const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`; + exceptionMap.set(key, ex); + } + + const resourceMap = new Map(); + const events: any[] = []; + + for (const series of seriesList) { + const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); + for (const occurrenceDate of occurrenceDates) { + const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); + const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); + if (exception && exception.is_cancelled) continue; + + const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time_local); + const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time_local); + + const doctor = exception?.override_doctor ?? series.doctor; + const room = exception?.override_room ?? series.room; + const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart; + const eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd; + const eventTitle = exception?.override_title || series.title; + + const roomId = room?.id || null; + const doctorId = doctor?.id || null; + const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`; + const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || ""; + const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || ""; + + resourceMap.set(resourceId, { + id: resourceId, + roomId, + roomName, + doctorId, + doctorName, + }); + + events.push({ + id: `occ:${series.id}:${occurrenceYmd}`, + seriesId: series.id, + resourceId, + title: eventTitle, + startAt: eventStart.toISOString(), + endAt: eventEnd.toISOString(), + isRecurring: !!series.recurrence_rule, + isException: !!exception, + status: "confirmed", + }); + } + } + + return { resources: [...resourceMap.values()], events }; + } + + static async getScheduleOptions(filterObj = {}): Promise { + const query = await ScheduleModel.listSeries(filterObj); + query.andWhere("ScheduleSeries.deleted_at IS NULL"); + const seriesList: ScheduleSeries[] = await query.getMany(); + + const doctorsMap = new Map(); + const roomsMap = new Map(); + + for (const series of seriesList) { + if (series.doctor?.id) { + doctorsMap.set(series.doctor.id, { + id: series.doctor.id, + title: (series.doctor as any).name || (series.doctor as any).username || "Doctor", + }); + } + if (series.room?.id) { + roomsMap.set(series.room.id, { + id: series.room.id, + title: (series.room as any).room || (series.room as any).code || "Room", + }); + } + } + + return { + doctors: [...doctorsMap.values()], + rooms: [...roomsMap.values()], + }; + } + + static async getSeriesById(id: string): Promise { + const series = await OrmHelper.DB.getRepository(ScheduleSeries) + .createQueryBuilder("ScheduleSeries") + .leftJoinAndSelect("ScheduleSeries.doctor", "Doctor") + .leftJoinAndSelect("ScheduleSeries.room", "Room") + .where("ScheduleSeries.id = :id", { id }) + .andWhere("ScheduleSeries.deleted_at IS NULL") + .getOne(); + + if (!series) throw new Error("Schedule series not found"); + + const exceptions = await OrmHelper.DB.getRepository(ScheduleException) + .createQueryBuilder("ScheduleException") + .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom") + .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor") + .where("ScheduleException.series_id = :id", { id }) + .andWhere("ScheduleException.deleted_at IS NULL") + .orderBy("ScheduleException.occurrence_date", "ASC") + .getMany(); + + return { series, exceptions }; + } + + static async createSeries(payload: ScheduleSeriesPayload, actor: string): Promise { + const room = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.roomId } }); + if (!room) throw new Error("Room not found"); + + const doctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.doctorId } }); + if (!doctor) throw new Error("Doctor not found"); + + const series = new ScheduleSeries(); + series.room_id = payload.roomId; + series.doctor_id = payload.doctorId; + series.room = room; + series.doctor = doctor; + series.title = payload.title; + series.timezone = payload.timezone; + series.start_date = new Date(payload.startDate); + series.until_date = payload.untilDate ? new Date(payload.untilDate) : null; + series.start_time_local = payload.startTimeLocal; + series.end_time_local = payload.endTimeLocal; + series.recurrence_rule = ScheduleModel.recurrenceToRRule(payload.recurrence || null); + series.status = Status.Active; + series.created_by = actor; + series.updated_by = actor; + + return OrmHelper.DB.manager.save(series); + } + + static async updateSeries(id: string, payload: ScheduleSeriesPayload, actor: string): Promise { + const series = await OrmHelper.DB.getRepository(ScheduleSeries) + .createQueryBuilder("ScheduleSeries") + .where("ScheduleSeries.id = :id", { id }) + .andWhere("ScheduleSeries.deleted_at IS NULL") + .getOne(); + if (!series) throw new Error("Schedule series not found"); + + const room = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.roomId } }); + if (!room) throw new Error("Room not found"); + + const doctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.doctorId } }); + if (!doctor) throw new Error("Doctor not found"); + + series.room_id = payload.roomId; + series.doctor_id = payload.doctorId; + series.room = room; + series.doctor = doctor; + series.title = payload.title; + series.timezone = payload.timezone; + series.start_date = new Date(payload.startDate); + series.until_date = payload.untilDate ? new Date(payload.untilDate) : null; + series.start_time_local = payload.startTimeLocal; + series.end_time_local = payload.endTimeLocal; + series.recurrence_rule = ScheduleModel.recurrenceToRRule(payload.recurrence || null); + series.updated_by = actor; + + return OrmHelper.DB.manager.save(series); + } + + static async createSeriesException(seriesId: string, payload: ScheduleExceptionPayload, actor: string): Promise { + const series = await OrmHelper.DB.getRepository(ScheduleSeries).findOne({ where: { id: seriesId } }); + if (!series) throw new Error("Schedule series not found"); + + let overrideRoom: Room = null; + if (payload.overrideRoomId) { + overrideRoom = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.overrideRoomId } }); + if (!overrideRoom) throw new Error("Override room not found"); + } + + let overrideDoctor: User = null; + if (payload.overrideDoctorId) { + overrideDoctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.overrideDoctorId } }); + if (!overrideDoctor) throw new Error("Override doctor not found"); + } + + const occurrenceDate = new Date(payload.occurrenceDate); + const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); + + let exception = await OrmHelper.DB.getRepository(ScheduleException) + .createQueryBuilder("ScheduleException") + .where("ScheduleException.series_id = :seriesId", { seriesId }) + .andWhere("ScheduleException.occurrence_date = :occurrenceDate", { occurrenceDate: occurrenceYmd }) + .andWhere("ScheduleException.deleted_at IS NULL") + .getOne(); + + if (!exception) { + exception = new ScheduleException(); + exception.series_id = seriesId; + exception.series = series; + exception.occurrence_date = occurrenceDate; + exception.created_by = actor; + } + + exception.is_cancelled = !!payload.isCancelled; + exception.override_room_id = payload.overrideRoomId || null; + exception.override_doctor_id = payload.overrideDoctorId || null; + exception.override_room = overrideRoom; + exception.override_doctor = overrideDoctor; + exception.override_title = payload.overrideTitle || null; + exception.override_start_at = payload.overrideStartAt ? new Date(payload.overrideStartAt) : null; + exception.override_end_at = payload.overrideEndAt ? new Date(payload.overrideEndAt) : null; + exception.updated_by = actor; + + return OrmHelper.DB.manager.save(exception); + } + + private static expandSeriesOccurrences(series: ScheduleSeries, rangeStart: Date, rangeEnd: Date): Date[] { + const seriesStart = ScheduleModel.startOfDay(new Date(series.start_date)); + const seriesUntil = series.until_date ? ScheduleModel.endOfDay(new Date(series.until_date)) : null; + const rule = ScheduleModel.parseRRule(series.recurrence_rule || ""); + const ruleUntil = rule.until ? ScheduleModel.endOfDay(rule.until) : null; + + const effectiveStart = new Date(Math.max(rangeStart.getTime(), seriesStart.getTime())); + let effectiveEnd = new Date(rangeEnd); + if (seriesUntil && seriesUntil < effectiveEnd) effectiveEnd = seriesUntil; + if (ruleUntil && ruleUntil < effectiveEnd) effectiveEnd = ruleUntil; + if (effectiveStart > effectiveEnd) return []; + + if (!rule.freq) { + return seriesStart >= effectiveStart && seriesStart <= effectiveEnd ? [seriesStart] : []; + } + + const dates: Date[] = []; + let generatedCount = 0; + let current = new Date(seriesStart); + const byDay = rule.byDay?.length ? rule.byDay : [ScheduleModel.weekdayToRRule(seriesStart.getDay())]; + const interval = rule.interval || 1; + + while (current <= effectiveEnd) { + if (ScheduleModel.matchesRule(current, seriesStart, rule.freq, interval, byDay)) { + generatedCount += 1; + if (current >= effectiveStart) { + dates.push(new Date(current)); + } + if (rule.count && generatedCount >= rule.count) break; + } + current.setDate(current.getDate() + 1); + } + + return dates; + } + + private static matchesRule(date: Date, start: Date, freq: string, interval: number, byDay: string[]): boolean { + const diffDays = Math.floor((ScheduleModel.startOfDay(date).getTime() - ScheduleModel.startOfDay(start).getTime()) / 86400000); + if (diffDays < 0) return false; + + if (freq === "DAILY") return diffDays % interval === 0; + if (freq === "WEEKLY") { + const weekIndex = Math.floor(diffDays / 7); + return weekIndex % interval === 0 && byDay.includes(ScheduleModel.weekdayToRRule(date.getDay())); + } + if (freq === "MONTHLY") { + const months = (date.getFullYear() - start.getFullYear()) * 12 + (date.getMonth() - start.getMonth()); + return months % interval === 0 && date.getDate() === start.getDate(); + } + return false; + } + + private static parseRRule(rule: string): { freq?: string; interval?: number; count?: number; until?: Date; byDay?: string[] } { + if (!rule) return {}; + const pairs = rule.split(";").map((x) => x.trim()).filter(Boolean); + const out: { [key: string]: string } = {}; + for (const p of pairs) { + const [k, v] = p.split("="); + if (k && v) out[k.toUpperCase()] = v; + } + + const untilRaw = out.UNTIL; + let untilDate: Date = null; + if (untilRaw) { + if (/^\d{8}$/.test(untilRaw)) { + untilDate = new Date(`${untilRaw.slice(0, 4)}-${untilRaw.slice(4, 6)}-${untilRaw.slice(6, 8)}T00:00:00`); + } else if (/^\d{8}T\d{6}Z$/.test(untilRaw)) { + untilDate = new Date(`${untilRaw.slice(0, 4)}-${untilRaw.slice(4, 6)}-${untilRaw.slice(6, 8)}T${untilRaw.slice(9, 11)}:${untilRaw.slice(11, 13)}:${untilRaw.slice(13, 15)}Z`); + } else { + untilDate = new Date(untilRaw); + } + } + + return { + freq: out.FREQ?.toUpperCase(), + interval: out.INTERVAL ? Number(out.INTERVAL) : 1, + count: out.COUNT ? Number(out.COUNT) : undefined, + until: untilDate && !isNaN(untilDate.getTime()) ? untilDate : undefined, + byDay: out.BYDAY ? out.BYDAY.split(",").map((x) => x.trim().toUpperCase()) : undefined, + }; + } + + private static combineDateAndTime(ymd: string, hhmmss: string): Date { + return new Date(`${ymd}T${hhmmss}`); + } + + private static startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); + } + + private static endOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999); + } + + private static formatYmd(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; + } + + private static weekdayToRRule(day: number): string { + return ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][day]; + } + + private static recurrenceToRRule(recurrence: RecurrenceInput | null): string { + if (!recurrence) return null; + const interval = recurrence.interval && recurrence.interval > 0 ? recurrence.interval : 1; + if (recurrence.type === "weekly") { + const byDay = recurrence.daysOfWeek && recurrence.daysOfWeek.length > 0 ? recurrence.daysOfWeek.join(",") : ""; + return byDay ? `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${byDay}` : `FREQ=WEEKLY;INTERVAL=${interval}`; + } + if (recurrence.type === "daily") { + return `FREQ=DAILY;INTERVAL=${interval}`; + } + if (recurrence.type === "monthly") { + return `FREQ=MONTHLY;INTERVAL=${interval}`; + } + throw new Error("Unsupported recurrence type"); + } +} diff --git a/src/routes/private.ts b/src/routes/private.ts index 8a6cf0a..f5549c1 100644 --- a/src/routes/private.ts +++ b/src/routes/private.ts @@ -58,6 +58,7 @@ import { DoctorItemController } from '../controllers/doctor_item'; import { UserToRoomController } from '../controllers/user_to_room'; import { RoomToPharmacyController } from '../controllers/room_to_pharmacy'; import { PlanningVerificatorController } from '../controllers/pharmacy/planning_verificator'; +import { ScheduleController } from '../controllers/schedule'; export class RoutePrivate { static setup(app: express.Application) { @@ -205,6 +206,14 @@ export class RoutePrivate { app.delete('/api/doctor-schedule/delete/:id/:hard', DoctorScheduleController.delete) app.put('/api/doctor-schedule/restore/:id', DoctorScheduleController.restore) + app.get('/api/schedule/list', ScheduleController.list) + app.post('/api/schedule/create', ScheduleController.create) + app.get('/api/schedule/detail/:id', ScheduleController.detail) + app.put('/api/schedule/update/:id', ScheduleController.update) + app.get('/api/schedule/options', ScheduleController.options) + app.get('/api/schedule/calendar', ScheduleController.calendar) + app.post('/api/schedule/:id/create-exception', ScheduleController.createException) + app.get('/api/doctor-item/list', DoctorItemController.listAll) app.get('/api/doctor-item/list-fav', DoctorItemController.listFav) app.post('/api/doctor-item/select-fav', DoctorItemController.selectFav) diff --git a/src/swagger/builder.js b/src/swagger/builder.js index ffb99b7..697017d 100644 --- a/src/swagger/builder.js +++ b/src/swagger/builder.js @@ -356,6 +356,60 @@ const doc = { planning_verificator: { $user_id: 'User ID (required, UUID)' }, + scheduleRecurrence: { + $type: { "@enum": ["daily", "weekly", "monthly"] }, + $interval: 1, + $daysOfWeek: ["MO", "WE", "FR"] + }, + scheduleSeriesCreate: { + $roomId: "uuid-string", + $doctorId: "uuid-string", + $title: "Practice Schedule", + $timezone: "Asia/Jakarta", + $startDate: "2026-03-20", + $untilDate: "2026-06-30", + $startTimeLocal: "09:00:00", + $endTimeLocal: "12:00:00", + $recurrence: { $ref: "#/components/schemas/scheduleRecurrence" } + }, + scheduleSeriesUpdate: { + $roomId: "uuid-string", + $doctorId: "uuid-string", + $title: "Practice Schedule Updated", + $timezone: "Asia/Jakarta", + $startDate: "2026-03-20", + $untilDate: "2026-07-31", + $startTimeLocal: "10:00:00", + $endTimeLocal: "13:00:00", + $recurrence: { $ref: "#/components/schemas/scheduleRecurrence" } + }, + scheduleExceptionCreate: { + $occurrenceDate: "2026-03-24", + $isCancelled: false, + $overrideRoomId: "uuid-string", + $overrideDoctorId: "uuid-string", + $overrideTitle: "Override Practice", + $overrideStartAt: "2026-03-24T10:00:00.000Z", + $overrideEndAt: "2026-03-24T12:30:00.000Z" + }, + scheduleCalendarResource: { + $id: "room-1:doctor-1", + $roomId: "room-1", + $roomName: "Room 1", + $doctorId: "doctor-1", + $doctorName: "Dr A" + }, + scheduleCalendarEvent: { + $id: "occ:series-101:2026-03-20", + $seriesId: "series-101", + $resourceId: "room-1:doctor-1", + $title: "Practice", + $startAt: "2026-03-20T09:00:00+07:00", + $endAt: "2026-03-20T12:00:00+07:00", + $isRecurring: true, + $isException: false, + $status: "confirmed" + }, }, parameters: {