feat(club-005): club registered users: account linking and automatic recovery
This commit is contained in:
@@ -146,6 +146,79 @@ export async function registerClubRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// ── Link anonymous member to registered user account ──────────────────────
|
||||
// Called by the frontend after login/register to link the device's anonymous
|
||||
// member to the newly authenticated user account.
|
||||
app.patch(
|
||||
'/club/me',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club'],
|
||||
summary: 'Link current anonymous Club member to the authenticated user account',
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request); // requires session
|
||||
try {
|
||||
const result = await clubs.linkMemberToUser({
|
||||
userId: user.id,
|
||||
deviceToken: deviceTokenFromRequest(request),
|
||||
});
|
||||
const config = await clubs.getPublicConfig();
|
||||
return reply.send({
|
||||
member: serializeMember(result.member),
|
||||
linked: result.linked,
|
||||
config: serializeSettings(config),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapClubError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Merge two Club memberships ───────────────────────────────────────────────
|
||||
app.post(
|
||||
'/club/merge',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club'],
|
||||
summary: 'Merge an anonymous Club member into the authenticated user\'s member',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['anonymousMemberId'],
|
||||
properties: {
|
||||
anonymousMemberId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object' }, 400: errorSchema, 401: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request); // requires session
|
||||
const body = request.body as { anonymousMemberId?: string };
|
||||
if (!body?.anonymousMemberId) {
|
||||
throw new AppError(400, 'CLUB_INVALID_MERGE', 'anonymousMemberId es obligatorio');
|
||||
}
|
||||
try {
|
||||
// The user's registered member is the primary
|
||||
const userMember = await clubs.getMemberOrThrow({
|
||||
userId: user.id,
|
||||
deviceToken: null,
|
||||
});
|
||||
const result = await clubs.mergeMembers(userMember.id, body.anonymousMemberId);
|
||||
const config = await clubs.getPublicConfig();
|
||||
return reply.send({
|
||||
member: serializeMember(result.primary),
|
||||
mergedId: result.merged.id,
|
||||
config: serializeSettings(config),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapClubError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Recovery codes ───────────────────────────────────────────────────────────
|
||||
// Generate new recovery codes (plaintext codes returned — show once only)
|
||||
app.post(
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
ClubSettings,
|
||||
GenerateRecoveryCodesResult,
|
||||
JoinClubResult,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
RecoverByCodeResult,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
@@ -135,6 +137,19 @@ export class ClubService {
|
||||
return { member, codes };
|
||||
}
|
||||
|
||||
async linkMemberToUser(input: ClubResolveInput): Promise<LinkMemberResult> {
|
||||
if (!input.userId) throw new ClubDeviceTokenRequiredError();
|
||||
const member = await this.getMemberOrThrow(input);
|
||||
return this.clubs.linkMemberToUser(member.id, input.userId);
|
||||
}
|
||||
|
||||
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
|
||||
if (primaryMemberId === mergedMemberId) {
|
||||
throw new InvalidClubTransactionError('No se puede fusionar un socio consigo mismo');
|
||||
}
|
||||
return this.clubs.mergeMembers(primaryMemberId, mergedMemberId);
|
||||
}
|
||||
|
||||
async recoverByCode(code: string, newDeviceToken: string): Promise<RecoverByCodeResult> {
|
||||
if (!code || typeof code !== 'string' || !code.trim()) {
|
||||
throw new ClubRecoveryCodeInvalidError('Código de recuperación obligatorio');
|
||||
|
||||
@@ -96,3 +96,13 @@ export interface RecoverByCodeResult {
|
||||
deviceToken: string;
|
||||
usedCodeFingerprint: string;
|
||||
}
|
||||
|
||||
export interface LinkMemberResult {
|
||||
member: ClubMember;
|
||||
linked: boolean; // true if newly linked, false if already had userId
|
||||
}
|
||||
|
||||
export interface MergeMembersResult {
|
||||
primary: ClubMember; // the member kept (registered one)
|
||||
merged: ClubMember; // the member archived (anonymous one)
|
||||
}
|
||||
|
||||
@@ -53,3 +53,17 @@ export class ClubRecoveryCodesDisabledError extends Error {
|
||||
this.name = 'ClubRecoveryCodesDisabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClubMemberAlreadyLinkedError extends Error {
|
||||
constructor() {
|
||||
super('Este dispositivo ya está vinculado a otra cuenta');
|
||||
this.name = 'ClubMemberAlreadyLinkedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClubMergeConflictError extends Error {
|
||||
constructor(message = 'No se puede fusionar las cuentas del Club') {
|
||||
super(message);
|
||||
this.name = 'ClubMergeConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
UpdateClubSettingsCommand,
|
||||
@@ -30,4 +32,8 @@ export interface ClubRepository {
|
||||
generateRecoveryCodes(memberId: string, count: number, expiresInDays: number): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]>;
|
||||
listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]>;
|
||||
consumeRecoveryCode(fingerprint: string, newDeviceTokenHash: string): Promise<{ memberId: string; fingerprint: string }>;
|
||||
|
||||
// Member linking and merging
|
||||
linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult>;
|
||||
mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
UpdateClubSettingsCommand,
|
||||
@@ -203,6 +205,97 @@ export class PgClubRepository implements ClubRepository {
|
||||
return result.rows.map(toTransaction);
|
||||
}
|
||||
|
||||
// ── Member linking ─────────────────────────────────────────────────────────
|
||||
|
||||
async linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult> {
|
||||
const existing = await this.pool.query<MemberRow>(
|
||||
`SELECT * FROM club_members WHERE user_id = $1 AND status <> 'merged' LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
// User already has a member — return it, don't link the device member
|
||||
return { member: toMember(existing.rows[0]), linked: false };
|
||||
}
|
||||
|
||||
const result = await this.pool.query<MemberRow>(
|
||||
`UPDATE club_members SET user_id = $2, updated_at = now()
|
||||
WHERE id = $1 AND user_id IS NULL
|
||||
RETURNING *`,
|
||||
[memberId, userId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
// Member not found or already has a userId — treat as already linked
|
||||
const current = await this.pool.query<MemberRow>(
|
||||
`SELECT * FROM club_members WHERE id = $1`,
|
||||
[memberId],
|
||||
);
|
||||
if (!current.rows[0]) throw new Error(`club_members row not found: ${memberId}`);
|
||||
return { member: toMember(current.rows[0]), linked: false };
|
||||
}
|
||||
return { member: toMember(result.rows[0]), linked: true };
|
||||
}
|
||||
|
||||
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const primary = await client.query<MemberRow>(
|
||||
`SELECT * FROM club_members WHERE id = $1 AND status = 'active' FOR UPDATE`,
|
||||
[primaryMemberId],
|
||||
);
|
||||
const merged = await client.query<MemberRow>(
|
||||
`SELECT * FROM club_members WHERE id = $1 AND status = 'active' FOR UPDATE`,
|
||||
[mergedMemberId],
|
||||
);
|
||||
if (!primary.rows[0] || !merged.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
throw new Error('Uno de los socios no fue encontrado o no está activo');
|
||||
}
|
||||
|
||||
const primaryRow = primary.rows[0];
|
||||
const mergedRow = merged.rows[0];
|
||||
|
||||
// Transfer devices from merged to primary
|
||||
await client.query(
|
||||
`UPDATE club_devices SET member_id = $1 WHERE member_id = $2`,
|
||||
[primaryMemberId, mergedMemberId],
|
||||
);
|
||||
|
||||
// Mark merged member as merged and zero out balance
|
||||
await client.query(
|
||||
`UPDATE club_members
|
||||
SET status = 'merged', user_id = $1, current_balance_cents = 0, updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[primaryMemberId, mergedMemberId],
|
||||
);
|
||||
|
||||
// Credit merged balance to primary
|
||||
const newBalance = Number(primaryRow.current_balance_cents) + Number(mergedRow.current_balance_cents);
|
||||
const updatedPrimary = await client.query<MemberRow>(
|
||||
`UPDATE club_members SET current_balance_cents = $2, updated_at = now()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[primaryMemberId, newBalance],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
if (!updatedPrimary.rows[0]) throw new Error('Club merge: primary member not found after update');
|
||||
return {
|
||||
primary: toMember(updatedPrimary.rows[0]),
|
||||
merged: toMember({
|
||||
...mergedRow,
|
||||
status: 'merged' as const,
|
||||
current_balance_cents: 0,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recovery codes ──────────────────────────────────────────────────────────
|
||||
|
||||
async generateRecoveryCodes(
|
||||
|
||||
Reference in New Issue
Block a user