Files
mercadodevida/work/artifacts/POS-002/architect.md
2026-08-21 21:55:43 +02:00

181 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# POS-002 — Design: Schema migrations 043046 + OrderState.COMPLETED + Role extensions
**Author:** architect
**Date:** 2026-08-21
**Stage:** design
## Context
Phase 1 (POS-001) defined the POS integration. Phase 2's first ticket (this) lays the schema foundation: 4 migrations, 2 type extensions, 1 inventory service signature change. Everything in `POS-003` through `POS-046` depends on this ticket landing cleanly.
## Decisions locked in (from POS-001 + operator answers)
- Default store name: "Natural - Mercado de Vida" (`00000000-0000-0000-0000-000000000001`).
- Refund window: 15 days, manager > 50 € (configurable; defaults baked into the backend; admin UI follows in POS-023).
- Cookie domain: separate port for now; POS-002 doesn't touch cookies.
- Hardware decisions: out of scope for this ticket.
## Design
### Migration order
The 4 migrations run sequentially. Each is idempotent and reversible where possible:
1. **`043_pos_basics.js`** — create the 6 new `pos_*` tables. Seed the default store and 2 default payment methods. **Independent** of all other migrations.
2. **`044_pos_inventory_store.js`** — add `store_id` to `inventory_stock` and `inventory_movements`. Backfill all rows with the default store UUID (the one seeded in 043). Replace the unique constraint on `inventory_stock` from `(variant_id)` to `(variant_id, store_id)`. **Depends on 043** (needs the default store UUID).
3. **`045_pos_orders_source.js`** — add `source`, `terminal_id`, `cash_session_id` to `orders_orders`. Drop NOT NULL on `user_id`. Add `COMPLETED` to state CHECK. Add indexes. **Independent** of 044 but logically before 046 because role-based features will reference the new `terminal_id`.
4. **`046_pos_backoffice_roles.js`** — extend `backoffice_users.role` CHECK to include `pos_cashier` and `pos_manager`. **Independent** but listed last so the operator sees the "front-office" roles only after the schema is in place.
### Migration 043: `pos_basics`
Six tables, all `IF NOT EXISTS`. The seed runs at the end of the `up()` body (after all tables exist):
- `pos_stores` — primary key on UUID. UNIQUE on `slug`. CHECK on `name` length.
- `pos_terminals` — FK to `pos_stores`. UNIQUE on `binding_code` (nullable, partial). CHECK on `status`, `interface_mode`.
- `pos_payment_methods` — FK to `pos_stores`. UNIQUE on `(store_id, code)`. CHECK on `kind`.
- `pos_quick_products` — FK to `pos_stores`, `pos_terminals` (nullable), `backoffice_users` (nullable), `catalog_product_variants`. UNIQUE NULLS NOT DISTINCT on `(store_id, terminal_id, user_id, variant_id)`.
- `pos_parked_tickets` — FK to `pos_stores`, `pos_terminals`, `backoffice_users`, `identity_users` (nullable). Default `expires_at = now() + interval '24 hours'`.
- `pos_cash_sessions` — FK to `pos_terminals`, `pos_stores`, `backoffice_users`. Partial UNIQUE index `WHERE status = 'OPEN'` ensures one open session per terminal at the DB level.
Seeds:
- One `pos_stores` row with the well-known UUID `00000000-0000-0000-0000-000000000001`.
- Two `pos_payment_methods` rows for the default store: `cash` (Efectivo) and `card` (Tarjeta).
Downs: drop the 6 tables in reverse dependency order. Cascade removes dependent rows.
### Migration 044: `pos_inventory_store`
Steps (in `up()`):
1. `ALTER TABLE inventory_stock ADD COLUMN IF NOT EXISTS store_id uuid;` — nullable initially so backfill doesn't break.
2. `UPDATE inventory_stock SET store_id = '00000000-0000-0000-0000-000000000001' WHERE store_id IS NULL;` — backfill.
3. `ALTER TABLE inventory_stock ALTER COLUMN store_id SET NOT NULL;`
4. `ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_store_id_fkey FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT;`
5. `ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_id_key;`
6. `ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);`
7. Same 4-step pattern for `inventory_movements`.
Downs: drop the FK + new constraint, drop the column. Loses the `store_id` data on rollback (acceptable; this is a Phase 2 migration the operator can roll back).
### Migration 045: `pos_orders_source`
1. Add columns: `source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin'))`, `terminal_id uuid`, `cash_session_id uuid`.
2. Add indexes.
3. `ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL;` — enables walk-in sales.
4. Add `COMPLETED` to the state CHECK by `DROP CONSTRAINT` + `ADD CONSTRAINT`.
5. Add FKs: `terminal_id` and `cash_session_id` reference `pos_terminals` and `pos_cash_sessions` respectively, both `ON DELETE SET NULL` (we don't want deleting a terminal to delete historical sales).
### Migration 046: `pos_backoffice_roles`
Drop the existing role CHECK, add a new one with `pos_cashier` and `pos_manager`.
### TypeScript type extensions
#### `src/shared/auth.ts`
- Add `pos_cashier` and `pos_manager` to `Role`:
```ts
export type Role = 'customer' | 'admin' | 'editor' | 'pos_cashier' | 'pos_manager';
```
- Add `requireAnyRole(user, [...roles])`:
```ts
export function requireAnyRole(user: CurrentUser, roles: ReadonlyArray<Role>): void {
if (!roles.includes(user.role)) {
throw new AppError(403, 'FORBIDDEN', 'Access denied');
}
}
```
- Keep `requireRole` unchanged (backwards compatible).
- Update the existing `tests/auth.test.ts` to cover `requireAnyRole`.
#### `src/modules/orders/domain/order.ts`
- Add `'COMPLETED'` to `OrderState` union.
- Add `COMPLETED: []` to `ALLOWED_TRANSITIONS` (terminal state).
- Verify nothing else needs to change (the state machine validator uses `ALLOWED_TRANSITIONS` to gate transitions).
#### `src/modules/inventory/application/inventory-service.ts` (and ports)
The biggest code change. Add `storeId` parameter to every public inventory method:
```ts
// Before
checkAvailability(variantId: string, quantity: number): Promise<Availability>
reserve(variantId: string, quantity: number): Promise<Reservation>
release(variantId: string, quantity: number): Promise<void>
confirm(variantId: string, quantity: number): Promise<void>
setStock(variantId: string, quantity: number): Promise<{ available: number }>
// After
checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability>
reserve(variantId: string, storeId: string, quantity: number): Promise<Reservation>
release(variantId: string, storeId: string, quantity: number): Promise<void>
confirm(variantId: string, storeId: string, quantity: number): Promise<void>
setStock(variantId: string, storeId: string, quantity: number): Promise<{ available: number }>
```
This is a **breaking change** in the inventory module. The cascading effect:
- `src/modules/checkout/application/checkout-service.ts` calls `inventory.reserve(...)` and `inventory.checkAvailability(...)`. Update both calls to pass `DEFAULT_STORE_ID`.
- `src/modules/inventory/api/inventory.routes.ts` accepts `?storeId=...` query param (defaulting to `DEFAULT_STORE_ID` if absent). The existing `setStock` route gains the param.
- All itest tests that call inventory methods are updated.
#### Default store constant
A new constant in `src/modules/inventory/domain/ports.ts` (or a sibling module) — the well-known UUID `00000000-0000-0000-0000-000000000001`. Single source of truth; both the migration and the seed flow reference it. Kept as `as const` so it's typed as a literal UUID.
### Files affected (summary)
| Path | Change |
|---|---|
| `project/migrations/043_pos_basics.js` | NEW — 6 tables + seed |
| `project/migrations/044_pos_inventory_store.js` | NEW — inventory_store_id columns + backfill + new constraint |
| `project/migrations/045_pos_orders_source.js` | NEW — source column, terminal/cash_session FKs, COMPLETED state |
| `project/migrations/046_pos_backoffice_roles.js` | NEW — role CHECK extension |
| `project/src/shared/auth.ts` | extend `Role`, add `requireAnyRole` |
| `project/src/shared/tests/auth.test.ts` | cover `requireAnyRole` |
| `project/src/modules/orders/domain/order.ts` | add `'COMPLETED'` to `OrderState`, `ALLOWED_TRANSITIONS` |
| `project/src/modules/orders/tests/order-state-machine.test.ts` | cover `COMPLETED` transitions |
| `project/src/modules/inventory/application/inventory-service.ts` | add `storeId` parameter to all 5 public methods |
| `project/src/modules/inventory/domain/ports.ts` | add `storeId` to all 5 method signatures |
| `project/src/modules/inventory/api/inventory.routes.ts` | accept `?storeId=` on GET endpoints; require `storeId` on PUT |
| `project/src/modules/inventory/infrastructure/pg-inventory-repository.ts` | thread `store_id` into SQL |
| `project/src/modules/checkout/application/checkout-service.ts` | pass `DEFAULT_STORE_ID` to inventory calls |
| `project/src/modules/inventory/index.ts` | export `DEFAULT_STORE_ID` |
| `project/src/app/tests/*.itest.ts` | update tests that call inventory methods |
### Tests
- `project/src/shared/tests/auth.test.ts` — new test block for `requireAnyRole`.
- `project/src/modules/orders/tests/order-state-machine.test.ts` — new test block for `COMPLETED` (terminal state, no outgoing transitions).
- `project/src/modules/inventory/tests/*.test.ts` — update existing tests to pass `storeId`; add new tests for store-scoped queries.
- Migration idempotency: each migration's `up()` is re-runnable; `down()` is symmetrical.
### Out of scope
- No new endpoints. POS-004 introduces `/pos/*` routes.
- No new module. `project/src/modules/pos/**` is created in POS-003.
- No frontend work. POS-006 starts `apps/pos/`.
- No store_settings defaults for refund window. That's POS-023 (admin UI) and a follow-up migration in POS-013.
## Acceptance criteria
- AC-1. All 4 migrations apply cleanly on a fresh DB (`dropdb && createdb && npm run db:migrate`).
- AC-2. All 4 migrations apply cleanly on the current dev DB.
- AC-3. `SELECT COUNT(*) FROM pos_stores` = 1 (seed).
- AC-4. `SELECT COUNT(*) FROM pos_payment_methods WHERE store_id = '00000000-0000-0000-0000-000000000001'` = 2 (cash + card).
- AC-5. `SELECT COUNT(*) FROM inventory_stock WHERE store_id IS NULL` = 0 (backfill).
- AC-6. `SELECT COUNT(*) FROM inventory_stock` matches the pre-migration count (no rows lost).
- AC-7. `INSERT INTO inventory_stock (variant_id, store_id, available) VALUES ('x', '00000000-0000-0000-0000-000000000001', 0)` succeeds; second insert with same `(variant_id, store_id)` fails with unique violation.
- AC-8. `INSERT INTO orders_orders (user_id, source) VALUES (NULL, 'pos')` succeeds; `INSERT INTO orders_orders (user_id, source) VALUES (NULL, 'ecommerce')` fails.
- AC-9. `INSERT INTO orders_orders (state, source) VALUES ('COMPLETED', 'pos')` succeeds.
- AC-10. `INSERT INTO orders_orders (state, source) VALUES ('COMPLETED', 'ecommerce')` succeeds (state is allowed for any source).
- AC-11. `INSERT INTO backoffice_users (email, password_hash, role) VALUES ('a@b.c', 'x', 'pos_cashier')` succeeds.
- AC-12. `INSERT INTO backoffice_users (email, password_hash, role) VALUES ('a@b.c', 'x', 'pos_owner')` fails with CHECK violation.
- AC-13. `requireAnyRole({ role: 'pos_cashier' }, ['pos_cashier','pos_manager'])` does not throw.
- AC-14. `requireAnyRole({ role: 'customer' }, ['pos_cashier','pos_manager'])` throws `AppError(403)`.
- AC-15. `inventory.reserve('variant-uuid', 'default-store-uuid', 1)` succeeds; existing tests pass after the signature change.
- AC-16. `verify.sh` exit 0.
- AC-17. Re-running each migration is a no-op (`IF NOT EXISTS` clauses + WHERE filters).