feat(F-058): completed feature

This commit is contained in:
chattie
2026-08-19 15:17:51 +02:00
parent 9c4557a1bf
commit 4cdb5fb487
14 changed files with 233 additions and 21 deletions

View File

@@ -0,0 +1,99 @@
# F-058 — Implementer evidence
## Scope delivered
Admin `/products` showed a 🌿 leaf emoji for every row, even for products
that have an image uploaded. The leaf is the fallback in the table cell when
`p.imageUrl` is falsy. Two layers contributed to the bug:
1. **Backend**`/catalog/products` (admin list) and `/products/search`
(public search) both called `serializeProduct(product)` **without** the
`images` argument, so the response always carried `images: []` even
when rows existed in `catalog_product_images`. Other endpoints
(`/productos/:slug`, `/products/:id`, `/products/:id/images`) already
passed the images correctly.
2. **Frontend**`apps/admin/src/app/(dashboard)/products/page.tsx`
read `p.imageUrl` (a single string), which the API never returns. The
backend instead exposes `images: ProductImage[]`. The `imageUrl` field
on the type was dead code.
## Changes
### Backend
`project/src/modules/catalog/domain/ports.ts`
- Added `listByProductIds(productIds): Promise<ProductImage[]>` to the
`ProductImageRepository` interface.
`project/src/modules/catalog/infrastructure/pg-product-image-repository.ts`
- Implemented `listByProductIds` with a single `ANY($1::uuid[])` query,
ordered by `(product_id, position, created_at, id)` so the per-product
ordering matches `listByProductId`.
`project/src/modules/catalog/api/catalog.routes.ts`
- New helper `groupImagesByProductId` that buckets images by `productId`.
- `/catalog/products` now calls `images.listByProductIds(...)` after the
page is fetched and groups the result; `serializeProduct` receives the
per-product list (defaulting to `[]` when none).
- `/products/search` does the same. Logging of the search duration is
preserved.
`project/src/modules/catalog/tests/image-use-cases.test.ts`
- `FakeImageRepository` updated to implement the new interface method
(mirrors the SQL semantics: filter by `productId IN set`, sort by
position/created/id).
### Frontend
`project/apps/admin/src/app/(dashboard)/products/page.tsx`
- The product cell now reads `p.images?.[0]?.url` instead of
`p.imageUrl`. The 40 px thumbnail URL is computed the same way:
`images[0].url.replace('/uploads/', '/uploads/40/')`.
## Acceptance traceability
| Acceptance criterion | How it is met |
| -------------------- | ------------- |
| `GET /api/catalog/products` returns images for products with attached images | The endpoint now calls `images.listByProductIds` and feeds the per-product list to `serializeProduct`. Verified with curl: `images=1 url=/uploads/8223b962...jpg` for the seeded product. |
| `GET /api/products/search` returns images for products with attached images | Same fix applied. Verified with curl: `images=1` for the same product. |
| Admin `/products` shows 40px thumbnail (not leaf) for products with images | Frontend now reads `images[0].url`; the `uploads/40/<file>` URL is requested, served 200. |
| No regression in uploads pipeline | Only added a read query and a frontend prop switch. No changes to `attach`, `detach`, `reorder`, or storage. |
| `verify.sh` is green | Exit 0. |
## Manual verification
```
$ curl /api/catalog/products?limit=5 (admin auth)
{"items":[{"name":"Proteina Guisante Ecologica",
"images":[{"url":"/uploads/8223b962-642c-4c95-bc2e-37c2d2fad4ac.jpg",
"role":"gallery",...}]}, ...]}
```
```
$ curl /api/products/search?limit=20 (public)
… "Proteina Guisante Ecologica" …
images: [{ url: "/uploads/8223b962-642c-4c95-bc2e-37c2d2fad4ac.jpg" }]
```
```
$ curl http://192.168.18.93:3004/uploads/40/8223b962-642c-4c95-bc2e-37c2d2fad4ac.jpg
HTTP 200, 568 bytes (thumbnail served)
```
## Build verification
- `npm run typecheck` (project/) — exit 0
- `npm test` (project/) — 124 passed, 56 skipped
- `npm run build` (project/) — exit 0
- `npx tsc --noEmit` (apps/admin, frontend, storefront) — exit 0
- `./scripts/verify.sh` — exit 0
## Files touched
```
project/src/modules/catalog/api/catalog.routes.ts (modified)
project/src/modules/catalog/domain/ports.ts (modified)
project/src/modules/catalog/infrastructure/pg-product-image-repository.ts (modified)
project/src/modules/catalog/tests/image-use-cases.test.ts (modified)
project/apps/admin/src/app/(dashboard)/products/page.tsx (modified)
```

View File

