##### 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.