feat(club-006): club admin: dashboard, members, movements and settings
This commit is contained in:
@@ -7,7 +7,7 @@ import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { ClubService } from '../application/club-service.js';
|
||||
import type { ClubMember, ClubRecoveryCode, ClubSettings, ClubTransaction } from '../domain/club.js';
|
||||
import type { ClubMember, ClubMemberWithUser, ClubRecoveryCode, ClubSettings, ClubTransaction } from '../domain/club.js';
|
||||
import {
|
||||
ClubAnonymousJoinDisabledError,
|
||||
ClubDeviceTokenRequiredError,
|
||||
@@ -411,6 +411,107 @@ export async function registerClubRoutes(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Admin member management ──────────────────────────────────────────────────
|
||||
app.get(
|
||||
'/admin/club/members',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club Admin'],
|
||||
summary: 'List Club members (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const query = request.query as { q?: string; status?: string; limit?: number; offset?: number };
|
||||
const result = await clubs.listMembers({
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
limit: query.limit ?? 20,
|
||||
offset: query.offset ?? 0,
|
||||
});
|
||||
return reply.send({
|
||||
items: result.items.map((m: ClubMemberWithUser) => serializeMemberWithUser(m)),
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/club/members/:memberId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club Admin'],
|
||||
summary: 'Get Club member detail (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['memberId'],
|
||||
properties: { memberId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const params = request.params as { memberId?: string };
|
||||
try {
|
||||
const member = await clubs.getMemberById(params.memberId!);
|
||||
return reply.send({ member: serializeMemberWithUser(member) });
|
||||
} catch (error) {
|
||||
throw mapClubError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/club/members/:memberId/movements',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club Admin'],
|
||||
summary: 'List Club member movements (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['memberId'],
|
||||
properties: { memberId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } },
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const params = request.params as { memberId?: string };
|
||||
const query = request.query as { limit?: number };
|
||||
try {
|
||||
const member = await clubs.getMemberById(params.memberId!);
|
||||
const { items } = await clubs.listMemberTransactions(member.id, query.limit ?? 50);
|
||||
return reply.send({
|
||||
member: serializeMemberWithUser(member),
|
||||
items: items.map(serializeTransaction),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapClubError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function authenticateOptional(
|
||||
@@ -509,6 +610,13 @@ function serializeMember(member: ClubMember) {
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMemberWithUser(member: ClubMemberWithUser) {
|
||||
return {
|
||||
...serializeMember(member),
|
||||
userEmail: member.userEmail,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeTransaction(transaction: ClubTransaction) {
|
||||
return {
|
||||
id: transaction.id,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type {
|
||||
ClubMember,
|
||||
ClubMemberWithUser,
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
GenerateRecoveryCodesResult,
|
||||
JoinClubResult,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
PaginatedClubMembers,
|
||||
RecoverByCodeResult,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
@@ -143,6 +146,27 @@ export class ClubService {
|
||||
return this.clubs.linkMemberToUser(member.id, input.userId);
|
||||
}
|
||||
|
||||
async listMembers(opts: { q?: string; status?: string; limit: number; offset: number }): Promise<PaginatedClubMembers> {
|
||||
return this.clubs.listMembers({
|
||||
q: opts.q?.trim() || undefined,
|
||||
status: opts.status || undefined,
|
||||
limit: Math.min(Math.max(opts.limit, 1), 100),
|
||||
offset: Math.max(opts.offset, 0),
|
||||
});
|
||||
}
|
||||
|
||||
async getMemberById(id: string): Promise<ClubMemberWithUser> {
|
||||
const member = await this.clubs.findMemberById(id);
|
||||
if (!member) throw new ClubMemberNotFoundError();
|
||||
return member;
|
||||
}
|
||||
|
||||
async listMemberTransactions(memberId: string, limit = 50): Promise<{ items: ClubTransaction[] }> {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 200);
|
||||
const items = await this.clubs.listTransactions(memberId, safeLimit);
|
||||
return { items };
|
||||
}
|
||||
|
||||
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
|
||||
if (primaryMemberId === mergedMemberId) {
|
||||
throw new InvalidClubTransactionError('No se puede fusionar un socio consigo mismo');
|
||||
|
||||
@@ -106,3 +106,14 @@ export interface MergeMembersResult {
|
||||
primary: ClubMember; // the member kept (registered one)
|
||||
merged: ClubMember; // the member archived (anonymous one)
|
||||
}
|
||||
|
||||
export interface ClubMemberWithUser extends ClubMember {
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedClubMembers {
|
||||
items: ClubMemberWithUser[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type {
|
||||
ClubMember,
|
||||
ClubMemberWithUser,
|
||||
ClubRecoveryCode,
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
PaginatedClubMembers,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
UpdateClubSettingsCommand,
|
||||
@@ -36,4 +38,8 @@ export interface ClubRepository {
|
||||
// Member linking and merging
|
||||
linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult>;
|
||||
mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult>;
|
||||
|
||||
// Admin
|
||||
listMembers(opts: { q?: string; status?: string; limit: number; offset: number }): Promise<PaginatedClubMembers>;
|
||||
findMemberById(id: string): Promise<ClubMemberWithUser | null>;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import type {
|
||||
ClubMember,
|
||||
ClubMemberWithUser,
|
||||
ClubRecoveryCode,
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
PaginatedClubMembers,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
UpdateClubSettingsCommand,
|
||||
@@ -205,6 +207,113 @@ export class PgClubRepository implements ClubRepository {
|
||||
return result.rows.map(toTransaction);
|
||||
}
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async listMembers(
|
||||
opts: { q?: string; status?: string; limit: number; offset: number },
|
||||
): Promise<PaginatedClubMembers> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (opts.q) {
|
||||
conditions.push(
|
||||
`(member_code ILIKE $${paramIdx} OR identity_users.email ILIKE $${paramIdx})`,
|
||||
);
|
||||
params.push(`%${opts.q}%`);
|
||||
paramIdx += 1;
|
||||
}
|
||||
if (opts.status) {
|
||||
conditions.push(`member.status = $${paramIdx}`);
|
||||
params.push(opts.status);
|
||||
paramIdx += 1;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const countResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM club_members member
|
||||
LEFT JOIN identity_users ON identity_users.id = member.user_id
|
||||
${where}`,
|
||||
params,
|
||||
);
|
||||
const total = Number(countResult.rows[0]?.count ?? 0);
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit, 1), 100);
|
||||
const offset = Math.max(opts.offset, 0);
|
||||
|
||||
const rows = await this.pool.query<{
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
member_code: string;
|
||||
status: 'active' | 'blocked' | 'merged';
|
||||
tier_code: string;
|
||||
current_balance_cents: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
user_email: string | null;
|
||||
}>(
|
||||
`SELECT member.*, identity_users.email AS user_email
|
||||
FROM club_members member
|
||||
LEFT JOIN identity_users ON identity_users.id = member.user_id
|
||||
${where}
|
||||
ORDER BY member.created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
|
||||
[...params, limit, offset],
|
||||
);
|
||||
|
||||
return {
|
||||
items: rows.rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
memberCode: row.member_code,
|
||||
status: row.status,
|
||||
tierCode: row.tier_code,
|
||||
currentBalanceCents: Number(row.current_balance_cents),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
userEmail: row.user_email,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
async findMemberById(id: string): Promise<ClubMemberWithUser | null> {
|
||||
const result = await this.pool.query<{
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
member_code: string;
|
||||
status: 'active' | 'blocked' | 'merged';
|
||||
tier_code: string;
|
||||
current_balance_cents: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
user_email: string | null;
|
||||
}>(
|
||||
`SELECT member.*, identity_users.email AS user_email
|
||||
FROM club_members member
|
||||
LEFT JOIN identity_users ON identity_users.id = member.user_id
|
||||
WHERE member.id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!result.rows[0]) return null;
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
memberCode: row.member_code,
|
||||
status: row.status,
|
||||
tierCode: row.tier_code,
|
||||
currentBalanceCents: Number(row.current_balance_cents),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
userEmail: row.user_email,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Member linking ─────────────────────────────────────────────────────────
|
||||
|
||||
async linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult> {
|
||||
|
||||
Reference in New Issue
Block a user