feat: pet photo upload via presigned S3 URLs (GH #93, GRO-123)

- DB migration 0012: add photo_key and photo_uploaded_at columns to pets table
- S3 client utility (apps/api/src/lib/s3.ts): presigned PUT/GET, delete via Rook-Ceph RGW
- API photo routes on petsRouter:
  - POST /:petId/photo/upload-url — returns presigned PUT URL + object key
  - POST /:petId/photo/confirm    — records key in DB after successful upload
  - DELETE /:petId/photo          — deletes from storage and clears DB
  - GET /:petId/photo             — returns presigned GET URL
- RBAC: all staff roles (manager, receptionist, groomer) may upload/delete photos;
  restructured index.ts guards so groomer-accessible photo paths don't overlap
  with the manager/receptionist-only general pets write guard
- Frontend PetPhotoDisplay: responsive image with shimmer skeleton and paw placeholder
- Frontend PetPhotoUpload: client-side resize to max 1200px, XHR with progress,
  presigned PUT flow — binary data never passes through the API server
- Wired both components into Clients.tsx staff portal pet cards
- Unit tests: 14 test cases covering all four routes (happy path + error cases)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Scrubs McBarkley
2026-03-22 00:07:48 +00:00
parent 8fdffb9564
commit 1380848aea
11 changed files with 1998 additions and 27 deletions
+3 -1
View File
@@ -21,7 +21,9 @@
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"openid-client": "^6.1.7",
"zod": "^3.24.1"
"zod": "^3.24.1",
"@aws-sdk/client-s3": "^3.800.0",
"@aws-sdk/s3-request-presigner": "^3.800.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
+242
View File
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
role: "manager",
name: "Manager McManager",
email: "manager@example.com",
active: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
// ─── Shared mutable DB state ──────────────────────────────────────────────────
const PET_ID = "pet-uuid-1234";
const PHOTO_KEY = `pets/${PET_ID}/1700000000000.jpg`;
let dbPetRow: Record<string, unknown> | null;
function resetDb() {
dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null };
}
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
const pets = new Proxy(
{ _name: "pets" },
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => (dbPetRow ? [dbPetRow] : []),
}),
}),
update: () => ({
set: () => ({
where: () => ({
returning: () => (dbPetRow ? [{ ...dbPetRow }] : []),
}),
}),
}),
}),
pets,
eq: vi.fn(),
};
});
vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-put"),
getPresignedGetUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-get"),
deleteObject: vi.fn().mockResolvedValue(undefined),
}));
// ─── Import after mocks are set up ───────────────────────────────────────────
const { petsRouter } = await import("../routes/pets.js");
// ─── App builder ─────────────────────────────────────────────────────────────
function buildApp(staffRow: StaffRow) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
c.set("staff", staffRow);
await next();
});
app.route("/pets", petsRouter);
return app;
}
// ─── Reset before each test ───────────────────────────────────────────────────
beforeEach(() => {
resetDb();
vi.clearAllMocks();
});
// ─── POST /:petId/photo/upload-url ───────────────────────────────────────────
describe("POST /pets/:petId/photo/upload-url", () => {
it("returns presigned upload URL and object key for valid image contentType", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/jpeg" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { uploadUrl: string; key: string };
expect(body.uploadUrl).toBe("https://storage.example.com/presigned-put");
expect(body.key).toMatch(/^pets\//);
expect(body.key).toContain(PET_ID);
});
it("rejects non-image contentType with 400", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "application/pdf" }),
});
expect(res.status).toBe(400);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/jpeg" }),
});
expect(res.status).toBe(404);
});
it("allows groomers to request an upload URL", async () => {
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/png" }),
});
expect(res.status).toBe(200);
});
});
// ─── POST /:petId/photo/confirm ───────────────────────────────────────────────
describe("POST /pets/:petId/photo/confirm", () => {
it("confirms upload and returns ok: true", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: PHOTO_KEY }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when key is missing", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: PHOTO_KEY }),
});
expect(res.status).toBe(404);
});
});
// ─── DELETE /:petId/photo ────────────────────────────────────────────────────
describe("DELETE /pets/:petId/photo", () => {
it("returns 404 with 'no photo' message when pet has no photo", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/no photo/i);
});
it("deletes photo and returns ok: true when photo exists", async () => {
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
expect(res.status).toBe(404);
});
});
// ─── GET /:petId/photo ────────────────────────────────────────────────────────
describe("GET /pets/:petId/photo", () => {
it("returns 404 when pet has no photo", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(404);
});
it("returns presigned GET URL when photo exists", async () => {
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(200);
const body = (await res.json()) as { url: string; photoKey: string };
expect(body.url).toBe("https://storage.example.com/presigned-get");
expect(body.photoKey).toBe(PHOTO_KEY);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(404);
});
it("groomer can read photo URL", async () => {
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(200);
});
});
+17 -2
View File
@@ -72,13 +72,28 @@ api.use("/impersonation/*", requireRole("manager"));
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
// Clients, pets, appointments: all roles may read; only manager + receptionist may write
// Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms)
// These must be registered before the general pets write guard. Because Hono path params
// match single segments, "/pets/:petId" does NOT match "/pets/:petId/photo/:action",
// so there is no guard overlap.
api.on(
["POST", "DELETE"],
["/pets/:petId/photo", "/pets/:petId/photo/:action"],
requireRole("manager", "receptionist", "groomer")
);
// Clients, appointments: all roles may read; only manager + receptionist may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
["/clients/*", "/pets/*", "/appointments/*"],
["/clients/*", "/appointments/*"],
requireRole("manager", "receptionist")
);
// Pets (non-photo CRUD): manager + receptionist for writes
// ":petId" matches only single-segment paths — photo sub-routes are unaffected
api.post("/pets", requireRole("manager", "receptionist"));
api.on(["PUT", "PATCH", "DELETE"], "/pets/:petId", requireRole("manager", "receptionist"));
// Services: all roles may read; only managers may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
+62
View File
@@ -0,0 +1,62 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
function getS3Client(): S3Client {
return new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true, // required for Ceph RGW
});
}
function getBucket(): string {
return process.env.S3_BUCKET ?? "groombook-pet-photos";
}
/** Generate a presigned PUT URL for uploading a pet photo. Expires in 15 min. */
export async function getPresignedUploadUrl(
key: string,
contentType: string,
expiresIn = 900
): Promise<string> {
const client = getS3Client();
const command = new PutObjectCommand({
Bucket: getBucket(),
Key: key,
ContentType: contentType,
});
return getSignedUrl(client, command, { expiresIn });
}
/** Generate a presigned GET URL for viewing a pet photo. Expires in 1 hour. */
export async function getPresignedGetUrl(
key: string,
expiresIn = 3600
): Promise<string> {
const client = getS3Client();
const command = new GetObjectCommand({
Bucket: getBucket(),
Key: key,
});
return getSignedUrl(client, command, { expiresIn });
}
/** Delete a pet photo object from storage. */
export async function deleteObject(key: string): Promise<void> {
const client = getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: getBucket(),
Key: key,
})
);
}
+108 -1
View File
@@ -2,8 +2,14 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq, getDb, pets } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
getPresignedGetUrl,
deleteObject,
} from "../lib/s3.js";
export const petsRouter = new Hono();
export const petsRouter = new Hono<AppEnv>();
const createPetSchema = z.object({
clientId: z.string().uuid(),
@@ -90,3 +96,104 @@ petsRouter.delete("/:id", async (c) => {
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
// ─── Photo routes ──────────────────────────────────────────────────────────────
const uploadUrlSchema = z.object({
contentType: z
.string()
.refine((v) => v.startsWith("image/"), {
message: "contentType must be an image/* MIME type",
}),
});
const confirmSchema = z.object({
key: z.string().min(1),
});
/**
* POST /:petId/photo/upload-url
* Returns a presigned S3 PUT URL and the object key for the upload.
* All staff roles (manager, receptionist, groomer) may call this.
*/
petsRouter.post(
"/:petId/photo/upload-url",
zValidator("json", uploadUrlSchema),
async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const { contentType } = c.req.valid("json");
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Pet not found" }, 404);
const ext = contentType.split("/")[1] ?? "jpg";
const key = `pets/${petId}/${Date.now()}.${ext}`;
const uploadUrl = await getPresignedUploadUrl(key, contentType);
return c.json({ uploadUrl, key });
}
);
/**
* POST /:petId/photo/confirm
* Called after the client has successfully uploaded to the presigned URL.
* Records the object key in the DB.
*/
petsRouter.post(
"/:petId/photo/confirm",
zValidator("json", confirmSchema),
async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const { key } = c.req.valid("json");
const [row] = await db
.update(pets)
.set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() })
.where(eq(pets.id, petId))
.returning();
if (!row) return c.json({ error: "Pet not found" }, 404);
return c.json({ ok: true, photoKey: row.photoKey });
}
);
/**
* DELETE /:petId/photo
* Removes the photo from object storage and clears the DB record.
* Manager-only (write-destructive operation).
*/
petsRouter.delete("/:petId/photo", async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Pet not found" }, 404);
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
await deleteObject(pet.photoKey);
await db
.update(pets)
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
.where(eq(pets.id, petId));
return c.json({ ok: true });
});
/**
* GET /:petId/photo
* Returns a presigned GET URL for the pet's photo.
* All authenticated staff may access (read).
*/
petsRouter.get("/:petId/photo", async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Pet not found" }, 404);
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
const url = await getPresignedGetUrl(pet.photoKey);
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
});
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
interface Props {
petId: string;
/** Size of the photo avatar in pixels. Default: 64. */
size?: number;
className?: string;
}
type PhotoState =
| { status: "idle" }
| { status: "loading" }
| { status: "loaded"; url: string }
| { status: "none" }
| { status: "error" };
/**
* Fetches and displays a pet's photo from the API.
* Shows a loading skeleton while fetching, a paw-print placeholder when no photo exists,
* and gracefully falls back to the placeholder on error.
*/
export function PetPhotoDisplay({ petId, size = 64, className }: Props) {
const [state, setState] = useState<PhotoState>({ status: "idle" });
useEffect(() => {
setState({ status: "loading" });
fetch(`/api/pets/${petId}/photo`)
.then(async (res) => {
if (res.status === 404) {
setState({ status: "none" });
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { url: string };
setState({ status: "loaded", url: data.url });
})
.catch(() => setState({ status: "error" }));
}, [petId]);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: Math.round(size * 0.2),
overflow: "hidden",
background: "#f0ebe4",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
};
if (state.status === "loading") {
return (
<div
className={className}
style={{
...containerStyle,
background: "linear-gradient(90deg, #f0ebe4 25%, #e8e0d8 50%, #f0ebe4 75%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s infinite",
}}
aria-label="Loading photo…"
/>
);
}
if (state.status === "loaded") {
return (
<div className={className} style={containerStyle}>
<img
src={state.url}
alt="Pet photo"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
loading="lazy"
/>
</div>
);
}
// no photo / error — paw placeholder
return (
<div className={className} style={containerStyle} aria-label="No photo">
<span style={{ fontSize: Math.round(size * 0.45), userSelect: "none" }}>🐾</span>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useRef, useState } from "react";
interface Props {
petId: string;
/** Called after a successful upload so the parent can refresh the display. */
onUploaded: () => void;
}
const MAX_DIMENSION = 1200;
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
/**
* Client-side-resize-then-upload component.
*
* Flow:
* 1. User selects a file
* 2. Component resizes to max 1200px on the longest side (canvas)
* 3. Requests a presigned PUT URL from the API
* 4. PUTs the resized blob directly to object storage
* 5. Confirms upload with the API (records the key in DB)
*/
export function PetPhotoUpload({ petId, onUploaded }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<
| { status: "idle" }
| { status: "resizing" }
| { status: "uploading"; progress: number }
| { status: "confirming" }
| { status: "done" }
| { status: "error"; message: string }
>({ status: "idle" });
async function resizeImage(file: File): Promise<{ blob: Blob; contentType: string }> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const { width, height } = img;
const scale =
Math.max(width, height) > MAX_DIMENSION
? MAX_DIMENSION / Math.max(width, height)
: 1;
const canvas = document.createElement("canvas");
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("Canvas not supported"));
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const contentType = file.type === "image/png" ? "image/png" : "image/jpeg";
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error("Failed to encode image"));
resolve({ blob, contentType });
},
contentType,
0.85
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
});
}
async function handleFile(file: File) {
if (!ACCEPTED_TYPES.includes(file.type)) {
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
return;
}
setState({ status: "resizing" });
let blob: Blob;
let contentType: string;
try {
({ blob, contentType } = await resizeImage(file));
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Image resize failed" });
return;
}
// Get presigned upload URL
setState({ status: "uploading", progress: 0 });
let uploadUrl: string;
let key: string;
try {
const res = await fetch(`/api/pets/${petId}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as { uploadUrl: string; key: string };
uploadUrl = data.uploadUrl;
key = data.key;
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to get upload URL" });
return;
}
// Upload directly to object storage
try {
const xhr = new XMLHttpRequest();
await new Promise<void>((resolve, reject) => {
xhr.upload.addEventListener("progress", (ev) => {
if (ev.lengthComputable) {
setState({ status: "uploading", progress: Math.round((ev.loaded / ev.total) * 100) });
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
});
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", contentType);
xhr.send(blob);
});
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Upload failed" });
return;
}
// Confirm with API
setState({ status: "confirming" });
try {
const res = await fetch(`/api/pets/${petId}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to confirm upload" });
return;
}
setState({ status: "done" });
onUploaded();
// Reset after a moment
setTimeout(() => setState({ status: "idle" }), 2000);
}
const busy = state.status === "resizing" || state.status === "uploading" || state.status === "confirming";
return (
<div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
// reset so re-selecting same file works
e.target.value = "";
}}
/>
<button
type="button"
disabled={busy}
onClick={() => inputRef.current?.click()}
style={{
fontSize: 12,
padding: "0.2rem 0.55rem",
borderRadius: 5,
border: "1px solid #d1d5db",
background: "#fff",
cursor: busy ? "not-allowed" : "pointer",
color: busy ? "#9ca3af" : "#374151",
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
}}
>
{state.status === "idle" && "📷 Upload photo"}
{state.status === "resizing" && "Resizing…"}
{state.status === "uploading" && `Uploading ${state.progress}%`}
{state.status === "confirming" && "Saving…"}
{state.status === "done" && "✓ Uploaded"}
{state.status === "error" && "📷 Upload photo"}
</button>
{state.status === "error" && (
<div style={{ fontSize: 11, color: "#dc2626", marginTop: "0.2rem" }}>
{state.message}
</div>
)}
</div>
);
}
+44 -23
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
// ─── Forms ───────────────────────────────────────────────────────────────────
@@ -70,6 +72,12 @@ export function ClientsPage() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState("");
// Photo refresh counters (incremented after upload to force PetPhotoDisplay re-fetch)
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
}, []);
// Visit log
const [logPetId, setLogPetId] = useState<string | null>(null);
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
@@ -512,30 +520,43 @@ export function ClientsPage() {
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
<div style={{ display: "flex", gap: "0.3rem" }}>
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
<button
onClick={() => openLogForm(p.id)}
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, backgroundColor: "#eff6ff", borderColor: "#bfdbfe" }}
>
Log visit
</button>
<button
onClick={() => { void deletePet(p.id); }}
disabled={deletingPetId === p.id}
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, color: "#dc2626", borderColor: "#fca5a5" }}
>
{deletingPetId === p.id ? "…" : "Delete"}
</button>
{/* ── Photo + header ── */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
<div style={{ display: "flex", gap: "0.3rem" }}>
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
<button
onClick={() => openLogForm(p.id)}
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, backgroundColor: "#eff6ff", borderColor: "#bfdbfe" }}
>
Log visit
</button>
<button
onClick={() => { void deletePet(p.id); }}
disabled={deletingPetId === p.id}
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, color: "#dc2626", borderColor: "#fca5a5" }}
>
{deletingPetId === p.id ? "…" : "Delete"}
</button>
</div>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.2rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
@@ -0,0 +1,5 @@
-- Add photo storage columns to pets table
-- Ref: GitHub #93
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;
+2
View File
@@ -81,6 +81,8 @@ export const pets = pgTable("pets", {
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
+1228
View File
File diff suppressed because it is too large Load Diff