Skip to content

Abstract services into per-tenant rows; sitters can add custom services#18

Merged
bradburch merged 11 commits into
mainfrom
custom-services
Jul 13, 2026
Merged

Abstract services into per-tenant rows; sitters can add custom services#18
bradburch merged 11 commits into
mainfrom
custom-services

Conversation

@bradburch

Copy link
Copy Markdown
Owner

Summary

Services were a closed, cross-tenant enum: SERVICE_CATALOG hardcoded five services, schema CHECKs repeated the list in three tables, and per-type behavior lived in code. A sitter couldn't offer "Morning walk" and "Afternoon walk" as distinct services — the exact limitation that forced the 2026-07-04 time-windowed spec to reject per-slot service types.

This makes TenantServices the authoritative, agnostic Service store. Every row carries its own behavior (Label, Icon, Shape, RateUnit, HasDuration, CapacityKind, SortOrder); ServiceType becomes a per-tenant slug. The five built-ins become SERVICE_TEMPLATES: creating a service clones a template's behavior permanently, so sitters pick a template + a name and nothing else.

CapacityKind names the capacity pool, not the service. A custom "Luxury boarding" shares the tenant's boarding pet pool — capacity models the sitter's real-world resources, which don't multiply when a service is cloned. The shared capacity engine (src/shared/booking/capacity.ts) is untouched.

