feat(club-006): club admin: dashboard, members, movements and settings

This commit is contained in:
Deploy
2026-08-26 20:55:00 +02:00
parent db33706d2d
commit 665fc2a60d
18 changed files with 830 additions and 8 deletions

View File

@@ -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,

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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>;
}

View File

@@ -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> {