diff --git a/app/admin/App.tsx b/app/admin/App.tsx index e53ef72..85cc515 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, IconClipboardCheck, @@ -371,6 +371,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; @@ -423,6 +435,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..418955b 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,30 @@ 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 [fileInputKey, setFileInputKey] = useState(0); + + 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); + setCsvFile(null); + setFileInputKey((k) => k + 1); + } + } finally { + setImporting(false); + } + }; + return ( <>

@@ -83,6 +107,53 @@ 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/app/shared-ui/api.ts b/app/shared-ui/api.ts index 17dd350..8b0c5e2 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 type AdminBooking = { id: string; customerEmail: string | null; @@ -202,6 +210,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 }), + }), }, bookings: { list: (slug: string, token: string) => 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/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/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,, diff --git a/server/__tests__/csv-parser.test.ts b/server/__tests__/csv-parser.test.ts new file mode 100644 index 0000000..d754eb4 --- /dev/null +++ b/server/__tests__/csv-parser.test.ts @@ -0,0 +1,42 @@ +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'; + 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'; + expect(parseCsvRows(text)).toEqual([ + ['a', 'b'], + ['1', '2'], + ]); + }); + + 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'], [''], ['']]); + }); + + it('returns an empty array for an empty string', () => { + expect(parseCsvRows('')).toEqual([]); + }); +}); diff --git a/server/__tests__/customers-import.test.ts b/server/__tests__/customers-import.test.ts new file mode 100644 index 0000000..5d38033 --- /dev/null +++ b/server/__tests__/customers-import.test.ts @@ -0,0 +1,209 @@ +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([]); + }); + + 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('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(); + 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/lib/csv.ts b/server/lib/csv.ts new file mode 100644 index 0000000..37ebb4f --- /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[][] { + if (text === '') return []; + const rows: string[][] = []; + const lines = text.split(/\r\n|\r|\n/); + for (const line of lines) { + 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; +} diff --git a/server/routes/admin.ts b/server/routes/admin.ts index a15c86a..54b4916 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -9,6 +9,7 @@ import { createService, getBookingWithCustomer, getEndUserById, + getEndUserByEmail, deleteBlockedRange, deleteCustomer, deleteService, @@ -33,6 +34,7 @@ import { updateTenantSettings, } from '../db/repo'; import { isEmailConfigured, sendBookingStatusEmail, sendInvite } from '../lib/email'; +import { parseCsvRows } from '../lib/csv'; import { reconcileIfStale } from '../lib/calendar-sync'; import { buildAuthUrl, revokeToken } from '../lib/google-calendar'; import { adminAuth } from '../lib/middleware'; @@ -65,6 +67,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; @@ -615,6 +625,108 @@ export const adminRoutes = new Hono() 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 + 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) + .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: 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 === 1 && cells[0] === '') continue; // blank line — not a real row + 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; + } + + 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) { + importedCustomers++; + freshCustomers.push(email); + } + + 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' }); + } + } + + 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 }); + }) .get('/:slug/admin/bookings', async (c) => { const tenant = c.get('tenant');