Changes

  • Migration 0006: rebuilds the three tables without ServiceType CHECKs (PRAGMA defer_foreign_keys for D1's in-transaction parent-table drop), backfills behavior columns from templates, seeds all five built-in rows per tenant (missing ones disabled). Covered by migration-0006.test.ts and applied to local D1.
  • Server: behavior read from the row everywhere (estimateCost, checkAvailability, monthAvailability); listCapacityRows joins TenantServices so custom pool services count toward capacity; calendar sync carries the row's label; isServiceType guards replaced by tenant-row lookups.
  • Admin API: POST /admin/services {template, label} (created disabled; priced + enabled via the normal settings PUT) and DELETE /admin/services/:type (custom only; refused with booking history). Settings GET serves rows + the template list.
  • Admin UI: "Add service" form (template picker + name), delete button on custom services, icons from the payload.
  • Widget: icons keyed by the row's icon field with paw fallback; rendering was already generic.

Verification

  • 234 tests pass (9 new lifecycle tests + 3 migration tests); typecheck, eslint, prettier clean.
  • Driven end-to-end against wrangler dev + migrated local D1: admin create → price/enable → widget config → customer login (D1 code) → booking ($27 est). A 2-pet "Luxury Boarding" booking filled the shared 2-pet boarding pool and blocked built-in boarding on the same dates. Delete guards probed live: built-in → 400, booked custom → 409, unused custom → 204.

Notes

  • The 2026-07-04 time-windowed spec's window/capacity option columns can still layer on top of this; nothing conflicts.
  • Remote D1 still needs npx wrangler d1 execute pawbook-db --remote --file ./migrations/0006_custom_services.sql at deploy time.

🤖 Generated with Claude Code

bradburch and others added 7 commits July 4, 2026 23:21
Scopes support for sitter-defined fixed-window service options (e.g.
Morning Walk 11-2, capacity 4) by extending TenantServiceOptions rather
than introducing a new slots table or enum values.
Adversarial review surfaced 6 issues across two passes: duration/window
drift risk, OptionKey collisions on windowed options, a missing
StartTime column in insertBookingRequest, a missing type update, plus
two follow-ups (server must recompute duration rather than trust the
client, and slug derivation/collision handling needed to be explicit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Services were a closed cross-tenant enum (SERVICE_CATALOG + CHECK
constraints in three tables) with per-type behavior hardcoded in the
capacity/availability/pricing paths. TenantServices is now the
authoritative, agnostic Service store: every row carries its own
behavior (Label, Icon, Shape, RateUnit, HasDuration, CapacityKind,
SortOrder) and ServiceType is a per-tenant slug.

The five built-ins become SERVICE_TEMPLATES — seed data plus the admin
'Add service' picker. Creating a service (e.g. 'Morning walk') clones a
template's behavior permanently, so the shared capacity engine is
untouched: CapacityKind names the capacity POOL a service draws from
(boarding pets / house-sit days / none), and custom services share the
tenant-level pools rather than minting their own.

- Migration 0006 rebuilds the three tables (defer_foreign_keys for the
  parent-table drop under D1), backfills behavior from templates, and
  gives every tenant all five built-in rows.
- POST/DELETE /admin/services endpoints; built-ins can only be
  disabled, and a slug with booking history can't be deleted.
- listCapacityRows joins TenantServices so custom pool services count
  toward capacity; calendar-sync carries the label, and month
  availability maps GCal category slugs to pools via the rows.
- Admin UI: template+name 'Add service' form, delete on custom rows.
  Widget: icons keyed by the row's icon field.

Verified end-to-end against wrangler dev: create → price/enable →
widget config → customer booking; a 2-pet 'Luxury Boarding' booking
fills the shared boarding pool and blocks built-in boarding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onciliation (#21)

* Add design spec for sitter booking dashboard (confirm/reject + calendar reconciliation)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Ignore .worktrees/ for isolated subagent-driven implementation workspaces

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add Declined flag + booking-lifecycle repo functions

Implements Task 1 schema change (Declined column on BookingRequests) and three
repo-layer functions (listBookingsForTenant, updateBookingStatus,
getBookingWithCustomer) that the sitter dashboard will build on. Includes full
test coverage for booking lifecycle state transitions and tenant isolation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix CapacityRow typecheck ripple from Declined field addition

BookingRow.Declined (added in ae009b3) is required, which flows through to
CapacityRow (BookingRow & { CapacityKind }). Two pre-existing CapacityRow
literals in availability.test.ts's rowsToCapacityEvents tests were missing
it; npm test (vitest) doesn't full-typecheck so this wasn't caught until
npm run typecheck was run explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add sendBookingStatusEmail

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add GET/POST admin bookings routes

GET /:slug/admin/bookings lists a tenant's bookings joined with customer
contact info; POST /:slug/admin/bookings/:id/status transitions a booking's
status and best-effort emails the customer, reporting whether they were
notified.

* Add calendar reconciliation with a KV-cached TTL

* Make reconcileIfStale's KV read/write best-effort too

The try/catch only wrapped the reconcileBookingsWithCalendar call; the KV get/put
around it could still throw uncaught out of the GET /admin/bookings route on a
KV outage. Wrap the whole function body so a KV failure is as best-effort as a
Calendar failure.

* Write the reconcile TTL marker even when reconciliation fails

The previous fix moved the KV put inside the try block, so a
reconcileBookingsWithCalendar failure (the exact case the TTL cache exists to
throttle) skipped the marker write entirely, letting every subsequent
dashboard load during an outage re-attempt the full Calendar round-trip.
Use try/catch/finally so the marker always gets written (guarded with its own
.catch), and guard the initial KV get the same way so a KV read failure is
treated as a cache miss instead of throwing.

* Add AdminBooking type and adminApi.bookings client

Implements Task 5: adds the AdminBooking type (for displaying booking info in the admin dashboard) and the bookings namespace with list() and setStatus() methods to the adminApi object. These will be consumed by the admin booking dashboard component in Task 6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add BookingsSection component

* Fix BookingsSection: missing .pb-chip-bad style, colliding savebar

- Add .pb-chip-bad to admin.css (existed on main, wasn't ported over
  when the component was copied); cancelled bookings now render with
  the bad/red styling instead of falling back to the default chip.
- Replace BookingsSection's local fixed .pb-savebar with an in-flow
  .pb-hint message. App.tsx already renders a global fixed savebar
  across all sections; a second one at the same position would
  overlap it when both are active.

* Wire BookingsSection into the admin dashboard nav

* Bound calendar reconciliation candidates to the query window

listSyncedBookingIds returned ALL synced, non-cancelled bookings for the
tenant with no date bound, while reconcileBookingsWithCalendar only queried
Google Calendar for a fixed [today-1, today+180) window. Any booking outside
that window (already completed, or booked far enough out) could never appear
in the Calendar response and was wrongly flagged "missing" and auto-cancelled.

Give listSyncedBookingIds the same window bounds reconcileBookingsWithCalendar
computes for its own Calendar query, using the same overlap-check idiom as
listCapacityRows, so only bookings we could actually have seen in the Calendar
response are considered candidates for cancellation.

Also locks down two spec non-goals with regression tests: cancelling a booking
never calls out to Google Calendar (admin-bookings.test.ts), and reconciliation
only checks bookingId presence, never diffs event start/end times
(calendar-reconcile.test.ts). Test fixture dates switched from hardcoded 2030
values to dates computed relative to the real clock, since the new window
bound is itself relative to real "today" and a hardcoded future date would
eventually age out of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix Google Calendar pagination truncation risk in reconciliation

listCalendarEvents fetched up to 2500 events with no pagination. When a
tenant's query window has more instances than that (plausible once
recurring series are expanded via singleEvents=true), events past page 1
went missing from the response. reconcileBookingsWithCalendar infers
"event deleted" from a booking id's absence from that list, so truncation
could wrongly auto-cancel real, confirmed bookings.

listCalendarEvents now throws if the Google API response includes a
nextPageToken, instead of silently returning a partial list. Both
existing callers (reconcileIfStale's try/catch, availability.ts's
try/catch) already degrade safely on a thrown error, so this fails loud
at the source rather than adding pagination.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Simplify booking JOIN queries, drop BOOKING_COLS_QUALIFIED

BOOKING_COLS_QUALIFIED existed only to disambiguate the Id column in two
JOIN queries. BookingRequests.* does the same thing without maintaining a
derived, table-prefixed copy of BOOKING_COLS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Apply prettier formatting

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Add design spec for sitter booking dashboard (confirm/reject + calendar reconciliation)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Ignore .worktrees/ for isolated subagent-driven implementation workspaces

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add design spec for CSV client/pet import

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add a minimal CSV row parser

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add CSV client/pet import route

POST /:slug/admin/customers/import parses a CSV of client/pet rows,
upserts customers and pets via existing repo helpers, dedups pets per
client, and optionally sends invites to genuinely new customers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add ImportResult type and adminApi.customers.import client

Add the ImportResult type to represent the response from a bulk CSV import,
and add the import method to adminApi.customers to call the backend import endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add CSV import UI to the clients section

* Reset file input after successful CSV import

Uncontrolled <input type="file"> doesn't clear from a state reset alone;
remount it via a key that bumps on success so the sitter can't
accidentally resubmit the same file.

* Isolate per-row errors in CSV import so one bad row doesn't 500 the whole request

Wrap each row's DB-mutating work in try/catch and skip the row on failure
instead of throwing uncaught -- previously processed rows in the same
request had already been committed to D1, so a mid-loop throw silently
left partial results with no response body at all.

* Cap CSV client import at 500 rows to avoid a mid-loop platform crash

A large import triggers ~5 sequential D1 calls per pet-bearing row and can
exceed Workers' subrequest/CPU ceiling partway through the loop. That abort
happens outside the per-row try/catch, so it returns a bare 500 with no
partial-import report even though earlier rows already committed. Check the
row count up front and fail fast with an actionable error instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix row-number desync in CSV import caused by silently dropped blank lines

parseCsvRows() used to silently skip blank lines, but admin.ts computed each
reported row number positionally against that filtered array. A blank line
anywhere before a bad row shifted every subsequent skippedRows entry off from
the sitter's actual spreadsheet line, undermining the whole point of the
partial-import report.

parseCsvRows() now emits every line (blank lines become a single ['']
row), keeping array index aligned with source line number. The import loop
gained an explicit blank-row check ahead of the malformed-row check so blank
lines are still silently skipped, but line alignment for everything after
them stays correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Simplify freshCustomers to a plain email list; drop dead .some() dedup guard

Rows are processed with sequential awaits, so by the time the same email
recurs on a later row, getEndUserByEmail already sees the prior row's
committed insert and existing is truthy — the .some() dedup branch could
never actually fire. endUserId was also unused past this point, so
freshCustomers collapses to string[]. Behavior-neutral; all existing import
tests still pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Apply prettier formatting

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
bradburch and others added 4 commits July 12, 2026 13:11
* Add design spec for earnings analytics + payment tracking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revise earnings analytics spec per subagent review

Fact-check pass: guarded INSERT idiom spelled out, paidTotal route glue,
icon/error-banner conventions corrected, migration test dropped to match
repo convention. Design pass: per-booking payments panel (list + delete),
deletePayment scoped to booking id, refund/cancel-after-payment decision
documented, isRealDate/isValidRate reuse, pending-deposit semantics,
all-time labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add Payments table and payment repo layer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add getAnalytics earnings aggregates to repo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add admin payment routes and paidTotal on the bookings list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add admin analytics route with earnings tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin seeded customer name literal in analytics tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add payments API client and shared PaymentsPanel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard payment form submit against invalid amount/date

Add client-side validation to prevent submission of zero/negative amounts
or empty dates. Validates amount is a positive integer and date is non-empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Show payment state and payments panel in Bookings section

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hide zero paid text and gate payments panel on row status

Fix 1: paidText now returns null when paidTotal is 0 (no paid text shown until a payment exists).
Fix 2: PaymentsPanel mount now gated on row status (cancelled/declined), matching the toggle button condition to prevent stuck-open panels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add Earnings dashboard section

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix Prettier formatting in pre-existing files

The final-task verification gate runs `npm run format`, which caught
whitespace-only drift left over from earlier tasks; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard earnings reload against post-unmount state updates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reset alive ref on mount so StrictMode remount keeps earnings reload working

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review follow-ups: payments GET 404, analytics reconcile, query bounds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Show read-only payments ledger on cancelled bookings; per-action busy in panel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Polish earnings section: empty copy, truncation, chart label overflow

- Change outstanding empty state to neutral "No outstanding balances."
- Hoist duplicated "No payments recorded yet." to module constant NO_PAYMENTS
- Add overflow: hidden with text-overflow: ellipsis to hbars grid cells and list items
- Hide SVG bar chart totals when >= 5 digits to prevent overlap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ure races, and duplication

Review of PR #18 surfaced two prototype-chain bugs from using `in` on a
plain-object template map (a service named "Constructor" was
undeletable/misreported; template "constructor" crashed to a 500), two
check-then-act races that swallowed errors or silently no-op'd instead of
failing loudly, and hand-copied constants/types that had already drifted
apart in style. Fixes each at the root (isTemplateId, setServiceConfig's
return contract, migration 0006's template source) plus regression tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ith time-windowed slots

main independently built time-windowed group services (capacity-limited
options with a fixed clock window), a D1-single-source-of-truth rewrite of
monthAvailability (fixing a real bug where the month grid could contradict
the authoritative booking check), and a provider-registry simplification,
while custom-services built the per-tenant TenantServices abstraction,
payments/earnings, CSV import, and calendar reconciliation. Neither branch
had the other's work.

Reconciliation approach: keep custom-services' DB-row service model
throughout (a custom service can hold any CapacityKind, so main's
fixed-literal capacity/service-catalog code couldn't be adopted verbatim);
port main's windowed-option logic, D1-source availability fix, and
provider simplification onto that model; renumber main's migrations
(0006-0008) to continue after custom-services' own 0006-0008 lineage,
dropping the duplicate Declined column addition. Booking-lifecycle and
status-email code had been independently built near-identically on both
branches and needed no reconciliation beyond picking one.

Fixed two silent data-loss bugs the automatic 3-way merge introduced:
CalendarEvent's type definition and ServiceConfig's minNights/maxNights/
minPetCount/maxPetCount fields were both dropped by clean (non-conflicting)
hunks that git merged without flagging.

Verified: full test suite (336/336), typecheck, lint, prettier all clean;
fresh-schema and incremental-migration paths produce equivalent schemas;
manual end-to-end smoke test of both a custom-service booking and a
capacity-limited windowed-option booking (including the 409 at capacity
and the month grid reflecting it) against a running dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
npm ci with the merged package.json (which now includes main's
devDependency versions) surfaced 5 files formatted with a stale prettier
run — CI's fresh install caught what my local check missed the first
time. No content changes, formatting only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@bradburch bradburch merged commit c4ebbbe into main Jul 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant