mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
fix(couples): protect joined invites from cancellation
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user