From d1f8d27d1c2726ca8df7544a703bd49ec558f2e7 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 10:55:24 +0000 Subject: [PATCH 01/15] fix: remove unused X import from lucide-react Resolves ESLint error: 'X' is defined but never used GRO-1347 Co-Authored-By: Paperclip --- src/portal/sections/PetProfiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx index 787eeb3..6f0fdb6 100644 --- a/src/portal/sections/PetProfiles.tsx +++ b/src/portal/sections/PetProfiles.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star, X } from "lucide-react"; +import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react"; import { PetForm } from "./PetForm.js"; import type { Pet } from "@groombook/types"; -- 2.52.0 From f414d2589f44a18500e7d69706cb0984feee3ae0 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 14:22:02 +0000 Subject: [PATCH 02/15] fix(GRO-1366): add non-null assertion to removeButtons[0] Fix TypeScript error on line 114: HTMLElement | undefined is not assignable to Element. Added ! assertion since length guard already excludes the empty-array case. Co-Authored-By: Paperclip --- src/__tests__/PetForm.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/PetForm.test.tsx b/src/__tests__/PetForm.test.tsx index 8c5f3b1..f49e135 100644 --- a/src/__tests__/PetForm.test.tsx +++ b/src/__tests__/PetForm.test.tsx @@ -111,7 +111,7 @@ describe("PetForm", () => { render(); const removeButtons = screen.getAllByRole("button", { name: "" }); if (removeButtons.length === 0) return; - const removeButton = removeButtons[0]; + const removeButton = removeButtons[0]!; if (!removeButton) return; fireEvent.click(removeButton); expect(screen.queryByText("Allergic to chicken")).toBeNull(); -- 2.52.0 From f1bb7c4fa69a6cea20c18d580e8731c57b07a58c Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 06:58:56 +0000 Subject: [PATCH 03/15] fix(GRO-1414): update pet size value from x-large to xlarge Co-Authored-By: Paperclip --- src/pages/Book.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx index 495c440..179b0f0 100644 --- a/src/pages/Book.tsx +++ b/src/pages/Book.tsx @@ -519,7 +519,7 @@ export function BookPage() { - +
-- 2.52.0 From ec17f1e8851bd58f334b4850368b4233b0fac7a9 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 11:12:45 +0000 Subject: [PATCH 04/15] feat(GRO-1173): apply buffer rules UI changes to extracted groombook/web repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit ports the GRO-1173 admin UI changes from the app monorepo into the extracted groombook/web repo, using the correct source paths (src/ instead of apps/web/src/): - New BufferRulesSection component (full CRUD UI for /api/buffer-rules) - Default Buffer (minutes) field added to service create/edit form - Size Category and Coat Type dropdowns added to PetForm (portal) - @groombook/types Service interface extended with defaultBufferMinutes - BufferRulesSection embedded in Settings page The PetForm already had coatType — this commit adds petSizeCategory and renders both fields with proper dropdown selectors. Co-Authored-By: Paperclip --- packages/types/src/index.ts | 1 + src/components/BufferRules.tsx | 276 ++++++++++++++++++++++++++++++++ src/pages/Services.tsx | 20 ++- src/pages/Settings.tsx | 5 + src/portal/sections/PetForm.tsx | 20 +++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/components/BufferRules.tsx diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ea46cc2..a83b36d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -71,6 +71,7 @@ export interface Service { basePriceCents: number; durationMinutes: number; active: boolean; + defaultBufferMinutes?: number; createdAt: string; updatedAt: string; } diff --git a/src/components/BufferRules.tsx b/src/components/BufferRules.tsx new file mode 100644 index 0000000..5103f0a --- /dev/null +++ b/src/components/BufferRules.tsx @@ -0,0 +1,276 @@ +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; + +interface Service { + id: string; + name: string; + description?: string; + basePriceCents: number; + durationMinutes: number; + active: boolean; +} + +interface BufferRule { + id: string; + serviceId: string; + serviceName: string; + sizeCategory?: string; + coatType?: string; + bufferMinutes: number; + createdAt: string; + updatedAt: string; +} + +interface BufferRuleForm { + serviceId: string; + sizeCategory: string; + coatType: string; + bufferMinutes: string; +} + +const EMPTY_FORM: BufferRuleForm = { + serviceId: "", + sizeCategory: "", + coatType: "", + bufferMinutes: "", +}; + +const SIZE_OPTIONS = ["", "small", "medium", "large", "xlarge"] as const; +const COAT_OPTIONS = ["", "smooth", "double", "wire", "curly", "long", "hairless"] as const; + +export function BufferRulesSection() { + const [rules, setRules] = useState([]); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editBuffer, setEditBuffer] = useState(""); + + useEffect(() => { + Promise.all([ + fetch("/api/buffer-rules").then(r => r.ok ? r.json() : []), + fetch("/api/services?includeInactive=true").then(r => r.ok ? r.json() : []), + ]).then(([rulesData, servicesData]) => { + setRules(rulesData as BufferRule[]); + setServices(servicesData as Service[]); + }).catch(() => setError("Failed to load")).finally(() => setLoading(false)); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const mins = parseInt(form.bufferMinutes); + if (!form.serviceId || isNaN(mins) || mins <= 0) { + setFormError("Service and valid buffer minutes are required."); + return; + } + setSaving(true); + setFormError(null); + try { + const body: Record = { + serviceId: form.serviceId, + bufferMinutes: mins, + }; + if (form.sizeCategory) body.sizeCategory = form.sizeCategory; + if (form.coatType) body.coatType = form.coatType; + const res = await fetch("/api/buffer-rules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const newRule = await res.json() as BufferRule; + setRules(prev => [...prev, newRule]); + setShowForm(false); + setForm(EMPTY_FORM); + } catch (e: unknown) { + setFormError(e instanceof Error ? e.message : "Failed to create rule"); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + setDeletingId(id); + try { + await fetch(`/api/buffer-rules/${id}`, { method: "DELETE" }); + setRules(prev => prev.filter(r => r.id !== id)); + } finally { + setDeletingId(null); + setConfirmDeleteId(null); + } + } + + function startEdit(rule: BufferRule) { + setEditingId(rule.id); + setEditBuffer(String(rule.bufferMinutes)); + } + + async function saveEdit(rule: BufferRule) { + const mins = parseInt(editBuffer); + if (isNaN(mins) || mins <= 0) return; + setSaving(true); + try { + const res = await fetch(`/api/buffer-rules/${rule.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bufferMinutes: mins }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated = await res.json() as BufferRule; + setRules(prev => prev.map(r => r.id === updated.id ? updated : r)); + } catch { + // silent fail + } finally { + setSaving(false); + setEditingId(null); + setEditBuffer(""); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Buffer Rules

+

Extra time rules per service / pet size / coat type

+
+ +
+ + {showForm && ( +
+
+
+ + +
+
+ + setForm(f => ({ ...f, bufferMinutes: e.target.value }))} + required + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + +
+
+ + +
+
+ {formError &&

{formError}

} + +
+ )} + + {rules.length === 0 && !showForm ? ( +

No buffer rules configured yet.

+ ) : ( +
+ {rules.map(rule => ( +
+
+
{rule.serviceName}
+
+ {rule.sizeCategory && Size: {rule.sizeCategory}} + {rule.coatType && Coat: {rule.coatType}} +
+
+ {editingId === rule.id ? ( +
+ setEditBuffer(e.target.value)} + className="w-20 border border-stone-200 rounded px-2 py-1 text-sm" + /> + min + + +
+ ) : ( + <> + {rule.bufferMinutes} min + + + )} + {confirmDeleteId === rule.id ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/Services.tsx b/src/pages/Services.tsx index 7398c9d..60c4932 100644 --- a/src/pages/Services.tsx +++ b/src/pages/Services.tsx @@ -6,6 +6,7 @@ interface ServiceForm { description: string; priceStr: string; durationMinutes: number; + defaultBufferMinutes: number; active: boolean; } @@ -14,6 +15,7 @@ const EMPTY_FORM: ServiceForm = { description: "", priceStr: "", durationMinutes: 60, + defaultBufferMinutes: 0, active: true, }; @@ -55,6 +57,7 @@ export function ServicesPage() { description: s.description ?? "", priceStr: (s.basePriceCents / 100).toFixed(2), durationMinutes: s.durationMinutes, + defaultBufferMinutes: s.defaultBufferMinutes ?? 0, active: s.active, }); setFormError(null); @@ -76,6 +79,7 @@ export function ServicesPage() { description: form.description || undefined, basePriceCents: Math.round(price * 100), durationMinutes: form.durationMinutes, + defaultBufferMinutes: form.defaultBufferMinutes, active: form.active, }; const res = editing @@ -138,7 +142,7 @@ export function ServicesPage() { - {["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( + {["Name", "Description", "Price", "Duration", "Default Buffer", "Status", ""].map((h) => ( @@ -152,6 +156,7 @@ export function ServicesPage() { +
{h} {s.description ?? "—"} ${(s.basePriceCents / 100).toFixed(2)} {s.durationMinutes} min{(s as Service & { defaultBufferMinutes?: number }).defaultBufferMinutes ?? 0} min + {saveError && ( +

{saveError}

+ )} ); diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx index 6f0fdb6..412b475 100644 --- a/src/portal/sections/PetProfiles.tsx +++ b/src/portal/sections/PetProfiles.tsx @@ -83,9 +83,27 @@ export function PetProfiles({ sessionId, readOnly }: Props) { const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date()); const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null; - function handlePetSave(updatedPet: Pet) { - setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p)); - setEditingPetId(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + async function handlePetSave(updatedPet: Pet) { + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/api/portal/pets/${updatedPet.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...buildHeaders(sessionId) }, + body: JSON.stringify(updatedPet), + }); + if (!res.ok) throw new Error("Failed to save pet"); + const saved: Pet = await res.json(); + setPets(prev => prev.map(p => p.id === saved.id ? saved : p)); + setEditingPetId(null); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Failed to save pet"); + } finally { + setSaving(false); + } } if (editingPet) { @@ -94,6 +112,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) { pet={editingPet} onSave={handlePetSave} onCancel={() => setEditingPetId(null)} + saving={saving} + saveError={saveError} /> ); } -- 2.52.0 From 034f4ab29542109f4b05d7ec74447b4ea3b65ff7 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 19:32:33 +0000 Subject: [PATCH 11/15] docs(GRO-1470): add UAT test cases for pet profile API persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add §5.23 covering: - API persistence (page reload verification) - Save error state (form stays open on failure) - Saving indicator (spinner while in-flight) Updated UAT_PLAYBOOK.md §5.23 Co-Authored-By: Claude Opus 4.7 --- UAT_PLAYBOOK.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 0e89ca0..b138ae4 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -275,6 +275,14 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.22.6 | Size and coat persisted | Save pet with size + coat, edit again | Both fields retain their selected values | | TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save | +### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) | +| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared | +| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled | + ## 6. Pass/Fail Criteria **Pass:** -- 2.52.0 From 35d31a984ddff0fee2bd9af91eceeade6aff3669 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sat, 23 May 2026 13:57:47 +0000 Subject: [PATCH 12/15] fix(GRO-1592): fallback auth baseURL to window.location.origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When VITE_API_URL is not set (e.g. in Docker/container deployments where the env var was never injected), fallback to window.location.origin so the auth client uses relative URLs and cookies are sent to the correct origin. Previously the fallback was empty string "", which caused the auth client to default to http://localhost:3000 — the nginx sub_filter workaround only handles strings baked into the JS bundle at build time, not runtime-constructed URLs. Fixes: SSO session cookie not set in browser after Authentik callback Co-Authored-By: Paperclip --- src/lib/auth-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 6a9939a..02b7608 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,7 +1,7 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_API_URL ?? "", + baseURL: import.meta.env.VITE_API_URL || (typeof window !== "undefined" ? window.location.origin : ""), }); export const { signIn, signOut, useSession, changePassword } = authClient; \ No newline at end of file -- 2.52.0 From 8ee58471b25b1d31a1579ee8a5f78e7a37092752 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sat, 23 May 2026 14:02:16 +0000 Subject: [PATCH 13/15] =?UTF-8?q?docs(UAT=5FPLAYBOOK):=20add=20TC-AUTH-5.3?= =?UTF-8?q?.4=20=E2=80=94=20SSO=20cookie=20after=20Authentik=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the acceptance criteria for GRO-1592: after completing Authentik SSO login without VITE_API_URL set, the __Secure-better-auth.session_token cookie must be present in the browser and sent with subsequent /api/* calls. Updated: UAT_PLAYBOOK.md §5.3 Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 1 + 1 file changed, 1 insertion(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 655c505..d70c9a2 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -69,6 +69,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-AUTH-5.3.1 | Auth client falls back to window.location.origin | Do not set `VITE_API_URL`, load app | Auth client uses `window.location.origin` as base URL | | TC-AUTH-5.3.2 | Sign-in on localhost | Load app without `VITE_API_URL` on localhost:3000 | Auth client uses `http://localhost:3000` as base URL | | TC-AUTH-5.3.3 | Sign-in on dev environment | Load app without `VITE_API_URL` on `https://dev.groombook.dev` | Auth client uses `https://dev.groombook.dev` as base URL | +| TC-AUTH-5.3.4 | SSO cookie set after Authentik callback (GRO-1592) | Complete Authentik SSO login on UAT without `VITE_API_URL` set | `__Secure-better-auth.session_token` cookie is present in browser; subsequent `/api/*` calls include the cookie and return 200 | ### 5.4 Session Persistence -- 2.52.0 From db892409efd7109c8f8b7383566196cccb269974 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Sun, 24 May 2026 22:08:59 +0000 Subject: [PATCH 14/15] fix(GRO-1633): add buildx network=host and provenance:false to web CI (#17) --- .gitea/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c58375f..ea7c842 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -78,6 +78,8 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host - name: Log in to Gitea Container Registry uses: docker/login-action@v3 @@ -92,6 +94,7 @@ jobs: context: . file: Dockerfile push: true + provenance: false tags: | git.farh.net/groombook/web:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }} -- 2.52.0 From b630b40c92e8c60907e9953cd1b5263520d903ae Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley <18+gb_scrubs@noreply.git.farh.net> Date: Mon, 25 May 2026 23:39:46 +0000 Subject: [PATCH 15/15] fix(GRO-1757): add SSO + OOBE test cases to groombook-web UAT_PLAYBOOK (#18) --- UAT_PLAYBOOK.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 322c512..089dde6 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -78,6 +78,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-AUTH-5.4.1 | Session persists across page reload | Sign in, reload page | Session remains active | | TC-AUTH-5.4.2 | Session clears on sign-out | Sign in, sign out | User is logged out, redirected to login | +### 5.4.1 SSO Login Journey (Authentik OIDC end-to-end) + +| # | Scenario | Steps | Pass Criteria | Fail Criteria | +|---|----------|-------|---------------|---------------| +| TC-WEB-SSO-1 | Sign-in page shows SSO button | Navigate to app root URL | Sign-in page displayed with "Sign in with SSO" button visible | No SSO button, 403 before page loads | +| TC-WEB-SSO-2 | Click SSO redirects to Authentik | Click "Sign in with SSO" button | Browser redirected to Authentik login at auth.farh.net | No redirect, error shown, button does nothing | +| TC-WEB-SSO-3 | Valid OIDC credentials authenticate | At Authentik, enter valid credentials and authenticate | Redirected back to app with active session | Redirect loop, 403, session not established | +| TC-WEB-SSO-4 | Post-login dashboard accessible | After SSO flow completes, dashboard loads | Dashboard displays correctly with user identity shown | Blank page, 403, session not active | +| TC-WEB-SSO-5 | User identity displayed correctly | After SSO login, check header/nav | User name/email/initials shown in nav, role reflected in UI | No user indicator, wrong user shown | + +### 5.4.2 OOBE Flow Post-Login + +| # | Scenario | Steps | Pass Criteria | Fail Criteria | +|---|----------|-------|---------------|---------------| +| TC-WEB-OOBE-1 | Fresh DB shows setup wizard | On fresh DB (no super user), navigate to app | Setup wizard / OOBE screen displayed | Regular login page shown instead of setup | +| TC-WEB-OOBE-2 | Configure OIDC via setup | During OOBE, configure OIDC auth provider via /api/setup/auth-provider | OIDC configured successfully, no 403 | 403 during setup, config rejected | +| TC-WEB-OOBE-3 | Setup completes and redirects | Complete OOBE setup with business name | Redirected to app dashboard as super user, setup bypassed on reload | Setup errors, wrong redirect, setup reappears | +| TC-WEB-OOBE-4 | Admin panel accessible after setup | After completing OOBE, navigate to admin panel | Admin features accessible | 403 on admin panel, insufficient permissions | +| TC-WEB-OOBE-5 | SSO login during OOBE does not interfere | During fresh OOBE, attempt SSO login before completing setup | SSO login redirected appropriately, setup can still complete | Auto-provision creates staff prematurely, setup flow broken | + ### 5.5 Dashboard | # | Scenario | Steps | Expected | -- 2.52.0