feat(F-153): completed feature

This commit is contained in:
chattie
2026-08-22 07:41:08 +02:00
parent 569815fd87
commit 4b799f5c1b
16 changed files with 418 additions and 141 deletions

View File

@@ -0,0 +1,51 @@
# F-153 — Architectural Decision Record
## Status: accepted (architect)
## Context
The order read model (`OrderView`) carries the customer's `userId` but never the `email`, even
though `orders_orders.user_id` references `identity_users` whose `email citext NOT NULL UNIQUE`
always exists. As a result:
- Order serialization (`serializeOrder`) never displays the customer email — the reported
"customer email missing" symptom.
- The admin force-transition (`POST /orders/:id/transitions/admin`) worked around the gap with
an inline `SELECT email FROM identity_users WHERE id = order.userId`, a fragile per-request
lookup that produced "El cliente no tiene email asociado" whenever the view itself did not
carry the association (and would N+1 if reused broadly).
## Decision
Associate the customer's email to the order **read model** and resolve it once, in the orders
repository, by joining `identity_users` (the same `LEFT JOIN` already used by the `search`
method):
1. Add `email: string | null` to `OrderView` (`identity/domain` vs `orders` boundary untouched;
`Order` core aggregate stays without email — email is a denormalized read attribute).
2. Add `email` to `OrderRow`; extend `findById`, `findByIdAndUserId`, `findAll`, and `search` to
`SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id`,
and build the view through a single `toOrderView(row, items)` helper.
3. Surface `email` in `serializeOrder` (detail + list).
4. In the admin force-transition, consume `order.email` as the single source of truth and remove
the inline lookup.
## Rationale
- **Single source of truth**: once the email is on the view, both the API serialization and the
admin notification read it from one place — no divergent lookups.
- **Reuses an established pattern**: `orders/infrastructure/pg-order-repository.ts` `search`
already joins `identity_users`; F-153 generalizes that to all order reads. No new pattern.
- **Boundary-safe**: `identity_users` is referenced only as a SQL table-name string at the orders
infrastructure layer (no TypeScript import of `identity` from `orders`). `identity` does not
import `orders`/`payments`. `lint:boundaries` R1/R2 unaffected.
- **No migration / backward compatible**: `identity_users.email` already exists and is `NOT
NULL`; `email: string | null` on the view is null only when an order has no linked user.
Serialization only adds a field.
- **No N+1 on lists**: `findAll`/`search` resolve email in the same SELECT, so the admin list
returns emails without per-item lookups.
## Consequences
- Additive API field `email` on order DTOs; frontend/admin consumers gain the value for free.
- `order-service.test.ts`, `payments-service.test.ts`, `checkout-service.test.ts`
`OrderView` literals gain `email: null`.
- New `orders/infrastructure/pg-order-repository.test.ts` covers email resolution (happy path
and no-linked-user).
- Admin transition no longer depends on a raw `identity_users` query in the route layer.

View File

@@ -0,0 +1,56 @@
# F-153 — Implementer Evidence
## Build
Feature: F-153 — Orders: associate the linked customer's email to the order read
model and display it (customer email was missing despite the user having an email).
### Changes (production)
- `src/modules/orders/domain/order.ts` — added `email?: string | null` to `OrderView`
(read model). `Order` core aggregate is untouched (email is a denormalized read attr).
- `src/modules/orders/infrastructure/pg-order-repository.ts`:
- added `email: string | null` to `OrderRow`;
- added `toOrderView(row, items)` helper;
- `findById`, `findByIdAndUserId`, `findAll`, `search` now `SELECT o.*, u.email` with
`LEFT JOIN identity_users u ON u.id = o.user_id` (reuses the existing `search` join pattern);
- every `OrderView` is built via `toOrderView`, so `email` is always populated
(`string | null`; `null` when the order has no linked identity_user).
- `src/modules/orders/api/orders.routes.ts`:
- `serializeOrder` now emits `email: order.email ?? null` (detail + list);
- admin force-transition (`POST /orders/:id/transitions/admin`) consumes
`order.email` (the associated email) and no longer issues a separate inline
`SELECT email FROM identity_users` lookup → no N+1, single source of truth.
### Changes (tests)
- new `src/modules/orders/infrastructure/pg-order-repository.test.ts`:
- `findById` resolves `email` via the `identity_users` join (asserts the query contains
`identity_users` + `u.email` and returns the row's email);
- returns `null` when the order has no linked identity_user;
- returns `undefined` when the order does not exist.
- existing `OrderView` literals (`order-service`, `payments-service`, `checkout-service`)
needed no change (`email` is optional on the view; fixtures omit it → serialized as `null`).
### Boundary / compatibility notes
- `identity_users` is referenced only as a SQL table name (pre-existing pattern in `search`);
no TypeScript import crosses the identity↔orders boundary → `lint:boundaries` unchanged.
- No migration (`identity_users.email` already `citext NOT NULL UNIQUE`).
- Additive API field; no state-machine / endpoint / contract change.
## Evidence (commands + results)
- `npx tsc --noEmit`**0 errors** (`TSC_EXIT:0`).
- `npx vitest run`**200 passed | 56 skipped (50 files)**; new F-153 suite
`orders/infrastructure/pg-order-repository.test.ts`**3 passed**.
- `npx eslint` (touched files) → **0 errors / 0 warnings** (`ESLINT_EXIT:0`).
- `npx prettier --check` on touched files → only **pre-existing baseline warnings**
(identical to `HEAD` for both `pg-order-repository.ts` and `orders.routes.ts`; my added
lines are short / long string literals accepted by Prettier; no new reformatting).
- `npm run lint:boundaries`**1 violation, pre-existing** (`security/routes` R1);
**0 new violations** from F-153.
- `git diff --check`**clean** (no whitespace errors).
- `./scripts/verify.sh`**exit 0** (backlog 270 features valid; runtime-status advance to
build/running with F-153 in_progress — see below).
## Decisions
- Resolved via the read model (Option A in ADR) rather than route-layer lookups (Option B)
because `orders` already joins `identity_users` in SQL for `search`; generalizing that join
to all order reads gives the email as a first-class read attribute and removes the
per-request lookup from the admin transition.

View File

@@ -0,0 +1,13 @@
{
"feature_id": "F-153",
"agent": "leader",
"stage": "close",
"verdict": "APPROVED",
"summary": "F-153 completed: customer email associated to the order read model (LEFT JOIN identity_users) and exposed in serializeOrder (detail + list) and in the admin force-transition notification (order.email; inline lookup removed). Reviewer/Security/QA approved. verify.sh green; committed feat(F-153).",
"checks": [
{"item": "Gates approved", "ok": true, "evidence": "reviewer.json, security.json, qa.json -> APPROVED"},
{"item": "verify.sh", "ok": true, "evidence": "exit 0 (backlog 270 features valid, runtime-status valid)"},
{"item": "Artifacts present", "ok": true, "evidence": "architect.md, implementer.md, reviewer.json, security.json, qa.json, leader-close.json"}
],
"issues": []
}

View File

@@ -0,0 +1,15 @@
{
"feature_id": "F-153",
"agent": "qa",
"stage": "qa_gate",
"verdict": "APPROVED",
"summary": "Cobertura directa de F-153: 3 tests nuevos en pg-order-repository.test.ts. Suite completa 200 passed / 56 skipped sin regresiones.",
"checks": [
{"item": "Happy path: email resuelto vía JOIN", "ok": true, "evidence": "findById retorna email='customer@example.com' y la query contiene identity_users + u.email"},
{"item": "Edge: email null sin usuario vinculado", "ok": true, "evidence": "LEFT JOIN -> email null cuando orders_orders.user_id no referencia identity_user"},
{"item": "Edge: order inexistente", "ok": true, "evidence": "findById retorna undefined"},
{"item": "No regresión", "ok": true, "evidence": "order/payments/checkout/identity/notification suites pass; 200 passed total"},
{"item": "Tipos consistentes", "ok": true, "evidence": "tsc --noEmit 0 errors (OrderView literals en tests omiten email opcional -> null en salida)"}
],
"issues": []
}

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-153",
"agent": "reviewer",
"stage": "review_gate",
"verdict": "APPROVED",
"summary": "Asocia el email del cliente (identity_users.email) al order read model vía LEFT JOIN en el repo, y lo expone en serializeOrder (detail + lista) y en la notificación del force-transition. El lookup inline de la transición admin se elimina (usa order.email).",
"checks": [
{"item": "OrderView.email", "ok": true, "evidence": "orders/domain/order.ts: OrderView.email?: string | null"},
{"item": "Repo JOIN identity_users", "ok": true, "evidence": "pg-order-repository: findById/findByIdAndUserId/findAll/search ahora SELECT o.*, u.email LEFT JOIN identity_users; construcción centralizada en toOrderView"},
{"item": "serializeOrder expone email", "ok": true, "evidence": "orders.routes.ts: email: order.email ?? null"},
{"item": "Admin force-transition single source of truth", "ok": true, "evidence": "POST /orders/:id/transitions/admin usa order.email; inline SELECT email FROM identity_users eliminado"}
],
"issues": []
}

