feat(F-154): completed feature
This commit is contained in:
86
spec/tech.md
86
spec/tech.md
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user