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>
- 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>
- 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>
- 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>
GRO-778 QA found that the previous commit only added a misleading comment;
the portalRouter.post("/dev-session") handler remained at line ~476, well
after portalRouter.use("/*", validatePortalSession, portalAudit) at line 16.
In Hono, use() applies only to routes registered AFTER it.
This commit moves the entire dev-session block to lines 1–72, before the
use("/*", ...) call, so the exemption actually takes effect.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Route ordering: /dev-session is registered after portalRouter.use("/*")
so it is NOT subject to the validatePortalSession/portalAudit middleware
chain — this is correct Hono behaviour since use() only applies to routes
registered after it.
The /dev-session POST endpoint creates the impersonation session and
cannot have a valid X-Impersonation-Session-Id header at call time.
Without this exemption, POST /api/portal/dev-session returns 401 before
the handler runs, breaking all portal pages when AUTH_DISABLED=true.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(GRO-766): prevent horizontal overflow on portal mobile pages
- Add overflow-x-hidden to main content area in CustomerPortal
- Add w-full overflow-hidden to content wrapper div
- Add flex-wrap to BillingPayments tab button row
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(GRO-769): proxy logo uploads through API server to fix mixed content
The pre-signed URL flow used an internal HTTP endpoint for S3 uploads,
which browsers blocked as mixed content on HTTPS pages. Instead of
generating a pre-signed URL that the browser uploads to directly,
the new /logo/upload endpoint receives the file via multipart POST
and streams it to S3 from the API server using the internal endpoint.
This resolves the mixed content error that was blocking logo uploads
on dev.groombook.dev.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
- Extend updateInvoiceSchema to accept optional tipSplits array in PATCH body
- Validate tip splits sum to 100% (10000 bps) when marking paid with tipCents > 0
- Return 422 if tipSplits not provided and no existing splits in DB
- Save tip splits atomically in same DB transaction as invoice status update
- Update frontend markPaid() to send tipSplits in PATCH body instead of separate POST
- Remove non-atomic POST /tip-splits call from markPaid flow
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
- Add idx_appointments_client_id, idx_appointments_staff_id,
idx_appointments_start_time, idx_appointments_status to schema.
Migration 0029 already handles the DB side; this brings schema.ts
in sync so drizzle-kit push is clean going forward.
- Wrap deleteObject calls in try/catch (POST /photo/confirm and
DELETE /:petId/photo endpoints) so S3 failures don't abort the
DB update — orphaned objects are logged as warnings instead.
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
- Add SMS opt-in fields to clients schema (smsOptIn, smsConsentDate, smsOptOutDate, smsConsentText)
- Add channel column to reminderLogs with per-channel idempotency
- Create SMS service with Telnyx SDK integration and E.164 validation
- Update reminders service to conditionally send SMS to opted-in clients
- Add TCPA opt-out text to SMS reminders
- Graceful degradation: catch SMS errors without blocking email
- Fix: use clients.phone instead of non-existent clients.phoneE164
- Update clients route to expose SMS fields in API
- Add telnyx dependency to API package
- Create database migration 0028_sms_reminders
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix invoice status transitions, tip-split validation, refund idempotency, and tip-split response format
- Add ALLOWED_TRANSITIONS state machine for invoice status changes (GRO-637)
- Replace floating-point tip-split validation with integer basis-points math
- Add idempotency key support to refund endpoint with new refunds table
- Return full invoice shape from POST /:id/tip-splits matching GET response
- All existing tests pass
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(invoices): wrap refund flow in transaction for idempotency safety
- Wrap idempotency check + processRefund() + db.insert() in db.transaction()
- This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund
- Add migration 0027_refunds for the refunds table (was missing)
- Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks)
Fixes GRO-637 per CTO review
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(api): wire up CSRF middleware for protected routes
Register csrfMiddleware in the protected API routes after authMiddleware
and resolveStaffMiddleware to protect against CSRF attacks on state-
changing operations (POST, PUT, PATCH, DELETE).
Addresses CTO review feedback on PR #278.
* fix(api): remove CSRF middleware that breaks POST/PUT/PATCH/DELETE
The CSRF middleware requires x-csrf-token header but the frontend never
sends it, which would break all mutating operations with 403 errors.
CSRF protection should be implemented in a separate coordinated PR with
frontend changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
Adds Zod validation across 5 API routes:
1. invoices GET / — query param validation (uuid, enum, int bounds)
2. book POST / — future-time refinement on startTime
3. appointments — recurrence series capped at 1 year
4. services — durationMinutes capped at 480 (8 hours)
5. stripe-webhooks — UUID validation on invoice IDs before DB lookup
Closes GRO-636
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add SQL-level LIMIT/OFFSET pagination to churn risk query
- Add separate COUNT(*) subquery for total without fetching all rows
- Accept page and limit query params with sensible defaults and bounds
- Return page, limit, and churnRiskTotal in response
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Replace SELECT-then-UPDATE with atomic UPDATE ... WHERE token=? AND status='pending' RETURNING *
to prevent confirmation token replay attacks (TOCTOU race condition)
- Fix requireRoleOrSuperUser() error message: swap the conditional branches so
'Forbidden: super user privileges required' is returned when user lacks role,
and 'Forbidden: role X is not permitted' when user is not superuser
- Add 'and' mock export to confirmation.test.ts and rbac.test.ts for new query patterns
- Update test expectations to match corrected error message semantics
- appointmentGroups: Hono<AppEnv>() + groomer isolation on all 5 endpoints
- groomingLogs: Hono<AppEnv>() + groomer isolation on GET, POST, DELETE with appointmentId preserved
- appointments: batherStaffId conflict checks in POST and PATCH handlers
- Non-groomer roles retain full access
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds the missing rate_limit table that Better Auth v1.5.6 requires when rateLimit.storage is set to 'database'. Without this table, all auth endpoints return HTTP 500.
Also includes GRO-566: SKIP_OOBE env var to bypass setup wizard in dev/test.
cc @cpfarhood
- Add database migration 0024 with indexes on invoices, invoice_line_items, and invoice_tip_splits
- Update Drizzle schema with index definitions for sync
- Add pagination (limit/offset) to GET /api/invoices with max 200 limit
- Add LEFT JOIN to include clientName in invoice list response
- Return { data: [...], total: N } response shape for pagination
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Fix type errors that caused CI Lint & Typecheck job to fail:
- setup.ts: replace unavailable isNull import with sql template tag
(isNull not exported from @groombook/db; sql IS exported)
- setup.ts: add non-null assertion on newStaff after insert.returning()
- setup.test.ts: add sql mock template tag to @groombook/db mock
- setup.test.ts: fix evaluateCond to handle sql template tag type
- setup.test.ts: add type assertions for body.staff in OOBE regression tests
- setup.test.ts: fix dbStaffRows type casts in mock insert function
All 18 tests pass, full typecheck clean.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Exempt POST /api/setup from resolveStaffMiddleware so OOBE users (with no
pre-existing staff record) can complete the out-of-box experience without
getting blocked by the "no staff record found" 403 error.
Changes:
- rbac.ts: add /api/setup to path exemption alongside /api/auth/
- setup.ts POST /: add find-or-create logic that:
- Looks up existing staff by userId from JWT
- Auto-links legacy staff records by email if userId is null
- Creates a new staff record if none exists (OOBE case)
- Returns 400 if JWT has no email and no staff record found
- setup.test.ts: add regression tests for all scenarios
Fixes GRO-485 (OOBE regression introduced by GRO-480).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Switch the test endpoint from putAuthProviderSchema.omit({ clientSecret })
(which requires providerId, displayName, clientId, scopes) to the
minimal authProviderTestSchema (issuerUrl, internalBaseUrl?) that matches
what the Settings.tsx frontend actually sends.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
PUT /api/admin/auth-provider was returning HTTP 500 with an HTML error page
when BETTER_AUTH_SECRET was missing, because encryptSecret() throws an
unhandled error. This change wraps both the encryption step and the DB
transaction in try/catch blocks to return a proper JSON error response.
Also adds the missing authProviderConfig schema and encryptSecret crypto
helpers from the feat/gro-392-oobe-auth-provider-bootstrap branch.
Fixes: GRO-441
Co-Authored-By: Paperclip <noreply@paperclip.ing>
reinitAuth() can throw if BETTER_AUTH_SECRET is missing, causing
an unhandled rejection that returns an HTML error page instead of
JSON. Wrap both PUT and DELETE handlers in try/catch to return a
proper JSON error response.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add reinitAuth() import and calls to routes/authProvider.ts (active router)
instead of routes/admin/authProvider.ts (dead code, not imported)
- Add AbortSignal.timeout(10_000) to fetch in setup auth-provider/test endpoint
- Add .replace(/\/$/, "") to strip trailing slash from internalBaseUrl
- Delete dead routes/admin/authProvider.ts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Test connection was always 400 because testAuthProviderSchema required
clientSecret, but OIDC discovery only needs issuer/internal URLs.
Aligned admin test endpoint with setup.ts behavior:
- Drop providerId, clientId, clientSecret from schema
- Add optional internalBaseUrl; use it for discovery URL when set
- Frontend now sends issuerUrl + internalBaseUrl (when populated)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace zValidator-orphaned c.req.valid("json") calls with await c.req.json()
in the auth provider bootstrap and test endpoints per CTO review.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
POST /api/setup/auth-provider and POST /api/setup/auth-provider/test
were returning 400 (Zod validation) instead of 403 when needsSetup
was false, because zValidator middleware ran before the route handler
body. Now manually parse the body after the needsSetup guard so 403
fires immediately for post-setup requests.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Fix CTO review comments on GRO-392:
- POST /api/setup/auth-provider/test now uses authProviderTestSchema
(only issuerUrl + internalBaseUrl) instead of full
authProviderBootstrapSchema — clientSecret is not needed for OIDC
discovery and was not being sent by the frontend handler
- POST /api/admin/auth-provider/test already uses omit() correctly;
no change needed
- apps/api/src/routes/admin/authProvider.ts: added trailing newline
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Implement admin API endpoints for managing auth provider configuration:
- GET /api/admin/auth-provider — get current config (secret redacted)
- PUT /api/admin/auth-provider — create or update provider config
- POST /api/admin/auth-provider/test — validate via OIDC discovery endpoint
- DELETE /api/admin/auth-provider — remove DB config (falls back to env vars)
All endpoints are gated by requireSuperUser(). The clientSecret is
AES-256-GCM encrypted before DB write and always redacted on return.
Test-connection fetches /.well-known/openid-configuration and returns
metadata on success or error detail on failure.
Includes 16 unit tests covering all endpoints and error paths.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add POST /api/setup/auth-provider/test endpoint for OOBE test connection
- Guard with same !superUser check as bootstrap endpoint
- Update SetupWizard to call /api/setup/auth-provider/test instead of
/api/admin/auth-provider/test (which requires auth session)
- Add trailing newline at EOF in setup.ts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Backend:
- GET /api/setup/status now returns showAuthProviderStep, authConfigExists,
and authEnvVarsSet to inform the frontend whether to show the step
- POST /api/setup/auth-provider: unauthenticated endpoint for first-time
auth provider configuration during OOBE; guarded by needsSetup check
(returns 403 after setup completes); encrypts clientSecret before storing
Frontend:
- SetupWizard fetches /api/setup/status on mount to determine if the
auth provider step is needed (fresh install with no DB config and no
OIDC env vars)
- When needed, inserts the Auth Provider step after Welcome, before
Business Name; includes full form with Test Connection button
- Endpoint is POST /api/admin/auth-provider/test for connection testing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Implements admin API endpoints for managing auth provider configuration.
All gated by requireSuperUser().
Endpoints:
- GET /api/admin/auth-provider - returns config with clientSecret=redacted
- PUT /api/admin/auth-provider - encrypts clientSecret before DB write
- POST /api/admin/auth-provider/test - validates OIDC discovery endpoint
- DELETE /api/admin/auth-provider - removes DB config
Fixes CTO review findings:
- PUT uses db.transaction() for atomic upsert (was non-atomic delete+insert)
- Rebased on latest main (drops stale GRO-404/406 commits)
- Added EOF newlines to authProvider.ts and authProvider.test.ts
Unit tests with 9 passing test cases covering all endpoints and RBAC.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(api): enforce requireSuperUser on settings PATCH and fix dev-mode auth bypass
- Add requireSuperUser() middleware to PATCH /api/admin/settings route
to ensure only super users can modify business settings
- Fix dev-mode (AUTH_DISABLED=true) force-set of isSuperUser:true
for all staff records in resolveStaffMiddleware. Now preserves
actual database value with isSuperUser ?? false fallback.
This prevents non-super-users (e.g., receptionists) from
bypassing RBAC checks in dev mode.
- Fix test data: RECEPTIONIST and GROOMER now correctly have
isSuperUser: false (was incorrectly inheriting true from MANAGER)
- Add 7 new tests for requireSuperUser middleware covering:
- Super user access allowed
- Non-super-user receptionist blocked with 403
- Non-super-user groomer blocked with 403
- Unresolved staff record returns 403
- Receptionist cannot grant super user via PATCH
- JSON error response format
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(api): remove dead code in rbac test
Remove unused `app` variable from 'returns 403 when staff record is
not resolved' test - the test uses `testApp` instead.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: groombook-engineer[bot] <3141748+groombook-engineer[bot]@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
The returning() on the update query can produce undefined when zero
rows match. Added explicit 404 if updated is falsy.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Generated 16 diverse pet images for demo site using MiniMax image generation:
- Multiple dog breeds (Golden Retriever, Poodle, Labrador, Shih Tzu, Cocker Spaniel, Schnauzer, Maltese, Dachshund, Pomeranian)
- Professional grooming styles and poses
- Studio lighting for quality showcase
Updated seed.ts to create 9 demo pets with image references:
- Expands from single demo pet to diverse pet portfolio
- Images deployed to apps/web/public/demo-pets/
- Each pet has breed-accurate styling and professional grooming
This completes GRO-395 demo assets expansion using allocated MiniMax credits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>