diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf86865..81b71fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,6 @@ on: pull_request: branches: [main, dev] workflow_dispatch: - inputs: - ref: - description: "Branch or ref to run CI against" - required: false - default: "main" jobs: lint-typecheck: @@ -86,14 +81,8 @@ jobs: - name: Run E2E tests run: pnpm --filter @groombook/e2e test - - - name: Upload Playwright report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: apps/e2e/playwright-report/ - retention-days: 7 + env: + PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080 - name: Stop Docker Compose stack if: always() @@ -129,9 +118,6 @@ jobs: needs: [build, e2e] outputs: tag: ${{ steps.version.outputs.tag }} - permissions: - contents: read - packages: write steps: - uses: actions/checkout@v4 @@ -152,12 +138,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Log in to Gitea Container Registry uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: git.farh.net + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push API image uses: docker/build-push-action@v6 @@ -167,10 +153,10 @@ jobs: target: runner push: true tags: | - ghcr.io/groombook/api:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }} - cache-from: type=gha - cache-to: type=gha,mode=max + git.farh.net/groombook/api:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:api + cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max - name: Build and push Migrate image uses: docker/build-push-action@v6 @@ -180,10 +166,10 @@ jobs: target: migrate push: true tags: | - ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }} - cache-from: type=gha - cache-to: type=gha,mode=max + git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate + cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max - name: Build and push Seed image uses: docker/build-push-action@v6 @@ -193,10 +179,10 @@ jobs: target: seed push: true tags: | - ghcr.io/groombook/seed:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }} - cache-from: type=gha - cache-to: type=gha,mode=max + git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:seed + cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max - name: Build and push Reset image uses: docker/build-push-action@v6 @@ -206,10 +192,10 @@ jobs: target: reset push: true tags: | - ghcr.io/groombook/reset:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }} - cache-from: type=gha - cache-to: type=gha,mode=max + git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:reset + cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max - name: Build and push Web image uses: docker/build-push-action@v6 @@ -218,19 +204,16 @@ jobs: file: apps/web/Dockerfile push: true tags: | - ghcr.io/groombook/web:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }} - cache-from: type=gha - cache-to: type=gha,mode=max + git.farh.net/groombook/web:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:web + cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max deploy-dev: name: Deploy PR to groombook-dev runs-on: runners-groombook needs: [docker] if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: write steps: - name: Install kubectl run: | @@ -247,7 +230,6 @@ jobs: TAG="pr-$PR_NUM-${SHA::7}" echo "Deploying images tagged $TAG to groombook-dev..." - # Run migration with PR image kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found cat <2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly | | TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot
2. View in calendar | Appointments are grouped/linked appropriately | | TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking | +| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard
2. Select a pet with sizeCategory and coatType set
3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values | +| TC-APP-4.5.8 | Large/X-Large pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "x-large" to an appointment
2. Note the service duration
3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category | +| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)
2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min
3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown | +| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9
2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times | +| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9
2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time | +| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)
2. Extend a prior appointment so D would shift to the next day
3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day | +| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`
2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment | ### 4.6 Services diff --git a/apps/api/src/lib/cascade.ts b/apps/api/src/lib/cascade.ts new file mode 100644 index 0000000..ea9f66e --- /dev/null +++ b/apps/api/src/lib/cascade.ts @@ -0,0 +1,281 @@ +import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db"; +import { appointments, clients, pets, services, staff, type Db } from "@groombook/db"; +import { resolveBufferMinutes } from "./buffer.js"; +import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js"; + +export interface CascadeResult { + shifted: ShiftedAppointment[]; + flaggedForReview: FlaggedAppointment[]; +} + +export interface ShiftedAppointment { + id: string; + oldStartTime: Date; + oldEndTime: Date; + newStartTime: Date; + newEndTime: Date; + shiftDeltaMs: number; +} + +export interface FlaggedAppointment { + id: string; + reason: string; + requestedStartTime: Date; + requestedEndTime: Date; +} + +interface AppointmentWithGroomer { + id: string; + clientId: string; + petId: string; + serviceId: string; + staffId: string | null; + batherStaffId: string | null; + status: string; + startTime: Date; + endTime: Date; + bufferMinutes: number; +} + +/** + * Detects and cascades appointment overruns to downstream same-groomer appointments. + * + * Trigger conditions: + * - PATCH extends endTime beyond the original endTime + * - Status transitions where current time exceeds endTime + bufferMinutes + * + * Guard rails: + * - Only shifts `scheduled` and `confirmed` appointments + * - Skips `in_progress`, `completed`, `cancelled`, `no_show` + * - Flags appointments that would fall outside business hours for manual review + */ +export async function detectAndCascadeOverrun({ + db, + overrunningAppointmentId, + newEndTime, + originalEndTime, +}: { + db: Db; + overrunningAppointmentId: string; + newEndTime: Date; + originalEndTime: Date; +}): Promise { + const result: CascadeResult = { shifted: [], flaggedForReview: [] }; + + // Fetch the overrunning appointment to get groomer/staff info + const [overrunning] = await db + .select() + .from(appointments) + .where(eq(appointments.id, overrunningAppointmentId)) + .limit(1); + + if (!overrunning) return result; + + const groomerId = overrunning.staffId; + if (!groomerId) return result; + + // Determine the effective buffer for the overrunning appointment + const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning); + const overrunEnd = newEndTime; + const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000); + + // Query same-groomer appointments that start AFTER the overrunning appointment ends + // and are ordered by startTime ASC (nearest first) + const downstreamAppointments = await db + .select() + .from(appointments) + .where( + and( + eq(appointments.staffId, groomerId), + gt(appointments.startTime, overrunning.endTime), + or( + eq(appointments.status, "scheduled"), + eq(appointments.status, "confirmed") + ) + ) + ) + .orderBy(asc(appointments.startTime)); + + // Track which appointments have been processed to avoid double-processing in cascade + const processedIds = new Set(); + processedIds.add(overrunningAppointmentId); + + let currentOverrunEnd = effectiveEnd; + + for (const downstream of downstreamAppointments) { + if (processedIds.has(downstream.id)) continue; + + const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream); + + // Check if this downstream appointment conflicts with the current overrun end + const conflictThreshold = new Date( + currentOverrunEnd.getTime() + downstreamBuffer * 60_000 + ); + + if (conflictThreshold <= downstream.startTime) { + // No conflict — cascade is complete + break; + } + + // Conflict detected — need to shift this appointment + const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime(); + const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs); + const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs); + + // Check business hours (simple: only shift within same calendar day window for now) + // A more sophisticated implementation would check actual business hours from businessSettings + const isSameDay = + newStartTime.toDateString() === downstream.startTime.toDateString(); + + if (!isSameDay) { + result.flaggedForReview.push({ + id: downstream.id, + reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`, + requestedStartTime: newStartTime, + requestedEndTime: newEndTime, + }); + // Continue cascade check — we still process downstream appointments + currentOverrunEnd = newEndTime; + processedIds.add(downstream.id); + continue; + } + + // Apply the shift + await db + .update(appointments) + .set({ + startTime: newStartTime, + endTime: newEndTime, + updatedAt: new Date(), + }) + .where(eq(appointments.id, downstream.id)); + + result.shifted.push({ + id: downstream.id, + oldStartTime: downstream.startTime, + oldEndTime: downstream.endTime, + newStartTime, + newEndTime, + shiftDeltaMs, + }); + + // Update current overrun end for next iteration + currentOverrunEnd = newEndTime; + processedIds.add(downstream.id); + } + + // Send notifications for all shifted appointments + for (const shifted of result.shifted) { + await notifyShiftedAppointment(db, shifted); + } + + return result; +} + +/** + * Determines if an appointment update represents an overrun that triggers cascade logic. + */ +export function isOverrun({ + originalEndTime, + newEndTime, + originalStartTime, + newStartTime, + status, + currentTime, + bufferMinutes, +}: { + originalEndTime: Date; + newEndTime: Date; + originalStartTime: Date; + newStartTime?: Date; + status: string; + currentTime: Date; + bufferMinutes: number; +}): boolean { + // Case 1: endTime extended beyond original + if (newEndTime > originalEndTime) { + return true; + } + + // Case 2: status transition where current time exceeds endTime + bufferMinutes + // This handles cases where an appointment ran long but wasn't explicitly rescheduled + if ( + (status === "in_progress" || status === "completed") && + currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000) + ) { + return true; + } + + return false; +} + +async function resolveBufferMinutesForAppointment( + db: Db, + appt: AppointmentWithGroomer +): Promise { + // First check if the appointment has an explicit bufferMinutes override + if (appt.bufferMinutes > 0) { + return appt.bufferMinutes; + } + + // Fall back to buffer time rules based on service + pet characteristics + const [pet] = await db + .select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType }) + .from(pets) + .where(eq(pets.id, appt.petId)) + .limit(1); + + if (!pet) return 0; + + return resolveBufferMinutes({ + serviceId: appt.serviceId, + sizeCategory: pet.sizeCategory, + coatType: pet.coatType, + db, + }); +} + +async function notifyShiftedAppointment( + db: Db, + shifted: ShiftedAppointment +): Promise { + const [row] = await db + .select({ + clientName: clients.name, + clientEmail: clients.email, + clientEmailOptOut: clients.emailOptOut, + petName: pets.name, + serviceName: services.name, + groomerName: staff.name, + appointmentStartTime: appointments.startTime, + }) + .from(appointments) + .innerJoin(clients, eq(clients.id, appointments.clientId)) + .innerJoin(pets, eq(pets.id, appointments.petId)) + .innerJoin(services, eq(services.id, appointments.serviceId)) + .leftJoin(staff, eq(staff.id, appointments.staffId)) + .where(eq(appointments.id, shifted.id)) + .limit(1); + + if (!row) return; + const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row; + + if (!clientEmail || clientEmailOptOut) return; + if (!petName || !serviceName) return; + + console.log( + `[cascade] Notifying shift for appointment ${shifted.id}: ` + + `${shifted.oldStartTime.toISOString()} → ${shifted.newStartTime.toISOString()}` + ); + + await sendEmail( + buildRescheduleNotificationEmail(clientEmail, { + clientName, + petName, + serviceName, + groomerName: groomerName ?? null, + oldStartTime: shifted.oldStartTime, + newStartTime: shifted.newStartTime, + }) + ); +} \ No newline at end of file diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 62e65c2..af171d2 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -21,6 +21,10 @@ import { } from "@groombook/db"; import { buildConfirmationEmail, sendEmail } from "../services/email.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; +import { + detectAndCascadeOverrun, + isOverrun, +} from "../lib/cascade.js"; import type { AppEnv } from "../middleware/rbac.js"; async function withRetry( @@ -584,6 +588,7 @@ appointmentsRouter.patch( // (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; + let originalEndTime: Date | undefined; try { row = await db.transaction(async (tx) => { const [current] = await tx @@ -595,6 +600,9 @@ appointmentsRouter.patch( throw Object.assign(new Error("not found"), { statusCode: 404 }); } + // Preserve original endTime for cascade detection after update + originalEndTime = current.endTime; + const start = updateFields.startTime ? new Date(updateFields.startTime) : current.startTime; @@ -684,6 +692,29 @@ appointmentsRouter.patch( } if (!row) return c.json({ error: "Not found" }, 404); + + // Cascade delay prevention: detect overrun and shift downstream appointments + if ( + originalEndTime && + updateFields.endTime && + isOverrun({ + originalEndTime, + newEndTime: new Date(updateFields.endTime), + originalStartTime: row.startTime, + status: row.status, + currentTime: new Date(), + bufferMinutes: row.bufferMinutes ?? 0, + }) + ) { + const cascadeResult = await detectAndCascadeOverrun({ + db, + overrunningAppointmentId: id, + newEndTime: new Date(updateFields.endTime), + originalEndTime, + }); + return c.json({ ...row, cascade: cascadeResult }); + } + return c.json(row); } diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index 69f61e5..e020754 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -38,11 +38,13 @@ bookRouter.get("/services", async (c) => { // ─── GET /api/book/availability ───────────────────────────────────────────── // Public: return ISO startTime strings for slots where ≥1 groomer is free -// Query params: serviceId (uuid), date (YYYY-MM-DD) +// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType bookRouter.get("/availability", async (c) => { const serviceId = c.req.query("serviceId"); const dateStr = c.req.query("date"); + const petSizeCategory = c.req.query("petSizeCategory") ?? undefined; + const petCoatType = c.req.query("petCoatType") ?? undefined; if (!serviceId || !dateStr) { return c.json({ error: "serviceId and date are required" }, 400); @@ -58,6 +60,12 @@ bookRouter.get("/availability", async (c) => { .where(and(eq(services.id, serviceId), eq(services.active, true))); if (!service) return c.json({ error: "Service not found" }, 404); + // Buffer-aware duration: extra time for large/x-large or complex coats + const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large") + ? (service.defaultBufferMinutes ?? 0) + : 0; + const durationMinutes = service.durationMinutes + extraBuffer; + const groomers = await db .select({ id: staff.id }) .from(staff) @@ -89,7 +97,7 @@ bookRouter.get("/availability", async (c) => { const slots = generateAvailableSlots({ dateStr, - durationMinutes: service.durationMinutes, + durationMinutes, groomerIds: groomers.map((g) => g.id), booked, }); @@ -112,6 +120,12 @@ const bookingSchema = z.object({ petName: z.string().min(1).max(200), petSpecies: z.string().min(1).max(100), petBreed: z.string().max(100).optional(), + petSizeCategory: z + .enum(["small", "medium", "large", "x-large"]) + .optional(), + petCoatType: z + .enum(["smooth", "double", "curly", "wire", "long", "hairless"]) + .optional(), notes: z.string().max(2000).optional(), }); @@ -129,7 +143,7 @@ bookRouter.post( .where(and(eq(services.id, body.serviceId), eq(services.active, true))); if (!service) return c.json({ error: "Service not found" }, 404); - const end = new Date(start.getTime() + service.durationMinutes * 60_000); + let end = new Date(start.getTime() + service.durationMinutes * 60_000); // Find all active groomers const groomers = await db @@ -191,11 +205,18 @@ bookRouter.post( name: body.petName, species: body.petSpecies, breed: body.petBreed ?? null, + sizeCategory: body.petSizeCategory ?? null, + coatType: body.petCoatType ?? null, }) .returning(); const pet = petInserted[0]; if (!pet) return c.json({ error: "Failed to create pet" }, 500); + // Buffer-aware end time: large/x-large pets add service bufferMinutes + if (body.petSizeCategory === "large" || body.petSizeCategory === "x-large") { + end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000); + } + // Insert appointment in a transaction to guard against race conditions let appointment; try { diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index 4cd4be9..8200f6d 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -201,3 +201,52 @@ export function buildWaitlistNotificationEmail(

