855 lines
28 KiB
TypeScript
855 lines
28 KiB
TypeScript
import { Hono } from "hono";
|
|
import { zValidator } from "@hono/zod-validator";
|
|
import { z } from "zod/v3";
|
|
import { randomBytes } from "node:crypto";
|
|
import {
|
|
and,
|
|
eq,
|
|
getDb,
|
|
gte,
|
|
lt,
|
|
lte,
|
|
ne,
|
|
or,
|
|
appointments,
|
|
clients,
|
|
pets,
|
|
recurringSeries,
|
|
reminderLogs,
|
|
services,
|
|
staff,
|
|
} from "@groombook/db";
|
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
|
import type { AppEnv } from "../middleware/rbac.js";
|
|
|
|
async function withRetry<T>(
|
|
fn: () => Promise<T>,
|
|
maxRetries: number,
|
|
delayMs: number,
|
|
context: string
|
|
): Promise<void> {
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
await fn();
|
|
return;
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (attempt < maxRetries) {
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
}
|
|
console.error(`[appointments] ${context}: ${lastError}`);
|
|
}
|
|
|
|
export const appointmentsRouter = new Hono<AppEnv>();
|
|
|
|
const createAppointmentSchema = z.object({
|
|
clientId: z.string().uuid(),
|
|
petId: z.string().uuid(),
|
|
serviceId: z.string().uuid(),
|
|
staffId: z.string().uuid().optional(),
|
|
batherStaffId: z.string().uuid().optional(),
|
|
startTime: z.string().datetime(),
|
|
endTime: z.string().datetime(),
|
|
notes: z.string().max(2000).optional(),
|
|
priceCents: z.number().int().positive().optional(),
|
|
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
|
recurrence: z
|
|
.object({
|
|
frequencyWeeks: z.number().int().min(1).max(52),
|
|
count: z.number().int().min(2).max(52),
|
|
})
|
|
.refine(
|
|
(r) => r.frequencyWeeks * r.count <= 52,
|
|
{ message: "Recurrence series must not exceed 1 year" }
|
|
)
|
|
.optional(),
|
|
});
|
|
|
|
const updateAppointmentSchema = z.object({
|
|
staffId: z.string().uuid().nullable().optional(),
|
|
batherStaffId: z.string().uuid().nullable().optional(),
|
|
status: z
|
|
.enum([
|
|
"scheduled",
|
|
"confirmed",
|
|
"in_progress",
|
|
"completed",
|
|
"cancelled",
|
|
"no_show",
|
|
])
|
|
.optional(),
|
|
startTime: z.string().datetime().optional(),
|
|
endTime: z.string().datetime().optional(),
|
|
notes: z.string().max(2000).nullable().optional(),
|
|
priceCents: z.number().int().positive().nullable().optional(),
|
|
// When updating a series member, optionally propagate the change
|
|
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
|
|
});
|
|
|
|
// List appointments, optionally filtered by date range or staffId.
|
|
// Groomers see only their own appointments (staffId or batherStaffId).
|
|
appointmentsRouter.get("/", async (c) => {
|
|
const db = getDb();
|
|
const from = c.req.query("from");
|
|
const to = c.req.query("to");
|
|
const staffId = c.req.query("staffId");
|
|
const staffRow = c.get("staff");
|
|
const isGroomer = staffRow?.role === "groomer";
|
|
|
|
const conditions = [];
|
|
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
|
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
|
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
|
|
|
// Groomer: restrict to their own appointments (as groomer or bather)
|
|
if (isGroomer) {
|
|
conditions.push(
|
|
or(
|
|
eq(appointments.staffId, staffRow.id),
|
|
eq(appointments.batherStaffId, staffRow.id)
|
|
)
|
|
);
|
|
}
|
|
|
|
const rows =
|
|
conditions.length > 0
|
|
? await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(and(...conditions))
|
|
.orderBy(appointments.startTime)
|
|
: await db
|
|
.select()
|
|
.from(appointments)
|
|
.orderBy(appointments.startTime);
|
|
|
|
return c.json(rows);
|
|
});
|
|
|
|
appointmentsRouter.get("/:id", async (c) => {
|
|
const db = getDb();
|
|
const staffRow = c.get("staff");
|
|
const isGroomer = staffRow?.role === "groomer";
|
|
const [row] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, c.req.param("id")));
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
// Groomer: 403 if not assigned as groomer or bather
|
|
if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
return c.json(row);
|
|
});
|
|
|
|
appointmentsRouter.post(
|
|
"/",
|
|
zValidator("json", createAppointmentSchema),
|
|
async (c) => {
|
|
const db = getDb();
|
|
const body = c.req.valid("json");
|
|
const start = new Date(body.startTime);
|
|
const end = new Date(body.endTime);
|
|
|
|
if (end <= start) {
|
|
return c.json({ error: "endTime must be after startTime" }, 422);
|
|
}
|
|
|
|
const { recurrence, ...apptFields } = body;
|
|
|
|
// Wrap conflict check + insert in a transaction to prevent double-booking
|
|
// race conditions under concurrent load (fixes #18).
|
|
let firstRow: typeof appointments.$inferSelect;
|
|
try {
|
|
firstRow = await db.transaction(async (tx) => {
|
|
// Conflict check applies to the first occurrence only; subsequent
|
|
// occurrences are spread weeks apart so conflicts are unlikely and can
|
|
// be resolved individually if needed.
|
|
if (apptFields.staffId) {
|
|
const conflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
eq(appointments.staffId, apptFields.staffId),
|
|
lt(appointments.startTime, end),
|
|
gte(appointments.endTime, start),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (conflicts.length > 0) {
|
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
}
|
|
}
|
|
|
|
if (apptFields.batherStaffId) {
|
|
const bathConflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
or(
|
|
eq(appointments.staffId, apptFields.batherStaffId),
|
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
|
),
|
|
lt(appointments.startTime, end),
|
|
gte(appointments.endTime, start),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (bathConflicts.length > 0) {
|
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
}
|
|
}
|
|
|
|
if (!recurrence) {
|
|
// Single appointment
|
|
const [inserted] = await tx
|
|
.insert(appointments)
|
|
.values({ ...apptFields, startTime: start, endTime: end })
|
|
.returning();
|
|
if (!inserted) throw new Error("Insert failed");
|
|
return inserted;
|
|
}
|
|
|
|
// Create recurring series
|
|
const seriesRows = await tx
|
|
.insert(recurringSeries)
|
|
.values({ frequencyWeeks: recurrence.frequencyWeeks })
|
|
.returning();
|
|
const series = seriesRows[0];
|
|
if (!series) throw new Error("Failed to create recurring series");
|
|
|
|
const durationMs = end.getTime() - start.getTime();
|
|
const intervalMs =
|
|
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
|
|
|
let first: typeof appointments.$inferSelect | undefined;
|
|
const conflictingInstances: number[] = [];
|
|
for (let i = 0; i < recurrence.count; i++) {
|
|
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
|
const instanceEnd = new Date(
|
|
instanceStart.getTime() + durationMs
|
|
);
|
|
|
|
if (apptFields.staffId) {
|
|
const conflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
eq(appointments.staffId, apptFields.staffId),
|
|
lt(appointments.startTime, instanceEnd),
|
|
gte(appointments.endTime, instanceStart),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (conflicts.length > 0) {
|
|
conflictingInstances.push(i);
|
|
}
|
|
}
|
|
|
|
if (apptFields.batherStaffId) {
|
|
const conflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
or(
|
|
eq(appointments.staffId, apptFields.batherStaffId),
|
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
|
),
|
|
lt(appointments.startTime, instanceEnd),
|
|
gte(appointments.endTime, instanceStart),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (conflicts.length > 0) {
|
|
conflictingInstances.push(i);
|
|
}
|
|
}
|
|
|
|
const [inserted] = await tx
|
|
.insert(appointments)
|
|
.values({
|
|
...apptFields,
|
|
startTime: instanceStart,
|
|
endTime: instanceEnd,
|
|
seriesId: series.id,
|
|
seriesIndex: i,
|
|
})
|
|
.returning();
|
|
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
|
if (i === 0) first = inserted;
|
|
}
|
|
|
|
if (conflictingInstances.length > 0) {
|
|
throw Object.assign(
|
|
new Error(
|
|
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
|
),
|
|
{ statusCode: 409 }
|
|
);
|
|
}
|
|
|
|
if (!first) throw new Error("No appointments created");
|
|
return first;
|
|
});
|
|
} catch (err: unknown) {
|
|
if (
|
|
err instanceof Error &&
|
|
(err as Error & { statusCode?: number }).statusCode === 409
|
|
) {
|
|
return c.json(
|
|
{ error: "Staff member has a conflicting appointment at this time" },
|
|
409
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Send confirmation email (fire-and-forget — never fails the request)
|
|
withRetry(
|
|
() => sendConfirmationEmail(db, firstRow),
|
|
2,
|
|
1000,
|
|
`Failed to send confirmation email for appointment ${firstRow.id}`
|
|
);
|
|
|
|
return c.json(firstRow, 201);
|
|
}
|
|
);
|
|
|
|
// ─── Confirmation email helper ─────────────────────────────────────────────
|
|
|
|
async function sendConfirmationEmail(
|
|
db: ReturnType<typeof getDb>,
|
|
appt: typeof appointments.$inferSelect
|
|
): Promise<void> {
|
|
const [client] = await db
|
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
|
.from(clients)
|
|
.where(eq(clients.id, appt.clientId))
|
|
.limit(1);
|
|
|
|
if (!client || !client.email || client.emailOptOut) return;
|
|
|
|
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);
|
|
|
|
let groomerName: string | null = null;
|
|
if (appt.staffId) {
|
|
const [groomer] = await db
|
|
.select({ name: staff.name })
|
|
.from(staff)
|
|
.where(eq(staff.id, appt.staffId))
|
|
.limit(1);
|
|
groomerName = groomer?.name ?? null;
|
|
}
|
|
|
|
if (!pet || !service) return;
|
|
|
|
const sent = await sendEmail(
|
|
buildConfirmationEmail(client.email, {
|
|
clientName: client.name,
|
|
petName: pet.name,
|
|
serviceName: service.name,
|
|
groomerName,
|
|
startTime: appt.startTime,
|
|
})
|
|
);
|
|
|
|
if (sent) {
|
|
await db
|
|
.insert(reminderLogs)
|
|
.values({ appointmentId: appt.id, reminderType: "confirmation" })
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
|
|
appointmentsRouter.patch(
|
|
"/:id",
|
|
zValidator("json", updateAppointmentSchema),
|
|
async (c) => {
|
|
const db = getDb();
|
|
const id = c.req.param("id");
|
|
const body = c.req.valid("json");
|
|
const { cascadeMode = "this_only", ...updateFields } = body;
|
|
|
|
// ── Cascade update (this_and_future / all) ────────────────────────────────
|
|
if (cascadeMode !== "this_only") {
|
|
let row: typeof appointments.$inferSelect | undefined;
|
|
try {
|
|
row = await db.transaction(async (tx) => {
|
|
const [current] = await tx
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, id))
|
|
.limit(1);
|
|
if (!current) {
|
|
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
|
}
|
|
|
|
// Compute time deltas and apply them uniformly across the series so
|
|
// all instances shift by the same amount (e.g. rescheduled 1 hr later).
|
|
const startDeltaMs = updateFields.startTime
|
|
? new Date(updateFields.startTime).getTime() -
|
|
current.startTime.getTime()
|
|
: 0;
|
|
const endDeltaMs = updateFields.endTime
|
|
? new Date(updateFields.endTime).getTime() -
|
|
current.endTime.getTime()
|
|
: 0;
|
|
|
|
// Validate resulting times on the anchor appointment
|
|
const newStart = new Date(
|
|
current.startTime.getTime() + startDeltaMs
|
|
);
|
|
const newEnd = new Date(current.endTime.getTime() + endDeltaMs);
|
|
if (newEnd <= newStart) {
|
|
throw Object.assign(new Error("end before start"), {
|
|
statusCode: 422,
|
|
});
|
|
}
|
|
|
|
// Determine which appointments to update
|
|
let whereClause;
|
|
if (current.seriesId && current.seriesIndex !== null) {
|
|
whereClause =
|
|
cascadeMode === "this_and_future"
|
|
? and(
|
|
eq(appointments.seriesId, current.seriesId),
|
|
gte(appointments.seriesIndex, current.seriesIndex),
|
|
)
|
|
: eq(appointments.seriesId, current.seriesId);
|
|
} else {
|
|
// Not part of a series — fall back to single update
|
|
whereClause = eq(appointments.id, id);
|
|
}
|
|
|
|
const affected = await tx
|
|
.select()
|
|
.from(appointments)
|
|
.where(whereClause);
|
|
|
|
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
|
for (const appt of affected) {
|
|
const newStart =
|
|
startDeltaMs !== 0
|
|
? new Date(appt.startTime.getTime() + startDeltaMs)
|
|
: appt.startTime;
|
|
const newEnd =
|
|
endDeltaMs !== 0
|
|
? new Date(appt.endTime.getTime() + endDeltaMs)
|
|
: appt.endTime;
|
|
const newStaffId =
|
|
updateFields.staffId !== undefined
|
|
? updateFields.staffId
|
|
: appt.staffId;
|
|
const newBatherStaffId =
|
|
updateFields.batherStaffId !== undefined
|
|
? updateFields.batherStaffId
|
|
: appt.batherStaffId;
|
|
|
|
if (
|
|
newStaffId &&
|
|
(startDeltaMs !== 0 ||
|
|
endDeltaMs !== 0 ||
|
|
updateFields.staffId !== undefined)
|
|
) {
|
|
const conflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
eq(appointments.staffId, newStaffId),
|
|
lt(appointments.startTime, newEnd),
|
|
gte(appointments.endTime, newStart),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
ne(appointments.id, appt.id),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (conflicts.length > 0) {
|
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
}
|
|
}
|
|
|
|
if (
|
|
newBatherStaffId &&
|
|
(startDeltaMs !== 0 ||
|
|
endDeltaMs !== 0 ||
|
|
updateFields.batherStaffId !== undefined)
|
|
) {
|
|
const conflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
or(
|
|
eq(appointments.staffId, newBatherStaffId),
|
|
eq(appointments.batherStaffId, newBatherStaffId)
|
|
),
|
|
lt(appointments.startTime, newEnd),
|
|
gte(appointments.endTime, newStart),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
ne(appointments.id, appt.id),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (conflicts.length > 0) {
|
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
}
|
|
}
|
|
|
|
const apptUpdate: Record<string, unknown> = {
|
|
updatedAt: new Date(),
|
|
};
|
|
if (updateFields.staffId !== undefined)
|
|
apptUpdate.staffId = updateFields.staffId;
|
|
if (updateFields.notes !== undefined)
|
|
apptUpdate.notes = updateFields.notes;
|
|
if (updateFields.status !== undefined)
|
|
apptUpdate.status = updateFields.status;
|
|
if (updateFields.priceCents !== undefined)
|
|
apptUpdate.priceCents = updateFields.priceCents;
|
|
if (startDeltaMs !== 0)
|
|
apptUpdate.startTime = new Date(
|
|
appt.startTime.getTime() + startDeltaMs
|
|
);
|
|
if (endDeltaMs !== 0)
|
|
apptUpdate.endTime = new Date(
|
|
appt.endTime.getTime() + endDeltaMs
|
|
);
|
|
|
|
const [updated] = await tx
|
|
.update(appointments)
|
|
.set(apptUpdate)
|
|
.where(eq(appointments.id, appt.id))
|
|
.returning();
|
|
if (appt.id === id) firstUpdated = updated;
|
|
}
|
|
|
|
return firstUpdated;
|
|
});
|
|
} catch (err: unknown) {
|
|
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
|
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
|
if (statusCode === 422)
|
|
return c.json({ error: "endTime must be after startTime" }, 422);
|
|
if (statusCode === 409)
|
|
return c.json(
|
|
{
|
|
error: "Staff member has a conflicting appointment at this time",
|
|
},
|
|
409
|
|
);
|
|
throw err;
|
|
}
|
|
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
return c.json(row);
|
|
}
|
|
|
|
// ── this_only (original logic) ────────────────────────────────────────────
|
|
const needsConflictCheck =
|
|
updateFields.startTime !== undefined ||
|
|
updateFields.endTime !== undefined ||
|
|
updateFields.staffId !== undefined ||
|
|
updateFields.batherStaffId !== undefined;
|
|
|
|
const update: Record<string, unknown> = {
|
|
...updateFields,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (updateFields.startTime) update.startTime = new Date(updateFields.startTime);
|
|
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
|
|
|
if (needsConflictCheck) {
|
|
// Wrap conflict check + update in a transaction to prevent race conditions
|
|
// (fixes #18). Also falls back to the existing staffId when staffId is
|
|
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
|
let row: typeof appointments.$inferSelect | undefined;
|
|
try {
|
|
row = await db.transaction(async (tx) => {
|
|
const [current] = await tx
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, id))
|
|
.limit(1);
|
|
if (!current) {
|
|
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
|
}
|
|
|
|
const start = updateFields.startTime
|
|
? new Date(updateFields.startTime)
|
|
: current.startTime;
|
|
const end = updateFields.endTime
|
|
? new Date(updateFields.endTime)
|
|
: current.endTime;
|
|
// Use provided staffId (may be null to unassign); fall back to existing
|
|
const staffId =
|
|
updateFields.staffId !== undefined
|
|
? updateFields.staffId
|
|
: current.staffId;
|
|
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
|
const batherStaffId =
|
|
updateFields.batherStaffId !== undefined
|
|
? updateFields.batherStaffId
|
|
: current.batherStaffId;
|
|
|
|
if (end <= start) {
|
|
throw Object.assign(new Error("end before start"), {
|
|
statusCode: 422,
|
|
});
|
|
}
|
|
|
|
if (staffId) {
|
|
const conflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
eq(appointments.staffId, staffId),
|
|
lt(appointments.startTime, end),
|
|
gte(appointments.endTime, start),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
ne(appointments.id, id),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (conflicts.length > 0) {
|
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
}
|
|
}
|
|
|
|
if (batherStaffId) {
|
|
const bathConflicts = await tx
|
|
.select({ id: appointments.id })
|
|
.from(appointments)
|
|
.where(
|
|
and(
|
|
or(
|
|
eq(appointments.staffId, batherStaffId),
|
|
eq(appointments.batherStaffId, batherStaffId)
|
|
),
|
|
lt(appointments.startTime, end),
|
|
gte(appointments.endTime, start),
|
|
ne(appointments.status, "cancelled"),
|
|
ne(appointments.status, "no_show"),
|
|
ne(appointments.id, id),
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (bathConflicts.length > 0) {
|
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
}
|
|
}
|
|
|
|
const [updated] = await tx
|
|
.update(appointments)
|
|
.set(update)
|
|
.where(eq(appointments.id, id))
|
|
.returning();
|
|
return updated;
|
|
});
|
|
} catch (err: unknown) {
|
|
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
|
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
|
if (statusCode === 422)
|
|
return c.json({ error: "endTime must be after startTime" }, 422);
|
|
if (statusCode === 409)
|
|
return c.json(
|
|
{
|
|
error: "Staff member has a conflicting appointment at this time",
|
|
},
|
|
409
|
|
);
|
|
throw err;
|
|
}
|
|
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
return c.json(row);
|
|
}
|
|
|
|
const [row] = await db
|
|
.update(appointments)
|
|
.set(update)
|
|
.where(eq(appointments.id, id))
|
|
.returning();
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
return c.json(row);
|
|
}
|
|
);
|
|
|
|
// Soft-delete: cancel the appointment instead of removing the row,
|
|
// preserving audit trail and financial records (fixes #20).
|
|
// Optional ?cascade=this_only|this_and_future|all for series appointments.
|
|
appointmentsRouter.delete("/:id", async (c) => {
|
|
const db = getDb();
|
|
const id = c.req.param("id");
|
|
const cascade = c.req.query("cascade") ?? "this_only";
|
|
|
|
if (cascade === "this_and_future" || cascade === "all") {
|
|
const [current] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, id))
|
|
.limit(1);
|
|
if (!current) return c.json({ error: "Not found" }, 404);
|
|
|
|
if (current.seriesId && current.seriesIndex !== null) {
|
|
const whereClause =
|
|
cascade === "this_and_future"
|
|
? and(
|
|
eq(appointments.seriesId, current.seriesId),
|
|
gte(appointments.seriesIndex, current.seriesIndex),
|
|
)
|
|
: eq(appointments.seriesId, current.seriesId);
|
|
await db
|
|
.update(appointments)
|
|
.set({ status: "cancelled", updatedAt: new Date() })
|
|
.where(whereClause);
|
|
} else {
|
|
// Not in a series — cancel only this one
|
|
await db
|
|
.update(appointments)
|
|
.set({ status: "cancelled", updatedAt: new Date() })
|
|
.where(eq(appointments.id, id));
|
|
}
|
|
|
|
const apptDate = current.startTime.toISOString().slice(0, 10);
|
|
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
|
withRetry(
|
|
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
|
2,
|
|
1000,
|
|
`Failed to notify waitlist for appointment ${id}`
|
|
);
|
|
|
|
return c.json({ ok: true });
|
|
}
|
|
|
|
// Single cancel (default)
|
|
const [current] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, id))
|
|
.limit(1);
|
|
if (!current) return c.json({ error: "Not found" }, 404);
|
|
|
|
const apptDate = current.startTime.toISOString().slice(0, 10);
|
|
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
|
|
|
const [row] = await db
|
|
.update(appointments)
|
|
.set({ status: "cancelled", updatedAt: new Date() })
|
|
.where(eq(appointments.id, id))
|
|
.returning();
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
|
|
withRetry(
|
|
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
|
2,
|
|
1000,
|
|
`Failed to notify waitlist for appointment ${id}`
|
|
);
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── POST /api/appointments/:id/confirm ───────────────────────────────────────
|
|
// Staff/portal: confirm a specific appointment by ID. Idempotent.
|
|
|
|
appointmentsRouter.post("/:id/confirm", async (c) => {
|
|
const db = getDb();
|
|
const id = c.req.param("id");
|
|
|
|
const [appt] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, id))
|
|
.limit(1);
|
|
|
|
if (!appt) return c.json({ error: "Not found" }, 404);
|
|
|
|
if (appt.confirmationStatus === "cancelled") {
|
|
return c.json({ error: "Cannot confirm a cancelled appointment" }, 409);
|
|
}
|
|
|
|
if (appt.confirmationStatus === "confirmed") {
|
|
return c.json(appt); // idempotent
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(appointments)
|
|
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(appointments.id, id))
|
|
.returning();
|
|
|
|
return c.json(updated);
|
|
});
|
|
|
|
// ─── POST /api/appointments/:id/cancel ───────────────────────────────────────
|
|
// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified.
|
|
|
|
appointmentsRouter.post("/:id/cancel", async (c) => {
|
|
const db = getDb();
|
|
const id = c.req.param("id");
|
|
|
|
const [appt] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.id, id))
|
|
.limit(1);
|
|
|
|
if (!appt) return c.json({ error: "Not found" }, 404);
|
|
|
|
if (appt.confirmationStatus === "cancelled") {
|
|
return c.json({ error: "Appointment is already cancelled" }, 409);
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(appointments)
|
|
.set({
|
|
confirmationStatus: "cancelled",
|
|
cancelledAt: new Date(),
|
|
confirmationToken: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(appointments.id, id))
|
|
.returning();
|
|
|
|
return c.json(updated);
|
|
});
|
|
|
|
// ─── Token generation helper ──────────────────────────────────────────────────
|
|
|
|
export function generateConfirmationToken(): string {
|
|
return randomBytes(32).toString("hex");
|
|
}
|