Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/admin/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ImportResult | null> => {
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;
Expand Down Expand Up @@ -423,6 +435,7 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
addPet={addPet}
removePet={removePet}
enabledPetTypes={enabledPetTypes}
importCsv={importCsv}
/>
),
apps: (
Expand Down
73 changes: 72 additions & 1 deletion app/admin/sections/ClientsSection.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -48,6 +48,7 @@ export function ClientsSection({
addPet,
removePet,
enabledPetTypes,
importCsv,
}: {
customers: Customer[];
custEmail: string;
Expand All @@ -59,7 +60,30 @@ export function ClientsSection({
addPet: (endUserId: string, name: string, petType: string) => Promise<void>;
removePet: (endUserId: string, petId: string) => Promise<void>;
enabledPetTypes: string[];
importCsv: (csv: string, sendInvites: boolean) => Promise<ImportResult | null>;
}) {
const [csvFile, setCsvFile] = useState<File | null>(null);
const [sendInvites, setSendInvites] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(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 (
<>
<h2>
Expand All @@ -83,6 +107,53 @@ export function ClientsSection({
/>
<button onClick={() => void addCustomer()}>Add customer</button>
</div>
<div className="pb-row">
<input
key={fileInputKey}
type="file"
accept=".csv"
onChange={(e) => {
setCsvFile(e.target.files?.[0] ?? null);
setImportResult(null);
}}
/>
<label>
<input
type="checkbox"
checked={sendInvites}
onChange={(e) => setSendInvites(e.target.checked)}
/>{' '}
Send invite emails to new clients
</label>
<button onClick={() => void runImport()} disabled={!csvFile || importing}>
{importing ? 'Importing…' : 'Import'}
</button>
<a href="/clients-import-example.csv" download>
Download example CSV
</a>
</div>
{importResult && (
<div className="pb-row">
<p>
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.`
: ''}
</p>
{importResult.skippedRows.length > 0 && (
<ul>
{importResult.skippedRows.map((r) => (
<li key={r.row}>
Row {r.row}: {r.reason}
</li>
))}
</ul>
)}
</div>
)}
<ul>
{customers.map((cust) => (
<li key={cust.id} className="pb-customer">
Expand Down
14 changes: 14 additions & 0 deletions app/shared-ui/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -202,6 +210,12 @@ export const adminApi = {
method: 'DELETE',
headers: authHeaders(token),
}),
import: (slug: string, token: string, csv: string, sendInvites: boolean) =>
request<ImportResult>(`/api/${slug}/admin/customers/import`, {
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
body: JSON.stringify({ csv, sendInvites }),
}),
},
bookings: {
list: (slug: string, token: string) =>
Expand Down
5 changes: 5 additions & 0 deletions docs/examples/clients-import-example.csv
Original file line number Diff line number Diff line change
@@ -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,,
13 changes: 6 additions & 7 deletions docs/superpowers/specs/2026-07-10-csv-client-import-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).

Expand All @@ -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).
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions public/clients-import-example.csv
Original file line number Diff line number Diff line change
@@ -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,,
42 changes: 42 additions & 0 deletions server/__tests__/csv-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading