Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 660d3e0741 | |||
| 03bd2d0235 |
@@ -68,6 +68,20 @@ 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 body = await response.Body!.transformToBuffer();
|
||||
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,
|
||||
|
||||
@@ -142,10 +142,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 +150,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) => {
|
||||
|
||||
@@ -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, getPresignedGetUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
@@ -215,7 +215,8 @@ 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) => {
|
||||
const db = getDb();
|
||||
@@ -224,8 +225,10 @@ settingsRouter.get("/logo", async (c) => {
|
||||
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);
|
||||
c.header("Content-Type", contentType);
|
||||
c.header("Cache-Control", "public, max-age=86400");
|
||||
return c.body(body);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", logoBase64: null, logoMimeType: null }));
|
||||
setMessage({ type: "success", text: "Logo uploaded." });
|
||||
refresh();
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user