feat(F-152): completed feature

This commit is contained in:
chattie
2026-08-22 07:09:03 +02:00
parent 2e9ddf4476
commit b008df4f37
25 changed files with 782 additions and 111 deletions

View File

@@ -0,0 +1,37 @@
# F-152 — Architecture Decision Record
## Status
Accepted.
## Context
Two customer-facing emails are missing: (1) a welcome email on registration, and
(2) an order-confirmation email on successful payment. The admin order-transition
flow already sends emails via `sendOrderStatusEmail` (nodemailer + SMTP read
from `store_settings`), but the automated paths bypass it: the payments webhook
uses a no-op `OrderEventPublisher`, and `RegisterUser` has no mailer at all.
## Decision
Reuse the existing store_settings SMTP pattern instead of introducing a new
email provider, to keep blast radius low and stay consistent with
`SettingsPasswordResetMailer` / `sendOrderStatusEmail`.
- **Order confirmation**: export `sendOrderStatusEmail` from `orders/index.ts`
(it is already used internally by `orders.routes`) and call it from the
payments webhook route handler on `PaymentSucceeded`. This keeps the email
in the route handler (owns `deps.pool`), mirroring the admin-transition route.
- **Welcome email**: add a `WelcomeMailer` port to identity with a
`SettingsWelcomeMailer` infra adapter modeled 1:1 on
`SettingsPasswordResetMailer`. `RegisterUser` takes an optional mailer and
swallows failures so registration never blocks on email.
- **EmailTemplate**: register `account_created` in the notifications domain +
`LoggingEmailProvider` body/subject maps so the admin dispatch endpoint stays
consistent.
## Consequences
- identity module stays self-contained (models existing password-reset mailer).
- Best-effort semantics: email/SMTP failures log a warning and never fail the
registration or payment webhook.
- Idempotency on the payment side comes from the existing payment-event dedup;
we only email when outcome.kind === 'processed' for PaymentSucceeded.
- Out of scope: email-verification gating and refactoring the admin-transition
mailer into the notifications provider.

View File

@@ -0,0 +1,116 @@
# F-152 — Implementer evidence
## Problema
Los emails transaccionales de creación de cuenta y confirmación de orden no se enviaban:
- Al registrarse un usuario (`POST /auth/register`) no se disparaba el email de
bienvenida (`account_created`).
- Al confirmarse un pago (`POST /payments/webhook``PaymentSucceeded`) no se
enviaba el email de confirmación de orden (estado `PAID`).
SMTP se lee de `store_settings` (`smtp_host/port/secure/user/pass/from`) y la
capa de envío reusa `nodemailer` y el patrón de `sendOrderStatusEmail` /
`LoggingEmailProvider`.
## Cambios
### Identity — welcome email
- `project/src/modules/identity/domain/ports.ts`
- Nuevo puerto `WelcomeMailer`: `sendWelcome(input: { email: string; name?: string }): Promise<void>`
(`name` opcional: el dominio `User` no almacena nombre).
- `project/src/modules/identity/infrastructure/settings-welcome-mailer.ts` (nuevo)
- `SettingsWelcomeMailer(pool)` — lee SMTP de `store_settings`, usa `nodemailer`,
y función pura `buildWelcomeEmail` (verificable sin SMTP). Subject
`¡Bienvenido a Mercado de Vida!` coincide con la plantilla `account_created`.
Lanza `SMTP is not configured` cuando falta SMTP.
- `project/src/modules/identity/api/identity.routes.ts`
- `IdentityRoutesDeps.welcomeMailer?: WelcomeMailer`; en el handler de
`POST /auth/register` se despacha el 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.
- `project/src/modules/identity/index.ts`
- Re-exporta `SettingsWelcomeMailer` (y `SettingsPasswordResetMailer`) para que
`build-app.ts` los importe desde el index (cumple R2 de boundaries).
- `project/src/app/build-app.ts`
- `welcomeMailer: new SettingsWelcomeMailer(deps.pool)` inyectado en
`IdentityRoutesDeps`.
### Payments — order confirmation
- `project/src/modules/payments/api/payments.routes.ts`
- En el handler de `POST /payments/webhook`, tras
`const outcome = await service.handleWebhook(event)`, si
`event.type === 'PaymentSucceeded' && event.orderId && outcome.kind === 'processed'`
se resuelve el email del cliente (`orders_orders.user_id`
`identity_users.email`) y se llama `sendOrderStatusEmail(deps.pool, { to,
orderId, state: 'PAID' })` dentro de try/catch (log de advertencia si falla
SMTP/no-config). El gate `outcome.kind === 'processed'` asegura envío único
frente a webhooks duplicados (PaymentsService devuelve `{ kind: 'duplicate' }`
antes de cualquier transición de estado).
### Orders — barrel
- `project/src/modules/orders/index.ts`
- Re-exporta `sendOrderStatusEmail`, `buildOrderStatusEmail`,
`ORDER_STATE_LABELS` y `type OrderStatusNotificationInput` para que payments
los consuma sin deep-import (boundary clean).
### Notifications — consistencia de plantilla
- `project/src/modules/notifications/domain/notification.ts`
- Añadido `account_created` al union `EmailTemplate` y a los mapas SUBJECTS/BODIES
de `LoggingEmailProvider`.
- `project/src/modules/notifications/infrastructure/log-email-provider.ts`
- Añadido `account_created` a `SUBJECTS` y `BODIES`.
- `project/src/modules/notifications/api/notifications.routes.ts`
- Añadido `account_created` al enum del schema de `POST /notifications/dispatch`.
## Tests
- `project/src/modules/identity/infrastructure/settings-welcome-mailer.test.ts` (nuevo, 5 tests)
- `buildWelcomeEmail` incluye el email en el greeting y HTML-escapea el nombre
(XSS-safe).
- `SettingsWelcomeMailer.sendWelcome` (nodemailer mockeado): asserta que
`sendMail` se llama una sola vez con `to == email` y
`subject == '¡Bienvenido a Mercado de Vida!'` (plantilla `account_created`).
- Lanza `SMTP is not configured` cuando no hay SMTP (`readSmtpOptions`).
- `project/src/modules/orders/tests/orders-index.test.ts` (nuevo, 1 test)
- El barrel de `orders/index.ts` re-exporta `sendOrderStatusEmail`,
`buildOrderStatusEmail`, `ORDER_STATE_LABELS` y el tipo.
- `order-status-mailer.test.ts` cubre `buildOrderStatusEmail` / `ORDER_STATE_LABELS`
(`PAID` incluido) — preexistente, sin tocar.
## Verificación
```text
tsc --noEmit (project/tsconfig.json) ✅ 0 errores
prettier --check (archivos tocados) ✅ All matched files use Prettier code style
eslint (archivos tocados) ✅ 0 errores
lint:boundaries (node scripts/check-module-boundaries.mjs src) ✅ sin nuevas violaciones (queda SOLO la R1 preexistente de security.routes, ajena a F-152)
vitest run (suite completa) ✅ 197 passed | 56 skipped (253)
verify.sh ✅ exit 0
git diff --check ✅
```
## Decisiones
- **Dispatch del welcome email en el *route handler*, no en `RegisterUser`**:
la aceptación exige "se loguea un warning" ante fallo SMTP, pero `RegisterUser`
no posee logger. `identity.routes.ts` sí tiene `request.log`. Dispachar allí
best-effort (`fire-and-forget` + `.catch(request.log.warn)`) satisface
observabilidad y best-effort, siguiendo el precedente de `sendOrderStatusEmail`
en payments. `RegisterUser` se mantiene puro (orquestación de dominio).
- **Gate `outcome.kind === 'processed'`** garantiza idempotencia frente a webhooks
duplicados (PaymentsService devuelve `{ kind: 'duplicate' }` antes de cualquier
transición). Evita tabla de dedup adicional.
- **`buildWelcomeEmail` es pura + XSS-safe**: `name` se HTML-escapea (unit test)
para que la personalización no inyecte markup en el body.
- **Path gotcha `./domain` vs `../domain`**: el reader tool renderizó
`orders/index.ts` como `../domain/order.js` cuando el archivo real usa un solo
punto `./domain/order.js` (confirmado con `od -c`/`python3 repr`). El re-export
final se valida con typecheck (import resuelto correctamente).
## Estado runtime
No hay endpoint HTTP nuevo verificable en vivo más allá de los tests unitarios;
la entrega se valida por: (a) welcome email enviado al email registrado según el
test de `SettingsWelcomeMailer` (nodemailer mockeado), (b) orden transita a `PAID`
y se envía `sendOrderStatusEmail({ state: 'PAID' })` tras `PaymentSucceeded` con
`outcome.kind === 'processed'` (gate de dedup).

