feat(F-052): completed feature

This commit is contained in:
chattie
2026-08-19 10:47:17 +02:00
parent 30c131a809
commit adf1d20c4c
19 changed files with 424 additions and 135 deletions

View File

@@ -0,0 +1,32 @@
# Architect — F-052
## Diagnóstico
- Las URLs de imagen de producto se almacenan como `/uploads/<uuid>.<ext>` en `catalog_product_images.url`.
- Solo el admin (`apps/admin/public/uploads/`) tiene esos archivos.
- El frontend (3003) y el storefront (3005) sirven estáticos desde sus propios `public/`, que no contienen `uploads/`.
- El backend (3000) no tiene una ruta `/uploads/` y por tanto tampoco puede servirlos.
- Resultado: `<img src="/uploads/...">` desde frontend/storefront → 404.
## Solución elegida
**Sincronizar `public/uploads/` del admin al frontend y al storefront en cada upload, y al arranque.**
Pasos:
1. Crear un helper que, ante una subida nueva en el admin (`/api/upload`), copie el archivo a `frontend/public/uploads/` y `storefront/public/uploads/` después de escribirlo.
2. Al arranque de `monolith.sh`, sincronizar los archivos existentes con `rsync` para cubrir el caso "arranque en frío sin uploads aún".
3. El backend sigue siendo solo API; no se añade ruta `/uploads/`.
Razón de no añadir ruta backend:
- Mantener el backend libre de filesystem compartido.
- Evita acoplarse a `process.cwd()` del backend, que es distinto al del admin.
- El admin es el único origen de uploads.
## Alternativas descartadas
- **Ruta `/uploads/` en el backend**: requiere cambiar `cwd` o aceptar una ruta absoluta; complica el deployment.
- **Proxy desde frontend a admin**: añade un round-trip y un puerto extra en la URL.
## Acceptance
- Tras subir una imagen desde el admin, esa imagen aparece como 200 en:
- `GET http://192.168.18.93:3003/uploads/<file>`
- `GET http://192.168.18.93:3005/uploads/<file>`
- Tras un restart del monolito, los uploads ya existentes se sincronizan.
- `next build` no falla por assets faltantes.

View File

@@ -0,0 +1,17 @@
# Documenter — F-052
## Cambio visible
Las imágenes de producto ahora cargan correctamente en:
- Frontend: `http://192.168.18.93:3003/products/<slug>`
- Storefront: `http://192.168.18.93:3005/products/<slug>`
Las nuevas subidas desde el backoffice aparecen **sin reiniciar** ningún servicio.
## Cómo funciona
1. El admin sube una imagen vía `/api/upload` → escribe en `apps/admin/public/uploads/`.
2. Inmediatamente, el mismo handler copia el archivo a `frontend/public/uploads/` y `storefront/public/uploads/`.
3. Los tres apps sirven la imagen con un handler dinámico `/uploads/[filename]/route.ts` que lee del disco en cada request.
4. Al hacer `monolith.sh start|restart`, `sync_uploads()` reconcilia los archivos que estuvieran desincronizados.
## Detalle técnico
Next.js en producción cachea el listado de `public/` al hacer `next build`. Sin el handler dinámico, los archivos subidos después devolverían 404 hasta el próximo rebuild. El handler dinámico (`readFile` en cada request) evita ese problema y replica el patrón del admin.

View File

@@ -0,0 +1,42 @@
# Implementer — F-052 (iteración 2)
## Cambios (completos)
### Código
- `apps/admin/src/app/api/upload/route.ts`: tras `writeFile`, copia a `frontend/public/uploads/` y `storefront/public/uploads/` con `Promise.allSettled`. La copia no bloquea el upload principal.
- `project/scripts/monolith.sh`: nueva función `sync_uploads()` que se invoca al final de `build_prod()` y antes de `spawn_service` en `start_all()` para cubrir el caso "arranque en frío sin uploads aún".
- **`frontend/src/app/uploads/[filename]/route.ts`** y **`storefront/src/app/uploads/[filename]/route.ts`**: handlers dinámicos (idénticos al del admin) que leen cada petición de `public/uploads/<filename>` directamente del disco. Imprescindible: Next.js sirve los estáticos de `public/` cacheados al build time, por lo que cualquier archivo subido después devolvería 404 sin esta ruta.
### Assets
- `frontend/public/uploads/` y `storefront/public/uploads/` creados.
- Archivos sincronizados manualmente para evitar esperar al próximo restart.
### Servicios
- `next build` re-ejecutado para admin, frontend y storefront.
- Servicios reiniciados.
## Verificación end-to-end
### Imágenes existentes (en build)
```
GET 3004 /uploads/fa7a31db-...jpg → 200 image/jpeg 129711
GET 3003 /uploads/fa7a31db-...jpg → 200 image/jpeg 129711
GET 3005 /uploads/fa7a31db-...jpg → 200 image/jpeg 129711
```
### Imagen subida en caliente (sin rebuild)
```
POST 3004 /api/upload (admin) → 201 {url: /uploads/a7e3aa30-...jpg}
GET 3004 /uploads/a7e3aa30-...jpg → 200 image/jpeg
GET 3003 /uploads/a7e3aa30-...jpg → 200 image/jpeg
GET 3005 /uploads/a7e3aa30-...jpg → 200 image/jpeg
```
### Favicons siguen 200
```
GET 3004 /favicon.ico → 200
GET 3005 /favicon.ico → 200
```
## Por qué fue necesaria la 2ª iteración
El `mirrorToPeers` del admin copia el archivo al disco de frontend/storefront, pero los servicios en producción cachean el listado de `public/` al build. Sin una ruta dinámica `/uploads/[filename]/`, los archivos nuevos solo se sirven si existían al hacer `next build`. La ruta dinámica los lee frescos del disco en cada request, igual que hace el admin desde F-050.

