# WP-IMG-1 — Product images: fix the gallery URL + remove the broken attachment bridge

**Repo:** BE only — `/home/moonui4/moon-erp-be` (branch `hazemdev4`)
**Review:** code-reviewer · **[FIN]:** no · **Migration:** **NO — do not create one**
**Owner-approved 2026-07-19.** Analysis: `/home/moonui4/public_html/his-analysis/webstore-product-images-analysis.html`

## Context — read this before touching anything

Moon ERP has **two unrelated image systems**, and product images were historically pushed through the wrong one:

| System | Storage | Public? |
|---|---|---|
| `products.image` (main) + `product_images` (gallery, `position`-ordered) | `storage/app/public/uploads/products/` via `HandlesImageUpload` | **yes** — served through the `/storage` symlink |
| `attachments` table (polymorphic, internal documents) | `storage/app/private/attachments/…` | **no, deliberately** — served only via authenticated `GET /api/attachments/{id}/download` |

Verified facts (do not re-litigate, but DO verify anything you rely on):
- `product_images` exists (`id, product_id, image, position`) with **0 rows**; `Product::images()` is `hasMany(...)->orderBy('position')` at `Modules/Core/app/Models/Product.php:198-201`.
- `attachments` has **no** flag column (no `type`/`collection`/`is_primary`/`position`) — only `mime_type` distinguishes anything.
- **Nothing in the codebase creates an Attachment for a product image.** `ProductController` + `HandlesImageUpload::resolveImageField` write to disk `public` and set `products.image` / `product_images` directly.
- The owner confirmed the 17k historical products were machine-imported and **never used by a real customer**, so re-importing them correctly is the chosen fix. That import is a SEPARATE work package — **not this one**.

## Task 1 — `ProductImageResource` returns a broken relative path 🔴

`Modules/WebStore/app/Http/Resources/ProductImageResource.php:13`

```php
'image' => $this->image,      // → "uploads/products/x.jpg" — relative, unusable by the storefront
```

Every sibling resource in that same directory builds the URL (`url('storage/'.$this->image)` — see `StoreCategoryTreeResource.php:30`, `StoreAdResource.php:21`, `StoreOfferResource.php:27`). This one does not.

**Consequence:** the product's main image works, and **every gallery image fails**. Latent today only because `product_images` is empty — it detonates on the first multi-image product, i.e. on the very import this is preparing for.

**Required:** make it consistent with its siblings. Keep the existing `image` key's meaning aligned with how the rest of the WebStore API behaves — check `StorefrontProductResource.php:31` (which exposes the *absolute* URL under the key `image`) and decide the shape so the **storefront's existing TypeScript model does not break**. Look at `projects/storefront/src/app/core/models/product.model.ts` in `/home/moonui4/public_html/moon-erp` (READ ONLY — do not edit the frontend in this WP) and state in your report whether the shape you chose matches what the FE already expects. If it does not, say so loudly rather than silently changing the contract.

## Task 2 — the broken bridge in `getImageUrlAttribute()` 🔴

`Modules/Core/app/Models/Product.php:207-222`, second branch:

```php
$attachment = ... $this->attachments()->first();
if ($attachment) {
    return url('storage/'.$attachment->file_path);   // attachments/… lives on the PRIVATE disk
}
```

This composes the **public** `/storage/` prefix over a **private**-disk path. It is a **guaranteed 404** — a well-formed URL that can never resolve. That is worse than `null`, because the UI treats it as valid and tries to load it instead of showing its placeholder immediately.

It also takes **the first attachment by id with no mime-type check** — a PDF datasheet uploaded before the photo becomes "the product image".

**Required:** stop returning a URL that cannot resolve. Preferred: return `null` (the storefront's `ProductImageComponent` already handles empty correctly and shows a local placeholder).

⚠️ **Before you change it, find every consumer** (`image_url` is used by at least `StorefrontProductResource:31`, `StorefrontProductListResource:20`, `StoreCartItemResource:21`, `StoreOfferProductResource:18`, and `ProductResource` builds its own). Report the full list and confirm none depends on the attachment fallback actually working. If you find a consumer that would genuinely regress, **STOP and report instead of guessing.**

## Task 3 — close the door (lower priority, do it only if 1 & 2 are clean)

Make it impossible-or-loud to register a product image through the generic attachments API, so the same mistake cannot recur on the next import. A validation rule or a documented guard is fine. **Do NOT** make the attachments disk public, and **do NOT** change attachment storage for non-product models — that folder holds internal documents for every company and publishing it would be a serious leak.

## Constraints — absolute

1. ⛔ **No migration.** The columns you need already exist.
2. ⛔ **NEVER** `migrate:fresh`/`migrate:refresh`/`db:wipe`/`RefreshDatabase` — `moonui4_dev_be` is not binlogged; a wrong wipe is unrecoverable.
3. ⛔ Touch only `/home/moonui4/…`. Never `/home/moonui`, `/home/moonui2`, `/home/moonui3`.
4. Do **not** edit the frontend in this WP (read-only reference is fine).
5. Do **not** delete or rewrite `FixImagePaths.php` — out of scope.
6. Every query you write carries its own `company_id` filter. The backend has **zero Eloquent global scopes**; `TenantAware` stamps `company_id` on create only.
7. ⛔ **Do not push, deploy, run `/fullpush`, or merge to `main`.**

## Tests

Write Pest regression tests that **fail before your change and pass after** — show both:
- a product with gallery images returns loadable absolute URLs for every gallery entry;
- a product whose only image source is a private attachment does **not** return a URL that 404s.

Baseline: `php artisan test Modules/WebStore --compact` = **376 passed / 1158 assertions**, zero pre-existing failures. Your tests add to that. Also run the `Modules/Core` suite since `Product` is a Core model.

`vendor/bin/pint --dirty --format agent` must be clean.

## Commit

Conventional commit(s) on `hazemdev4`. This is a user-facing fix (gallery images work), so add **one bilingual bullet** under `## [Unreleased]` in `docs/moonstack/CHANGELOG.md` and verify the file stays well-formed.

## Report back

Paste real output for every claim — do not assert. **Flag anything in this brief that turns out to be wrong**: every work package on this project so far has found a genuine error in its own brief, including a factually wrong claim about a seeder and a wrong statement about which routes exist. Be explicit about what you VERIFIED versus what you INFERRED.
