fix(couples): protect joined invites from cancellation

This commit is contained in:
jjbrunton committed 2026-09-06 12:54:40 +01:00
1 parent a02e9d531b
commit 1fed348072
10 files changed
+139 -2

No files matched your search

@@ -33,6 +33,7 @@ export interface CoupleRepository {
getState(userId: string): Promise<CoupleStateResponse>;
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
join(userId: string, inviteCode: string): Promise<Couple>;
cancelInvite(userId: string): Promise<void>;
cancel(userId: string): Promise<void>;
close(): Promise<void>;
}
@@ -268,6 +269,42 @@ export class PostgresCoupleRepository implements CoupleRepository {
});
}
async cancelInvite(userId: string): Promise<void> {
await transaction(this.pool, async (client) => {
// Lock in the same order as join(): the caller's profile first, then the
// couple. A concurrent join therefore either completes first and makes
// this a safe no-op error, or observes that the invite no longer exists.
const profile = await profileForUpdate(client, userId);
if (!profile.couple_id) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
const coupleResult = await client.query<{ id: string }>(
'select id from couples where id = $1 for update',
[profile.couple_id],
);
if (!coupleResult.rows[0]) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
const members = await client.query<{ id: string }>(
'select id from profiles where couple_id = $1 for update',
[profile.couple_id],
);
if (members.rows.length > 1) {
throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409);
}
if (members.rows.length !== 1 || members.rows[0]?.id !== userId) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
// At this point the couple has precisely the authenticated creator as a
// member. Removing it cannot delete paired data, and the foreign key
// clears the creator's couple_id while leaving sealed answers intact.
await client.query('delete from couples where id = $1', [profile.couple_id]);
});
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
+5
View File
@@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
const result = await route(() => service.cancel(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/couple/invite', async (c) => {
const result = await route(() => service.cancelInvite(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
}
+5
View File
@@ -41,4 +41,9 @@ export class CoupleService {
await this.repository.cancel(userId);
return { success: true, couple_id: null };
}
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
await this.repository.cancelInvite(userId);
return { success: true, couple_id: null };
}
}
+1
View File
@@ -19,6 +19,7 @@ export type CoupleErrorCode =
| 'invalid_invite_code'
| 'couple_full'
| 'not_paired'
| 'invite_already_joined'
| 'invite_code_collision';
export class CoupleError extends Error {