##### Context
Catalog and gift imagery is currently referenced as absolute web paths (`/img/catalog/rose-bouquet.jpg`) stored directly in the database and, in one case, hardcoded in a controller:
- `Product.DefaultImageUrl`, `ProductImage.Url`
- gift snapshots frozen at submit: `CustomerScheduledGift.ProductImageUrlSnapshot`, `ScheduledGiftTransaction.ProductImageUrlSnapshot`
- `CustomerScheduledGift.CardPhotoUrl`
- the Scheduled-Gift category tiles, hardcoded as `/img/catalog/category-*.jpg` in `GiftController`
The physical files live in `Rempla.Web/wwwroot/img/catalog/`. There is no upload path — an admin pastes a URL into a text field on **Products → Edit**.
This couples image assets to the web deploy artifact and does not scale:
1. **Deploy-coupled.** Images sit inside `wwwroot`, which is overwritten on every publish. The asset library cannot grow independently of the application, cannot be backed up on its own, and bloats the deploy.
2. **No ownership.** Rempla does not yet host its own catalog imagery as a managed, first-class asset store; URLs imply externally-hosted or hand-placed files.
3. **Shared-use friction.** Both Rempla.Web (customer flow) and Rempla.Admin (catalog management) need the same images; a wwwroot-per-app model duplicates or cross-references awkwardly.
4. **Flat and unbounded.** A single `img/catalog` folder will become a dumping ground as the catalog grows.
5. **No backend seam.** Moving to blob/CDN later would require rewriting stored values everywhere.
The goal: Rempla owns its catalog images, stored independently of the site, organized by category, with the database holding only a stable relative key.
##### Decision
Store catalog images in a Rempla-owned **image root outside the web roots**, served through a dedicated static-file mapping, with the database holding only a **category-relative path**. Introduce `ICatalogImageResolver` as the single seam between a stored path and the delivered URL.
1. **Image root (config-mapped).** `Catalog:ImageRoot`.
- **Prod:** `C:\images` — same drive as the IIS sites, top-level, **outside** `C:\inetpub\rempla` (and `…\rempla-admin`) so deploys never touch it. Shared by both apps.
- **Dev:** a repo-adjacent folder; a relative `ImageRoot` resolves against the app's ContentRoot, an absolute one is used as-is.
- Optional `Catalog:ImageBaseUrl` to point at a CDN host in a later phase.
2. **Serving.** A second `UseStaticFiles` with a `PhysicalFileProvider` rooted at `ImageRoot`, `RequestPath = "/catalog-img"`, registered in **both** Web and Admin. The provider confines serving to the root (no `../` traversal); no custom streaming controller needed.
3. **Database stores a relative path only** — `{categorySlug}/{filename}` (e.g. `flowers/rose-bouquet.jpg`). Never a URL, never a physical path. This value is stable across environments and across a storage-backend swap.
4. **Rename columns to `*ImagePath`** to stop the names implying a ready-to-use URL and to force callers through the resolver:
- `Product.DefaultImageUrl` → `DefaultImagePath`
- `ProductImage.Url` → `ImagePath`
- `CustomerScheduledGift.ProductImageUrlSnapshot` → `ProductImagePathSnapshot`
- `ScheduledGiftTransaction.ProductImageUrlSnapshot` → `ProductImagePathSnapshot`
- `CustomerScheduledGift.CardPhotoUrl` → `CardPhotoPath`
- **Add `ProductCategory.ImagePath`** and move the hardcoded category tiles out of `GiftController` into seeded category data.
5. **Resolver.** `ICatalogImageResolver.ToUrl(string? relativePath)` → public URL (`{ImageBaseUrl ?? "/catalog-img"}/{relativePath}`), returning a placeholder for null/empty. It is the one place that changes for a CDN/blob backend. Every `
`/`background-image` in Web and Admin renders through it; raw column values are no longer used directly.
6. **Organization.** One subfolder per catalog **category slug**; the relative path encodes the category. Mirrors the catalog taxonomy and avoids a single massive folder.
##### Rationale
- An external root plus a static mapping keeps **DB values stable** when the storage backend changes — blob/CDN becomes a resolver + mapping change, not a data migration.
- Outside `wwwroot`/`inetpub`: **deploy-safe**, independently backupable, unbounded growth.
- A **relative path** (not a URL) is portable across environments and hosts; the host/scheme is composed at render time.
- The rename removes the "Url" misnomer and **routes every consumer through the resolver**, preventing code from assuming a usable URL.
- `PhysicalFileProvider` provides safe, traversal-free serving with no bespoke controller.
- Category subfolders keep the store navigable and align it with the catalog model.
##### Consequences
- A **rename migration** runs against the DB; the `HasData` seed is updated to relative paths; `ProductCategory` gains `ImagePath` plus seed values.
- **New per-environment config** is required. A missing/empty root must degrade gracefully to a placeholder, not 500.
- Web and Admin **must resolve every image** through `ICatalogImageResolver`; raw stored values are no longer valid `
` targets.
- **Deploy/runbook change:** provision `C:\images` on the server, seed it with the initial assets, and **exclude it from the publish sync**. Add it to the backup set. (`DEPLOYMENT.md` to be updated.)
- ADR-010's references to `ProductImageUrlSnapshot` are superseded by the `…ImagePath…` naming established here.
- **Phase 3 (blob/CDN) is deferred:** the resolver and `RequestPath` are the only swap points; stored data is unaffected.
##### Migration & Phasing
- **Phase 1 — Storage model.** Config + `PhysicalFileProvider` mapping (Web + Admin); `ICatalogImageResolver`; rename migration; `ProductCategory.ImagePath` + seed; relocate the 7 existing images into category subfolders under the image root; migrate seed/data to relative paths; render every image through the resolver. No user-visible change.
- **Phase 2 — Admin upload.** Multipart form + `IFormFile` handler on Products (and category/variant as needed): validate content-type and size, write the file under `{categorySlug}/` with a collision-safe (slug/GUID) filename, store the relative path. Replace the `DefaultImageUrl` text box with an uploader + preview.
- **Phase 3 — Blob/CDN (later).** Swap the resolver to a blob/CDN backend; provision storage + credentials; set `Catalog:ImageBaseUrl`. No database change.