- {/* Detail image: the container fills its column so the image is
- always horizontally centered within the column (matches the
- behaviour of the checkout layout). `max-h` bounds the height
- and `object-contain` on the inner image preserves the source
- aspect ratio without cropping. */}
-
diff --git a/project/src/modules/catalog/api/catalog.routes.ts b/project/src/modules/catalog/api/catalog.routes.ts
index 673604a..f54ecd2 100644
--- a/project/src/modules/catalog/api/catalog.routes.ts
+++ b/project/src/modules/catalog/api/catalog.routes.ts
@@ -655,6 +655,9 @@ function serializeProduct(product: Product, images: ProductImage[] = []) {
images: images.map(serializeImage),
description: product.description,
state: product.state,
+ channels: product.channels,
+ featured: product.featured,
+ attributes: product.attributes,
seoTitle: product.seoTitle,
seoDescription: product.seoDescription,
categoryIds: product.categoryIds,
diff --git a/project/src/modules/catalog/infrastructure/pg-product-repository.ts b/project/src/modules/catalog/infrastructure/pg-product-repository.ts
index ad5f2cb..749f493 100644
--- a/project/src/modules/catalog/infrastructure/pg-product-repository.ts
+++ b/project/src/modules/catalog/infrastructure/pg-product-repository.ts
@@ -121,8 +121,16 @@ export class PgProductRepository implements ProductRepository {
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
- values.push(patch[key]);
- setClauses.push(`${column} = $${values.length}`);
+ // The `attributes` column is JSONB; pg-node would otherwise serialise
+ // JS arrays as PG array literals (`{bio,keto}`) which the JSONB parser
+ // rejects. Stringify explicitly and cast to JSONB.
+ if (key === 'attributes' && Array.isArray(patch[key])) {
+ values.push(JSON.stringify(patch[key]));
+ setClauses.push(`${column} = $${values.length}::jsonb`);
+ } else {
+ values.push(patch[key]);
+ setClauses.push(`${column} = $${values.length}`);
+ }
}
}
diff --git a/work/artifacts/F-066/implementer.md b/work/artifacts/F-066/implementer.md
new file mode 100644
index 0000000..c854aa1
--- /dev/null
+++ b/work/artifacts/F-066/implementer.md
@@ -0,0 +1,122 @@
+# F-066 — Implementer evidence
+
+## Scope delivered
+
+Three separate defects surfaced together; all are addressed in this
+ticket.
+
+1. **Image invisible on `/products/[slug]`.** The container in
+ `frontend/src/app/products/[slug]/page.tsx` was `relative w-full
+ max-h-[500px]` with a `next/image` `fill` child. `next/image` with
+ `fill` requires a non-zero-height parent; `max-h` is only a ceiling,
+ so the parent collapsed to zero and the absolutely positioned image
+ filled zero pixels. F-061 had removed the `aspect-[5/7]` constraint
+ that used to give the box a real height (it caused a different
+ centering problem). This fix restores the `aspect-[5/7]` inside a
+ flex centering wrapper, so the box has a height **and** is centred
+ horizontally inside its grid cell.
+
+2. **`PATCH /api/products/:id` returned 500 when the payload included
+ `attributes`.** The `attributes` column is JSONB, but
+ `PgProductRepository.update` was binding the JS array directly via
+ `pg-node`. `pg-node` serialises JS arrays as PG array literals
+ (`{bio,keto}`); the JSONB parser rejects that with
+ `invalid input syntax for type json`. The fix `JSON.stringify`s the
+ array and casts the parameter as `::jsonb`.
+
+3. **`serializeProduct` omitted `channels`, `featured`, and
+ `attributes`.** The admin editor saved these values successfully but
+ the next GET round-tripped an object without them, so the UI never
+ reflected the change. Added the three fields to `serializeProduct`.
+
+## Changes
+
+`project/frontend/src/app/products/[slug]/page.tsx`
+
+```diff
+-
+```
+
+`project/src/modules/catalog/infrastructure/pg-product-repository.ts`
+
+```diff
+ for (const [key, column] of UPDATABLE) {
+ if (key in patch) {
++ if (key === 'attributes' && Array.isArray(patch[key])) {
++ values.push(JSON.stringify(patch[key]));
++ setClauses.push(`${column} = $${values.length}::jsonb`);
++ } else {
+ values.push(patch[key]);
++ setClauses.push(`${column} = $${values.length}`);
++ }
+ }
+ }
+```
+
+`project/src/modules/catalog/api/catalog.routes.ts`
+
+```diff
+ function serializeProduct(product, images = []) {
+ return {
+ …,
+ state: product.state,
++ channels: product.channels,
++ featured: product.featured,
++ attributes: product.attributes,
+ seoTitle: product.seoTitle,
+ …
+ };
+ }
+```
+
+## Acceptance traceability
+
+| Acceptance criterion | How it is met |
+| -------------------- | ------------- |
+| `GET /products/[slug]` shows the product image (no zero-height container) | `aspect-[5/7] max-h-[500px]` gives the wrapper a real height; curl on the rendered page shows `
`. |
+| `PATCH /api/products/:id` with `attributes` succeeds and round-trips | `JSON.stringify` + `::jsonb` cast; verified with `PATCH {"attributes":["bio","keto"],"featured":true,"channels":"online"}` → 200 + the same shape echoed back. |
+| `GET /api/products/:id` returns `channels`, `featured`, `attributes` | `serializeProduct` now includes them; verified the field is present in the JSON. |
+| `verify.sh` is green | Exit 0. |
+
+## Manual verification
+
+```
+$ curl -X PATCH http://192.168.18.93:3004/api/products/13a65dc0-… \
+ -H 'Content-Type: application/json' -b /tmp/admin_cookies.txt \
+ -d '{"attributes":["bio","keto"],"featured":true,"channels":"online"}'
+{ … "channels":"online", "featured":true, "attributes":["bio","keto"], … }
+HTTP 200
+
+$ curl http://192.168.18.93:3004/api/products/13a65dc0-… -b /tmp/admin_cookies.txt
+{ … "channels":"online", "featured":true, "attributes":["bio","keto"], … }
+
+$ curl http://192.168.18.93:3003/products/proteina-guisante-ecologica | grep -oE 'aspect-\[5/7\][^"]*'
+aspect-[5/7] max-h-[500px] …
+```
+
+The 500 line is gone from `project/.runtime/prod/backend.log`; the
+next PATCH with `attributes` is 200.
+
+## Build verification
+
+- `npm run typecheck` (backend) — exit 0
+- `npm test` (backend) — 124 passed, 56 skipped
+- `npm run build` (backend) — exit 0
+- `npx tsc --noEmit` (frontend) — exit 0
+- `monolith.sh prod restart backend frontend` → backend / frontend 200
+- `./scripts/verify.sh` — exit 0
+
+## Files touched
+
+```
+project/frontend/src/app/products/[slug]/page.tsx (image container height)
+project/src/modules/catalog/infrastructure/pg-product-repository.ts (JSONB cast for attributes)
+project/src/modules/catalog/api/catalog.routes.ts (serializeProduct fields)
+```
\ No newline at end of file
diff --git a/work/artifacts/F-066/leader-close.json b/work/artifacts/F-066/leader-close.json
new file mode 100644
index 0000000..ae969e0
--- /dev/null
+++ b/work/artifacts/F-066/leader-close.json
@@ -0,0 +1,13 @@
+{
+ "feature_id": "F-066",
+ "agent": "leader",
+ "verdict": "APPROVED",
+ "summary": "All gates approved. Closing F-066.",
+ "evidence": [
+ "work/artifacts/F-066/reviewer.json verdict=APPROVED",
+ "work/artifacts/F-066/security.json verdict=APPROVED",
+ "work/artifacts/F-066/qa.json verdict=APPROVED",
+ "./scripts/verify.sh exit 0"
+ ],
+ "timestamp": "2026-08-19T15:25:00Z"
+}
\ No newline at end of file
diff --git a/work/artifacts/F-066/qa.json b/work/artifacts/F-066/qa.json
new file mode 100644
index 0000000..1d5c7ed
--- /dev/null
+++ b/work/artifacts/F-066/qa.json
@@ -0,0 +1,15 @@
+{
+ "feature_id": "F-066",
+ "agent": "qa",
+ "verdict": "APPROVED",
+ "summary": "End-to-end trace. The image now renders on /products/[slug]. PATCH with attributes no longer 500s and round-trips. GET returns channels, featured, attributes. Backend tests and verify.sh pass.",
+ "evidence": [
+ "AC1 'GET /products/[slug] shows the product image (no zero-height container)' — rendered HTML has aspect-[5/7] max-h-[500px] container with the image inside; the next/image srcSet returns 200",
+ "AC2 'PATCH /api/products/:id with attributes succeeds and round-trips' — curl verified",
+ "AC3 'GET /api/products/:id returns channels featured and attributes' — curl verified; previously missing",
+ "AC4 'verify.sh is green' — exit 0",
+ "Regression: backend tests 124/180 green; storefront still uses plain
![]()
tag with object-contain and 200 response",
+ "Regression: typecheck + build green across backend, frontend, admin"
+ ],
+ "timestamp": "2026-08-19T15:25:00Z"
+}
\ No newline at end of file
diff --git a/work/artifacts/F-066/reviewer.json b/work/artifacts/F-066/reviewer.json
new file mode 100644
index 0000000..9c0c7ca
--- /dev/null
+++ b/work/artifacts/F-066/reviewer.json
@@ -0,0 +1,18 @@
+{
+ "feature_id": "F-066",
+ "agent": "reviewer",
+ "verdict": "APPROVED",
+ "summary": "Three surgical fixes. The product image container now has aspect-[5/7] inside a flex centering wrapper so it both has a real height and is centred horizontally. The PATCH 500 is gone (JSON.stringify + ::jsonb cast). serializeProduct includes channels, featured, attributes so admin edits round-trip through the API.",
+ "evidence": [
+ "git diff project/frontend/src/app/products/[slug]/page.tsx — restored aspect-[5/7] max-h-[500px] inside a flex justify-center wrapper",
+ "git diff project/src/modules/catalog/infrastructure/pg-product-repository.ts — JSON.stringify(patch[key]) + ::jsonb cast for attributes",
+ "git diff project/src/modules/catalog/api/catalog.routes.ts — channels, featured, attributes added to serializeProduct",
+ "curl PATCH /api/products/13a65dc0-… with attributes → 200 with attributes, featured, channels in the response body",
+ "curl GET /api/products/13a65dc0-… → same fields present (round-trip)",
+ "curl /products/proteina-guisante-ecologica → image element rendered inside aspect-[5/7] max-h-[500px] container",
+ "backend log no longer shows 'invalid input syntax for type json' for PATCH",
+ "npm test (backend) — 124 passed, 56 skipped",
+ "./scripts/verify.sh — exit 0"
+ ],
+ "timestamp": "2026-08-19T15:25:00Z"
+}
\ No newline at end of file
diff --git a/work/artifacts/F-066/security.json b/work/artifacts/F-066/security.json
new file mode 100644
index 0000000..7028d12
--- /dev/null
+++ b/work/artifacts/F-066/security.json
@@ -0,0 +1,13 @@
+{
+ "feature_id": "F-066",
+ "agent": "security",
+ "verdict": "APPROVED",
+ "summary": "No security boundary change. The PATCH endpoint was already gated by admin role; we just stopped it from 500-ing. The image container change is a CSS-only fix. Adding fields to serializeProduct expands the response shape but does not introduce a new write surface.",
+ "evidence": [
+ "PATCH /api/products/:id still requires admin role (unchanged)",
+ "JSON.stringify on attributes is the same encoding the JSONB parser expected; no new code path",
+ "serializeProduct expansion: read-only — the write path is unchanged",
+ "No new env vars, no new dependencies"
+ ],
+ "timestamp": "2026-08-19T15:25:00Z"
+}
\ No newline at end of file
diff --git a/work/runtime-status.json b/work/runtime-status.json
index e86927f..e5e06a2 100644
--- a/work/runtime-status.json
+++ b/work/runtime-status.json
@@ -1,20 +1,13 @@
{
- "feature_id": "F-065",
+ "feature_id": "F-066",
"stage": "build",
"agent": "implementer",
- "action": "fix a11y missing id/name on search inputs",
+ "action": "fix image height attributes jsonb and serializeProduct",
"state": "running",
"next_agent": "reviewer",
"waiting_for": null,
- "updated_at": "2026-08-19T15:10:40Z",
+ "updated_at": "2026-08-19T15:19:51Z",
"timeline": [
- {
- "ts": "2026-08-19T08:48:27Z",
- "agent": "documenter",
- "stage": "document",
- "state": "running",
- "message": "Inicio"
- },
{
"ts": "2026-08-19T08:52:45Z",
"agent": "leader",
@@ -147,6 +140,13 @@
"stage": "build",
"state": "running",
"message": "fix a11y missing id/name on search inputs"
+ },
+ {
+ "ts": "2026-08-19T15:19:51Z",
+ "agent": "implementer",
+ "stage": "build",
+ "state": "running",
+ "message": "fix image height attributes jsonb and serializeProduct"
}
],
"last_updated": "2026-08-19T09:10:00Z",