From 9be6a8710516d263a731202aff2415b06e4346e5 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Tue, 7 Apr 2026 20:01:56 +0000
Subject: [PATCH 1/2] chore: implement hourly reset CronJob for prod and UAT
- Add ALLOW_RESET env var override to reset.ts safety guard
- Add reset Docker build target to Dockerfile
- Add reset image build step to CI docker job
- Add reset image tag update to CD job dev overlay update
Co-Authored-By: Paperclip
---
.github/workflows/ci.yml | 14 ++++++++++++++
apps/api/Dockerfile | 4 ++++
packages/db/src/reset.ts | 4 ++--
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fbafc46..69b8800 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -188,6 +188,19 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
+ - name: Build and push Reset image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: apps/api/Dockerfile
+ target: reset
+ push: true
+ tags: |
+ ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
+ ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
- name: Build and push Web image
uses: docker/build-push-action@v6
with:
@@ -356,6 +369,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
+ yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index e7a0882..1a89f85 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -43,3 +43,7 @@ CMD ["pnpm", "db:migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
CMD ["pnpm", "db:seed"]
+
+# Reset stage — drops all tables, re-runs migrations, and re-seeds
+FROM builder AS reset
+CMD ["pnpm", "db:reset"]
diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts
index c390f1c..41c3ce8 100644
--- a/packages/db/src/reset.ts
+++ b/packages/db/src/reset.ts
@@ -16,8 +16,8 @@ async function reset() {
process.exit(1);
}
- if (process.env.NODE_ENV === "production") {
- console.error("[FATAL] db:reset must not be run in production.");
+ if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
+ console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
process.exit(1);
}
--
2.52.0
From 94764d853273a63795180b9f8d76a60848657509 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Tue, 7 Apr 2026 20:11:24 +0000
Subject: [PATCH 2/2] Frontend: use paginated invoices API, eliminate
over-fetching
- Replace loadAll() with single GET /api/invoices?limit=50&offset=0
- Remove parallel fetches of clients/appointments/services/staff from list load
- Use clientName from API response instead of client-side enrichment
- Add offset-based pagination controls with Previous/Next buttons
- Lazy-load staff/appointments only when opening invoice detail modal
- Lazy-load clients/appointments/services only when opening create form
- Filter changes only re-fetch invoices, not all endpoints
Co-Authored-By: Paperclip
---
apps/web/src/pages/Invoices.tsx | 147 ++++++++++++++++++++++----------
1 file changed, 103 insertions(+), 44 deletions(-)
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx
index 5039dd3..8363695 100644
--- a/apps/web/src/pages/Invoices.tsx
+++ b/apps/web/src/pages/Invoices.tsx
@@ -52,6 +52,8 @@ interface CreateFromApptProps {
appointments: Appointment[];
clients: Client[];
services: Service[];
+ loading: boolean;
+ onOpen: () => void;
onCreated: () => void;
onClose: () => void;
}
@@ -60,10 +62,13 @@ function CreateFromAppointmentForm({
appointments,
clients,
services,
+ loading,
+ onOpen,
onCreated,
onClose,
}: CreateFromApptProps) {
const [selectedApptId, setSelectedApptId] = useState("");
+ useEffect(() => { onOpen(); }, [onOpen]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
@@ -99,6 +104,8 @@ function CreateFromAppointmentForm({
}
}
+ if (loading) return Loading…
;
+
return (
Create Invoice from Appointment
@@ -148,16 +155,21 @@ function InvoiceDetailModal({
invoice,
allStaff,
allAppointments,
+ loading,
+ onOpen,
onClose,
onUpdated,
}: {
invoice: Invoice;
allStaff: Staff[];
allAppointments: Appointment[];
+ loading: boolean;
+ onOpen: () => void;
onClose: () => void;
onUpdated: () => void;
}) {
const [saving, setSaving] = useState(false);
+ useEffect(() => { onOpen(); }, [onOpen]);
const [error, setError] = useState(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash");
@@ -259,6 +271,8 @@ function InvoiceDetailModal({
}
}
+ if (loading) return Loading…
;
+
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
@@ -460,64 +474,77 @@ function SummaryRow({ label, value, bold }: { label: string; value: string; bold
// ─── Main Page ────────────────────────────────────────────────────────────────
+interface PaginatedResponse {
+ data: T[];
+ total: number;
+}
+
export function InvoicesPage() {
const [invoiceList, setInvoiceList] = useState([]);
- const [clients, setClients] = useState([]);
- const [appointments, setAppointments] = useState([]);
- const [services, setServices] = useState([]);
- const [allStaff, setAllStaff] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState(null);
const [statusFilter, setStatusFilter] = useState("");
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [createData, setCreateData] = useState<{ clients: Client[]; appointments: Appointment[]; services: Service[] } | null>(null);
+ const [createLoading, setCreateLoading] = useState(false);
+ const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
- async function loadAll() {
- const [invRes, clientRes, apptRes, svcRes, staffRes] = await Promise.all([
- fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")),
- fetch("/api/clients"),
- fetch("/api/appointments"),
- fetch("/api/services?includeInactive=true"),
- fetch("/api/staff"),
- ]);
+ const LIMIT = 50;
- if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok || !staffRes.ok) {
- throw new Error("Failed to load data");
- }
-
- const [invData, clientData, apptData, svcData, staffData] = await Promise.all([
- invRes.json() as Promise,
- clientRes.json() as Promise,
- apptRes.json() as Promise,
- svcRes.json() as Promise,
- staffRes.json() as Promise,
- ]);
-
- const clientMap = new Map(clientData.map((c) => [c.id, c.name]));
- const enriched: InvoiceWithClient[] = invData.map((inv) => ({
- ...inv,
- clientName: clientMap.get(inv.clientId),
- }));
-
- setInvoiceList(enriched);
- setClients(clientData);
- setAppointments(apptData);
- setServices(svcData);
- setAllStaff(staffData);
+ async function loadInvoices(newOffset: number) {
+ const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
+ if (statusFilter) params.set("status", statusFilter);
+ const res = await fetch(`/api/invoices?${params}`);
+ if (!res.ok) throw new Error("Failed to load invoices");
+ const page = (await res.json()) as PaginatedResponse;
+ setInvoiceList(page.data);
+ setTotal(page.total);
+ setOffset(newOffset);
}
useEffect(() => {
setLoading(true);
- loadAll()
+ loadInvoices(0)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, [statusFilter]);
+ function loadCreateData() {
+ if (createData) return Promise.resolve();
+ setCreateLoading(true);
+ return Promise.all([
+ fetch("/api/clients"),
+ fetch("/api/appointments"),
+ fetch("/api/services?includeInactive=true"),
+ ])
+ .then(([c, a, s]) => Promise.all([c.json(), a.json(), s.json()]))
+ .then(([clients, appointments, services]) => {
+ setCreateData({ clients, appointments, services });
+ })
+ .finally(() => setCreateLoading(false));
+ }
+
+ function loadDetailData() {
+ if (detailData) return Promise.resolve();
+ setDetailLoading(true);
+ return Promise.all([fetch("/api/staff"), fetch("/api/appointments")])
+ .then(([s, a]) => Promise.all([s.json(), a.json()]))
+ .then(([staff, appointments]) => {
+ setDetailData({ staff, appointments });
+ })
+ .finally(() => setDetailLoading(false));
+ }
+
async function openInvoiceDetail(inv: InvoiceWithClient) {
const res = await fetch(`/api/invoices/${inv.id}`);
if (!res.ok) return;
const data = (await res.json()) as Invoice;
setSelectedInvoice(data);
+ loadDetailData();
}
if (loading) return Loading…
;
@@ -551,6 +578,7 @@ export function InvoicesPage() {
No invoices yet. Create one from a completed appointment.
) : (
+ <>
@@ -584,16 +612,41 @@ export function InvoicesPage() {
+
+
+ {offset + 1}–{Math.min(offset + LIMIT, total)} of {total} invoices
+
+
+
+
+
+
+ >
)}
{showCreate && (
loadCreateData()}
onCreated={() => {
setShowCreate(false);
- loadAll().catch(() => {});
+ setCreateData(null);
+ loadInvoices(0).catch(() => {});
}}
onClose={() => setShowCreate(false)}
/>
@@ -602,12 +655,18 @@ export function InvoicesPage() {
{selectedInvoice && (
setSelectedInvoice(null)}
+ allStaff={detailData?.staff ?? []}
+ allAppointments={detailData?.appointments ?? []}
+ loading={detailLoading}
+ onOpen={() => loadDetailData()}
+ onClose={() => {
+ setSelectedInvoice(null);
+ setDetailData(null);
+ }}
onUpdated={() => {
setSelectedInvoice(null);
- loadAll().catch(() => {});
+ setDetailData(null);
+ loadInvoices(offset).catch(() => {});
}}
/>
)}
--
2.52.0