Abstract services into per-tenant rows; sitters can add custom services#18
Merged
Conversation
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>
4 tasks
* 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Services were a closed, cross-tenant enum:
SERVICE_CATALOGhardcoded five services, schemaCHECKs 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
TenantServicesthe authoritative, agnostic Service store. Every row carries its own behavior (Label,Icon,Shape,RateUnit,HasDuration,CapacityKind,SortOrder);ServiceTypebecomes a per-tenant slug. The five built-ins becomeSERVICE_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
ServiceTypeCHECKs (PRAGMA defer_foreign_keysfor D1's in-transaction parent-table drop), backfills behavior columns from templates, seeds all five built-in rows per tenant (missing ones disabled). Covered bymigration-0006.test.tsand applied to local D1.estimateCost,checkAvailability,monthAvailability);listCapacityRowsjoinsTenantServicesso custom pool services count toward capacity; calendar sync carries the row's label;isServiceTypeguards replaced by tenant-row lookups.POST /admin/services{template, label} (created disabled; priced + enabled via the normal settings PUT) andDELETE /admin/services/:type(custom only; refused with booking history). Settings GET serves rows + the template list.iconfield with paw fallback; rendering was already generic.Verification
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
npx wrangler d1 execute pawbook-db --remote --file ./migrations/0006_custom_services.sqlat deploy time.🤖 Generated with Claude Code