From b1e50198a9d86fd4d6e137a5013fa2281d2f9bda Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 11:35:01 -0400 Subject: [PATCH 01/13] Add design spec for sitter booking dashboard (confirm/reject + calendar reconciliation) Co-Authored-By: Claude Sonnet 5 --- ...6-07-10-sitter-booking-dashboard-design.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md diff --git a/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md b/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md new file mode 100644 index 0000000..3acf042 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md @@ -0,0 +1,230 @@ +# Sitter booking dashboard: confirm/reject + calendar reconciliation — design + +**Date:** 2026-07-10 +**Status:** Approved (pending spec review) +**Branch target:** off `custom-services` + +## Problem + +There is no admin UI for a sitter to see incoming booking requests or act on +them. `insertBookingRequest` (`server/db/repo.ts:241`) creates every booking +as `Status='pending'` or `'confirmed'` and that's the end of the road — no +route lists bookings for a tenant, no route changes a booking's status, and +`app/admin/App.tsx`'s `SECTIONS` array has no bookings entry. Every booking is +also pushed to Google Calendar on creation (`syncBookingToCalendar`, +`server/routes/bookings.ts:185`), but nothing ever reads back — if a sitter or +customer edits/deletes the event directly in Google Calendar, Pawbook's DB +never finds out, so the dashboard would show stale state. + +This exact feature (confirm/decline list + status route) already exists on +`main` — merged via #15 and #17, function-for-function what this spec ports — +but `main` and `custom-services` have diverged: `custom-services` replaced the +static `SERVICE_CATALOG` with per-tenant `TenantServices` rows. Investigation +below found the divergence doesn't actually touch the booking code path +(neither the route nor the UI component reference `SERVICE_CATALOG`), so this +is a near-verbatim port, not a rewrite. + +## Goals + +1. A sitter can see all booking requests for their tenant, grouped into + "needs your reply" (pending) and everything else, and confirm or decline a + pending request, or cancel a confirmed one. +2. Every dashboard load reconciles against Google Calendar: if a synced + booking's calendar event was deleted directly in Calendar, the booking is + automatically marked cancelled before the list is returned. +3. Reconciliation is cheap — a short-TTL KV cache means back-to-back page + loads don't each trigger a Calendar API call. +4. The customer is notified by email on any status change, best-effort, + matching the existing "never let email failure affect the booking" + philosophy already used elsewhere in this codebase. + +## Non-goals + +- Deleting or updating the Google Calendar event when a sitter cancels or + declines from the dashboard. `main` shipped with this same gap (see its + `// ponytail:` comment on the status route) — carried forward unchanged + here; revisit if sitters complain about stale calendar entries. +- Reconciling in the other direction in more detail than "event exists / + doesn't exist" — e.g. detecting a *time change* on the calendar event and + reflecting it back onto `BookingRequests.StartDate`/`StartTime`. Out of + scope; a missing event is the only signal this pass acts on. +- A manual "sync now" button. The cache TTL (Goal 3) is short enough that a + sitter reloading the page is sufficient; add a button later only if the TTL + proves too coarse in practice. +- Blocked-day rows (`ServiceType='blocked'`) — excluded from every query and + route in this feature, same as `main`. + +## Alternatives considered + +**Cloudflare Cron Trigger for reconciliation**, decoupled from dashboard +loads, reconciling all tenants on a fixed schedule. Rejected for now: adds a +new infrastructure primitive (`wrangler.jsonc` cron config, a new entry +point) for a prototype with a handful of tenants, and still leaves a lag +window up to the schedule interval. The on-demand + KV-cache approach +(Goal 3) gets "reasonably fresh, never hammers the API" without new +infrastructure, reusing a pattern (`tenant-resolve.ts`) already proven in +this codebase. Revisit if tenant count grows enough that per-load +reconciliation cost matters. + +**Flag-only reconciliation** (show a mismatch warning, never auto-write) was +considered and rejected per your answer during brainstorming — auto-cancelling +keeps the list trustworthy without an extra manual step, and cancelling is +already the correct terminal state for "the calendar event is gone." + +## Design + +### 1. Schema + +New migration `migrations/0007_booking_lifecycle.sql` (+ `sql/schema.sql` +updated in lockstep), verbatim from `main`: + +```sql +ALTER TABLE BookingRequests ADD COLUMN Declined INTEGER NOT NULL DEFAULT 0; +``` + +`Status` stays `CHECK (Status IN ('pending', 'confirmed', 'cancelled'))` — +unchanged. A sitter's decline is stored as `Status='cancelled'` + +`Declined=1` rather than widening the `CHECK`, because SQLite `CHECK` +constraints can't be altered without a full table rebuild +(`ALTER TABLE ... ADD COLUMN` is cheap; changing a `CHECK` is not). This is +`main`'s already-reviewed approach — reused rather than re-litigated. + +`BOOKING_COLS` (`server/db/repo.ts:28`) gains `Declined`. A new +`BOOKING_COLS_QUALIFIED` constant (`` `BookingRequests.${c}` `` for each +column in `BOOKING_COLS`) is needed for the two new JOIN queries below — +today's plain `BOOKING_COLS` string is unqualified, and both `BookingRequests` +and `EndUsers` have an `Id` column, which is ambiguous once joined. + +### 2. Repo layer (`server/db/repo.ts`) + +Three functions, ported from `main` with `BOOKING_COLS_QUALIFIED` swapped in +for `main`'s equivalent constant name: + +- `listBookingsForTenant(db, tenantId)` — all non-blocked bookings for a + tenant, `LEFT JOIN EndUsers` for `Email`/`Name`, ordered newest-first. +- `updateBookingStatus(db, tenantId, id, status)` — the sitter-driven + transition. Guard is entirely in the SQL `WHERE` clause (atomic with the + write): `'declined'` only matches `Status='pending'` rows and sets + `Status='cancelled', Declined=1`; `'confirmed'`/`'cancelled'` match any + non-cancelled, non-blocked row. Returns whether a row actually changed. +- `getBookingWithCustomer(db, tenantId, id)` — one booking joined with + customer contact info, for the notification email. + +### 3. Admin routes (`server/routes/admin.ts`) + +Ported verbatim from `main`, added alongside the existing customer/pet +routes (same file, same `adminAuth`/`tenantMiddleware` stack every other +admin route already uses): + +- `GET /:slug/admin/bookings` — calls the KV-cached reconciliation (Section 5) + first, then `listBookingsForTenant`, mapping `Declined ? 'declined' : + Status` into the response `status` field (main's derived-status pattern). +- `POST /:slug/admin/bookings/:id/status` — validates `status` is one of + `confirmed`/`declined`/`cancelled`, calls `updateBookingStatus`, 404s if no + row changed. On success, best-effort emails the customer via + `sendBookingStatusEmail` (Section 4) if `isEmailConfigured(c.env)` and the + booking has a customer email; returns `{ status, notified }` so the + dashboard can honestly tell the sitter whether the client was told. + +### 4. Email (`server/lib/email.ts`) + +New `sendBookingStatusEmail(env, to, displayName, statusWord, whenText)`, +ported verbatim from `main` — same `resendPost` call shape as the existing +`sendLoginCode`/`sendInvite`, throws if email isn't configured (caller +catches and reports `notified: false`, same pattern already used for every +other best-effort email in this codebase). + +### 5. Calendar reconciliation (`server/lib/calendar-sync.ts`) + +New function: + +```ts +export async function reconcileBookingsWithCalendar(env: Env, tenant: Tenant): Promise +``` + +- No-ops immediately if there's no connected calendar provider + (`getProviderConnection(..., 'calendar')` missing or `Status !== + 'connected'`) — matches the existing guard at the top of + `syncBookingToCalendar`. +- Lists events via the already-existing `listCalendarEvents` (already reads + back `extendedProperties.private.bookingId`, since `buildEventResource` + writes it on every synced event) over a fixed window: today − 1 day + through +180 days. A fixed window is simplest and matches this prototype's + scale; if bookings routinely land further out, switch to querying + `MIN`/`MAX(StartDate)` from open bookings instead. +- Builds a `Set` of the `bookingId`s present in that event list. +- Queries `BookingRequests` for this tenant where `GCalEventId IS NOT NULL` + and `Status != 'cancelled'`; for any row whose `Id` is missing from the + Calendar set, calls `updateBookingStatus(..., 'cancelled')` (plain cancel, + not decline — the sitter didn't decline it, the calendar event just isn't + there anymore). +- Wrapped in `try/catch` at the call site (Section 6) — a Calendar API + failure (revoked token, rate limit, network) must never block the + dashboard from returning current DB state, matching the "Google failure + must never affect a booking" comment already on `syncBookingToCalendar`. + +### 6. KV cache (new function in `calendar-sync.ts`, alongside reconciliation) + +Mirrors `tenant-resolve.ts`'s read-through pattern exactly: + +```ts +const CALENDAR_SYNC_TTL_SECONDS = 120; +const calendarSyncKey = (tenantId: string) => `calendar-sync:${tenantId}:last`; + +export async function reconcileIfStale(env: Env, tenant: Tenant): Promise { + const key = calendarSyncKey(tenant.Id); + if (await env.PAWBOOK_CACHE.get(key)) return; // reconciled recently, skip + try { + await reconcileBookingsWithCalendar(env, tenant); + } catch { + /* best-effort; next load tries again once the (unwritten) cache key expires */ + } + await env.PAWBOOK_CACHE.put(key, '1', { expirationTtl: CALENDAR_SYNC_TTL_SECONDS }); +} +``` + +The cache key is written *after* the attempt (success or failure) so a +transient failure doesn't cause every subsequent load in the TTL window to +retry — it backs off for the full TTL either way, same as any other +best-effort background reconciliation. `GET /:slug/admin/bookings` +(Section 3) calls `reconcileIfStale` before `listBookingsForTenant`. + +### 7. Frontend + +- `app/admin/sections/BookingsSection.tsx` — new file, ported verbatim from + `main`'s (confirmed via direct diff that it has no `SERVICE_CATALOG` + dependency: `b.type` renders the raw `ServiceType` slug, no label lookup). + Pending rows get Confirm/Decline buttons; confirmed rows get Cancel + (with a native `confirm()` guard, matching `main`); a save-bar-style + message reports whether the customer was emailed. +- `app/shared-ui/api.ts` — add `AdminBooking` type and an `adminApi.bookings` + namespace (`list`, `setStatus`), ported verbatim from `main`. +- `app/admin/App.tsx` — add `'bookings'` to the `SectionKey` union and + `SECTIONS` array (`app/admin/App.tsx:119-121`), rendered the same + registry-driven way every other section already is. + +## Error handling + +- Calendar reconciliation failures are swallowed (Section 5/6) — dashboard + always falls back to current DB state. +- Invalid status-transition attempts (e.g. declining an already-confirmed + booking) are simply no-ops at the DB layer (`updateBookingStatus` returns + `false`) and surface as a 404 from the route, matching the existing + "row didn't change → not found" idiom used elsewhere in this file (e.g. + `removeEndUserPet`). +- Email failures never block the status change — `notified: false` is + reported, matching `main`'s exact behavior. + +## Testing + +- `server/__tests__/admin-bookings.test.ts` (new, mirrors the existing + per-concern test file convention): `updateBookingStatus` transition guards + (pending→confirmed/declined, confirmed→cancelled, cancelled is terminal, + blocked rows never match), `GET`/`POST` route behavior including the + `notified` flag. +- `server/__tests__/calendar-sync.test.ts` (extend existing, or new file if + none exists yet): `reconcileBookingsWithCalendar` cancels a booking whose + event is missing from `listCalendarEvents`, leaves one whose event is + present untouched, no-ops with no connected calendar provider. +- Cache behavior: a mock KV covering the skip-on-fresh-key / + reconcile-on-miss branches of `reconcileIfStale`. From b446f284c732e8621ac0d6e3b3ba4d1820b6489a Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 12:55:37 -0400 Subject: [PATCH 02/13] Ignore .worktrees/ for isolated subagent-driven implementation workspaces Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2245dfc..3b1b0f7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ worker-configuration.d.ts *.tsbuildinfo .superpowers/ docs/superpowers/plans/ +.worktrees/ client_secret* CALENDAR_LOGIC.md CLAUDE.md From 3280ecf726e3fcfe1a7f46157abbd7d63a9415ae Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 17:46:50 -0400 Subject: [PATCH 03/13] Add design spec for CSV client/pet import Co-Authored-By: Claude Sonnet 5 --- .../2026-07-10-csv-client-import-design.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-csv-client-import-design.md diff --git a/docs/superpowers/specs/2026-07-10-csv-client-import-design.md b/docs/superpowers/specs/2026-07-10-csv-client-import-design.md new file mode 100644 index 0000000..baed833 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-csv-client-import-design.md @@ -0,0 +1,240 @@ +# CSV client/pet import — design + +**Date:** 2026-07-10 +**Status:** Approved (pending spec review) +**Branch target:** off `custom-services` + +## Problem + +Sitters onboarding to Pawbook with an existing client list today can only add +customers and pets one at a time via `ClientsSection.tsx`'s inline forms +(`POST /:slug/admin/customers`, `POST /:slug/admin/customers/:id/pets`, +`server/routes/admin.ts:548-604`). There's no way to bring in a spreadsheet of +existing clients and their pets in one action. + +## Goals + +1. A sitter can upload a CSV of clients and their pets and have them imported + in one action. +2. Re-uploading the same file (or an updated version) is safe — it never + creates duplicate clients or duplicate pets, whether the duplicate is + within the same file or against data already in the database. +3. A file with some bad rows still imports everything that's valid, and tells + the sitter exactly which rows were skipped and why. +4. The sitter chooses whether importing sends the same "you're invited" + email a manual add would trigger — useful when migrating a historical + list nobody should be emailed about yet. +5. A documented example CSV exists both as a file in the repo and as a + one-click download from the import UI, so sitters know the exact format + to follow. + +## Non-goals + +- Editing or removing existing clients/pets via CSV — this is additive only + (create-or-reuse), matching `insertInvitedCustomer`'s existing semantics. + A follow-up CSV *export* or *update* flow is out of scope. +- A preview-then-confirm step before committing the import. Because the + import is already non-destructive and idempotent (Goal 2), there's nothing + to undo — the sitter gets the full result report (what was created, what + was skipped and why) immediately after uploading, not before. +- Any field not already on `EndUser`/`EndUserPet` (phone, pet notes/breed, + etc.). Adding those would need a schema migration unrelated to the import + mechanism itself; the CSV format only covers `Email`, `Name` (client) and + `Name`, `Type` (pet) — the same fields the single-add forms already + collect. +- True `multipart/form-data` upload handling. No route in this codebase + parses multipart bodies today; adding that machinery buys nothing here + since CSV files of a sitter's client list are trivially small. + +## Alternatives considered + +**Client-side CSV parsing**, sending a pre-parsed JSON array of row objects +instead of raw CSV text. Rejected: validation must be authoritative on the +server regardless (client input can't be trusted), so parsing client-side +would mean maintaining the parsing/validation logic in two places for no +benefit — this codebase's only test infrastructure is server-side +(`server/__tests__/*.test.ts`; there is no frontend test setup), so keeping +all of it server-side keeps it testable in the existing way. + +**True multipart file upload** (`c.req.formData()`). Rejected: this would be +the first multipart route in the codebase, adding a new request-parsing +pattern for a file that's a few KB of text. Sending the file's text content +as a plain JSON string field reuses the exact `adminApi`/`request()` +convention every other admin mutation already uses +(`app/shared-ui/api.ts:92-97`). + +**Chosen: browser reads the file as text via the native `File.text()` API**, +POSTs `{ csv: string, sendInvites: boolean }` as a normal JSON body to a new +route, which does all parsing, validation, dedup, and importing server-side +in one pass. + +## Design + +### 1. CSV format + +Four columns, one row per pet (a client with multiple pets repeats across +multiple rows; a client with no pets yet gets one row with the pet columns +blank): + +``` +Client Email,Client Name,Pet Name,Pet Type +jess@example.com,Jess Nguyen,Bella,dog +jess@example.com,Jess Nguyen,Mochi,cat +sam@example.com,Sam Diaz,Rex,dog +team@example.com,Team Co,, +``` + +- `Client Email` — required, validated against the existing `EMAIL_RE` + (`server/lib/validation.ts:10`). +- `Client Name` — optional, same as the single-add form (blank → `null`). +- `Pet Name` / `Pet Type` — both optional, but only meaningful together: + either both blank (client-only row) or both present. `Pet Type` must be + `dog` or `cat` (case-insensitive) **and** enabled for that tenant (same + two-part check the single-add pet route already applies — + `isPetType` + `listPetTypes(...).Enabled`, `server/routes/admin.ts:594, + 599-602`). + +An example file matching this exact format is committed to the repo at +`docs/examples/clients-import-example.csv` and served as a static download +link from the import UI (Section 4). + +### 2. CSV parser (`server/lib/csv.ts`, new file) + +A small hand-rolled row parser — not a dependency — since the format is four +fixed columns and the only real complexity is RFC4180 quoting (a client name +containing a comma, e.g. `"Doe, Jane"`, exported from Excel/Sheets with +quotes). One function: + +```ts +export function parseCsvRows(text: string): string[][] +``` + +Splits on newlines (tolerating `\r\n`), splits each line on commas outside +quotes, and un-escapes `""` → `"` inside a quoted field. The caller (Section +3) treats row 1 as the header and ignores it (column order is fixed, not +name-matched — matching the fixed 4-column format above; a header with +different labels or order is not supported in this pass). + +### 3. Import route (`server/routes/admin.ts`) + +New route, alongside the existing customer routes: + +`POST /:slug/admin/customers/import` — body `{ csv: string, sendInvites: boolean }`. + +For each parsed row (1-indexed against the sitter's actual file, header = row 1): + +1. Validate `Client Email`. Invalid/blank → skip the whole row, record + `{ row, reason: 'Invalid email address' }`. +2. `insertInvitedCustomer(db, tenant.Id, email, name)` — already idempotent + by `(TenantId, Email)` (`server/db/repo.ts:578-583`), reused as-is. This + is what makes re-uploading the same file safe for the client half of + every row. +3. If `Pet Name`/`Pet Type` are both blank, row is done (client-only row). + If exactly one of the two is present, skip just the pet with a reason + (`'Pet type given without a pet name'` / `'Pet name given without a pet + type'`) — the client from step 2 is still kept. +4. If both are present: validate `Pet Type` (same two checks as the + single-add route). Invalid → skip just the pet, record the reason (client + from step 2 is still kept). +5. **Dedup check**: before inserting, check whether this customer already + has a pet with this name — an in-memory `Map>` + for the request, seeded per-customer on first encounter from + `listAllEndUserPetsByTenant` (already fetched once for the whole tenant, + `server/db/repo.ts` — reused to build the seed map rather than querying + per row) and updated as pets are added during this same run. A name + already in the set → skip, record `{ row, reason: 'Pet already exists for + this client' }`. This one check is what makes both "duplicate row within + this file" and "re-uploading the same file later" safe — both look + identical to it. +6. Otherwise `addEndUserPet(db, tenant.Id, endUserId, name, petType)` and add + the name to the dedup set. + +After all rows: if `sendInvites` is true, for every customer whose `Status` +came back `'invited'` from step 2 (freshly created this run — reusing the +same distinction the single-add route already makes), send the invite via +`sendInvite` (`server/lib/email.ts`) with the same `widgetUrl` construction +the single-add route uses (`admin.ts:562-564`). **Best-effort per email** — +unlike the single-add route's `502` on send failure, a bulk import must +never fail the whole request over one bad email address; a failed send is +counted in `invitesFailed`, not surfaced as a skipped row (the customer/pet +records are still created either way). + +Response: + +```ts +{ + importedCustomers: number, // count of rows whose client was newly created (not reused) + importedPets: number, + invitesSent: number, + invitesFailed: number, + skippedRows: Array<{ row: number; reason: string }>, +} +``` + +### 4. Frontend (`app/admin/sections/ClientsSection.tsx`, `app/shared-ui/api.ts`, `app/admin/shared.ts`) + +- A file ``, a "Send invite emails to new + clients" checkbox (defaulting unchecked — the safer default per Goal 4, + matching "migrating a historical list" being the more surprising direction + to get wrong), an "Import" button, and a "Download example CSV" link + (points at `docs/examples/clients-import-example.csv`, copied into the + built `dist/` assets the same way other static content is served — see + Section 5). +- On import, `file.text()` reads the selected file, POSTs to the new + `adminApi.customers.import(slug, token, csv, sendInvites)` (added to + `app/shared-ui/api.ts` next to the existing `customers` namespace). +- The result renders as an inline panel within `ClientsSection` (not the + transient sticky save-bar other mutations use) — showing the counts and, + when non-empty, the full `skippedRows` list with row numbers and reasons — + because this result can be multiple lines and the sitter needs time to + read it, unlike the 4-second transient success messages elsewhere. +- After a successful import, the customer list refreshes the same way + `addCustomer`/`addPet` already trigger a refresh today (`App.tsx`'s + `withCustomerRefresh`/`reloadCustomers` pattern). + +### 5. Example CSV file + +`docs/examples/clients-import-example.csv` — the exact 4-row example shown +in Section 1. Needs to be reachable as a real download from the admin UI; +the simplest option reusing this repo's existing static-serving setup +(Vite build → `ASSETS` binding) is copying it into `public/` (Vite's +convention for files served as-is, unprocessed) rather than `docs/`, so it +ships at a stable URL. Decision: keep the source of truth in +`docs/examples/` (near this spec, easy for a developer to find) and also +place an identical copy in `public/clients-import-example.csv` for the +actual download link — two files, not a build step, since this is a single +static file that changes rarely. + +## Error handling + +- Malformed CSV (e.g. inconsistent quoting) — the parser's job is to not + throw; a line that can't be cleanly split is treated as an invalid row + (all its columns skipped, reason `'Could not parse this row'`) rather than + aborting the whole import. +- An empty file (header only or fully empty) — returns the same response + shape with all counts zero, not an error; the sitter sees "0 clients + imported" rather than a cryptic failure. +- Invite email failures never abort the import or fail the request — counted + in `invitesFailed`, matching the "email must never affect what it's + attached to" pattern already established for booking-status emails + elsewhere in this codebase. + +## Testing + +`server/__tests__/customers-import.test.ts` (new): + +- Parser: quoted fields with embedded commas, `""`-escaped quotes within a + quoted field, `\r\n` line endings, a trailing blank line. +- Happy path: a multi-row file creating several clients and pets, correct + `importedCustomers`/`importedPets` counts. +- Skip reasons: invalid email, disabled/unknown pet type, pet name without a + type and vice versa — each produces the right `reason` string and the + right row number. +- Dedup: the same client+pet appearing twice in one file only creates one + pet; re-running the exact same import a second time creates zero new pets + and zero new clients (client reused via existing idempotency, pet skipped + via the new dedup check). +- `sendInvites` toggle: `true` sends invites only for freshly-created + clients (not ones that already existed), `false` sends none; a mocked + failing send is reflected in `invitesFailed` without failing the request + or skipping the client/pet records. From 5bd9b7a539af2745ef67426f814170cc29acd129 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 17:53:37 -0400 Subject: [PATCH 04/13] Add a minimal CSV row parser Co-Authored-By: Claude Sonnet 5 --- server/__tests__/csv-parser.test.ts | 45 +++++++++++++++++++++++++++++ server/lib/csv.ts | 42 +++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 server/__tests__/csv-parser.test.ts create mode 100644 server/lib/csv.ts diff --git a/server/__tests__/csv-parser.test.ts b/server/__tests__/csv-parser.test.ts new file mode 100644 index 0000000..02db4e2 --- /dev/null +++ b/server/__tests__/csv-parser.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { parseCsvRows } from '../lib/csv'; + +describe('parseCsvRows', () => { + it('splits a simple comma-separated file into rows and cells', () => { + const text = 'a,b,c\n1,2,3\n'; + expect(parseCsvRows(text)).toEqual([ + ['a', 'b', 'c'], + ['1', '2', '3'], + ]); + }); + + it('handles a quoted field containing a comma', () => { + const text = 'Client Email,Client Name\njess@example.com,"Doe, Jane"'; + expect(parseCsvRows(text)).toEqual([ + ['Client Email', 'Client Name'], + ['jess@example.com', 'Doe, Jane'], + ]); + }); + + it('un-escapes doubled quotes inside a quoted field', () => { + const text = 'a\n"She said ""hi"""'; + expect(parseCsvRows(text)).toEqual([['a'], ['She said "hi"']]); + }); + + it('tolerates CRLF line endings', () => { + const text = 'a,b\r\n1,2\r\n'; + expect(parseCsvRows(text)).toEqual([ + ['a', 'b'], + ['1', '2'], + ]); + }); + + it('skips blank lines', () => { + const text = 'a,b\n\n1,2\n\n'; + expect(parseCsvRows(text)).toEqual([ + ['a', 'b'], + ['1', '2'], + ]); + }); + + it('returns an empty array for an empty string', () => { + expect(parseCsvRows('')).toEqual([]); + }); +}); diff --git a/server/lib/csv.ts b/server/lib/csv.ts new file mode 100644 index 0000000..afb88e0 --- /dev/null +++ b/server/lib/csv.ts @@ -0,0 +1,42 @@ +/** + * Minimal RFC4180-ish CSV row parser: splits on commas outside quotes, un-escapes `""` to `"` + * inside a quoted field. Not a general CSV library — this codebase's only CSV use is the fixed + * 4-column client/pet import format (see + * docs/superpowers/specs/2026-07-10-csv-client-import-design.md), so a small hand-rolled parser + * avoids a dependency for a narrow, well-defined need. + */ +export function parseCsvRows(text: string): string[][] { + const rows: string[][] = []; + const lines = text.split(/\r\n|\r|\n/); + for (const line of lines) { + if (line === '') continue; + const cells: string[] = []; + let cell = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + cell += '"'; + i++; + } else { + inQuotes = false; + } + } else { + cell += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + cells.push(cell); + cell = ''; + } else { + cell += ch; + } + } + cells.push(cell); + rows.push(cells); + } + return rows; +} From e86c5028fcda8de7164bf1953891b2c0539af7e8 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 17:58:41 -0400 Subject: [PATCH 05/13] 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 --- server/__tests__/customers-import.test.ts | 145 ++++++++++++++++++++++ server/routes/admin.ts | 91 ++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 server/__tests__/customers-import.test.ts diff --git a/server/__tests__/customers-import.test.ts b/server/__tests__/customers-import.test.ts new file mode 100644 index 0000000..b62e26c --- /dev/null +++ b/server/__tests__/customers-import.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import app from '../index'; +import { adminToken, createTestEnv, TENANT_A } from './helpers'; + +type ImportResult = { + importedCustomers: number; + importedPets: number; + invitesSent: number; + invitesFailed: number; + skippedRows: { row: number; reason: string }[]; +}; + +async function importCsv( + env: Env, + csv: string, + sendInvites = false, +): Promise<{ status: number; body: ImportResult }> { + const token = await adminToken(TENANT_A); + const res = await app.request( + '/api/sunny-paws/admin/customers/import', + { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ csv, sendInvites }), + }, + env, + ); + return { status: res.status, body: (await res.json()) as ImportResult }; +} + +describe('POST /:slug/admin/customers/import', () => { + afterEach(() => vi.restoreAllMocks()); + + it('imports clients and pets from a well-formed CSV', async () => { + const { env } = createTestEnv(); + const csv = + 'Client Email,Client Name,Pet Name,Pet Type\n' + + 'new1@example.com,New One,Fido,dog\n' + + 'new1@example.com,New One,Whiskers,cat\n' + + 'new2@example.com,New Two,,\n'; + const { status, body } = await importCsv(env, csv); + expect(status).toBe(200); + expect(body.importedCustomers).toBe(2); + expect(body.importedPets).toBe(2); + expect(body.skippedRows).toEqual([]); + }); + + it('reports the row and reason for an invalid email', async () => { + const { env } = createTestEnv(); + const csv = 'Client Email,Client Name,Pet Name,Pet Type\nnot-an-email,X,,\n'; + const { body } = await importCsv(env, csv); + expect(body.skippedRows).toEqual([{ row: 2, reason: 'Invalid email address' }]); + expect(body.importedCustomers).toBe(0); + }); + + it('skips a disabled/unknown pet type but still creates the client', async () => { + const { env } = createTestEnv(); + const csv = 'Client Email,Client Name,Pet Name,Pet Type\nnew3@example.com,X,Ferret,ferret\n'; + const { body } = await importCsv(env, csv); + expect(body.importedCustomers).toBe(1); + expect(body.importedPets).toBe(0); + expect(body.skippedRows).toEqual([{ row: 2, reason: "'ferret' is not an enabled pet type" }]); + }); + + it('skips a pet name given without a type, and vice versa', async () => { + const { env } = createTestEnv(); + const csv = + 'Client Email,Client Name,Pet Name,Pet Type\n' + + 'new4@example.com,X,Rex,\n' + + 'new5@example.com,Y,,dog\n'; + const { body } = await importCsv(env, csv); + expect(body.skippedRows).toEqual([ + { row: 2, reason: 'Pet name given without a pet type' }, + { row: 3, reason: 'Pet type given without a pet name' }, + ]); + expect(body.importedCustomers).toBe(2); + }); + + it('dedups a pet appearing twice in the same file, and across a repeated import', async () => { + const { env } = createTestEnv(); + const csv = + 'Client Email,Client Name,Pet Name,Pet Type\n' + + 'dup@example.com,X,Bella,dog\n' + + 'dup@example.com,X,Bella,dog\n'; + const first = await importCsv(env, csv); + expect(first.body.importedPets).toBe(1); + expect(first.body.skippedRows).toEqual([ + { row: 3, reason: 'Pet already exists for this client' }, + ]); + + const second = await importCsv(env, csv); + expect(second.body.importedCustomers).toBe(0); // client already existed + expect(second.body.importedPets).toBe(0); + expect(second.body.skippedRows).toHaveLength(2); + }); + + it('only sends invites for genuinely new customers, never for a pre-existing one', async () => { + const { env } = createTestEnv(); + // jess@example.com is a seeded pre-existing customer for sunny-paws — must not be re-invited. + const csv = + 'Client Email,Client Name,Pet Name,Pet Type\n' + + 'jess@example.com,Jess,,\n' + + 'brandnew@example.com,New,,\n'; + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })); + const envWithEmail = { ...env, RESEND_API_KEY: 'k', RESEND_FROM: 'Pawbook ' } as Env; + const { body } = await importCsv(envWithEmail, csv, true); + expect(body.importedCustomers).toBe(1); // only brandnew@ + expect(body.invitesSent).toBe(1); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('does not fail the request when an invite send fails; counts it instead', async () => { + const { env } = createTestEnv(); + const csv = 'Client Email,Client Name,Pet Name,Pet Type\nfailmail@example.com,X,,\n'; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + const envWithEmail = { ...env, RESEND_API_KEY: 'k', RESEND_FROM: 'Pawbook ' } as Env; + const { status, body } = await importCsv(envWithEmail, csv, true); + expect(status).toBe(200); + expect(body.importedCustomers).toBe(1); + expect(body.invitesFailed).toBe(1); + expect(body.invitesSent).toBe(0); + }); + + it('does not send invites when sendInvites is false, even with email configured', async () => { + const { env } = createTestEnv(); + const csv = 'Client Email,Client Name,Pet Name,Pet Type\nnoinvite@example.com,X,,\n'; + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })); + const envWithEmail = { ...env, RESEND_API_KEY: 'k', RESEND_FROM: 'Pawbook ' } as Env; + const { body } = await importCsv(envWithEmail, csv, false); + expect(body.invitesSent).toBe(0); + expect(spy).not.toHaveBeenCalled(); + }); + + it('treats an empty file (header only) as zero imports, not an error', async () => { + const { env } = createTestEnv(); + const { status, body } = await importCsv(env, 'Client Email,Client Name,Pet Name,Pet Type\n'); + expect(status).toBe(200); + expect(body.importedCustomers).toBe(0); + expect(body.skippedRows).toEqual([]); + }); +}); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index ab873d8..e75db2d 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -8,6 +8,7 @@ import { countBookingsForUser, createService, getEndUserById, + getEndUserByEmail, deleteBlockedRange, deleteCustomer, deleteService, @@ -30,6 +31,7 @@ import { updateTenantSettings, } from '../db/repo'; import { isEmailConfigured, sendInvite } from '../lib/email'; +import { parseCsvRows } from '../lib/csv'; import { buildAuthUrl, revokeToken } from '../lib/google-calendar'; import { adminAuth } from '../lib/middleware'; import { signState } from '../lib/oauth-state'; @@ -610,4 +612,93 @@ export const adminRoutes = new Hono() const removed = await removeEndUserPet(c.env.PAWBOOK_DB, tenant.Id, c.req.param('petId')); if (!removed) return c.json({ error: 'Not found.' }, 404); return c.body(null, 204); + }) + .post('/:slug/admin/customers/import', async (c) => { + const tenant = c.get('tenant'); + const body = await c.req + .json<{ csv?: unknown; sendInvites?: unknown }>() + .catch(() => ({}) as { csv?: unknown; sendInvites?: unknown }); + const csv = typeof body.csv === 'string' ? body.csv : ''; + const sendInvites = body.sendInvites === true; + + const rows = parseCsvRows(csv).slice(1); // row 1 is the header + const petTypesEnabled = new Set( + (await listPetTypes(c.env.PAWBOOK_DB, tenant.Id)) + .filter((pt) => pt.Enabled) + .map((pt) => pt.PetType), + ); + const existingPetNames = new Map>(); + for (const pet of await listAllEndUserPetsByTenant(c.env.PAWBOOK_DB, tenant.Id)) { + const set = existingPetNames.get(pet.EndUserId) ?? new Set(); + set.add(pet.Name.toLowerCase()); + existingPetNames.set(pet.EndUserId, set); + } + + let importedCustomers = 0; + let importedPets = 0; + let invitesSent = 0; + let invitesFailed = 0; + const skippedRows: { row: number; reason: string }[] = []; + const freshCustomers: { email: string; endUserId: string }[] = []; + + for (const [i, cells] of rows.entries()) { + const row = i + 2; // 1-indexed against the sitter's file; +1 since the header was sliced off + if (cells.length < 4) { + skippedRows.push({ row, reason: 'Could not parse this row' }); + continue; + } + const [rawEmail, rawName, rawPetName, rawPetType] = cells; + const email = rawEmail.trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + skippedRows.push({ row, reason: 'Invalid email address' }); + continue; + } + + const existing = await getEndUserByEmail(c.env.PAWBOOK_DB, tenant.Id, email); + const name = rawName.trim() || null; + const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name); + if (!existing && !freshCustomers.some((f) => f.endUserId === customer.Id)) { + importedCustomers++; + freshCustomers.push({ email, endUserId: customer.Id }); + } + + const petName = rawPetName.trim(); + const petType = rawPetType.trim().toLowerCase(); + if (!petName && !petType) continue; // client-only row + if (petName && !petType) { + skippedRows.push({ row, reason: 'Pet name given without a pet type' }); + continue; + } + if (!petName && petType) { + skippedRows.push({ row, reason: 'Pet type given without a pet name' }); + continue; + } + if (!isPetType(petType) || !petTypesEnabled.has(petType)) { + skippedRows.push({ row, reason: `'${rawPetType.trim()}' is not an enabled pet type` }); + continue; + } + const petSet = existingPetNames.get(customer.Id) ?? new Set(); + if (petSet.has(petName.toLowerCase())) { + skippedRows.push({ row, reason: 'Pet already exists for this client' }); + continue; + } + await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, customer.Id, petName, petType); + petSet.add(petName.toLowerCase()); + existingPetNames.set(customer.Id, petSet); + importedPets++; + } + + if (sendInvites && isEmailConfigured(c.env)) { + const widgetUrl = new URL(`/embed/${tenant.Slug}`, c.req.url).toString(); + for (const { email } of freshCustomers) { + try { + await sendInvite(c.env, email, tenant.DisplayName, widgetUrl); + invitesSent++; + } catch { + invitesFailed++; + } + } + } + + return c.json({ importedCustomers, importedPets, invitesSent, invitesFailed, skippedRows }); }); From af6bc0b4b9f8b3093e42400f275882c3f70d164d Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 18:02:59 -0400 Subject: [PATCH 06/13] 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 --- app/shared-ui/api.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/shared-ui/api.ts b/app/shared-ui/api.ts index 0fd2c38..15c7105 100644 --- a/app/shared-ui/api.ts +++ b/app/shared-ui/api.ts @@ -75,6 +75,14 @@ export type Customer = { pets: Pet[]; }; +export type ImportResult = { + importedCustomers: number; + importedPets: number; + invitesSent: number; + invitesFailed: number; + skippedRows: { row: number; reason: string }[]; +}; + export class ApiError extends Error { constructor( public status: number, @@ -187,6 +195,12 @@ export const adminApi = { method: 'DELETE', headers: authHeaders(token), }), + import: (slug: string, token: string, csv: string, sendInvites: boolean) => + request(`/api/${slug}/admin/customers/import`, { + method: 'POST', + headers: { ...jsonHeaders, ...authHeaders(token) }, + body: JSON.stringify({ csv, sendInvites }), + }), }, calendar: { start: (slug: string, token: string) => From 28892978f164dad342541905d9b5d5bb8deb15d5 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 18:05:37 -0400 Subject: [PATCH 07/13] Add CSV import UI to the clients section --- app/admin/App.tsx | 15 +++++- app/admin/sections/ClientsSection.tsx | 67 +++++++++++++++++++++++- docs/examples/clients-import-example.csv | 5 ++ public/clients-import-example.csv | 5 ++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 docs/examples/clients-import-example.csv create mode 100644 public/clients-import-example.csv diff --git a/app/admin/App.tsx b/app/admin/App.tsx index bb4306c..c3364b4 100644 --- a/app/admin/App.tsx +++ b/app/admin/App.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } from 'react'; -import { adminApi, isAuthExpired, type Customer } from '../shared-ui/api.js'; +import { adminApi, isAuthExpired, type Customer, type ImportResult } from '../shared-ui/api.js'; import { IconCalendar, IconCode, @@ -360,6 +360,18 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => const removePet = (endUserId: string, petId: string) => withCustomerRefresh(() => adminApi.customers.removePet(slug, token, endUserId, petId)); + const importCsv = async (csv: string, sendInvites: boolean): Promise => { + setError(''); + try { + const result = await adminApi.customers.import(slug, token, csv, sendInvites); + setCustomers(await loadCustomers()); + return result; + } catch (e) { + handle(e); + return null; + } + }; + // Initial settings load: setState only inside the promise callback (react-hooks rule). useEffect(() => { let active = true; @@ -409,6 +421,7 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => addPet={addPet} removePet={removePet} enabledPetTypes={enabledPetTypes} + importCsv={importCsv} /> ), apps: ( diff --git a/app/admin/sections/ClientsSection.tsx b/app/admin/sections/ClientsSection.tsx index d06808a..30cca9f 100644 --- a/app/admin/sections/ClientsSection.tsx +++ b/app/admin/sections/ClientsSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { Customer } from '../../shared-ui/api.js'; +import type { Customer, ImportResult } from '../../shared-ui/api.js'; import { IconUsers } from '../../shared-ui/icons'; function PetAdder({ @@ -48,6 +48,7 @@ export function ClientsSection({ addPet, removePet, enabledPetTypes, + importCsv, }: { customers: Customer[]; custEmail: string; @@ -59,7 +60,25 @@ export function ClientsSection({ addPet: (endUserId: string, name: string, petType: string) => Promise; removePet: (endUserId: string, petId: string) => Promise; enabledPetTypes: string[]; + importCsv: (csv: string, sendInvites: boolean) => Promise; }) { + const [csvFile, setCsvFile] = useState(null); + const [sendInvites, setSendInvites] = useState(false); + const [importResult, setImportResult] = useState(null); + const [importing, setImporting] = useState(false); + + const runImport = async () => { + if (!csvFile || importing) return; + setImporting(true); + try { + const csv = await csvFile.text(); + const result = await importCsv(csv, sendInvites); + if (result) setImportResult(result); + } finally { + setImporting(false); + } + }; + return ( <>