View File

@@ -0,0 +1,10 @@
{
"feature_id": "F-052",
"verdict": "APPROVED",
"agent": "leader",
"timestamp": "2026-08-19T08:47:30Z",
"gates_approved": {"reviewer": true, "security": true, "qa": true},
"verify_sh": "green",
"summary": "Imágenes de producto servidas en los 3 apps; nuevos uploads aparecen sin rebuild vía mirrorToPeers + rutas dinámicas /uploads/[filename]/.",
"push": "No origin remote"
}

View File

@@ -0,0 +1,17 @@
{
"feature_id": "F-052",
"verdict": "APPROVED",
"agent": "qa",
"timestamp": "2026-08-19T08:47:30Z",
"checks": {
"acceptance_existing_frontend": {"pass": true, "evidence": "GET /uploads/fa7a31db-...jpg en 3003 → 200 image/jpeg 129711"},
"acceptance_existing_storefront": {"pass": true, "evidence": "GET /uploads/fa7a31db-...jpg en 3005 → 200 image/jpeg 129711"},
"acceptance_hot_upload_admin": {"pass": true, "evidence": "Subida nueva a7e3aa30-...jpg sin rebuild → 200 en 3004"},
"acceptance_hot_upload_frontend": {"pass": true, "evidence": "GET /uploads/a7e3aa30-...jpg en 3003 → 200 image/jpeg"},
"acceptance_hot_upload_storefront": {"pass": true, "evidence": "GET /uploads/a7e3aa30-...jpg en 3005 → 200 image/jpeg"},
"regression_favicon": {"pass": true, "evidence": "Favicons siguen 200 (F-051 OK)"},
"regression_lan_smoke": {"pass": true, "evidence": "5/5 servicios en LAN 200"},
"hygiene": {"pass": true, "evidence": "git diff --check verde"}
},
"notes": "QA aprobado. Las imágenes se sirven correctamente desde cualquier app y los nuevos uploads aparecen sin rebuild gracias a la ruta dinámica."
}

View File

@@ -0,0 +1,16 @@
{
"feature_id": "F-052",
"verdict": "APPROVED",
"agent": "reviewer",
"timestamp": "2026-08-19T08:47:00Z",
"checks": {
"acceptance_frontend": {"pass": true, "notes": "GET /uploads/fa7a31db-...jpg en 3003 → 200 image/jpeg 129711"},
"acceptance_storefront": {"pass": true, "notes": "GET /uploads/fa7a31db-...jpg en 3005 → 200 image/jpeg 129711"},
"acceptance_hot_upload": {"pass": true, "notes": "Subida nueva en admin sin restart → 200 image/jpeg en los 3 puertos"},
"code_review_route": {"pass": true, "notes": "frontend y storefront tienen /uploads/[filename]/route.ts idénticos al admin (path.basename + path traversal guard + mime map + Cache-Control immutable)"},
"mirror_review": {"pass": true, "notes": "admin upload route.ts mirrorToPeers con Promise.allSettled"},
"monolith_review": {"pass": true, "notes": "sync_uploads() idempotente con find -maxdepth 1"},
"build_ok": {"pass": true, "notes": "admin, frontend, storefront builds sin errores"}
},
"notes": "Aprobado. La segunda iteración añadió la ruta dinámica que faltaba para que Next.js sirva archivos subidos tras el build."
}

View File

@@ -0,0 +1,15 @@
{
"feature_id": "F-052",
"verdict": "APPROVED",
"agent": "security",
"timestamp": "2026-08-19T08:47:30Z",
"checks": {
"audit": {"pass": true, "notes": "npm audit --omit=dev --audit-level=high: 0 vulnerabilidades"},
"upload_route_security": {"pass": true, "notes": "El handler sigue exigiendo isAuthenticatedBackofficeRequest y validación magic-bytes + tamaño; la copia a peers se hace después del writeFile atómico"},
"new_routes_security": {"pass": true, "notes": "Los handlers dinámicos /uploads/[filename]/route.ts de frontend y storefront validan filename con path.basename y rechazan '..' antes de leer; mime map fijo; X-Content-Type-Options: nosniff"},
"path_safety": {"pass": true, "notes": "Sin path traversal. Los peer dirs se construyen con path.join relativo a process.cwd() y filename siempre es UUID generado"},
"secret_scan": {"pass": true, "notes": "Sin credenciales ni cambios en auth"},
"hygiene": {"pass": true, "notes": "git diff --check verde"}
},
"notes": "Security aprobado tras segunda iteración."
}