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