// ─── Available Slots ─────────────────────────────────────────────────────────
appointments.get('/available-slots', requireClinicContext, async (c) => {
try {
const db = getReadDb();
await ensureAppointmentsSchema(db);
const clinicContext = c.get('clinicContext');
const currentClinicId = clinicContext?.currentClinicId || '';
if (!currentClinicId) throw new AppError('Clinic ID required', 400);
const dateParam = c.req.query('date');
const doctorIdParam = c.req.query('doctorId') || null;
if (!dateParam || !/^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
throw new AppError('date query param required (YYYY-MM-DD)', 400);
}
const toMinutes = (t: string): number => {
const [h, m] = t.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
const DAY_NAMES = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;
const dayOfWeekKey = DAY_NAMES[new Date(`${dateParam}T00:00:00`).getDay()];
// 1. Clinic operating hours for that day
const [clinicHours] = await db.select({
openTime: clinicOperatingHours.openTime,
closeTime: clinicOperatingHours.closeTime,
isClosed: clinicOperatingHours.isClosed,
})
.from(clinicOperatingHours)
.where(and(
eq(clinicOperatingHours.clinicId, currentClinicId),
eq(clinicOperatingHours.dayOfWeek, dayOfWeekKey as any),
))
.limit(1);
// Fetch clinic phone for the closed-day response
const [clinicRow] = await db.select({ phone: clinics.phone })
.from(clinics)
.where(eq(clinics.id, currentClinicId))
.limit(1);
const clinicPhone = clinicRow?.phone || null;
if (clinicHours?.isClosed) {
return c.json({ slots: [], clinicClosed: true, doctorOff: false, clinicPhone });
}
// Clinic window in minutes (default full day if no hours set)
const clinicOpen = clinicHours?.openTime ? toMinutes(clinicHours.openTime) : 0;
const clinicClose = clinicHours?.closeTime ? toMinutes(clinicHours.closeTime) : 24 * 60;
// 2. Doctor schedule for that day (if doctorId provided)
let effectiveOpen = clinicOpen;
let effectiveClose = clinicClose;
let doctorOff = false;
if (doctorIdParam) {
// Validate doctor belongs to this clinic
const [doctorUser] = await db.select({ id: users.id })
.from(users)
.where(and(eq(users.id, doctorIdParam), eq(users.primaryClinicId, currentClinicId)))
.limit(1);
if (!doctorUser) throw new AppError('Doctor not found in this clinic', 403);
const [docSchedule] = await db.select()
.from(doctorSchedules)
.where(and(
eq(doctorSchedules.clinicId, currentClinicId),
eq(doctorSchedules.doctorId, doctorIdParam),
eq(doctorSchedules.dayOfWeek, dayOfWeekKey),
))
.limit(1);
if (docSchedule) {
if (docSchedule.isOff) {
return c.json({ slots: [], clinicClosed: false, doctorOff: true, clinicPhone });
}
// Intersect doctor hours with clinic hours
effectiveOpen = Math.max(clinicOpen, toMinutes(docSchedule.startTime));
effectiveClose = Math.min(clinicClose, toMinutes(docSchedule.endTime));
}
// If no schedule row exists for this doctor, fall back to clinic hours
}
if (effectiveOpen >= effectiveClose) {
return c.json({ slots: [], clinicClosed: false, doctorOff: false, clinicPhone });
}
// 3. Generate 30-minute slots
const allSlots: string[] = [];
for (let m = effectiveOpen; m + 30 <= effectiveClose; m += 30) {
const hh = String(Math.floor(m / 60)).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0');
allSlots.push(`${hh}:${mm}`);
}
// 4. Remove already-booked slots for this doctor+date
const bookedFilter = [
eq(appointments.clinicId, currentClinicId),
eq(appointments.appointmentDate, dateParam),
sql`${appointments.status} NOT IN ('cancelled', 'rejected')`,
];
if (doctorIdParam) bookedFilter.push(eq(appointments.doctorId, doctorIdParam));
const booked = await db.select({ time: appointments.appointmentTime, duration: appointments.durationMinutes })
.from(appointments)
.where(and(...bookedFilter));
const bookedMinutes = new Set<number>();
for (const b of booked) {
if (!b.time) continue;
const start = toMinutes(b.time);
const dur = Number(b.duration || 30);
for (let m = start; m < start + dur; m += 30) bookedMinutes.add(m);
}
// 5. Remove past slots if date is today (PKT)
const todayPKT = nowPKTDateString();
const nowTimeStr = nowPKTTimeString();
const nowMin = toMinutes(nowTimeStr);
const availableSlots = allSlots.filter(slot => {
const slotMin = toMinutes(slot);
if (bookedMinutes.has(slotMin)) return false;
if (dateParam === todayPKT && slotMin <= nowMin) return false;
return true;
});
return c.json({ slots: availableSlots, clinicClosed: false, doctorOff: false, clinicPhone });
} catch (error) {
return handleError(error, c);
}
});