Files
bsk/lib/imaging/image-schema.ts
tiennm99 e718547332 feat(imaging): checkup media capture, gallery & barcode (Phase 5)
- private bsk-checkup-media bucket + checkup_images table; RLS: enrolled read,
  clinical write; storage.objects policies scoped to the bucket
- webcam (getUserMedia) + file capture, client canvas JPEG compression to
  <=200KB; upload via browser client, metadata recorded server-side with a
  path-ownership guard; audit-logged
- gallery with 1h signed URLs + soft-delete; code128 barcode of the checkup id
  (no PII); linked from the checkup page; vi/en; bwip-js added
2026-07-25 12:36:49 +07:00

49 lines
1.8 KiB
TypeScript

/**
* Shared constants + validation for checkup imaging (Phase 5). Shared
* client/server. Storage layout: private bucket `bsk-checkup-media`, object
* key `${checkupId}/${uuid}.jpg`. Compression happens client-side (canvas →
* JPEG, quality stepped down until ≤200KB); signed URLs are issued
* server-side with a 1h TTL and never persisted.
*/
import { z } from "zod";
export const CHECKUP_MEDIA_BUCKET = "bsk-checkup-media";
export const MAX_IMAGE_BYTES = 200 * 1024; // 200 KB — Hobby-tier storage budget (PLAN §5, §7)
export const SIGNED_URL_TTL_SECONDS = 60 * 60; // 1 hour
const UUID_JPG = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/i;
/** Builds the object key for an uploaded image — `{checkupId}/{uuid}.jpg`. */
export function buildStoragePath(checkupId: number, uuid: string): string {
return `${checkupId}/${uuid}.jpg`;
}
/**
* Validates that a storage path belongs to the given checkup and matches the
* `{uuid}.jpg` naming convention. Prevents a client from recording metadata
* that points at an object under a different checkup (or an arbitrary key).
*/
export function isValidStoragePath(checkupId: number, path: string): boolean {
const parts = path.split("/");
if (parts.length !== 2 || parts[0] !== String(checkupId)) return false;
const filename = parts[1] ?? "";
return UUID_JPG.test(filename);
}
export const RecordImageSchema = z.object({
checkupId: z.coerce.number().int().positive(),
storagePath: z.string().trim().min(1).max(300),
});
export const DeleteImageSchema = z.object({
checkupId: z.coerce.number().int().positive(),
imageId: z.coerce.number().int().positive(),
storagePath: z.string().trim().min(1).max(300),
});
export type ImageActionState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "success" };