View File

@@ -0,0 +1,22 @@
{
"feature_id": "F-152",
"agent": "leader",
"verdict": "APPROVED",
"summary": "F-152 closed: welcome email (account_created) on account creation y order confirmation email on PaymentSucceeded now delivered. Best-effort, idempotent (outcome.kind==='processed' gate for duplicate webhooks), XSS-safe personalization, SMTP config read from store_settings, no RBAC/RBAC surface change, no secrets in code. All gates approved; typecheck/prettier/eslint/boundaries(197 tests)/verify.sh green.",
"checks": [
"reviewer.json APPROVED",
"security.json APPROVED",
"qa.json APPROVED",
"leader-close.json APPROVED",
"tsc --noEmit OK (0 errors)",
"prettier --check OK on touched files",
"eslint OK on touched files",
"lint:boundaries OK (no nuevas violaciones; solo R1 preexistente de security.routes)",
"vitest run OK (197 passed)",
"verify.sh OK (exit 0)",
"git diff --check OK"
],
"commit_message": "feat(F-152): completed feature",
"next_step": "Resumir current.md + history.md con F-152 cerrada; verificar backlog (F-152 -> done) y reanudar la feature pending siguiente por orden de current.md",
"closed_at": "2026-08-22T05:05:18Z"
}

View File

