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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}>
|
||||
|
||||
Reference in New Issue
Block a user