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 <noreply@anthropic.com>

* fix: remove unused updateClientSchema (lint error)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #69.
This commit is contained in:
groombook-paperclip[bot]
2026-03-19 20:03:18 +00:00
committed by GitHub
parent b6b4bc21a0
commit 19e0f5e3ca
8 changed files with 212 additions and 24 deletions
+2 -2
View File
@@ -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
+33 -7
View File
@@ -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<string, unknown> = { ...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)
+6
View File
@@ -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",
},
+140 -15
View File
@@ -64,6 +64,10 @@ export function ClientsPage() {
const [savingPet, setSavingPet] = useState(false);
const [deletingPetId, setDeletingPetId] = useState<string | null>(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<string | null>(null);
@@ -74,17 +78,18 @@ export function ClientsPage() {
const [logFormError, setLogFormError] = useState<string | null>(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" }}
/>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontSize: 12, color: "#6b7280", marginBottom: "0.75rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={showDisabled}
onChange={(e) => setShowDisabled(e.target.checked)}
/>
Show disabled clients
</label>
{filtered.length === 0 && <p style={{ color: "#6b7280", fontSize: 14 }}>No clients found.</p>}
{filtered.map((c) => (
<div
@@ -337,7 +396,14 @@ export function ClientsPage() {
border: selectedClient?.id === c.id ? "1px solid #bfdbfe" : "1px solid transparent",
}}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{c.name}</div>
<div style={{ fontWeight: 600, fontSize: 14, display: "flex", alignItems: "center", gap: "0.4rem" }}>
{c.name}
{c.status === "disabled" && (
<span style={{ fontSize: 10, background: "#fef2f2", color: "#dc2626", padding: "0.1rem 0.4rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</div>
{c.email && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.email}</div>}
{c.phone && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.phone}</div>}
</div>
@@ -349,7 +415,14 @@ export function ClientsPage() {
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1rem" }}>
<div>
<h2 style={{ margin: "0 0 0.2rem" }}>{selectedClient.name}</h2>
<h2 style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
{selectedClient.name}
{selectedClient.status === "disabled" && (
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</h2>
{selectedClient.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.email}</div>}
{selectedClient.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.phone}</div>}
{selectedClient.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{selectedClient.address}</div>}
@@ -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
</a>
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
Edit client
</button>
{selectedClient.status === "active" ? (
<button
onClick={() => { void disableClient(selectedClient.id); }}
disabled={disablingClient}
style={{ ...btnStyle, color: "#d97706", borderColor: "#fde68a" }}
>
{disablingClient ? "Disabling…" : "Disable client"}
</button>
) : (
<button
onClick={() => { void enableClient(selectedClient.id); }}
disabled={disablingClient}
style={{ ...btnStyle, color: "#059669", borderColor: "#6ee7b7" }}
>
{disablingClient ? "Enabling…" : "Re-enable client"}
</button>
)}
<button
onClick={() => { void deleteClient(selectedClient.id); }}
disabled={deletingClient}
onClick={() => { setShowDeleteConfirm(true); setDeleteConfirmName(""); }}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#fca5a5" }}
>
{deletingClient ? "Deleting…" : "Delete client"}
Delete permanently
</button>
</div>
</div>
@@ -651,6 +740,42 @@ export function ClientsPage() {
</form>
</Modal>
)}
{/* ── Delete confirmation modal ── */}
{showDeleteConfirm && selectedClient && (
<Modal onClose={() => setShowDeleteConfirm(false)}>
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
<p style={{ fontSize: 14, color: "#374151" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p>
<p style={{ fontSize: 14, color: "#374151" }}>
Consider disabling the client instead, which preserves their data for reporting.
</p>
<Field label={`Type "${selectedClient.name}" to confirm`}>
<input
value={deleteConfirmName}
onChange={(e) => setDeleteConfirmName(e.target.value)}
style={inputStyle}
placeholder={selectedClient.name}
/>
</Field>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button
onClick={() => { void deleteClient(selectedClient.id); }}
disabled={deletingClient || deleteConfirmName !== selectedClient.name}
style={{
...btnStyle,
backgroundColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#f3f4f6",
color: deleteConfirmName === selectedClient.name ? "#fff" : "#9ca3af",
borderColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#d1d5db",
}}
>
{deletingClient ? "Deleting…" : "Delete permanently"}
</button>
<button type="button" onClick={() => setShowDeleteConfirm(false)} style={btnStyle}>Cancel</button>
</div>
</Modal>
)}
</div>
);
}
@@ -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;
+14
View File
@@ -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
}
]
}
+7
View File
@@ -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(),
});
+4
View File
@@ -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;
}