@@ -83,6 +102,52 @@ export function ClientsSection({ /> +
+ { + setCsvFile(e.target.files?.[0] ?? null); + setImportResult(null); + }} + /> + + + + Download example CSV + +
+ {importResult && ( +
+

+ Imported {importResult.importedCustomers} client + {importResult.importedCustomers === 1 ? '' : 's'} and {importResult.importedPets} pet + {importResult.importedPets === 1 ? '' : 's'}. + {importResult.invitesSent > 0 ? ` Sent ${importResult.invitesSent} invite(s).` : ''} + {importResult.invitesFailed > 0 + ? ` ${importResult.invitesFailed} invite(s) failed to send.` + : ''} +

+ {importResult.skippedRows.length > 0 && ( +
    + {importResult.skippedRows.map((r) => ( +
  • + Row {r.row}: {r.reason} +
  • + ))} +
+ )} +
+ )}
    {customers.map((cust) => (
  • diff --git a/docs/examples/clients-import-example.csv b/docs/examples/clients-import-example.csv new file mode 100644 index 0000000..a9f39eb --- /dev/null +++ b/docs/examples/clients-import-example.csv @@ -0,0 +1,5 @@ +Client Email,Client Name,Pet Name,Pet Type +jess@example.com,Jess Nguyen,Bella,dog +jess@example.com,Jess Nguyen,Mochi,cat +sam@example.com,Sam Diaz,Rex,dog +team@example.com,Team Co,, diff --git a/public/clients-import-example.csv b/public/clients-import-example.csv new file mode 100644 index 0000000..a9f39eb --- /dev/null +++ b/public/clients-import-example.csv @@ -0,0 +1,5 @@ +Client Email,Client Name,Pet Name,Pet Type +jess@example.com,Jess Nguyen,Bella,dog +jess@example.com,Jess Nguyen,Mochi,cat +sam@example.com,Sam Diaz,Rex,dog +team@example.com,Team Co,, From 3df74a4a82e002c4c231740f09da5b5dd2037d41 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 19:27:03 -0400 Subject: [PATCH 08/13] Reset file input after successful CSV import Uncontrolled 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. --- app/admin/sections/ClientsSection.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/admin/sections/ClientsSection.tsx b/app/admin/sections/ClientsSection.tsx index 30cca9f..418955b 100644 --- a/app/admin/sections/ClientsSection.tsx +++ b/app/admin/sections/ClientsSection.tsx @@ -66,6 +66,7 @@ export function ClientsSection({ const [sendInvites, setSendInvites] = useState(false); const [importResult, setImportResult] = useState(null); const [importing, setImporting] = useState(false); + const [fileInputKey, setFileInputKey] = useState(0); const runImport = async () => { if (!csvFile || importing) return; @@ -73,7 +74,11 @@ export function ClientsSection({ try { const csv = await csvFile.text(); const result = await importCsv(csv, sendInvites); - if (result) setImportResult(result); + if (result) { + setImportResult(result); + setCsvFile(null); + setFileInputKey((k) => k + 1); + } } finally { setImporting(false); } @@ -104,6 +109,7 @@ export function ClientsSection({
    { From b865d09299682f98cdb1e82741886d07f07b4fa0 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 19:34:08 -0400 Subject: [PATCH 09/13] 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. --- server/__tests__/customers-import.test.ts | 29 ++++++++++ server/routes/admin.ts | 64 ++++++++++++----------- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/server/__tests__/customers-import.test.ts b/server/__tests__/customers-import.test.ts index b62e26c..037b4d4 100644 --- a/server/__tests__/customers-import.test.ts +++ b/server/__tests__/customers-import.test.ts @@ -142,4 +142,33 @@ describe('POST /:slug/admin/customers/import', () => { expect(body.importedCustomers).toBe(0); expect(body.skippedRows).toEqual([]); }); + + it('turns a single row DB failure into a skip instead of a 500', async () => { + vi.doMock('../db/repo', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + addEndUserPet: vi.fn().mockRejectedValueOnce(new Error('boom')), + }; + }); + vi.resetModules(); + const { default: freshApp } = await import('../index'); + const { env } = createTestEnv(); + const csv = 'Client Email,Client Name,Pet Name,Pet Type\nboom@example.com,X,Rex,dog\n'; + const token = await adminToken(TENANT_A); + const res = await freshApp.request( + '/api/sunny-paws/admin/customers/import', + { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ csv, sendInvites: false }), + }, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ImportResult; + expect(body.skippedRows).toEqual([{ row: 2, reason: 'Could not import this row' }]); + vi.doUnmock('../db/repo'); + vi.resetModules(); + }); }); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index e75db2d..4c4e1e1 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -654,38 +654,42 @@ export const adminRoutes = new Hono() continue; } - const existing = await getEndUserByEmail(c.env.PAWBOOK_DB, tenant.Id, email); - const name = rawName.trim() || null; - const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name); - if (!existing && !freshCustomers.some((f) => f.endUserId === customer.Id)) { - importedCustomers++; - freshCustomers.push({ email, endUserId: customer.Id }); - } + try { + const existing = await getEndUserByEmail(c.env.PAWBOOK_DB, tenant.Id, email); + const name = rawName.trim() || null; + const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name); + if (!existing && !freshCustomers.some((f) => f.endUserId === customer.Id)) { + importedCustomers++; + freshCustomers.push({ email, endUserId: customer.Id }); + } - const petName = rawPetName.trim(); - const petType = rawPetType.trim().toLowerCase(); - if (!petName && !petType) continue; // client-only row - if (petName && !petType) { - skippedRows.push({ row, reason: 'Pet name given without a pet type' }); - continue; - } - if (!petName && petType) { - skippedRows.push({ row, reason: 'Pet type given without a pet name' }); - continue; - } - if (!isPetType(petType) || !petTypesEnabled.has(petType)) { - skippedRows.push({ row, reason: `'${rawPetType.trim()}' is not an enabled pet type` }); - continue; - } - const petSet = existingPetNames.get(customer.Id) ?? new Set(); - if (petSet.has(petName.toLowerCase())) { - skippedRows.push({ row, reason: 'Pet already exists for this client' }); - continue; + const petName = rawPetName.trim(); + const petType = rawPetType.trim().toLowerCase(); + if (!petName && !petType) continue; // client-only row + if (petName && !petType) { + skippedRows.push({ row, reason: 'Pet name given without a pet type' }); + continue; + } + if (!petName && petType) { + skippedRows.push({ row, reason: 'Pet type given without a pet name' }); + continue; + } + if (!isPetType(petType) || !petTypesEnabled.has(petType)) { + skippedRows.push({ row, reason: `'${rawPetType.trim()}' is not an enabled pet type` }); + continue; + } + const petSet = existingPetNames.get(customer.Id) ?? new Set(); + if (petSet.has(petName.toLowerCase())) { + skippedRows.push({ row, reason: 'Pet already exists for this client' }); + continue; + } + await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, customer.Id, petName, petType); + petSet.add(petName.toLowerCase()); + existingPetNames.set(customer.Id, petSet); + importedPets++; + } catch { + skippedRows.push({ row, reason: 'Could not import this row' }); } - await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, customer.Id, petName, petType); - petSet.add(petName.toLowerCase()); - existingPetNames.set(customer.Id, petSet); - importedPets++; } if (sendInvites && isEmailConfigured(c.env)) { From c0a38531400b5280b038eb933c97eaf12e1621c6 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 22:16:32 -0400 Subject: [PATCH 10/13] 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 --- server/__tests__/customers-import.test.ts | 27 +++++++++++++++++++++++ server/routes/admin.ts | 16 ++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/server/__tests__/customers-import.test.ts b/server/__tests__/customers-import.test.ts index 037b4d4..62269b3 100644 --- a/server/__tests__/customers-import.test.ts +++ b/server/__tests__/customers-import.test.ts @@ -143,6 +143,33 @@ describe('POST /:slug/admin/customers/import', () => { expect(body.skippedRows).toEqual([]); }); + it('rejects a file over the row cap before touching the database', async () => { + const { env, raw } = createTestEnv(); + const countEndUsers = () => + (raw.prepare('SELECT COUNT(*) AS n FROM EndUsers').get() as { n: number }).n; + const before = countEndUsers(); + const header = 'Client Email,Client Name,Pet Name,Pet Type'; + const rows = Array.from( + { length: 501 }, + (_, n) => `over${n}@example.com,Over ${n},,`, + ).join('\n'); + const token = await adminToken(TENANT_A); + const res = await app.request( + '/api/sunny-paws/admin/customers/import', + { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ csv: `${header}\n${rows}`, sendInvites: false }), + }, + env, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/501 rows/); + expect(body.error).toMatch(/500 or fewer/); + expect(countEndUsers()).toBe(before); // no rows should have been processed at all + }); + it('turns a single row DB failure into a skip instead of a 500', async () => { vi.doMock('../db/repo', async (importOriginal) => { const actual = await importOriginal(); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 4c4e1e1..b733ea6 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -63,6 +63,14 @@ import type { ServiceQuestion } from '../../src/shared/index.js'; const COLOR_RE = /^#[0-9a-fA-F]{6}$/; +/** + * Each pet-bearing row triggers several sequential D1 calls; an unbounded import can exceed + * Workers' subrequest/CPU ceiling mid-loop, which aborts outside the per-row try/catch and + * returns a bare 500 with no partial-import report. Cap row count so oversized files fail fast + * with an actionable error instead of a platform crash. + */ +const MAX_IMPORT_ROWS = 500; + /** null/undefined (use default) or a timezone Intl accepts. */ function isValidTimezone(value: unknown): value is string | null | undefined { if (value === null || value === undefined) return true; @@ -622,6 +630,14 @@ export const adminRoutes = new Hono() const sendInvites = body.sendInvites === true; const rows = parseCsvRows(csv).slice(1); // row 1 is the header + if (rows.length > MAX_IMPORT_ROWS) { + return c.json( + { + error: `This file has ${rows.length} rows; split it into files of ${MAX_IMPORT_ROWS} or fewer and import in batches.`, + }, + 400, + ); + } const petTypesEnabled = new Set( (await listPetTypes(c.env.PAWBOOK_DB, tenant.Id)) .filter((pt) => pt.Enabled) From d57d092dfa22b3e99051fc796675908d1bc98647 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 22:17:49 -0400 Subject: [PATCH 11/13] 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 --- server/__tests__/csv-parser.test.ts | 11 ++++------- server/__tests__/customers-import.test.ts | 9 +++++++++ server/lib/csv.ts | 2 +- server/routes/admin.ts | 1 + 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/server/__tests__/csv-parser.test.ts b/server/__tests__/csv-parser.test.ts index 02db4e2..d754eb4 100644 --- a/server/__tests__/csv-parser.test.ts +++ b/server/__tests__/csv-parser.test.ts @@ -3,7 +3,7 @@ import { parseCsvRows } from '../lib/csv'; describe('parseCsvRows', () => { it('splits a simple comma-separated file into rows and cells', () => { - const text = 'a,b,c\n1,2,3\n'; + const text = 'a,b,c\n1,2,3'; expect(parseCsvRows(text)).toEqual([ ['a', 'b', 'c'], ['1', '2', '3'], @@ -24,19 +24,16 @@ describe('parseCsvRows', () => { }); it('tolerates CRLF line endings', () => { - const text = 'a,b\r\n1,2\r\n'; + const text = 'a,b\r\n1,2'; expect(parseCsvRows(text)).toEqual([ ['a', 'b'], ['1', '2'], ]); }); - it('skips blank lines', () => { + it('represents a blank line as a single empty-string cell, preserving row alignment', () => { const text = 'a,b\n\n1,2\n\n'; - expect(parseCsvRows(text)).toEqual([ - ['a', 'b'], - ['1', '2'], - ]); + expect(parseCsvRows(text)).toEqual([['a', 'b'], [''], ['1', '2'], [''], ['']]); }); it('returns an empty array for an empty string', () => { diff --git a/server/__tests__/customers-import.test.ts b/server/__tests__/customers-import.test.ts index 62269b3..83a5df1 100644 --- a/server/__tests__/customers-import.test.ts +++ b/server/__tests__/customers-import.test.ts @@ -170,6 +170,15 @@ describe('POST /:slug/admin/customers/import', () => { expect(countEndUsers()).toBe(before); // no rows should have been processed at all }); + it('preserves correct row numbers after a blank line in the file', async () => { + const { env } = createTestEnv(); + // Blank line at position 2; without the fix this shifts the reported row number for the + // invalid-email row below by one. + const csv = 'Client Email,Client Name,Pet Name,Pet Type\n\nnot-an-email,X,,\n'; + const { body } = await importCsv(env, csv); + expect(body.skippedRows).toEqual([{ row: 3, reason: 'Invalid email address' }]); + }); + it('turns a single row DB failure into a skip instead of a 500', async () => { vi.doMock('../db/repo', async (importOriginal) => { const actual = await importOriginal(); diff --git a/server/lib/csv.ts b/server/lib/csv.ts index afb88e0..37ebb4f 100644 --- a/server/lib/csv.ts +++ b/server/lib/csv.ts @@ -6,10 +6,10 @@ * avoids a dependency for a narrow, well-defined need. */ export function parseCsvRows(text: string): string[][] { + if (text === '') return []; const rows: string[][] = []; const lines = text.split(/\r\n|\r|\n/); for (const line of lines) { - if (line === '') continue; const cells: string[] = []; let cell = ''; let inQuotes = false; diff --git a/server/routes/admin.ts b/server/routes/admin.ts index b733ea6..dbb6b54 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -659,6 +659,7 @@ export const adminRoutes = new Hono() for (const [i, cells] of rows.entries()) { const row = i + 2; // 1-indexed against the sitter's file; +1 since the header was sliced off + if (cells.length === 1 && cells[0] === '') continue; // blank line — not a real row if (cells.length < 4) { skippedRows.push({ row, reason: 'Could not parse this row' }); continue; From fd64d8cffdc5f1aaa88422ad1bf4a16bd7a7dee9 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 22:18:25 -0400 Subject: [PATCH 12/13] Simplify freshCustomers to a plain email list; drop dead .some() dedup guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/routes/admin.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routes/admin.ts b/server/routes/admin.ts index dbb6b54..7603ba2 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -655,7 +655,7 @@ export const adminRoutes = new Hono() let invitesSent = 0; let invitesFailed = 0; const skippedRows: { row: number; reason: string }[] = []; - const freshCustomers: { email: string; endUserId: string }[] = []; + const freshCustomers: string[] = []; for (const [i, cells] of rows.entries()) { const row = i + 2; // 1-indexed against the sitter's file; +1 since the header was sliced off @@ -675,9 +675,9 @@ export const adminRoutes = new Hono() const existing = await getEndUserByEmail(c.env.PAWBOOK_DB, tenant.Id, email); const name = rawName.trim() || null; const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name); - if (!existing && !freshCustomers.some((f) => f.endUserId === customer.Id)) { + if (!existing) { importedCustomers++; - freshCustomers.push({ email, endUserId: customer.Id }); + freshCustomers.push(email); } const petName = rawPetName.trim(); @@ -711,7 +711,7 @@ export const adminRoutes = new Hono() if (sendInvites && isEmailConfigured(c.env)) { const widgetUrl = new URL(`/embed/${tenant.Slug}`, c.req.url).toString(); - for (const { email } of freshCustomers) { + for (const email of freshCustomers) { try { await sendInvite(c.env, email, tenant.DisplayName, widgetUrl); invitesSent++; From 17a8973b2eefc90797633b83a6be8613b85cf4ba Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Fri, 10 Jul 2026 22:22:16 -0400 Subject: [PATCH 13/13] Apply prettier formatting Co-Authored-By: Claude Sonnet 5 --- .../2026-07-04-time-windowed-services-design.md | 15 ++++++++------- .../specs/2026-07-10-csv-client-import-design.md | 13 ++++++------- .../2026-07-10-sitter-booking-dashboard-design.md | 10 +++++----- server/__tests__/customers-import.test.ts | 7 +++---- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-07-04-time-windowed-services-design.md b/docs/superpowers/specs/2026-07-04-time-windowed-services-design.md index 114f3f0..46d440a 100644 --- a/docs/superpowers/specs/2026-07-04-time-windowed-services-design.md +++ b/docs/superpowers/specs/2026-07-04-time-windowed-services-design.md @@ -110,8 +110,8 @@ plain duration option, would collide under that scheme. Fix: **windowed options derive `OptionKey` from a slug of their label instead** (e.g. "Morning Walk" → `morning-walk`); non-windowed options keep the existing `d${durationMinutes}` scheme unchanged. The existing per-service duplicate -check (`admin.ts:280-284`) is generalized to check the *final derived -`OptionKey` set* for the service rather than just `DurationMinutes` — this +check (`admin.ts:280-284`) is generalized to check the _final derived +`OptionKey` set_ for the service rather than just `DurationMinutes` — this covers collisions from either derivation scheme in one check, and matches the real invariant that actually matters: the DB's `UNIQUE (TenantId, ServiceType, OptionKey)` constraint (`schema.sql:53`). @@ -121,7 +121,7 @@ OptionKey)` constraint (`schema.sql:53`). - `listServiceOptions` SELECT expands to include the three new columns. - New function, sibling to `listCapacityRows` (`repo.ts:147-166`), which today explicitly excludes `walk`/`checkin` (`ServiceType IN ('boarding', - 'housesitting', 'blocked')`): +'housesitting', 'blocked')`): ```ts countSlotBookings( @@ -139,9 +139,10 @@ OptionKey)` constraint (`schema.sql:53`). Same status filter already used by `listCapacityRows` (pending+confirmed count toward capacity; cancelled doesn't). + - `insertBookingRequest`'s INSERT (`repo.ts:186-190`) currently writes `Id, - TenantId, EndUserId, ServiceType, StartDate, EndDate, OptionKey, PetType, - PetCount, EstCost, Answers, Status` — `StartTime` is **not** in that column +TenantId, EndUserId, ServiceType, StartDate, EndDate, OptionKey, PetType, +PetCount, EstCost, Answers, Status` — `StartTime` is **not** in that column list today even though the column exists and is already read back via `BOOKING_COLS`. The function signature and INSERT both need a `startTime` param/column added. The call site in `bookings.ts:146-157` passes @@ -186,7 +187,7 @@ itself — it already branches into a timed `dateTime` event when `startTime` is present, computing the event's end via `durationMinutes` (`addMinutesToLocal`). This path has simply never been exercised because nothing upstream set `startTime` until now. Because Section 2 makes -`DurationMinutes` *derived* from `EndTime - StartTime` for windowed options +`DurationMinutes` _derived_ from `EndTime - StartTime` for windowed options (not independently settable), passing `option.StartTime` and `option.DurationMinutes` through as today's sync payload already does (`bookings.ts:183-184`) is sufficient — no separate `EndTime` plumbing into @@ -199,7 +200,7 @@ answers. ### 7. Admin route (`server/routes/admin.ts`) - `OptionBody` type gains `startTime?: string | null`, `endTime?: string | - null`, `capacity?: number | null`. +null`, `capacity?: number | null`. - Validation, added to the existing per-option loop (`admin.ts:275-284`): - `startTime`/`endTime` must both be present or both absent — reject one without the other. diff --git a/docs/superpowers/specs/2026-07-10-csv-client-import-design.md b/docs/superpowers/specs/2026-07-10-csv-client-import-design.md index baed833..16f5d93 100644 --- a/docs/superpowers/specs/2026-07-10-csv-client-import-design.md +++ b/docs/superpowers/specs/2026-07-10-csv-client-import-design.md @@ -32,7 +32,7 @@ existing clients and their pets in one action. - Editing or removing existing clients/pets via CSV — this is additive only (create-or-reuse), matching `insertInvitedCustomer`'s existing semantics. - A follow-up CSV *export* or *update* flow is out of scope. + A follow-up CSV _export_ or _update_ flow is out of scope. - A preview-then-confirm step before committing the import. Because the import is already non-destructive and idempotent (Goal 2), there's nothing to undo — the sitter gets the full result report (what was created, what @@ -92,7 +92,7 @@ team@example.com,Team Co,, `dog` or `cat` (case-insensitive) **and** enabled for that tenant (same two-part check the single-add pet route already applies — `isPetType` + `listPetTypes(...).Enabled`, `server/routes/admin.ts:594, - 599-602`). +599-602`). An example file matching this exact format is committed to the repo at `docs/examples/clients-import-example.csv` and served as a static download @@ -106,12 +106,11 @@ containing a comma, e.g. `"Doe, Jane"`, exported from Excel/Sheets with quotes). One function: ```ts -export function parseCsvRows(text: string): string[][] +export function parseCsvRows(text: string): string[][]; ``` Splits on newlines (tolerating `\r\n`), splits each line on commas outside -quotes, and un-escapes `""` → `"` inside a quoted field. The caller (Section -3) treats row 1 as the header and ignores it (column order is fixed, not +quotes, and un-escapes `""` → `"` inside a quoted field. The caller (Section 3) treats row 1 as the header and ignores it (column order is fixed, not name-matched — matching the fixed 4-column format above; a header with different labels or order is not supported in this pass). @@ -132,7 +131,7 @@ For each parsed row (1-indexed against the sitter's actual file, header = row 1) 3. If `Pet Name`/`Pet Type` are both blank, row is done (client-only row). If exactly one of the two is present, skip just the pet with a reason (`'Pet type given without a pet name'` / `'Pet name given without a pet - type'`) — the client from step 2 is still kept. +type'`) — the client from step 2 is still kept. 4. If both are present: validate `Pet Type` (same two checks as the single-add route). Invalid → skip just the pet, record the reason (client from step 2 is still kept). @@ -143,7 +142,7 @@ For each parsed row (1-indexed against the sitter's actual file, header = row 1) `server/db/repo.ts` — reused to build the seed map rather than querying per row) and updated as pets are added during this same run. A name already in the set → skip, record `{ row, reason: 'Pet already exists for - this client' }`. This one check is what makes both "duplicate row within +this client' }`. This one check is what makes both "duplicate row within this file" and "re-uploading the same file later" safe — both look identical to it. 6. Otherwise `addEndUserPet(db, tenant.Id, endUserId, name, petType)` and add diff --git a/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md b/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md index 3acf042..9b16720 100644 --- a/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md +++ b/docs/superpowers/specs/2026-07-10-sitter-booking-dashboard-design.md @@ -45,7 +45,7 @@ is a near-verbatim port, not a rewrite. `// ponytail:` comment on the status route) — carried forward unchanged here; revisit if sitters complain about stale calendar entries. - Reconciling in the other direction in more detail than "event exists / - doesn't exist" — e.g. detecting a *time change* on the calendar event and + doesn't exist" — e.g. detecting a _time change_ on the calendar event and reflecting it back onto `BookingRequests.StartDate`/`StartTime`. Out of scope; a missing event is the only signal this pass acts on. - A manual "sync now" button. The cache TTL (Goal 3) is short enough that a @@ -118,7 +118,7 @@ admin route already uses): - `GET /:slug/admin/bookings` — calls the KV-cached reconciliation (Section 5) first, then `listBookingsForTenant`, mapping `Declined ? 'declined' : - Status` into the response `status` field (main's derived-status pattern). +Status` into the response `status` field (main's derived-status pattern). - `POST /:slug/admin/bookings/:id/status` — validates `status` is one of `confirmed`/`declined`/`cancelled`, calls `updateBookingStatus`, 404s if no row changed. On success, best-effort emails the customer via @@ -139,12 +139,12 @@ other best-effort email in this codebase). New function: ```ts -export async function reconcileBookingsWithCalendar(env: Env, tenant: Tenant): Promise +export async function reconcileBookingsWithCalendar(env: Env, tenant: Tenant): Promise; ``` - No-ops immediately if there's no connected calendar provider (`getProviderConnection(..., 'calendar')` missing or `Status !== - 'connected'`) — matches the existing guard at the top of +'connected'`) — matches the existing guard at the top of `syncBookingToCalendar`. - Lists events via the already-existing `listCalendarEvents` (already reads back `extendedProperties.private.bookingId`, since `buildEventResource` @@ -183,7 +183,7 @@ export async function reconcileIfStale(env: Env, tenant: Tenant): Promise } ``` -The cache key is written *after* the attempt (success or failure) so a +The cache key is written _after_ the attempt (success or failure) so a transient failure doesn't cause every subsequent load in the TTL window to retry — it backs off for the full TTL either way, same as any other best-effort background reconciliation. `GET /:slug/admin/bookings` diff --git a/server/__tests__/customers-import.test.ts b/server/__tests__/customers-import.test.ts index 83a5df1..5d38033 100644 --- a/server/__tests__/customers-import.test.ts +++ b/server/__tests__/customers-import.test.ts @@ -149,10 +149,9 @@ describe('POST /:slug/admin/customers/import', () => { (raw.prepare('SELECT COUNT(*) AS n FROM EndUsers').get() as { n: number }).n; const before = countEndUsers(); const header = 'Client Email,Client Name,Pet Name,Pet Type'; - const rows = Array.from( - { length: 501 }, - (_, n) => `over${n}@example.com,Over ${n},,`, - ).join('\n'); + const rows = Array.from({ length: 501 }, (_, n) => `over${n}@example.com,Over ${n},,`).join( + '\n', + ); const token = await adminToken(TENANT_A); const res = await app.request( '/api/sunny-paws/admin/customers/import',