← All docs

Image database & R2 storage โ€” build plan

Move every generated image (and its metadata) into a queryable catalog so nothing is lost on disk and every image is auditable.
2026-05-03 Dev task ~1โ€“2 hour build Touches: Cloudflare R2 ยท Supabase ยท apps/control

How to use this doc

Hand this whole doc (or the final prompt block) to a fresh Claude Code session. Everything below is self-contained โ€” no need to re-explain the problem. The prompt at the bottom embeds a link back to this doc for full context.

Goal

Establish a single canonical image catalog so that every image we generate (or upload) ends up in one queryable place with full metadata: who/what generated it, with which prompt, at what cost, where the bytes live, and what use-case it's for. After this lands, no image bypasses the catalog โ€” that's the discipline.

Decisions already made (do not relitigate)

Schema

SQL migration to add to apps/control/migrations/20260504_images.sql (use the next sequential date):

create table public.images (
  id           uuid primary key default gen_random_uuid(),
  slug         text unique,                       -- stable handle, e.g. 'hp-01-hero-v3-1'
  storage_key  text not null,                     -- R2 key, e.g. 'hero/2026-05/hp-01-hero-v3-1.png'
  public_url   text,                              -- https://images.sponicgardens.com/...

  -- generation
  provider     text not null,                     -- 'azure-openai'|'google'|'human-upload'
  model        text,                              -- 'gpt-image-2' | null
  prompt       text,                              -- exact prompt
  size         text,                              -- '1536x1024'
  quality      text,                              -- 'low'|'medium'|'high'
  format       text,                              -- 'png'|'jpg'|'webp'
  cost_usd     numeric(8,4),                      -- computed at gen time

  -- file
  bytes        integer,
  width        integer,
  height       integer,
  sha256       text unique,                       -- dedup

  -- domain / classification
  category     text,                              -- broad bucket: 'brand'|'product'|'docs'|'team'|'event'|'space'|'food'
  use_case     text,                              -- specific role: 'hero'|'audition'|'thumb'|'avatar'|'illustration'|'diagram'
  tags         text[] default '{}',               -- free-form: ['warsaw','spa','evening']
  alt_text     text,                              -- accessibility / SEO
  parent_id    uuid references public.images(id),

  -- audit
  created_at   timestamptz default now(),
  created_by   uuid references auth.users(id),
  metadata     jsonb default '{}'
);

create index on public.images (use_case, created_at desc);
create index on public.images using gin (tags);

-- Read access policy (intranet authenticated users)
alter table public.images enable row level security;
create policy images_read_authenticated on public.images
  for select using (auth.role() = 'authenticated');
-- Insert policy: only via service_role (the wrapper uses service-role key).

Cloudflare R2 setup

  1. Create R2 bucket sponic-images in the wingsiebird account (9cd3a280โ€ฆ).
  2. Bind custom domain images.sponicgardens.com to the bucket. DNS lives in the same Cloudflare account, so the CNAME is one click.
  3. Set bucket CORS to allow GET from https://sponicgardens.com and https://in.sponicgardens.com.
  4. Mint an R2 API token scoped to sponic-images with Read+Write. Store in BW item Cloudflare R2 โ€” sponic-images in devops-sponic folder. Update config/project.config.ts with the bucket name + custom domain + BW item ID.
  5. Smoke-test: PUT a tiny PNG via the S3-compatible API, GET it via https://images.sponicgardens.com/<key>, verify 200.

Wrapper API

One function in apps/control/src/lib/image-gen.ts. No image generation anywhere else in the codebase after this lands.

// apps/control/src/lib/image-gen.ts
import { PROJECT_CONFIG } from '../../../../config/project.config';

export interface GenerateImageOptions {
  category: 'brand' | 'product' | 'docs' | 'team' | 'event' | 'space' | 'food';
  useCase: 'hero' | 'audition' | 'thumb' | 'avatar' | 'illustration' | 'diagram';
  size?: '1024x1024' | '1024x1536' | '1536x1024';
  quality?: 'low' | 'medium' | 'high';
  tags?: string[];          // free-form, e.g. ['warsaw','spa','evening']
  altText?: string;         // accessibility / SEO
  parentId?: string;        // for variants / re-runs of an existing image
  slug?: string;            // optional human handle
}

export interface ImageRecord {
  id: string;
  slug: string | null;
  storage_key: string;
  public_url: string;
  provider: string;
  model: string;
  cost_usd: number;
  bytes: number;
  width: number;
  height: number;
}

export async function generateImage(
  prompt: string,
  opts: GenerateImageOptions
): Promise<ImageRecord> {
  // 1. Resolve config: primary first, fallback on Azure 5xx / quota errors.
  // 2. Call provider; receive PNG bytes (b64 โ†’ Buffer).
  // 3. Compute sha256. If already in public.images, return existing row (dedup).
  // 4. Compute cost from PROJECT_CONFIG.imageGeneration.pricing.
  // 5. Upload to R2 at `${useCase}/YYYY-MM/${slug || sha256.slice(0,12)}.png`.
  // 6. Insert public.images row via supabase service-role client.
  // 7. Return ImageRecord.
}

Backfill

One-shot script (or inline in the migration session) to import the existing branding folder:

Config updates

Add to config/project.config.ts under a new storage section:

storage: {
  r2: {
    bucket: 'sponic-images',
    publicHost: 'images.sponicgardens.com',
    accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
    accessKeyIdEnv: 'R2_ACCESS_KEY_ID',
    secretAccessKeyEnv: 'R2_SECRET_ACCESS_KEY',
    bwItemId: null, // populate after BW item is created
  },
},

Also add the env var names to apps/control/.env.example with BW lookup hints.

Verification

The session is done when:

Watch out for

Prompt โ€” copy into a fresh Claude Code session

Build the Sponic image catalog: Cloudflare R2 bucket + Supabase public.images table + a generateImage() wrapper that writes to both, then backfill the existing /branding hero auditions. Full spec, schema, decisions, and step-by-step plan are in: https://sponicgardens.com/docs/devtasks/imagedatabaseplan Read that doc first. It's self-contained โ€” schema, R2 setup steps, wrapper signature, backfill logic, and "done when" criteria are all there. Don't relitigate decisions already in the "Decisions already made" section. Authoritative project config is at config/project.config.ts (image-gen primary/fallback, pricing table, cloud accounts) and systemarchitecture.md (overall layout). Update both when this lands: add a storage.r2 section to project.config.ts and update the "Image generation" section of systemarchitecture.md to describe the new flow. Credentials needed (all in Bitwarden, folder devops-sponic): - Azure OpenAI endpoint + key โ€” BW item 41461676-1c12-4bc0-be89-b43f00f78928 - Cloudflare token โ€” mint a new one scoped to R2:Edit on the wingsiebird account (id 9cd3a280a54ce2a5b382602f0247b577) using the existing "Cloudflare โ€” Token Factory (wingsiebird)" item in DevOps-alpacapps. Store the new token as a NEW BW item "Cloudflare R2 โ€” sponic-images" in devops-sponic. - Supabase service-role key for SponicControl โ€” already in BW (look up via Supabase Mgmt API recipe in infra/runbook.md if needed). Main branch: main. Cloudflare Pages auto-deploys on push. Verification gates from the doc must all pass before declaring done.

Doc owner: Rahul. Last updated 2026-05-03. After the build session lands, prune sections of this doc that are now redundant with systemarchitecture.md.