Compare commits

...

12 Commits

Author SHA1 Message Date
Flea Flicker 3d45582609 fix(GRO-874): add requireSuperUser() to GET /api/admin/settings/logo
The logo proxy route was missing auth middleware, allowing any
unauthenticated caller to receive the presigned S3 URL and exposing
the internal Ceph RGW hostname. Matches auth pattern used by all
other /api/admin/* routes in this file.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:42:29 +00:00
Flea Flicker 2af1671891 fix(GRO-870): /api/branding returns raw S3 URL — add public logo proxy
Add GET /api/branding/logo as a public endpoint that proxies logo bytes
from S3, and change /api/branding to return logoUrl: "/api/branding/logo"
instead of calling getPresignedGetUrl(). Eliminates mixed-content warnings
when the branding context is consumed on unauthenticated pages (portal,
login).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:08:36 +00:00
Flea Flicker c811b58c62 fix(GRO-867): remove unused getPresignedGetUrl import from settings.ts
ESLint @typescript-eslint/no-unused-vars flagged the import.
The logo proxy no longer uses pre-signed GET URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 22:20:55 +00:00
Flea Flicker 1dfcdcc2cb fix(GRO-867): c.body does not accept Buffer in Hono 4.x
c.body() signature only accepts string | ArrayBuffer | ReadableStream | Uint8Array
in Hono 4.x, not Node.js Buffer. Return a plain Response directly instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 22:19:26 +00:00
Flea Flicker f74e034495 fix(GRO-867): replace transformToBuffer with async iteration over S3 stream
transformToBuffer() does not exist on StreamingBlobPayloadOutputTypes
in the AWS SDK v3 client. Use for-await-of over the async iterable body
to collect chunks and Buffer.concat instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 22:16:08 +00:00
Flea Flicker 4c46cec4e3 fix(GRO-867): proxy logo download through API server — eliminate mixed content
All logo S3 interactions are now server-proxied:
- GET /api/admin/settings/logo streams image bytes directly instead of
  returning a presigned S3 URL to the browser
- Upload already went through POST /api/admin/settings/logo/upload
- Frontend uses relative /api/admin/settings/logo path as img src,
  never a raw S3 URL
- Appends cache-buster query param (?t=Date.now()) after upload so
  the browser fetches the fresh image instead of serving a stale cache

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 22:07:21 +00:00
the-dogfather-cto[bot] 251b36b863 fix(e2e): mock /api/invoices/stats/summary to prevent Invoices page crash
fix(e2e): mock /api/invoices/stats/summary to prevent Invoices page crash
2026-04-20 13:59:10 +00:00
the-dogfather-cto[bot] 3c366ccc46 Merge pull request #346 from groombook/fix/gro-816-portal-pets-crash
fix(GRO-816): fix PetProfiles crash from appointments response shape change
2026-04-19 11:02:07 +00:00
Test User ff149f75dc fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler
The PR refactored appointments response from { upcoming, past } to
{ appointments: [] } but the `now` variable used to compute those
filters was left behind. ESLint correctly flags it as unused.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 10:52:13 +00:00
Flea Flicker 03bd2d0235 fix(GRO-816): update PetProfiles.tsx to use new appointments response shape
- PetProfiles.tsx: update AppointmentsResponse interface to use flat
  appointments[] array instead of { upcoming, past }
- PetProfiles.tsx: update petHistory filter to use appointments.appointments
  with date filter for past-only appointments
- portal.ts: change /api/portal/appointments response to { appointments: [] }
  instead of { upcoming: [], past: [] }
- portal.ts: change /api/portal/pets response field names to match frontend
  Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl,
  groomingNotes→notes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 08:13:53 +00:00
Test User 10ad5e7b04 fix(e2e): mock /api/invoices/stats/summary to prevent useEffect crash on Invoices page
The GRO-609 paymentStats useEffect fetches /api/invoices/stats/summary
on every render. Without a mock, the response {} (from the generic // Appointments,
clients, ... fallback) doesn't contain revenueThisMonth, causing the page
to fail rendering before AdminLayout ever mounts. Other admin pages don't
have this problem because they don't make unconditional side-effect fetches.

E2E tests mock all /api/** calls, so the new endpoint needs its own mock.

cc @cpfarhood
2026-04-19 02:25:12 +00:00
the-dogfather-cto[bot] 4f85a4a432 feat(gro-609): add refund handling and payment stats to admin (#341)
feat(gro-609): add refund handling and payment stats to admin
2026-04-19 02:05:06 +00:00
8 changed files with 71 additions and 47 deletions
+20 -9
View File
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js";
import { getPresignedGetUrl } from "./lib/s3.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
@@ -126,20 +126,31 @@ function validateLogoMagicBytes(
}
}
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
app.get("/api/branding/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
// Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
let logoUrl: string | null = null;
if (settings.logoKey) {
try {
logoUrl = await getPresignedGetUrl(settings.logoKey);
} catch {
// If S3 URL generation fails, fall back to legacy base64
}
}
// Return the public proxy path so browser never sees a raw S3 URL
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
// Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields
+19
View File
@@ -68,6 +68,25 @@ export async function deleteObject(key: string): Promise<void> {
);
}
/** Read an object from S3 and return its body buffer and content type. */
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
const chunks: Uint8Array[] = [];
// response.Body is a Readable stream; collect chunks into a buffer
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const contentType = response.ContentType ?? "application/octet-stream";
return { body, contentType };
}
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
export async function putObject(
key: string,
+2 -6
View File
@@ -102,7 +102,6 @@ portalRouter.get("/appointments", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
@@ -142,10 +141,7 @@ portalRouter.get("/appointments", async (c) => {
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
return c.json({ appointments: appts });
});
portalRouter.get("/pets", async (c) => {
@@ -153,7 +149,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
});
portalRouter.get("/invoices", async (c) => {
+12 -5
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db";
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
@@ -215,17 +215,24 @@ settingsRouter.post(
/**
* GET /api/admin/settings/logo
* Returns a presigned GET URL for the logo.
* Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type.
*/
settingsRouter.get("/logo", async (c) => {
settingsRouter.get("/logo", requireSuperUser(), async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const url = await getPresignedGetUrl(row.logoKey);
return c.json({ url, logoKey: row.logoKey });
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
/**
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const entry = rateLimitMap.get(ip);
const now = Date.now();
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
+10
View File
@@ -44,6 +44,16 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
});
}
if (url.includes("/api/invoices/stats/summary")) {
return route.fulfill({
json: {
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
},
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
+4 -22
View File
@@ -89,24 +89,14 @@ export function SettingsPage() {
fetch("/api/admin/settings")
.then((r) => r.json())
.then(async (data) => {
let logoUrl: string | null = null;
if (data.logoKey) {
try {
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
logoUrl = logoData.url;
}
} catch {
// ignore
}
}
// The logo is now proxied through the API server so the browser
// never receives an S3 URL — use the proxy path directly as the src.
setForm({
businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null,
logoUrl,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null,
});
@@ -172,15 +162,7 @@ export function SettingsPage() {
throw new Error(err?.error ?? "Failed to upload logo");
}
const { logoKey } = await uploadRes.json();
// Fetch the presigned GET URL for display
const logoRes = await fetch("/api/admin/settings/logo");
if (logoRes.ok) {
const logoData = await logoRes.json();
setForm((f) => ({ ...f, logoKey, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
} else {
setForm((f) => ({ ...f, logoKey, logoUrl: null, logoBase64: null, logoMimeType: null }));
}
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo uploaded." });
refresh();
} catch (err: unknown) {
+3 -4
View File
@@ -27,8 +27,7 @@ interface Appointment {
}
interface AppointmentsResponse {
upcoming: Appointment[];
past: Appointment[];
appointments: Appointment[];
}
interface Props {
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
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) {