Compare commits

...

105 Commits

Author SHA1 Message Date
Chris Farhood cc0259975b fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types
CI / Lint & Typecheck (pull_request) Successful in 22s
CI / Test (pull_request) Successful in 23s
CI / Build (pull_request) Successful in 23s
CI / E2E Tests (pull_request) Failing after 3m25s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Pet interface: added sizeCategory and coatType (nullable strings).
Service interface: added defaultBufferMinutes.
Appointment interface: added bufferMinutes.

These fields are referenced by Book.tsx, cascade.ts, buffer.ts, appointment
routes, and other type-annotated consuming code. Without them, any file that
imports these interfaces and accesses the fields causes a TypeScript error.

cc @cpfarhood

Co-Authored-By: Flea Flicker <noreply@paperclip.ing>
2026-05-20 15:44:08 +00:00
the-dogfather-cto[bot] f27110eb07 Merge pull request #418 from groombook/fix/GRO-1289-fix-ci-yml-infra-path
fix(GRO-1289): correct infra repo paths in ci.yml Update Infra Image Tags job
2026-05-14 20:55:50 +00:00
the-dogfather-cto[bot] d069eff7d6 fix: correct infra repo paths in ci.yml Update Infra Image Tags job (#417)
fix: correct infra repo paths in ci.yml Update Infra Image Tags job
2026-05-14 20:37:48 +00:00
Chris Farhood 3ed1e10ecb fix(GRO-1289): correct infra repo paths in ci.yml Update Infra Image Tags job
Fix 'stat apps/groombook/overlays/dev/kustomization.yaml: no such file'
error by correcting paths from apps/groombook/overlays/dev to apps/overlays/dev
and apps/groombook/base to apps/base.

GRO-1289
2026-05-14 20:36:38 +00:00
Chris Farhood 904cd9c1b9 fix: correct infra repo paths in ci.yml Update Infra Image Tags job
GRO-1287
2026-05-14 20:26:53 +00:00
the-dogfather-cto[bot] 573869e517 fix: correct infra paths in promote-to-uat workflow (#414)
* Promote dev → uat: ARIA modal fix + tip split atomicity (#335)

* feat(GRO-785): validate tip split totals before marking invoice paid

- PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits
  exist or splits don't sum to 100%
- POST /invoices/:id/tip-splits now returns 400 (not 422) on validation
  failure via router-level ZodError handler

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-786): add ARIA label attributes to Modal dialog component

- Update Modal component to accept title and titleStyle props
- Add role="dialog", aria-modal="true", and aria-labelledby attributes
- Use useId() to generate stable ID for title heading association
- Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet,
  Log Grooming Visit, Permanently Delete Client) with title props
- Delete modal passes titleStyle for red color on warning

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-786): remove duplicate dialog role and restore focus trap

- Remove role="dialog" and aria-modal="true" from outer backdrop div
- Keep ARIA attributes only on inner dialog div (the actual modal)
- Restore useEffect focus management: auto-focus first element,
  Tab cycle wrapping, Escape key handler, focus restore on close

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore atomic tip split save in PATCH and fix error message

- When body.tipSplits is provided in PATCH /invoices/:id, validate sum
  first then atomically replace existing splits (delete + insert)
- When no incoming splits, validate existing DB splits with corrected
  message: "Tip splits are required when tip amount is greater than zero"
  (previously misleading "must sum to 100%" when no splits existed)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): address invoice tip split regression

- Use body.tipCents ?? current.tipCents for validation condition
  so that simultaneous status=paid + tipCents=0 skip split validation
- Use body.tipCents (now aliased as tipCents) instead of current.tipCents
  inside the atomic transaction for shareCents calculation
- Add explicit check for empty tipSplits array with appropriate error
  message ("Tip splits are required when tip amount is greater than zero")
  before the sum-to-100% check
- Destructure tipSplits out of body before spreading into update object
  to prevent it from leaking into the invoices table SET clause

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): wrap tip split save + invoice update in single transaction

Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.

Also removed unnecessary eslint-disable comment on _tipSplits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com>

* fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch

- Add stripePaymentIntentId to the GET /invoices list query so the refund button
  renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
  defaults instead of 5xx, preventing the Invoices page from crashing on
  mount for groomer-role sessions

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)

- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
  summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-890): populate stripePaymentIntentId on all paid seed invoices

All paid invoices created by the seed script now get a deterministic
stripePaymentIntentId of the form pi_test_seed_NNNNNN, unblocking the
refund button conditional in Invoices.tsx:514 during UAT.

Pending/draft invoices retain null as before.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-898): update CI to deploy on dev branch pushes

Update the Update Infra Image Tags job condition to also trigger
on pushes to the dev branch, not just main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: correct infra paths in promote-to-uat workflow

Remove 'groombook/' prefix from 4 path references in promote-to-uat.yml since the groombook/infra repo has apps/overlays/ and apps/base/ at the root, not under a groombook/ subdirectory.

GRO-1274

---------

Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com>
Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: lint-roller-qa[bot] <269744346+lint-roller-qa[bot]@users.noreply.github.com>
Co-authored-by: scrubs-mcbarkley-ceo[bot] <269735724+scrubs-mcbarkley-ceo[bot]@users.noreply.github.com>
Co-authored-by: Test User <test@example.com>
Co-authored-by: groombook-engineer[bot] <3141748+groombook-engineer[bot]@users.noreply.github.com>
Co-authored-by: Chris Farhood <chris@farhood.org>
2026-05-14 20:16:22 +00:00
the-dogfather-cto[bot] b31cbce82e fix: VITE_API_URL hardcoding that breaks CI E2E (GRO-1280)
fix: resolve VITE_API_URL hardcoding that breaks CI E2E (GRO-1280)
2026-05-14 20:11:31 +00:00
Chris Farhood 2398dabe3a fix: set VITE_API_URL env var in Build job
Ensures Vite sees VITE_API_URL as an empty string (not undefined) during
pnpm build, so the || window.location.origin fallback fires at runtime
instead of baking in the UAT URL.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 19:51:47 +00:00
Chris Farhood c2dd1dbf84 fix: add explicit ARG/ENV VITE_API_URL to Dockerfile
Without this, Vite sees VITE_API_URL as undefined (not empty string) at
build time. The ?? operator only replaces null/undefined, not a missing var,
so better-auth receives undefined — which it treats as a relative path and
prepends window.location.origin at build time, resulting in the UAT URL being
baked in.

Explicitly setting ARG VITE_API_URL= (empty string) in the Dockerfile makes
Vite see it as defined with empty value, so the || fallback fires at runtime.

Fixes GRO-1280.
2026-05-14 19:51:34 +00:00
Chris Farhood 7339d51acf fix: use window.location.origin as fallback for VITE_API_URL
Vite bakes VITE_* vars at build time, so hardcoding a URL in .env.production
breaks CI E2E which runs on localhost. Now falls back to the browser origin
at runtime, which works correctly since nginx reverse-proxies /api to the
local API container.

