fix(portal): implement reschedule button and modal for Customer Portal
- Add POST /api/portal/appointments/:id/reschedule endpoint with: - Session auth via X-Impersonation-Session-Id header - Ownership validation (clientId match) - Past/in-progress/cancelled/completed guard - Conflict detection for the target time slot - Duration-preserving reschedule (keeps original endTime offset) - Add RescheduleFlow modal component in AppointmentsSection: - Date picker + time slot grid (same times as BookingFlow) - Shows current appointment summary - POSTs to /api/portal/appointments/:id/reschedule - Reloads page on success - Wire Reschedule button in AppointmentCard (Appointments section) - Wire Reschedule button in Dashboard next-appointment card - Add showReschedule/rescheduleAppointment state in CustomerPortal Fixes GRO-166 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -212,6 +212,105 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Appointment reschedule ──────────────────────────────────────────────────
|
||||
|
||||
const rescheduleSchema = z.object({
|
||||
startTime: z.string().datetime(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/appointments/:id/reschedule",
|
||||
zValidator("json", rescheduleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
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.clientId !== session.clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422);
|
||||
}
|
||||
|
||||
const newStart = new Date(body.startTime);
|
||||
const durationMs = appt.endTime.getTime() - appt.startTime.getTime();
|
||||
const newEnd = new Date(newStart.getTime() + durationMs);
|
||||
|
||||
const [existingConflict] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, appt.staffId),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gt(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingConflict) {
|
||||
return c.json({ error: "The selected time slot is no longer available" }, 409);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
startTime: updated.startTime,
|
||||
endTime: updated.endTime,
|
||||
status: updated.status,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Client-facing waitlist routes ───────────────────────────────────────────
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user