fixing timezone

This commit is contained in:
avicenan
2026-03-25 10:10:31 +07:00
parent 1f73c5e04f
commit e872fc8e4d

View File

@ -4,455 +4,455 @@ import CommonHelper from "../helpers/common";
import { OrmHelper } from "../helpers/orm"; import { OrmHelper } from "../helpers/orm";
type RecurrenceInput = { type RecurrenceInput = {
type: "daily" | "weekly" | "monthly"; type: "daily" | "weekly" | "monthly";
interval?: number; interval?: number;
daysOfWeek?: string[]; daysOfWeek?: string[];
}; };
type ScheduleSeriesPayload = { type ScheduleSeriesPayload = {
roomId: string; roomId: string;
doctorId: string; doctorId: string;
title: string; title: string;
timezone: string; timezone: string;
startDate: string; startDate: string;
untilDate?: string | null; untilDate?: string | null;
startTimeLocal: string; startTimeLocal: string;
endTimeLocal: string; endTimeLocal: string;
recurrence?: RecurrenceInput | null; recurrence?: RecurrenceInput | null;
}; };
type ScheduleExceptionPayload = { type ScheduleExceptionPayload = {
occurrenceDate: string; occurrenceDate: string;
isCancelled?: boolean; isCancelled?: boolean;
overrideRoomId?: string | null; overrideRoomId?: string | null;
overrideDoctorId?: string | null; overrideDoctorId?: string | null;
overrideTitle?: string | null; overrideTitle?: string | null;
overrideStartAt?: string | null; overrideStartAt?: string | null;
overrideEndAt?: string | null; overrideEndAt?: string | null;
}; };
type CalendarQueryOptions = { type CalendarQueryOptions = {
limit?: number; limit?: number;
page?: number; page?: number;
}; };
export class ScheduleModel { export class ScheduleModel {
static async listSeries(filterObj = {}): Promise<SelectQueryBuilder<any>> { static async listSeries(filterObj = {}): Promise<SelectQueryBuilder<any>> {
const repo = OrmHelper.DB.getRepository(ScheduleSeries); const repo = OrmHelper.DB.getRepository(ScheduleSeries);
let whereAttr: string[] = []; let whereAttr: string[] = [];
let whereVal: any = {}; let whereVal: any = {};
if (filterObj && Object.keys(filterObj).length > 0) { if (filterObj && Object.keys(filterObj).length > 0) {
const filterResult = CommonHelper.handleQueryFilter(filterObj); const filterResult = CommonHelper.handleQueryFilter(filterObj);
whereAttr = [...whereAttr, ...filterResult.whereAttr]; whereAttr = [...whereAttr, ...filterResult.whereAttr];
whereVal = { ...whereVal, ...filterResult.whereVal }; whereVal = { ...whereVal, ...filterResult.whereVal };
} }
let query = repo let query = repo
.createQueryBuilder("ScheduleSeries") .createQueryBuilder("ScheduleSeries")
.leftJoinAndSelect("ScheduleSeries.doctor", "Doctor") .leftJoinAndSelect("ScheduleSeries.doctor", "Doctor")
.leftJoinAndSelect("ScheduleSeries.room", "Room"); .leftJoinAndSelect("ScheduleSeries.room", "Room");
if (whereAttr.length !== 0) { if (whereAttr.length !== 0) {
query = query.where(whereAttr.join(" and "), whereVal); query = query.where(whereAttr.join(" and "), whereVal);
} }
return query; return query;
} }
static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> { static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new Error("Invalid start or end date"); throw new Error("Invalid start or end date");
} }
const rangeStart = ScheduleModel.startOfDay(startDate); const rangeStart = ScheduleModel.startOfDay(startDate);
const rangeEnd = ScheduleModel.endOfDay(endDate); const rangeEnd = ScheduleModel.endOfDay(endDate);
if (rangeStart > rangeEnd) { if (rangeStart > rangeEnd) {
throw new Error("Start date must be before end date"); throw new Error("Start date must be before end date");
} }
const seriesQuery = await ScheduleModel.listSeries(filterObj); const seriesQuery = await ScheduleModel.listSeries(filterObj);
seriesQuery seriesQuery
.andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) }) .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) })
.andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", {
rangeStartDate: ScheduleModel.formatYmd(rangeStart), rangeStartDate: ScheduleModel.formatYmd(rangeStart),
}) })
.andWhere("ScheduleSeries.deleted_at IS NULL"); .andWhere("ScheduleSeries.deleted_at IS NULL");
const seriesList: ScheduleSeries[] = await seriesQuery.getMany(); const seriesList: ScheduleSeries[] = await seriesQuery.getMany();
if (seriesList.length === 0) return { resources: [], events: [] }; if (seriesList.length === 0) return { resources: [], events: [] };
const seriesIds = seriesList.map((series) => series.id); const seriesIds = seriesList.map((series) => series.id);
const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException); const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException);
const exceptions = await exceptionRepo const exceptions = await exceptionRepo
.createQueryBuilder("ScheduleException") .createQueryBuilder("ScheduleException")
.leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom") .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom")
.leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor") .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor")
.where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds }) .where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds })
.andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", { .andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", {
rangeStart: ScheduleModel.formatYmd(rangeStart), rangeStart: ScheduleModel.formatYmd(rangeStart),
rangeEnd: ScheduleModel.formatYmd(rangeEnd), rangeEnd: ScheduleModel.formatYmd(rangeEnd),
}) })
.andWhere("ScheduleException.deleted_at IS NULL") .andWhere("ScheduleException.deleted_at IS NULL")
.getMany(); .getMany();
const exceptionMap = new Map<string, ScheduleException>(); const exceptionMap = new Map<string, ScheduleException>();
for (const ex of exceptions) { for (const ex of exceptions) {
const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`; const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`;
exceptionMap.set(key, ex); exceptionMap.set(key, ex);
} }
const allEvents: any[] = []; const allEvents: any[] = [];
for (const series of seriesList) { for (const series of seriesList) {
const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd);
for (const occurrenceDate of occurrenceDates) { for (const occurrenceDate of occurrenceDates) {
const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate);
const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`);
if (exception && exception.is_cancelled) continue; if (exception && exception.is_cancelled) continue;
const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time_local); const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time_local);
const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time_local); const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time_local);
const doctor = exception?.override_doctor ?? series.doctor; const doctor = exception?.override_doctor ?? series.doctor;
const room = exception?.override_room ?? series.room; const room = exception?.override_room ?? series.room;
const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart; 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 eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd;
const eventTitle = exception?.override_title || series.title; const eventTitle = exception?.override_title || series.title;
const roomId = room?.id || null; const roomId = room?.id || null;
const doctorId = doctor?.id || null; const doctorId = doctor?.id || null;
const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`; const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`;
const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || ""; 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 || ""; const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || "";
allEvents.push({ allEvents.push({
id: `occ:${series.id}:${occurrenceYmd}`, id: `occ:${series.id}:${occurrenceYmd}`,
seriesId: series.id, seriesId: series.id,
resourceId, resourceId,
title: eventTitle, title: eventTitle,
startAt: eventStart.toISOString(), startAt: eventStart.toLocaleString('en-US', { timeZone: series.timezone }),
endAt: eventEnd.toISOString(), endAt: eventEnd.toLocaleString('en-US', { timeZone: series.timezone }),
isRecurring: !!series.recurrence_rule, isRecurring: !!series.recurrence_rule,
isException: !!exception, isException: !!exception,
status: "confirmed", status: "confirmed",
_resource: { _resource: {
id: resourceId, id: resourceId,
roomId, roomId,
roomName, roomName,
doctorId, doctorId,
doctorName, doctorName,
}, },
}); });
} }
} }
const allResourcesMap = new Map<string, any>(); const allResourcesMap = new Map<string, any>();
for (const event of allEvents) { for (const event of allEvents) {
allResourcesMap.set(event._resource.id, event._resource); allResourcesMap.set(event._resource.id, event._resource);
} }
const allResources = [...allResourcesMap.values()].sort((a, b) => a.id.localeCompare(b.id)); const allResources = [...allResourcesMap.values()].sort((a, b) => a.id.localeCompare(b.id));
const totalCount = allResources.length; const totalCount = allResources.length;
const page = options.page ?? 1; const page = options.page ?? 1;
const limit = options.limit ?? totalCount; const limit = options.limit ?? totalCount;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const pagedResources = allResources.slice(offset, offset + limit); const pagedResources = allResources.slice(offset, offset + limit);
const allowedResourceIds = new Set(pagedResources.map((resource) => resource.id)); const allowedResourceIds = new Set(pagedResources.map((resource) => resource.id));
const pagedEvents = allEvents const pagedEvents = allEvents
.filter((event) => allowedResourceIds.has(event.resourceId)) .filter((event) => allowedResourceIds.has(event.resourceId))
.sort((a, b) => { .sort((a, b) => {
if (a.startAt === b.startAt) return a.id.localeCompare(b.id); if (a.startAt === b.startAt) return a.id.localeCompare(b.id);
return a.startAt.localeCompare(b.startAt); return a.startAt.localeCompare(b.startAt);
}) })
.map((event) => { .map((event) => {
const { _resource, ...cleanEvent } = event; const { _resource, ...cleanEvent } = event;
return cleanEvent; return cleanEvent;
}); });
return { return {
resources: pagedResources, resources: pagedResources,
events: pagedEvents, events: pagedEvents,
meta: { meta: {
total_count: totalCount, total_count: totalCount,
page, page,
limit, limit,
}, },
}; };
} }
static async getScheduleOptions(filterObj = {}): Promise<any> { static async getScheduleOptions(filterObj = {}): Promise<any> {
const query = await ScheduleModel.listSeries(filterObj); const query = await ScheduleModel.listSeries(filterObj);
query.andWhere("ScheduleSeries.deleted_at IS NULL"); query.andWhere("ScheduleSeries.deleted_at IS NULL");
const seriesList: ScheduleSeries[] = await query.getMany(); const seriesList: ScheduleSeries[] = await query.getMany();
const doctorsMap = new Map<string, any>(); const doctorsMap = new Map<string, any>();
const roomsMap = new Map<string, any>(); const roomsMap = new Map<string, any>();
for (const series of seriesList) { for (const series of seriesList) {
if (series.doctor?.id) { if (series.doctor?.id) {
doctorsMap.set(series.doctor.id, { doctorsMap.set(series.doctor.id, {
id: series.doctor.id, id: series.doctor.id,
title: (series.doctor as any).name || (series.doctor as any).username || "Doctor", title: (series.doctor as any).name || (series.doctor as any).username || "Doctor",
}); });
} }
if (series.room?.id) { if (series.room?.id) {
roomsMap.set(series.room.id, { roomsMap.set(series.room.id, {
id: series.room.id, id: series.room.id,
title: (series.room as any).room || (series.room as any).code || "Room", title: (series.room as any).room || (series.room as any).code || "Room",
}); });
} }
} }
return { return {
doctors: [...doctorsMap.values()], doctors: [...doctorsMap.values()],
rooms: [...roomsMap.values()], rooms: [...roomsMap.values()],
}; };
} }
static async getSeriesById(id: string): Promise<any> { static async getSeriesById(id: string): Promise<any> {
const series = await OrmHelper.DB.getRepository(ScheduleSeries) const series = await OrmHelper.DB.getRepository(ScheduleSeries)
.createQueryBuilder("ScheduleSeries") .createQueryBuilder("ScheduleSeries")
.leftJoinAndSelect("ScheduleSeries.doctor", "Doctor") .leftJoinAndSelect("ScheduleSeries.doctor", "Doctor")
.leftJoinAndSelect("ScheduleSeries.room", "Room") .leftJoinAndSelect("ScheduleSeries.room", "Room")
.where("ScheduleSeries.id = :id", { id }) .where("ScheduleSeries.id = :id", { id })
.andWhere("ScheduleSeries.deleted_at IS NULL") .andWhere("ScheduleSeries.deleted_at IS NULL")
.getOne(); .getOne();
if (!series) throw new Error("Schedule series not found"); if (!series) throw new Error("Schedule series not found");
const exceptions = await OrmHelper.DB.getRepository(ScheduleException) const exceptions = await OrmHelper.DB.getRepository(ScheduleException)
.createQueryBuilder("ScheduleException") .createQueryBuilder("ScheduleException")
.leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom") .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom")
.leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor") .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor")
.where("ScheduleException.series_id = :id", { id }) .where("ScheduleException.series_id = :id", { id })
.andWhere("ScheduleException.deleted_at IS NULL") .andWhere("ScheduleException.deleted_at IS NULL")
.orderBy("ScheduleException.occurrence_date", "ASC") .orderBy("ScheduleException.occurrence_date", "ASC")
.getMany(); .getMany();
return { series, exceptions }; return { series, exceptions };
} }
static async createSeries(payload: ScheduleSeriesPayload, actor: string): Promise<ScheduleSeries> { static async createSeries(payload: ScheduleSeriesPayload, actor: string): Promise<ScheduleSeries> {
const room = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.roomId } }); const room = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.roomId } });
if (!room) throw new Error("Room not found"); if (!room) throw new Error("Room not found");
const doctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.doctorId } }); const doctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.doctorId } });
if (!doctor) throw new Error("Doctor not found"); if (!doctor) throw new Error("Doctor not found");
const series = new ScheduleSeries(); const series = new ScheduleSeries();
series.room_id = payload.roomId; series.room_id = payload.roomId;
series.doctor_id = payload.doctorId; series.doctor_id = payload.doctorId;
series.room = room; series.room = room;
series.doctor = doctor; series.doctor = doctor;
series.title = payload.title; series.title = payload.title;
series.timezone = payload.timezone; series.timezone = payload.timezone;
series.start_date = new Date(payload.startDate); series.start_date = new Date(payload.startDate);
series.until_date = payload.untilDate ? new Date(payload.untilDate) : null; series.until_date = payload.untilDate ? new Date(payload.untilDate) : null;
series.start_time_local = payload.startTimeLocal; series.start_time_local = payload.startTimeLocal;
series.end_time_local = payload.endTimeLocal; series.end_time_local = payload.endTimeLocal;
series.recurrence_rule = ScheduleModel.recurrenceToRRule(payload.recurrence || null); series.recurrence_rule = ScheduleModel.recurrenceToRRule(payload.recurrence || null);
series.status = Status.Active; series.status = Status.Active;
series.created_by = actor; series.created_by = actor;
series.updated_by = actor; series.updated_by = actor;
return OrmHelper.DB.manager.save(series); return OrmHelper.DB.manager.save(series);
} }
static async updateSeries(id: string, payload: ScheduleSeriesPayload, actor: string): Promise<ScheduleSeries> { static async updateSeries(id: string, payload: ScheduleSeriesPayload, actor: string): Promise<ScheduleSeries> {
const series = await OrmHelper.DB.getRepository(ScheduleSeries) const series = await OrmHelper.DB.getRepository(ScheduleSeries)
.createQueryBuilder("ScheduleSeries") .createQueryBuilder("ScheduleSeries")
.where("ScheduleSeries.id = :id", { id }) .where("ScheduleSeries.id = :id", { id })
.andWhere("ScheduleSeries.deleted_at IS NULL") .andWhere("ScheduleSeries.deleted_at IS NULL")
.getOne(); .getOne();
if (!series) throw new Error("Schedule series not found"); if (!series) throw new Error("Schedule series not found");
const room = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.roomId } }); const room = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.roomId } });
if (!room) throw new Error("Room not found"); if (!room) throw new Error("Room not found");
const doctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.doctorId } }); const doctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.doctorId } });
if (!doctor) throw new Error("Doctor not found"); if (!doctor) throw new Error("Doctor not found");
series.room_id = payload.roomId; series.room_id = payload.roomId;
series.doctor_id = payload.doctorId; series.doctor_id = payload.doctorId;
series.room = room; series.room = room;
series.doctor = doctor; series.doctor = doctor;
series.title = payload.title; series.title = payload.title;
series.timezone = payload.timezone; series.timezone = payload.timezone;
series.start_date = new Date(payload.startDate); series.start_date = new Date(payload.startDate);
series.until_date = payload.untilDate ? new Date(payload.untilDate) : null; series.until_date = payload.untilDate ? new Date(payload.untilDate) : null;
series.start_time_local = payload.startTimeLocal; series.start_time_local = payload.startTimeLocal;
series.end_time_local = payload.endTimeLocal; series.end_time_local = payload.endTimeLocal;
series.recurrence_rule = ScheduleModel.recurrenceToRRule(payload.recurrence || null); series.recurrence_rule = ScheduleModel.recurrenceToRRule(payload.recurrence || null);
series.updated_by = actor; series.updated_by = actor;
return OrmHelper.DB.manager.save(series); return OrmHelper.DB.manager.save(series);
} }
static async createSeriesException(seriesId: string, payload: ScheduleExceptionPayload, actor: string): Promise<ScheduleException> { static async createSeriesException(seriesId: string, payload: ScheduleExceptionPayload, actor: string): Promise<ScheduleException> {
const series = await OrmHelper.DB.getRepository(ScheduleSeries).findOne({ where: { id: seriesId } }); const series = await OrmHelper.DB.getRepository(ScheduleSeries).findOne({ where: { id: seriesId } });
if (!series) throw new Error("Schedule series not found"); if (!series) throw new Error("Schedule series not found");
let overrideRoom: Room = null; let overrideRoom: Room = null;
if (payload.overrideRoomId) { if (payload.overrideRoomId) {
overrideRoom = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.overrideRoomId } }); overrideRoom = await OrmHelper.DB.getRepository(Room).findOne({ where: { id: payload.overrideRoomId } });
if (!overrideRoom) throw new Error("Override room not found"); if (!overrideRoom) throw new Error("Override room not found");
} }
let overrideDoctor: User = null; let overrideDoctor: User = null;
if (payload.overrideDoctorId) { if (payload.overrideDoctorId) {
overrideDoctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.overrideDoctorId } }); overrideDoctor = await OrmHelper.DB.getRepository(User).findOne({ where: { id: payload.overrideDoctorId } });
if (!overrideDoctor) throw new Error("Override doctor not found"); if (!overrideDoctor) throw new Error("Override doctor not found");
} }
const occurrenceDate = new Date(payload.occurrenceDate); const occurrenceDate = new Date(payload.occurrenceDate);
const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate);
let exception = await OrmHelper.DB.getRepository(ScheduleException) let exception = await OrmHelper.DB.getRepository(ScheduleException)
.createQueryBuilder("ScheduleException") .createQueryBuilder("ScheduleException")
.where("ScheduleException.series_id = :seriesId", { seriesId }) .where("ScheduleException.series_id = :seriesId", { seriesId })
.andWhere("ScheduleException.occurrence_date = :occurrenceDate", { occurrenceDate: occurrenceYmd }) .andWhere("ScheduleException.occurrence_date = :occurrenceDate", { occurrenceDate: occurrenceYmd })
.andWhere("ScheduleException.deleted_at IS NULL") .andWhere("ScheduleException.deleted_at IS NULL")
.getOne(); .getOne();
if (!exception) { if (!exception) {
exception = new ScheduleException(); exception = new ScheduleException();
exception.series_id = seriesId; exception.series_id = seriesId;
exception.series = series; exception.series = series;
exception.occurrence_date = occurrenceDate; exception.occurrence_date = occurrenceDate;
exception.created_by = actor; exception.created_by = actor;
} }
exception.is_cancelled = !!payload.isCancelled; exception.is_cancelled = !!payload.isCancelled;
exception.override_room_id = payload.overrideRoomId || null; exception.override_room_id = payload.overrideRoomId || null;
exception.override_doctor_id = payload.overrideDoctorId || null; exception.override_doctor_id = payload.overrideDoctorId || null;
exception.override_room = overrideRoom; exception.override_room = overrideRoom;
exception.override_doctor = overrideDoctor; exception.override_doctor = overrideDoctor;
exception.override_title = payload.overrideTitle || null; exception.override_title = payload.overrideTitle || null;
exception.override_start_at = payload.overrideStartAt ? new Date(payload.overrideStartAt) : null; exception.override_start_at = payload.overrideStartAt ? new Date(payload.overrideStartAt) : null;
exception.override_end_at = payload.overrideEndAt ? new Date(payload.overrideEndAt) : null; exception.override_end_at = payload.overrideEndAt ? new Date(payload.overrideEndAt) : null;
exception.updated_by = actor; exception.updated_by = actor;
return OrmHelper.DB.manager.save(exception); return OrmHelper.DB.manager.save(exception);
} }
private static expandSeriesOccurrences(series: ScheduleSeries, rangeStart: Date, rangeEnd: Date): Date[] { private static expandSeriesOccurrences(series: ScheduleSeries, rangeStart: Date, rangeEnd: Date): Date[] {
const seriesStart = ScheduleModel.startOfDay(new Date(series.start_date)); const seriesStart = ScheduleModel.startOfDay(new Date(series.start_date));
const seriesUntil = series.until_date ? ScheduleModel.endOfDay(new Date(series.until_date)) : null; const seriesUntil = series.until_date ? ScheduleModel.endOfDay(new Date(series.until_date)) : null;
const rule = ScheduleModel.parseRRule(series.recurrence_rule || ""); const rule = ScheduleModel.parseRRule(series.recurrence_rule || "");
const ruleUntil = rule.until ? ScheduleModel.endOfDay(rule.until) : null; const ruleUntil = rule.until ? ScheduleModel.endOfDay(rule.until) : null;
const effectiveStart = new Date(Math.max(rangeStart.getTime(), seriesStart.getTime())); const effectiveStart = new Date(Math.max(rangeStart.getTime(), seriesStart.getTime()));
let effectiveEnd = new Date(rangeEnd); let effectiveEnd = new Date(rangeEnd);
if (seriesUntil && seriesUntil < effectiveEnd) effectiveEnd = seriesUntil; if (seriesUntil && seriesUntil < effectiveEnd) effectiveEnd = seriesUntil;
if (ruleUntil && ruleUntil < effectiveEnd) effectiveEnd = ruleUntil; if (ruleUntil && ruleUntil < effectiveEnd) effectiveEnd = ruleUntil;
if (effectiveStart > effectiveEnd) return []; if (effectiveStart > effectiveEnd) return [];
if (!rule.freq) { if (!rule.freq) {
return seriesStart >= effectiveStart && seriesStart <= effectiveEnd ? [seriesStart] : []; return seriesStart >= effectiveStart && seriesStart <= effectiveEnd ? [seriesStart] : [];
} }
const dates: Date[] = []; const dates: Date[] = [];
let generatedCount = 0; let generatedCount = 0;
let current = new Date(seriesStart); let current = new Date(seriesStart);
const byDay = rule.byDay?.length ? rule.byDay : [ScheduleModel.weekdayToRRule(seriesStart.getDay())]; const byDay = rule.byDay?.length ? rule.byDay : [ScheduleModel.weekdayToRRule(seriesStart.getDay())];
const interval = rule.interval || 1; const interval = rule.interval || 1;
while (current <= effectiveEnd) { while (current <= effectiveEnd) {
if (ScheduleModel.matchesRule(current, seriesStart, rule.freq, interval, byDay)) { if (ScheduleModel.matchesRule(current, seriesStart, rule.freq, interval, byDay)) {
generatedCount += 1; generatedCount += 1;
if (current >= effectiveStart) { if (current >= effectiveStart) {
dates.push(new Date(current)); dates.push(new Date(current));
} }
if (rule.count && generatedCount >= rule.count) break; if (rule.count && generatedCount >= rule.count) break;
} }
current.setDate(current.getDate() + 1); current.setDate(current.getDate() + 1);
} }
return dates; return dates;
} }
private static matchesRule(date: Date, start: Date, freq: string, interval: number, byDay: string[]): boolean { 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); const diffDays = Math.floor((ScheduleModel.startOfDay(date).getTime() - ScheduleModel.startOfDay(start).getTime()) / 86400000);
if (diffDays < 0) return false; if (diffDays < 0) return false;
if (freq === "DAILY") return diffDays % interval === 0; if (freq === "DAILY") return diffDays % interval === 0;
if (freq === "WEEKLY") { if (freq === "WEEKLY") {
const weekIndex = Math.floor(diffDays / 7); const weekIndex = Math.floor(diffDays / 7);
return weekIndex % interval === 0 && byDay.includes(ScheduleModel.weekdayToRRule(date.getDay())); return weekIndex % interval === 0 && byDay.includes(ScheduleModel.weekdayToRRule(date.getDay()));
} }
if (freq === "MONTHLY") { if (freq === "MONTHLY") {
const months = (date.getFullYear() - start.getFullYear()) * 12 + (date.getMonth() - start.getMonth()); const months = (date.getFullYear() - start.getFullYear()) * 12 + (date.getMonth() - start.getMonth());
return months % interval === 0 && date.getDate() === start.getDate(); return months % interval === 0 && date.getDate() === start.getDate();
} }
return false; return false;
} }
private static parseRRule(rule: string): { freq?: string; interval?: number; count?: number; until?: Date; byDay?: string[] } { private static parseRRule(rule: string): { freq?: string; interval?: number; count?: number; until?: Date; byDay?: string[] } {
if (!rule) return {}; if (!rule) return {};
const pairs = rule.split(";").map((x) => x.trim()).filter(Boolean); const pairs = rule.split(";").map((x) => x.trim()).filter(Boolean);
const out: { [key: string]: string } = {}; const out: { [key: string]: string } = {};
for (const p of pairs) { for (const p of pairs) {
const [k, v] = p.split("="); const [k, v] = p.split("=");
if (k && v) out[k.toUpperCase()] = v; if (k && v) out[k.toUpperCase()] = v;
} }
const untilRaw = out.UNTIL; const untilRaw = out.UNTIL;
let untilDate: Date = null; let untilDate: Date = null;
if (untilRaw) { if (untilRaw) {
if (/^\d{8}$/.test(untilRaw)) { if (/^\d{8}$/.test(untilRaw)) {
untilDate = new Date(`${untilRaw.slice(0, 4)}-${untilRaw.slice(4, 6)}-${untilRaw.slice(6, 8)}T00:00:00`); 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)) { } 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`); 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 { } else {
untilDate = new Date(untilRaw); untilDate = new Date(untilRaw);
} }
} }
return { return {
freq: out.FREQ?.toUpperCase(), freq: out.FREQ?.toUpperCase(),
interval: out.INTERVAL ? Number(out.INTERVAL) : 1, interval: out.INTERVAL ? Number(out.INTERVAL) : 1,
count: out.COUNT ? Number(out.COUNT) : undefined, count: out.COUNT ? Number(out.COUNT) : undefined,
until: untilDate && !isNaN(untilDate.getTime()) ? untilDate : undefined, until: untilDate && !isNaN(untilDate.getTime()) ? untilDate : undefined,
byDay: out.BYDAY ? out.BYDAY.split(",").map((x) => x.trim().toUpperCase()) : undefined, byDay: out.BYDAY ? out.BYDAY.split(",").map((x) => x.trim().toUpperCase()) : undefined,
}; };
} }
private static combineDateAndTime(ymd: string, hhmmss: string): Date { private static combineDateAndTime(ymd: string, hhmmss: string): Date {
return new Date(`${ymd}T${hhmmss}`); return new Date(`${ymd}T${hhmmss}`);
} }
private static startOfDay(date: Date): Date { private static startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
} }
private static endOfDay(date: Date): Date { private static endOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999); return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
} }
private static formatYmd(date: Date): string { private static formatYmd(date: Date): string {
const y = date.getFullYear(); const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0"); const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
} }
private static weekdayToRRule(day: number): string { private static weekdayToRRule(day: number): string {
return ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][day]; return ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][day];
} }
private static recurrenceToRRule(recurrence: RecurrenceInput | null): string { private static recurrenceToRRule(recurrence: RecurrenceInput | null): string {
if (!recurrence) return null; if (!recurrence) return null;
const interval = recurrence.interval && recurrence.interval > 0 ? recurrence.interval : 1; const interval = recurrence.interval && recurrence.interval > 0 ? recurrence.interval : 1;
if (recurrence.type === "weekly") { if (recurrence.type === "weekly") {
const byDay = recurrence.daysOfWeek && recurrence.daysOfWeek.length > 0 ? recurrence.daysOfWeek.join(",") : ""; const byDay = recurrence.daysOfWeek && recurrence.daysOfWeek.length > 0 ? recurrence.daysOfWeek.join(",") : "";
return byDay ? `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${byDay}` : `FREQ=WEEKLY;INTERVAL=${interval}`; return byDay ? `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${byDay}` : `FREQ=WEEKLY;INTERVAL=${interval}`;
} }
if (recurrence.type === "daily") { if (recurrence.type === "daily") {
return `FREQ=DAILY;INTERVAL=${interval}`; return `FREQ=DAILY;INTERVAL=${interval}`;
} }
if (recurrence.type === "monthly") { if (recurrence.type === "monthly") {
return `FREQ=MONTHLY;INTERVAL=${interval}`; return `FREQ=MONTHLY;INTERVAL=${interval}`;
} }
throw new Error("Unsupported recurrence type"); throw new Error("Unsupported recurrence type");
} }
} }