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

@@ -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).

View File

@@ -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).

View File

@@ -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.