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

@@ -0,0 +1,175 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { clubAdminApi } from '@/lib/api-client';
const TX_LABELS: Record<string, string> = {
earn: 'Cashback',
redeem: 'Canje',
refund: 'Reembolso',
bonus: 'Bono',
adjustment: 'Ajuste',
};
const TX_COLORS: Record<string, string> = {
earn: 'text-green-700',
redeem: 'text-blue-700',
refund: 'text-orange-700',
bonus: 'text-purple-700',
adjustment: 'text-gray-700',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
interface MemberDetail {
id: string;
userId: string | null;
userEmail: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
}
interface Transaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: string;
amountCents: number;
balanceDeltaCents: number;
createdAt: string;
}
export default function ClubMemberPage() {
const { id } = useParams<{ id: string }>();
const [member, setMember] = useState<MemberDetail | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await clubAdminApi.getMemberMovements(id);
setMember(data.member as MemberDetail);
setTransactions(data.items as Transaction[]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { void load(); }, [load]);
if (loading) {
return (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
);
}
if (error || !member) {
return (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error || 'Socio no encontrado'}</p>
<Link href="/club" className="text-sm text-[#2D6A4F] hover:underline"> Volver al Club</Link>
</div>
);
}
return (
<div className="p-8">
<div className="mb-6">
<Link href="/club" className="text-sm text-[#2D6A4F] hover:underline mb-4 inline-block"> Club de Clientes</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{member.memberCode}</h1>
<p className="text-sm text-gray-500 mt-1">
{member.userEmail ?? 'Socio anónimo'}
{!member.userId && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded">Sin cuenta</span>}
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-gray-900">{formatPrice(member.currentBalanceCents)}</p>
<p className="text-xs text-gray-500">Saldo actual</p>
<span className={`inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium ${
member.status === 'active' ? 'bg-green-100 text-green-800' :
member.status === 'blocked' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-600'
}`}>
{member.status}
</span>
</div>
</div>
</div>
{/* Meta */}
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Datos del socio</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div><span className="text-gray-500">ID:</span> <span className="font-mono text-gray-700">{member.id}</span></div>
<div><span className="text-gray-500">Código:</span> <span className="font-mono text-gray-700">{member.memberCode}</span></div>
<div><span className="text-gray-500">Email:</span> <span className="text-gray-700">{member.userEmail ?? '—'}</span></div>
<div><span className="text-gray-500">User ID:</span> <span className="font-mono text-gray-700">{member.userId ?? '—'}</span></div>
<div><span className="text-gray-500">Alta:</span> <span className="text-gray-700">{formatDate(member.createdAt)}</span></div>
<div><span className="text-gray-500">Última actualización:</span> <span className="text-gray-700">{formatDate(member.updatedAt)}</span></div>
</div>
</div>
{/* Movements */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Movimientos</h2>
</div>
{transactions.length === 0 ? (
<div className="p-12 text-center text-gray-400 text-sm">Sin movimientos</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Fecha', 'Tipo', 'Importe', 'Delta'].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-5 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{transactions.map((tx) => (
<tr key={tx.id} className="hover:bg-gray-50">
<td className="px-5 py-3 text-sm text-gray-600">{formatDate(tx.createdAt)}</td>
<td className="px-5 py-3">
<span className={`text-sm font-medium ${TX_COLORS[tx.type] ?? 'text-gray-700'}`}>
{TX_LABELS[tx.type] ?? tx.type}
</span>
</td>
<td className="px-5 py-3 text-sm text-gray-700">{formatPrice(tx.amountCents)}</td>
<td className={`px-5 py-3 text-sm font-bold ${tx.balanceDeltaCents >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{tx.balanceDeltaCents >= 0 ? '+' : ''}{formatPrice(tx.balanceDeltaCents)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { clubAdminApi } from '@/lib/api-client';
type MemberStatus = 'active' | 'blocked' | 'merged';
const STATUS_LABELS: Record<MemberStatus, string> = {
active: 'Activo',
blocked: 'Bloqueado',
merged: 'Fusionado',
};
const STATUS_COLORS: Record<MemberStatus, string> = {
active: 'bg-green-100 text-green-800',
blocked: 'bg-red-100 text-red-800',
merged: 'bg-gray-100 text-gray-600',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function timeAgo(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Hoy';
if (diffDays === 1) return 'Ayer';
if (diffDays < 30) return `Hace ${diffDays} días`;
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
}
export default function ClubPage() {
const [members, setMembers] = useState<Array<{
id: string; userId: string | null; userEmail: string | null;
memberCode: string; status: MemberStatus; tierCode: string;
currentBalanceCents: number; createdAt: string;
}>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const limit = 20;
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await clubAdminApi.listMembers({
q: debouncedSearch || undefined,
status: filterStatus || undefined,
limit,
offset,
});
setMembers(data.items as typeof members);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [debouncedSearch, filterStatus, offset]);
useEffect(() => { void load(); }, [load]);
const totalPages = Math.ceil(total / limit);
return (
<div className="p-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Club de Clientes</h1>
<p className="text-sm text-gray-500 mt-0.5">{total} socios</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-3 mb-6 flex-wrap">
<div className="relative flex-1 max-w-xs">
<input
type="search"
placeholder="Buscar por código o email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
>
<option value="">Todos los estados</option>
<option value="active">Activo</option>
<option value="blocked">Bloqueado</option>
<option value="merged">Fusionado</option>
</select>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={() => void load()} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar
</button>
</div>
) : members.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">🎫</p>
<p className="text-gray-500 text-sm">No hay socios del Club</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Código', 'Email', 'Estado', 'Saldo', 'Alta'].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{members.map((m) => (
<tr key={m.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<Link href={`/club/${m.id}`} className="text-sm font-mono text-[#2D6A4F] hover:underline">
{m.memberCode}
</Link>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{m.userEmail ?? '—'}</p>
{m.userEmail && <p className="text-xs text-gray-400">Anónimo vinculado</p>}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[m.status]}`}>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
{STATUS_LABELS[m.status]}
</span>
</td>
<td className="px-4 py-3">
<p className="text-sm font-bold text-gray-900">{formatPrice(m.currentBalanceCents)}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{timeAgo(m.createdAt)}</p>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
{offset + 1}{Math.min(offset + limit, total)} de {total}
</p>
<div className="flex gap-1">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-3 py-1 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
Anterior
</button>
<button
onClick={() => setOffset(offset + limit)}
disabled={offset + limit >= total}
className="px-3 py-1 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -441,3 +441,58 @@ export const settingsApi = {
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
about: () => api.get<AboutInfo>('/api/admin/about'),
};
// ── Club Admin API ────────────────────────────────────────────────────────────
interface ClubAdminMember {
id: string;
userId: string | null;
userEmail: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
}
interface ClubAdminTransaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
amountCents: number;
balanceDeltaCents: number;
idempotencyKey: string | null;
metadata: Record<string, unknown>;
createdAt: string;
}
interface ClubAdminMembersResponse {
items: ClubAdminMember[];
total: number;
limit: number;
offset: number;
}
export const clubAdminApi = {
listMembers: (opts?: { q?: string; status?: string; limit?: number; offset?: number }) => {
const params = new URLSearchParams();
if (opts?.q) params.set('q', opts.q);
if (opts?.status) params.set('status', opts.status);
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
const qs = params.toString();
return api.get<ClubAdminMembersResponse>(`/api/admin/club/members${qs ? `?${qs}` : ''}`);
},
getMember: (memberId: string) =>
api.get<{ member: ClubAdminMember }>(`/api/admin/club/members/${memberId}`),
getMemberMovements: (memberId: string, limit = 50) => {
const params = new URLSearchParams({ limit: String(limit) });
return api.get<{ member: ClubAdminMember; items: ClubAdminTransaction[] }>(
`/api/admin/club/members/${memberId}/movements?${params}`,
);
},
};

View File

@@ -45,6 +45,7 @@ export const NAV_ITEMS: NavItem[] = [
{ href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/club', label: 'Club', icon: '🎫', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },

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