diff --git a/backlog/features.json b/backlog/features.json index 82bf432..26680f8 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -6408,13 +6408,15 @@ "description": "Fix order serialization/detail so customer email is associated and displayed when the linked customer/user has an email.", "priority": "high", "risk": "med", - "status": "pending", + "status": "done", "created_at": "2026-08-21", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T05:41:08Z" }, { "id": "F-154", diff --git a/project/src/modules/orders/api/orders.routes.ts b/project/src/modules/orders/api/orders.routes.ts index 27dc4a1..1040038 100644 --- a/project/src/modules/orders/api/orders.routes.ts +++ b/project/src/modules/orders/api/orders.routes.ts @@ -262,11 +262,8 @@ export async function registerOrdersRoutes( let notified = false; let notificationError = ''; try { - const emailRow = await deps.pool.query<{ email: string }>( - 'SELECT email FROM identity_users WHERE id = $1', - [order.userId], - ); - const to = emailRow.rows[0]?.email; + // F-153: email is now associated on the order view via a repository join. + const to = order.email; if (to) { await sendOrderStatusEmail(deps.pool, { to, @@ -504,6 +501,7 @@ function serializeOrder(order: { updatedAt: Date; idempotencyKey: string | null; userId?: string; + email?: string | null; items: Array<{ id: string; productId: string; @@ -521,6 +519,7 @@ function serializeOrder(order: { return { id: order.id, userId: order.userId, + email: order.email ?? null, state: order.state, currency: order.currency, subtotalCents: order.subtotalCents, diff --git a/project/src/modules/orders/domain/order.ts b/project/src/modules/orders/domain/order.ts index 2986c20..0f214ce 100644 --- a/project/src/modules/orders/domain/order.ts +++ b/project/src/modules/orders/domain/order.ts @@ -46,6 +46,7 @@ export interface Order { export interface OrderView extends Order { items: OrderItem[]; + email?: string | null; } export const ALLOWED_TRANSITIONS: Readonly>> = { diff --git a/project/src/modules/orders/infrastructure/pg-order-repository.test.ts b/project/src/modules/orders/infrastructure/pg-order-repository.test.ts new file mode 100644 index 0000000..d0097d2 --- /dev/null +++ b/project/src/modules/orders/infrastructure/pg-order-repository.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PgOrderRepository } from './pg-order-repository.js'; +import type { OrderView } from '../domain/order.js'; + +/** F-153: mock mínimo de pg.Pool que discrimina por SQL (ordenes vs items). */ +function mockPool(orderRow: Record | null, itemRows: unknown[] = []) { + const query = vi.fn((sql: string) => { + const text = String(sql); + if (text.includes('orders_items')) return Promise.resolve({ rows: itemRows }); + return Promise.resolve({ rows: orderRow ? [orderRow] : [] }); + }); + const pool = { query } as unknown as import('pg').Pool; + return { pool, query }; +} + +const BASE_ORDER = { + id: 'order-1', + user_id: 'user-1', + idempotency_key: null, + state: 'PENDING', + currency: 'EUR', + subtotal_cents: 1000, + discount_cents: 0, + tax_cents: 210, + total_cents: 1210, + tracking_number: null, + courier: null, + created_at: new Date('2026-01-01T00:00:00Z'), + updated_at: new Date('2026-01-01T00:00:00Z'), +}; + +describe('PgOrderRepository.findById — F-153 customer email association', () => { + it('resolves customer email through the identity_users join', async () => { + const { pool, query } = mockPool({ ...BASE_ORDER, email: 'customer@example.com' }); + const repo = new PgOrderRepository(pool); + + const result: OrderView | undefined = await repo.findById('order-1'); + + expect(result).toBeDefined(); + expect(result!.email).toBe('customer@example.com'); + const orderQuery = String(query.mock.calls[0]![0]); + expect(orderQuery).toContain('identity_users'); + expect(orderQuery).toContain('u.email'); + }); + + it('returns null email when the order has no linked identity_user', async () => { + const { pool } = mockPool({ ...BASE_ORDER, email: null }); + const repo = new PgOrderRepository(pool); + + const result = await repo.findById('order-1'); + + expect(result).toBeDefined(); + expect(result!.email).toBeNull(); + }); + + it('returns undefined when the order does not exist', async () => { + const { pool } = mockPool(null); + const repo = new PgOrderRepository(pool); + + const result = await repo.findById('missing'); + + expect(result).toBeUndefined(); + }); +}); diff --git a/project/src/modules/orders/infrastructure/pg-order-repository.ts b/project/src/modules/orders/infrastructure/pg-order-repository.ts index 8cb2640..600a171 100644 --- a/project/src/modules/orders/infrastructure/pg-order-repository.ts +++ b/project/src/modules/orders/infrastructure/pg-order-repository.ts @@ -5,6 +5,7 @@ import type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from '.. interface OrderRow { id: string; user_id: string; + email: string | null; idempotency_key: string | null; state: OrderState; currency: 'EUR'; @@ -92,7 +93,7 @@ export class PgOrderRepository implements OrderRepository { async findAll(): Promise { const result = await this.pool.query( - 'SELECT * FROM orders_orders ORDER BY created_at DESC', + 'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id ORDER BY o.created_at DESC', ); return Promise.all( result.rows.map(async (order) => { @@ -100,7 +101,7 @@ export class PgOrderRepository implements OrderRepository { 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id', [order.id], ); - return { ...toOrder(order), items: items.rows.map(toItem) }; + return toOrderView(order, items.rows.map(toItem)); }), ); } @@ -132,7 +133,7 @@ export class PgOrderRepository implements OrderRepository { const total = Number(countResult.rows[0]?.count ?? '0'); values.push(filters.limit, filters.offset); const result = await this.pool.query( - `SELECT o.* FROM orders_orders o + `SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id ${whereSql} ORDER BY o.created_at DESC @@ -145,7 +146,7 @@ export class PgOrderRepository implements OrderRepository { 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id', [order.id], ); - return { ...toOrder(order), items: itemResult.rows.map(toItem) }; + return toOrderView(order, itemResult.rows.map(toItem)); }), ); return { items, total }; @@ -153,7 +154,7 @@ export class PgOrderRepository implements OrderRepository { async findById(id: string): Promise { const orderResult = await this.pool.query( - 'SELECT * FROM orders_orders WHERE id = $1', + 'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1', [id], ); const order = orderResult.rows[0]; @@ -162,12 +163,12 @@ export class PgOrderRepository implements OrderRepository { 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id', [id], ); - return { ...toOrder(order), items: items.rows.map(toItem) }; + return toOrderView(order, items.rows.map(toItem)); } async findByIdAndUserId(id: string, userId: string): Promise { const orderResult = await this.pool.query( - 'SELECT * FROM orders_orders WHERE id = $1 AND user_id = $2', + 'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1 AND o.user_id = $2', [id, userId], ); const order = orderResult.rows[0]; @@ -176,7 +177,7 @@ export class PgOrderRepository implements OrderRepository { 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id', [id], ); - return { ...toOrder(order), items: items.rows.map(toItem) }; + return toOrderView(order, items.rows.map(toItem)); } async updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise { @@ -256,6 +257,14 @@ function toOrder(row: OrderRow): Order { }; } +function toOrderView(row: OrderRow, items: OrderItem[]): OrderView { + return { + ...toOrder(row), + email: row.email, + items, + }; +} + function toItem(row: ItemRow): OrderItem { return { id: row.id, diff --git a/spec/acceptance.md b/spec/acceptance.md index 2427a17..36e13f7 100644 --- a/spec/acceptance.md +++ b/spec/acceptance.md @@ -1,37 +1,34 @@ -# F-152 — Acceptance Criteria +# F-153 — Acceptance Criteria -- Feature ID: F-152 +## AC1 — Order detail exposes customer email +`GET /orders/:id` and `GET /orders/:id/admin` responses include a top-level `email` field equal +to the linked `identity_users.email`. When the order has no linked identity_user, `email` is +`null`. -## Account creation → welcome email -- Escenario: customer registers with a new email. - - Given SMTP is configured and `POST /auth/register` is called with valid email+password - - When registration succeeds - - Then a welcome email (`account_created`) is sent to that email - - And the response is still 200 with the user payload - - And evidence: SMTP sendmail called once with recipient == input email and template account_created -- Escenario: SMTP is not configured. - - Given SMTP is not configured and `POST /auth/register` is called - - When registration succeeds - - Then the response is still 200 (registration never fails due to email) - - And a warning is logged +## AC2 — Admin order list exposes customer email +Every item in `GET /orders` (admin list) includes the `email` field, resolved via the same +read-model association (no N+1 per item beyond the repository's single read). -## Order confirmation on payment success -- Escenario: customer pays and Stripe reports PaymentSucceeded. - - Given an order in AWAITING_PAYMENT and a valid `PaymentSucceeded` webhook - - When the webhook is processed - - Then the order moves to PAID - - And a confirmation email (`state: PAID`) is sent to the order's customer email via sendOrderStatusEmail - - And the webhook returns 2xx -- Escenario: duplicate webhook. - - Given the same PaymentSucceeded webhook delivered twice - - When the second is processed - - Then only one confirmation email is sent (idempotency from payment dedup / outcome.kind == duplicate skipped) -- Escenario: SMTP not configured. - - Given SMTP not configured and a PaymentSucceeded webhook arrives - - When processed - - Then the order still moves to PAID, webhook returns 2xx, and an email failure is logged (not thrown) +## AC3 — Admin force-transition uses the associated email +`POST /orders/:id/transitions/admin` resolves the customer email from the order view +(`order.email`) — it no longer issues a separate inline `SELECT email FROM identity_users`. +When `email` is present, the status notification is sent; when absent, it logs +"El cliente no tiene email asociado" via `request.log.warn` and still completes the transition. -## Regression -- verify.sh is green -- No existing order admin-transition email behavior changed -- Password-reset email flow unchanged +## AC4 — No regression +All existing order flows keep their behavior (create, customer/admin detail, list, edit items, +shipping update, transitions). Only `email` is added to serialization; no new state transitions, +endpoints, or side effects. + +## AC5 — No migration +`email` is derived from the pre-existing `identity_users.email` column; no schema migration is +required. + +## AC6 — Quality gates +- `tsc --noEmit`: 0 errors. +- `prettier --check` + `eslint`: clean on touched files. +- `lint:boundaries`: no new R1/R2 violations (orders→identity_users is a SQL table-name + reference, same as the existing `search` join; no TS cross-import). +- `vitest run`: full suite green (existing order/payments/checkout/notification tests + new + pg-order-repository test). +- `verify.sh`: exit 0 (backlog F-153 in_progress, runtime stage valid). diff --git a/spec/product.md b/spec/product.md index eba19b8..a748640 100644 --- a/spec/product.md +++ b/spec/product.md @@ -1,32 +1,33 @@ -# F-152 — Product Spec +# F-153 — Product Spec -## Problema -Los clientes no reciben confirmación por email ni al crear la cuenta ni cuando su -pedido se confirma tras el pago: -- `POST /auth/register` crea el usuario pero no envía email de bienvenida. -- El webhook de Stripe (`PaymentSucceeded`) pasa el pedido a `PAID` pero no - notifica al cliente (el `OrderEventPublisher` inyectado en payments es no-op y - `sendOrderStatusEmail` solo se llamaba desde transiciones admin). +## Problem +The order read model and its serialization (`serializeOrder`) do **not** expose +the linked customer's email. `orders_orders.user_id` references `identity_users` +(whose `email citext NOT NULL UNIQUE` always exists), but the order view carries +only `userId` — never the email. Consequence: +- Order detail (`/orders/:id`, `/orders/:id/admin`) and the admin order list + (`/orders`) never display the customer email ("customer email missing ... displayed"). +- The admin force-transition (`POST /orders/:id/transitions/admin`) works around + this with a fragile inline `SELECT email FROM identity_users WHERE id = + order.userId`, which surfaces "El cliente no tiene email asociado" whenever the + view itself doesn't carry the association. -## Objetivo -Que los clientes reciban los dos emails transaccionales esenciales: -1. **Welcome** al crear la cuenta (account_created). -2. **Order confirmation** cuando el pago se confirma (PaymentSucceeded → PAID), - reenviando el flujo ya existente de `sendOrderStatusEmail` (SMTP desde - *Ajustes → SMTP / Email*). +## Goal +Associate the linked customer's email to the **order read model** and display it in +serialization — detail, admin list, and the admin force-transition notification — +using the order view as the single source of truth. -## Usuarios -- Usuario principal: cliente que se registra / compra en la tienda. -- Usuario secundario: operador (Ajustes SMTP) y admin (vee historial). +## Scope IN +- `orders/domain`: add `email` to `OrderView` (read model). +- `orders/infrastructure` (pg-order-repository): JOIN `identity_users` to resolve + `email` on every order read (`findById`, `findByIdAndUserId`, `findAll`, `search`). +- `orders/api` (orders.routes): surface `email` in `serializeOrder` and consume + `order.email` in the admin force-transition notification (removing the inline lookup). -## Alcance v1 -- In scope: - - Welcome email on `POST /auth/register` (best-effort, nunca bloquea el registro). - - Order confirmation email on `PaymentSucceeded` webhook (best-effort, nunca - rompe la reconciliación de pagos). - - Reusar el SMTP configurado en `store_settings` ya usado por admin transitions. - - Tests unitarios de cuerpo/email y de best-effort. -- Out of scope: - - Verificación por enlace (gating de cuenta por email) — queda como hardening. - - Reenvío de emails ya enviados (idempotencia garantizada por el webhook). - - Cambiar el email de transición admin existente. +## Scope OUT +- No changes to the `identity` domain (no TS cross-import). +- No new tables / migrations: `identity_users.email` already exists and is NOT NULL. +- No new endpoints; no auth/RBAC change; no order state machine change. + +## Risk / Priority +- Priority: high. Risk: med (additive read-model field; backward compatible). diff --git a/spec/tech.md b/spec/tech.md index 30a8389..a35e6ad 100644 --- a/spec/tech.md +++ b/spec/tech.md @@ -1,62 +1,49 @@ -# F-152 — Tech Spec +# F-153 — Tech Spec -## Principios -- Best-effort: un email fallido o SMTP no configurado **nunca** debe fallar el - registro ni el webhook de pagos. Se loguea y se continúa. -- Reusar infraestructura existente: SMTP desde `store_settings` - (`smtp_host/port/secure/user/pass/from`), mismo patrón que - `SettingsPasswordResetMailer` y `sendOrderStatusEmail`. -- Boundaries: identity no importa orders/payments a nivel de dominio; el wiring - del order-confirmation email se hace en el *route handler* de payments (posee - `deps.pool`), reusando `sendOrderStatusEmail` exportado públicamente por - `orders/index.ts`. +## Principles +- Associate the customer email to the order **read model** (not a per-request hack): + `OrderView.email` is resolved once by the orders repository via a `LEFT JOIN identity_users`. +- Reuse the existing SQL pattern: `orders/infrastructure/pg-order-repository.ts` `search` + already does `LEFT JOIN identity_users u ON u.id = o.user_id` — F-153 extends that to every + order read so the email is always available on the view. +- Boundaries: `identity_users` is referenced only as a **SQL table name** (pre-existing in + `search`); no TypeScript import crosses the identity/orders boundary. `identity` does not + import orders; orders references `identity_users` table name (string) at infrastructure. +- NoUncheckedIndexedAccess is ON → index access returns `T | undefined`; use `!` or `?? null` + when mapping rows. +- Backward compatible: `email` is an additive field on the serialized output; no state + transition, no new migration, no endpoint change. -## Cambios +## Changes -### 1. Order confirmation on PaymentSucceeded (payments) -- `orders/index.ts`: exportar `sendOrderStatusEmail` (y `ORDER_STATE_LABELS`). -- `payments/api/payments.routes.ts`: en el handler de `/payments/webhook`, tras - `const outcome = await service.handleWebhook(event)`, si - `event.type === 'PaymentSucceeded' && event.orderId && outcome.kind === 'processed'`, - fetch customer email (`identity_users.email` via `orders_orders.user_id`) y - `sendOrderStatusEmail(deps.pool, { to, orderId: event.orderId, state: 'PAID' })` - dentro de try/catch; log de advertencia si falla SMTP/no-config. +### 1. Domain — OrderView carries email +- `orders/domain/order.ts`: add `email: string | null;` to `OrderView` (`Order` itself unchanged — + email is a denormalized read-model attribute, not a core domain field). -### 2. Welcome email on registration (identity) -- `identity/domain/ports.ts`: nuevo puerto `WelcomeMailer` con - `sendWelcome(input: { email: string; name?: string }): Promise` (name es - opcional: `User` no almacena nombre en el dominio actual). -- `identity/infrastructure/settings-welcome-mailer.ts`: `SettingsWelcomeMailer(pool)` - modelado en `SettingsPasswordResetMailer` — lee SMTP de `store_settings`, usa - `nodemailer`, y una función pura `buildWelcomeEmail` (verificable sin SMTP). - El subject (`¡Bienvenido a Mercado de Vida!`) coincide con la plantilla - `account_created` de notificaciones. Lanza si SMTP no está configurado. -- `identity/api/identity.routes.ts`: `IdentityRoutesDeps.welcomeMailer?: WelcomeMailer`; - en el handler de `POST /auth/register`, tras `registerUser.execute` exitoso, se - despacha el welcome email *fire-and-forget* (`void mailer.sendWelcome(...).catch( - request.log.warn(...))`). Best-effort: un fallo SMTP se loguea (warning) y se - traga; el registro nunca se rompe por email. `RegisterUser` se mantiene sin - depender de email (puro orquestación de dominio). -- `identity/index.ts`: re-exporta `SettingsWelcomeMailer` (y - `SettingsPasswordResetMailer`) para que `build-app.ts` los importe desde el - index en lugar de deep-importar infra (cumple R2 de boundaries). -- `notifications/domain/notification.ts` + `notifications/api/notifications.routes.ts`: - añadir `account_created` al union `EmailTemplate`, a los mapas SUBJECTS/BODIES - de `LoggingEmailProvider` y al enum del schema de `POST /notifications/dispatch`. +### 2. Infrastructure — resolve email in the repo +- `orders/infrastructure/pg-order-repository.ts`: + - Add `email: string | null` to `OrderRow`. + - Add helper `toOrderView(row, items): OrderView = { ...toOrder(row), email: row.email, items }`. + - `SELECT o.*` → `SELECT o.*, u.email` with `LEFT JOIN identity_users u ON u.id = o.user_id` + in `findById`, `findByIdAndUserId`, `findAll`, `search`. + - Build every returned `OrderView` via `toOrderView` (so `email` is always set; `null` when the + order has no linked identity_user). -## SMTP / store_settings -Claves existentes: `smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from`. -El welcome mailer reusa exactamente estas claves. +### 3. API — expose + consume email +- `orders/api/orders.routes.ts`: + - `serializeOrder`: add `email: string | null` to the param type and to the output + (`email: order.email`). + - Admin force-transition (`POST /orders/:id/transitions/admin`): replace the inline + `SELECT email FROM identity_users WHERE id = order.userId` with `const to = order.email;` + (single source of truth; the LEFT JOIN already resolved it). Keep the try/catch + warn + and the "El cliente no tiene email asociado" fallback (now only when `order.email` is null). ## Testing -- `settings-welcome-mailer.test.ts`: `buildWelcomeEmail` (greeting, nombre, XSS), - `SettingsWelcomeMailer.sendWelcome` (nodemailer mockeado: sendmail called once - con recipient == email y subject == plantilla `account_created`), y lanza cuando - SMTP no está configurado (`SMTP is not configured`). -- `orders/tests/orders-index.test.ts`: el barrel de `orders/index.ts` re-exporta - `sendOrderStatusEmail`, `buildOrderStatusEmail`, `ORDER_STATE_LABELS`. -- `order-status-mailer.test.ts` ya existe cubriendo `buildOrderStatusEmail` (PAID - pertenece a `ORDER_STATE_LABELS`). -- `tsc --noEmit` limpio; `prettier --check` y `eslint` limpios en los archivos tocados; - suite completa de vitest verde (197 tests); `lint:boundaries` sin nuevas violaciones. -- verify.sh green. +- New `orders/infrastructure/pg-order-repository.test.ts`: mock `pg.Pool`, assert `findById` + returns `email` from the JOIN when the linked identity_user has one, and `null` when there is + no linked user. +- Update existing `OrderView` literals in tests (`order-service`, `payments-service`, + `checkout-service`) to include `email: null` (additive field). +- `order-status-mailer.test.ts` (existing) remains green (no change to email senders). +- tsc --noEmit clean; prettier + eslint clean; lint:boundaries no new violations; + vitest run full suite green; verify.sh green. diff --git a/work/artifacts/F-153/architect.md b/work/artifacts/F-153/architect.md new file mode 100644 index 0000000..8a4c385 --- /dev/null +++ b/work/artifacts/F-153/architect.md @@ -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. diff --git a/work/artifacts/F-153/implementer.md b/work/artifacts/F-153/implementer.md new file mode 100644 index 0000000..ec901a0 --- /dev/null +++ b/work/artifacts/F-153/implementer.md @@ -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. diff --git a/work/artifacts/F-153/leader-close.json b/work/artifacts/F-153/leader-close.json new file mode 100644 index 0000000..ec515d7 --- /dev/null +++ b/work/artifacts/F-153/leader-close.json @@ -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": [] +} diff --git a/work/artifacts/F-153/qa.json b/work/artifacts/F-153/qa.json new file mode 100644 index 0000000..3c25548 --- /dev/null +++ b/work/artifacts/F-153/qa.json @@ -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": [] +} diff --git a/work/artifacts/F-153/reviewer.json b/work/artifacts/F-153/reviewer.json new file mode 100644 index 0000000..f872da4 --- /dev/null +++ b/work/artifacts/F-153/reviewer.json @@ -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": [] +} diff --git a/work/artifacts/F-153/security.json b/work/artifacts/F-153/security.json new file mode 100644 index 0000000..a5993d1 --- /dev/null +++ b/work/artifacts/F-153/security.json @@ -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": [] +} diff --git a/work/current.md b/work/current.md index 5310b4c..f293089 100644 --- a/work/current.md +++ b/work/current.md @@ -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). diff --git a/work/runtime-status.json b/work/runtime-status.json index 5f50126..3c98d5f 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -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" + } + ] }