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

@@ -6408,13 +6408,15 @@
"description": "Fix order serialization/detail so customer email is associated and displayed when the linked customer/user has an email.", "description": "Fix order serialization/detail so customer email is associated and displayed when the linked customer/user has an email.",
"priority": "high", "priority": "high",
"risk": "med", "risk": "med",
"status": "pending", "status": "done",
"created_at": "2026-08-21", "created_at": "2026-08-21",
"gates": { "gates": {
"reviewer": false, "reviewer": true,
"security": false, "security": true,
"qa": false "qa": true,
} "close": true
},
"completed_at": "2026-08-22T05:41:08Z"
}, },
{ {
"id": "F-154", "id": "F-154",

View File

@@ -262,11 +262,8 @@ export async function registerOrdersRoutes(
let notified = false; let notified = false;
let notificationError = ''; let notificationError = '';
try { try {
const emailRow = await deps.pool.query<{ email: string }>( // F-153: email is now associated on the order view via a repository join.
'SELECT email FROM identity_users WHERE id = $1', const to = order.email;
[order.userId],
);
const to = emailRow.rows[0]?.email;
if (to) { if (to) {
await sendOrderStatusEmail(deps.pool, { await sendOrderStatusEmail(deps.pool, {
to, to,
@@ -504,6 +501,7 @@ function serializeOrder(order: {
updatedAt: Date; updatedAt: Date;
idempotencyKey: string | null; idempotencyKey: string | null;
userId?: string; userId?: string;
email?: string | null;
items: Array<{ items: Array<{
id: string; id: string;
productId: string; productId: string;
@@ -521,6 +519,7 @@ function serializeOrder(order: {
return { return {
id: order.id, id: order.id,
userId: order.userId, userId: order.userId,
email: order.email ?? null,
state: order.state, state: order.state,
currency: order.currency, currency: order.currency,
subtotalCents: order.subtotalCents, subtotalCents: order.subtotalCents,

View File

@@ -46,6 +46,7 @@ export interface Order {
export interface OrderView extends Order { export interface OrderView extends Order {
items: OrderItem[]; items: OrderItem[];
email?: string | null;
} }
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = { export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {

View File

@@ -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<string, unknown> | 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();
});
});

View File

@@ -5,6 +5,7 @@ import type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from '..
interface OrderRow { interface OrderRow {
id: string; id: string;
user_id: string; user_id: string;
email: string | null;
idempotency_key: string | null; idempotency_key: string | null;
state: OrderState; state: OrderState;
currency: 'EUR'; currency: 'EUR';
@@ -92,7 +93,7 @@ export class PgOrderRepository implements OrderRepository {
async findAll(): Promise<OrderView[]> { async findAll(): Promise<OrderView[]> {
const result = await this.pool.query<OrderRow>( const result = await this.pool.query<OrderRow>(
'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( return Promise.all(
result.rows.map(async (order) => { 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', 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
[order.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'); const total = Number(countResult.rows[0]?.count ?? '0');
values.push(filters.limit, filters.offset); values.push(filters.limit, filters.offset);
const result = await this.pool.query<OrderRow>( const result = await this.pool.query<OrderRow>(
`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 LEFT JOIN identity_users u ON u.id = o.user_id
${whereSql} ${whereSql}
ORDER BY o.created_at DESC 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', 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
[order.id], [order.id],
); );
return { ...toOrder(order), items: itemResult.rows.map(toItem) }; return toOrderView(order, itemResult.rows.map(toItem));
}), }),
); );
return { items, total }; return { items, total };
@@ -153,7 +154,7 @@ export class PgOrderRepository implements OrderRepository {
async findById(id: string): Promise<OrderView | undefined> { async findById(id: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>( const orderResult = await this.pool.query<OrderRow>(
'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], [id],
); );
const order = orderResult.rows[0]; 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', 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[id], [id],
); );
return { ...toOrder(order), items: items.rows.map(toItem) }; return toOrderView(order, items.rows.map(toItem));
} }
async findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined> { async findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>( const orderResult = await this.pool.query<OrderRow>(
'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], [id, userId],
); );
const order = orderResult.rows[0]; 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', 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[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<OrderView | undefined> { async updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView | undefined> {
@@ -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 { function toItem(row: ItemRow): OrderItem {
return { return {
id: row.id, id: row.id,

View File

@@ -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 ## AC2 — Admin order list exposes customer email
- Escenario: customer registers with a new email. Every item in `GET /orders` (admin list) includes the `email` field, resolved via the same
- Given SMTP is configured and `POST /auth/register` is called with valid email+password read-model association (no N+1 per item beyond the repository's single read).
- 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
## Order confirmation on payment success ## AC3 — Admin force-transition uses the associated email
- Escenario: customer pays and Stripe reports PaymentSucceeded. `POST /orders/:id/transitions/admin` resolves the customer email from the order view
- Given an order in AWAITING_PAYMENT and a valid `PaymentSucceeded` webhook (`order.email`) — it no longer issues a separate inline `SELECT email FROM identity_users`.
- When the webhook is processed When `email` is present, the status notification is sent; when absent, it logs
- Then the order moves to PAID "El cliente no tiene email asociado" via `request.log.warn` and still completes the transition.
- 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)
## Regression ## AC4 — No regression
- verify.sh is green All existing order flows keep their behavior (create, customer/admin detail, list, edit items,
- No existing order admin-transition email behavior changed shipping update, transitions). Only `email` is added to serialization; no new state transitions,
- Password-reset email flow unchanged 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).

View File

@@ -1,32 +1,33 @@
# F-152 — Product Spec # F-153 — Product Spec
## Problema ## Problem
Los clientes no reciben confirmación por email ni al crear la cuenta ni cuando su The order read model and its serialization (`serializeOrder`) do **not** expose
pedido se confirma tras el pago: the linked customer's email. `orders_orders.user_id` references `identity_users`
- `POST /auth/register` crea el usuario pero no envía email de bienvenida. (whose `email citext NOT NULL UNIQUE` always exists), but the order view carries
- El webhook de Stripe (`PaymentSucceeded`) pasa el pedido a `PAID` pero no only `userId` — never the email. Consequence:
notifica al cliente (el `OrderEventPublisher` inyectado en payments es no-op y - Order detail (`/orders/:id`, `/orders/:id/admin`) and the admin order list
`sendOrderStatusEmail` solo se llamaba desde transiciones admin). (`/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 ## Goal
Que los clientes reciban los dos emails transaccionales esenciales: Associate the linked customer's email to the **order read model** and display it in
1. **Welcome** al crear la cuenta (account_created). serialization — detail, admin list, and the admin force-transition notification —
2. **Order confirmation** cuando el pago se confirma (PaymentSucceeded → PAID), using the order view as the single source of truth.
reenviando el flujo ya existente de `sendOrderStatusEmail` (SMTP desde
*Ajustes → SMTP / Email*).
## Usuarios ## Scope IN
- Usuario principal: cliente que se registra / compra en la tienda. - `orders/domain`: add `email` to `OrderView` (read model).
- Usuario secundario: operador (Ajustes SMTP) y admin (vee historial). - `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 ## Scope OUT
- In scope: - No changes to the `identity` domain (no TS cross-import).
- Welcome email on `POST /auth/register` (best-effort, nunca bloquea el registro). - No new tables / migrations: `identity_users.email` already exists and is NOT NULL.
- Order confirmation email on `PaymentSucceeded` webhook (best-effort, nunca - No new endpoints; no auth/RBAC change; no order state machine change.
rompe la reconciliación de pagos).
- Reusar el SMTP configurado en `store_settings` ya usado por admin transitions. ## Risk / Priority
- Tests unitarios de cuerpo/email y de best-effort. - Priority: high. Risk: med (additive read-model field; backward compatible).
- 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.

View File

@@ -1,62 +1,49 @@
# F-152 — Tech Spec # F-153 — Tech Spec
## Principios ## Principles
- Best-effort: un email fallido o SMTP no configurado **nunca** debe fallar el - Associate the customer email to the order **read model** (not a per-request hack):
registro ni el webhook de pagos. Se loguea y se continúa. `OrderView.email` is resolved once by the orders repository via a `LEFT JOIN identity_users`.
- Reusar infraestructura existente: SMTP desde `store_settings` - Reuse the existing SQL pattern: `orders/infrastructure/pg-order-repository.ts` `search`
(`smtp_host/port/secure/user/pass/from`), mismo patrón que already does `LEFT JOIN identity_users u ON u.id = o.user_id` — F-153 extends that to every
`SettingsPasswordResetMailer` y `sendOrderStatusEmail`. order read so the email is always available on the view.
- Boundaries: identity no importa orders/payments a nivel de dominio; el wiring - Boundaries: `identity_users` is referenced only as a **SQL table name** (pre-existing in
del order-confirmation email se hace en el *route handler* de payments (posee `search`); no TypeScript import crosses the identity/orders boundary. `identity` does not
`deps.pool`), reusando `sendOrderStatusEmail` exportado públicamente por import orders; orders references `identity_users` table name (string) at infrastructure.
`orders/index.ts`. - 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) ### 1. Domain — OrderView carries email
- `orders/index.ts`: exportar `sendOrderStatusEmail` (y `ORDER_STATE_LABELS`). - `orders/domain/order.ts`: add `email: string | null;` to `OrderView` (`Order` itself unchanged —
- `payments/api/payments.routes.ts`: en el handler de `/payments/webhook`, tras email is a denormalized read-model attribute, not a core domain field).
`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.
### 2. Welcome email on registration (identity) ### 2. Infrastructure — resolve email in the repo
- `identity/domain/ports.ts`: nuevo puerto `WelcomeMailer` con - `orders/infrastructure/pg-order-repository.ts`:
`sendWelcome(input: { email: string; name?: string }): Promise<void>` (name es - Add `email: string | null` to `OrderRow`.
opcional: `User` no almacena nombre en el dominio actual). - Add helper `toOrderView(row, items): OrderView = { ...toOrder(row), email: row.email, items }`.
- `identity/infrastructure/settings-welcome-mailer.ts`: `SettingsWelcomeMailer(pool)` - `SELECT o.*``SELECT o.*, u.email` with `LEFT JOIN identity_users u ON u.id = o.user_id`
modelado en `SettingsPasswordResetMailer` — lee SMTP de `store_settings`, usa in `findById`, `findByIdAndUserId`, `findAll`, `search`.
`nodemailer`, y una función pura `buildWelcomeEmail` (verificable sin SMTP). - Build every returned `OrderView` via `toOrderView` (so `email` is always set; `null` when the
El subject (`¡Bienvenido a Mercado de Vida!`) coincide con la plantilla order has no linked identity_user).
`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`.
## SMTP / store_settings ### 3. API — expose + consume email
Claves existentes: `smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from`. - `orders/api/orders.routes.ts`:
El welcome mailer reusa exactamente estas claves. - `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 ## Testing
- `settings-welcome-mailer.test.ts`: `buildWelcomeEmail` (greeting, nombre, XSS), - New `orders/infrastructure/pg-order-repository.test.ts`: mock `pg.Pool`, assert `findById`
`SettingsWelcomeMailer.sendWelcome` (nodemailer mockeado: sendmail called once returns `email` from the JOIN when the linked identity_user has one, and `null` when there is
con recipient == email y subject == plantilla `account_created`), y lanza cuando no linked user.
SMTP no está configurado (`SMTP is not configured`). - Update existing `OrderView` literals in tests (`order-service`, `payments-service`,
- `orders/tests/orders-index.test.ts`: el barrel de `orders/index.ts` re-exporta `checkout-service`) to include `email: null` (additive field).
`sendOrderStatusEmail`, `buildOrderStatusEmail`, `ORDER_STATE_LABELS`. - `order-status-mailer.test.ts` (existing) remains green (no change to email senders).
- `order-status-mailer.test.ts` ya existe cubriendo `buildOrderStatusEmail` (PAID - tsc --noEmit clean; prettier + eslint clean; lint:boundaries no new violations;
pertenece a `ORDER_STATE_LABELS`). vitest run full suite green; verify.sh green.
- `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.

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 # 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) ## 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). - `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, "feature_id": "F-153",
"stage": "idle", "stage": "close",
"agent": "leader", "agent": "leader",
"action": "Sin ejecución activa", "action": "Close F-153 — gates approved",
"state": "waiting", "state": "done",
"next_agent": "leader", "next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado", "waiting_for": "code + tests + implementer.md",
"updated_at": "2026-08-22T05:09:09Z", "updated_at": "2026-08-22T05:41:08Z",
"timeline": [] "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"
}
]
} }