feat(F-154): completed feature
This commit is contained in:
@@ -6425,13 +6425,15 @@
|
|||||||
"description": "Fix admin Users and Customers modules so Customers shows only storefront customers and Users shows only internal/backoffice users, avoiding mixed identity/backoffice concepts.",
|
"description": "Fix admin Users and Customers modules so Customers shows only storefront customers and Users shows only internal/backoffice users, avoiding mixed identity/backoffice concepts.",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "high",
|
"risk": "high",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-21",
|
"created_at": "2026-08-21",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T06:35:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-155",
|
"id": "F-155",
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function AdminUsersPage() {
|
|||||||
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
|
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
|
||||||
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||||
<option value="">Todos los roles</option>
|
<option value="">Todos los roles</option>
|
||||||
<option value="admin">Admin</option><option value="editor">Editor</option><option value="customer">Customer</option>
|
<option value="admin">Admin</option><option value="editor">Editor</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
|||||||
});
|
});
|
||||||
expect(asAdmin.statusCode).toBe(200);
|
expect(asAdmin.statusCode).toBe(200);
|
||||||
const body = asAdmin.json() as { items: Array<{ id: string }> };
|
const body = asAdmin.json() as { items: Array<{ id: string }> };
|
||||||
expect(body.items.some((item) => item.id === anaId)).toBe(true);
|
// F-154: /users now serves storefront customers only (role 'customer').
|
||||||
|
// ana was promoted to 'admin' -> no longer a customer -> excluded from the list.
|
||||||
|
// ben is a storefront customer -> included.
|
||||||
|
expect(body.items.some((item) => item.id === benId)).toBe(true);
|
||||||
|
expect(body.items.some((item) => item.id === anaId)).toBe(false);
|
||||||
|
|
||||||
// Admin can also read another user's profile (owner-or-admin).
|
// Admin can also read another user's profile (owner-or-admin).
|
||||||
const adminReadsBen = await app.inject({
|
const adminReadsBen = await app.inject({
|
||||||
|
|||||||
110
project/src/modules/security/api/security.routes.test.ts
Normal file
110
project/src/modules/security/api/security.routes.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { registerSecurityRoutes } from './security.routes.js';
|
||||||
|
import type { SecurityRoutesDeps } from '../index.js';
|
||||||
|
|
||||||
|
interface QueryResult {
|
||||||
|
rows: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** F-154: mock pg.Pool returning configurable rows keyed by SQL family. */
|
||||||
|
function mockPool(countRows: unknown[], selectRows: unknown[]) {
|
||||||
|
const query = vi.fn(async (sql: string, _params: unknown[]): Promise<QueryResult> => {
|
||||||
|
if (String(sql).includes('COUNT')) return { rows: countRows };
|
||||||
|
return { rows: selectRows };
|
||||||
|
});
|
||||||
|
const pool = { query } as unknown as import('pg').Pool;
|
||||||
|
return { pool, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: FastifyInstance[] = [];
|
||||||
|
|
||||||
|
/** Builds a minimal Fastify app with security routes + mocked deps (no DB). */
|
||||||
|
async function buildAdminApp(pool: unknown) {
|
||||||
|
const app = Fastify();
|
||||||
|
created.push(app);
|
||||||
|
const deps = {
|
||||||
|
pool,
|
||||||
|
authenticate: vi.fn().mockResolvedValue({ id: 'a1', role: 'admin' }),
|
||||||
|
rateLimiter: vi.fn(),
|
||||||
|
auditLogger: vi.fn(),
|
||||||
|
} as unknown as SecurityRoutesDeps;
|
||||||
|
await registerSecurityRoutes(app, deps);
|
||||||
|
await app.ready();
|
||||||
|
const query = (pool as { query: ReturnType<typeof vi.fn> }).query;
|
||||||
|
return { app, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_USER = {
|
||||||
|
id: 'a1',
|
||||||
|
email: 'ana@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
const EDITOR_USER = {
|
||||||
|
id: 'e1',
|
||||||
|
email: 'e@example.com',
|
||||||
|
role: 'editor',
|
||||||
|
created_at: new Date('2026-01-02T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const app of created) {
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/users — F-154 internal-only by default', () => {
|
||||||
|
it('returns only internal/backoffice users and never storefront customers', async () => {
|
||||||
|
const { app, query } = await buildAdminApp(
|
||||||
|
mockPool([{ count: '1' }], [ADMIN_USER, EDITOR_USER]),
|
||||||
|
);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/admin/users' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body ?? '') as {
|
||||||
|
items: Array<{ id: string; role: string }>;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
expect(body.items).toHaveLength(2);
|
||||||
|
expect(body.items.every((u) => u.role !== 'customer')).toBe(true);
|
||||||
|
expect(body.items.map((u) => u.id)).toEqual(['a1', 'e1']);
|
||||||
|
expect(body.total).toBe(1);
|
||||||
|
|
||||||
|
const selectSql = query.mock.calls[1]![0] as string;
|
||||||
|
expect(selectSql).toContain("role <> 'customer'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows with ?role=admin within staff (parametrized)', async () => {
|
||||||
|
const { app, query } = await buildAdminApp(mockPool([{ count: '1' }], [ADMIN_USER]));
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/admin/users?role=admin' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body ?? '') as { items: Array<{ id: string }> };
|
||||||
|
expect(body.items).toHaveLength(1);
|
||||||
|
expect(body.items[0]!.id).toBe('a1');
|
||||||
|
|
||||||
|
const selectSql = query.mock.calls[1]![0] as string;
|
||||||
|
expect(selectSql).toContain("role <> 'customer'");
|
||||||
|
expect(selectSql).toContain('role = $1');
|
||||||
|
expect(query.mock.calls[1]![1]).toContain('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields an empty set (never customers) when ?role=customer is requested', async () => {
|
||||||
|
const { app, query } = await buildAdminApp(mockPool([{ count: '0' }], []));
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/admin/users?role=customer' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body ?? '') as { items: Array<{ id: string }> };
|
||||||
|
expect(body.items).toHaveLength(0);
|
||||||
|
|
||||||
|
const selectSql = query.mock.calls[1]![0] as string;
|
||||||
|
expect(selectSql).toContain("role <> 'customer'");
|
||||||
|
expect(selectSql).toContain('role = $1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -189,7 +189,9 @@ export async function registerSecurityRoutes(
|
|||||||
const admin = await deps.authenticate(request);
|
const admin = await deps.authenticate(request);
|
||||||
requireRole(admin, 'admin');
|
requireRole(admin, 'admin');
|
||||||
const { limit, offset, role, q } = parseJson(userQuerySchema, request.query ?? {});
|
const { limit, offset, role, q } = parseJson(userQuerySchema, request.query ?? {});
|
||||||
const conditions: string[] = [];
|
// F-154: Users module == internal/backoffice only (never storefront customers).
|
||||||
|
// Base literal excludes customers; ?role= then narrows within internal staff.
|
||||||
|
const conditions: string[] = ["role <> 'customer'"];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
let i = 1;
|
let i = 1;
|
||||||
if (role) {
|
if (role) {
|
||||||
@@ -200,7 +202,7 @@ export async function registerSecurityRoutes(
|
|||||||
conditions.push(`(email ILIKE $${i++})`);
|
conditions.push(`(email ILIKE $${i++})`);
|
||||||
values.push(`%${q}%`);
|
values.push(`%${q}%`);
|
||||||
}
|
}
|
||||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||||
const countResult = await deps.pool.query<{ count: string }>(
|
const countResult = await deps.pool.query<{ count: string }>(
|
||||||
`SELECT COUNT(*) FROM identity_users ${where}`,
|
`SELECT COUNT(*) FROM identity_users ${where}`,
|
||||||
values,
|
values,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PgProfileRepository } from './pg-profile-repository.js';
|
||||||
|
|
||||||
|
interface QueryResult {
|
||||||
|
rows: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** F-154: mock pg.Pool returning configurable rows keyed by SQL family. */
|
||||||
|
function mockPool(countRows: unknown[], selectRows: unknown[]) {
|
||||||
|
const query = vi.fn(async (sql: string, _params: unknown[]): Promise<QueryResult> => {
|
||||||
|
if (String(sql).includes('COUNT')) return { rows: countRows };
|
||||||
|
return { rows: selectRows };
|
||||||
|
});
|
||||||
|
const pool = { query } as unknown as import('pg').Pool;
|
||||||
|
return { pool, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOMER_ROW = {
|
||||||
|
user_id: 'c1',
|
||||||
|
email: 'ben@example.com',
|
||||||
|
role: 'customer',
|
||||||
|
display_name: 'Ben',
|
||||||
|
phone: '111',
|
||||||
|
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PgProfileRepository.listCustomers — F-154 customers-only', () => {
|
||||||
|
it('always restricts the list query to role = customer', async () => {
|
||||||
|
const { pool, query } = mockPool([{ cnt: '1' }], [CUSTOMER_ROW]);
|
||||||
|
const repo = new PgProfileRepository(pool);
|
||||||
|
|
||||||
|
const result = await repo.listCustomers({ offset: 0, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.role).toBe('customer');
|
||||||
|
expect(result.items[0]!.email).toBe('ben@example.com');
|
||||||
|
|
||||||
|
const selectSql = query.mock.calls[1]![0] as string;
|
||||||
|
expect(selectSql).toContain("iu.role = 'customer'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the customer filter to the count query too', async () => {
|
||||||
|
const { pool, query } = mockPool([{ cnt: '1' }], [CUSTOMER_ROW]);
|
||||||
|
const repo = new PgProfileRepository(pool);
|
||||||
|
|
||||||
|
await repo.listCustomers({ offset: 0, limit: 20 });
|
||||||
|
|
||||||
|
const countSql = query.mock.calls[0]![0] as string;
|
||||||
|
expect(countSql).toContain('COUNT');
|
||||||
|
expect(countSql).toContain("iu.role = 'customer'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the email search parameterized alongside the role filter', async () => {
|
||||||
|
const { pool, query } = mockPool([{ cnt: '1' }], [CUSTOMER_ROW]);
|
||||||
|
const repo = new PgProfileRepository(pool);
|
||||||
|
|
||||||
|
await repo.listCustomers({ q: 'ben', offset: 0, limit: 20 });
|
||||||
|
|
||||||
|
const selectSql = query.mock.calls[1]![0] as string;
|
||||||
|
const params = query.mock.calls[1]![1] as unknown[];
|
||||||
|
expect(selectSql).toContain("iu.role = 'customer'");
|
||||||
|
expect(selectSql).toContain('ILIKE $1');
|
||||||
|
expect(params[0]).toBe('%ben%');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,10 +94,13 @@ export class PgProfileRepository implements ProfileRepository {
|
|||||||
async listCustomers(opts: CustomerListOptions): Promise<CustomerListResult> {
|
async listCustomers(opts: CustomerListOptions): Promise<CustomerListResult> {
|
||||||
const searchFilter = opts.q ? `%${opts.q}%` : null;
|
const searchFilter = opts.q ? `%${opts.q}%` : null;
|
||||||
|
|
||||||
|
// F-154: Customers endpoint serves storefront customers only (role 'customer').
|
||||||
|
// role is a code literal (DB column compared to a constant), not user input,
|
||||||
|
// so it does not affect the parameterized $N index of the email search.
|
||||||
const countResult = await this.pool.query<{ cnt: string }>(
|
const countResult = await this.pool.query<{ cnt: string }>(
|
||||||
`SELECT COUNT(*)::text AS cnt
|
`SELECT COUNT(*)::text AS cnt
|
||||||
FROM identity_users iu
|
FROM identity_users iu
|
||||||
WHERE ($1::text IS NULL OR iu.email ILIKE $1)`,
|
WHERE iu.role = 'customer' AND ($1::text IS NULL OR iu.email ILIKE $1)`,
|
||||||
[searchFilter],
|
[searchFilter],
|
||||||
);
|
);
|
||||||
const total = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
|
const total = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
|
||||||
@@ -107,7 +110,7 @@ export class PgProfileRepository implements ProfileRepository {
|
|||||||
up.display_name, up.phone, iu.created_at
|
up.display_name, up.phone, iu.created_at
|
||||||
FROM identity_users iu
|
FROM identity_users iu
|
||||||
LEFT JOIN users_profiles up ON up.user_id = iu.id
|
LEFT JOIN users_profiles up ON up.user_id = iu.id
|
||||||
WHERE ($1::text IS NULL OR iu.email ILIKE $1)
|
WHERE iu.role = 'customer' AND ($1::text IS NULL OR iu.email ILIKE $1)
|
||||||
ORDER BY iu.created_at DESC
|
ORDER BY iu.created_at DESC
|
||||||
LIMIT $2 OFFSET $3`,
|
LIMIT $2 OFFSET $3`,
|
||||||
[searchFilter, opts.limit, opts.offset],
|
[searchFilter, opts.limit, opts.offset],
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
# F-153 — Acceptance Criteria
|
# F-154 — Acceptance Criteria
|
||||||
|
|
||||||
## AC1 — Order detail exposes customer email
|
## AC1 — Customers list shows only storefront customers
|
||||||
`GET /orders/:id` and `GET /orders/:id/admin` responses include a top-level `email` field equal
|
`GET /users` (admin) devuelve SOLO usuarios con `role = 'customer'`. Un usuario
|
||||||
to the linked `identity_users.email`. When the order has no linked identity_user, `email` is
|
interno (admin/editor) NO aparece en el listado. El buscador `q` sigue filtrando sobre email
|
||||||
`null`.
|
dentro de los clientes.
|
||||||
|
|
||||||
## AC2 — Admin order list exposes customer email
|
## AC2 — Users list shows only internal/backoffice users
|
||||||
Every item in `GET /orders` (admin list) includes the `email` field, resolved via the same
|
`GET /admin/users` (admin, default sin `?role=`) devuelve SOLO usuarios con
|
||||||
read-model association (no N+1 per item beyond the repository's single read).
|
`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
|
## AC3 — No regression on user profile / addresses
|
||||||
`POST /orders/:id/transitions/admin` resolves the customer email from the order view
|
`/users/:id` (GET/PATCH) owner-or-admin sigue devolviendo/editando CUALQUIER usuario
|
||||||
(`order.email`) — it no longer issues a separate inline `SELECT email FROM identity_users`.
|
sin filtro por rol (admin ve perfil de cliente; cliente ve el suyo). CRUD de
|
||||||
When `email` is present, the status notification is sent; when absent, it logs
|
`/users/:id/addresses` inalterado.
|
||||||
"El cliente no tiene email asociado" via `request.log.warn` and still completes the transition.
|
|
||||||
|
|
||||||
## AC4 — No regression
|
## AC4 — No boundary / injection violation
|
||||||
All existing order flows keep their behavior (create, customer/admin detail, list, edit items,
|
- `identity_users` referenciado solo como tabla SQL (sin import TS).
|
||||||
shipping update, transitions). Only `email` is added to serialization; no new state transitions,
|
- Valores `q`/`role` parametrizados; el literal `'customer'`/`'customer'` es constante de código.
|
||||||
endpoints, or side effects.
|
- Sin migración.
|
||||||
|
|
||||||
## AC5 — No migration
|
## AC5 — Quality gates
|
||||||
`email` is derived from the pre-existing `identity_users.email` column; no schema migration is
|
- `tsc --noEmit` (API) 0 errores; `npx tsc --noEmit` (apps/admin) sin errores nuevos.
|
||||||
required.
|
- `npm run lint:boundaries` sin violaciones nuevas.
|
||||||
|
- `vitest run` (sin DB) → suite nueva F-154 + suite existente en verde.
|
||||||
## AC6 — Quality gates
|
- `verify.sh` exit 0 (backlog F-154 in_progress, runtime stage válido).
|
||||||
- `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).
|
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
# F-153 — Product Spec
|
# F-154 — Admin: separate customers from internal users
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
The order read model and its serialization (`serializeOrder`) do **not** expose
|
El panel admin muestra usuarios mezclados. `GET /users` (módulo `users`) devuelve
|
||||||
the linked customer's email. `orders_orders.user_id` references `identity_users`
|
TODOS los identity_users (clientes + backoffice) y `GET /admin/users` (módulo `security`)
|
||||||
(whose `email citext NOT NULL UNIQUE` always exists), but the order view carries
|
por defecto también devuelve todos. La página Customers llama a `/api/users` y la página
|
||||||
only `userId` — never the email. Consequence:
|
Users llama a `/api/admin/users`; como ambos devuelven todo, ambos listados aparecen
|
||||||
- Order detail (`/orders/:id`, `/orders/:id/admin`) and the admin order list
|
mezclados (conceptos de identity/storefront con backoffice en un mismo listado).
|
||||||
(`/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.
|
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Associate the linked customer's email to the **order read model** and display it in
|
Customers muestra SOLO clientes storefront (`role = 'customer'`); Users muestra SOLO
|
||||||
serialization — detail, admin list, and the admin force-transition notification —
|
usuarios internos/backoffice (`role != 'customer'`). Separación forzada en el backend
|
||||||
using the order view as the single source of truth.
|
(single source of truth), no solo filtrado cliente.
|
||||||
|
|
||||||
## Scope IN
|
## Scope IN
|
||||||
- `orders/domain`: add `email` to `OrderView` (read model).
|
- `project/src/modules/users` (`listCustomers` / `GET /users`): filtrar `role = 'customer'`.
|
||||||
- `orders/infrastructure` (pg-order-repository): JOIN `identity_users` to resolve
|
- `project/src/modules/security` (`GET /admin/users`): default `role != 'customer'`;
|
||||||
`email` on every order read (`findById`, `findByIdAndUserId`, `findAll`, `search`).
|
`?role=admin|editor` sigue afinando dentro de internos.
|
||||||
- `orders/api` (orders.routes): surface `email` in `serializeOrder` and consume
|
- `project/apps/admin/.../users/page.tsx`: quitar opción `customer` del dropdown (Users = backoffice).
|
||||||
`order.email` in the admin force-transition notification (removing the inline lookup).
|
- Tests unitarios (mock pool, sin DB) + actualizar itest AC2/AC3.
|
||||||
|
|
||||||
## Scope OUT
|
## Scope OUT
|
||||||
- No changes to the `identity` domain (no TS cross-import).
|
- No se crea `/customers` (el cliente ya consume `/users`).
|
||||||
- No new tables / migrations: `identity_users.email` already exists and is NOT NULL.
|
- `/users/:id`, `/users/:id/addresses` (owner-or-admin) siguen sin filtro por rol (un admin
|
||||||
- No new endpoints; no auth/RBAC change; no order state machine change.
|
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
|
## Type
|
||||||
- Priority: high. Risk: med (additive read-model field; backward compatible).
|
fix — high priority / high risk.
|
||||||
|
|||||||
86
spec/tech.md
86
spec/tech.md
@@ -1,49 +1,49 @@
|
|||||||
# F-153 — Tech Spec
|
# F-154 — Technical Design
|
||||||
|
|
||||||
## Principles
|
## Context
|
||||||
- Associate the customer email to the order **read model** (not a per-request hack):
|
- `identity_users` tiene `role: citext NOT NULL DEFAULT 'customer'` (valores: `customer`,
|
||||||
`OrderView.email` is resolved once by the orders repository via a `LEFT JOIN identity_users`.
|
`admin`, `editor`, `pos_cashier`, `pos_manager`). `customer` = storefront; el resto = backoffice.
|
||||||
- Reuse the existing SQL pattern: `orders/infrastructure/pg-order-repository.ts` `search`
|
- `GET /users` (módulo `users`): `PgProfileRepository.listCustomers` hace
|
||||||
already does `LEFT JOIN identity_users u ON u.id = o.user_id` — F-153 extends that to every
|
`SELECT ... FROM identity_users iu LEFT JOIN users_profiles up ... WHERE ($1::text IS NULL OR iu.email ILIKE $1)`.
|
||||||
order read so the email is always available on the view.
|
Devuelve TODO. Usado por `clientsApi.list` (página Customers) → `/api/users`.
|
||||||
- Boundaries: `identity_users` is referenced only as a **SQL table name** (pre-existing in
|
- `GET /admin/users` (módulo `security`): query inline con condiciones opcionales `role` y `q`.
|
||||||
`search`); no TypeScript import crosses the identity/orders boundary. `identity` does not
|
Sin `?role=` devuelve TODO. Usado por `adminUsersApi.list` (página Users) → `/api/admin/users`.
|
||||||
import orders; orders references `identity_users` table name (string) at infrastructure.
|
- `listCustomers` se consume SOLO en `users.routes.ts` (`/users`). `findCustomerById`
|
||||||
- NoUncheckedIndexedAccess is ON → index access returns `T | undefined`; use `!` or `?? null`
|
(single, `/users/:id`) es role-agnostic (owner-or-admin) → no cambia.
|
||||||
when mapping rows.
|
- No existe test de `users`/`security` routes; `users.itest.ts` AC2/AC3 asocia al admin (ana)
|
||||||
- Backward compatible: `email` is an additive field on the serialized output; no state
|
al listado `/users` (true hoy porque /users devuelve todo; romperá si /users es customer-only).
|
||||||
transition, no new migration, no endpoint change.
|
|
||||||
|
|
||||||
## 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
|
## Alternatives
|
||||||
- `orders/domain/order.ts`: add `email: string | null;` to `OrderView` (`Order` itself unchanged —
|
- Filtrado cliente-only: rechazado. El backend es la fuente única de verdad; el cliente no
|
||||||
email is a denormalized read-model attribute, not a core domain field).
|
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
|
## Boundary / Security
|
||||||
- `orders/infrastructure/pg-order-repository.ts`:
|
- `users` módulo referencia `identity_users` SOLO como nombre de tabla SQL (patrón ya usado en
|
||||||
- Add `email: string | null` to `OrderRow`.
|
`search`); sin import TS users↔security. `lint:boundaries` sin cambios nuevos.
|
||||||
- Add helper `toOrderView(row, items): OrderView = { ...toOrder(row), email: row.email, items }`.
|
- `role` proviene de la DB (no user input directo en el filtro de roles; el literal `'customer'`/`'customer'`
|
||||||
- `SELECT o.*` → `SELECT o.*, u.email` with `LEFT JOIN identity_users u ON u.id = o.user_id`
|
está en código). En `/admin/users`, `?role=` validado por zod enum `['customer','editor','admin']`.
|
||||||
in `findById`, `findByIdAndUserId`, `findAll`, `search`.
|
- Sin inyección: los valores user input (`q`, `role`) siguen parametrizados (`$N`); los literales
|
||||||
- Build every returned `OrderView` via `toOrderView` (so `email` is always set; `null` when the
|
`role = 'customer'` / `role <> 'customer'` son constantes de código.
|
||||||
order has no linked identity_user).
|
|
||||||
|
|
||||||
### 3. API — expose + consume email
|
## Migration
|
||||||
- `orders/api/orders.routes.ts`:
|
Ninguna. `identity_users.role` ya existe (NOT NULL DEFAULT 'customer').
|
||||||
- `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).
|
|
||||||
|
|
||||||
## Testing
|
## Tests
|
||||||
- New `orders/infrastructure/pg-order-repository.test.ts`: mock `pg.Pool`, assert `findById`
|
- `pg-profile-repository.test.ts` (mock pool): `listCustomers` emite `iu.role = 'customer'`,
|
||||||
returns `email` from the JOIN when the linked identity_user has one, and `null` when there is
|
`q` filtra sobre email, COUNT y SELECT coinciden, returns solo filas customer.
|
||||||
no linked user.
|
- `security.routes.test.ts` (mock app+deps): `/admin/users` default → `role <> 'customer'`;
|
||||||
- Update existing `OrderView` literals in tests (`order-service`, `payments-service`,
|
`?role=admin` → `role <> 'customer' AND role = $1`; respuesta items internos.
|
||||||
`checkout-service`) to include `email: null` (additive field).
|
- `users.itest.ts` AC2/AC3: actualizar aserción — `/users` devuelve customer (ben) no admin (ana).
|
||||||
- `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.
|
|
||||||
|
|||||||
54
work/artifacts/F-154/architect.md
Normal file
54
work/artifacts/F-154/architect.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# F-154 — Architect Evidence
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
F-154 — Admin: separate customers from internal users (fix, high/high).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Two admin list endpoints both returned **all** `identity_users`, so the Customers and Users
|
||||||
|
pages showed storefront customers and backoffice staff mixed together:
|
||||||
|
- `GET /users` (module `users`, `PgProfileRepository.listCustomers`) → all users, no role filter.
|
||||||
|
Consumed by the Customers page (`customersApi.list` → `/api/users`).
|
||||||
|
- `GET /admin/users` (module `security`) → all users unless `?role=` supplied.
|
||||||
|
Consumed by the Users page (`adminUsersApi.list` → `/api/admin/users`).
|
||||||
|
|
||||||
|
## Roles (DB `identity_users.role`, `citext NOT NULL DEFAULT 'customer'`)
|
||||||
|
- `customer` → storefront client.
|
||||||
|
- `admin` / `editor` / `pos_cashier` / `pos_manager` → internal / backoffice staff.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Force the separation in the **backend** (single source of truth), not client-side:
|
||||||
|
1. `listCustomers` (→ `GET /users`) now always appends `AND iu.role = 'customer'` (code literal,
|
||||||
|
no user input → no injection). The `?q` search still applies on `email`. Single-user
|
||||||
|
`findCustomerById` (`/users/:id`) is untouched (owner-or-admin, role-agnostic).
|
||||||
|
2. `GET /admin/users`: base condition `role <> 'customer'` (literal) so the Users list NEVER
|
||||||
|
returns storefront customers; `?role=admin|editor` still narrows within internal staff.
|
||||||
|
`?role=customer` resolves to an empty intersection (still no leak).
|
||||||
|
3. Frontend: remove the `customer` option from the Users page role dropdown (UX polish — backend
|
||||||
|
already enforces the boundary). Customers page unchanged (already calls `/users`).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- Client-side filtering only: rejected — backend is the trust boundary; the admin API must
|
||||||
|
not leak customers through `/admin/users`.
|
||||||
|
- New `/customers` endpoint: rejected — the frontend already binds Customers↔`/users` and
|
||||||
|
Users↔`/admin/users`; splitting now duplicates effort with no behavioral gain.
|
||||||
|
|
||||||
|
## Boundary / security
|
||||||
|
- `users` module references `identity_users` only as a SQL table name (existing pattern in
|
||||||
|
`search`); no TypeScript import crosses the identity↔users↔security boundary.
|
||||||
|
`lint:boundaries` unaffected.
|
||||||
|
- No migration (`identity_users.role` already exists, NOT NULL DEFAULT 'customer').
|
||||||
|
- User input (`q`, `role`) stays parameterized (`$N`); the role constants are code literals.
|
||||||
|
|
||||||
|
## Test plan (no DB required → runs in `npm test`)
|
||||||
|
- `users/infrastructure/pg-profile-repository.test.ts`: mock `pg.Pool`, assert `listCustomers`
|
||||||
|
emits `iu.role = 'customer'`, respects `q`, count === select filter, returns only customer rows.
|
||||||
|
- `security/api/security.routes.test.ts`: register routes on a mock Fastify with a mock pool +
|
||||||
|
mocked `authenticate` returning `admin`; assert `GET /admin/users` queries `role <> 'customer'`
|
||||||
|
and that `?role=admin` adds `AND role = $1`; response excludes `customer` rows.
|
||||||
|
- `app/tests/users.itest.ts` AC2/AC3: update assertion — `/users` returns the customer (ben),
|
||||||
|
not the promoted admin (ana).
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
Medium-high: changes admin list semantics (`/users` now customer-only). Mitigations:
|
||||||
|
`/users/:id` (single) unchanged; only the LIST contract changes; existing itest updated;
|
||||||
|
no migration; boundary/lint verified.
|
||||||
21
work/artifacts/F-154/documenter.md
Normal file
21
work/artifacts/F-154/documenter.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# F-154 — Documentation
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Separated storefront customers from internal/backoffice users in the admin panel.
|
||||||
|
|
||||||
|
## API behavior change
|
||||||
|
- `GET /users` (admin): now returns **storefront customers only** (`identity_users.role = 'customer'`).
|
||||||
|
Previously returned all users. Used by the Customers page (`/api/users`).
|
||||||
|
- `GET /admin/users` (admin): now returns **internal/backoffice users only** (`role <> 'customer'`),
|
||||||
|
narrowed by optional `?role=admin|editor`. Previously returned all users when no `?role=`.
|
||||||
|
`?role=customer` returns an empty list (never leaks customers). Used by the Users page (`/api/admin/users`).
|
||||||
|
- No change to `GET /users/:id`, `PATCH /users/:id`, `/users/:id/addresses*`, or `/admin/users/:id`.
|
||||||
|
Response shapes unchanged (`CustomerSummary` and admin user list both include `id, email, role, createdAt`).
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- Users page: removed the "Customer" option from the role filter dropdown (backend already
|
||||||
|
enforces internal-only; the page lists `/admin/users` staff). Customers page unchanged.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No migration (`identity_users.role` already exists).
|
||||||
|
- The role filter is a code constant (SQL literal), not user input — no injection surface.
|
||||||
44
work/artifacts/F-154/implementer.md
Normal file
44
work/artifacts/F-154/implementer.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# F-154 — Implementer Evidence
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
F-154 — Admin: separate customers from internal users (fix, high/high).
|
||||||
|
|
||||||
|
## Changes (all under project/)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. `users/infrastructure/pg-profile-repository.ts` (`listCustomers` → `GET /users`):
|
||||||
|
Added literal `iu.role = 'customer' AND` to both the COUNT and SELECT WHERE clauses.
|
||||||
|
Parameter indices unchanged (`$1` = email search, `$2` = limit, `$3` = offset) since the
|
||||||
|
role filter is a code constant, not a bound parameter (no injection). `findCustomerById`
|
||||||
|
(`/users/:id`) is untouched (owner-or-admin, role-agnostic).
|
||||||
|
2. `security/api/security.routes.ts` (`GET /admin/users`):
|
||||||
|
Base condition `["role <> 'customer'"]` so the Users list NEVER returns storefront
|
||||||
|
customers. `?role=admin|editor` still narrows within internal staff via `role = $N`.
|
||||||
|
`?role=customer` resolves to an empty intersection (no leak). `where` is always present.
|
||||||
|
|
||||||
|
### Frontend (apps/admin)
|
||||||
|
- `(dashboard)/users/page.tsx`: removed the `customer` option from the Users role filter
|
||||||
|
dropdown. The Customers page is unchanged (already calls `/users`, now customer-only).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `users/infrastructure/pg-profile-repository.test.ts` (NEW, 3 tests): mock pool —
|
||||||
|
`listCustomers` emits `iu.role = 'customer'` on SELECT+COUNT; `q` stays parametrized as `$1`.
|
||||||
|
- `security/api/security.routes.test.ts` (NEW, 3 tests): mock Fastify + mock deps —
|
||||||
|
`/admin/users` default → `role <> 'customer'`; `?role=admin` → adds `role = $1` with param;
|
||||||
|
`?role=customer` → empty (intersection), never returns customers.
|
||||||
|
- `app/tests/users.itest.ts` AC2/AC3: flipped — `/users` returns the customer (ben), not the
|
||||||
|
promoted admin (ana). (DB itest, skipped without TEST_DATABASE_URL.)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `npx vitest run <2 new files>` → 6/6 pass (re-run fresh for this session).
|
||||||
|
- `npm test` → 206 passed / 56 skipped (itests), 0 failures, no regressions.
|
||||||
|
- `npm run typecheck` → 0 errors.
|
||||||
|
- `cd project/apps/admin && npx tsc --noEmit` → 0 errors.
|
||||||
|
- `npm run lint` → no errors/warnings in files touched.
|
||||||
|
- `npm run lint:boundaries` → no NEW violation (R1 on security.routes.ts → log-broadcaster
|
||||||
|
is pre-existing: git diff shows the import is untouched by F-154).
|
||||||
|
- `./scripts/verify.sh` → exit 0.
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
Medium-high: list semantics change. Mitigated by: single-user `/users/:id` unchanged,
|
||||||
|
no migration (`identity_users.role` already exists), tsc + lint + tests green, itest updated.
|
||||||
13
work/artifacts/F-154/leader-close.json
Normal file
13
work/artifacts/F-154/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-154",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-154 completed: Customers (/users) now customer-only and Users (/admin/users) internal-only — backend-enforced role separation, frontend dropdown cleaned. 6 new unit tests + itest flip; full suite green; tsc/lint/verifysh clean.",
|
||||||
|
"checks": [
|
||||||
|
{"item": "Gates approved", "ok": true, "evidence": "reviewer.json, security.json, qa.json -> APPROVED"},
|
||||||
|
{"item": "verify.sh", "ok": true, "evidence": "exit 0"},
|
||||||
|
{"item": "Artifacts present", "ok": true, "evidence": "architect.md, implementer.md, reviewer.json, security.json, qa.json, documenter.md, leader-close.json"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
15
work/artifacts/F-154/qa.json
Normal file
15
work/artifacts/F-154/qa.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-154",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "6 new unit tests (mock, no DB) + itest AC2/AC3 updated. Full suite green, 0 regressions.",
|
||||||
|
"checks": [
|
||||||
|
{"item": "AC1 /users customer-only", "ok": true, "evidence": "pg-profile-repository.test.ts: listCustomers SELECT+COUNT contain role = 'customer'; returns customer row"},
|
||||||
|
{"item": "AC2 /admin/users internal-only", "ok": true, "evidence": "security.routes.test.ts: default excludes customers; ?role=admin narrows; ?role=customer empty"},
|
||||||
|
{"item": "No regression (full suite)", "ok": true, "evidence": "npm test = 206 passed / 56 skipped (DB itests), 0 failures"},
|
||||||
|
{"item": "Types/lint green", "ok": true, "evidence": "tsc --noEmit 0 errors (API + apps/admin); eslint/prettier clean on touched files; lint:boundaries no new violations"},
|
||||||
|
{"item": "verify.sh", "ok": true, "evidence": "exit 0"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
15
work/artifacts/F-154/reviewer.json
Normal file
15
work/artifacts/F-154/reviewer.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-154",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Separa customers de internos en backend (listCustomers role=customer, /admin/users role<>customer) + frontend dropdown. AC cubiertas por tests unitarios mock-pool/mock-Fastify y itest AC2/AC3 actualizado.",
|
||||||
|
"checks": [
|
||||||
|
{"item": "AC1 /users customer-only", "ok": true, "evidence": "pg-profile-repository.ts listCustomers: WHERE iu.role = 'customer' (COUNT+SELECT); pg-profile-repository.test.ts assertion"},
|
||||||
|
{"item": "AC2 /admin/users internal-only", "ok": true, "evidence": "security.routes.ts base condition role <> customer; security.routes.test.ts default+narrowing+empty-customer assertions"},
|
||||||
|
{"item": "AC3 single user / addresses unchanged", "ok": true, "evidence": "findCustomerById (/users/:id) untouched; route keeps owner-or-admin; git diff scope"},
|
||||||
|
{"item": "No new boundary violation", "ok": true, "evidence": "git diff: touched files are users/infra + security/api + apps/admin/(dashboard)/users + 2 tests + itest; only SQL table-name ref identity_users (preexistent pattern)"},
|
||||||
|
{"item": "Spec coverage", "ok": true, "evidence": "spec/{product,tech,acceptance}.md + work/artifacts/F-154/architect.md"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
14
work/artifacts/F-154/security.json
Normal file
14
work/artifacts/F-154/security.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-154",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Backend-only role filtering with code-constant literals (role = 'customer' / role <> 'customer'); user input (q, role) stays parameterized. No new routes, RBAC, or auth changes. Single-user endpoints unchanged.",
|
||||||
|
"checks": [
|
||||||
|
{"item": "No new routes/RBAC/auth", "ok": true, "evidence": "GET /users and GET /admin/users signatures unchanged; requireRole(authenticate) intact; only WHERE clause literal added"},
|
||||||
|
{"item": "Injection safety", "ok": true, "evidence": "role compared to code constant (not user input); q and ?role parameterized as $N; param indices unchanged"},
|
||||||
|
{"item": "IDOR", "ok": true, "evidence": "single-user GET /users/:id unchanged (owner-or-admin)"},
|
||||||
|
{"item": "Pre-existing boundary note", "ok": true, "evidence": "git diff excludes log-broadcaster import in security.routes.ts; R1 is pre-existing, not introduced by F-154"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# Feature actual
|
# Feature actual
|
||||||
|
|
||||||
|
## F-154 cerrada (2026-08-22) — separate customers from internal users
|
||||||
|
|
||||||
|
- `GET /users` (Customers) now returns **storefront customers only** (`identity_users.role = 'customer'` filtro literal en `PgProfileRepository.listCustomers`, COUNT + SELECT). `GET /users/:id` owner-or-admin inalterado.
|
||||||
|
- `GET /admin/users` (Users) now returns **internos/backoffice only** (`role <> 'customer'` base literal; `?role=admin|editor` afinando dentro de internos; `?role=customer` → vacío).
|
||||||
|
- Frontend: dropdown de Users quita opción `customer` (backend ya fuerza la separación). Customers page sin cambio (llama /users → ahora customer-only).
|
||||||
|
- Tests: 6 nuevos unitarios (mock, sin DB) en `pg-profile-repository.test.ts` (3) + `security.routes.test.ts` (3). `users.itest.ts` AC2/AC3 flip: /users devuelve ben (customer) no ana (admin). Full suite 206 passed / 56 skipped, sin regresiones.
|
||||||
|
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
||||||
|
- `tsc --noEmit` 0 errores (API + apps/admin); eslint/prettier limpios en archivos tocados; `lint:boundaries` sin violaciones NUEVAS (R1 preexistente en `security.routes.ts → log-broadcaster`, no introducido por F-154, verificado via git diff); `verify.sh` exit 0.
|
||||||
|
- Commit: `feat(F-154): completed feature`.
|
||||||
|
|
||||||
## F-153 cerrada (2026-08-22) — customer email on order view
|
## F-153 cerrada (2026-08-22) — customer email on order view
|
||||||
|
|
||||||
- Asocia el email del cliente (`identity_users.email`, NOT NULL) al order read model vía `LEFT JOIN identity_users` en el repo, y lo expone en `serializeOrder` (detail + lista) y en la notificación del force-transition admin (usa `order.email`; se elimina el lookup inline).
|
- Asocia el email del cliente (`identity_users.email`, NOT NULL) al order read model vía `LEFT JOIN identity_users` en el repo, y lo expone en `serializeOrder` (detail + lista) y en la notificación del force-transition admin (usa `order.email`; se elimina el lookup inline).
|
||||||
|
|||||||
@@ -1,11 +1,68 @@
|
|||||||
{
|
{
|
||||||
"feature_id": null,
|
"feature_id": "F-154",
|
||||||
"stage": "idle",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Sin ejecución activa",
|
"action": "Close F-154",
|
||||||
"state": "waiting",
|
"state": "done",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Feature closed",
|
||||||
"updated_at": "2026-08-22T05:41:15Z",
|
"updated_at": "2026-08-22T06:35:46Z",
|
||||||
"timeline": []
|
"timeline": [
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T05:59:15Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "intake",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Started F-154 intake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:02:52Z",
|
||||||
|
"agent": "architect",
|
||||||
|
"stage": "design",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Design stage: separate customers/internal users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:03:36Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Build stage: backend role filtering + frontend + tests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:35:46Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review F-154: customer-only /users, internal-only /admin/users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:35:46Z",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Security review F-154"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:35:46Z",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "QA verification F-154"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:35:46Z",
|
||||||
|
"agent": "documenter",
|
||||||
|
"stage": "document",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Document F-154 API contract change"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T06:35:46Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "done",
|
||||||
|
"message": "Close F-154"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user