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:
committed by
GitHub
parent
b6b4bc21a0
commit
19e0f5e3ca
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user