fix(calendar): address CTO review - N+1, Content-Disposition, SEQUENCE, sensitive leak

- Replace N+1 queries with single INNER JOIN across clients, pets, services
- Change Content-Disposition from attachment to inline for calendar auto-sync
- Add SEQUENCE:0 for confirmed, SEQUENCE:1 for cancelled events (RFC 5546)
- Fix sensitive field leak: return only {id, customerNotes, updatedAt}
- Add missing null-check guard after .returning() in portal.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Flea Flicker
2026-03-26 04:07:35 +00:00
parent f147dc3f89
commit 8ca120d521
2 changed files with 30 additions and 31 deletions
+19 -29
View File
@@ -46,6 +46,7 @@ function buildIcalFeed(
for (const appt of appointments) {
const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED";
const sequence = appt.status === "cancelled" ? "1" : "0";
const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`;
const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`;
@@ -58,6 +59,7 @@ function buildIcalFeed(
`SUMMARY:${escapeIcalText(summary)}`,
`DESCRIPTION:${escapeIcalText(description)}`,
`STATUS:${status}`,
`SEQUENCE:${sequence}`,
"END:VEVENT"
);
}
@@ -87,8 +89,22 @@ calendarRouter.get("/:staffId.ics", async (c) => {
const now = new Date();
const rows = await db
.select()
.select({
id: appointments.id,
startTime: appointments.startTime,
endTime: appointments.endTime,
status: appointments.status,
clientId: appointments.clientId,
petId: appointments.petId,
serviceId: appointments.serviceId,
clientName: clients.name,
petName: pets.name,
serviceName: services.name,
})
.from(appointments)
.innerJoin(clients, eq(appointments.clientId, clients.id))
.innerJoin(pets, eq(appointments.petId, pets.id))
.innerJoin(services, eq(appointments.serviceId, services.id))
.where(
and(
eq(appointments.staffId, staffId),
@@ -97,36 +113,10 @@ calendarRouter.get("/:staffId.ics", async (c) => {
)
.orderBy(appointments.startTime);
const enriched = await Promise.all(
rows.map(async (appt) => {
const [client] = await db
.select({ name: clients.name })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
const [pet] = await db
.select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
return {
...appt,
clientName: client?.name ?? null,
petName: pet?.name ?? null,
serviceName: service?.name ?? null,
};
})
);
const ical = buildIcalFeed(enriched, staffMember.name);
const ical = buildIcalFeed(rows, staffMember.name);
return c.text(ical, 200, {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="${staffMember.name.replace(/\s+/g, "_")}_calendar.ics"`,
"Content-Disposition": `inline; filename="${staffMember.name.replace(/\s+/g, "_")}_calendar.ics"`,
});
});
+11 -2
View File
@@ -7,7 +7,8 @@ import type { AppEnv } from "../middleware/rbac.js";
export const portalRouter = new Hono<AppEnv>();
const customerNotesSchema = z.object({
customerNotes: z.string().max(500),
// .min(1) prevents empty strings — clearing notes is not a supported use case
customerNotes: z.string().min(1).max(500),
});
portalRouter.patch(
@@ -64,6 +65,14 @@ portalRouter.patch(
.where(eq(appointments.id, id))
.returning();
return c.json(updated);
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
customerNotes: updated.customerNotes,
updatedAt: updated.updatedAt,
});
}
);