From 19e0f5e3ca35f2108c07ec8bb885a9a2bb819467 Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:03:18 +0000 Subject: [PATCH] feat: client disable/deletion with soft-delete (#69) * feat: add client disable/deletion with soft-delete (#67) Add soft-delete support for clients: disable is the default action (hiding from client list and booking flow), with permanent deletion requiring explicit type-to-confirm. Disabled clients remain in reporting and can be re-enabled by staff. - Add client_status enum (active/disabled) and disabled_at column - API defaults GET /api/clients to active-only, ?includeDisabled=true shows all - PATCH /api/clients/:id accepts status field for disable/enable - DELETE requires ?confirm=true query param - Booking flow skips disabled clients - Frontend: show disabled toggle, disable/enable buttons, delete confirmation modal Co-Authored-By: Claude Opus 4.6 * fix: remove unused updateClientSchema (lint error) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Groom Book CTO Co-authored-by: Claude Opus 4.6 --- apps/api/src/routes/book.ts | 4 +- apps/api/src/routes/clients.ts | 40 ++++- apps/e2e/tests/clients.spec.ts | 6 + apps/web/src/pages/Clients.tsx | 155 ++++++++++++++++-- .../db/migrations/0009_client_soft_delete.sql | 6 + packages/db/migrations/meta/_journal.json | 14 ++ packages/db/src/schema.ts | 7 + packages/types/src/index.ts | 4 + 8 files changed, 212 insertions(+), 24 deletions(-) create mode 100644 packages/db/migrations/0009_client_soft_delete.sql diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index 11428de..a60f008 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -160,11 +160,11 @@ bookRouter.post( ); } - // Find or create client by email + // Find or create client by email (skip disabled clients) let [client] = await db .select() .from(clients) - .where(eq(clients.email, body.clientEmail)); + .where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active"))); if (!client) { const inserted = await db diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index e560393..90313a2 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -13,12 +13,15 @@ const createClientSchema = z.object({ notes: z.string().max(2000).optional(), }); -const updateClientSchema = createClientSchema.partial(); -// List all clients +// List clients β€” defaults to active only, ?includeDisabled=true shows all clientsRouter.get("/", async (c) => { const db = getDb(); - const rows = await db.select().from(clients).orderBy(clients.name); + const includeDisabled = c.req.query("includeDisabled") === "true"; + const query = includeDisabled + ? db.select().from(clients).orderBy(clients.name) + : db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name); + const rows = await query; return c.json(rows); }); @@ -41,16 +44,31 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { return c.json(row, 201); }); -// Update a client +// Update a client (including status changes) +const patchClientSchema = createClientSchema.partial().extend({ + status: z.enum(["active", "disabled"]).optional(), +}); + clientsRouter.patch( "/:id", - zValidator("json", updateClientSchema), + zValidator("json", patchClientSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); + const now = new Date(); + + const setValues: Record = { ...body, updatedAt: now }; + + // When disabling, set disabledAt; when re-enabling, clear it + if (body.status === "disabled") { + setValues.disabledAt = now; + } else if (body.status === "active") { + setValues.disabledAt = null; + } + const [row] = await db .update(clients) - .set({ ...body, updatedAt: new Date() }) + .set(setValues) .where(eq(clients.id, c.req.param("id"))) .returning(); if (!row) return c.json({ error: "Not found" }, 404); @@ -58,8 +76,16 @@ clientsRouter.patch( } ); -// Delete a client +// Delete a client β€” requires ?confirm=true query param clientsRouter.delete("/:id", async (c) => { + const confirm = c.req.query("confirm"); + if (confirm !== "true") { + return c.json( + { error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." }, + 400 + ); + } + const db = getDb(); const [row] = await db .delete(clients) diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 8b0ea62..cf99ad4 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -14,6 +14,9 @@ const MOCK_CLIENTS = [ phone: "555-0101", address: null, notes: null, + emailOptOut: false, + status: "active", + disabledAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, @@ -24,6 +27,9 @@ const MOCK_CLIENTS = [ phone: null, address: null, notes: null, + emailOptOut: false, + status: "active", + disabledAt: null, createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", }, diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 242d33a..50afed9 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -64,6 +64,10 @@ export function ClientsPage() { const [savingPet, setSavingPet] = useState(false); const [deletingPetId, setDeletingPetId] = useState(null); const [deletingClient, setDeletingClient] = useState(false); + const [disablingClient, setDisablingClient] = useState(false); + const [showDisabled, setShowDisabled] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteConfirmName, setDeleteConfirmName] = useState(""); // Visit log const [logPetId, setLogPetId] = useState(null); @@ -74,17 +78,18 @@ export function ClientsPage() { const [logFormError, setLogFormError] = useState(null); const [savingLog, setSavingLog] = useState(false); - async function loadClients() { - const r = await fetch("/api/clients"); + async function loadClients(includeDisabled = false) { + const url = includeDisabled ? "/api/clients?includeDisabled=true" : "/api/clients"; + const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); setClients((await r.json()) as Client[]); } useEffect(() => { - loadClients() + loadClients(showDisabled) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error")) .finally(() => setLoading(false)); - }, []); + }, [showDisabled]); async function loadPets(clientId: string) { setPetsLoading(true); @@ -146,7 +151,7 @@ export function ClientsPage() { } const updated = (await res.json()) as Client; setShowClientForm(false); - await loadClients(); + await loadClients(showDisabled); if (editingClient) setSelectedClient(updated); } catch (e: unknown) { setClientFormError(e instanceof Error ? e.message : "Failed to save"); @@ -198,18 +203,64 @@ export function ClientsPage() { } } + async function disableClient(clientId: string) { + if (!window.confirm("Disable this client? They will be hidden from the client list and booking flow.")) return; + setDisablingClient(true); + try { + const res = await fetch(`/api/clients/${clientId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "disabled" }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const updated = (await res.json()) as Client; + setSelectedClient(updated); + await loadClients(showDisabled); + } catch (e: unknown) { + alert(e instanceof Error ? e.message : "Failed to disable client"); + } finally { + setDisablingClient(false); + } + } + + async function enableClient(clientId: string) { + setDisablingClient(true); + try { + const res = await fetch(`/api/clients/${clientId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "active" }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const updated = (await res.json()) as Client; + setSelectedClient(updated); + await loadClients(showDisabled); + } catch (e: unknown) { + alert(e instanceof Error ? e.message : "Failed to re-enable client"); + } finally { + setDisablingClient(false); + } + } + async function deleteClient(clientId: string) { - if (!window.confirm("Delete this client and all their pets? This cannot be undone.")) return; setDeletingClient(true); try { - const res = await fetch(`/api/clients/${clientId}`, { method: "DELETE" }); + const res = await fetch(`/api/clients/${clientId}?confirm=true`, { method: "DELETE" }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } setSelectedClient(null); + setShowDeleteConfirm(false); + setDeleteConfirmName(""); setPets([]); - await loadClients(); + await loadClients(showDisabled); } catch (e: unknown) { alert(e instanceof Error ? e.message : "Failed to delete client"); } finally { @@ -324,8 +375,16 @@ export function ClientsPage() { placeholder="Search…" value={search} onChange={(e) => setSearch(e.target.value)} - style={{ ...inputStyle, marginBottom: "0.75rem" }} + style={{ ...inputStyle, marginBottom: "0.5rem" }} /> + {filtered.length === 0 &&

No clients found.

} {filtered.map((c) => (
-
{c.name}
+
+ {c.name} + {c.status === "disabled" && ( + + Disabled + + )} +
{c.email &&
{c.email}
} {c.phone &&
{c.phone}
}
@@ -349,7 +415,14 @@ export function ClientsPage() {
-

{selectedClient.name}

+

+ {selectedClient.name} + {selectedClient.status === "disabled" && ( + + Disabled + + )} +

{selectedClient.email &&
{selectedClient.email}
} {selectedClient.phone &&
{selectedClient.phone}
} {selectedClient.address &&
{selectedClient.address}
} @@ -364,17 +437,33 @@ export function ClientsPage() { href={`/?impersonate=true&clientName=${encodeURIComponent(selectedClient.name)}&staffName=${encodeURIComponent("Staff")}&reason=${encodeURIComponent(`Support view for ${selectedClient.name}`)}`} style={{ ...btnStyle, backgroundColor: "#fef3c7", color: "#92400e", borderColor: "#fde68a", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "0.3rem" }} > - πŸ‘ View as Customer + View as Customer + {selectedClient.status === "active" ? ( + + ) : ( + + )}
@@ -651,6 +740,42 @@ export function ClientsPage() { )} + + {/* ── Delete confirmation modal ── */} + {showDeleteConfirm && selectedClient && ( + setShowDeleteConfirm(false)}> +

Permanently Delete Client

+

+ This will permanently delete {selectedClient.name} and all their pets. This action cannot be undone. +

+

+ Consider disabling the client instead, which preserves their data for reporting. +

+ + setDeleteConfirmName(e.target.value)} + style={inputStyle} + placeholder={selectedClient.name} + /> + +
+ + +
+
+ )}
); } diff --git a/packages/db/migrations/0009_client_soft_delete.sql b/packages/db/migrations/0009_client_soft_delete.sql new file mode 100644 index 0000000..b495478 --- /dev/null +++ b/packages/db/migrations/0009_client_soft_delete.sql @@ -0,0 +1,6 @@ +-- Add client status (soft-delete support) +CREATE TYPE "client_status" AS ENUM ('active', 'disabled'); + +ALTER TABLE "clients" + ADD COLUMN "status" "client_status" NOT NULL DEFAULT 'active', + ADD COLUMN "disabled_at" timestamp; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 9a5743d..2b1b6b9 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1773820800000, "tag": "0007_tip_splitting", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1773907200000, + "tag": "0008_business_settings", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1773993600000, + "tag": "0009_client_soft_delete", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c7db30c..990759e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -42,6 +42,11 @@ export const paymentMethodEnum = pgEnum("payment_method", [ "other", ]); +export const clientStatusEnum = pgEnum("client_status", [ + "active", + "disabled", +]); + // ─── Tables ─────────────────────────────────────────────────────────────────── export const clients = pgTable("clients", { @@ -53,6 +58,8 @@ export const clients = pgTable("clients", { notes: text("notes"), // Set to true if the client has opted out of email reminders/notifications emailOptOut: boolean("email_opt_out").notNull().default(false), + status: clientStatusEnum("status").notNull().default("active"), + disabledAt: timestamp("disabled_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5b3250c..89e726e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,8 @@ export type AppointmentStatus = | "cancelled" | "no_show"; +export type ClientStatus = "active" | "disabled"; + export interface Client { id: string; name: string; @@ -16,6 +18,8 @@ export interface Client { address: string | null; notes: string | null; emailOptOut: boolean; + status: ClientStatus; + disabledAt: string | null; createdAt: string; updatedAt: string; }