merge: add atomic invite cancellation

This commit is contained in:
jjbrunton committed 2026-09-06 12:56:31 +01:00
commit 0dfe4435b0
10 files changed
+159 -2

No files matched your search

@@ -33,6 +33,7 @@ export interface CoupleRepository {
getState(userId: string): Promise<CoupleStateResponse>; getState(userId: string): Promise<CoupleStateResponse>;
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>; create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
join(userId: string, inviteCode: string): Promise<Couple>; join(userId: string, inviteCode: string): Promise<Couple>;
cancelInvite(userId: string): Promise<void>;
cancel(userId: string): Promise<void>; cancel(userId: string): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -268,6 +269,43 @@ 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',
[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. Answers made before
// invite creation stay sealed; answers made while waiting are coupled
// and are deleted with the cancelled invite.
await client.query('delete from couples where id = $1', [profile.couple_id]);
});
}
async close(): Promise<void> { async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool); 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)); const result = await route(() => service.cancel(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status); 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); await this.repository.cancel(userId);
return { success: true, couple_id: null }; 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' | 'invalid_invite_code'
| 'couple_full' | 'couple_full'
| 'not_paired' | 'not_paired'
| 'invite_already_joined'
| 'invite_code_collision'; | 'invite_code_collision';
export class CoupleError extends Error { export class CoupleError extends Error {
@@ -68,6 +68,56 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0); expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
}); });
it('cancels a waiting invite only for its creator', async () => {
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const pack = 'abababab-abab-4bab-8bab-abababababab';
const sealedQuestion = 'acacacac-acac-4cac-8cac-acacacacacac';
const waitingQuestion = 'adadadad-adad-4dad-8dad-adadadadadad';
await pool.query('insert into question_packs(id, name) values ($1, $2)', [pack, 'Invite cancellation']);
await pool.query(
'insert into questions(id, pack_id, text) values ($1, $3, $2), ($4, $3, $5)',
[sealedQuestion, 'Sealed before invite', pack, waitingQuestion, 'Answered while waiting'],
);
await pool.query(
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')",
[randomUUID(), charlie, sealedQuestion],
);
await repository.create(charlie, coupleId, 'WAIT2345');
await pool.query(
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, $4, 'no')",
[randomUUID(), charlie, waitingQuestion, coupleId],
);
await repository.cancelInvite(charlie);
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [sealedQuestion])).rows[0].count).toBe(1);
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [waitingQuestion])).rows[0].count).toBe(0);
});
it('never deletes a joined couple through the invite cancellation path', async () => {
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await repository.create(alice, coupleId, 'SAFE2345');
await repository.join(bob, 'SAFE2345');
const before = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
code: 'invite_already_joined',
status: 409,
});
const after = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
expect(after.rows).toEqual(before.rows);
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
});
it('reports a solo answerer\'s sealed count before any couple exists', async () => { it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
+26
View File
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
import type { CoupleRepository } from '../src/domains/couples/repository.js'; import type { CoupleRepository } from '../src/domains/couples/repository.js';
import { registerCoupleRoutes } from '../src/domains/couples/routes.js'; import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
import { CoupleService } from '../src/domains/couples/service.js'; import { CoupleService } from '../src/domains/couples/service.js';
import { CoupleError } from '../src/domains/couples/types.js';
const identity: AuthIdentity = { const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
@@ -25,6 +26,7 @@ function setup() {
invite_code: inviteCode, invite_code: inviteCode,
created_at: '2026-08-27T00:00:00.000Z', created_at: '2026-08-27T00:00:00.000Z',
})), })),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined), close: vi.fn(async () => undefined),
}; };
@@ -80,4 +82,28 @@ describe('couple routes', () => {
expect(await response.json()).toEqual({ success: true, couple_id: null }); expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancel).toHaveBeenCalledWith(identity.id); expect(repository.cancel).toHaveBeenCalledWith(identity.id);
}); });
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
expect(repository.cancel).not.toHaveBeenCalled();
});
it('does not turn a joined invite into relationship deletion', async () => {
const { app, repository } = setup();
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
);
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
});
expect(repository.cancel).not.toHaveBeenCalled();
});
}); });
+10
View File
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })), getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async () => couple), create: vi.fn(async () => couple),
join: vi.fn(async () => couple), join: vi.fn(async () => couple),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined), close: vi.fn(async () => undefined),
...overrides, ...overrides,
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
expect(create).toHaveBeenCalledTimes(2); expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true); expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
}); });
it('uses the invite-only cancellation operation for a waiting creator', async () => {
const repo = repository();
const service = new CoupleService(repo);
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
expect(repo.cancel).not.toHaveBeenCalled();
});
}); });
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
await coupleApi.getState(); await coupleApi.getState();
await coupleApi.create(); await coupleApi.create();
await coupleApi.join("ABCD2345"); await coupleApi.join("ABCD2345");
await coupleApi.cancelInvite();
await coupleApi.cancel(); await coupleApi.cancel();
expect(request.mock.calls).toEqual([ expect(request.mock.calls).toEqual([
["/v1/couple"], ["/v1/couple"],
["/v1/couple", { method: "POST", body: {} }], ["/v1/couple", { method: "POST", body: {} }],
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }], ["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
["/v1/couple/invite", { method: "DELETE" }],
["/v1/couple", { method: "DELETE" }], ["/v1/couple", { method: "DELETE" }],
]); ]);
}); });
+1
View File
@@ -24,4 +24,5 @@ export const coupleApi = {
body: { invite_code: inviteCode }, body: { invite_code: inviteCode },
}), }),
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }), cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
cancelInvite: () => apiRequest<CoupleMutationResponse>("/v1/couple/invite", { method: "DELETE" }),
}; };
+21 -2
View File
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
- All messages - All messages
- All couple_packs settings - All couple_packs settings
### Standalone API invite cancellation (DELETE)
`DELETE /v1/couple/invite` cancels an unjoined invite for the authenticated
creator only. It is intentionally separate from `DELETE /v1/couple`, which
continues to end an existing relationship for legacy clients.
The server locks the authenticated profile and then the couple row in one
transaction. It deletes the invite only when that caller is the sole member.
If another member has joined, it returns `409 invite_already_joined` and makes
no changes. This serializes safely with a concurrent `POST /v1/couple` join:
one operation completes first, while the other observes the resulting state.
```javascript
// Response on a cancelled waiting invite
{ success: true, couple_id: null }
```
## Database Schema ## Database Schema
### Couple Record ### Couple Record
@@ -182,8 +199,9 @@ This prevents race conditions where multiple users try to join simultaneously.
The invite journey leads with the shared outcome, then shows the recoverable The invite journey leads with the shared outcome, then shows the recoverable
eight-character code. It offers both a bare-code copy and a full eight-character code. It offers both a bare-code copy and a full
`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it `https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it
deletes answers made after the code was created. The five-second poll surfaces removes only an unjoined invite. Sealed answers made before invite creation
an inline offline state but continues retrying. remain, while answers made while waiting are deleted with the invite. The
five-second poll surfaces an inline offline state but continues retrying.
When pairing succeeds, both members are routed once per account and couple to When pairing succeeds, both members are routed once per account and couple to
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
@@ -397,4 +415,5 @@ Storage (chat-media) is manually cleaned before the cascade.
| Already paired | User tries to create/join while in couple | "You are already in a couple" | | Already paired | User tries to create/join while in couple | "You are already in a couple" |
| Invalid code | Invite code doesn't exist | "Invalid invite code" | | Invalid code | Invite code doesn't exist | "Invalid invite code" |
| Couple full | Couple already has 2 members | "This couple already has two partners" | | Couple full | Couple already has 2 members | "This couple already has two partners" |
| Invite already joined | Creator tries to cancel after a partner joins | "This invite has already been joined" |
| DB trigger | Race condition caught | "A couple can only have 2 members" | | DB trigger | Race condition caught | "A couple can only have 2 members" |