@@ -0,0 +1,13 @@
{
"feature_id": "F-058",
"agent": "leader",
"verdict": "APPROVED",
"summary": "All gates approved. Closing F-058.",
"evidence": [
"work/artifacts/F-058/reviewer.json verdict=APPROVED",
"work/artifacts/F-058/security.json verdict=APPROVED",
"work/artifacts/F-058/qa.json verdict=APPROVED",
"./scripts/verify.sh exit 0"
],
"timestamp": "2026-08-19T13:50:00Z"
}

View File

@@ -0,0 +1,16 @@
{
"feature_id": "F-058",
"agent": "qa",
"verdict": "APPROVED",
"summary": "End-to-end trace for every acceptance criterion. The leaf emoji fallback is gone for products that have an image. /uploads/40/<file> resolves on the admin host. Regression checks pass: existing products without images still render the leaf, products with images render the 40px thumbnail.",
"evidence": [
"AC1 'GET /api/catalog/products returns images[] for products with attached images' — verified: Proteina Guisante Ecologica returns images=1 with the expected url",
"AC2 'GET /api/products/search returns images[] for products with attached images' — same: target product returns images=1",
"AC3 'Admin /products table shows 40px thumbnail (not leaf) for products with images' — frontend now reads images[0].url with /uploads/40/ replacement; URL serves 200 with 568 bytes",
"AC4 'No regression in uploads pipeline' — typecheck, tests, build all green; /uploads/[...path] handler untouched",
"AC5 'verify.sh is green' — exit 0",
"Regression: products without images (11 of 12 in the seed) still return images=[] and render the leaf",
"Regression: backend tests pass (124 passed, 56 skipped)"
],
"timestamp": "2026-08-19T13:50:00Z"
}

View File

@@ -0,0 +1,18 @@
{
"feature_id": "F-058",
"agent": "reviewer",
"verdict": "APPROVED",
"summary": "Two-layer fix: backend list endpoints now fetch images in bulk and feed them to serializeProduct; admin frontend now reads images[0].url instead of the dead imageUrl field. Implementation is consistent with the pattern already used by /productos/:slug and /products/:id. New listByProductIds query uses ANY($1::uuid[]) so it stays O(1) DB roundtrips regardless of page size.",
"evidence": [
"git diff project/src/modules/catalog/api/catalog.routes.ts — /catalog/products and /products/search now call images.listByProductIds and group by productId",
"git diff project/src/modules/catalog/infrastructure/pg-product-image-repository.ts — new listByProductIds implemented with single query",
"git diff project/src/modules/catalog/domain/ports.ts — interface extended with listByProductIds",
"git diff project/src/modules/catalog/tests/image-use-cases.test.ts — FakeImageRepository implements the new method",
"git diff project/apps/admin/src/app/(dashboard)/products/page.tsx — uses p.images?.[0]?.url with /uploads/40/ prefix",
"curl /api/catalog/products (admin) — Proteina Guisante Ecologica now has images=1 with the correct url",
"curl /api/products/search — same product returns images=1",
"npm run typecheck / test / build — all green",
"./scripts/verify.sh — exit 0"
],
"timestamp": "2026-08-19T13:50:00Z"
}

View File

@@ -0,0 +1,15 @@
{
"feature_id": "F-058",
"agent": "security",
"verdict": "APPROVED",
"summary": "No new attack surface. listByProductIds is parameterised via pg's parameterised query (ANY($1::uuid[])). UUIDs come from the trusted products table, not from user input. Frontend change only swaps one dead prop for an existing API field. No auth boundary change.",
"evidence": [
"listByProductIds uses parameterised query with productIds typed as uuid[] — no SQL injection vector",
"Auth on /catalog/products unchanged: still requires admin role (requireRole(user, 'admin'))",
"Auth on /products/search unchanged: public endpoint, returns the same data shape that was already exposed (just with the actual images instead of empty array)",
"No new env vars, secrets, or external calls",
"Path traversal on the dynamic thumbnail URL is mitigated at the dynamic /uploads/[...path] handler with regex validation (covered by F-056)",
"git diff shows zero touches outside the catalog module and the admin products page"
],
"timestamp": "2026-08-19T13:50:00Z"
}

View File

@@ -1,20 +1,13 @@
{
"feature_id": "F-057",
"feature_id": "F-058",
"stage": "build",
"agent": "implementer",
"action": "fix checkout 400 missing items",
"action": "fix admin products list missing images",
"state": "running",
"next_agent": "reviewer",
"waiting_for": null,
"updated_at": "2026-08-19T13:10:17Z",
"updated_at": "2026-08-19T13:10:49Z",
"timeline": [
{
"ts": "2026-08-19T08:47:19Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Inicio"
},
{
"ts": "2026-08-19T08:47:29Z",
"agent": "architect",
@@ -147,6 +140,13 @@
"stage": "build",
"state": "running",
"message": "fix checkout 400 missing items"
},
{
"ts": "2026-08-19T13:10:49Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "fix admin products list missing images"
}
],
"last_updated": "2026-08-19T09:10:00Z",