View File

@@ -0,0 +1,15 @@
{
"feature_id": "F-153",
"agent": "security",
"stage": "security_gate",
"verdict": "APPROVED",
"summary": "Cambio aditivo y sin auth. El email ya existía en identity_users (NOT NULL) y era accesible para admin vía lookup inline; ahora via OrderView. No hay exposición no autorizada ni nuevos vectores.",
"checks": [
{"item": "No nuevas rutas/auth", "ok": true, "evidence": "Campo aditivo sobre endpoints ya autenticados; sin cambio de RBAC ni nuevas rutas"},
{"item": "Exposición email", "ok": true, "evidence": "GET /orders/:id (cliente) devuelve su propio email; /orders y /orders/:id/admin son admin-autenticados"},
{"item": "Sin SQL injection", "ok": true, "evidence": "JOINs y lookups usan $1/$2 parametrizados; whereSql del search usa tokens fijos con valores parametrizados (patrón preexistente)"},
{"item": "IDOR", "ok": true, "evidence": "cliente usa findByIdAndUserId (scope user_id); admin usa findById en contexto admin"},
{"item": "Sin logs de datos sensibles", "ok": true, "evidence": "order.email solo aparece en la respuesta serializada, no se loguea"}
],
"issues": []
}

View File

@@ -1,5 +1,15 @@
# Feature actual
## F-153 cerrada (2026-08-22) — customer email on order view
- Asocia el email del cliente (`identity_users.email`, NOT NULL) al order read model vía `LEFT JOIN identity_users` en el repo, y lo expone en `serializeOrder` (detail + lista) y en la notificación del force-transition admin (usa `order.email`; se elimina el lookup inline).
- `OrderView.email?: string | null` (additivo, opcional en fixtures de test → serializado como `null`). Sin migración. Boundary intacta (orders→identity_users es referencia SQL, no import TS).
- Tests: 3 nuevos en `pg-order-repository.test.ts` (email resuelto vía JOIN, `null` sin usuario vinculado, `undefined` cuando no existe). Suite 200 passed / 56 skipped, sin regresiones.
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
- `tsc --noEmit` 0 errores; eslint 0; prettier baseline-only; `lint:boundaries` sin violaciones nuevas; `verify.sh` exit 0.
- Commit: `feat(F-153): completed feature` + `chore: reset runtime after F-153`.
- Pendiente siguiente por orden: **F-154** (separate customers from internal users).
## Sesión 2026-08-22 — F-152 cerrada (emails on account creation + order confirmation)
- `F-152` cerrada: welcome email (`account_created`) on `POST /auth/register` y order confirmation email on `POST /payments/webhook` (PaymentSucceeded).

View File

@@ -1,11 +1,54 @@
{
"feature_id": null,
"stage": "idle",
"feature_id": "F-153",
"stage": "close",
"agent": "leader",
"action": "Sin ejecución activa",
"state": "waiting",
"action": "Close F-153 — gates approved",
"state": "done",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T05:09:09Z",
"timeline": []
"waiting_for": "code + tests + implementer.md",
"updated_at": "2026-08-22T05:41:08Z",
"timeline": [
{
"ts": "2026-08-22T05:20:42Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Started F-153; advance to design"
},
{
"ts": "2026-08-22T05:27:00Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Design done; implement repo JOIN + serializeOrder + admin transition"
},
{
"ts": "2026-08-22T05:39:44Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "F-153 build verified"
},
{
"ts": "2026-08-22T05:39:44Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "additive, no auth change"
},
{
"ts": "2026-08-22T05:39:44Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "200 tests pass"
},
{
"ts": "2026-08-22T05:41:08Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "leader close"
}
]
}