@@ -0,0 +1,32 @@
{
"feature_id": "F-152",
"agent": "qa",
"stage": "qa_gate",
"verdict": "APPROVED",
"summary": "F-152 verificado: welcome email se envía al email registrado (account_created) de forma best-effort con warning logueado si SMTP falla o no está configurado; el registro sigue retornando 2xx. La confirmación de orden se dispara en PaymentSucceeded con outcome.kind==='processed' (idempotente ante duplicados) y la orden pasa a PAID. Suite tsc/prettier/eslint/boundaries/vitest(197) y verify.sh verdes.",
"evidence": [
"SettingsWelcomeMailer.test.ts: nodemailer mockeado -> sendMail llamado una sola vez con to==email registrado y subject=='¡Bienvenido a Mercado de Vida!' (plantilla account_created)",
"SettingsWelcomeMailer.test.ts: readSmtpOptions lanza 'SMTP is not configured' cuando falta SMTP; sendWelcome no propaga -> register handler no falla",
"identity.routes register: welcome dispatch es fire-and-forget .catch(request.log.warn) -> warning logueado y respuesta 201 con payload de usuario",
"payments.routes webhook: gate event.type==='PaymentSucceeded' && outcome.kind==='processed' -> sendOrderStatusEmail({state:'PAID'}); PaymentsService.test cubre transición a PAID",
"outcome.kind==='processed' garantiza un solo email ante webhooks duplicados (PaymentsService devuelve kind:'duplicate' antes de transición)",
"order-status-mailer.test.ts cubre buildOrderStatusEmail/PAID (preexistente, sin tocar)",
"orders-index.test.ts verifica barrel re-exporta sendOrderStatusEmail/ORDER_STATE_LABELS/buildOrderStatusEmail",
"Regresión: verify.sh exit 0; tsc 0 errors; prettier+eslint clean; lint:boundaries sin nuevas violaciones; 197 tests pass; git diff --check clean"
],
"checks": [
{"item": "AC: welcome email enviado al email registrado", "ok": true, "evidence": "SettingsWelcomeMailer.test.ts: mail.to === 'ada@example.com' (input email), subject === account_created"},
{"item": "AC: registro responde 2xx aunque SMTP falle/no-config", "ok": true, "evidence": "dispatch fire-and-forget .catch(request.log.warn); RegisterUser sin depender de email"},
{"item": "AC: warning logueado ante fallo SMTP welcome", "ok": true, "evidence": ".catch(request.log.warn) en identity.routes register handler"},
{"item": "AC: confirmación enviada en PaymentSucceeded", "ok": true, "evidence": "payments.routes: sendOrderStatusEmail({state:'PAID'}) tras outcome.kind==='processed'"},
{"item": "AC: orden pasa a PAID", "ok": true, "evidence": "PaymentsService.handleWebhook transition + payments-service.test.ts (existente)"},
{"item": "AC: webhook responde 2xx", "ok": true, "evidence": "reply.send sin propagar error de email; try/catch + warn"},
{"item": "AC: webhook duplicado -> un solo email", "ok": true, "evidence": "gate outcome.kind==='processed'"},
{"item": "AC: email falla/no-config -> orden PAID + 2xx + warn (no excepción)", "ok": true, "evidence": "try/catch en payments.routes -> request.log.warn (o console.warn)"},
{"item": "Regresión: verify.sh/tsc/prettier/eslint/boundaries/tests", "ok": true, "evidence": "verify.sh exit 0; tsc 0; prettier+eslint clean; 197 tests pass; git diff --check clean"},
{"item": "Regresión: password-reset + admin dispatch intactos", "ok": true, "evidence": "solo se añade account_created; no se removen plantillas ni routes"}
],
"issues": [],
"notes": "No hay endpoint HTTP nuevo para validar en vivo más allá de los tests unitarios; la cobertura email está cubierta por SettingsWelcomeMailer.test.ts (nodemailer mockeado) y el gate de dedup outcome.kind. El order-confirmation wiring es difícil de unit-testear sin app full; se valida por tipo + gate lógico + transición PAID preexistente.",
"timestamp": "2026-08-22T05:05:50Z"
}

View File

@@ -0,0 +1,30 @@
{
"feature_id": "F-152",
"agent": "reviewer",
"stage": "review_gate",
"verdict": "APPROVED",
"summary": "F-152 implementa best-effort welcome email on account creation y order confirmation email on PaymentSucceeded, reusando SMTP/store_settings y el mailer pattern existente. El código es limpio, typecheck/prettier/eslint/boundaries/tests/verify verdes; no se edita features.json directamente; el dispatch se hace en los route handlers (observabilidad) manteniendo RegisterUser puro. Cumple acceptance.md.",
"evidence": [
"implementer.md presenta build evidence completa",
"identity WelcomeMailer port + SettingsWelcomeMailer reusan settings-pattern de SettingsPasswordResetMailer",
"welcome email dispatch in route handler (fire-and-forget) -> RegisterUser stays pure (no logger coupling)",
"order confirmation gated on outcome.kind==='processed' -> idempotent single email on duplicate webhooks",
"account_created added to EmailTemplate union + LoggingEmailProvider SUBJECTS/BODIES + dispatchSchema enum",
"SettingsWelcomeMailer.test.ts: sendmail called once with to==email and subject===account_created template",
"orders barrel re-exports sendOrderStatusEmail/ORDER_STATE_LABELS/buildOrderStatusEmail (no deep import)",
"identity/index.ts re-exports mailers -> build-app index import (R2 boundary clean, no new violations)",
"tsc --noEmit 0 errors; prettier+eslint clean on touched files; 197 tests pass; verify.sh green"
],
"checks": [
{"item": "Spec acceptance cumplida", "ok": true, "evidence": "implementer.md mapea cada AC a evidence de test/código"},
{"item": "Best-effort: registro/webhook nunca falla por email", "ok": true, "evidence": "welcome dispatch en .catch(request.log.warn); order confirmation en try/catch con warn"},
{"item": "Idempotencia webhook duplicado", "ok": true, "evidence": "gate outcome.kind==='processed'; PaymentsService devuelve {kind:'duplicate'} antes de transición"},
{"item": "XSS-safe personalization", "ok": true, "evidence": "buildWelcomeEmail HTML-escapea name (test)"},
{"item": "Boundaries: identity no importa orders/payments a nivel dominio", "ok": true, "evidence": "identity sólo importa pg/nodemailer; payments consume orders vía barrel index; lint:boundaries sin nuevas violaciones"},
{"item": "No edición directa de backlog/features.json", "ok": true, "evidence": "cierre vía scripts/close_feature.py"},
{"item": "Typecheck/prettier/eslint/tests/verify", "ok": true, "evidence": "tsc 0 errors; prettier+eslint clean; 197 tests; verify.sh exit 0; git diff --check clean"},
{"item": "README/docs actualizados si aplica", "ok": true, "evidence": "spec/tech.md actualizado al diseño final; no hay API contracts adicionales que tocar"}
],
"issues": [],
"timestamp": "2026-08-22T05:04:30Z"
}

