// server/src/routes/public-appointments.ts
import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { getDatabaseUrl } from '../lib/env';
import { appointments, patients, clinics } from '../schema';
import { eq, and } from 'drizzle-orm';
import { handleError, AppError } from '../lib/errors';
import { verifyAppointmentToken, TokenExpiredError, TokenInvalidError } from '../lib/appointment-tokens';
import { decryptPatientPHI } from '../lib/encryption';
import { queueAppointmentStatusNotifications } from '../lib/appointment-notifications';
import { getClientIp } from '../lib/turnstile';
const publicAppointmentsRoute = new Hono();
// In-memory rate limiter: 10 requests per minute per IP
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowMs = 60 * 1000;
const maxRequests = 10;
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });
return false;
}
if (entry.count >= maxRequests) return true;
entry.count++;
return false;
}
const TERMINAL_STATUSES = ['completed', 'in_progress', 'no_show'] as const;
publicAppointmentsRoute.post('/respond', async (c) => {
try {
const ip = getClientIp(c);
if (isRateLimited(ip)) {
return c.json({ error: 'Too many requests. Please try again later.' }, 429);
}
const body = await c.req.json().catch(() => ({}));
const { token } = body as { token?: string };
if (!token || typeof token !== 'string') {
return c.json({ error: 'Token required' }, 400);
}
let appointmentId: string;
let action: 'confirm' | 'cancel';
try {
({ appointmentId, action } = verifyAppointmentToken(token));
} catch (e) {
if (e instanceof TokenExpiredError) return c.json({ error: 'Token expired' }, 400);
return c.json({ error: 'Invalid token' }, 400);
}
const db = await getDatabase(getDatabaseUrl());
const [row] = await db
.select({ appointment: appointments, patient: patients, clinic: clinics })
.from(appointments)
.leftJoin(patients, eq(appointments.patientId, patients.id))
.leftJoin(clinics, eq(appointments.clinicId, clinics.id))
.where(eq(appointments.id, appointmentId))
.limit(1);
if (!row?.appointment) throw new AppError('Appointment not found', 404);
const { appointment, patient, clinic } = row;
const clinicName = clinic?.name || 'Your Clinic';
const decPat = patient ? decryptPatientPHI(patient as Record<string, any>) : null;
const patientName = decPat ? `${decPat.firstName} ${decPat.lastName}` : 'Patient';
const dateVal = appointment.appointmentDate as unknown;
const dateStr = dateVal instanceof Date ? dateVal.toISOString().split('T')[0] : String(dateVal);
const dt = new Date(`${dateStr}T${appointment.appointmentTime}`);
const appointmentDate = dt.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
const appointmentTime = dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
// If appointment is in a terminal state that this action can't change, return gracefully
const currentStatus = appointment.status as string;
const targetStatus = action === 'confirm' ? 'confirmed' : 'cancelled';
if (currentStatus === targetStatus) {
return c.json({ ok: true, alreadyProcessed: true, status: currentStatus, patientName, clinicName, appointmentDate, appointmentTime });
}
if ((TERMINAL_STATUSES as readonly string[]).includes(currentStatus) || currentStatus === 'cancelled') {
return c.json({ ok: true, alreadyProcessed: true, status: currentStatus, patientName, clinicName, appointmentDate, appointmentTime });
}
// Update appointment status
const [updated] = await db
.update(appointments)
.set({ status: targetStatus, updatedAt: new Date() })
.where(eq(appointments.id, appointmentId))
.returning();
if (!updated) throw new AppError('Failed to update appointment', 500);
// Fire notifications to staff (email + in-app) — executionCtx not available on public route,
// so we await directly. The notification helper handles its own error logging.
const doctorInfo = appointment.doctorId
? undefined // we don't have doctor details here; notifications will skip doctor email
: undefined;
queueAppointmentStatusNotifications({
db,
clinicId: appointment.clinicId,
appointment: {
id: appointment.id,
appointmentDate: appointment.appointmentDate,
appointmentTime: appointment.appointmentTime,
appointmentType: appointment.appointmentType,
notes: appointment.notes,
},
patientInfo: decPat as any,
doctorInfo,
status: targetStatus as any,
executionCtx: {
waitUntil: (p: Promise<any>) => p.catch(err => console.error('Notification error:', err)),
},
});
return c.json({ ok: true, alreadyProcessed: false, status: targetStatus, patientName, clinicName, appointmentDate, appointmentTime });
} catch (error) {
return handleError(error, c);
}
});
export default publicAppointmentsRoute;