import { Room, ScheduleException, ScheduleSeries, Status, User } from "entity"; import { IsNull, SelectQueryBuilder } from "typeorm"; import CommonHelper from "../helpers/common"; import { OrmHelper } from "../helpers/orm"; import dayjs from "dayjs"; import { RoomModel } from "./room"; import { filter } from "compression"; type RecurrenceInput = { type: "daily" | "weekly" | "monthly"; interval?: number; daysOfWeek?: string[]; }; type ScheduleSeriesPayload = { roomId: string; doctorId: string; title: string; quota?: number | null; startDate: string; untilDate?: string | null; startTime: string; endTime: 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; }; type CalendarQueryOptions = { limit?: number; page?: number; }; 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: any, options: CalendarQueryOptions = {}): Promise { const startDate = dayjs(start); const endDate = dayjs(end); if (!startDate.isValid() || !endDate.isValid()) { throw new Error("Invalid start or end date"); } const rangeStart = startDate.startOf("day").toDate(); const rangeEnd = endDate.endOf("day").toDate(); if (rangeStart > rangeEnd) { throw new Error("Start date must be before end date"); } const filterRoomId = filterObj && filterObj.roomId !== "" && filterObj.roomId !== null && filterObj.roomId !== undefined ? String(filterObj.roomId) : ""; const page = options.page ?? 1; const limit = options.limit ?? 10; const offset = (page - 1) * limit; const query = await RoomModel.list({ ...filterObj, with_deleted: false }); const roomQuery = query .select(["Room.id", "Room.room", "Room.code", "Room.picture", "Room.location", "Room.status", "Department.name"]) .leftJoin("Room.department", "Department") .where("Department.name = :departmentName", { departmentName: "Outpatient" }); if (filterRoomId !== "") { roomQuery.andWhere("Room.id = :roomId", { roomId: filterRoomId }); } const roomTotalCount = await roomQuery .getCount(); const rooms = await roomQuery .orderBy("Room.room", "ASC") .offset(offset) .limit(limit) .getMany(); const roomIds = rooms.map((room) => room.id); const seriesQuery = await ScheduleModel.listSeries(filterObj); const seriesList = await seriesQuery .where("ScheduleSeries.room_id IN (:...roomIds)", { roomIds }) .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: dayjs(rangeEnd).format("YYYY-MM-DD") }) .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { rangeStartDate: dayjs(rangeStart).format("YYYY-MM-DD") }) .andWhere("ScheduleSeries.deleted_at IS NULL") .getMany(); const allEvents: any[] = []; if (seriesList.length > 0) { const seriesIds = seriesList.map((series) => series.id); const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd); 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}:${dayjs(ex.occurrence_date).format("YYYY-MM-DD")}`; exceptionMap.set(key, ex); } for (const series of seriesList) { const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); for (const occurrenceDate of occurrenceDates) { const occurrenceYmd = dayjs(occurrenceDate).format("YYYY-MM-DD"); const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); if (exception && exception.is_cancelled) continue; const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time); const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time); const doctor = exception?.override_doctor ?? series.doctor; const room = exception?.override_room ?? series.room; const eventStart = exception?.override_start_at ? exception.override_start_at : defaultStart; const eventEnd = exception?.override_end_at ? exception.override_end_at : defaultEnd; const eventTitle = exception?.override_title || series.title; const roomId = room?.id || null; const doctorId = doctor?.id || null; 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 || ""; allEvents.push({ id: `occ:${series.id}:${occurrenceYmd}`, series_id: series.id, title: eventTitle, room_id: roomId, room_name: roomName, doctor_id: doctorId, doctor_name: doctorName, start_at: eventStart, end_at: eventEnd, quota: series.quota ?? null, used_quota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0, left_quota: typeof series.quota === "number" ? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0)) : null, is_recurring: !!series.recurrence_rule, is_exception: !!exception, status: "confirmed", }); } } } for (const room of rooms) { const roomEvents = allEvents.filter((event) => event.room_id === room.id); room.events = roomEvents; } return { count: rooms.length, page: page, total_count: roomTotalCount, list: rooms, }; } 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(); const recurrenceRule = series.recurrence_rule; const { recurrence_rule: _omitRule, ...seriesRest } = series as ScheduleSeries & { recurrence_rule?: string | null }; return { series: { ...seriesRest, recurrence: ScheduleModel.rRuleToRecurrence(recurrenceRule), }, 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.quota = payload.quota ?? null; series.start_date = new Date(payload.startDate); series.until_date = payload.untilDate ? new Date(payload.untilDate) : null; series.start_time = payload.startTime; series.end_time = payload.endTime; 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.quota = payload.quota ?? null; series.start_date = new Date(payload.startDate); series.until_date = payload.untilDate ? new Date(payload.untilDate) : null; series.start_time = payload.startTime; series.end_time = payload.endTime; 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); } static async deleteSeries(id: string, hard: boolean, actor: string): Promise { const manager = OrmHelper.DB.manager; if (hard) { await manager.delete(ScheduleException, { series_id: id }); const result = await manager.delete(ScheduleSeries, { id }); return result.affected ?? 0; } const series = await OrmHelper.DB.getRepository(ScheduleSeries) .createQueryBuilder("ScheduleSeries") .where("ScheduleSeries.id = :id", { id }) .andWhere("ScheduleSeries.deleted_at IS NULL") .getOne(); if (!series) return 0; const now = new Date(); await manager.transaction(async (tx) => { await tx .createQueryBuilder() .update(ScheduleException) .set({ deleted_at: now, deleted_by: actor }) .where("series_id = :id", { id }) .andWhere("deleted_at IS NULL") .execute(); series.deleted_at = now; series.deleted_by = actor; await tx.save(series); }); return 1; } 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) { return `${ymd} ${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"); } private static rRuleToRecurrence(recurrence_rule: string | null): RecurrenceInput | null { if (!recurrence_rule || !String(recurrence_rule).trim()) return null; const parsed = ScheduleModel.parseRRule(recurrence_rule); if (!parsed.freq) return null; const interval = parsed.interval && parsed.interval > 0 ? parsed.interval : 1; const freq = parsed.freq.toUpperCase(); if (freq === "DAILY") { return { type: "daily", interval }; } if (freq === "WEEKLY") { const out: RecurrenceInput = { type: "weekly", interval }; if (parsed.byDay && parsed.byDay.length > 0) { out.daysOfWeek = parsed.byDay; } return out; } if (freq === "MONTHLY") { return { type: "monthly", interval }; } return null; } private static async getDailyUsedQuotaMap(seriesIds: string[], rangeStart: Date, rangeEnd: Date): Promise> { const usedQuotaMap = new Map(); if (seriesIds.length === 0) return usedQuotaMap; const placeholders = seriesIds.map((_, index) => `$${index + 1}`).join(", "); const startParamIndex = seriesIds.length + 1; const endParamIndex = seriesIds.length + 2; const params = [...seriesIds, ScheduleModel.formatYmd(rangeStart), ScheduleModel.formatYmd(rangeEnd)]; const query = ` SELECT ro.schedule_series_id AS series_id, DATE(ro.visit_date) AS visit_date, COUNT(ro.id) AS used_count FROM emr.emr_registration_outpatient ro WHERE ro.schedule_series_id IN (${placeholders}) AND DATE(ro.visit_date) BETWEEN $${startParamIndex} AND $${endParamIndex} AND ro.deleted_at IS NULL GROUP BY ro.schedule_series_id, DATE(ro.visit_date) `; const rows: Array<{ series_id: string; visit_date: string | Date; used_count: string | number }> = await OrmHelper.DB.manager.query( query, params ); for (const row of rows) { const occurrenceYmd = ScheduleModel.formatYmd(new Date(row.visit_date)); usedQuotaMap.set(`${row.series_id}:${occurrenceYmd}`, Number(row.used_count) || 0); } return usedQuotaMap; } }