feat: add schedule column labels, office hours enforcement, and appointment move fix

- Schedule columns default to labels A–F (localStorage, per-browser, click to rename)
- Settings → Advanced → Office Hours: configure Doctors (A-C) and Hygienists (D-F) AM/PM hours per weekday
- Gray out schedule slots outside office hours; override dialog for manual exceptions
- Override Office Hours toggle: select specific dates where all slots are open
- Fix appointment move: send only real DB fields to avoid Zod strict-mode rejection of computed fields (hasProcedures, hasClaimWithNumber)
- Fix backend PUT /appointments: safe error logging to prevent Prisma error crashing Node inspect
- Add OfficeHours Prisma model and GET/PUT /api/office-hours route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-05 09:15:18 -04:00
parent 70ffd8398b
commit 2312ad66ca
465 changed files with 11834 additions and 1461 deletions

View File

@@ -381,34 +381,37 @@ router.put(
const newYMD = new Date(date).toISOString().slice(0, 10);
const isDateChanged = oldYMD !== newYMD;
const updatePayload = {
...appointmentData,
...(isDateChanged ? { eligibilityStatus: "UNKNOWN" as const } : {}),
};
// Only pass the fields that are safe to update; never overwrite patientId/userId via this route
const updatePayload: Record<string, unknown> = {};
if (appointmentData.staffId !== undefined) updatePayload.staffId = appointmentData.staffId;
if (appointmentData.title !== undefined) updatePayload.title = appointmentData.title;
if (appointmentData.date !== undefined) updatePayload.date = appointmentData.date;
if (appointmentData.startTime !== undefined) updatePayload.startTime = appointmentData.startTime;
if (appointmentData.endTime !== undefined) updatePayload.endTime = appointmentData.endTime;
if (appointmentData.type !== undefined) updatePayload.type = appointmentData.type;
if (appointmentData.status !== undefined) updatePayload.status = appointmentData.status;
if (appointmentData.notes !== undefined) updatePayload.notes = appointmentData.notes;
if (isDateChanged) updatePayload.eligibilityStatus = "UNKNOWN";
// Update appointment
const updatedAppointment = await storage.updateAppointment(
appointmentId,
updatePayload
updatePayload as any
);
return res.json(updatedAppointment);
} catch (error) {
console.error("Error updating appointment:", error);
// Prisma error objects crash Node's util.inspect — always log as string
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
console.error("Error updating appointment:", msg);
if (error instanceof z.ZodError) {
console.log(
"Validation error details:",
JSON.stringify(error.format(), null, 2)
);
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
const fieldErrors = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(" | ");
console.error("Zod validation in PUT /appointments:", fieldErrors);
return res.status(400).json({ message: fieldErrors });
}
res.status(500).json({
message: "Failed to update appointment",
error: error instanceof Error ? error.message : String(error),
return res.status(500).json({
message: msg,
});
}
}

View File

@@ -26,6 +26,7 @@ import exportPaymentsReportsRoutes from "./export-payments-reports";
import jobMonitorRoutes from "./job-monitor";
import twilioRoutes from "./twilio";
import aiSettingsRoutes from "./ai-settings";
import officeHoursRoutes from "./office-hours";
const router = Router();
@@ -56,5 +57,6 @@ router.use("/export-payments-reports", exportPaymentsReportsRoutes);
router.use("/job-monitor", jobMonitorRoutes);
router.use("/twilio", twilioRoutes);
router.use("/ai", aiSettingsRoutes);
router.use("/office-hours", officeHoursRoutes);
export default router;

View File

@@ -0,0 +1,37 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
const router = express.Router();
// GET /api/office-hours
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const record = await storage.getOfficeHours(userId);
return res.status(200).json(record ? record.data : null);
} catch (err) {
return res.status(500).json({ error: "Failed to fetch office hours", details: String(err) });
}
});
// PUT /api/office-hours
router.put("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const data = req.body;
if (!data || typeof data !== "object") {
return res.status(400).json({ message: "Invalid office hours data" });
}
const record = await storage.upsertOfficeHours(userId, data);
return res.status(200).json(record.data);
} catch (err) {
return res.status(500).json({ error: "Failed to save office hours", details: String(err) });
}
});
export default router;

View File

@@ -114,14 +114,10 @@ export const appointmentsStorage: IStorage = {
id: number,
updateData: UpdateAppointment
): Promise<Appointment> {
try {
return await db.appointment.update({
where: { id },
data: updateData,
});
} catch (err) {
throw new Error(`Appointment with ID ${id} not found`);
}
return db.appointment.update({
where: { id },
data: updateData,
});
},
async deleteAppointment(id: number): Promise<void> {

View File

@@ -19,6 +19,7 @@ import * as exportPaymentsReportsStorage from "./export-payments-reports-storage
import { cronJobLogStorage } from "./cron-job-log-storage";
import { twilioStorage } from "./twilio-storage";
import { aiSettingsStorage } from "./ai-settings-storage";
import { officeHoursStorage } from "./office-hours-storage";
export const storage = {
@@ -41,6 +42,7 @@ export const storage = {
...cronJobLogStorage,
...twilioStorage,
...aiSettingsStorage,
...officeHoursStorage,
};

View File

@@ -0,0 +1,15 @@
import { prisma as db } from "@repo/db/client";
export const officeHoursStorage = {
async getOfficeHours(userId: number) {
return db.officeHours.findUnique({ where: { userId } });
},
async upsertOfficeHours(userId: number, data: object) {
return db.officeHours.upsert({
where: { userId },
update: { data },
create: { userId, data },
});
},
};