feat(F-154): completed feature

This commit is contained in:
chattie
2026-08-22 08:35:46 +02:00
parent 1415590106
commit 5098723284
19 changed files with 535 additions and 114 deletions

View File

@@ -1,34 +1,28 @@
# F-153 — Acceptance Criteria
# F-154 — Acceptance Criteria
## 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`.
## AC1 — Customers list shows only storefront customers
`GET /users` (admin) devuelve SOLO usuarios con `role = 'customer'`. Un usuario
interno (admin/editor) NO aparece en el listado. El buscador `q` sigue filtrando sobre email
dentro de los clientes.
## 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).
## AC2 — Users list shows only internal/backoffice users
`GET /admin/users` (admin, default sin `?role=`) devuelve SOLO usuarios con
`role != 'customer'` (admin/editor/pos). Un cliente (`role = 'customer'`) NO aparece.
`?role=admin` y `?role=editor` siguen afinando dentro de internos; `?role=customer`
NO devuelve clientes (devuelve vacío) — la separación está forzada en backend.
## 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.
## AC3 — No regression on user profile / addresses
`/users/:id` (GET/PATCH) owner-or-admin sigue devolviendo/editando CUALQUIER usuario
sin filtro por rol (admin ve perfil de cliente; cliente ve el suyo). CRUD de
`/users/:id/addresses` inalterado.
## 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.
## AC4 — No boundary / injection violation
- `identity_users` referenciado solo como tabla SQL (sin import TS).
- Valores `q`/`role` parametrizados; el literal `'customer'`/`'customer'` es constante de código.
- Sin migración.
## 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).
## AC5 — Quality gates
- `tsc --noEmit` (API) 0 errores; `npx tsc --noEmit` (apps/admin) sin errores nuevos.
- `npm run lint:boundaries` sin violaciones nuevas.
- `vitest run` (sin DB) → suite nueva F-154 + suite existente en verde.
- `verify.sh` exit 0 (backlog F-154 in_progress, runtime stage válido).

View File

@@ -1,33 +1,30 @@
# F-153Product Spec
# F-154Admin: separate customers from internal users
## 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.
El panel admin muestra usuarios mezclados. `GET /users` (módulo `users`) devuelve
TODOS los identity_users (clientes + backoffice) y `GET /admin/users` (módulo `security`)
por defecto también devuelve todos. La página Customers llama a `/api/users` y la página
Users llama a `/api/admin/users`; como ambos devuelven todo, ambos listados aparecen
mezclados (conceptos de identity/storefront con backoffice en un mismo listado).
## 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.
Customers muestra SOLO clientes storefront (`role = 'customer'`); Users muestra SOLO
usuarios internos/backoffice (`role != 'customer'`). Separación forzada en el backend
(single source of truth), no solo filtrado cliente.
## 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).
- `project/src/modules/users` (`listCustomers` / `GET /users`): filtrar `role = 'customer'`.
- `project/src/modules/security` (`GET /admin/users`): default `role != 'customer'`;
`?role=admin|editor` sigue afinando dentro de internos.
- `project/apps/admin/.../users/page.tsx`: quitar opción `customer` del dropdown (Users = backoffice).
- Tests unitarios (mock pool, sin DB) + actualizar itest AC2/AC3.
## 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.
- No se crea `/customers` (el cliente ya consume `/users`).
- `/users/:id`, `/users/:id/addresses` (owner-or-admin) siguen sin filtro por rol (un admin
ve el perfil de cualquier usuario; un cliente ve el suyo).
- No migración (identity_users.role ya existe, NOT NULL con default 'customer').
- Frontend Customer page: sin cambio (ya llama /users → ahora customer-only).
## Risk / Priority
- Priority: high. Risk: med (additive read-model field; backward compatible).
## Type
fix — high priority / high risk.

View File

@@ -1,49 +1,49 @@
# F-153 — Tech Spec
# F-154 — Technical Design
## 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.
## Context
- `identity_users` tiene `role: citext NOT NULL DEFAULT 'customer'` (valores: `customer`,
`admin`, `editor`, `pos_cashier`, `pos_manager`). `customer` = storefront; el resto = backoffice.
- `GET /users` (módulo `users`): `PgProfileRepository.listCustomers` hace
`SELECT ... FROM identity_users iu LEFT JOIN users_profiles up ... WHERE ($1::text IS NULL OR iu.email ILIKE $1)`.
Devuelve TODO. Usado por `clientsApi.list` (página Customers) → `/api/users`.
- `GET /admin/users` (módulo `security`): query inline con condiciones opcionales `role` y `q`.
Sin `?role=` devuelve TODO. Usado por `adminUsersApi.list` (página Users) → `/api/admin/users`.
- `listCustomers` se consume SOLO en `users.routes.ts` (`/users`). `findCustomerById`
(single, `/users/:id`) es role-agnostic (owner-or-admin) → no cambia.
- No existe test de `users`/`security` routes; `users.itest.ts` AC2/AC3 asocia al admin (ana)
al listado `/users` (true hoy porque /users devuelve todo; romperá si /users es customer-only).
## Changes
## Decision
Forzar la separación en el backend (no cliente):
1. `listCustomers` → siempre `... AND iu.role = 'customer'` (literal, no user input → sin inyección).
Parámetros inalterados: `[searchFilter, limit, offset]`; COUNT también filtra por rol.
2. `GET /admin/users` → condición base `role <> 'customer'` (literal). `?role=admin|editor`
se andaña con `AND role = $1`. Así `/admin/users` NUNCA devuelve customers, incluso con
`?role=customer` (devuelve vacío). Parámetro base es literal → índices de `$N` de los
filtros opcionales inalterados.
3. Frontend: dropdown de Users quita `<option value="customer">`.
### 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).
## Alternatives
- Filtrado cliente-only: rechazado. El backend es la fuente única de verdad; el cliente no
debe poder ver customers vía `/admin/users`.
- Nuevo endpoint `/customers`: rechazado. El cliente ya consume `/users` (customers) y
`/admin/users` (internos); crear `/customers` duplicaría y obligaría cambios frontend
sin valor.
### 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).
## Boundary / Security
- `users` módulo referencia `identity_users` SOLO como nombre de tabla SQL (patrón ya usado en
`search`); sin import TS users↔security. `lint:boundaries` sin cambios nuevos.
- `role` proviene de la DB (no user input directo en el filtro de roles; el literal `'customer'`/`'customer'`
está en código). En `/admin/users`, `?role=` validado por zod enum `['customer','editor','admin']`.
- Sin inyección: los valores user input (`q`, `role`) siguen parametrizados (`$N`); los literales
`role = 'customer'` / `role <> 'customer'` son constantes de código.
### 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).
## Migration
Ninguna. `identity_users.role` ya existe (NOT NULL DEFAULT 'customer').
## Testing
- 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.
## Tests
- `pg-profile-repository.test.ts` (mock pool): `listCustomers` emite `iu.role = 'customer'`,
`q` filtra sobre email, COUNT y SELECT coinciden, returns solo filas customer.
- `security.routes.test.ts` (mock app+deps): `/admin/users` default → `role <> 'customer'`;
`?role=admin``role <> 'customer' AND role = $1`; respuesta items internos.
- `users.itest.ts` AC2/AC3: actualizar aserción — `/users` devuelve customer (ben) no admin (ana).