— Groom Book

`, }; } + +// ─── Reschedule notification email ──────────────────────────────────────────── + +interface RescheduleEmailData { + clientName: string; + petName: string; + serviceName: string; + groomerName: string | null; + oldStartTime: Date; + newStartTime: Date; +} + +export function buildRescheduleNotificationEmail( + to: string, + data: RescheduleEmailData +): Mail.Options { + const oldTime = formatDateTime(data.oldStartTime); + const newTime = formatDateTime(data.newStartTime); + const groomer = data.groomerName ? ` with ${data.groomerName}` : ""; + return { + to, + subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`, + text: [ + `Hi ${data.clientName},`, + ``, + `Your appointment has been rescheduled.`, + ``, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` Was: ${oldTime}${groomer}`, + ` Now: ${newTime}${groomer}`, + ``, + `If you have any questions or need to make changes, please contact us.`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Your appointment has been rescheduled.

+ + + + + +
Pet${data.petName}
Service${data.serviceName}
Was${oldTime}${groomer}
Now${newTime}${groomer}
+

If you have any questions or need to make changes, please contact us.

+

— Groom Book

`, + }; +} diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx index dc58c9b..119d2de 100644 --- a/apps/web/src/pages/Book.tsx +++ b/apps/web/src/pages/Book.tsx @@ -13,6 +13,8 @@ interface BookingBody { petName: string; petSpecies: string; petBreed: string; + petSizeCategory: string; + petCoatType: string; notes: string; } @@ -123,6 +125,8 @@ export function BookPage() { petName: "", petSpecies: "", petBreed: "", + petSizeCategory: "", + petCoatType: "", notes: "", }); const [formError, setFormError] = useState(null); @@ -168,14 +172,18 @@ export function BookPage() { if (!selectedService || !date) return; setSlotsLoading(true); setSelectedSlot(null); - fetch( - `/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}` - ) + const params = new URLSearchParams({ + serviceId: selectedService.id, + date, + }); + if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory); + if (form.petCoatType) params.set("petCoatType", form.petCoatType); + fetch(`/api/book/availability?${params}`) .then((r) => r.json() as Promise) .then(setSlots) .catch(() => setSlots([])) .finally(() => setSlotsLoading(false)); - }, [selectedService, date]); + }, [selectedService, date, form.petSizeCategory, form.petCoatType]); function goToStep2(svc: Service) { setSelectedService(svc); @@ -214,6 +222,8 @@ export function BookPage() { petName: form.petName, petSpecies: form.petSpecies, petBreed: form.petBreed || undefined, + petSizeCategory: form.petSizeCategory || undefined, + petCoatType: form.petCoatType || undefined, notes: form.notes || undefined, }), }); @@ -494,6 +504,36 @@ export function BookPage() { placeholder="Golden Retriever" /> +
+ + +
+
+ + +