Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45477bce4f | |||
| 964c63bbdf | |||
| 4ec2885b09 | |||
| fdd35a4cde | |||
| 559274becd | |||
| f3c56b43f0 | |||
| 89b3d81a82 | |||
| 4a628ef3b7 | |||
| 15af4f0962 | |||
| 990bc4400c | |||
| c12935de9c | |||
| 9b49b6388d | |||
| fe5de5fec8 | |||
| 82f1e3856f | |||
| 0d191743e2 | |||
| 526251b63a | |||
| 3aa7631519 | |||
| 511bdf0d7d | |||
| de3877b28d | |||
| 7d3adeae98 | |||
| 1fbe670751 | |||
| f265d61475 | |||
| 7d8d7535a5 | |||
| cc0259975b | |||
| bddbf008b5 | |||
| acb65fa5bb |
@@ -53,41 +53,6 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: E2E Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [lint-typecheck, test]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: pnpm --filter @groombook/e2e exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Start Docker Compose stack
|
|
||||||
run: docker compose up -d --wait
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: pnpm --filter @groombook/e2e test
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080
|
|
||||||
|
|
||||||
- name: Stop Docker Compose stack
|
|
||||||
if: always()
|
|
||||||
run: docker compose down
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -115,7 +80,7 @@ jobs:
|
|||||||
docker:
|
docker:
|
||||||
name: Build & Push Docker Images
|
name: Build & Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build, e2e]
|
needs: [build]
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Shedward Scissorhands — UAT Agent Instructions
|
||||||
|
|
||||||
|
You are the GroomBook User Acceptance Tester. Your sole job is to execute UAT playbooks against deployed environments and report results.
|
||||||
|
|
||||||
|
## Mandatory Tooling
|
||||||
|
|
||||||
|
You MUST use the **groombook-playwright MCP server** (`mcp__playwright-groombook__*` tools) for ALL browser interaction. Do not:
|
||||||
|
|
||||||
|
- Run scripted Playwright suites (`npx playwright test`, `pnpm test:e2e`, etc.)
|
||||||
|
- Use manual browser commands or shell-based browser automation
|
||||||
|
- Open browsers outside the MCP server
|
||||||
|
|
||||||
|
Every page navigation, click, form fill, and verification MUST go through MCP tools.
|
||||||
|
|
||||||
|
## Available MCP Tools
|
||||||
|
|
||||||
|
| Tool | When to use |
|
||||||
|
|------|-------------|
|
||||||
|
| `browser_navigate` | Open a URL |
|
||||||
|
| `browser_snapshot` | Read page state (preferred over screenshot for assertions) |
|
||||||
|
| `browser_take_screenshot` | Capture visual evidence |
|
||||||
|
| `browser_click` | Click an element (use ref from snapshot) |
|
||||||
|
| `browser_fill_form` | Fill form fields |
|
||||||
|
| `browser_type` | Type text into focused element |
|
||||||
|
| `browser_press_key` | Press keyboard keys |
|
||||||
|
| `browser_select_option` | Select dropdown options |
|
||||||
|
| `browser_hover` | Hover over elements |
|
||||||
|
| `browser_wait_for` | Wait for elements or navigation |
|
||||||
|
| `browser_console_messages` | Check for JS errors |
|
||||||
|
| `browser_network_requests` | Inspect API calls |
|
||||||
|
| `browser_evaluate` | Run JS in page context |
|
||||||
|
| `browser_resize` | Test responsive layouts |
|
||||||
|
| `browser_close` | Close browser session |
|
||||||
|
|
||||||
|
## Execution Workflow
|
||||||
|
|
||||||
|
1. Read the `UAT_PLAYBOOK.md` in the repo being tested.
|
||||||
|
2. For each test case, translate the human-readable steps into MCP tool calls.
|
||||||
|
3. Capture evidence: use `browser_snapshot` for assertions, `browser_take_screenshot` for visual proof.
|
||||||
|
4. Report pass/fail per test case with evidence.
|
||||||
|
5. If a test fails, document: severity, steps to reproduce, actual vs expected, and attach screenshots.
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
| Environment | URL | Auth |
|
||||||
|
|-------------|-----|------|
|
||||||
|
| Dev | `https://dev.groombook.dev` | Dev login selector (no OIDC) |
|
||||||
|
| UAT | `https://uat.groombook.dev` | Authentik OIDC at `https://auth.farh.net` |
|
||||||
|
| Production | `https://demo.groombook.dev` | Authentik OIDC |
|
||||||
|
| Site | `https://groombook.farh.net` | No auth required |
|
||||||
+77
-13
@@ -4,7 +4,49 @@
|
|||||||
|
|
||||||
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
|
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
|
||||||
|
|
||||||
## 2. Environments
|
## 2. Execution Method
|
||||||
|
|
||||||
|
All UAT is executed by **Shedward Scissorhands** via the **groombook-playwright MCP server**. No manual browser checks or scripted Playwright suites are used for UAT.
|
||||||
|
|
||||||
|
### MCP Tools
|
||||||
|
|
||||||
|
Shedward uses the `mcp__playwright-groombook__*` tool family:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `browser_navigate` | Navigate to a URL |
|
||||||
|
| `browser_snapshot` | Capture accessibility snapshot (preferred over screenshot) |
|
||||||
|
| `browser_take_screenshot` | Capture visual screenshot when needed |
|
||||||
|
| `browser_click` | Click an element by ref or selector |
|
||||||
|
| `browser_fill_form` | Fill form fields |
|
||||||
|
| `browser_type` | Type text into focused element |
|
||||||
|
| `browser_press_key` | Press keyboard keys (Enter, Tab, etc.) |
|
||||||
|
| `browser_select_option` | Select dropdown options |
|
||||||
|
| `browser_hover` | Hover over elements |
|
||||||
|
| `browser_wait_for` | Wait for elements or conditions |
|
||||||
|
| `browser_console_messages` | Check console for errors |
|
||||||
|
| `browser_network_requests` | Inspect network traffic |
|
||||||
|
| `browser_evaluate` | Run JavaScript in page context |
|
||||||
|
| `browser_tabs` | Manage browser tabs |
|
||||||
|
| `browser_close` | Close browser |
|
||||||
|
|
||||||
|
### How Test Cases Map to MCP Calls
|
||||||
|
|
||||||
|
Each test case in Section 4 describes steps like "Navigate to X" or "Click Y". Shedward translates these to MCP tool calls:
|
||||||
|
|
||||||
|
- **"Navigate to [URL]"** → `browser_navigate` with the environment URL
|
||||||
|
- **"Click [element]"** → `browser_snapshot` to find the element ref, then `browser_click`
|
||||||
|
- **"Fill in [field]"** → `browser_fill_form` or `browser_click` + `browser_type`
|
||||||
|
- **"Verify [state]"** → `browser_snapshot` and inspect the accessibility tree
|
||||||
|
- **"Check for errors"** → `browser_console_messages` + `browser_snapshot`
|
||||||
|
|
||||||
|
Shedward reads this playbook, executes each test case via MCP tools, captures evidence (snapshots/screenshots), and reports pass/fail per test case.
|
||||||
|
|
||||||
|
### Legacy CI Tests
|
||||||
|
|
||||||
|
The scripted Playwright suites in `apps/e2e/` and `apps/web/e2e/` are retained for CI regression testing only. They are **not** the primary UAT mechanism. UAT is exclusively MCP-driven by Shedward.
|
||||||
|
|
||||||
|
## 3. Environments
|
||||||
|
|
||||||
| Environment | URL | Notes |
|
| Environment | URL | Notes |
|
||||||
|-------------|-----|-------|
|
|-------------|-----|-------|
|
||||||
@@ -14,7 +56,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
|||||||
|
|
||||||
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
|
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
|
||||||
|
|
||||||
## 3. Pre-conditions
|
## 4. Pre-conditions
|
||||||
|
|
||||||
- UAT environment is accessible at `https://uat.groombook.dev`
|
- UAT environment is accessible at `https://uat.groombook.dev`
|
||||||
- Test accounts are seeded with the following personas:
|
- Test accounts are seeded with the following personas:
|
||||||
@@ -29,18 +71,23 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
|||||||
- Stripe test keys are configured for payment flow testing
|
- Stripe test keys are configured for payment flow testing
|
||||||
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
|
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
|
||||||
|
|
||||||
## 4. Test Cases
|
## 5. Test Cases
|
||||||
|
|
||||||
### 4.1 Authentication
|
### 4.1 Authentication
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-APP-4.1.1 | OIDC login | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
|
| TC-APP-4.1.1 | OIDC login (Authentik) | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
|
||||||
| TC-APP-4.1.2 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
|
| TC-APP-4.1.2 | Email + password login (UAT Super) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-super@groombook.dev` and UAT super password<br>4. Submit | User is logged in and redirected to dashboard with manager access |
|
||||||
| TC-APP-4.1.3 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
|
| TC-APP-4.1.3 | Email + password login (UAT Groomer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-groomer@groombook.dev` and UAT groomer password<br>4. Submit | User is logged in and redirected to dashboard with staff/groomer access |
|
||||||
| TC-APP-4.1.4 | RBAC - Manager access | 1. Log in as Manager<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
|
| TC-APP-4.1.4 | Email + password login (UAT Customer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-customer@groombook.dev` and UAT customer password<br>4. Submit | User is logged in with client portal access |
|
||||||
| TC-APP-4.1.5 | RBAC - Staff access | 1. Log in as Staff<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
|
| TC-APP-4.1.5 | Email + password login (UAT Tester) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-tester@groombook.dev` and UAT tester password<br>4. Submit | User is logged in with staff/tester access |
|
||||||
| TC-APP-4.1.6 | RBAC - Client access | 1. Log in as Client<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
|
| TC-APP-4.1.6 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
|
||||||
|
| TC-APP-4.1.7 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
|
||||||
|
| TC-APP-4.1.8 | RBAC - Manager access | 1. Log in as Manager (OIDC or email+password)<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
|
||||||
|
| TC-APP-4.1.9 | RBAC - Staff access | 1. Log in as Staff (OIDC or email+password)<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
|
||||||
|
| TC-APP-4.1.10 | RBAC - Client access | 1. Log in as Client (email+password)<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
|
||||||
|
| TC-APP-4.1.11 | Login after hourly reset | 1. Wait for or trigger `reset-demo-data` CronJob to run<br>2. Attempt email+password login as any UAT persona | Login succeeds — Better Auth credential accounts survive the reset cycle |
|
||||||
|
|
||||||
### 4.2 Setup Wizard / OOBE
|
### 4.2 Setup Wizard / OOBE
|
||||||
|
|
||||||
@@ -79,7 +126,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
|||||||
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
|
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>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.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<br>2. Select a pet with sizeCategory and coatType set<br>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.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>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<br>2. Note the service duration<br>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.8 | Large/Xlarge pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "xlarge" to an appointment<br>2. Note the service duration<br>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)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>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.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>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<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
|
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>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<br>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.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>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 |
|
||||||
@@ -235,7 +282,24 @@ 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.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 |
|
| TC-APP-4.20.6 | Cross-tenant isolation | 1. Staff from Business A attempts to read Business B conversations | 403 or empty response returned |
|
||||||
|
|
||||||
## 5. Pass/Fail Criteria
|
|
||||||
|
### 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"` |
|
||||||
|
## 6. Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.
|
**Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.
|
||||||
|
|
||||||
@@ -248,7 +312,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
|||||||
|
|
||||||
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
|
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
|
||||||
|
|
||||||
## 6. Update Policy
|
## 7. Update Policy
|
||||||
|
|
||||||
**Any PR that changes user-facing behaviour MUST update this file.**
|
**Any PR that changes user-facing behaviour MUST update this file.**
|
||||||
|
|
||||||
@@ -258,4 +322,4 @@ When modifying features that affect:
|
|||||||
- Configuration (settings, integrations)
|
- Configuration (settings, integrations)
|
||||||
- Data visibility (reports, search, filtering)
|
- Data visibility (reports, search, filtering)
|
||||||
|
|
||||||
The corresponding test case(s) in Section 4 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
|
The corresponding test case(s) in Section 5 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db";
|
import { eq, and, gt, or, asc } from "@groombook/db";
|
||||||
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
|
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
|
||||||
import { resolveBufferMinutes } from "./buffer.js";
|
import { resolveBufferMinutes } from "./buffer.js";
|
||||||
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
|
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
|
||||||
@@ -53,12 +53,12 @@ export async function detectAndCascadeOverrun({
|
|||||||
db,
|
db,
|
||||||
overrunningAppointmentId,
|
overrunningAppointmentId,
|
||||||
newEndTime,
|
newEndTime,
|
||||||
originalEndTime,
|
_originalEndTime,
|
||||||
}: {
|
}: {
|
||||||
db: Db;
|
db: Db;
|
||||||
overrunningAppointmentId: string;
|
overrunningAppointmentId: string;
|
||||||
newEndTime: Date;
|
newEndTime: Date;
|
||||||
originalEndTime: Date;
|
_originalEndTime: Date;
|
||||||
}): Promise<CascadeResult> {
|
}): Promise<CascadeResult> {
|
||||||
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
|
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
|
||||||
|
|
||||||
@@ -178,16 +178,16 @@ export async function detectAndCascadeOverrun({
|
|||||||
export function isOverrun({
|
export function isOverrun({
|
||||||
originalEndTime,
|
originalEndTime,
|
||||||
newEndTime,
|
newEndTime,
|
||||||
originalStartTime,
|
_originalStartTime,
|
||||||
newStartTime,
|
_newStartTime,
|
||||||
status,
|
status,
|
||||||
currentTime,
|
currentTime,
|
||||||
bufferMinutes,
|
bufferMinutes,
|
||||||
}: {
|
}: {
|
||||||
originalEndTime: Date;
|
originalEndTime: Date;
|
||||||
newEndTime: Date;
|
newEndTime: Date;
|
||||||
originalStartTime: Date;
|
_originalStartTime: Date;
|
||||||
newStartTime?: Date;
|
_newStartTime?: Date;
|
||||||
status: string;
|
status: string;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
bufferMinutes: number;
|
bufferMinutes: number;
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ appointmentsRouter.patch(
|
|||||||
isOverrun({
|
isOverrun({
|
||||||
originalEndTime,
|
originalEndTime,
|
||||||
newEndTime: new Date(updateFields.endTime),
|
newEndTime: new Date(updateFields.endTime),
|
||||||
originalStartTime: row.startTime,
|
_originalStartTime: row.startTime,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
currentTime: new Date(),
|
currentTime: new Date(),
|
||||||
bufferMinutes: row.bufferMinutes ?? 0,
|
bufferMinutes: row.bufferMinutes ?? 0,
|
||||||
@@ -710,7 +710,7 @@ appointmentsRouter.patch(
|
|||||||
db,
|
db,
|
||||||
overrunningAppointmentId: id,
|
overrunningAppointmentId: id,
|
||||||
newEndTime: new Date(updateFields.endTime),
|
newEndTime: new Date(updateFields.endTime),
|
||||||
originalEndTime,
|
_originalEndTime: originalEndTime,
|
||||||
});
|
});
|
||||||
return c.json({ ...row, cascade: cascadeResult });
|
return c.json({ ...row, cascade: cascadeResult });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
const serviceId = c.req.query("serviceId");
|
const serviceId = c.req.query("serviceId");
|
||||||
const dateStr = c.req.query("date");
|
const dateStr = c.req.query("date");
|
||||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
||||||
const petCoatType = c.req.query("petCoatType") ?? undefined;
|
|
||||||
|
|
||||||
if (!serviceId || !dateStr) {
|
if (!serviceId || !dateStr) {
|
||||||
return c.json({ error: "serviceId and date are required" }, 400);
|
return c.json({ error: "serviceId and date are required" }, 400);
|
||||||
@@ -61,7 +60,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||||
|
|
||||||
// Buffer-aware duration: extra time for large/x-large or complex coats
|
// Buffer-aware duration: extra time for large/x-large or complex coats
|
||||||
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large")
|
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "xlarge")
|
||||||
? (service.defaultBufferMinutes ?? 0)
|
? (service.defaultBufferMinutes ?? 0)
|
||||||
: 0;
|
: 0;
|
||||||
const durationMinutes = service.durationMinutes + extraBuffer;
|
const durationMinutes = service.durationMinutes + extraBuffer;
|
||||||
@@ -121,7 +120,7 @@ const bookingSchema = z.object({
|
|||||||
petSpecies: z.string().min(1).max(100),
|
petSpecies: z.string().min(1).max(100),
|
||||||
petBreed: z.string().max(100).optional(),
|
petBreed: z.string().max(100).optional(),
|
||||||
petSizeCategory: z
|
petSizeCategory: z
|
||||||
.enum(["small", "medium", "large", "x-large"])
|
.enum(["small", "medium", "large", "xlarge"])
|
||||||
.optional(),
|
.optional(),
|
||||||
petCoatType: z
|
petCoatType: z
|
||||||
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
||||||
@@ -213,7 +212,7 @@ bookRouter.post(
|
|||||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||||
|
|
||||||
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
||||||
if (body.petSizeCategory === "large" || body.petSizeCategory === "x-large") {
|
if (body.petSizeCategory === "large" || body.petSizeCategory === "xlarge") {
|
||||||
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
|
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof import("@groombook/db").getDb>,
|
||||||
|
};
|
||||||
|
|
||||||
|
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."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
|
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { detectKeyword, handleConsentKeyword } from "./consent.js";
|
||||||
|
import { sendMessage } from "./outbound.js";
|
||||||
|
|
||||||
export interface TelnyxMessageReceivedPayload {
|
export interface TelnyxMessageReceivedPayload {
|
||||||
data: {
|
data: {
|
||||||
@@ -152,7 +154,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
|
|||||||
throw new Error(`No business owns messaging number: ${toPhone}`);
|
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()
|
await getDb()
|
||||||
.update(conversations)
|
.update(conversations)
|
||||||
@@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
|
|||||||
"received"
|
"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 };
|
return { conversationId, messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:8080",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ export function BookPage() {
|
|||||||
<option value="small">Small (under 15 lbs)</option>
|
<option value="small">Small (under 15 lbs)</option>
|
||||||
<option value="medium">Medium (15–40 lbs)</option>
|
<option value="medium">Medium (15–40 lbs)</option>
|
||||||
<option value="large">Large (40–80 lbs)</option>
|
<option value="large">Large (40–80 lbs)</option>
|
||||||
<option value="x-large">X-Large (over 80 lbs)</option>
|
<option value="xlarge">X-Large (over 80 lbs)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -568,7 +568,7 @@ export function BookPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "xlarge") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||||
|
|||||||
+16
-1
@@ -43,6 +43,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -50,8 +56,17 @@ services:
|
|||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
import { randomBytes, scrypt } from "node:crypto";
|
||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -509,6 +510,81 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
||||||
|
|
||||||
|
// ── Better Auth credential accounts for UAT personas ─────────────────────
|
||||||
|
// Creates user + account rows so UAT personas can email+password login.
|
||||||
|
// Uses the same scrypt config as better-auth (keylen=64, N=16384, r=8, p=1).
|
||||||
|
const uatCredAccounts: Array<{ email: string; passwordEnvKey: string; staffId: string }> = [
|
||||||
|
{ email: "uat-super@groombook.dev", passwordEnvKey: "SEED_UAT_SUPER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000003" },
|
||||||
|
{ email: "uat-groomer@groombook.dev", passwordEnvKey: "SEED_UAT_GROOMER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000004" },
|
||||||
|
{ email: "uat-customer@groombook.dev", passwordEnvKey: "SEED_UAT_CUSTOMER_PASSWORD", staffId: "" },
|
||||||
|
{ email: "uat-tester@groombook.dev", passwordEnvKey: "SEED_UAT_TESTER_PASSWORD", staffId: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const acct of uatCredAccounts) {
|
||||||
|
const password = process.env[acct.passwordEnvKey];
|
||||||
|
if (!password) {
|
||||||
|
console.log(`⊘ No ${acct.passwordEnvKey} set — skipping Better Auth account for ${acct.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.user)
|
||||||
|
.where(eq(schema.user.email, acct.email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
if (existingUser) {
|
||||||
|
userId = existingUser.id;
|
||||||
|
console.log(`✓ Better Auth user '${acct.email}' already exists — skipping`);
|
||||||
|
} else {
|
||||||
|
// Hash with same scrypt params as better-auth: keylen=64, N=16384, r=8, p=1
|
||||||
|
// Use Promise-based scrypt API (callback pattern, wrapped in Promise)
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const key = await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
scrypt(password.normalize("NFKC"), salt, 64, { N: 16384, r: 8, p: 1 } as any, (err: Error | null, derivedKey: Buffer) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const passwordHash = `${salt.toString("hex")}:${key.toString("hex")}`;
|
||||||
|
|
||||||
|
const [newUser] = await db.insert(schema.user).values({
|
||||||
|
id: uuid(),
|
||||||
|
name: acct.email.split("@")[0]!,
|
||||||
|
email: acct.email,
|
||||||
|
emailVerified: true,
|
||||||
|
}).returning();
|
||||||
|
userId = newUser!.id;
|
||||||
|
|
||||||
|
await db.insert(schema.account).values({
|
||||||
|
id: uuid(),
|
||||||
|
accountId: userId,
|
||||||
|
providerId: "credential",
|
||||||
|
userId,
|
||||||
|
password: passwordHash,
|
||||||
|
});
|
||||||
|
console.log(`✓ Created Better Auth credential account for '${acct.email}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link staff record to Better Auth user if staff exists and has no userId yet
|
||||||
|
if (acct.staffId) {
|
||||||
|
const [existingStaff] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.staff)
|
||||||
|
.where(eq(schema.staff.id, acct.staffId))
|
||||||
|
.limit(1);
|
||||||
|
if (existingStaff && !existingStaff.userId) {
|
||||||
|
await db.update(schema.staff)
|
||||||
|
.set({ userId })
|
||||||
|
.where(eq(schema.staff.id, acct.staffId));
|
||||||
|
console.log(` ↳ Linked staff '${acct.email}' to Better Auth user`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Client: Demo Client ──
|
// ── Client: Demo Client ──
|
||||||
const [existingClient] = await db
|
const [existingClient] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface Pet {
|
|||||||
name: string;
|
name: string;
|
||||||
species: string;
|
species: string;
|
||||||
breed: string | null;
|
breed: string | null;
|
||||||
|
sizeCategory: string | null;
|
||||||
|
coatType: string | null;
|
||||||
weightKg: number | null;
|
weightKg: number | null;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
healthAlerts: string | null;
|
healthAlerts: string | null;
|
||||||
@@ -115,6 +117,7 @@ export interface Appointment {
|
|||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
confirmationToken: string | null;
|
confirmationToken: string | null;
|
||||||
customerNotes: string | null;
|
customerNotes: string | null;
|
||||||
|
bufferMinutes: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user