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.",
|
"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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>> = {
|
||||||
|
|||||||
@@ -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 {
|
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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
97
spec/tech.md
97
spec/tech.md
@@ -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.
|
|
||||||
|
|||||||
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
|
# 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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user