feat(F-153): completed feature
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Order {
|
||||
|
||||
export interface OrderView extends Order {
|
||||
items: OrderItem[];
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<OrderView[]> {
|
||||
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(
|
||||
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<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
|
||||
${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<OrderView | undefined> {
|
||||
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],
|
||||
);
|
||||
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<OrderView | undefined> {
|
||||
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],
|
||||
);
|
||||
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<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 {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
97
spec/tech.md
97
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<void>` (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.
|
||||
|
||||
51
work/artifacts/F-153/architect.md
Normal file
51
work/artifacts/F-153/architect.md
Normal 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.
|
||||
56
work/artifacts/F-153/implementer.md
Normal file
56
work/artifacts/F-153/implementer.md
Normal 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.
|
||||
13
work/artifacts/F-153/leader-close.json
Normal file
13
work/artifacts/F-153/leader-close.json
Normal 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": []
|
||||
}
|
||||
15
work/artifacts/F-153/qa.json
Normal file
15
work/artifacts/F-153/qa.json
Normal 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": []
|
||||
}
|
||||
14
work/artifacts/F-153/reviewer.json
Normal file
14
work/artifacts/F-153/reviewer.json
Normal 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": []
|
||||
}
|
||||
15
work/artifacts/F-153/security.json
Normal file
15
work/artifacts/F-153/security.json
Normal 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": []
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user