feat(F-138): completed feature
This commit is contained in:
@@ -1,28 +1,26 @@
|
||||
# F-154 — Acceptance Criteria
|
||||
# F-138 — Criterios de aceptación
|
||||
|
||||
## 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.
|
||||
## AC1 — Sembrado inmediato de precio
|
||||
Tras crear una variante, `GET /pricing/variants/:variantId` devuelve **200** (no 404) con una fila default: `netUnitAmountCents = 0`, `vatRate = 'general'`, `currency = 'EUR'`.
|
||||
- **Unit:** `CreateProductVariant.execute` llama a `PricingService.seedVariantPrice(variant.id)` tras `variants.create`.
|
||||
- **Itest (skip sin DB):** POST `/products/:id/variants` → GET `/pricing/variants/:variantId` = 200.
|
||||
|
||||
## 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.
|
||||
## AC2 — Idempotente (re-sembbrado no-op)
|
||||
Si la fila de precio ya existe, re-sembrar no lanza ni duplica: `ON CONFLICT (variant_id) DO NOTHING`.
|
||||
- **Unit:** segunda llamada a `seedVariantPrice` no arroja; `seedCalls` contiene el id una sola vez (o ambas, sin error).
|
||||
|
||||
## 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.
|
||||
## AC3 — Best-effort (no rompe la creación)
|
||||
Si `seedVariantPrice` lanza, `CreateProductVariant.execute` **sigue devolviendo la variante creada** (no propaga el error).
|
||||
- **Unit:** con `FakePricingService.seedShouldThrow = true`, `execute` devuelve el `ProductVariant` sin lanzar.
|
||||
|
||||
## 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.
|
||||
## AC4 — Los 3 call sites crean la variante con precio
|
||||
Los 3 puntos que crean variantes dejan fila de precio:
|
||||
1. `POST /products` (autovariante `SKU-MV-{id}`).
|
||||
2. `GET /products/:id/variants` (lazy, admin).
|
||||
3. `POST /products/:id/variants`.
|
||||
- Todos comparten la misma instancia `createVariant` (inyecta `pricing`) → todos sembran.
|
||||
|
||||
## 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).
|
||||
## Gates
|
||||
- **reviewer:** arquitectura limpia (pricing owning su tabla; inyección de servicio público; build-app ordering safe).
|
||||
- **security:** SQL con parámetro (`$1`), literales `'general'`/`0`/`NULL` (no user input); no inyección.
|
||||
- **qa:** tests unitarios 3/3 verdes; `npm test` no rompe; tsc 0 errores; verify.sh green.
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
# F-154 — Admin: separate customers from internal users
|
||||
# F-138 — Auto-sembrar fila de precio en creación de variante
|
||||
|
||||
## Problem
|
||||
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).
|
||||
## Título
|
||||
Auto-sembrar fila de precio en creación de variante para evitar la ventana 404 en `GET /pricing/variants/:id`.
|
||||
|
||||
## Goal
|
||||
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.
|
||||
## Contexto / Problema
|
||||
- `GET /pricing/variants/:variantId` (modulo `pricing`, `PricingService.getVariantPrice` → `PgPricingRepository.findByVariantId`) devuelve **404** cuando `pricing_variant_prices` no tiene fila para `variant_id`.
|
||||
- `CreateProductVariant.execute` (`catalog/application/variant-use-cases.ts`) solo llama a `variants.create` (inserta en `catalog_product_variants`) y **nunca** inserta en `pricing_variant_prices`.
|
||||
- 3 call sites disparan `createVariant.execute`:
|
||||
1. Creación de producto con variante por defecto (`POST /products`, autovariante `SKU-MV-{id}`).
|
||||
2. Lazy migration en `GET /products/:id/variants` (producto legacy sin variantes → crea variante default para admin).
|
||||
3. `POST /products/:id/variants` (creación explícita de variante).
|
||||
- En todos los casos, la variante existe en `catalog_product_variants` pero `GET /pricing/variants/:variantId` 404ea **hasta que un admin no asocie un precio** → la carrotera/pos pueden romper ("el precio no existe").
|
||||
|
||||
## Scope IN
|
||||
- `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.
|
||||
## Solución
|
||||
Sembrar (seed) una fila de precio por defecto **inmediatamente después de crear la variante**, con valores neutros: `net_unit_amount_cents = 0`, `vat_rate = 'general'`, `currency = 'EUR'` (default DDL). El sembrado es **idempotente** (`ON CONFLICT (variant_id) DO NOTHING`) y **best-effort**: si falla, la creación de la variante no se anula (la variante primaria es la prioridad; el precio es secundario).
|
||||
|
||||
## Scope OUT
|
||||
- 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).
|
||||
## Alcance
|
||||
- Backend: `pricing` (nuevo método `seedVariantPrice`) + `catalog` (inyección en `CreateProductVariant` + wiring build-app).
|
||||
- Frontend: N/A (no hay cambios de UI).
|
||||
- Migración: N/A — `pricing_variant_prices.variant_id` ya es UNIQUE/PK (lo demuestra `setVariantPrice` usando `ON CONFLICT (variant_id)`); no se requiere migración ni columna nueva.
|
||||
|
||||
## Type
|
||||
fix — high priority / high risk.
|
||||
## Definición de terminado
|
||||
- [x] `PricingService.seedVariantPrice(variantId)` existe + persiste fila default.
|
||||
- [x] `CreateProductVariant` llama a `seedVariantPrice` tras `variants.create`.
|
||||
- [x] Los 3 call sites dejan fila de precio tras crear variante.
|
||||
- [x] Re-sembrar es no-op (idempotente).
|
||||
- [x] Tests unitarios (sin DB) pasan; itest de AC skipped sin `TEST_DATABASE_URL`.
|
||||
- [x] `npm run typecheck` 0 errores; `npm test` (targeted) verde; `verify.sh` green.
|
||||
|
||||
86
spec/tech.md
86
spec/tech.md
@@ -1,49 +1,51 @@
|
||||
# F-154 — Technical Design
|
||||
# F-138 — Especificación técnica
|
||||
|
||||
## 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).
|
||||
## Contrato de separación (F-154 precedent)
|
||||
- `pricing` **es dueño** de `pricing_variant_prices` (tabla a su módulo). No se inserta desde `catalog` con SQL crudo — se expone un método en el **servicio público** `PricingService` (patrón idéntico al que `cart` ya inyecta en build-app: cross-module write vía servicio público, R1 legal).
|
||||
|
||||
## 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">`.
|
||||
## Cambios
|
||||
|
||||
## 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.
|
||||
### 1. `pricing/domain/ports.ts`
|
||||
- Añadir a `PricingRepository`: `seedVariantPrice(variantId: string): Promise<void>`.
|
||||
- Añadir a `PricingService`: `seedVariantPrice(variantId: string): Promise<void>`.
|
||||
|
||||
## 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.
|
||||
### 2. `pricing/application/pricing-service.ts`
|
||||
- `PricingService.seedVariantPrice(variantId)` → delega a `this.repository.seedVariantPrice(variantId)`. Sin validación extra (el `variantId` ya proviene de una variante creada en la misma transacción lógica).
|
||||
|
||||
## Migration
|
||||
Ninguna. `identity_users.role` ya existe (NOT NULL DEFAULT 'customer').
|
||||
### 3. `pricing/infrastructure/pg-pricing-repository.ts`
|
||||
```ts
|
||||
async seedVariantPrice(variantId: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, offer_cents, cost_cents, vat_rate)
|
||||
VALUES ($1, 0, NULL, NULL, 'general')
|
||||
ON CONFLICT (variant_id) DO NOTHING`,
|
||||
[variantId],
|
||||
);
|
||||
}
|
||||
```
|
||||
- Columnas idénticas al INSERT de `setVariantPrice` (omite `currency` → DDL default `'EUR'`; `created_at`/`updated_at` → `now()` DDL default). Reutiliza `VatRate = 'general'`.
|
||||
- `ON CONFLICT (variant_id)` válido: `variant_id` es UNIQUE/PK (usado por `setVariantPrice`). → **idempotente / re-sembbrado no-op**.
|
||||
|
||||
### 4. `catalog/application/variant-use-cases.ts`
|
||||
- Import (type, público, R1): `import type { PricingService } from '../../pricing/index.js';` (catalog/application → ../../pricing/index = modules/pricing/index ✓ R1 a index público).
|
||||
- `CreateProductVariant` recibe `pricing: PricingService` en ctor.
|
||||
- `execute`: tras `this.variants.create(productId, input)` (y solo si el producto existe), `await this.pricing.seedVariantPrice(variant.id)` **best-effort** (try/catch silencioso: la variante ya persistió; un fallo del seed no revierte la creación).
|
||||
|
||||
### 5. `catalog/api/catalog.routes.ts`
|
||||
- `CatalogRoutesDeps` += `pricing: PricingService` (import type desde pricing index — R1).
|
||||
- `const createVariant = new CreateProductVariant(repository, variants, pricing);` (único constructor; cubre los 3 call sites: POST /products autovariante, GET /products/:id/variants lazy, POST /products/:id/variants).
|
||||
|
||||
### 6. `build-app.ts`
|
||||
- Mover `const pricing = createPricingService(deps.pool);` **antes** del bloque `registerCatalogRoutes` (actualmente está después → orden L247 catalog, L264 pricing). Crear pricing antes del registro de catalog permite pasarlo a `CatalogRoutesDeps`.
|
||||
- Pasar `pricing` en deps de `registerCatalogRoutes`.
|
||||
- La ruta de pricing (`registerPricingRoutes`) y cart siguen usando `pricing` (sin cambios; pricing sigue definido). `createPricingService` ya está importado (L33).
|
||||
|
||||
## Riesgos / mitigaciones
|
||||
- **Orden en build-app (timing):** mover `const pricing` antes de catalog es seguro (constructor puro, `deps.pool` disponible). Precio routes lo vuelve a usar → no se rompe.
|
||||
- **Fallo del seed:** best-effort (try/catch) → no hace 500 en variant creation. AC3 lo prueba.
|
||||
- **Doble creación (createVariant en autovariante + retry):** `ON CONFLICT DO NOTHING` → idempotente. AC2 lo prueba.
|
||||
- **Boundary R1:** catalog→pricing público index ✓ (cart ya lo usa). Deep imports prohibidos — usar `../../pricing/index.js`.
|
||||
|
||||
## 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).
|
||||
- **Unitario (runnable, sin DB):** `catalog/tests/variant-use-cases.test.ts` — FakeProductRepository, FakeProductVariantRepository, FakePricingService; assert (a) seedVariantPrice llamado con variant.id tras create, (b) no se sembran si producto no existe, (c) create sigue devolviendo variante si seed lanza.
|
||||
- **Integración (AC):** itest en `catalog.itest.ts` skipIf(!hasDb) — POST /products/:id/variants → GET /pricing/variants/:id = 200 con `netUnitAmountCents=0`, `vatRate='general'`. Skipped sin `TEST_DATABASE_URL` (no bloquea verify.sh).
|
||||
|
||||
Reference in New Issue
Block a user