feat(F-066): completed feature
This commit is contained in:
@@ -3284,6 +3284,38 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-19T15:12:25Z"
|
"completed_at": "2026-08-19T15:12:25Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-066",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Product image invisible, PATCH 500 on attributes and missing fields in serializeProduct",
|
||||||
|
"problem": "Product detail image rendered empty even though the URL returns 200; PATCH /api/products/:id returns 500 when the payload includes attributes; and serializeProduct omits channels featured and attributes so admin edits never round-trip through the API",
|
||||||
|
"goal": "The product image is visible on the detail page; PATCH with attributes stores and returns the attributes; serializeProduct returns channels featured and attributes; admin and public clients see updates",
|
||||||
|
"scope_in": [
|
||||||
|
"Core flow"
|
||||||
|
],
|
||||||
|
"scope_out": [
|
||||||
|
"Fix the product detail image container so it has a real height (next/image fill requires a non-zero-height parent). Wrap the container in a flex centering div and bring back aspect-5/7 max-h-500 so it has height and stays centred. Fix pg-product-repository.update to JSON.stringify the attributes array before binding it to the JSONB column and cast as jsonb. Add channels featured attributes to serializeProduct."
|
||||||
|
],
|
||||||
|
"priority": "low",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Problem: Product detail image rendered empty even though the URL returns 200; PATCH /api/products/:id returns 500 when the payload includes attributes; and serializeProduct omits channels featured and attributes so admin edits never round-trip through the API. Goal: The product image is visible on the detail page; PATCH with attributes stores and returns the attributes; serializeProduct returns channels featured and attributes; admin and public clients see updates. Scope IN: Core flow. Scope OUT: Fix the product detail image container so it has a real height (next/image fill requires a non-zero-height parent). Wrap the container in a flex centering div and bring back aspect-5/7 max-h-500 so it has height and stays centred. Fix pg-product-repository.update to JSON.stringify the attributes array before binding it to the JSONB column and cast as jsonb. Add channels featured attributes to serializeProduct.. Type: fix. Priority: low. Risk: low.",
|
||||||
|
"acceptance": [
|
||||||
|
"high",
|
||||||
|
"GET /products/[slug] shows the product image (no zero-height container)",
|
||||||
|
"PATCH /api/products/:id with attributes succeeds and round-trips",
|
||||||
|
"GET /api/products/:id returns channels featured and attributes",
|
||||||
|
"verify.sh is green"
|
||||||
|
],
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-19",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-19T15:22:39Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,12 +100,13 @@ export default async function ProductPage({ params }: Props) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div>
|
<div>
|
||||||
{/* Detail image: the container fills its column so the image is
|
{/* Detail image: wrap in a flex container so the inner box is
|
||||||
always horizontally centered within the column (matches the
|
horizontally centred inside the grid cell. The inner box has
|
||||||
behaviour of the checkout layout). `max-h` bounds the height
|
`aspect-[5/7] max-h-[500px]` so it always has a real height
|
||||||
and `object-contain` on the inner image preserves the source
|
(next/image `fill` needs a non-zero parent) and the image is
|
||||||
aspect ratio without cropping. */}
|
letterboxed with `object-contain`. */}
|
||||||
<div className="relative w-full max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 overflow-hidden">
|
<div className="flex justify-center">
|
||||||
|
<div className="relative w-full max-w-md aspect-[5/7] max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 overflow-hidden">
|
||||||
{product.images?.[0] ? (
|
{product.images?.[0] ? (
|
||||||
<Image
|
<Image
|
||||||
src={product.images[0].url}
|
src={product.images[0].url}
|
||||||
@@ -120,6 +121,7 @@ export default async function ProductPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -655,6 +655,9 @@ function serializeProduct(product: Product, images: ProductImage[] = []) {
|
|||||||
images: images.map(serializeImage),
|
images: images.map(serializeImage),
|
||||||
description: product.description,
|
description: product.description,
|
||||||
state: product.state,
|
state: product.state,
|
||||||
|
channels: product.channels,
|
||||||
|
featured: product.featured,
|
||||||
|
attributes: product.attributes,
|
||||||
seoTitle: product.seoTitle,
|
seoTitle: product.seoTitle,
|
||||||
seoDescription: product.seoDescription,
|
seoDescription: product.seoDescription,
|
||||||
categoryIds: product.categoryIds,
|
categoryIds: product.categoryIds,
|
||||||
|
|||||||
@@ -121,10 +121,18 @@ export class PgProductRepository implements ProductRepository {
|
|||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
for (const [key, column] of UPDATABLE) {
|
for (const [key, column] of UPDATABLE) {
|
||||||
if (key in patch) {
|
if (key in patch) {
|
||||||
|
// 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]);
|
values.push(patch[key]);
|
||||||
setClauses.push(`${column} = $${values.length}`);
|
setClauses.push(`${column} = $${values.length}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let exists = true;
|
let exists = true;
|
||||||
if (setClauses.length > 0) {
|
if (setClauses.length > 0) {
|
||||||
|
|||||||
122
work/artifacts/F-066/implementer.md
Normal file
122
work/artifacts/F-066/implementer.md
Normal file
@@ -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
|
||||||
|
- <div className="relative w-full max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 overflow-hidden">
|
||||||
|
- <Image fill className="object-contain" ... />
|
||||||
|
- </div>
|
||||||
|
+ <div className="flex justify-center">
|
||||||
|
+ <div className="relative w-full max-w-md aspect-[5/7] max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 overflow-hidden">
|
||||||
|
+ <Image fill className="object-contain" ... />
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 `<img alt="Proteina Guisante Ecologica" … style="position:absolute;height:100%;width:100%;…">` inside `<div class="relative w-full max-w-md aspect-[5/7] max-h-[500px] …">`. |
|
||||||
|
| `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)
|
||||||
|
```
|
||||||
13
work/artifacts/F-066/leader-close.json
Normal file
13
work/artifacts/F-066/leader-close.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
15
work/artifacts/F-066/qa.json
Normal file
15
work/artifacts/F-066/qa.json
Normal file
@@ -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 <img> tag with object-contain and 200 response",
|
||||||
|
"Regression: typecheck + build green across backend, frontend, admin"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T15:25:00Z"
|
||||||
|
}
|
||||||
18
work/artifacts/F-066/reviewer.json
Normal file
18
work/artifacts/F-066/reviewer.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
13
work/artifacts/F-066/security.json
Normal file
13
work/artifacts/F-066/security.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-065",
|
"feature_id": "F-066",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "fix a11y missing id/name on search inputs",
|
"action": "fix image height attributes jsonb and serializeProduct",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "reviewer",
|
"next_agent": "reviewer",
|
||||||
"waiting_for": null,
|
"waiting_for": null,
|
||||||
"updated_at": "2026-08-19T15:10:40Z",
|
"updated_at": "2026-08-19T15:19:51Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-19T08:48:27Z",
|
|
||||||
"agent": "documenter",
|
|
||||||
"stage": "document",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Inicio"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-19T08:52:45Z",
|
"ts": "2026-08-19T08:52:45Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
@@ -147,6 +140,13 @@
|
|||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "fix a11y missing id/name on search inputs"
|
"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",
|
"last_updated": "2026-08-19T09:10:00Z",
|
||||||
|
|||||||
Reference in New Issue
Block a user