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 @@ -228,6 +235,23 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR | TC-APP-4.20.5 | Unread indicator | 1. Client sends a new message | Thread marked unread until staff views it | | TC-APP-4.20.6 | Cross-tenant isolation | 1. Staff from Business A attempts to read Business B conversations | 403 or empty response returned | + +### 4.21 SMS Consent (STOP/HELP Keyword Handler) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-APP-4.21.1 | STOP → unsubscribe + auto-reply | 1. Send `STOP` (case-insensitive, with whitespace) from a subscribed client's phone number | Client is opted out (`smsOptIn=false`, `smsOptOutDate` set), event is logged, user receives auto-reply: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." | +| TC-APP-4.21.2 | START → resubscribe + auto-reply | 1. Send `START` (case-insensitive) from an opted-out client's phone number | Client is opted back in (`smsOptIn=true`, `smsConsentDate` updated, `smsOptOutDate` cleared), event is logged, user receives auto-reply: "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." | +| TC-APP-4.21.3 | HELP → no opt-in change + default reply | 1. Send `HELP` (case-insensitive) from any client's phone number | No change to opt-in state, no database update, event is logged, user receives auto-reply: "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." | +| TC-APP-4.21.4 | STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT → opt-out | 1. Send each alias from a subscribed client's phone | Same behaviour as STOP: opt-out applied, correct reply sent | +| TC-APP-4.21.5 | UNSTOP / YES / SUBSCRIBE → opt-in | 1. Send each alias from an opted-out client's phone | Same behaviour as START: opt-in applied, correct reply sent | +| TC-APP-4.21.6 | INFO → help reply | 1. Send `INFO` from any client's phone | Same behaviour as HELP: no state change, help reply returned | +| TC-APP-4.21.7 | Double STOP (idempotency) | 1. Send `STOP` from an already-opted-out client | Event is logged, no update call made, idempotent — no duplicate update | +| TC-APP-4.21.8 | Double START (idempotency) | 1. Send `START` from an already-subscribed client | Event is logged, no update call made, idempotent — no duplicate update | +| TC-APP-4.21.9 | Case insensitivity | 1. Send `stop`, `Stop`, `sToP`, ` stop ` from subscribed client | All variants are detected and handled as opt-out | +| TC-APP-4.21.10 | Whitespace trimming | 1. Send ` START ` or `\tSTOP\n` | Keywords are trimmed before matching | +| TC-APP-4.21.11 | Non-keyword messages ignored | 1. Send `STOP IT`, `help me`, `hello` | Returns null from `detectKeyword`, no consent event inserted, no reply sent | +| TC-APP-4.21.12 | Consent event audit log | 1. After any keyword, query `messageConsentEvents` table | Record exists with correct `clientId`, `businessId`, `kind`, and `source: "sms_keyword"` | ## 5. Pass/Fail Criteria **Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented. diff --git a/apps/api/src/lib/cascade.ts b/apps/api/src/lib/cascade.ts new file mode 100644 index 0000000..a4c8ae1 --- /dev/null +++ b/apps/api/src/lib/cascade.ts @@ -0,0 +1,281 @@ +import { eq, and, gt, 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..46a36c2 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: 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..bd6fa67 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -38,11 +38,12 @@ 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; if (!serviceId || !dateStr) { return c.json({ error: "serviceId and date are required" }, 400); @@ -58,6 +59,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 === "xlarge") + ? (service.defaultBufferMinutes ?? 0) + : 0; + const durationMinutes = service.durationMinutes + extraBuffer; + const groomers = await db .select({ id: staff.id }) .from(staff) @@ -89,7 +96,7 @@ bookRouter.get("/availability", async (c) => { const slots = generateAvailableSlots({ dateStr, - durationMinutes: service.durationMinutes, + durationMinutes, groomerIds: groomers.map((g) => g.id), booked, }); @@ -112,6 +119,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", "xlarge"]) + .optional(), + petCoatType: z + .enum(["smooth", "double", "curly", "wire", "long", "hairless"]) + .optional(), notes: z.string().max(2000).optional(), }); @@ -129,7 +142,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 +204,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 === "xlarge") { + 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/api/src/services/messaging/__tests__/consent.test.ts b/apps/api/src/services/messaging/__tests__/consent.test.ts new file mode 100644 index 0000000..d810c9e --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/consent.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { detectKeyword } from "../consent.js"; + +const mockDb = { + insert: vi.fn(), + update: vi.fn(), + select: vi.fn(), +}; + +vi.mock("@groombook/db", () => ({ + getDb: () => mockDb, + clients: {}, + messageConsentEvents: {}, + businessSettings: {}, + eq: vi.fn(), +})); + +const { handleConsentKeyword } = await import("../consent.js"); + +describe("detectKeyword", () => { + it.each([ + ["STOP", "opt_out"], + ["STOPALL", "opt_out"], + ["UNSUBSCRIBE", "opt_out"], + ["CANCEL", "opt_out"], + ["END", "opt_out"], + ["QUIT", "opt_out"], + ])("opt-out keyword %s → opt_out", (keyword, expected) => { + expect(detectKeyword(keyword)).toEqual({ kind: expected }); + }); + + it.each([ + ["START", "opt_in"], + ["UNSTOP", "opt_in"], + ["YES", "opt_in"], + ["SUBSCRIBE", "opt_in"], + ])("opt-in keyword %s → opt_in", (keyword, expected) => { + expect(detectKeyword(keyword)).toEqual({ kind: expected }); + }); + + it.each([ + ["HELP", "help"], + ["INFO", "help"], + ])("help keyword %s → help", (keyword, expected) => { + expect(detectKeyword(keyword)).toEqual({ kind: expected }); + }); + + it("is case insensitive", () => { + expect(detectKeyword("stop")).toEqual({ kind: "opt_out" }); + expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" }); + expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" }); + }); + + it("trims whitespace", () => { + expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" }); + expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" }); + }); + + it("returns null for non-keyword messages", () => { + expect(detectKeyword("hello")).toBeNull(); + expect(detectKeyword("STOP IT")).toBeNull(); + expect(detectKeyword("help me")).toBeNull(); + }); +}); + +describe("handleConsentKeyword", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockResolvedValue([{ id: "event-1" }]), + } as any); + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + } as any); + }); + + const baseOpts = { + clientId: "client-1", + businessId: "biz-1", + db: mockDb as unknown as ReturnType, + }; + + describe("opt_out", () => { + it("inserts consent event with sms_keyword source", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + + expect(mockDb.insert).toHaveBeenCalledOnce(); + }); + + it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("is idempotent — second opt-out logs event but skips client update", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("returns unsubscribe reply text", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" }); + expect(result.replyText).toBe( + "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." + ); + }); + }); + + describe("opt_in", () => { + it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("clears smsOptOutDate on opt-in after opt-out", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("is idempotent — second opt-in skips client update", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]), + }), + }), + } as any); + + await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("returns resubscribe reply text", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]), + }), + }), + } as any); + + const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" }); + expect(result.replyText).toBe( + "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." + ); + }); + }); + + describe("help", () => { + it("returns default help reply without querying businessSettings", async () => { + const result = await handleConsentKeyword({ ...baseOpts, kind: "help" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockDb.select).not.toHaveBeenCalled(); + expect(result.replyText).toBe( + "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." + ); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/services/messaging/consent.ts b/apps/api/src/services/messaging/consent.ts new file mode 100644 index 0000000..408486d --- /dev/null +++ b/apps/api/src/services/messaging/consent.ts @@ -0,0 +1,77 @@ +import { clients, messageConsentEvents, eq } from "@groombook/db"; +import type { Db } from "@groombook/db"; + +export type KeywordKind = "opt_in" | "opt_out" | "help"; + +const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]); +const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]); +const HELP_KEYWORDS = new Set(["HELP", "INFO"]); + +export function detectKeyword(body: string): { kind: KeywordKind } | null { + const normalized = body.trim().toUpperCase(); + if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" }; + if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" }; + if (HELP_KEYWORDS.has(normalized)) return { kind: "help" }; + return null; +} + +export async function handleConsentKeyword(opts: { + clientId: string; + businessId: string; + kind: KeywordKind; + db: Db; +}): Promise<{ replyText: string }> { + const { clientId, businessId, kind, db: database } = opts; + + await database.insert(messageConsentEvents).values({ + clientId, + businessId, + kind, + source: "sms_keyword", + }); + + if (kind === "opt_out") { + const [existing] = await database + .select({ smsOptIn: clients.smsOptIn }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (existing?.smsOptIn !== false) { + await database + .update(clients) + .set({ smsOptIn: false, smsOptOutDate: new Date() }) + .where(eq(clients.id, clientId)); + } + + return { + replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.", + }; + } + + if (kind === "opt_in") { + const [existing] = await database + .select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (existing?.smsOptIn !== true) { + await database + .update(clients) + .set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null }) + .where(eq(clients.id, clientId)); + } + + return { + replyText: + "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.", + }; + } + + // kind === "help" + const replyText = + "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."; + + return { replyText }; +} \ No newline at end of file diff --git a/apps/api/src/services/messaging/inbound.ts b/apps/api/src/services/messaging/inbound.ts index de9d0e4..4b41576 100644 --- a/apps/api/src/services/messaging/inbound.ts +++ b/apps/api/src/services/messaging/inbound.ts @@ -1,5 +1,7 @@ import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db"; import { v4 as uuidv4 } from "uuid"; +import { detectKeyword, handleConsentKeyword } from "./consent.js"; +import { sendMessage } from "./outbound.js"; export interface TelnyxMessageReceivedPayload { data: { @@ -152,7 +154,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa throw new Error(`No business owns messaging number: ${toPhone}`); } - const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone); + const { id: conversationId, clientId } = await findOrCreateConversation(businessId, fromPhone, toPhone); await getDb() .update(conversations) @@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa "received" ); + const keyword = detectKeyword(message.body ?? ""); + if (keyword) { + const { replyText } = await handleConsentKeyword({ + clientId, + businessId, + kind: keyword.kind, + db: getDb(), + }); + await sendMessage({ + businessId, + clientId, + body: replyText, + sentByStaffId: undefined, + }); + } + return { conversationId, messageId }; } diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx index dc58c9b..885eb2b 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" /> +
+ + +
+
+ + +