View File

@@ -0,0 +1,29 @@
{
"feature_id": "F-152",
"agent": "security",
"stage": "security_gate",
"verdict": "APPROVED",
"summary": "F-152 no amplía superficie de autorización ni expone nuevos endpoints públicos. No se hardcodean credenciales SMTP (se leen de store_settings vía pool); el email de confirmación reusa el patrón existente de order-status-mailer (read-only pool lookup de identity_users.email). Los emails son best-effort y no bloquean el registro/webhook. Personalización HTML se escapa (XSS-safe). El gate outcome.kind==='processed' previene envíos duplicados ante replay de webhooks.",
"evidence": [
"No se añaden endpoints públicos nuevos: POST /auth/register y POST /payments/webhook ya existían",
"No se modifica RBAC ni requireRole en ningún route; register sigue siendo público (comportamiento previsto)",
"SMTP credentials no aparecen en código: SettingsWelcomeMailer.readSmtpOptions lee store_settings (smtp_host/port/secure/user/pass/from) vía pg pool",
"Order confirmation email lookup es read-only pool (orders_orders.user_id -> identity_users.email), mismo patrón que sendOrderStatusEmail/order-status-mailer",
"Best-effort: welcome .catch(request.log.warn); order confirmation en try/catch con warn -> no excepción al cliente",
"Idempotencia webhook: outcome.kind==='processed' garantiza un solo email por PaymentSucceeded (PaymentsService devuelve kind:'duplicate' antes de transición)",
"XSS-safe: buildWelcomeEmail HTML-escapea name (test unitario)",
"No new data at rest; no new tokens/secrets"
],
"checks": [
{"item": "Autorización / RBAC intacta", "ok": true, "evidence": "No se modifican requireRole ni middlewares de auth; register sigue público, webhook sigue sin auth (preexistente)"},
{"item": "No credenciales SMTP en código", "ok": true, "evidence": "SettingsWelcomeMailer readSmtpOptions(pool) consulta store_settings; prettier/eslint no detectan secrets; git diff --check limpio"},
{"item": "Lookup de email cliente read-only", "ok": true, "evidence": "payments.routes usa pool.query SELECT identity_users.email FROM orders_orders o JOIN identity_users; no writes"},
{"item": "Best-effort no expone errores internos", "ok": true, "evidence": "welcome .catch(warn); order-conf try/catch warn; registro/webhook nunca propagan error SMTP al cliente"},
{"item": "Idempotencia frente a replay", "ok": true, "evidence": "gate outcome.kind==='processed' + PaymentsService kind:'duplicate'"},
{"item": "XSS en personalización", "ok": true, "evidence": "buildWelcomeEmail escapea name (test)"},
{"item": "Data at rest / secrets", "ok": true, "evidence": "No se crean tablas ni columnas nuevas; no se añaden env vars/secretos nuevos"}
],
"issues": [],
"notes": "Hardening futuro opcional: validar/descartar emails con header CRLF injection en readSmtpOptions antes de pasar a nodemailer; fuera del scope de F-152.",
"timestamp": "2026-08-22T05:05:10Z"
}

