feat(api): add schedule series and calendar endpoints
This commit is contained in:
324
src/controllers/schedule.ts
Normal file
324
src/controllers/schedule.ts
Normal file
@ -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<ILogObj> = new Logger({
|
||||
name: "[ScheduleController]",
|
||||
type: "pretty",
|
||||
});
|
||||
|
||||
export class ScheduleController {
|
||||
static async list(req: Request, res: Response, next: NextFunction): Promise<void | Response> {
|
||||
/*
|
||||
#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<void | Response> {
|
||||
/*
|
||||
#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<void | Response> {
|
||||
/*
|
||||
#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<void | Response> {
|
||||
/*
|
||||
#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<void | Response> {
|
||||
/*
|
||||
#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<void | Response> {
|
||||
/*
|
||||
#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<void | Response> {
|
||||
/*
|
||||
#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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: [],
|
||||
|
||||
424
src/model/schedule.ts
Normal file
424
src/model/schedule.ts
Normal file
@ -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<SelectQueryBuilder<any>> {
|
||||
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<any> {
|
||||
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<string, ScheduleException>();
|
||||
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<string, any>();
|
||||
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<any> {
|
||||
const query = await ScheduleModel.listSeries(filterObj);
|
||||
query.andWhere("ScheduleSeries.deleted_at IS NULL");
|
||||
const seriesList: ScheduleSeries[] = await query.getMany();
|
||||
|
||||
const doctorsMap = new Map<string, any>();
|
||||
const roomsMap = new Map<string, any>();
|
||||
|
||||
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<any> {
|
||||
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<ScheduleSeries> {
|
||||
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<ScheduleSeries> {
|
||||
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<ScheduleException> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user