feat(ADM-018): completed feature
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user