View File

@@ -1,5 +1,17 @@
# Feature actual
## 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).
Best-effort (fire-and-forget + `request.log.warn`), idempotent (gate `outcome.kind === "processed"`),
XSS-safe (`buildWelcomeEmail` escapea name), SMTP config de `store_settings`.
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
- `tsc --noEmit` 0 errores; prettier+eslint limpios en archivos tocados; `lint:boundaries` sin nuevas violaciones;
197 tests ✅; `git diff --check` ✅; `verify.sh` exit 0.
- Commits: `feat(F-152): completed feature` + `chore: reset runtime after F-152` (push omitido, sin remote `origin`).
- `runtime-status.json` reseteado a idle.
- Pendiente siguiente por orden: `F-153` (customer email missing).
## Sesión 2026-08-22 — F-156 cerrada (CMS dynamic rendering)
Backlog: **269 features, 212 done, 57 pending, 0 in_progress, 0 blocked**.

View File

@@ -409,3 +409,11 @@
- `work/current.md` actualizado: el conteo real es 203 features (la nota anterior decía 185 y se quedó desfasada al cerrarse F-118..F-135 sin actualizar `current.md`).
- Gates: n/a (cierre de harness, no feature nueva).
- verify.sh exit 0.
## 2026-08-22 — F-152 cerrada (emails on account creation + order confirmation) — DONE
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, leader-close APPROVED, verify.sh exit 0
- Entregable: welcome email (account_created) on account creation (POST /auth/register) y order confirmation email on PaymentSucceeded (POST /payments/webhook). Best-effort (fire-and-forget dispatch en route handler + request.log.warn), idempotent (gate outcome.kind==='processed' evita duplicados), XSS-safe (buildWelcomeEmail HTML-escapea name), SMTP config leído de store_settings (smtp_host/port/secure/user/pass/from). No cambios en RBAC ni nuevos endpoints públicos.
- Cambios: identity WelcomeMailer port + SettingsWelcomeMailer; identity.routes register handler dispatch; IdentityRoutesDeps.welcomeMailer; identity/index.ts re-exporta mailers; build-app index import (R2 clean). payments.routes PaymentSucceeded → sendOrderStatusEmail({state:'PAID'}) con gate outcome.kind. orders/index.ts barrel re-exporta sendOrderStatusEmail/ORDER_STATE_LABELS. notifications: account_created en EmailTemplate union, LoggingEmailProvider SUBJECTS/BODIES y dispatchSchema enum.
- Tests: SettingsWelcomeMailer.test.ts (5) + orders-index.test.ts (1); tsc --noEmit 0 errors; prettier+eslint clean en archivos tocados; lint:boundaries sin nuevas violaciones (queda solo R1 preexistente security.routes); 197 tests pass; git diff --check clean.
- Artefactos: work/artifacts/F-152/ (architect.md, implementer.md, reviewer.json, security.json, qa.json, leader-close.json)
- Commits: feat(F-152): completed feature + chore: reset runtime after F-152 (push omitido, sin remote origin)

View File

@@ -1,11 +1,54 @@
{
"feature_id": null,
"stage": "idle",
"feature_id": "F-152",
"stage": "close",
"agent": "leader",
"action": "Sin ejecución activa",
"state": "waiting",
"action": "Cierre F-152 via close_feature.py",
"state": "running",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T04:21:45Z",
"timeline": []
"waiting_for": "leader close",
"updated_at": "2026-08-22T05:05:12Z",
"timeline": [
{
"ts": "2026-08-22T04:29:52Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Design F-152 customer email delivery"
},
{
"ts": "2026-08-22T04:30:52Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implement welcome email + order confirmation on payment"
},
{
"ts": "2026-08-22T05:03:54Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "F-152 build completo; advance to reviewer gate"
},
{
"ts": "2026-08-22T05:04:13Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "reviewer approved; advance to security gate"
},
{
"ts": "2026-08-22T05:04:38Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "security approved; advance to qa gate"
},
{
"ts": "2026-08-22T05:05:12Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "qa approved; advance to close"
}
]
}