feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -8,7 +8,7 @@ import type pg from 'pg';
import { parseJson } from '../../../shared/http-input.js';
import { AppError } from '../../../shared/errors.js';
import { requireOwnerOrAdmin, requireRole, type Authenticate } from '../../../shared/auth.js';
import { GetProfile, ListProfiles, UpdateProfile } from '../application/profile-use-cases.js';
import { GetCustomer, GetProfile, ListCustomers, ListProfiles, UpdateProfile } from '../application/profile-use-cases.js';
import {
CreateAddress,
DeleteAddress,
@@ -18,7 +18,7 @@ import {
import { PgProfileRepository } from '../infrastructure/pg-profile-repository.js';
import { PgAddressRepository } from '../infrastructure/pg-address-repository.js';
import type { Address } from '../domain/address.js';
import type { Profile } from '../domain/profile.js';
import type { CustomerSummary, Profile } from '../domain/profile.js';
export interface UsersRoutesDeps {
pool: pg.Pool;
@@ -61,8 +61,10 @@ export async function registerUsersRoutes(
const profiles = new PgProfileRepository(deps.pool);
const addresses = new PgAddressRepository(deps.pool);
const getProfile = new GetProfile(profiles);
const getCustomer = new GetCustomer(profiles);
const updateProfile = new UpdateProfile(profiles);
const listProfiles = new ListProfiles(profiles);
const listCustomers = new ListCustomers(profiles);
const listAddresses = new ListAddresses(addresses);
const createAddress = new CreateAddress(addresses);
const updateAddress = new UpdateAddress(addresses);
@@ -71,19 +73,26 @@ export async function registerUsersRoutes(
app.get('/users', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const items = await listProfiles.execute();
return reply.send({ items: items.map(serializeProfile) });
const query = request.query as Record<string, string | undefined>;
const q = query.q || undefined;
const offset = Math.max(0, parseInt(query.offset ?? '0', 10) || 0);
const limit = Math.min(100, Math.max(1, parseInt(query.limit ?? '20', 10) || 20));
const result = await listCustomers.execute({ q, offset, limit });
return reply.send({
items: result.items.map(serializeCustomer),
total: result.total,
});
});
app.get('/users/:id', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(uuidParamSchema, request.params);
requireOwnerOrAdmin(user, id);
const profile = await getProfile.execute(id);
if (!profile) {
throw new AppError(404, 'NOT_FOUND', 'Profile not found');
const customer = await getCustomer.execute(id);
if (!customer) {
throw new AppError(404, 'NOT_FOUND', 'Customer not found');
}
return reply.send(serializeProfile(profile));
return reply.send(serializeCustomer(customer));
});
app.patch('/users/:id', async (request, reply) => {
@@ -146,6 +155,17 @@ function serializeProfile(profile: Profile) {
};
}
function serializeCustomer(c: CustomerSummary) {
return {
id: c.userId,
email: c.email,
role: c.role,
displayName: c.displayName,
phone: c.phone,
createdAt: c.createdAt.toISOString(),
};
}
function serializeAddress(address: Address) {
return {
id: address.id,

View File

@@ -3,7 +3,7 @@
* happen in the API layer before these run.
*/
import type { ProfileRepository } from '../domain/ports.js';
import type { Profile, ProfilePatch } from '../domain/profile.js';
import type { CustomerListOptions, CustomerListResult, CustomerSummary, Profile, ProfilePatch } from '../domain/profile.js';
export class GetProfile {
constructor(private readonly profiles: ProfileRepository) {}
@@ -25,3 +25,17 @@ export class ListProfiles {
return this.profiles.list();
}
}
export class ListCustomers {
constructor(private readonly profiles: ProfileRepository) {}
async execute(opts: CustomerListOptions): Promise<CustomerListResult> {
return this.profiles.listCustomers(opts);
}
}
export class GetCustomer {
constructor(private readonly profiles: ProfileRepository) {}
async execute(userId: string): Promise<CustomerSummary | undefined> {
return this.profiles.findCustomerById(userId);
}
}

View File

@@ -2,7 +2,7 @@
* Ports (driven interfaces). Domain owns them; infrastructure implements them.
* All operations are scoped by userId so ownership is enforced in the query.
*/
import type { Profile, ProfilePatch } from './profile.js';
import type { CustomerListOptions, CustomerListResult, CustomerSummary, Profile, ProfilePatch } from './profile.js';
import type { Address, AddressPatch, NewAddress } from './address.js';
export interface ProfileRepository {
@@ -10,6 +10,8 @@ export interface ProfileRepository {
/** Idempotent upsert; only provided fields change. */
upsert(userId: string, patch: ProfilePatch): Promise<Profile>;
list(): Promise<Profile[]>;
listCustomers(opts: CustomerListOptions): Promise<CustomerListResult>;
findCustomerById(userId: string): Promise<CustomerSummary | undefined>;
}
export interface AddressRepository {

View File

@@ -15,3 +15,26 @@ export interface ProfilePatch {
displayName?: string | null;
phone?: string | null;
}
/** Joined identity_users + users_profiles row for admin customer listing. */
export interface CustomerSummary {
userId: string;
email: string;
role: string;
displayName: string | null;
phone: string | null;
createdAt: Date;
}
/** Paginated customer list result. */
export interface CustomerListResult {
items: CustomerSummary[];
total: number;
}
/** Filters for customer listing. */
export interface CustomerListOptions {
q?: string;
offset: number;
limit: number;
}

View File

@@ -4,7 +4,13 @@
*/
import type pg from 'pg';
import type { ProfileRepository } from '../domain/ports.js';
import type { Profile, ProfilePatch } from '../domain/profile.js';
import type {
CustomerListOptions,
CustomerListResult,
CustomerSummary,
Profile,
ProfilePatch,
} from '../domain/profile.js';
interface ProfileRow {
user_id: string;
@@ -14,6 +20,15 @@ interface ProfileRow {
updated_at: Date;
}
interface CustomerRow {
user_id: string;
email: string;
role: string;
display_name: string | null;
phone: string | null;
created_at: Date;
}
export class PgProfileRepository implements ProfileRepository {
constructor(private readonly pool: pg.Pool) {}
@@ -67,6 +82,47 @@ export class PgProfileRepository implements ProfileRepository {
);
return result.rows.map(toProfile);
}
async listCustomers(opts: CustomerListOptions): Promise<CustomerListResult> {
const searchFilter = opts.q ? `%${opts.q}%` : null;
const countResult = await this.pool.query<{ cnt: string }>(
`SELECT COUNT(*)::text AS cnt
FROM identity_users iu
WHERE ($1::text IS NULL OR iu.email ILIKE $1)`,
[searchFilter],
);
const total = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
const result = await this.pool.query<CustomerRow>(
`SELECT iu.id AS user_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`,
[searchFilter, opts.limit, opts.offset],
);
return {
items: result.rows.map(toCustomerSummary),
total,
};
}
async findCustomerById(userId: string): Promise<CustomerSummary | undefined> {
const result = await this.pool.query<CustomerRow>(
`SELECT iu.id AS user_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 iu.id = $1`,
[userId],
);
const row = result.rows[0];
return row ? toCustomerSummary(row) : undefined;
}
}
function toProfile(row: ProfileRow): Profile {
@@ -78,3 +134,14 @@ function toProfile(row: ProfileRow): Profile {
updatedAt: row.updated_at,
};
}
function toCustomerSummary(row: CustomerRow): CustomerSummary {
return {
userId: row.user_id,
email: row.email,
role: row.role,
displayName: row.display_name,
phone: row.phone,
createdAt: row.created_at,
};
}