Fixes GRO-1280.
2026-05-14 19:40:59 +00:00
Chris Farhood 8eec29ad90 fix: correct infra paths in promote-to-uat workflow
Fix hardcoded apps/groombook/... paths to apps/... per GRO-1274.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 19:27:00 +00:00
groombook-engineer[bot] 050d478621 fix(GRO-1236): set VITE_API_URL and use /admin as OAuth callback URL (#403)
Two root causes fixed:
1. VITE_API_URL was empty in .env.production, so Better-Auth's client
   had no baseURL and could not correctly route the OAuth callback.
2. OAuth callbackURL was window.location.origin (root path), causing
   Better-Auth to redirect to / instead of /admin after login — since
   unauthenticated users at / are redirected to /login, this created a
   loop that appeared as 'session not persisting.'

With VITE_API_URL=https://uat.groombook.dev and callbackURL=/admin,
the callback lands on /admin which renders the admin layout and
correctly establishes the session cookie.

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-14 19:25:36 +00:00
the-dogfather-cto[bot] 795081cf10 Merge pull request #409 from groombook/fleaflicker/add-staff-messages-uat-playbook
docs(UAT_PLAYBOOK): add §4.20 Staff Messages test cases
2026-05-14 16:45:01 +00:00
Chris Farhood 8d5b71dc0f docs(UAT_PLAYBOOK): add §4.20 Staff Messages test cases
Add missing test coverage for the staff Messages page introduced
by PR #405 (GRO-106). Covers inbox load, conversation open,
send message, empty state, unread indicator, and cross-tenant
isolation per QA review on PR #408.

Updated UAT_PLAYBOOK.md §4.20 — staff Messages feature (GRO-106)
2026-05-14 16:37:04 +00:00
the-dogfather-cto[bot] c2d38bd3ee feat(GRO-106): staff messages page (#405)
feat(GRO-106): staff messages page
2026-05-14 16:23:27 +00:00
Chris Farhood 6a7229f330 merge: resolve conflicts with dev (keep API-aligned frontend)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:20:40 +00:00
Chris Farhood 9d9d7da13d fix(GRO-985): fix Messages test mocks and scrollIntoView guard
- Wrap conversation mocks in { items, nextCursor } response shape
  (loadConversations reads json.items, bare array caused undefined.length crash)
- Guard scrollIntoView with ?. (jsdom doesn't implement it)
- Use getAllByText for text appearing in both preview and thread

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:15:24 +00:00
lint-roller-qa[bot] 2c29c5e4a9 Merge pull request #406 from groombook/flea-flicker/gro-1248-fix-infra-path-prefix
fix(ci): correct infra repo paths in promote workflows
2026-05-14 16:12:51 +00:00
the-dogfather-cto[bot] ba5f8a916d Merge pull request #398 from groombook/feat/GRO-106-portal-communication-real
feat(GRO-106): portal Communication tab — real backend
2026-05-14 16:07:33 +00:00
Chris Farhood e873f11e4f fix(GRO-1241): test and guard scrollIntoView in MessagesPage 2026-05-14 15:46:31 +00:00
Chris Farhood aae11c0c4d fix(GRO-1241): remove unused readOnly and senderName in Communication.tsx
- Rename readOnly to _readOnly in MessageThread destructuring
  (satisfies ESLint no-unused-vars rule)
- Remove unused senderName variable in messages map

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 15:26:03 +00:00
Chris Farhood 537b5cb0b3 fix(GRO-1241): resolve all CI failures from QA review
1. **Remove duplicate staffReadAt** in `packages/db/src/schema.ts`
   (TS1117 duplicate identifier — merge conflict artifact)

2. **Add count to db index exports** in `packages/db/src/index.ts`
   (`count` from drizzle-orm was used in conversations.ts but not exported)

3. **Use dev version of conversations.ts** (no type errors, sql\`count(*)\`)
   — PR branch version had incompatible type errors (staff.businessId,
   count, optedOutAt fields not in schema)

4. **Remove duplicate conversationsRouter import** in `apps/api/src/index.ts`

All 289 tests pass, 0 lint errors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 14:46:52 +00:00
Chris Farhood d60200f8a7 fix(GRO-1241): remove duplicate staffReadAt + add count mock
- Remove duplicate staffReadAt column in conversations table schema
  (merge conflict artifact — TS1117 duplicate definition)
- Add count mock to conversations.test.ts mock @groombook/db export
  (PR switched from sql\`count(*)\` to Drizzle count() without updating mock)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 14:30:58 +00:00
Chris Farhood f150663047 fix(ci): correct infra repo paths in promote workflows
Replace incorrect `apps/groombook/` path prefix with `apps/` in both
promote-to-uat.yml and promote-prod.yml. The infra repo structure uses
`apps/` directly without a `groombook/` level.

GRO-1248

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:00:12 +00:00
Chris Farhood e605e1be74 fix(GRO-1242): align Messages frontend with conversations API contract
- Extract Conversation interface fields to match API response:
  replace lastMessageBody with lastMessage object, externalNumber with
  clientPhone, remove staffReadAt
- loadConversations(): extract json.items array instead of raw array
- loadMessages(): extract json.items and reverse() for chronological order
- Update test mocks to use { items, nextCursor } response shape

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:02:47 +00:00
Chris Farhood c4978be280 feat(GRO-106): staff messages page
- Adds staff conversations API (GET /api/conversations, GET /api/conversations/:id/messages, POST /api/conversations/:id/messages) with auth scoping and cross-tenant protection
- Adds staffReadAt column to conversations table for unread tracking
- Adds staff Messages page with two-column inbox layout (thread list + conversation view + composer)
- Adds Messages entry to staff sidebar navigation
- Includes tests for the MessagesPage component

Part of GRO-106 (SMS/MMS integration) Phase 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:41:35 +00:00
Chris Farhood f43e566dbd fix(GRO-1215): resolve ESLint error, cursor pagination, and UAT playbook gaps
- Add and() + lt() imports from @groombook/db
- Apply businessId to conversation WHERE clause for cross-tenant isolation
  (GET /portal/conversation: clientId AND businessId both scoped)
- Fix cursor pagination: apply lt(messages.createdAt, cursorMsg.createdAt)
  to the cursor WHERE clause so pages actually paginate
- Add UAT_PLAYBOOK.md §4.9.1 Communication tab test cases:
  TC-APP-4.9.6 message history with conversation
  TC-APP-4.9.7 empty state (no conversation yet)
  TC-APP-4.9.8 composer disabled with tooltip
  TC-APP-4.9.9 cross-tenant isolation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 12:40:06 +00:00
Chris Farhood 9c9568b80c feat(GRO-106): portal Communication tab — real backend
- Added GET /portal/conversation and GET /portal/conversation/messages endpoints
- Created Communication.api.ts with typed fetchers and React hooks
- Rewired Communication.tsx to use real API, removed mock data
- Added composer-disabled bar with "Reply from your phone" tooltip
- Added conversation route tests to portal.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:40:06 +00:00
the-dogfather-cto[bot] d0ba537b31 fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
2026-05-14 09:08:27 +00:00
Chris Farhood a9b9a0a733 fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
Add impersonationAuditLogs table mock and db.insert() method to the
@groombook/db mock in portal.test.ts to resolve "No 'impersonationAuditLogs'
export is defined" errors. The portalAudit middleware calls db.insert()
on every request, which was missing from the mock.

Passes all 26 portal tests.
2026-05-14 08:50:01 +00:00
the-dogfather-cto[bot] e818bdef4e fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
2026-05-14 08:39:43 +00:00
Chris Farhood dce9c96442 fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
The telnyx webhook handler at /api/webhooks/telnyx/messaging was
returning 401 for all requests including those with valid signatures.
This was caused by the authMiddleware being applied to all /api/*
routes via api.use("*", authMiddleware) after the webhook route was
registered at the app level.

authMiddleware already skips /api/auth/ paths; adding the same skip
for /api/webhooks/* fixes the issue — webhook endpoints use their own
signature validation and do not require Better-Auth session auth.

Root cause: authMiddleware was applied to webhook routes that were
registered at the app level before the api sub-app middleware, but
the skip condition only covered /api/auth/, not /api/webhooks/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 08:29:10 +00:00
the-dogfather-cto[bot] f50d240e56 feat(GRO-1208): conversations API route + staffReadAt migration (#399)
feat(GRO-1208): conversations API route + staffReadAt migration
2026-05-14 07:53:24 +00:00
Chris Farhood 22135859c2 fix(GRO-1208): remove phantom 0031_steady_veda journal entry
0031_steady_veda has no corresponding SQL file — caused Drizzle migration
runner to exit 1 in E2E. Renumber 0032_staff_read_at to idx 31.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:38:01 +00:00
Chris Farhood a5115f5291 fix(GRO-1208): remove unused isNull and AppEnv imports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:28:31 +00:00
Chris Farhood e64538822d feat(GRO-1208): add staff conversations API route and staffReadAt migration
- Add `staffReadAt` column to conversations table schema
- Add migration 0032_staff_read_at.sql for the new column
- Create /api/conversations router with GET / (list), GET /:id/messages (paginated), POST /:id/messages (send)
- Mark conversations as read (staffReadAt = NOW()) when staff fetches messages
- Return 409 when client has opted out of SMS
- 404 on cross-tenant access
- Add conversations.test.ts covering all 5 acceptance criteria

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:10:43 +00:00
the-dogfather-cto[bot] a70dbbd2c1 Merge pull request #392 from groombook/fix/gro-1024-auth-rate-limit
fix(auth): override Better Auth sign-in rate limit defaults
2026-05-11 03:31:31 +00:00
Chris Farhood a61614c4a9 fix(auth): override Better Auth sign-in rate limit defaults
- Add custom rate limit rules for /sign-in/social, /sign-in/email, and /sign-up/email
- Override default Better Auth limits (3 req/10s) with more permissive limits
- Apply rules to both placeholder and real auth configs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-11 02:16:58 +00:00
the-dogfather-cto[bot] 28a78a79d5 Add TELNYX_WEBHOOK_SECRET to .env.example (#390)
Add TELNYX_WEBHOOK_SECRET to .env.example
2026-05-11 02:03:54 +00:00
Chris Farhood 35c72a6c4b Add TELNYX_WEBHOOK_SECRET to .env.example
Add TELNYX_WEBHOOK_SECRET placeholder for Telnyx webhook validation.

Resolves GRO-1083

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-11 01:53:27 +00:00
groombook-engineer[bot] 2d88f18f75 feat(GRO-106): inbound Telnyx webhook + persistence (#378)
* feat(GRO-106): messaging schema + migrations

- Add conversations, messages, message_attachments, message_consent_events tables
- Add messagingChannelEnum, messageDirectionEnum, messageStatusEnum, messageConsentKindEnum
- Extend business_settings with messagingPhoneNumber and telnyxMessagingProfileId columns
- Add required indexes and unique constraints with cascade-on-delete FKs
- Add migration 0030_messaging.sql

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-981): restore journal entries and add DESC to indexes

- _journal.json: restore idx 28 (0028_sms_reminders), add idx 29
  (0029_db_indexes_constraints), renumber 0030_messaging to idx 30
  (was missing 0028 and 0029 entries — they were silently skipped)
- schema.ts: add .desc() to conversations.lastMessageAt and
  messages.createdAt indexes per spec
- 0030_messaging.sql: add DESC to both generated index statements

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-106): inbound Telnyx webhook + persistence

- Add POST /api/webhooks/telnyx/messaging route with HMAC signature verification
- Add services/messaging/inbound.ts: findOrCreateConversation, upsertMessage (idempotent on providerMessageId), delivery receipt handling
- Register telnyxWebhooksRouter in index.ts (before auth middleware)
- Add unit tests for signature validation, find-or-create, idempotent insert, delivery receipt

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-982): address all QA blocking failures

- #7: Extract validateTelnyxSignature in sms.ts as standalone exported fn,
  reuse in TelnyxProvider.validateWebhookSignature and telnyx.ts route
- #1: Replace uuid v4 import with crypto.randomUUID() (built-in, no dep)
- #2: Remove updatedAt from messages update in handleMessageFinalized
  (no such column exists)
- #3: Fix test import path ../../ → ../../../ for telnyx route import
- #4: validateTelnyxSignature accepts string | undefined | null to match
  Hono c.req.header() return type
- #5&6: Add null guards for .returning() results in findOrCreateConversation
  and upsertMessage
- #8: Remove dead buildFindOrCreateConversationParams function
- #9: Remove unused imports (messageDirectionEnum, messageStatusEnum,
  resolveBusinessIdByMessagingNumber in test)
- #10: Wrap upsertMessage insert in try/catch; unique violation returns
  {isNew: false} instead of crashing
- #11: Add EOF newlines to all modified files

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* chore: add uuid dependency for messaging services

* fix(GRO-982): address 5 test failures in inbound webhook

- Fix signature route tests: use /messaging not full mount path
- Fix handleMessageReceived mock order: business lookup first
- Fix stale mock state: add full mockReset in handleMessageFinalized beforeEach
- Fix delivery logic: set delivered for all message.finalized events
- Deduplicate test that was accidentally added twice

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-982): look up or create client by phone before inserting conversation

Fixes FK constraint violation where clientId was set to businessSettings.id
or a random UUID. Now looks up clients.phone = clientPhone first; if no match,
creates a placeholder client with phone as name and a placeholder email.

* fix(GRO-982): address QA round 4 blocking failures

- Fix URL in signature tests: use /messaging not full path
- Reorder mocks: businessSettings first, then conversations, clients, messages
- Add mockDb.mockReset in handleMessageFinalized beforeEach
- Remove direction guard: set delivered for any message.finalized

* fix(GRO-982): add missing message insert mock in handleMessageReceived test

* fix(GRO-982): simplify test mocks to match actual code flow

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 00:43:40 +00:00
the-dogfather-cto[bot] 9363929f32 Merge pull request #385 from groombook/fix/GRO-1036-security-findings
fix(GRO-1036): secure stats endpoint + restore refund preconditions
2026-05-04 22:43:41 +00:00
Chris Farhood 2c2a69f20b fix(GRO-1036): secure /api/invoices/stats/summary and refund endpoint
- Add requireRole('manager') auth middleware to /stats/summary handler
  (was completely unauthenticated, exposing revenue/PII stats)
- Restore stripePaymentIntentId pre-condition check on refund: return 422
  when invoice has no Stripe payment intent (prevents manual_ refund abuse)
- Remove groomer from refund role check (CTO ruling: manager-only)
- Remove manual refund branch since precondition now guarantees Stripe ID
- Move processRefund import to top of file

Fixes GRO-1036/GRO-1035 security findings.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 22:37:23 +00:00
the-dogfather-cto[bot] e52d561454 fix: portal mobile overflow — hide scrollbar on tab rows (GRO-730)
fix: portal mobile overflow — hide scrollbar on tab rows (GRO-730)
2026-05-04 21:02:38 +00:00
groombook-engineer[bot] 49dd698d22 feat(GRO-984): outbound SMS persistence
Outbound-only re-scoped slice. CI green. Reviewed by Lint Roller and CTO.
2026-05-04 17:55:47 +00:00
Chris Farhood 305394baaf BillingPayments: remove flex-wrap, add scrollbar-hide for mobile tabs
Fixes GRO-730 portal mobile overflow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 16:03:55 +00:00
groombook-engineer[bot] 706c91b3ac docs(GRO-106): 10DLC pilot registration runbook (#375)
* docs(GRO-106): 10DLC pilot registration runbook

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-106): address QA review feedback

- Change business_vertical from FINANCE_INSURANCE_BANKING to PROFESSIONAL_SERVICES
- Fix broken internal issue links (GRO-106, GRO-981) to plain text
- Add owner stamp alongside last-updated date
- Fix phone placeholder in SQL and API example to use +1XXXXXXXXXX
- Add trailing newline to both runbook files

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 15:58:11 +00:00
Hugh Hackman 39f5c83049 fix(GRO-730): restore global scrollbar polish, scope WebKit hide to .scrollbar-hide utility
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 15:40:53 +00:00
Hugh Hackman 6c0cdb33fe fix: portal mobile overflow — hide scrollbar on PetProfiles tab row
- Add scrollbar-hide CSS utility to index.css (webkit + Firefox + IE)
- Apply scrollbar-hide to PetProfiles tab overflow-x-auto row
- BillingPayments.tsx already has overflow-x-auto + flex-wrap on dev; no change needed

Fixes GRO-730: My Pets (+52px) and Billing (+61px) at 390px viewport

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 15:40:53 +00:00
groombook-engineer[bot] 2134676f10 fix(E2E): add missing API mocks for invoices stats and portal billing (#349)
* fix(E2E): add missing API mocks for invoices stats and portal billing

navigation.spec.ts:
- Add mock for /api/invoices/stats/summary returning the shape
  { revenueThisMonth, outstanding, refundsThisMonth, methodBreakdown }
  that InvoicesPage useEffect fetches on mount

portal-data.spec.ts billing test:
- Replace incorrect /api/billing** mock with correct portal endpoint
  mocks: /api/portal/config, /api/portal/invoices, /api/portal/payment-methods
  These are the actual endpoints BillingPayments component calls

Both fixes address the E2E failures reported by Lint Roller on PR #348.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-785): validate tip split totals before marking invoice paid

- PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits
  exist or splits don't sum to 100%
- POST /invoices/:id/tip-splits now returns 400 (not 422) on validation
  failure via router-level ZodError handler

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-786): add ARIA label attributes to Modal dialog component

- Update Modal component to accept title and titleStyle props
- Add role="dialog", aria-modal="true", and aria-labelledby attributes
- Use useId() to generate stable ID for title heading association
- Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet,
  Log Grooming Visit, Permanently Delete Client) with title props
- Delete modal passes titleStyle for red color on warning

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-786): remove duplicate dialog role and restore focus trap

- Remove role="dialog" and aria-modal="true" from outer backdrop div
- Keep ARIA attributes only on inner dialog div (the actual modal)
- Restore useEffect focus management: auto-focus first element,
  Tab cycle wrapping, Escape key handler, focus restore on close

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore atomic tip split save in PATCH and fix error message

- When body.tipSplits is provided in PATCH /invoices/:id, validate sum
  first then atomically replace existing splits (delete + insert)
- When no incoming splits, validate existing DB splits with corrected
  message: "Tip splits are required when tip amount is greater than zero"
  (previously misleading "must sum to 100%" when no splits existed)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): address invoice tip split regression

- Use body.tipCents ?? current.tipCents for validation condition
  so that simultaneous status=paid + tipCents=0 skip split validation
- Use body.tipCents (now aliased as tipCents) instead of current.tipCents
  inside the atomic transaction for shareCents calculation
- Add explicit check for empty tipSplits array with appropriate error
  message ("Tip splits are required when tip amount is greater than zero")
  before the sum-to-100% check
- Destructure tipSplits out of body before spreading into update object
  to prevent it from leaking into the invoices table SET clause

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): wrap tip split save + invoice update in single transaction

Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.

Also removed unnecessary eslint-disable comment on _tipSplits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* chore(GRO-720): harden .gitignore against agent runtime leaks

- Add .gh-token, *.gh-token to block token files
- Add .config/gh/ and **/.config/gh/ to block gh CLI config dirs
- Add infra-repo and infra-repo/ to block infra checkouts
- Add **/instructions/.gh-token to block per-agent token files
- Add **/AGENT_HOME/** and $AGENT_HOME/** to block agent home dirs
- Add .claude/ and .codex/ to block runtime directories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow groomer role to access invoices endpoint

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(gro-609): add refund handling and payment stats to admin

- Add stripePaymentIntentId to Invoice schema and types
- Add POST /api/invoices/:id/refund endpoint (Stripe placeholder)
- Add GET /api/invoices/stats/summary for payment analytics
- Add refund button + dialog (full/partial) to InvoiceDetailModal
- Add payment stats cards to Invoices page (revenue, outstanding, refunds, method breakdown)

Ref: GRO-609
Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(gro-609): add Stripe details to invoice modal and fix stats date filter

- Add GET /api/invoices/:id/stripe-details endpoint to fetch card last4 and
  payment status from Stripe
- Add getPaymentIntentDetails() to payment service
- Fix stats summary query to filter by startOfMonth
- Add cardLast4, paymentStatus, stripeRefundId transient fields to Invoice type
- Display Stripe details (card last4, payment status, refund status) in modal
- Add stripeRefundId and paymentFailureReason to Invoice schema (was missing in dev types)

Ref: GRO-609
Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-609): fix two bugs found by CTO review

1. Refund stats now sum actual refund amounts from refunds table
   instead of incorrectly summing tip_cents from invoices table.

2. Stripe payment_intents.retrieve now expands payment_method
   so card.last4 is correctly available instead of null.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-816): update PetProfiles.tsx to use new appointments response shape

- PetProfiles.tsx: update AppointmentsResponse interface to use flat
  appointments[] array instead of { upcoming, past }
- PetProfiles.tsx: update petHistory filter to use appointments.appointments
  with date filter for past-only appointments
- portal.ts: change /api/portal/appointments response to { appointments: [] }
  instead of { upcoming: [], past: [] }
- portal.ts: change /api/portal/pets response field names to match frontend
  Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl,
  groomingNotes→notes

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler

The PR refactored appointments response from { upcoming, past } to
{ appointments: [] } but the `now` variable used to compute those
filters was left behind. ESLint correctly flags it as unused.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(e2e): mock /api/invoices/stats/summary to prevent useEffect crash on Invoices page

The GRO-609 paymentStats useEffect fetches /api/invoices/stats/summary
on every render. Without a mock, the response {} (from the generic // Appointments,
clients, ... fallback) doesn't contain revenueThisMonth, causing the page
to fail rendering before AdminLayout ever mounts. Other admin pages don't
have this problem because they don't make unconditional side-effect fetches.

E2E tests mock all /api/** calls, so the new endpoint needs its own mock.

cc @cpfarhood

* fix(GRO-867): proxy logo download through API server — eliminate mixed content

All logo S3 interactions are now server-proxied:
- GET /api/admin/settings/logo streams image bytes directly instead of
  returning a presigned S3 URL to the browser
- Upload already went through POST /api/admin/settings/logo/upload
- Frontend uses relative /api/admin/settings/logo path as img src,
  never a raw S3 URL
- Appends cache-buster query param (?t=Date.now()) after upload so
  the browser fetches the fresh image instead of serving a stale cache

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-867): replace transformToBuffer with async iteration over S3 stream

transformToBuffer() does not exist on StreamingBlobPayloadOutputTypes
in the AWS SDK v3 client. Use for-await-of over the async iterable body
to collect chunks and Buffer.concat instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(GRO-867): c.body does not accept Buffer in Hono 4.x

c.body() signature only accepts string | ArrayBuffer | ReadableStream | Uint8Array
in Hono 4.x, not Node.js Buffer. Return a plain Response directly instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(GRO-867): remove unused getPresignedGetUrl import from settings.ts

ESLint @typescript-eslint/no-unused-vars flagged the import.
The logo proxy no longer uses pre-signed GET URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(GRO-870): /api/branding returns raw S3 URL — add public logo proxy

Add GET /api/branding/logo as a public endpoint that proxies logo bytes
from S3, and change /api/branding to return logoUrl: "/api/branding/logo"
instead of calling getPresignedGetUrl(). Eliminates mixed-content warnings
when the branding context is consumed on unauthenticated pages (portal,
login).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-609): cherry-pick refund/stats fixes to dev (#358)

* fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch

- Add stripePaymentIntentId to the GET /invoices list query so the refund button
  renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
  defaults instead of 5xx, preventing the Invoices page from crashing on
  mount for groomer-role sessions

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)

- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
  summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>

* fix(GRO-766): fix portal mobile overflow at 390px viewport

- CustomerPortal.tsx: change main from overflow-x-hidden to overflow-hidden
  to properly clip child overflow in both axes
- BillingPayments.tsx: add overflow-x-auto to tab button row so long
  button labels scroll instead of causing page-level overflow
- PetProfiles.tsx: already has overflow-x-auto on tab row — no change needed

Discovered in UAT by Shedward (DEF-2 and DEF-3 on GRO-754).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-876): wire up refund button in invoice detail modal

Cherry-pick of 628ed34 to fix @typescript-eslint/no-unused-vars
error on PR #351 Lint & Typecheck.

The issueRefund function was defined but never called. This commit:
- Removes the inline async onClick handler that bypassed issueRefund
- Wires the Refund button to open setShowRefundDialog(true) instead
- Uses issueRefund function (with refundAmount/refundError/refunding state)
- Adds manager role check before showing refund button
- Shows "Refunded" badge when invoice.stripeRefundId is set

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-876): remove dead issueRefund function from InvoiceDetailModal

The inline async onClick handler already calls the refund API directly. The
separate issueRefund function was defined but never called, causing
@typescript-eslint/no-unused-vars CI failure on PR #351.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-876): add partial refund validation and fix modal indentation

* fix(GRO-818): refund button for all paid invoices, inline cardLast4, manual refund for non-Stripe

- Backend refund endpoint: allow refunds on paid invoices without stripePaymentIntentId (manual refund path)
- Backend GET /invoices/🆔 inline fetch cardLast4 + paymentStatus from Stripe when stripePaymentIntentId present
- Frontend: show Refund button on all paid invoices for managers (not just Stripe-backed ones)
- Seed: add stripePaymentIntentId (pi_test_*) to ~20% of paid invoices for Stripe-path testing

cc @cpfarhood

* fix(GRO-887): wire OIDC + BETTER_AUTH env vars into API deployment (#369)

Wire BETTER_AUTH_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, BETTER_AUTH_SECRET
into API deployment. Add conditional OIDC_INTERNAL_BASE env var. Add new values
betterAuthUrl + internalBaseUrl in values.yaml. Add authSecretName helper.

Cherry-picked from e26718b (original GRO-898 fix).

Co-authored-by: Paperclip <paperclip@noreply.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>

* fix(E2E): remove duplicate invoices/stats/summary block after general /api/invoices check

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-980): restore 4-space indent on /api/invoices route handler

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com>
Co-authored-by: Paperclip <paperclip@noreply.com>
Co-authored-by: Chris Farhood <chris@farhood.org>
2026-05-04 15:05:39 +00:00
groombook-engineer[bot] dec4112ee5 feat(GRO-106): messaging schema + migrations (#374)
* feat(GRO-106): messaging schema + migrations

- Add conversations, messages, message_attachments, message_consent_events tables
- Add messagingChannelEnum, messageDirectionEnum, messageStatusEnum, messageConsentKindEnum
- Extend business_settings with messagingPhoneNumber and telnyxMessagingProfileId columns
- Add required indexes and unique constraints with cascade-on-delete FKs
- Add migration 0030_messaging.sql

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-981): restore journal entries and add DESC to indexes

- _journal.json: restore idx 28 (0028_sms_reminders), add idx 29
  (0029_db_indexes_constraints), renumber 0030_messaging to idx 30
  (was missing 0028 and 0029 entries — they were silently skipped)
- schema.ts: add .desc() to conversations.lastMessageAt and
  messages.createdAt indexes per spec
- 0030_messaging.sql: add DESC to both generated index statements

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 02:24:40 +00:00
scrubs-mcbarkley-ceo[bot] 53ab415713 promote: uat → main (GRO-887/GRO-958 chart hygiene)
promote: uat → main (GRO-887/GRO-958 chart hygiene)
2026-05-03 18:16:03 +00:00
The Dogfather a330e342e1 Merge main into uat to resolve PR #373 conflicts
Conflicts:
- apps/api/src/routes/invoices.ts — kept uat's stripeRefundId field (GRO-818)
- packages/db/src/seed.ts — kept main's deterministic stripePaymentIntentId
  population (GRO-890); removed duplicate uat declaration that survived auto-merge

Brings GRO-609 (refund/stats fixes), GRO-890 (seed stripe pi), GRO-898 (CI dev
branch) and prior GRO-865 logo proxy promote from main into uat so the
uat → main promote (GRO-958) becomes mergeable.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-03 18:03:59 +00:00
the-dogfather-cto[bot] 0f841e27fc Merge pull request #371 from groombook/dev
chore(uat): promote dev → uat (includes GRO-887 chart hygiene)
2026-05-03 17:58:14 +00:00
groombook-engineer[bot] a7bcce8b80 fix(GRO-887): wire OIDC + BETTER_AUTH env vars into API deployment (#369)
Wire BETTER_AUTH_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, BETTER_AUTH_SECRET
into API deployment. Add conditional OIDC_INTERNAL_BASE env var. Add new values
betterAuthUrl + internalBaseUrl in values.yaml. Add authSecretName helper.

Cherry-picked from e26718b (original GRO-898 fix).

Co-authored-by: Paperclip <paperclip@noreply.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-03 17:44:10 +00:00
groombook-engineer[bot] 5f1582a3b6 Merge pull request #367 from groombook/fix/gro-818-uat-defects
fix(GRO-818): UAT defects — refund button, cardLast4, manual refund, seed data
2026-05-02 21:02:32 +00:00
Test User c76ea93c29 fix(GRO-818): refund button for all paid invoices, inline cardLast4, manual refund for non-Stripe
- Backend refund endpoint: allow refunds on paid invoices without stripePaymentIntentId (manual refund path)
- Backend GET /invoices/🆔 inline fetch cardLast4 + paymentStatus from Stripe when stripePaymentIntentId present
- Frontend: show Refund button on all paid invoices for managers (not just Stripe-backed ones)
- Seed: add stripePaymentIntentId (pi_test_*) to ~20% of paid invoices for Stripe-path testing

cc @cpfarhood
2026-04-24 16:18:48 +00:00
the-dogfather-cto[bot] cd25d98384 Merge pull request #366 from groombook/fix/gro-898-ci-dev-branch
fix(GRO-898): update CI to deploy on dev branch pushes
2026-04-24 15:53:15 +00:00
Test User e9fceb78b3 fix(GRO-898): update CI to deploy on dev branch pushes
Update the Update Infra Image Tags job condition to also trigger
on pushes to the dev branch, not just main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 15:46:50 +00:00
the-dogfather-cto[bot] 0cae8adef8 Merge pull request #365 from groombook/promote/dev-to-uat-gro876
promote: dev → uat (GRO-876 refund button fix)
2026-04-24 15:27:25 +00:00
Test User 674626ba1e Merge remote-tracking branch 'origin/dev' into uat 2026-04-24 15:24:11 +00:00
the-dogfather-cto[bot] aa5686bed1 Merge pull request #361 from groombook/fix/gro-876-refund-button-dev
Merging GRO-876 refund button fix to dev. CTO + QA approved. All CI passes.
2026-04-24 15:22:26 +00:00
the-dogfather-cto[bot] 903fbf55d5 promote: dev → uat (GRO-766 portal mobile overflow fix)
promote: dev → uat (GRO-766 portal mobile overflow fix)
2026-04-24 15:02:13 +00:00
the-dogfather-cto[bot] 775e2e544b fix(GRO-766): portal mobile overflow CSS fix at 390px viewport
fix(GRO-766): portal mobile overflow CSS fix at 390px viewport
2026-04-24 14:57:57 +00:00
Test User fb9c922182 fix(GRO-766): fix portal mobile overflow at 390px viewport
- CustomerPortal.tsx: change main from overflow-x-hidden to overflow-hidden
  to properly clip child overflow in both axes
- BillingPayments.tsx: add overflow-x-auto to tab button row so long
  button labels scroll instead of causing page-level overflow
- PetProfiles.tsx: already has overflow-x-auto on tab row — no change needed

Discovered in UAT by Shedward (DEF-2 and DEF-3 on GRO-754).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 11:35:13 +00:00
Test User 1cc48f0b88 fix(GRO-876): add partial refund validation and fix modal indentation 2026-04-23 23:24:04 +00:00
Test User 1b8d7087c0 fix(GRO-876): remove dead issueRefund function from InvoiceDetailModal
The inline async onClick handler already calls the refund API directly. The
separate issueRefund function was defined but never called, causing
@typescript-eslint/no-unused-vars CI failure on PR #351.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:23:27 +00:00
Test User d65d121a5d fix(GRO-876): wire up refund button in invoice detail modal
Cherry-pick of 628ed34 to fix @typescript-eslint/no-unused-vars
error on PR #351 Lint & Typecheck.

The issueRefund function was defined but never called. This commit:
- Removes the inline async onClick handler that bypassed issueRefund
- Wires the Refund button to open setShowRefundDialog(true) instead
- Uses issueRefund function (with refundAmount/refundError/refunding state)
- Adds manager role check before showing refund button
- Shows "Refunded" badge when invoice.stripeRefundId is set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:23:27 +00:00
groombook-engineer[bot] b8fd7ec18f fix(gro-609): cherry-pick refund/stats fixes to dev (#358)
* fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch

- Add stripePaymentIntentId to the GET /invoices list query so the refund button
  renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
  defaults instead of 5xx, preventing the Invoices page from crashing on
  mount for groomer-role sessions

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)

- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
  summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-23 22:38:13 +00:00
the-dogfather-cto[bot] 7bf9cf9734 Merge pull request #359 from groombook/fix/gro-890-seed-stripe-payment-intent
fix(GRO-890): populate stripePaymentIntentId on paid seed invoices
2026-04-23 22:36:27 +00:00
groombook-engineer[bot] bf159f8b1f fix(GRO-890): populate stripePaymentIntentId on all paid seed invoices
All paid invoices created by the seed script now get a deterministic
stripePaymentIntentId of the form pi_test_seed_NNNNNN, unblocking the
refund button conditional in Invoices.tsx:514 during UAT.

Pending/draft invoices retain null as before.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 19:29:45 +00:00
the-dogfather-cto[bot] 2f3d4d8d01 fix(gro-609): refund button, stats 5xx, dashboard payment stats (#357)
fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch
2026-04-23 14:01:41 +00:00
Test User db9bb31702 fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)
- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
  summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 13:51:15 +00:00
Test User b38db65dde fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch
- Add stripePaymentIntentId to the GET /invoices list query so the refund button
  renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
  defaults instead of 5xx, preventing the Invoices page from crashing on
  mount for groomer-role sessions

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 13:47:27 +00:00
scrubs-mcbarkley-ceo[bot] 3178f81b99 promote: uat → main (GRO-865 logo proxy mixed content fix)
All SDLC gates cleared. Logo proxy fix ships to production. cc @cpfarhood
2026-04-22 03:50:15 +00:00
scrubs-mcbarkley-ceo[bot] 544d65959d promote: dev → uat (GRO-867 + GRO-870 logo proxy fixes)
Promoting logo proxy fixes to UAT. All SDLC gates passed. cc @cpfarhood
2026-04-22 03:49:30 +00:00
the-dogfather-cto[bot] fe2e093b92 Merge pull request #353 from groombook/fix/gro-867-logo-proxy
fix(GRO-870): /api/branding returns raw S3 URL — add public logo proxy
2026-04-22 03:21:15 +00:00
Flea Flicker 2af1671891 fix(GRO-870): /api/branding returns raw S3 URL — add public logo proxy
Add GET /api/branding/logo as a public endpoint that proxies logo bytes
from S3, and change /api/branding to return logoUrl: "/api/branding/logo"
instead of calling getPresignedGetUrl(). Eliminates mixed-content warnings
when the branding context is consumed on unauthenticated pages (portal,
login).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:08:36 +00:00
the-dogfather-cto[bot] ad80722eee Merge pull request #352 from groombook/fix/gro-867-logo-proxy
fix(GRO-867): proxy logo download through API server — eliminate mixed content
2026-04-22 02:48:54 +00:00
Flea Flicker c811b58c62 fix(GRO-867): remove unused getPresignedGetUrl import from settings.ts
ESLint @typescript-eslint/no-unused-vars flagged the import.
The logo proxy no longer uses pre-signed GET URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 22:20:55 +00:00
Flea Flicker 1dfcdcc2cb fix(GRO-867): c.body does not accept Buffer in Hono 4.x
c.body() signature only accepts string | ArrayBuffer | ReadableStream | Uint8Array
in Hono 4.x, not Node.js Buffer. Return a plain Response directly instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 22:19:26 +00:00
Flea Flicker f74e034495 fix(GRO-867): replace transformToBuffer with async iteration over S3 stream
transformToBuffer() does not exist on StreamingBlobPayloadOutputTypes
in the AWS SDK v3 client. Use for-await-of over the async iterable body
to collect chunks and Buffer.concat instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 22:16:08 +00:00
Flea Flicker 4c46cec4e3 fix(GRO-867): proxy logo download through API server — eliminate mixed content
All logo S3 interactions are now server-proxied:
- GET /api/admin/settings/logo streams image bytes directly instead of
  returning a presigned S3 URL to the browser
- Upload already went through POST /api/admin/settings/logo/upload
- Frontend uses relative /api/admin/settings/logo path as img src,
  never a raw S3 URL
- Appends cache-buster query param (?t=Date.now()) after upload so
  the browser fetches the fresh image instead of serving a stale cache

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 22:07:21 +00:00
lint-roller-qa[bot] f38bb244a4 Merge pull request #339 from groombook/dev
Promote dev → uat
2026-04-20 14:06:22 +00:00
the-dogfather-cto[bot] 251b36b863 fix(e2e): mock /api/invoices/stats/summary to prevent Invoices page crash
fix(e2e): mock /api/invoices/stats/summary to prevent Invoices page crash
2026-04-20 13:59:10 +00:00
the-dogfather-cto[bot] 3c366ccc46 Merge pull request #346 from groombook/fix/gro-816-portal-pets-crash
fix(GRO-816): fix PetProfiles crash from appointments response shape change
2026-04-19 11:02:07 +00:00
Test User ff149f75dc fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler
The PR refactored appointments response from { upcoming, past } to
{ appointments: [] } but the `now` variable used to compute those
filters was left behind. ESLint correctly flags it as unused.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 10:52:13 +00:00
Flea Flicker 03bd2d0235 fix(GRO-816): update PetProfiles.tsx to use new appointments response shape
- PetProfiles.tsx: update AppointmentsResponse interface to use flat
  appointments[] array instead of { upcoming, past }
- PetProfiles.tsx: update petHistory filter to use appointments.appointments
  with date filter for past-only appointments
- portal.ts: change /api/portal/appointments response to { appointments: [] }
  instead of { upcoming: [], past: [] }
- portal.ts: change /api/portal/pets response field names to match frontend
  Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl,
  groomingNotes→notes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 08:13:53 +00:00
Test User 10ad5e7b04 fix(e2e): mock /api/invoices/stats/summary to prevent useEffect crash on Invoices page
The GRO-609 paymentStats useEffect fetches /api/invoices/stats/summary
on every render. Without a mock, the response {} (from the generic // Appointments,
clients, ... fallback) doesn't contain revenueThisMonth, causing the page
to fail rendering before AdminLayout ever mounts. Other admin pages don't
have this problem because they don't make unconditional side-effect fetches.

E2E tests mock all /api/** calls, so the new endpoint needs its own mock.

cc @cpfarhood
2026-04-19 02:25:12 +00:00
the-dogfather-cto[bot] 4f85a4a432 feat(gro-609): add refund handling and payment stats to admin (#341)
feat(gro-609): add refund handling and payment stats to admin
2026-04-19 02:05:06 +00:00
Test User 560d33edf8 fix(gro-609): fix two bugs found by CTO review
1. Refund stats now sum actual refund amounts from refunds table
   instead of incorrectly summing tip_cents from invoices table.

2. Stripe payment_intents.retrieve now expands payment_method
   so card.last4 is correctly available instead of null.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 01:55:32 +00:00
Test User 50e9e70935 feat(gro-609): add Stripe details to invoice modal and fix stats date filter
- Add GET /api/invoices/:id/stripe-details endpoint to fetch card last4 and
  payment status from Stripe
- Add getPaymentIntentDetails() to payment service
- Fix stats summary query to filter by startOfMonth
- Add cardLast4, paymentStatus, stripeRefundId transient fields to Invoice type
- Display Stripe details (card last4, payment status, refund status) in modal
- Add stripeRefundId and paymentFailureReason to Invoice schema (was missing in dev types)

Ref: GRO-609
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 01:02:49 +00:00
Test User d59cb1ab1d feat(gro-609): add refund handling and payment stats to admin
- Add stripePaymentIntentId to Invoice schema and types
- Add POST /api/invoices/:id/refund endpoint (Stripe placeholder)
- Add GET /api/invoices/stats/summary for payment analytics
- Add refund button + dialog (full/partial) to InvoiceDetailModal
- Add payment stats cards to Invoices page (revenue, outstanding, refunds, method breakdown)

Ref: GRO-609
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 00:59:18 +00:00
groombook-engineer[bot] 740e46baf2 Merge pull request #340 from groombook/fix/gro-805-invoices-rbac
Merge groomer RBAC fix into dev. cc @cpfarhood
2026-04-18 11:00:57 +00:00
Test User b1b89966d9 fix: allow groomer role to access invoices endpoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 10:36:23 +00:00
the-dogfather-cto[bot] 25fd3308e0 chore(GRO-720): harden .gitignore against agent runtime leaks (#338)
chore(GRO-720): harden .gitignore against agent runtime leaks
2026-04-18 10:23:44 +00:00
lint-roller-qa[bot] be07c8b758 fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email (#312)
fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email
2026-04-18 10:18:38 +00:00
Flea Flicker ff2851eda2 chore(GRO-720): harden .gitignore against agent runtime leaks
- Add .gh-token, *.gh-token to block token files
- Add .config/gh/ and **/.config/gh/ to block gh CLI config dirs
- Add infra-repo and infra-repo/ to block infra checkouts
- Add **/instructions/.gh-token to block per-agent token files
- Add **/AGENT_HOME/** and $AGENT_HOME/** to block agent home dirs
- Add .claude/ and .codex/ to block runtime directories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:18:29 +00:00
the-dogfather-cto[bot] abee344ca4 Promote dev → uat: ARIA modal fix + tip split atomicity (#335)
* feat(GRO-785): validate tip split totals before marking invoice paid

- PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits
  exist or splits don't sum to 100%
- POST /invoices/:id/tip-splits now returns 400 (not 422) on validation
  failure via router-level ZodError handler

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-786): add ARIA label attributes to Modal dialog component

- Update Modal component to accept title and titleStyle props
- Add role="dialog", aria-modal="true", and aria-labelledby attributes
- Use useId() to generate stable ID for title heading association
- Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet,
  Log Grooming Visit, Permanently Delete Client) with title props
- Delete modal passes titleStyle for red color on warning

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-786): remove duplicate dialog role and restore focus trap

- Remove role="dialog" and aria-modal="true" from outer backdrop div
- Keep ARIA attributes only on inner dialog div (the actual modal)
- Restore useEffect focus management: auto-focus first element,
  Tab cycle wrapping, Escape key handler, focus restore on close

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore atomic tip split save in PATCH and fix error message

- When body.tipSplits is provided in PATCH /invoices/:id, validate sum
  first then atomically replace existing splits (delete + insert)
- When no incoming splits, validate existing DB splits with corrected
  message: "Tip splits are required when tip amount is greater than zero"
  (previously misleading "must sum to 100%" when no splits existed)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): address invoice tip split regression

- Use body.tipCents ?? current.tipCents for validation condition
  so that simultaneous status=paid + tipCents=0 skip split validation
- Use body.tipCents (now aliased as tipCents) instead of current.tipCents
  inside the atomic transaction for shareCents calculation
- Add explicit check for empty tipSplits array with appropriate error
  message ("Tip splits are required when tip amount is greater than zero")
  before the sum-to-100% check
- Destructure tipSplits out of body before spreading into update object
  to prevent it from leaking into the invoices table SET clause

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): wrap tip split save + invoice update in single transaction

Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.

Also removed unnecessary eslint-disable comment on _tipSplits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com>
2026-04-17 22:58:00 +00:00
scrubs-mcbarkley-ceo[bot] 460ba78112 Merge pull request #334 from groombook/uat
promote: uat → main (GRO-778, GRO-773, GRO-766, GRO-743)
2026-04-17 22:51:38 +00:00
the-dogfather-cto[bot] 1cc6d53546 promote: dev → uat (GRO-766, GRO-743, GRO-773, GRO-778)
promote: GRO-766, GRO-743, GRO-773, GRO-778 fixes to UAT
2026-04-17 22:09:40 +00:00
groombook-engineer[bot] 1da61fb466 Merge pull request #326 from groombook/dev
promote: dev → uat (GRO-769 S3 mixed content fix)
2026-04-17 17:19:40 +00:00
the-dogfather-cto[bot] e539b6c904 Merge pull request #324 from groombook/dev
promote: dev → uat (GRO-642 modal a11y + GRO-751 tip split validation)
2026-04-17 16:05:37 +00:00
the-dogfather-cto[bot] 9eb86004fc chore(uat): promote dev → uat (GRO-628 + GRO-749 + GRO-639)
chore(uat): promote dev → uat (GRO-628 + batched)
2026-04-17 12:31:53 +00:00
Test User 7f715ecdfc fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email
The resolveStaffMiddleware auto-links on first API call when staff.user_id
IS NULL. Setting userId at seed time blocks this path since Better-Auth's
user.id is opaque and unknown pre-auth. Remove userId from all staff inserts
so the middleware can populate it on first authenticated call.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 02:42:06 +00:00
68 changed files with 6418 additions and 6889 deletions
+4
View File
@@ -11,6 +11,10 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
# ── Webhooks ─────────────────────────────────────────────────────────────────
# Telnyx webhook secret for validating inbound message webhooks.
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
+7 -5
View File
@@ -119,6 +119,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build all packages
env:
VITE_API_URL: ""
run: pnpm build
docker:
@@ -340,7 +342,7 @@ jobs:
name: Update Infra Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
@@ -373,7 +375,7 @@ jobs:
echo "Updating dev overlay image tags to: $TAG"
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
DEV_KUST="apps/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
@@ -381,7 +383,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
@@ -390,7 +392,7 @@ jobs:
fi
# Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -413,7 +415,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
+4 -4
View File
@@ -58,7 +58,7 @@ jobs:
TAG: ${{ inputs.tag }}
run: |
cd /tmp/infra
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
PROD_KUST="apps/overlays/prod/kustomization.yaml"
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
@@ -70,14 +70,14 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -94,7 +94,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "release/promote-prod-${TAG}"
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}"
gh pr create \
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
run: |
echo "Updating UAT overlay image tags to: $TAG"
cd /tmp/infra
UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
UAT_KUST="apps/overlays/uat/kustomization.yaml"
if [ ! -f "$UAT_KUST" ]; then
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
@@ -55,7 +55,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
@@ -64,7 +64,7 @@ jobs:
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -81,7 +81,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-uat-image-tags-${TAG}"
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
+13
View File
@@ -8,3 +8,16 @@ dist/
.turbo/
coverage/
minimax-output/
# Agent runtime artifacts — never commit
.gh-token
*.gh-token
.config/gh/
**/.config/gh/
infra-repo
infra-repo/
**/instructions/.gh-token
**/AGENT_HOME/**
$AGENT_HOME/**
.claude/
.codex/
+254
View File
@@ -0,0 +1,254 @@
# UAT Playbook
## 1. Overview
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
| Environment | URL | Notes |
|-------------|-----|-------|
| Dev | `https://dev.groombook.dev` | Development environment for active development |
| UAT | `https://uat.groombook.dev` | User Acceptance Testing environment |
| Production | `https://demo.groombook.dev` | Production/demo environment |
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
## 3. Pre-conditions
- UAT environment is accessible at `https://uat.groombook.dev`
- Test accounts are seeded with the following personas:
- **Manager:** Full administrative access
- **Staff:** Limited access to assigned appointments and clients
- **Client:** Portal access to view and manage their own appointments
- OIDC is configured with Authentik at `https://auth.farh.net`
- Seed data is populated:
- Sample clients and pets
- Grooming services with pricing and duration
- Existing appointments
- Stripe test keys are configured for payment flow testing
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
## 4. Test Cases
### 4.1 Authentication
| # | 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.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.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.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.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.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 |
### 4.2 Setup Wizard / OOBE
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.2.1 | First-run setup | 1. Access fresh UAT environment with no configuration<br>2. Complete setup wizard: business name, hours, services | Configuration is saved, dashboard loads with setup complete |
| TC-APP-4.2.2 | Setup validation | 1. Start setup wizard<br>2. Leave required fields blank<br>3. Attempt to proceed | Validation errors displayed, cannot proceed without required fields |
| TC-APP-4.2.3 | Skip setup (if already configured) | 1. Access configured environment<br>2. Attempt to access setup wizard | Redirected to dashboard or setup is marked as complete |
### 4.3 Client Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.3.1 | Create new client | 1. Navigate to Clients page<br>2. Click "Add Client"<br>3. Fill in client details (name, email, phone, address)<br>4. Save | Client is created and appears in client list |
| TC-APP-4.3.2 | Edit client | 1. Select existing client<br>2. Click "Edit"<br>3. Modify client details<br>4. Save | Changes are saved and reflected in client profile |
| TC-APP-4.3.3 | Search clients | 1. Navigate to Clients page<br>2. Enter client name or email in search<br>3. Press Enter/submit | Search results display matching clients |
| TC-APP-4.3.4 | Archive client | 1. Select active client<br>2. Click "Archive"<br>3. Confirm action | Client is marked as archived, no longer appears in active client list |
### 4.4 Pet Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.4.1 | Add pet to client | 1. Select client<br>2. Click "Add Pet"<br>3. Fill in pet details (name, breed, weight, notes)<br>4. Save | Pet is added to client's pet list |
| TC-APP-4.4.2 | Edit pet information | 1. Select pet from client profile<br>2. Click "Edit"<br>3. Modify pet details<br>4. Save | Changes are saved and reflected |
| TC-APP-4.4.3 | View grooming history | 1. Select pet with past appointments<br>2. Navigate to "History" tab | All past grooming appointments and notes are displayed |
| TC-APP-4.4.4 | Add breed notes | 1. Edit pet<br>2. Add breed-specific notes (temperament, special handling)<br>3. Save | Notes are saved and visible to staff when scheduling |
### 4.5 Appointment Scheduling
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.5.1 | Create new appointment | 1. Navigate to Calendar or Appointments page<br>2. Click "New Appointment"<br>3. Select client, pet, service, staff, date/time<br>4. Save | Appointment is created and appears in calendar |
| TC-APP-4.5.2 | Modify appointment | 1. Select existing appointment<br>2. Click "Edit"<br>3. Change date/time, staff, or service<br>4. Save | Changes are saved and calendar updates |
| TC-APP-4.5.3 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Confirm and optionally select reason | Appointment is marked as cancelled, slot becomes available |
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>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<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 |
### 4.6 Services
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.6.1 | List all services | 1. Navigate to Services page | All configured grooming services are listed |
| TC-APP-4.6.2 | Create new service | 1. Click "Add Service"<br>2. Enter service name, description, duration, price<br>3. Save | Service is created and appears in list |
| TC-APP-4.6.3 | Edit service | 1. Select existing service<br>2. Modify pricing or duration<br>3. Save | Changes are saved and reflected |
| TC-APP-4.6.4 | Deactivate service | 1. Select service<br>2. Click "Deactivate"<br>3. Confirm | Service is marked as inactive, not available for new appointments |
### 4.7 Staff Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.7.1 | List all staff | 1. Navigate to Staff page | All staff members are listed with roles and status |
| TC-APP-4.7.2 | Add new staff member | 1. Click "Add Staff"<br>2. Enter staff details and assign role<br>3. Save | Staff member is created and can be assigned to appointments |
| TC-APP-4.7.3 | Assign RBAC role | 1. Select staff member<br>2. Change role (e.g., from Staff to Manager)<br>3. Save | Role change takes effect immediately |
| TC-APP-4.7.4 | Impersonate client | 1. As Manager, select client<br>2. Click "Impersonate"<br>3. Verify audit log | Manager views client's perspective, action is logged |
| TC-APP-4.7.5 | End impersonation | 1. While impersonating, click "End Impersonation" | Session returns to Manager's view |
### 4.8 Invoicing & Payments
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.8.1 | Generate invoice | 1. Select completed appointment<br>2. Click "Generate Invoice"<br>3. Review invoice details | Invoice is created with correct services, pricing, and taxes |
| TC-APP-4.8.2 | Process Stripe payment | 1. Open invoice<br>2. Click "Pay Now"<br>3. Enter Stripe test card details<br>4. Submit | Payment is processed, invoice marked as paid |
| TC-APP-4.8.3 | Add tip | 1. Before or after payment, add tip amount<br>2. Save | Tip is added to invoice total |
| TC-APP-4.8.4 | Generate receipt | 1. After payment, click "Generate Receipt"<br>2. Download or view receipt | Receipt is generated with payment details |
| TC-APP-4.8.5 | Process refund | 1. Select paid invoice<br>2. Click "Refund"<br>3. Enter refund amount and reason<br>4. Confirm | Refund is processed via Stripe, invoice status updated |
| TC-APP-4.8.6 | Failed payment handling | 1. Attempt payment with declined card<br>2. Verify error handling | Appropriate error message displayed, invoice remains unpaid |
### 4.9 Customer Portal
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.9.1 | Client login | 1. Access portal URL<br>2. Log in with client credentials | Client lands on portal dashboard |
| TC-APP-4.9.2 | View appointments | 1. Navigate to "My Appointments"<br>2. Review upcoming and past appointments | All client's appointments are listed |
| TC-APP-4.9.3 | Confirm appointment | 1. Select upcoming appointment<br>2. Click "Confirm" | Appointment is marked as confirmed by client |
| TC-APP-4.9.4 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Provide reason | Appointment is cancelled, notification sent to business |
| TC-APP-4.9.5 | View appointment history | 1. Navigate to "History" tab | All past appointments with details are shown |
### 4.9.1 Communication Tab
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.9.6 | View message history (conversation exists) | 1. Log in as client with existing conversation<br>2. Navigate to Communication tab | Real message history is displayed (not mock data) |
| TC-APP-4.9.7 | Empty state (no conversation yet) | 1. Log in as client with no conversation<br>2. Navigate to Communication tab | Empty state is shown; app does not crash or show mock messages |
| TC-APP-4.9.8 | Composer disabled | 1. Log in as client<br>2. Navigate to Communication tab | Composer/Reply field is hidden or disabled with tooltip "Reply from your phone" |
| TC-APP-4.9.9 | Cross-tenant isolation | 1. As client A, retrieve session token<br>2. Attempt to fetch client B conversation via API | Request returns 403 or empty; client A cannot access client B messages |
### 4.10 Waitlist
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.10.1 | Add client to waitlist | 1. Navigate to Waitlist page<br>2. Click "Add to Waitlist"<br>3. Select client, pet, preferred dates<br>4. Save | Client is added to waitlist |
| TC-APP-4.10.2 | View waitlist | 1. Navigate to Waitlist page | All waitlisted requests are displayed with priority |
| TC-APP-4.10.3 | Promote to appointment | 1. Select waitlist entry<br>2. Click "Promote to Appointment"<br>3. Select available slot | Appointment is created from waitlist, entry removed |
| TC-APP-4.10.4 | Remove from waitlist | 1. Select waitlist entry<br>2. Click "Remove"<br>3. Confirm | Entry is removed from waitlist |
### 4.11 Search
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.11.1 | Global search for clients | 1. Use global search bar<br>2. Enter client name or email<br>3. Select "Clients" | Search returns matching clients |
| TC-APP-4.11.2 | Global search for pets | 1. Use global search bar<br>2. Enter pet name or breed<br>3. Select "Pets" | Search returns matching pets with owner info |
| TC-APP-4.11.3 | Search filters | 1. Perform search<br>2. Apply filters (date range, status, etc.) | Results are filtered according to criteria |
| TC-APP-4.11.4 | No results handling | 1. Search for non-existent term<br>2. Verify UI | "No results found" message displayed |
### 4.12 Reports
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.12.1 | Revenue dashboard | 1. Navigate to Reports > Revenue<br>2. Select date range | Revenue metrics displayed (total, by service, by staff) |
| TC-APP-4.12.2 | Staff utilization | 1. Navigate to Reports > Utilization<br>2. Select date range | Staff hours booked vs. available shown |
| TC-APP-4.12.3 | Trend analytics | 1. Navigate to Reports > Trends<br>2. Select metric and time period | Trend chart displays with data points |
| TC-APP-4.12.4 | Export report | 1. View any report<br>2. Click "Export"<br>3. Select format (CSV, PDF) | Report file is downloaded |
### 4.13 Calendar
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.13.1 | Generate iCal feed | 1. Navigate to Calendar<br>2. Click "iCal Feed"<br>3. Copy URL | iCal feed URL is generated for external calendar apps |
| TC-APP-4.13.2 | Calendar sync (external) | 1. Import iCal feed into external calendar (Google, Outlook)<br>2. Verify sync | Appointments appear in external calendar |
| TC-APP-4.13.3 | Calendar availability display | 1. View calendar in any view mode | Available and booked slots are visually distinct |
### 4.14 Email Reminders
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.14.1 | Configure email reminders | 1. Navigate to Settings > Notifications<br>2. Set reminder timing (24h, 1h before)<br>3. Save | Configuration is saved |
| TC-APP-4.14.2 | Verify reminder delivery | 1. Create appointment for tomorrow<br>2. Wait for reminder trigger<br>3. Check test email account | Reminder email is received with correct details |
| TC-APP-4.14.3 | SMS notification | 1. Configure SMS provider (Telnyx)<br>2. Enable SMS reminders<br>3. Create appointment | SMS is sent to client's phone number |
| TC-APP-4.14.4 | Notification preferences | 1. As client, access portal settings<br>2. Toggle email/SMS preferences | Preferences are respected for future notifications |
### 4.15 Grooming Logs
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.15.1 | Log grooming entry | 1. Select pet<br>2. Click "Add Grooming Log"<br>3. Enter details (date, services, notes, photos)<br>4. Save | Log entry is created and linked to pet |
| TC-APP-4.15.2 | View grooming history | 1. Select pet<br>2. Navigate to "Grooming History" | All log entries are displayed chronologically |
| TC-APP-4.15.3 | Add photos to log | 1. Create or edit grooming log<br>2. Upload before/after photos<br>3. Save | Photos are attached to log entry |
| TC-APP-4.15.4 | Edit grooming log | 1. Select existing log entry<br>2. Modify notes or services<br>3. Save | Changes are saved |
### 4.16 Settings
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.16.1 | Business settings | 1. Navigate to Settings > Business<br>2. Update business name, hours, contact info<br>3. Save | Settings are saved and reflected app-wide |
| TC-APP-4.16.2 | App configuration | 1. Navigate to Settings > App<br>2. Configure theme, time zone, date format<br>3. Save | Configuration takes effect immediately |
| TC-APP-4.16.3 | Payment settings | 1. Navigate to Settings > Payments<br>2. Configure Stripe keys, tax rates<br>3. Save | Payment settings are updated |
| TC-APP-4.16.4 | Notification settings | 1. Navigate to Settings > Notifications<br>2. Configure email/SMS providers and defaults<br>3. Save | Notification configuration is saved |
### 4.17 Mobile / PWA
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.17.1 | Install prompt | 1. Access app on mobile device (or DevTools mobile view)<br>2. Verify install prompt appears | "Add to Home Screen" prompt is shown |
| TC-APP-4.17.2 | Responsive design (mobile) | 1. Resize viewport to 390x844 (iPhone dimensions)<br>2. Navigate through app | All pages are usable and properly formatted |
| TC-APP-4.17.3 | Offline basics | 1. Load app<br>2. Enable offline mode in DevTools<br>3. Navigate to previously loaded pages | Cached content is displayed, offline indicator shown |
| TC-APP-4.17.4 | Touch interactions | 1. On mobile viewport, tap buttons, forms, and navigation<br>2. Verify responsiveness | All touch targets are accessible and responsive |
### 4.18 Navigation
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.18.1 | All major sections accessible | 1. Click each main navigation item<br>2. Verify page loads | All sections (Dashboard, Calendar, Clients, Pets, Appointments, Reports, Settings) load successfully |
| TC-APP-4.18.2 | No broken links | 1. Navigate through app<br>2. Click various links and buttons | No 404 errors or dead ends encountered |
| TC-APP-4.18.3 | No blank pages | 1. Navigate to each section and sub-section<br>2. Verify content is displayed | All pages render with appropriate content |
| TC-APP-4.18.4 | Back/forward navigation | 1. Navigate through multiple pages<br>2. Use browser back and forward buttons | Navigation history works correctly |
### 4.19 Error States
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.19.1 | Form with bad data | 1. On any form, enter invalid email, phone, or dates<br>2. Submit | Validation errors display specific issues |
| TC-APP-4.19.2 | Missing required fields | 1. On any form, leave required fields blank<br>2. Submit | Clear error messages indicate which fields are required |
| TC-APP-4.19.3 | Empty states | 1. Navigate to pages with no data (empty calendar, no clients)<br>2. Verify UI | Helpful empty state message with call-to-action displayed |
| TC-APP-4.19.4 | Network error handling | 1. Disable network in DevTools<br>2. Attempt actions that require API calls<br>3. Re-enable network | Appropriate error message shown, app recovers when network restored |
### 4.20 Staff Messages
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.20.1 | Staff messages inbox loads | 1. Log in as Staff<br>2. Navigate to Messages | Conversation list renders with client phone and last message preview |
| TC-APP-4.20.2 | Open conversation | 1. Select a conversation from the list | Full message thread loads chronologically |
| TC-APP-4.20.3 | Send message | 1. Type a reply and submit | Message appears in thread; POST /api/conversations/:id/messages succeeds |
| TC-APP-4.20.4 | Empty state | 1. Log in as Staff with no conversations | Empty state shown; no crash |
| 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 |
## 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.
**Fail:** Any unexpected result is encountered. For failures, document:
- Severity (Critical, High, Medium, Low)
- Steps to reproduce
- Actual vs. expected behavior
- Screenshot(s) if applicable
- Browser and device information
**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
**Any PR that changes user-facing behaviour MUST update this file.**
When modifying features that affect:
- User workflows (authentication, scheduling, payments, etc.)
- UI/UX (navigation, forms, responsive design)
- Configuration (settings, integrations)
- 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").
+2 -1
View File
@@ -24,13 +24,14 @@
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"uuid": "^11.1.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveBufferMinutes } from "../lib/buffer.js";
// ─── Mock types matching schema ─────────────────────────────────────────────
interface MockBufferTimeRule {
id: string;
serviceId: string;
sizeCategory: string | null;
coatType: string | null;
bufferMinutes: number;
}
interface MockService {
id: string;
name: string;
defaultBufferMinutes: number;
}
// ─── Mock db factory ─────────────────────────────────────────────────────────
// Simulates Drizzle query builder: db.select().from(t).where(eq(...)) → await → array
// For services we use db.select().from(t).where(eq(...)).limit(1) → await → first item
function createMockDb(rules: MockBufferTimeRule[], services: MockService[]) {
let callCount = 0;
return {
select: vi.fn(() => {
callCount++;
const rulesQuery = {
from: () => ({
where: () => rules, // await resolves directly to rules array
}),
};
const serviceQuery = {
from: () => ({
where: () => ({
limit: () => services, // await resolves to services array
}),
}),
};
// First select call → rules, second → services
return callCount === 1 ? rulesQuery : serviceQuery;
}),
} as any;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("resolveBufferMinutes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns exact match when serviceId + sizeCategory + coatType all match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: "short", bufferMinutes: 15 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 10 },
{ id: "rule-3", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "short",
db,
});
expect(result).toBe(15);
});
it("returns service + size match when no exact match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 10 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "long",
db,
});
expect(result).toBe(10);
});
it("returns service + coat match when no exact or size match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: "wire", bufferMinutes: 12 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "wire",
db,
});
expect(result).toBe(12);
});
it("returns service-only match when no partial matches", async () => {
const db = createMockDb(
[{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 7 }],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "long",
db,
});
expect(result).toBe(7);
});
it("falls back to service.defaultBufferMinutes when no rules exist", async () => {
const db = createMockDb([], [{ id: "svc-1", name: "Bath", defaultBufferMinutes: 8 }]);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "small",
coatType: "curly",
db,
});
expect(result).toBe(8);
});
it("falls back to 0 when no rules and no service default", async () => {
const db = createMockDb([], []);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "small",
coatType: null,
db,
});
expect(result).toBe(0);
});
it("exact match beats partial matches (priority verification)", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: "short", bufferMinutes: 20 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 15 },
{ id: "rule-3", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 10 },
],
[{ id: "svc-1", name: "Groom", defaultBufferMinutes: 5 }]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "short",
db,
});
// Exact match (20) should win over service+size (15) and service default (5)
expect(result).toBe(20);
});
it("handles null sizeCategory and null coatType at rule level", async () => {
const db = createMockDb(
[{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 6 }],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: null,
coatType: null,
db,
});
expect(result).toBe(6);
});
it("prefers service+size over service-only when both exist", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "large", coatType: null, bufferMinutes: 14 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 3 },
],
[{ id: "svc-1", name: "Groom", defaultBufferMinutes: 1 }]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "smooth",
db,
});
expect(result).toBe(14);
});
});
@@ -0,0 +1,318 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const STAFF_ROW = {
id: "staff-uuid-1",
email: "groomer@groombook.com",
name: "Groomer",
role: "groomer" as const,
businessId: "business-uuid-1",
active: true,
userId: null,
oidcSub: null,
isSuperUser: false,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const BUSINESS_SETTINGS = {
id: "business-uuid-1",
businessName: "Test Salon",
};
const CONV_1 = {
id: "conv-uuid-1",
businessId: "business-uuid-1",
clientId: "client-uuid-1",
channel: "sms",
externalNumber: "+15551111111",
businessNumber: "+15552222222",
lastMessageAt: new Date("2025-01-10T10:00:00Z"),
status: "active",
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-10T10:00:00Z"),
staffReadAt: null,
};
const MSG_INBOUND_1 = {
id: "msg-uuid-1",
conversationId: "conv-uuid-1",
direction: "inbound",
body: "Hello",
status: "delivered",
sentByStaffId: null,
createdAt: new Date("2025-01-10T09:00:00Z"),
deliveredAt: new Date("2025-01-10T09:01:00Z"),
};
const MSG_OUTBOUND_1 = {
id: "msg-uuid-2",
conversationId: "conv-uuid-1",
direction: "outbound",
body: "Hi Alice!",
status: "delivered",
sentByStaffId: "staff-uuid-1",
createdAt: new Date("2025-01-10T10:00:00Z"),
deliveredAt: new Date("2025-01-10T10:01:00Z"),
};
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let selectRows2: Record<string, unknown>[] = [];
let selectRows3: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let selectCallCount = 0;
function resetMock() {
selectRows = [];
selectRows2 = [];
selectRows3 = [];
updatedValues = [];
selectCallCount = 0;
}
function resetAll() {
resetMock();
vi.clearAllMocks();
}
const mockSendMessage = vi.hoisted(() => vi.fn());
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") {
return () => chain;
}
if (prop === "from") {
return (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows;
return makeChainable(rows);
};
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]);
if (tableName === "messages") {
// Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2
if (selectRows3.length > 0) {
return makeChainable(selectRows3);
}
if (selectCallCount === 0 || selectCallCount === 1) {
const rows = selectCallCount === 0 ? selectRows : selectRows2;
selectCallCount++;
return makeChainable(rows);
}
return makeChainable(selectRows);
}
return makeChainable(selectRows);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return { returning: () => [vals] };
},
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
return { returning: () => [{ ...vals, id: "msg-uuid-new" }] };
},
}),
}),
conversations,
messages,
clients,
businessSettings,
eq: vi.fn((a, b) => ({ type: "eq", a, b })),
and: vi.fn((...args) => ({ type: "and", args })),
desc: vi.fn((col) => ({ type: "desc", col })),
lt: vi.fn((a, b) => ({ type: "lt", a, b })),
sql: vi.fn(() => ({ __type: "sql" })),
isNull: vi.fn((col) => ({ type: "isNull", col })),
count: vi.fn((col) => ({ type: "count", col })),
};
});
vi.mock("../services/messaging/outbound.js", () => ({
sendMessage: mockSendMessage,
}));
// ─── App setup ────────────────────────────────────────────────────────────────
const { conversationsRouter } = await import("../routes/conversations.js");
const app = new Hono();
app.use("*", async (c, next) => {
// @ts-expect-error — test-only context injection
c.set("staff", STAFF_ROW);
await next();
});
app.route("/conversations", conversationsRouter);
function jsonRequest(method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetAll());
// ─── GET /conversations ───────────────────────────────────────────────────────
describe("GET /api/conversations", () => {
it("returns conversations sorted by recency with unread count", async () => {
selectRows = [
{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" },
];
selectRows2 = [{ count: "1" }];
const res = await app.request("/conversations");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(1);
expect(body.items[0]!.id).toBe("conv-uuid-1");
expect(body.items[0]!.clientName).toBe("Alice");
});
it("supports cursor-based pagination", async () => {
selectRows = [];
const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1");
expect(res.status).toBe(200);
});
it("enforces max limit of 50", async () => {
selectRows = [];
const res = await app.request("/conversations?limit=200");
expect(res.status).toBe(200);
});
});
// ─── GET /conversations/:id/messages ─────────────────────────────────────────
describe("GET /api/conversations/:id/messages", () => {
it("returns paginated messages and marks conversation as read", async () => {
selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }];
const res = await app.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(2);
expect(body.items[0]!.id).toBe("msg-uuid-1");
expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true);
});
it("returns 404 when conversation belongs to different business", async () => {
selectRows = [];
const res = await app.request("/conversations/conv-uuid-other/messages");
expect(res.status).toBe(404);
});
it("returns 401 when not authenticated", async () => {
const appNoAuth = new Hono();
appNoAuth.route("/conversations", conversationsRouter);
const res = await appNoAuth.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(401);
});
});
// ─── POST /conversations/:id/messages ─────────────────────────────────────────
describe("POST /api/conversations/:id/messages", () => {
beforeEach(() => {
resetMock();
vi.clearAllMocks();
selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }];
selectRows2 = [];
selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }];
updatedValues = [];
});
it("sends via outbound service and returns 201", async () => {
mockSendMessage.mockResolvedValueOnce({
messageId: "msg-uuid-new",
providerMessageId: "provider-msg-1",
status: "queued",
suppressed: false,
});
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello Alice!",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.id).toBe("msg-uuid-new");
});
it("returns 409 when client opted out", async () => {
mockSendMessage.mockResolvedValueOnce({ suppressed: true });
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello",
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/opted out/i);
});
it("returns 404 for cross-tenant conversation", async () => {
selectRows = [];
const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", {
body: "Hello",
});
expect(res.status).toBe(404);
});
it("rejects empty body", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "",
});
expect(res.status).toBe(400);
});
it("rejects body over 1600 chars", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "a".repeat(1601),
});
expect(res.status).toBe(400);
});
});
+158
View File
@@ -40,11 +40,17 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
let selectBusinessSettingsRow: Record<string, unknown> | null = null;
let selectConversationRow: Record<string, unknown> | null = null;
let selectMessageRows: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
updatedValues = [];
selectBusinessSettingsRow = null;
selectConversationRow = null;
selectMessageRows = [];
}
vi.mock("@groombook/db", () => {
@@ -72,6 +78,26 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
return {
getDb: () => ({
select: () => ({
@@ -82,6 +108,15 @@ vi.mock("@groombook/db", () => {
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "businessSettings") {
return makeChainable(selectBusinessSettingsRow ? [selectBusinessSettingsRow] : []);
}
if (table._name === "conversations") {
return makeChainable(selectConversationRow ? [selectConversationRow] : []);
}
if (table._name === "messages") {
return makeChainable(selectMessageRows);
}
return makeChainable([]);
},
}),
@@ -99,11 +134,21 @@ vi.mock("@groombook/db", () => {
}),
}),
}),
insert: () => ({
values: () => ({
returning: () => [],
}),
}),
}),
impersonationSessions,
appointments,
impersonationAuditLogs,
businessSettings,
conversations,
messages,
eq: vi.fn(),
and: vi.fn(),
desc: vi.fn((col: unknown) => ({ _name: "desc", col })),
};
});
@@ -420,4 +465,117 @@ describe("POST /portal/appointments/:id/cancel", () => {
);
expect(res.status).toBe(404);
});
});
// ─── Conversation routes ───────────────────────────────────────────────────────
const BUSINESS_ID = "880e8400-e29b-41d4-a716-446655440008";
const CONVERSATION_ID = "990e8400-e29b-41d4-a716-446655440009";
const CONVERSATION = {
id: CONVERSATION_ID,
clientId: CLIENT_ID,
businessId: BUSINESS_ID,
channel: "sms",
status: "active",
lastMessageAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
const MESSAGE_1 = {
id: "m1",
conversationId: CONVERSATION_ID,
direction: "inbound",
body: "Hello",
status: "delivered",
createdAt: new Date().toISOString(),
deliveredAt: new Date().toISOString(),
};
const MESSAGE_2 = {
id: "m2",
conversationId: CONVERSATION_ID,
direction: "outbound",
body: "Hi there!",
status: "delivered",
createdAt: new Date(Date.now() + 1000).toISOString(),
deliveredAt: new Date().toISOString(),
};
function jsonGet(path: string, headers?: Record<string, string>) {
return app.request(path, { method: "GET", headers });
}
describe("GET /portal/conversation", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns conversation for the authenticated client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(CONVERSATION_ID);
expect(body.channel).toBe("sms");
expect(body.status).toBe("active");
});
it("returns 204 when client A's session has no conversation (cross-tenant isolation)", async () => {
// Cross-tenant isolation is enforced at the query level via portalClientId scoping.
// The mock cannot replicate eq() filtering — this test verifies the query is issued
// and no conversation is returned when the mock has no row for the session's clientId.
// Real DB: eq() on clientId ensures client A never sees client B's conversation.
selectSessionRow = { ...ACTIVE_SESSION, clientId: "client-a" };
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null; // client-a has no conversation
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
});
describe("GET /portal/conversation/messages", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns paginated messages", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_2, MESSAGE_1];
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages).toHaveLength(2);
expect(body.messages[0].id).toBe("m2");
expect(body.messages[1].id).toBe("m1");
expect(body.nextCursor).toBeNull();
});
it("returns messages and nextCursor reflects if more exist", async () => {
// Note: the mock does not enforce limit(), so it returns all messages.
// nextCursor is null when all messages fit (mock behavior).
// Real DB enforces limit and sets nextCursor when messages.length === limit.
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_1, MESSAGE_2];
const res = await jsonGet("/portal/conversation/messages?limit=1", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages.length).toBeGreaterThan(0);
// mock has no limit enforcement, so nextCursor may be null
expect(body).toHaveProperty("nextCursor");
});
});
+28 -10
View File
@@ -8,6 +8,7 @@ import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { waitlistRouter } from "./routes/waitlist.js";
import { conversationsRouter } from "./routes/conversations.js";
import { portalRouter } from "./routes/portal.js";
import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
@@ -19,7 +20,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js";
import { getPresignedGetUrl } from "./lib/s3.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
@@ -29,6 +30,7 @@ import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
const app = new Hono();
@@ -69,6 +71,9 @@ app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Public Telnyx messaging webhook — signature-verified, no auth required
app.route("/api/webhooks/telnyx", telnyxWebhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
@@ -126,20 +131,31 @@ function validateLogoMagicBytes(
}
}
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
app.get("/api/branding/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
// Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
let logoUrl: string | null = null;
if (settings.logoKey) {
try {
logoUrl = await getPresignedGetUrl(settings.logoKey);
} catch {
// If S3 URL generation fails, fall back to legacy base64
}
}
// Return the public proxy path so browser never sees a raw S3 URL
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
// Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields
@@ -202,7 +218,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
@@ -248,6 +264,7 @@ api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
api.route("/appointments", appointmentsRouter);
api.route("/waitlist", waitlistRouter);
api.route("/conversations", conversationsRouter);
api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
@@ -258,6 +275,7 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/conversations", conversationsRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
+6
View File
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
window: 10,
storage: "memory",
customRules: {
"/sign-in/social": { max: 10, window: 60 },
"/sign-in/email": { max: 10, window: 60 },
"/sign-up/email": { max: 5, window: 60 },
"/get-session": false,
},
},
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
window: 10,
storage: "memory",
customRules: {
"/sign-in/social": { max: 10, window: 60 },
"/sign-in/email": { max: 10, window: 60 },
"/sign-up/email": { max: 5, window: 60 },
"/get-session": false,
},
},
+66
View File
@@ -0,0 +1,66 @@
import { eq } from "@groombook/db";
import { bufferTimeRules, services, type Db } from "@groombook/db";
export async function resolveBufferMinutes({
serviceId,
sizeCategory,
coatType,
db,
}: {
serviceId: string;
sizeCategory: string | null;
coatType: string | null;
db: Db;
}): Promise<number> {
// Query all rules for this service in one DB call
const allRules = await db
.select()
.from(bufferTimeRules)
.where(eq(bufferTimeRules.serviceId, serviceId));
// Priority 1: exact match (serviceId + sizeCategory + coatType all match)
const exact = allRules.find(
(r) =>
r.sizeCategory === sizeCategory &&
r.coatType === coatType
);
if (exact) return exact.bufferMinutes;
// Priority 2: service + size, null coatType
const serviceSize = allRules.find(
(r) =>
r.sizeCategory === sizeCategory &&
r.coatType === null
);
if (serviceSize) return serviceSize.bufferMinutes;
// Priority 3: service + coat, null sizeCategory
const serviceCoat = allRules.find(
(r) =>
r.sizeCategory === null &&
r.coatType === coatType
);
if (serviceCoat) return serviceCoat.bufferMinutes;
// Priority 4: service only (null sizeCategory, null coatType)
const serviceOnly = allRules.find(
(r) =>
r.sizeCategory === null &&
r.coatType === null
);
if (serviceOnly) return serviceOnly.bufferMinutes;
// Priority 5: fallback to service.defaultBufferMinutes
const [service] = await db
.select({ defaultBufferMinutes: services.defaultBufferMinutes })
.from(services)
.where(eq(services.id, serviceId))
.limit(1);
if (service?.defaultBufferMinutes != null) {
return service.defaultBufferMinutes;
}
// Priority 6: final fallback to 0
return 0;
}
+19
View File
@@ -68,6 +68,25 @@ export async function deleteObject(key: string): Promise<void> {
);
}
/** Read an object from S3 and return its body buffer and content type. */
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
const chunks: Uint8Array[] = [];
// response.Body is a Readable stream; collect chunks into a buffer
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const contentType = response.ContentType ?? "application/octet-stream";
return { body, contentType };
}
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
export async function putObject(
key: string,
+2 -1
View File
@@ -23,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.path.startsWith("/api/auth/")) {
const path = c.req.path;
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
await next();
return;
}
+274
View File
@@ -0,0 +1,274 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
and,
eq,
desc,
lt,
sql,
getDb,
conversations,
messages,
clients,
businessSettings,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { sendMessage } from "../services/messaging/outbound.js";
export const conversationsRouter = new Hono<AppEnv>();
const sendMessageSchema = z.object({
body: z.string().min(1).max(1600),
});
// GET /api/conversations — List conversations
conversationsRouter.get("/", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "20"), 50);
let baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(eq(conversations.businessId, settings.id))
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
if (cursor) {
const [cursorRow] = await db
.select({ lastMessageAt: conversations.lastMessageAt })
.from(conversations)
.where(eq(conversations.id, cursor))
.limit(1);
if (cursorRow?.lastMessageAt) {
baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(
and(
eq(conversations.businessId, settings.id),
lt(conversations.lastMessageAt, cursorRow.lastMessageAt)
)
)
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
}
}
const rows = await baseQuery;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const items = await Promise.all(
rows.map(async (row) => {
const [unreadRow] = await db
.select({ count: sql<number>`count(*)` })
.from(messages)
.where(
and(
eq(messages.conversationId, row.id),
eq(messages.direction, "inbound"),
sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)`
)
)
.limit(1);
const [lastMsg] = await db
.select({
body: messages.body,
direction: messages.direction,
createdAt: messages.createdAt,
})
.from(messages)
.where(eq(messages.conversationId, row.id))
.orderBy(desc(messages.createdAt))
.limit(1);
return {
id: row.id,
clientId: row.clientId,
clientName: row.clientName,
clientPhone: row.clientPhone,
channel: row.channel,
lastMessageAt: row.lastMessageAt,
status: row.status,
unreadCount: Number(unreadRow?.count ?? 0),
lastMessage: lastMsg ?? null,
};
})
);
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items, nextCursor });
});
// GET /api/conversations/:id/messages — List messages for a conversation
conversationsRouter.get("/:id/messages", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
await db
.update(conversations)
.set({ staffReadAt: new Date() })
.where(eq(conversations.id, conversationId));
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
if (cursor) {
const [cursorRow] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorRow?.createdAt) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(
and(
eq(messages.conversationId, conversationId),
lt(messages.createdAt, cursorRow.createdAt)
)
)
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
}
}
const rows = await query;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items: rows, nextCursor });
});
// POST /api/conversations/:id/messages — Send a message
conversationsRouter.post(
"/:id/messages",
zValidator("json", sendMessageSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const { body } = c.req.valid("json");
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
const result = await sendMessage({
businessId: settings.id,
clientId: conv.clientId,
body,
sentByStaffId: staffRow.id,
});
if (result.suppressed) {
return c.json({ error: "Client has opted out of SMS" }, 409);
}
const [msg] = await db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.id, result.messageId))
.limit(1);
return c.json(msg, 201);
}
);
+95 -6
View File
@@ -14,7 +14,8 @@ import {
clients,
sql,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import type { AppEnv, StaffRole } from "../middleware/rbac.js";
import { requireRole } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
@@ -101,6 +102,8 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
@@ -128,7 +131,17 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
return c.json({ ...invoice, lineItems, tipSplits });
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
});
// Save tip splits for an invoice (replaces existing splits)
@@ -422,7 +435,7 @@ invoicesRouter.patch(
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund } from "../services/payment.js";
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
@@ -449,7 +462,7 @@ invoicesRouter.post(
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
return c.json({ error: "Invoice has no Stripe payment intent" }, 422);
}
return await db.transaction(async (tx) => {
@@ -465,15 +478,91 @@ invoicesRouter.post(
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
const refundId = result.refundId;
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: result.refundId,
stripeRefundId: refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId: result.refundId });
return c.json({ refundId });
});
}
);
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", requireRole("manager" as StaffRole), async (c) => {
try {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod);
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
} catch (err) {
console.error("stats/summary error:", err);
return c.json({
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
});
}
});
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
invoicesRouter.get("/:id/stripe-details", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({
stripePaymentIntentId: invoice.stripePaymentIntentId,
stripeRefundId: invoice.stripeRefundId,
cardLast4,
paymentStatus,
});
});
+97 -8
View File
@@ -1,8 +1,8 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { and, eq, inArray, desc, lt } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems, businessSettings, conversations, messages } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
@@ -102,7 +102,6 @@ portalRouter.get("/appointments", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
@@ -142,10 +141,7 @@ portalRouter.get("/appointments", async (c) => {
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
return c.json({ appointments: appts });
});
portalRouter.get("/pets", async (c) => {
@@ -153,7 +149,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
});
portalRouter.get("/invoices", async (c) => {
@@ -179,6 +175,99 @@ portalRouter.get("/invoices", async (c) => {
})));
});
// ─── Conversation routes ──────────────────────────────────────────────────────
portalRouter.get("/conversation", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const [settings] = await db.select({ id: businessSettings.id }).from(businessSettings).limit(1);
if (!settings) return c.json({ error: "Business not configured" }, 500);
const businessId = settings.id;
const [conversation] = await db
.select({
id: conversations.id,
channel: conversations.channel,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
createdAt: conversations.createdAt,
})
.from(conversations)
.where(and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)))
.limit(1);
if (!conversation) {
return c.body(null, 204);
}
return c.json(conversation);
});
portalRouter.get("/conversation/messages", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db.select({ id: businessSettings.id }).from(businessSettings).limit(1);
if (!settings) return c.json({ error: "Business not configured" }, 500);
const businessId = settings.id;
const [conversation] = await db
.select({ id: conversations.id })
.from(conversations)
.where(and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)))
.limit(1);
if (!conversation) {
return c.body(null, 204);
}
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversation.id))
.orderBy(desc(messages.createdAt))
.limit(limit);
if (cursor) {
const [cursorMsg] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorMsg) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(and(eq(messages.conversationId, conversation.id), lt(messages.createdAt, cursorMsg.createdAt)))
.orderBy(desc(messages.createdAt))
.limit(limit);
}
}
const messagesResult = await query;
const nextCursor = messagesResult.length === limit ? messagesResult[messagesResult.length - 1]!.id : null;
return c.json({ messages: messagesResult, nextCursor });
});
// ─── Appointment action routes ────────────────────────────────────────────────
const customerNotesSchema = z.object({
+11 -4
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db";
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
@@ -215,7 +215,8 @@ settingsRouter.post(
/**
* GET /api/admin/settings/logo
* Returns a presigned GET URL for the logo.
* Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type.
*/
settingsRouter.get("/logo", async (c) => {
const db = getDb();
@@ -224,8 +225,14 @@ settingsRouter.get("/logo", async (c) => {
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const url = await getPresignedGetUrl(row.logoKey);
return c.json({ url, logoKey: row.logoKey });
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
/**
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const entry = rateLimitMap.get(ip);
const now = Date.now();
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
+59
View File
@@ -0,0 +1,59 @@
import { Hono } from "hono";
import { validateTelnyxSignature } from "../../services/sms.js";
import {
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../../services/messaging/inbound.js";
export const telnyxWebhooksRouter = new Hono();
telnyxWebhooksRouter.post("/messaging", async (c) => {
const signature = c.req.header("telnyx-signature");
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
if (!validateTelnyxSignature(rawBody, signature)) {
return c.json({ error: "Invalid signature" }, 401);
}
let payload: TelnyxMessageReceivedPayload;
try {
payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload;
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const eventType = payload.data?.event_type;
if (!eventType) {
return c.json({ error: "Missing event_type" }, 400);
}
if (eventType === "message.received") {
try {
await handleMessageReceived(payload);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
if (msg.startsWith("No business owns")) {
return c.json({ error: "Unknown messaging number" }, 404);
}
return c.json({ error: msg }, 500);
}
return c.json({ received: true });
}
if (eventType === "message.finalized") {
const result = await handleMessageFinalized(payload);
if (result) {
return c.json({ received: true, messageId: result.messageId, status: result.newStatus });
}
return c.json({ received: true, messageId: null });
}
return c.json({ received: true });
});
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
findOrCreateConversation,
upsertMessage,
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../inbound.js";
import * as schema from "@groombook/db";
vi.mock("@groombook/db", () => ({
getDb: vi.fn(),
conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null },
messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null },
businessSettings: { id: "", messagingPhoneNumber: "" },
clients: { id: "", name: "", email: "", phone: "", status: "" },
eq: vi.fn(),
and: vi.fn(),
sql: vi.fn(),
}));
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnThis(),
};
vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType<typeof schema.getDb>);
const makePayload = (
eventType: "message.received" | "message.sent" | "message.finalized",
messageId: string,
fromPhone: string,
toPhone: string,
body = "Hello"
): TelnyxMessageReceivedPayload => ({
data: {
id: "evt-1",
event_type: eventType,
payload: {
message: {
id: messageId,
from: { phone: fromPhone, carrier: "carrier" },
to: [{ phone: toPhone }],
body,
},
},
},
});
describe("signature validation via route", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns 401 when telnyx-signature header is missing", async () => {
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
const req = new Request("http://localhost/messaging", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
const res = await telnyxWebhooksRouter.fetch(req);
expect(res.status).toBe(401);
});
it("returns 401 when signature does not match", async () => {
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
const req = new Request("http://localhost/messaging", {
method: "POST",
headers: {
"Content-Type": "application/json",
"telnyx-signature": "sha256=bad",
},
body: payload,
});
const res = await telnyxWebhooksRouter.fetch(req);
expect(res.status).toBe(401);
});
});
describe("findOrCreateConversation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
it("returns existing conversation when found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]),
}),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-1");
});
it("creates new conversation when none exists", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-2");
});
it("creates placeholder client for unknown phone then creates conversation", async () => {
mockDb.select
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-3");
expect(result.clientId).toBe("client-3");
});
});
describe("upsertMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns isNew=false when message with providerMessageId already exists", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]),
}),
}),
});
const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received");
expect(result.isNew).toBe(false);
expect(result.id).toBe("msg-existing");
});
it("inserts new message and returns isNew=true", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
}),
});
const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued");
expect(result.isNew).toBe(true);
expect(result.id).toBe("msg-new");
});
});
describe("handleMessageReceived", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}));
});
it("returns 404 when no business owns the to number", async () => {
const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000");
await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number");
});
it("creates conversation and message for valid inbound", async () => {
mockDb.select
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "biz-1" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "client-new" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]),
}),
});
mockDb.update.mockReturnValueOnce({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({}),
}),
});
mockDb.insert.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
}),
});
const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message");
const result = await handleMessageReceived(payload);
expect(result.messageId).toBe("msg-new");
});
});
describe("handleMessageFinalized", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
it("returns null when message not found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222");
const result = await handleMessageFinalized(payload);
expect(result).toBeNull();
});
it("updates status to delivered for finalized inbound", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]),
}),
}),
});
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-1" }]),
}),
}),
});
const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222");
const result = await handleMessageFinalized(payload);
expect(result?.newStatus).toBe("delivered");
});
});
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSendSms = vi.fn();
const mockGetDb = vi.fn();
const mockUuidv4 = vi.fn();
vi.mock("../../sms.js", () => ({
sendSms: mockSendSms,
}));
vi.mock("@groombook/db", () => ({
getDb: () => mockGetDb(),
conversations: {},
messages: {},
clients: {},
businessSettings: {},
eq: vi.fn((a, b) => [a, b]),
and: vi.fn((...args) => args),
}));
vi.mock("uuid", () => ({
v4: () => mockUuidv4(),
}));
const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.js");
describe("sendMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUuidv4.mockReturnValue("test-uuid");
});
function buildSelectMock(results: unknown[]) {
return vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(results),
}),
}),
});
}
it("returns suppressed=true when client has no phone", async () => {
mockGetDb.mockReturnValue({
select: buildSelectMock([{ phone: null, smsOptIn: true }]),
});
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({ suppressed: true });
expect(mockSendSms).not.toHaveBeenCalled();
});
it("returns suppressed=true when client has opted out of SMS", async () => {
mockGetDb.mockReturnValue({
select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]),
});
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({ suppressed: true });
expect(mockSendSms).not.toHaveBeenCalled();
});
it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => {
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]),
}),
}),
}),
});
await expect(
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
).rejects.toThrow(MissingTenantPhoneNumberError);
});
it("persists provider message id on success", async () => {
const messageId = "msg-1";
const conversationId = "conv-1";
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conversationId }]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
});
mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" });
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({
messageId,
providerMessageId: "provider-msg-1",
status: "sent",
suppressed: false,
});
expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined);
});
it("persists error on Telnyx failure", async () => {
const messageId = "msg-1";
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
});
mockSendSms.mockRejectedValue(new Error("Telnyx API error"));
await expect(
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
).rejects.toThrow("Telnyx API error");
});
});
+200
View File
@@ -0,0 +1,200 @@
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
export interface TelnyxMessageReceivedPayload {
data: {
id: string;
event_type: "message.received" | "message.sent" | "message.finalized";
payload: {
message: {
id: string;
from: { phone: string; carrier?: string };
to: { phone: string }[];
body: string;
media?: Array<{ type: string; url: string }>;
};
recording?: unknown;
leg_count?: number;
};
};
}
export async function findOrCreateConversation(
businessId: string,
clientPhone: string,
businessNumber: string
): Promise<{ id: string; clientId: string }> {
const db = getDb();
const [existing] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(
and(
eq(conversations.businessId, businessId),
eq(conversations.externalNumber, clientPhone),
eq(conversations.businessNumber, businessNumber)
)
)
.limit(1);
if (existing) {
return { id: existing.id, clientId: existing.clientId };
}
const [existingClient] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.phone, clientPhone))
.limit(1);
const clientId = existingClient?.id ?? uuidv4();
if (!existingClient) {
await db.insert(clients).values({
id: clientId,
name: clientPhone,
email: `sms-${uuidv4()}@placeholder.local`,
phone: clientPhone,
status: "active",
});
}
const [created] = await db
.insert(conversations)
.values({
id: crypto.randomUUID(),
businessId,
clientId,
channel: "sms",
externalNumber: clientPhone,
businessNumber,
lastMessageAt: new Date(),
status: "active",
})
.returning({ id: conversations.id, clientId: conversations.clientId });
if (!created) throw new Error("Failed to create conversation");
return { id: created.id, clientId: created.clientId };
}
export async function upsertMessage(
providerMessageId: string,
conversationId: string,
direction: "inbound" | "outbound",
body: string,
status: "queued" | "sent" | "delivered" | "failed" | "received",
sentByStaffId?: string
): Promise<{ id: string; isNew: boolean }> {
const db = getDb();
const [existing] = await db
.select({ id: messages.id })
.from(messages)
.where(eq(messages.providerMessageId, providerMessageId))
.limit(1);
if (existing) {
return { id: existing.id, isNew: false };
}
try {
const [inserted] = await db
.insert(messages)
.values({
id: crypto.randomUUID(),
conversationId,
direction,
body,
status,
providerMessageId,
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
if (!inserted) throw new Error("Failed to insert message");
return { id: inserted.id, isNew: true };
} catch (err) {
if (err instanceof Error && err.message.includes("unique")) {
const [existing] = await db
.select({ id: messages.id })
.from(messages)
.where(eq(messages.providerMessageId, providerMessageId))
.limit(1);
if (existing) return { id: existing.id, isNew: false };
}
throw err;
}
}
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
const db = getDb();
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.where(eq(businessSettings.messagingPhoneNumber, toNumber))
.limit(1);
return settings?.id ?? null;
}
export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> {
const { message } = payload.data.payload;
const fromPhone = message.from.phone;
const toPhone = message.to[0]?.phone;
if (!toPhone) {
throw new Error("No recipient phone in payload");
}
const businessId = await resolveBusinessIdByMessagingNumber(toPhone);
if (!businessId) {
throw new Error(`No business owns messaging number: ${toPhone}`);
}
const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
await getDb()
.update(conversations)
.set({ lastMessageAt: new Date(), updatedAt: new Date() })
.where(eq(conversations.id, conversationId));
const { id: messageId } = await upsertMessage(
message.id,
conversationId,
"inbound",
message.body,
"received"
);
return { conversationId, messageId };
}
export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> {
const { message } = payload.data.payload;
if (!message.id) return null;
const db = getDb();
const [existing] = await db
.select({ id: messages.id, status: messages.status })
.from(messages)
.where(eq(messages.providerMessageId, message.id))
.limit(1);
if (!existing) return null;
let newStatus = existing.status;
if (payload.data.event_type === "message.finalized") {
newStatus = "delivered";
}
if (newStatus !== existing.status) {
await db
.update(messages)
.set({ status: newStatus, deliveredAt: new Date() })
.where(eq(messages.id, existing.id));
}
return { messageId: existing.id, newStatus };
}
+159
View File
@@ -0,0 +1,159 @@
import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { sendSms } from "../sms.js";
export interface SendMessageOptions {
businessId: string;
clientId: string;
body: string;
sentByStaffId?: string;
mediaUrls?: string[];
}
export interface SendMessageResult {
messageId: string;
providerMessageId: string;
status: string;
suppressed: false;
}
export interface SendMessageSuppressed {
suppressed: true;
}
export type SendMessageResponse = SendMessageResult | SendMessageSuppressed;
export class MissingTenantPhoneNumberError extends Error {
constructor() {
super("Tenant messagingPhoneNumber is not configured");
this.name = "MissingTenantPhoneNumberError";
}
}
async function findOrCreateConversation(
businessId: string,
clientId: string,
externalNumber: string,
businessNumber: string
): Promise<{ id: string }> {
const db = getDb();
const [existing] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(
eq(conversations.businessId, businessId),
eq(conversations.externalNumber, externalNumber),
eq(conversations.businessNumber, businessNumber)
)
)
.limit(1);
if (existing) return { id: existing.id };
const [created] = await db
.insert(conversations)
.values({
id: uuidv4(),
businessId,
clientId,
channel: "sms",
externalNumber,
businessNumber,
lastMessageAt: new Date(),
status: "active",
})
.returning({ id: conversations.id });
if (!created) throw new Error("Failed to create conversation");
return { id: created.id };
}
async function resolveFromNumber(businessId: string): Promise<string | null> {
const db = getDb();
const [settings] = await db
.select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber })
.from(businessSettings)
.where(eq(businessSettings.id, businessId))
.limit(1);
return settings?.messagingPhoneNumber ?? null;
}
export async function sendMessage(opts: SendMessageOptions): Promise<SendMessageResponse> {
const db = getDb();
const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts;
const [client] = await db
.select({ phone: clients.phone, smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (!client?.phone) {
return { suppressed: true };
}
if (!client.smsOptIn) {
return { suppressed: true };
}
const from = await resolveFromNumber(businessId);
if (!from) throw new MissingTenantPhoneNumberError();
const to = client.phone;
const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id;
const [queuedMessage] = await db
.insert(messages)
.values({
id: uuidv4(),
conversationId,
direction: "outbound",
body,
status: "queued",
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
if (!queuedMessage) throw new Error("Failed to insert queued message");
try {
const result = await sendSms(to, body, mediaUrls);
await db
.update(messages)
.set({
status: "sent",
providerMessageId: result.messageId,
})
.where(eq(messages.id, queuedMessage.id));
await db
.update(conversations)
.set({ lastMessageAt: new Date() })
.where(eq(conversations.id, conversationId));
return {
messageId: queuedMessage.id,
providerMessageId: result.messageId,
status: result.status,
suppressed: false,
};
} catch (err) {
const errorCode = err instanceof Error ? err.name : "UNKNOWN";
const errorMessage = err instanceof Error ? err.message : String(err);
await db
.update(messages)
.set({
status: "failed",
errorCode,
errorMessage,
})
.where(eq(messages.id, queuedMessage.id));
throw err;
}
}
+16
View File
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
return { clientSecret: setupIntent.client_secret! };
}
export async function getPaymentIntentDetails(
paymentIntentId: string
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
const cardLast4 = pi.payment_method
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
: null;
return {
cardLast4,
paymentStatus: pi.status ?? null,
};
}
+30 -27
View File
@@ -32,6 +32,35 @@ function isE164(phone: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(phone);
}
export function validateTelnyxSignature(
rawBody: string,
signature: string | undefined | null
): boolean {
if (!signature) return false;
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
const sigByte = sigBuf[i] ?? 0;
const expByte = expBuf[i] ?? 0;
diff |= sigByte ^ expByte;
}
return diff === 0;
} catch {
return false;
}
}
export async function sendSms(
to: string,
body: string,
@@ -74,33 +103,7 @@ export class TelnyxProvider implements SmsProvider {
}
validateWebhookSignature(req: Request): boolean {
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
const signature = req.headers.get("telnyx-signature");
if (!signature) return false;
const payload = JSON.stringify(req.body);
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
const sigByte = sigBuf[i] ?? 0;
const expByte = expBuf[i] ?? 0;
diff |= sigByte ^ expByte;
}
return diff === 0;
} catch {
return false;
}
return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature"));
}
}
+10
View File
@@ -44,6 +44,16 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
});
}
if (url.includes("/api/invoices/stats/summary")) {
return route.fulfill({
json: {
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
},
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
+9 -3
View File
@@ -72,9 +72,15 @@ test.describe("Portal Data Integrity", () => {
});
test("billing section renders without JS errors", async ({ page }) => {
// Mock billing endpoint
await page.route("**/api/billing**", (route) =>
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
// Mock portal billing endpoints
await page.route("**/api/portal/config**", (route) =>
route.fulfill({ json: { stripePublishableKey: "" } })
);
await page.route("**/api/portal/invoices**", (route) =>
route.fulfill({ json: [] })
);
await page.route("**/api/portal/payment-methods**", (route) =>
route.fulfill({ json: [] })
);
const consoleErrors: string[] = [];
+1 -1
View File
@@ -1 +1 @@
VITE_API_URL=
VITE_API_URL=https://uat.groombook.dev
+2
View File
@@ -11,6 +11,8 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
ARG VITE_API_URL=
ENV VITE_API_URL=
COPY packages/types/ packages/types/
COPY apps/web/ apps/web/
RUN pnpm --filter @groombook/web build
+7 -1
View File
@@ -4,6 +4,7 @@ import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
import { ServicesPage } from "./pages/Services.js";
import { MessagesPage } from "./pages/Messages.js";
import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
@@ -39,7 +40,10 @@ function LoginPage() {
const handleSocialLogin = async (provider: string) => {
setIsLoading(true);
setError(null);
const result = await signIn.social({ provider, callbackURL: window.location.origin });
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
// after the OAuth callback completes, rather than back to /login
const callbackURL = `${window.location.origin}/admin`;
const result = await signIn.social({ provider, callbackURL });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
@@ -170,6 +174,7 @@ function LoginPage() {
const NAV_LINKS = [
{ to: "/admin", label: "Appointments" },
{ to: "/admin/messages", label: "Messages" },
{ to: "/admin/clients", label: "Clients" },
{ to: "/admin/services", label: "Services" },
{ to: "/admin/staff", label: "Staff" },
@@ -296,6 +301,7 @@ function AdminLayout() {
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
<Route path="/services" element={<ServicesPage />} />
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MessagesPage } from "../pages/Messages.js";
const mockConversations = [
{
id: "conv-1",
clientId: "client-1",
clientName: "Alice Smith",
channel: "sms",
clientPhone: "+1234567890",
lastMessageAt: "2026-05-14T10:00:00Z",
lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" },
unreadCount: 2,
status: "active",
},
{
id: "conv-2",
clientId: "client-2",
clientName: "Bob Jones",
channel: "sms",
clientPhone: "+1987654321",
lastMessageAt: "2026-05-13T08:00:00Z",
lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" },
unreadCount: 0,
status: "active",
},
];
const mockMessages = [
{
id: "msg-1",
direction: "inbound" as const,
body: "Hello, is my dog ready?",
status: "delivered",
createdAt: "2026-05-14T10:00:00Z",
sentByStaffId: null,
},
{
id: "msg-2",
direction: "outbound" as const,
body: "Yes, she is all done!",
status: "delivered",
createdAt: "2026-05-14T10:05:00Z",
sentByStaffId: "staff-1",
},
];
const makeResponse = (data: unknown): Response => {
return {
ok: true,
json: () => Promise.resolve(data),
} as Response;
};
const makeResponseWithStatus = (data: unknown, status: number): Response => {
return {
ok: true,
status,
json: () => Promise.resolve(data),
} as Response;
};
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("MessagesPage", () => {
it("renders empty state when no conversations", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
expect(screen.getByText("No conversations yet")).toBeInTheDocument();
});
});
it("renders conversation list", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
expect(screen.getByText("Alice Smith")).toBeInTheDocument();
expect(screen.getByText("Bob Jones")).toBeInTheDocument();
});
const unreadBadges = screen.getAllByText("2");
expect(unreadBadges).toHaveLength(1);
});
it("loads and displays messages when thread is selected", async () => {
vi.mocked(global.fetch).mockImplementation((input) => {
const url = String(input);
if (url === "/api/conversations?limit=20") {
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
}
if (url === "/api/conversations/conv-1/messages?limit=50") {
return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null }));
}
return Promise.resolve(makeResponseWithStatus(null, 404));
});
render(<MessagesPage />);
await waitFor(() => screen.getByText("Alice Smith"));
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => {
expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument();
});
});
it("sends a message on form submit", async () => {
let capturedBody: unknown = null;
vi.mocked(global.fetch).mockImplementation((input, init) => {
const url = String(input);
if (url.includes("/messages") && init?.method === "POST") {
capturedBody = init?.body;
return Promise.resolve(makeResponseWithStatus({
id: "msg-new",
direction: "outbound",
body: "Test message",
status: "queued",
createdAt: new Date().toISOString(),
sentByStaffId: "staff-1",
}, 201));
}
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
});
render(<MessagesPage />);
await waitFor(() => screen.getByText("Alice Smith"));
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => screen.getByPlaceholderText("Type a message…"));
fireEvent.change(screen.getByPlaceholderText("Type a message…"), {
target: { value: "Test message" },
});
fireEvent.click(screen.getByText("Send"));
await waitFor(() => {
expect(capturedBody).toBe('{"body":"Test message"}');
});
});
});
+10
View File
@@ -82,3 +82,13 @@ input:focus, select:focus, textarea:focus {
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* ─── Scrollbar hide utility ─── */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
baseURL: import.meta.env.VITE_API_URL || window.location.origin,
});
export const { signIn, signOut, useSession, changePassword } = authClient;
+26
View File
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const weekEnd = addDays(weekStart, 6);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => {
const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString();
@@ -314,6 +322,24 @@ export function AppointmentsPage() {
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
+148 -3
View File
@@ -173,6 +173,21 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmount, setRefundAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null);
const [refunding, setRefunding] = useState(false);
// Fetch current staff role to determine manager access
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
useEffect(() => {
fetch("/api/staff/me")
.then((r) => r.json())
.then((d) => setStaffMe(d))
.catch(() => setStaffMe(null));
}, []);
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
// Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId
@@ -335,6 +350,19 @@ function InvoiceDetailModal({
/>
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && (
<>
{invoice.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
)}
{invoice.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
)}
{invoice.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
)}
</div>
{/* ── Tip Distribution ── */}
@@ -452,11 +480,92 @@ function InvoiceDetailModal({
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button onClick={onClose} style={btnStyle}>Close</button>
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
{invoice.stripeRefundId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
</div>
)}
</Modal>
{showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
Full refund
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="Amount ($)"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }}
/>
</div>
)}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={async () => {
setRefunding(true);
setRefundError(null);
try {
if (refundType === "partial") {
const parsed = parseFloat(refundAmount);
if (isNaN(parsed) || parsed <= 0) {
setRefundError("Please enter a valid amount greater than zero.");
setRefunding(false);
return;
}
}
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setRefundError(e instanceof Error ? e.message : "Refund failed");
} finally {
setRefunding(false);
}
}}
disabled={refunding}
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
>
{refunding ? "Processing…" : "Process Refund"}
</button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div>
</div>
)}
</Modal>
);
}
@@ -497,9 +606,17 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const LIMIT = 50;
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
async function loadInvoices(newOffset: number) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter);
@@ -578,6 +695,34 @@ export function InvoicesPage() {
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
</div>
{paymentStats.methodBreakdown.length > 0 && (
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
<div style={{ fontSize: 13, color: "#64748b" }}>
{paymentStats.methodBreakdown.map((b) => (
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
))}
</div>
</div>
)}
</div>
)}
{invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment.
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useState, useRef } from "react";
interface Conversation {
id: string;
clientId: string;
clientName: string;
channel: string;
clientPhone: string;
lastMessageAt: string | null;
unreadCount: number;
status: string;
lastMessage: { body: string | null; direction: string; createdAt: string } | null;
}
interface Message {
id: string;
direction: "inbound" | "outbound";
body: string | null;
status: string;
createdAt: string;
sentByStaffId: string | null;
}
function relativeTime(dateStr: string | null): string {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function truncate(text: string | null, max: number): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "…" : text;
}
export function MessagesPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [messageError, setMessageError] = useState<string | null>(null);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
async function loadConversations() {
try {
const res = await fetch("/api/conversations?limit=20");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const data = json.items as Conversation[];
setConversations(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load conversations");
}
}
async function loadMessages(conversationId: string) {
setMessagesLoading(true);
setMessageError(null);
try {
const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setMessages((json.items as Message[]).reverse());
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to load messages");
} finally {
setMessagesLoading(false);
}
}
useEffect(() => {
loadConversations().finally(() => setLoading(false));
const interval = setInterval(loadConversations, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (selectedId) {
loadMessages(selectedId);
} else {
setMessages([]);
}
}, [selectedId]);
useEffect(() => {
if (messages.length > 0) {
messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
}
}, [messages]);
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!selectedId || !body.trim() || sending) return;
setSending(true);
setMessageError(null);
const optimistic: Message = {
id: `temp-${Date.now()}`,
direction: "outbound",
body: body.trim(),
status: "queued",
createdAt: new Date().toISOString(),
sentByStaffId: null,
};
setMessages((prev) => [...prev, optimistic]);
const currentBody = body;
setBody("");
try {
const res = await fetch(`/api/conversations/${selectedId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: currentBody }),
});
if (res.status === 409) {
const data = (await res.json()) as { error?: string };
setMessageError(data.error ?? "Client has opted out of SMS");
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
return;
}
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
const sent = (await res.json()) as Message;
setMessages((prev) => prev.map((m) => (m.id === optimistic.id ? sent : m)));
loadConversations();
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to send message");
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
} finally {
setSending(false);
}
}
return (
<div style={{ display: "flex", height: "calc(100vh - 90px)", fontFamily: "system-ui, sans-serif" }}>
{/* Thread list */}
<div style={{ width: 320, borderRight: "1px solid #e5e7eb", overflowY: "auto", background: "#fff" }}>
<div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid #f3f4f6", fontWeight: 600, fontSize: 14, color: "#374151" }}>
Conversations
</div>
{loading ? (
<p style={{ padding: "1rem", color: "#6b7280", fontSize: 13 }}>Loading</p>
) : error ? (
<p style={{ padding: "1rem", color: "#ef4444", fontSize: 13 }}>{error}</p>
) : conversations.length === 0 ? (
<p style={{ padding: "1rem", color: "#6b7280", fontSize: 13 }}>No conversations yet</p>
) : (
conversations.map((conv) => (
<div
key={conv.id}
onClick={() => setSelectedId(conv.id)}
style={{
padding: "0.75rem 1rem",
borderBottom: "1px solid #f3f4f6",
cursor: "pointer",
background: selectedId === conv.id ? "#ecfdf5" : "transparent",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<span style={{ fontWeight: 500, fontSize: 14, color: "#1a202c" }}>{conv.clientName}</span>
{conv.unreadCount > 0 && (
<span style={{ background: "#10b981", color: "#fff", borderRadius: 10, padding: "1px 6px", fontSize: 11, fontWeight: 600 }}>
{conv.unreadCount}
</span>
)}
</div>
<div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}>
{truncate(conv.lastMessage?.body ?? null, 60)}
</div>
<div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}>
{relativeTime(conv.lastMessageAt)}
</div>
</div>
))
)}
</div>
{/* Conversation view */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#f9fafb" }}>
{!selectedId ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#9ca3af" }}>
Select a conversation
</div>
) : messagesLoading ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#6b7280" }}>
Loading messages
</div>
) : (
<>
<div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.direction === "outbound" ? "flex-end" : "flex-start",
marginBottom: "0.75rem",
}}
>
<div
style={{
maxWidth: "70%",
padding: "0.5rem 0.75rem",
borderRadius: 12,
background: msg.direction === "outbound" ? "var(--color-primary, #4f8a6f)" : "#fff",
color: msg.direction === "outbound" ? "#fff" : "#1a202c",
border: msg.direction === "inbound" ? "1px solid #e5e7eb" : "none",
fontSize: 14,
lineHeight: 1.5,
}}
>
{msg.body}
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
{new Date(msg.createdAt).toLocaleString()}
</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
{messageError && (
<div style={{ margin: "0 1rem 0.5rem", padding: "0.5rem 0.75rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, color: "#991b1b", fontSize: 13 }}>
{messageError}
</div>
)}
<form onSubmit={handleSend} style={{ display: "flex", gap: "0.5rem", padding: "0.75rem 1rem", borderTop: "1px solid #e5e7eb", background: "#fff" }}>
<input
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Type a message…"
disabled={sending}
style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
<button
type="submit"
disabled={sending || !body.trim()}
style={{
padding: "0.5rem 1rem",
background: "var(--color-primary, #4f8a6f)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 500,
cursor: sending ? "wait" : "pointer",
opacity: sending ? 0.7 : 1,
}}
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</>
)}
</div>
</div>
);
}
+4 -22
View File
@@ -89,24 +89,14 @@ export function SettingsPage() {
fetch("/api/admin/settings")
.then((r) => r.json())
.then(async (data) => {
let logoUrl: string | null = null;
if (data.logoKey) {
try {
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
logoUrl = logoData.url;
}
} catch {
// ignore
}
}
// The logo is now proxied through the API server so the browser
// never receives an S3 URL — use the proxy path directly as the src.
setForm({
businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null,
logoUrl,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null,
});
@@ -172,15 +162,7 @@ export function SettingsPage() {
throw new Error(err?.error ?? "Failed to upload logo");
}
const { logoKey } = await uploadRes.json();
// Fetch the presigned GET URL for display
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
setForm((f) => ({ ...f, logoKey, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
} else {
setForm((f) => ({ ...f, logoKey, logoUrl: null, logoBase64: null, logoMimeType: null }));
}
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo uploaded." });
refresh();
} catch (err: unknown) {
+2 -2
View File
@@ -170,7 +170,7 @@ export function CustomerPortal() {
case "billing":
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
case "messages":
return <Communication readOnly={!!isReadOnly} />;
return <Communication readOnly={!!isReadOnly} sessionId={sessionId} />;
case "settings":
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
}
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)}
{/* Main Content */}
<main className="flex-1 min-h-screen overflow-x-hidden">
<main className="flex-1 min-h-screen overflow-hidden">
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
<div>
<h1 className="text-lg font-semibold text-stone-800">
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
@@ -0,0 +1,159 @@
export interface Conversation {
id: string;
channel: string;
lastMessageAt: string | null;
status: string;
createdAt: string;
}
export interface Message {
id: string;
direction: "inbound" | "outbound";
body: string | null;
status: string;
createdAt: string;
deliveredAt: string | null;
}
export interface MessagesResponse {
messages: Message[];
nextCursor: string | null;
}
export async function fetchConversation(sessionId: string): Promise<Conversation | null> {
const res = await fetch("/api/portal/conversation", {
headers: { "X-Impersonation-Session-Id": sessionId },
});
if (res.status === 204) return null;
if (!res.ok) throw new Error("Failed to fetch conversation");
return res.json();
}
export async function fetchMessages(
sessionId: string,
cursor?: string,
limit?: number
): Promise<MessagesResponse> {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
if (limit) params.set("limit", String(limit));
const query = params.toString();
const res = await fetch(`/api/portal/conversation/messages${query ? `?${query}` : ""}`, {
headers: { "X-Impersonation-Session-Id": sessionId },
});
if (res.status === 204) return { messages: [], nextCursor: null };
if (!res.ok) throw new Error("Failed to fetch messages");
return res.json();
}
import { useState, useEffect } from "react";
export function useConversation(sessionId: string | null): {
conversation: Conversation | null;
loading: boolean;
error: string | null;
} {
const [conversation, setConversation] = useState<Conversation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sessionId) {
setLoading(false);
setConversation(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
fetchConversation(sessionId)
.then((conv) => {
if (!cancelled) {
setConversation(conv);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
return { conversation, loading, error };
}
export function useMessages(sessionId: string | null): {
messages: Message[];
loading: boolean;
error: string | null;
loadMore: () => void;
hasMore: boolean;
} {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
useEffect(() => {
if (!sessionId) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setMessages([]);
setCursor(undefined);
setHasMore(false);
fetchMessages(sessionId)
.then((res) => {
if (!cancelled) {
setMessages(res.messages);
setCursor(res.nextCursor ?? undefined);
setHasMore(res.nextCursor !== null);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
const loadMore = () => {
if (loadingMore || !hasMore || !sessionId) return;
setLoadingMore(true);
fetchMessages(sessionId, cursor)
.then((res) => {
setMessages((prev) => [...prev, ...res.messages]);
setCursor(res.nextCursor ?? undefined);
setHasMore(res.nextCursor !== null);
setLoadingMore(false);
})
.catch(() => {
setLoadingMore(false);
});
};
return { messages, loading, error, loadMore, hasMore };
}
+105 -70
View File
@@ -1,14 +1,7 @@
import { useState, useEffect } from "react";
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
interface Message {
id: string;
sender: "customer" | "business";
senderName: string;
text: string;
timestamp: string;
read: boolean;
}
import { Bell, Mail, Smartphone } from "lucide-react";
import { useConversation, useMessages } from "./Communication.api.js";
import type { Message as ApiMessage } from "./Communication.api.js";
interface NotificationCategory {
email: boolean;
@@ -25,10 +18,11 @@ interface NotificationPreferences {
}
interface Props {
sessionId: string | null;
readOnly: boolean;
}
export function Communication({ readOnly }: Props) {
export function Communication({ sessionId, readOnly }: Props) {
const [tab, setTab] = useState<"messages" | "notifications">("messages");
return (
@@ -53,17 +47,23 @@ export function Communication({ readOnly }: Props) {
</button>
</div>
{tab === "messages" && <MessageThread readOnly={readOnly} />}
{tab === "messages" && <MessageThread sessionId={sessionId} readOnly={readOnly} />}
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
</div>
);
}
function MessageThread({ readOnly }: { readOnly: boolean }) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState("");
interface MessageThreadProps {
sessionId: string | null;
readOnly: boolean;
}
function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
const [businessName, setBusinessName] = useState<string>("Business");
const { conversation, loading: convLoading, error: convError } = useConversation(sessionId);
const { messages, loading: msgLoading, error: msgError, loadMore, hasMore } = useMessages(sessionId);
useEffect(() => {
async function fetchBranding() {
try {
@@ -79,19 +79,57 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
fetchBranding();
}, []);
const handleSend = () => {
if (!newMessage.trim() || readOnly) return;
const msg: Message = {
id: `m-${Date.now()}`,
sender: "customer",
senderName: "You",
text: newMessage.trim(),
timestamp: new Date().toISOString(),
read: false,
};
setMessages([...messages, msg]);
setNewMessage("");
};
const loading = convLoading || msgLoading;
const error = convError || msgError;
if (loading) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50 flex items-center justify-center">
<div className="animate-pulse text-stone-400 text-sm">Loading messages...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{businessName}</p>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-red-500 text-sm">{error}</p>
</div>
</div>
);
}
if (!conversation) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{businessName}</p>
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-8">
<div className="w-12 h-12 rounded-full bg-stone-100 flex items-center justify-center">
<Mail size={20} className="text-stone-400" />
</div>
<p className="text-stone-500 text-sm text-center">No conversation yet</p>
<p className="text-stone-400 text-xs text-center">Messages with {businessName} will appear here once you start texting.</p>
</div>
<div className="border-t border-stone-200 p-3 flex gap-2">
<div
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm text-stone-400 bg-stone-50 flex items-center justify-center gap-2"
title="Reply from your phone"
>
Reply from your phone
</div>
</div>
</div>
);
}
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
@@ -104,49 +142,46 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
{messages.length === 0 ? (
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
) : (
messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
messages.map((msg: ApiMessage) => {
const sender = msg.direction === "inbound" ? "customer" : "business";
return (
<div key={msg.id} className={`flex ${sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
{msg.body && <p className="text-sm">{msg.body}</p>}
<div className={`flex items-center gap-1 mt-1 ${sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
</div>
</div>
</div>
</div>
))
);
})
)}
{hasMore && (
<div className="flex justify-center">
<button
onClick={loadMore}
className="text-sm text-(--color-accent) hover:underline"
>
Load more
</button>
</div>
)}
</div>
{!readOnly && (
<div className="border-t border-stone-200 p-3 flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)/30 focus:border-(--color-accent)"
/>
<button
onClick={handleSend}
disabled={!newMessage.trim()}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Send size={16} />
</button>
<div className="border-t border-stone-200 p-3 flex gap-2">
<div
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm text-stone-400 bg-stone-50 flex items-center justify-center gap-2"
title="Reply from your phone"
>
Reply from your phone
</div>
)}
</div>
</div>
);
}
@@ -176,10 +211,10 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: Bell },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: Mail },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Smartphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: Mail },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: Bell },
];
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
@@ -236,4 +271,4 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
);
}
export default Communication;
export default Communication;
+4 -5
View File
@@ -27,8 +27,7 @@ interface Appointment {
}
interface AppointmentsResponse {
upcoming: Appointment[];
past: Appointment[];
appointments: Appointment[];
}
interface Props {
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
@@ -183,7 +182,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
)}
{/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide">
{([
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
+7
View File
@@ -119,3 +119,10 @@ uri
database-url
{{- end -}}
{{- end }}
{{/*
Auth secret name always use groombook-auth (sealed secret name)
*/}}
{{- define "groombook.authSecretName" -}}
{{- printf "%s" "groombook-auth" }}
{{- end }}
@@ -50,6 +50,27 @@ spec:
- name: OIDC_AUDIENCE
value: {{ .Values.api.env.oidcAudience | quote }}
{{- end }}
{{- if .Values.api.env.internalBaseUrl }}
- name: OIDC_INTERNAL_BASE
value: {{ .Values.api.env.internalBaseUrl | quote }}
{{- end }}
- name: BETTER_AUTH_URL
value: {{ .Values.api.env.betterAuthUrl | quote }}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_ID
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_SECRET
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: BETTER_AUTH_SECRET
- name: DATABASE_URL
valueFrom:
secretKeyRef:
+2
View File
@@ -18,6 +18,8 @@ api:
corsOrigin: ""
oidcIssuer: ""
oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000"
service:
type: ClusterIP
+309
View File
@@ -0,0 +1,309 @@
# 10DLC Pilot Tenant Registration Runbook
Authored for GRO-106 Phase 1.
---
## Pre-Flight Checklist
Before starting Telnyx registration, collect the following:
| Item | Details |
|------|---------|
| Legal business name | Exact name on EIN / business registration |
| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX |
| Business type | Sole Proprietor / LLC / Corporation |
| Primary contact email | General contact address (postmaster@, info@, etc.) |
| Primary contact phone | Direct line for carrier verification |
| Website URL | Must be live and contain privacy policy |
| Sample message templates | See [Sample Templates](#sample-message-templates) below |
| Messaging use case | Customer Care / Account Notification |
---
## Step 1 — Telnyx Account Requirements
- Active Telnyx account with billing configured.
- Role required: **Admin** or **Super User** to register brands and campaigns.
---
## Step 2 — Brand Registration
### Via Telnyx Console
1. Log in to [Telnyx Portal](https://portal.telnyx.com).
2. Navigate to **Messaging → A2P 10DLC → Brands**.
3. Click **Register Brand**.
4. Fill in:
- **Brand Name**: Legal business name
- **Legal Company Name**: Exact EIN name
- **Company Type**: Select from dropdown
- **EIN**: XX-XXXXXXX
- **Primary Contact**: Name, email, phone
- **Website**: Must be accessible
- **BusinessVertical**: Select appropriate vertical
5. Acknowledge the **Terms of Service**.
6. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/brands \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Your Legal Business Name",
"legal_company_name": "Your Legal Business Name",
"company_type": "llc",
"ein": "XX-XXXXXXX",
"primary_contact": {
"name": "Jane Doe",
"email": "compliance@example.com",
"phone": "+1XXXXXXXXXX"
},
"website": "https://www.example.com",
"business_vertical": "PROFESSIONAL_SERVICES"
}'
```
**Response fields to record:**
- `brand_id` — required for campaign registration
- `brand_score` — affects campaign vetting speed
### Expected Fees
| Fee Type | Amount |
|----------|--------|
| Brand registration fee | ~$0 (no direct fee from Telnyx) |
| Campaign registration fee | ~$15$25 per campaign (Telnyx fee, subject to change) |
| Carrier fees | Passed through from T-Mobile/AT&T/Verizon |
### Expected Approval Window
- **Vetting by Telnyx**: 13 business days after submission.
- **Carrier (T-Mobile/AT&T/Verizon) review**: 25 business days after Telnyx approval.
- Total end-to-end: **38 business days**.
---
## Step 3 — Campaign Registration
### Use Case Selection
- **Primary**: Customer Care
- **Secondary**: Account Notification
### Via Telnyx Console
1. Navigate to **Messaging → A2P 10DLC → Campaigns**.
2. Click **Register Campaign**.
3. Select **Brand** (use the brand registered in Step 2).
4. Fill in:
- **Campaign Name**: e.g., `groombook-pilot-customer-care`
- **Use Case**: Customer Care / Account Notification
- **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below.
- **Description**: Brief description of messaging program
- **Estimated Volume**: Enter monthly estimate (e.g., 500)
5. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_id": "YOUR_BRAND_ID",
"name": "groombook-pilot-customer-care",
"use_case": "CUSTOMER_CARE",
"sample_messages": [
"Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.",
"Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}."
],
"description": "Appointment reminders and account notifications for grooming clients",
"estimated_monthly_volume": 500
}'
```
**Response fields to record:**
- `campaign_id` — required for messaging profile
- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval
### Campaign Vetting — STOP/HELP Language Requirements
Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service:
- **STOP**: Users can text `STOP` to opt out of all messages.
- **HELP**: Users can text `HELP` to receive contact information.
Example STOP/HELP block:
```
Text STOP to opt out. Text HELP for help. Msg & data rates may apply.
```
---
## Step 4 — Messaging Profile + Phone Number Provisioning
### Create Messaging Profile
1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**.
2. Click **Create Messaging Profile**.
3. Name it (e.g., `groombook-pilot-prod`).
4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB.
### Provision a 10DLC Phone Number
1. Navigate to **Messaging → Phone Numbers**.
2. Search for a number in your desired area code.
3. Confirm the number is 10DLC-capable.
4. Purchase the number.
### Associate Number with Messaging Profile
```bash
# Assign number to messaging profile
curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID"
}'
```
---
## Step 5 — Record in Database
Once GRO-981 lands, record the following against the business record:
### SQL Path (when GRO-981 is complete)
```sql
UPDATE businesses
SET
messaging_phone_number = '+1XXXXXXXXXX',
telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID',
telnyx_brand_id = 'YOUR_BRAND_ID',
telnyx_campaign_id = 'YOUR_CAMPAIGN_ID',
telnyx_brand_status = 'APPROVED',
telnyx_campaign_status = 'ACTIVE',
updated_at = NOW()
WHERE id = 'pilot_business_id';
```
### Manual Admin Path (before GRO-981)
Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet:
| Field | Value |
|-------|-------|
| `messagingPhoneNumber` | +1XXXXXXXXXX |
| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `brandStatus` | APPROVED / PENDING |
| `campaignStatus` | ACTIVE / PENDING |
---
## Sample Message Templates
These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic.
### Transactional Appointment Reminder
```
Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply.
```
### Manual Staff Message
```
Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply.
```
---
## Failure Modes + Retry Guidance
### Vetting Rejection — Brand
| Rejection Reason | Common Fix |
|-----------------|------------|
| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly |
| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting |
| Incomplete primary contact | Provide direct phone and real email (no noreply) |
| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting |
### Campaign Rejection
| Rejection Reason | Common Fix |
|-----------------|------------|
| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends |
| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages |
| Volume estimate too low/high | Revise estimate to be realistic |
| Use case mismatch | Re-select use case that matches actual messaging |
### Re-submission
After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 2448 hours).
---
## Cost Summary
### Telnyx Fees (as of 2026)
| Fee Type | Amount | Notes |
|----------|--------|-------|
| 10DLC number (monthly) | ~$1.00$2.50/number | Varies by type and area code |
| Outbound message | $0.005$0.015/message | Depends on destination carrier |
| Inbound message | Included | No charge for received messages |
| Campaign registration | ~$15$25 one-time | Per campaign, subject to change |
### Carrier Fees (T-Mobile / AT&T / Verizon)
| Carrier | Outbound Fee | Notes |
|---------|-------------|-------|
| T-Mobile | ~$0.005$0.01/message | Varies by message size (segment) |
| AT&T | ~$0.005$0.015/message | Varies by message size (segment) |
| Verizon | ~$0.005$0.01/message | Varies by message size (segment) |
**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates.
### Example Monthly Cost (Pilot — 500 messages/month)
| Line Item | Cost |
|-----------|------|
| 1x 10DLC number | ~$2.00 |
| 500 outbound messages | ~$5.00$7.50 |
| Carrier pass-through | ~$2.50$7.50 |
| **Estimated Monthly Total** | **~$9.50$17.00** |
---
## Rollback / De-provisioning
If the pilot tenant must be de-provisioned:
1. Release the phone number: Telnyx Portal → Phone Numbers → Release.
2. Archive the campaign: set status to `INACTIVE` via API or console.
3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record.
4. Brand can remain registered (no harm) but will not be used.
---
## Contacts
| Resource | Contact |
|----------|---------|
| Telnyx Support | support@telnyx.com |
| Telnyx Dashboard | portal.telnyx.com |
| Internal Engineering | Raise issue in GRO-106 |
---
_Owner: Engineering · Last updated: 2026-05-04_
+11
View File
@@ -0,0 +1,11 @@
# GroomBook Runbooks
Operational runbooks for GroomBook staff and operators.
| Runbook | Description | Status |
|---------|-------------|--------|
| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active |
---
_To add a runbook, create a markdown file in this directory and update this table._
+72
View File
@@ -0,0 +1,72 @@
-- Migration: 0030_messaging.sql
-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings
-- ─── Enums ───────────────────────────────────────────────────────────────────
CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms');
CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound');
CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received');
CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help');
-- ─── Tables ───────────────────────────────────────────────────────────────────
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"business_id" uuid NOT NULL,
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
"channel" "messaging_channel" NOT NULL,
"external_number" text NOT NULL,
"business_number" text NOT NULL,
"last_message_at" timestamp,
"status" text NOT NULL DEFAULT 'active',
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC);
CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number");
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE,
"direction" "message_direction" NOT NULL,
"body" text,
"status" "message_status" NOT NULL DEFAULT 'queued',
"provider_message_id" text,
"error_code" text,
"error_message" text,
"sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL,
"created_at" timestamp NOT NULL DEFAULT now(),
"delivered_at" timestamp,
"read_by_client_at" timestamp
);
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC);
CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id");
CREATE TABLE "message_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
"content_type" text NOT NULL,
"url" text NOT NULL,
"size" integer NOT NULL,
"provider_media_id" text
);
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id");
CREATE TABLE "message_consent_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
"business_id" uuid NOT NULL,
"kind" "message_consent_kind" NOT NULL,
"source" text,
"created_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id");
-- ─── Business Settings extensions ────────────────────────────────────────────
ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text;
ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text;
+356
View File
@@ -0,0 +1,356 @@
CREATE TYPE "public"."client_status" AS ENUM('active', 'disabled');--> statement-breakpoint
CREATE TYPE "public"."coat_type" AS ENUM('smooth', 'double', 'curly', 'wire', 'long', 'hairless');--> statement-breakpoint
CREATE TYPE "public"."impersonation_session_status" AS ENUM('active', 'ended', 'expired');--> statement-breakpoint
CREATE TYPE "public"."invoice_status" AS ENUM('draft', 'pending', 'paid', 'void');--> statement-breakpoint
CREATE TYPE "public"."message_consent_kind" AS ENUM('opt_in', 'opt_out', 'help');--> statement-breakpoint
CREATE TYPE "public"."message_direction" AS ENUM('inbound', 'outbound');--> statement-breakpoint
CREATE TYPE "public"."message_status" AS ENUM('queued', 'sent', 'delivered', 'failed', 'received');--> statement-breakpoint
CREATE TYPE "public"."messaging_channel" AS ENUM('sms', 'mms');--> statement-breakpoint
CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card', 'check', 'other');--> statement-breakpoint
CREATE TYPE "public"."pet_size_category" AS ENUM('small', 'medium', 'large', 'xlarge');--> statement-breakpoint
CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "appointment_groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth_provider_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"provider_id" text NOT NULL,
"display_name" text NOT NULL,
"issuer_url" text NOT NULL,
"internal_base_url" text,
"client_id" text NOT NULL,
"client_secret" text NOT NULL,
"scopes" text DEFAULT 'openid profile email' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "auth_provider_config_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
CREATE TABLE "buffer_time_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"service_id" uuid NOT NULL,
"size_category" "pet_size_category",
"coat_type" "coat_type",
"buffer_minutes" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "buffer_time_rules_service_id_size_category_coat_type_unique" UNIQUE("service_id","size_category","coat_type")
);
--> statement-breakpoint
CREATE TABLE "business_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"business_name" text DEFAULT 'GroomBook' NOT NULL,
"logo_base64" text,
"logo_mime_type" text,
"logo_key" text,
"primary_color" text DEFAULT '#4f8a6f' NOT NULL,
"accent_color" text DEFAULT '#8b7355' NOT NULL,
"messaging_phone_number" text,
"telnyx_messaging_profile_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"business_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"channel" "messaging_channel" NOT NULL,
"external_number" text NOT NULL,
"business_number" text NOT NULL,
"last_message_at" timestamp,
"status" text DEFAULT 'active' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "uq_conversations_business_client_number" UNIQUE("business_id","client_id","business_number")
);
--> statement-breakpoint
CREATE TABLE "grooming_visit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"appointment_id" uuid,
"staff_id" uuid,
"cut_style" text,
"products_used" text,
"notes" text,
"groomed_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "impersonation_audit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"action" text NOT NULL,
"page_visited" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "impersonation_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"staff_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"reason" text,
"status" "impersonation_session_status" DEFAULT 'active' NOT NULL,
"started_at" timestamp DEFAULT now() NOT NULL,
"ended_at" timestamp,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_line_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"description" text NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"unit_price_cents" integer NOT NULL,
"total_cents" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_tip_splits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"staff_id" uuid,
"staff_name" text NOT NULL,
"share_pct" numeric(5, 2) NOT NULL,
"share_cents" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid,
"client_id" uuid NOT NULL,
"subtotal_cents" integer NOT NULL,
"tax_cents" integer DEFAULT 0 NOT NULL,
"tip_cents" integer DEFAULT 0 NOT NULL,
"total_cents" integer NOT NULL,
"status" "invoice_status" DEFAULT 'draft' NOT NULL,
"payment_method" "payment_method",
"paid_at" timestamp,
"stripe_payment_intent_id" text,
"stripe_refund_id" text,
"payment_failure_reason" text,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "message_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"message_id" uuid NOT NULL,
"content_type" text NOT NULL,
"url" text NOT NULL,
"size" integer NOT NULL,
"provider_media_id" text
);
--> statement-breakpoint
CREATE TABLE "message_consent_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"business_id" uuid NOT NULL,
"kind" "message_consent_kind" NOT NULL,
"source" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"direction" "message_direction" NOT NULL,
"body" text,
"status" "message_status" DEFAULT 'queued' NOT NULL,
"provider_message_id" text,
"error_code" text,
"error_message" text,
"sent_by_staff_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"delivered_at" timestamp,
"read_by_client_at" timestamp,
CONSTRAINT "uq_messages_provider_message_id" UNIQUE("provider_message_id")
);
--> statement-breakpoint
CREATE TABLE "recurring_series" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"frequency_weeks" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "refunds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"stripe_refund_id" text NOT NULL,
"idempotency_key" text,
"amount_cents" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "refunds_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "reminder_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid NOT NULL,
"reminder_type" text NOT NULL,
"channel" text DEFAULT 'email' NOT NULL,
"sent_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel")
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "waitlist_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"pet_id" uuid NOT NULL,
"service_id" uuid NOT NULL,
"preferred_date" text NOT NULL,
"preferred_time" text NOT NULL,
"status" "waitlist_status" DEFAULT 'active' NOT NULL,
"notified_at" timestamp,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "clients" ALTER COLUMN "email" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "bather_staff_id" uuid;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "series_id" uuid;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "series_index" integer;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "group_id" uuid;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "buffer_minutes" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "status" "client_status" DEFAULT 'active' NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "disabled_at" timestamp;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "cut_style" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "shampoo_preference" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "special_care_notes" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "custom_fields" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "size_category" "pet_size_category";--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "coat_type" "coat_type";--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "default_buffer_minutes" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointment_groups" ADD CONSTRAINT "appointment_groups_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "buffer_time_rules" ADD CONSTRAINT "buffer_time_rules_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grooming_visit_logs" ADD CONSTRAINT "grooming_visit_logs_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grooming_visit_logs" ADD CONSTRAINT "grooming_visit_logs_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grooming_visit_logs" ADD CONSTRAINT "grooming_visit_logs_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "impersonation_audit_logs" ADD CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."impersonation_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "impersonation_sessions" ADD CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "impersonation_sessions" ADD CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_tip_splits" ADD CONSTRAINT "invoice_tip_splits_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_tip_splits" ADD CONSTRAINT "invoice_tip_splits_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message_attachments" ADD CONSTRAINT "message_attachments_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message_consent_events" ADD CONSTRAINT "message_consent_events_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sent_by_staff_id_staff_id_fk" FOREIGN KEY ("sent_by_staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refunds" ADD CONSTRAINT "refunds_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_buffer_rules_service_id" ON "buffer_time_rules" USING btree ("service_id");--> statement-breakpoint
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations" USING btree ("business_id","last_message_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id");--> statement-breakpoint
CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint
CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_invoice_line_items_invoice_id" ON "invoice_line_items" USING btree ("invoice_id");--> statement-breakpoint
CREATE INDEX "idx_invoice_tip_splits_invoice_id" ON "invoice_tip_splits" USING btree ("invoice_id");--> statement-breakpoint
CREATE INDEX "idx_invoices_client_id" ON "invoices" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_invoices_status" ON "invoices" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_invoices_created_at" ON "invoices" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_invoices_stripe_payment_intent_id" ON "invoices" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments" USING btree ("message_id");--> statement-breakpoint
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages" USING btree ("conversation_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "idx_refunds_invoice_id" ON "refunds" USING btree ("invoice_id");--> statement-breakpoint
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds" USING btree ("idempotency_key");--> statement-breakpoint
CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint
CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_bather_staff_id_staff_id_fk" FOREIGN KEY ("bather_staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_series_id_recurring_series_id_fk" FOREIGN KEY ("series_id") REFERENCES "public"."recurring_series"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_group_id_appointment_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."appointment_groups"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_appointments_client_id" ON "appointments" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_appointments_staff_id" ON "appointments" USING btree ("staff_id");--> statement-breakpoint
CREATE INDEX "idx_appointments_start_time" ON "appointments" USING btree ("start_time");--> statement-breakpoint
CREATE INDEX "idx_appointments_status" ON "appointments" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_clients_email" ON "clients" USING btree ("email");--> statement-breakpoint
CREATE INDEX "idx_pets_client_id" ON "pets" USING btree ("client_id");--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint
ALTER TABLE "services" ADD CONSTRAINT "services_name_unique" UNIQUE("name");--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token");
@@ -0,0 +1,4 @@
-- Add staffReadAt column to conversations for unread tracking
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
CREATE INDEX "idx_conversations_business_id_staff_read_at" ON "conversations"("business_id", "staff_read_at" DESC);
@@ -0,0 +1 @@
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,504 +0,0 @@
{
"id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"prevId": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
"password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_groups": {
"name": "appointment_groups",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointments": {
"name": "appointments",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" },
"start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false },
"series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false },
"series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false },
"group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false },
"confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" },
"confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false },
"customer_notes": { "name": "customer_notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.business_settings": {
"name": "business_settings",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"disabled_at": { "name": "disabled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grooming_visit_logs": {
"name": "grooming_visit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_audit_logs": {
"name": "impersonation_audit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_sessions": {
"name": "impersonation_sessions",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_line_items": {
"name": "invoice_line_items",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_tip_splits": {
"name": "invoice_tip_splits",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoices": {
"name": "invoices",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"species": { "name": "species", "type": "text", "primaryKey": false, "notNull": true },
"breed": { "name": "breed", "type": "text", "primaryKey": false, "notNull": false },
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "primaryKey": false, "notNull": false },
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "primaryKey": false, "notNull": false },
"health_alerts": { "name": "health_alerts", "type": "text", "primaryKey": false, "notNull": false },
"grooming_notes": { "name": "grooming_notes", "type": "text", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "primaryKey": false, "notNull": false },
"special_care_notes": { "name": "special_care_notes", "type": "text", "primaryKey": false, "notNull": false },
"custom_fields": { "name": "custom_fields", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" },
"photo_key": { "name": "photo_key", "type": "text", "primaryKey": false, "notNull": false },
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.recurring_series": {
"name": "recurring_series",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_logs": {
"name": "reminder_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"ical_token": { "name": "ical_token", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist_entries": {
"name": "waitlist_entries",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] },
"public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] },
"public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] },
"public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] },
"public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] },
"public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] }
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}
@@ -1,505 +0,0 @@
{
"id": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f",
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
"password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_groups": {
"name": "appointment_groups",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointments": {
"name": "appointments",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" },
"start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false },
"series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false },
"series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false },
"group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false },
"confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" },
"confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false },
"customer_notes": { "name": "customer_notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.business_settings": {
"name": "business_settings",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
"logo_key": { "name": "logo_key", "type": "text", "primaryKey": false, "notNull": false },
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"disabled_at": { "name": "disabled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grooming_visit_logs": {
"name": "grooming_visit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_audit_logs": {
"name": "impersonation_audit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_sessions": {
"name": "impersonation_sessions",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_line_items": {
"name": "invoice_line_items",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_tip_splits": {
"name": "invoice_tip_splits",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoices": {
"name": "invoices",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"species": { "name": "species", "type": "text", "primaryKey": false, "notNull": true },
"breed": { "name": "breed", "type": "text", "primaryKey": false, "notNull": false },
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "primaryKey": false, "notNull": false },
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "primaryKey": false, "notNull": false },
"health_alerts": { "name": "health_alerts", "type": "text", "primaryKey": false, "notNull": false },
"grooming_notes": { "name": "grooming_notes", "type": "text", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "primaryKey": false, "notNull": false },
"special_care_notes": { "name": "special_care_notes", "type": "text", "primaryKey": false, "notNull": false },
"custom_fields": { "name": "custom_fields", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" },
"photo_key": { "name": "photo_key", "type": "text", "primaryKey": false, "notNull": false },
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.recurring_series": {
"name": "recurring_series",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_logs": {
"name": "reminder_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"ical_token": { "name": "ical_token", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist_entries": {
"name": "waitlist_entries",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] },
"public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] },
"public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] },
"public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] },
"public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] },
"public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] }
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}
@@ -1,103 +0,0 @@
{
"id": "0026_stripe_payment",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
+21
View File
@@ -204,6 +204,27 @@
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1775784467192,
"tag": "0029_db_indexes_constraints",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1775828067192,
"tag": "0030_messaging",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1778818472097,
"tag": "0032_staff_read_at",
"breakpoints": true
}
]
}
+4
View File
@@ -103,6 +103,8 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
sizeCategory: null,
coatType: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
@@ -117,6 +119,7 @@ export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
description: "A grooming service",
basePriceCents: 6500,
durationMinutes: 60,
defaultBufferMinutes: 0,
active: true,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
@@ -148,6 +151,7 @@ export function buildAppointment(
confirmationStatus: "pending",
confirmedAt: null,
cancelledAt: null,
bufferMinutes: 0,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+1 -1
View File
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
export { encryptSecret, decryptSecret } from "./crypto.js";
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
export { and, asc, count, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;
+154
View File
@@ -48,6 +48,22 @@ export const clientStatusEnum = pgEnum("client_status", [
"disabled",
]);
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
"small",
"medium",
"large",
"xlarge",
]);
export const coatTypeEnum = pgEnum("coat_type", [
"smooth",
"double",
"curly",
"wire",
"long",
"hairless",
]);
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
export const user = pgTable("user", {
@@ -146,6 +162,8 @@ export const pets = pgTable(
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
@@ -158,6 +176,7 @@ export const services = pgTable("services", {
description: text("description"),
basePriceCents: integer("base_price_cents").notNull(),
durationMinutes: integer("duration_minutes").notNull(),
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -240,6 +259,8 @@ export const appointments = pgTable(
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Per-appointment buffer time (may be overridden from service default or bufferTimeRules)
bufferMinutes: integer("buffer_minutes").notNull().default(0),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
@@ -406,6 +427,118 @@ export const impersonationAuditLogs = pgTable(
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
);
// ─── Messaging ───────────────────────────────────────────────────────────────
export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]);
export const messageDirectionEnum = pgEnum("message_direction", [
"inbound",
"outbound",
]);
export const messageStatusEnum = pgEnum("message_status", [
"queued",
"sent",
"delivered",
"failed",
"received",
]);
export const messageConsentKindEnum = pgEnum("message_consent_kind", [
"opt_in",
"opt_out",
"help",
]);
export const conversations = pgTable(
"conversations",
{
id: uuid("id").primaryKey().defaultRandom(),
businessId: uuid("business_id").notNull(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
channel: messagingChannelEnum("channel").notNull(),
externalNumber: text("external_number").notNull(),
businessNumber: text("business_number").notNull(),
lastMessageAt: timestamp("last_message_at"),
status: text("status").notNull().default("active"),
staffReadAt: timestamp("staff_read_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_conversations_business_id_last_message_at").on(
t.businessId,
t.lastMessageAt.desc()
),
unique("uq_conversations_business_client_number").on(
t.businessId,
t.clientId,
t.businessNumber
),
]
);
export const messages = pgTable(
"messages",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
direction: messageDirectionEnum("direction").notNull(),
body: text("body"),
status: messageStatusEnum("status").notNull().default("queued"),
providerMessageId: text("provider_message_id"),
errorCode: text("error_code"),
errorMessage: text("error_message"),
sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").notNull().defaultNow(),
deliveredAt: timestamp("delivered_at"),
readByClientAt: timestamp("read_by_client_at"),
},
(t) => [
index("idx_messages_conversation_id_created_at").on(
t.conversationId,
t.createdAt.desc()
),
unique("uq_messages_provider_message_id").on(t.providerMessageId),
]
);
export const messageAttachments = pgTable(
"message_attachments",
{
id: uuid("id").primaryKey().defaultRandom(),
messageId: uuid("message_id")
.notNull()
.references(() => messages.id, { onDelete: "cascade" }),
contentType: text("content_type").notNull(),
url: text("url").notNull(),
size: integer("size").notNull(),
providerMediaId: text("provider_media_id"),
},
(t) => [index("idx_message_attachments_message_id").on(t.messageId)]
);
export const messageConsentEvents = pgTable(
"message_consent_events",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
businessId: uuid("business_id").notNull(),
kind: messageConsentKindEnum("kind").notNull(),
source: text("source"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_message_consent_events_client_id").on(t.clientId)]
);
export const businessSettings = pgTable("business_settings", {
id: uuid("id").primaryKey().defaultRandom(),
businessName: text("business_name").notNull().default("GroomBook"),
@@ -414,6 +547,8 @@ export const businessSettings = pgTable("business_settings", {
logoKey: text("logo_key"),
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
accentColor: text("accent_color").notNull().default("#8b7355"),
messagingPhoneNumber: text("messaging_phone_number"),
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -486,3 +621,22 @@ export const authProviderConfig = pgTable("auth_provider_config", {
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const bufferTimeRules = pgTable(
"buffer_time_rules",
{
id: uuid("id").primaryKey().defaultRandom(),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
bufferMinutes: integer("buffer_minutes").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
unique().on(t.serviceId, t.sizeCategory, t.coatType),
index("idx_buffer_rules_service_id").on(t.serviceId),
]
);
+10 -1
View File
@@ -883,6 +883,7 @@ async function seed() {
let appointmentCount = 0;
let invoiceCount = 0;
let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable
const apptBatchSize = 100;
@@ -977,6 +978,10 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
invoiceBatch.push({
id: invoiceId,
@@ -989,6 +994,7 @@ async function seed() {
status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt,
stripePaymentIntentId,
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
});
@@ -1092,13 +1098,16 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
paidInvoiceCounter++;
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, notes: null,
paidAt,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,
+10
View File
@@ -32,6 +32,8 @@ export interface Pet {
name: string;
species: string;
breed: string | null;
sizeCategory: string | null;
coatType: string | null;
weightKg: number | null;
dateOfBirth: string | null;
healthAlerts: string | null;
@@ -64,6 +66,7 @@ export interface Service {
description: string | null;
basePriceCents: number;
durationMinutes: number;
defaultBufferMinutes: number;
active: boolean;
createdAt: string;
updatedAt: string;
@@ -114,6 +117,7 @@ export interface Appointment {
cancelledAt: string | null;
confirmationToken: string | null;
customerNotes: string | null;
bufferMinutes: number;
createdAt: string;
updatedAt: string;
}
@@ -152,10 +156,16 @@ export interface Invoice {
status: InvoiceStatus;
paymentMethod: PaymentMethod | null;
paidAt: string | null;
stripePaymentIntentId: string | null;
stripeRefundId: string | null;
paymentFailureReason: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
lineItems?: InvoiceLineItem[];
// Transient fields populated from Stripe API (not stored in DB)
cardLast4?: string | null;
paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[];
}
+19
View File
@@ -46,6 +46,9 @@ importers:
telnyx:
specifier: ^1.23.0
version: 1.27.0
uuid:
specifier: ^11.1.1
version: 11.1.1
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -59,6 +62,9 @@ importers:
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.23
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
@@ -2334,6 +2340,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4344,12 +4353,18 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
victory-vendor@37.3.6:
@@ -6910,6 +6925,8 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@10.0.0': {}
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -9014,6 +9031,8 @@ snapshots:
dependencies:
react: 19.2.4
uuid@11.1.1: {}
uuid@8.3.2: {}
uuid@9.0.1: {}