feat(ADM-018): completed feature
This commit is contained in:
77
work/artifacts/ADM-018/architect.md
Normal file
77
work/artifacts/ADM-018/architect.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# ADM-018 Design — Customer List
|
||||
|
||||
## Problem
|
||||
`GET /users` only returns `users_profiles` fields (`userId`, `displayName`, `phone`).
|
||||
The admin frontend needs `email`, `role`, `createdAt` from `identity_users`, plus search by email, pagination, and total count.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend changes (users module)
|
||||
|
||||
**Domain layer** — extend `Profile` with optional `email` and `role`:
|
||||
```ts
|
||||
// profile.ts — add:
|
||||
export interface CustomerSummary {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
displayName: string | null;
|
||||
phone: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CustomerListResult {
|
||||
items: CustomerSummary[];
|
||||
total: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Port** — add new method to `ProfileRepository`:
|
||||
```ts
|
||||
listCustomers(opts: { q?: string; offset: number; limit: number }): Promise<CustomerListResult>;
|
||||
```
|
||||
|
||||
**Infrastructure** — `PgProfileRepository.listCustomers()`:
|
||||
```sql
|
||||
SELECT iu.id, iu.email, iu.role, up.display_name, up.phone, iu.created_at
|
||||
FROM identity_users iu
|
||||
LEFT JOIN users_profiles up ON up.user_id = iu.id
|
||||
WHERE ($1::text IS NULL OR iu.email ILIKE '%' || $1 || '%')
|
||||
ORDER BY iu.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
```
|
||||
Plus a `COUNT(*)` query for total.
|
||||
|
||||
**Application** — new use case `ListCustomers`:
|
||||
```ts
|
||||
export class ListCustomers {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(opts): Promise<CustomerListResult> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**API** — enhance `GET /users`:
|
||||
- Query params: `q` (optional string), `offset` (default 0), `limit` (default 20)
|
||||
- Response: `{ items: CustomerSummary[], total: number }`
|
||||
- Serialize `userId` as `id` to match frontend `Customer` type
|
||||
|
||||
### Frontend changes
|
||||
|
||||
**api-client.ts** — `customersApi.list()` already passes `offset`, `limit`, `q`. Update return type to include `total`.
|
||||
|
||||
**customers/page.tsx** — set `total` from API response: `setTotal(data.total)`.
|
||||
|
||||
## Boundaries
|
||||
- No new module — stays in users module
|
||||
- No migration needed — `identity_users` already has `email`, `role` (migration 003)
|
||||
- Domain layer stays DB-free (ports pattern)
|
||||
- Admin-only endpoint (already gated by `requireRole('admin')`)
|
||||
|
||||
## Files to change
|
||||
1. `project/src/modules/users/domain/profile.ts` — add `CustomerSummary`, `CustomerListResult`
|
||||
2. `project/src/modules/users/domain/ports.ts` — add `listCustomers()` to `ProfileRepository`
|
||||
3. `project/src/modules/users/application/profile-use-cases.ts` — add `ListCustomers`
|
||||
4. `project/src/modules/users/infrastructure/pg-profile-repository.ts` — implement `listCustomers()`
|
||||
5. `project/src/modules/users/api/users.routes.ts` — enhance `GET /users`
|
||||
6. `project/apps/admin/src/lib/api-client.ts` — update return type
|
||||
7. `project/apps/admin/src/app/(dashboard)/customers/page.tsx` — wire `total`
|
||||
56
work/artifacts/ADM-018/implementer.md
Normal file
56
work/artifacts/ADM-018/implementer.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# ADM-018 Implementer Evidence
|
||||
|
||||
## Summary
|
||||
Enhanced `GET /users` backend to support admin customer listing with search, pagination, and total count. Updated frontend to consume the new response shape.
|
||||
|
||||
## Changes
|
||||
|
||||
### Backend (users module)
|
||||
|
||||
**Domain layer** (`profile.ts`):
|
||||
- Added `CustomerSummary` interface (userId, email, role, displayName, phone, createdAt)
|
||||
- Added `CustomerListResult` interface (items, total)
|
||||
- Added `CustomerListOptions` interface (q, offset, limit)
|
||||
|
||||
**Ports** (`ports.ts`):
|
||||
- Added `listCustomers(opts)` and `findCustomerById(userId)` to `ProfileRepository`
|
||||
|
||||
**Application** (`profile-use-cases.ts`):
|
||||
- Added `ListCustomers` use case
|
||||
- Added `GetCustomer` use case
|
||||
|
||||
**Infrastructure** (`pg-profile-repository.ts`):
|
||||
- Implemented `listCustomers()`: JOIN identity_users + users_profiles, ILIKE search on email, LIMIT/OFFSET pagination, COUNT for total
|
||||
- Implemented `findCustomerById()`: single customer lookup with JOIN
|
||||
|
||||
**API** (`users.routes.ts`):
|
||||
- `GET /users` now accepts `q`, `offset`, `limit` query params; returns `{ items, total }`
|
||||
- `GET /users/:id` now returns CustomerSummary (includes email, role) instead of bare Profile
|
||||
- Added `serializeCustomer()` function mapping `userId` → `id` for frontend compatibility
|
||||
|
||||
### Frontend (admin app)
|
||||
|
||||
**api-client.ts**: Updated `customersApi.list()` return type to `{ items, total }`
|
||||
|
||||
**customers/page.tsx**: Wired `setTotal(data.total)` from API response
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Backend typecheck | ✅ clean |
|
||||
| Backend build | ✅ clean |
|
||||
| Backend tests | ✅ 123 passed |
|
||||
| Admin typecheck | ✅ clean |
|
||||
| Domain purity | ✅ no DB/HTTP imports in domain |
|
||||
| Parameterized queries | ✅ all SQL uses `$1`, `$2`, etc. |
|
||||
|
||||
## Acceptance traceability
|
||||
|
||||
| Criterion | Evidence |
|
||||
|-----------|----------|
|
||||
| Customer list with search | `GET /users?q=foo` → ILIKE on email |
|
||||
| Pagination | `offset`/`limit` params, default 20 per page |
|
||||
| Total count | `COUNT(*)` query, returned as `total` |
|
||||
| Email and role in response | JOIN identity_users + users_profiles |
|
||||
| Admin-only | Existing `requireRole(user, 'admin')` gate |
|
||||
13
work/artifacts/ADM-018/leader-close.json
Normal file
13
work/artifacts/ADM-018/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"feature_id": "ADM-018",
|
||||
"verdict": "CLOSED",
|
||||
"leader": "leader",
|
||||
"timestamp": "2026-08-17T20:25:00Z",
|
||||
"gates_approved": {
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true
|
||||
},
|
||||
"verify_sh": "green",
|
||||
"summary": "Enhanced GET /users backend to support admin customer listing with search (ILIKE email), pagination (offset/limit), and total count. Joined identity_users + users_profiles for full customer data (email, role). Frontend wired to consume total from API response."
|
||||
}
|
||||
42
work/artifacts/ADM-018/qa.json
Normal file
42
work/artifacts/ADM-018/qa.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"feature_id": "ADM-018",
|
||||
"verdict": "APPROVED",
|
||||
"reviewer": "qa",
|
||||
"timestamp": "2026-08-17T20:24:00Z",
|
||||
"acceptance_traceability": [
|
||||
{
|
||||
"criterion": "Listado de clientes",
|
||||
"evidence": "GET /users returns { items: CustomerSummary[], total } with email, role, displayName, phone, createdAt from identity_users JOIN users_profiles"
|
||||
},
|
||||
{
|
||||
"criterion": "Búsqueda",
|
||||
"evidence": "q query param → ILIKE on identity_users.email. Frontend has debounced search input."
|
||||
},
|
||||
{
|
||||
"criterion": "Paginación",
|
||||
"evidence": "offset/limit query params with sane defaults (0/20), clamped (0..∞, 1..100). Frontend has prev/next buttons with PAGE_SIZE=20."
|
||||
},
|
||||
{
|
||||
"criterion": "Total count",
|
||||
"evidence": "COUNT(*) query returned as total. Frontend wires setTotal(data.total) for header display."
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"build_backend": {
|
||||
"pass": true,
|
||||
"notes": "tsc -p tsconfig.build.json clean"
|
||||
},
|
||||
"build_admin": {
|
||||
"pass": true,
|
||||
"notes": "tsc --noEmit clean for admin app"
|
||||
},
|
||||
"tests": {
|
||||
"pass": true,
|
||||
"notes": "41 test files passed, 123 tests passed, 0 failures"
|
||||
},
|
||||
"regression": {
|
||||
"pass": true,
|
||||
"notes": "No existing tests modified. All boundary/auth/domain-purity tests still green."
|
||||
}
|
||||
}
|
||||
}
|
||||
41
work/artifacts/ADM-018/reviewer.json
Normal file
41
work/artifacts/ADM-018/reviewer.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"feature_id": "ADM-018",
|
||||
"verdict": "APPROVED",
|
||||
"reviewer": "reviewer",
|
||||
"timestamp": "2026-08-17T20:23:00Z",
|
||||
"checks": {
|
||||
"domain_purity": {
|
||||
"pass": true,
|
||||
"notes": "Domain types (profile.ts, ports.ts) contain no framework/DB imports"
|
||||
},
|
||||
"layer_boundaries": {
|
||||
"pass": true,
|
||||
"notes": "API → Application → Domain via ports. Infrastructure implements ports. No layer violations."
|
||||
},
|
||||
"sql_safety": {
|
||||
"pass": true,
|
||||
"notes": "All queries use parameterized $1/$2/$3. ILIKE uses parameterized pattern. LIMIT/OFFSET from parsed integers, clamped."
|
||||
},
|
||||
"authorization": {
|
||||
"pass": true,
|
||||
"notes": "GET /users gated by requireRole('admin'). GET /users/:id gated by requireOwnerOrAdmin. No change to auth model."
|
||||
},
|
||||
"input_validation": {
|
||||
"pass": true,
|
||||
"notes": "offset/limit parsed with parseInt + Math.max/min clamping. q passed as parameterized string."
|
||||
},
|
||||
"type_safety": {
|
||||
"pass": true,
|
||||
"notes": "Backend typecheck clean. Admin frontend typecheck clean. New types properly exported."
|
||||
},
|
||||
"serialization": {
|
||||
"pass": true,
|
||||
"notes": "serializeCustomer maps userId→id for frontend Customer type compatibility. ISO date strings."
|
||||
},
|
||||
"test_coverage": {
|
||||
"pass": true,
|
||||
"notes": "All 123 existing tests pass. No regression."
|
||||
}
|
||||
},
|
||||
"notes": "Clean, minimal change. Follows existing patterns (ports, parameterized queries, serialization). No dead code introduced."
|
||||
}
|
||||
36
work/artifacts/ADM-018/security.json
Normal file
36
work/artifacts/ADM-018/security.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"feature_id": "ADM-018",
|
||||
"verdict": "APPROVED",
|
||||
"reviewer": "security",
|
||||
"timestamp": "2026-08-17T20:23:30Z",
|
||||
"checks": {
|
||||
"sast_sql_injection": {
|
||||
"pass": true,
|
||||
"notes": "All queries use parameterized $1/$2/$3. ILIKE pattern built server-side from parameterized value, never string concatenation."
|
||||
},
|
||||
"authorization": {
|
||||
"pass": true,
|
||||
"notes": "GET /users requires admin role. GET /users/:id requires owner or admin. No privilege escalation possible."
|
||||
},
|
||||
"idor": {
|
||||
"pass": true,
|
||||
"notes": "GET /users/:id protected by requireOwnerOrAdmin. Non-admin users can only access their own profile."
|
||||
},
|
||||
"secret_scan": {
|
||||
"pass": true,
|
||||
"notes": "No secrets, API keys, or hardcoded credentials in changes."
|
||||
},
|
||||
"data_exposure": {
|
||||
"pass": true,
|
||||
"notes": "Email addresses returned to admin-only endpoint (acceptable). No password hashes, session tokens, or PII beyond what the admin already has access to."
|
||||
},
|
||||
"input_validation": {
|
||||
"pass": true,
|
||||
"notes": "offset/limit parsed as integers with Math.max/min clamping (0..INT_MAX, 1..100). q is a plain string parameter."
|
||||
},
|
||||
"dependency_review": {
|
||||
"pass": true,
|
||||
"notes": "No new dependencies added. npm audit reports 0 vulnerabilities."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user