diff --git a/apps/api/src/domains/couples/repository.ts b/apps/api/src/domains/couples/repository.ts index 4f6e46b..819aa89 100644 --- a/apps/api/src/domains/couples/repository.ts +++ b/apps/api/src/domains/couples/repository.ts @@ -288,7 +288,7 @@ export class PostgresCoupleRepository implements CoupleRepository { } const members = await client.query<{ id: string }>( - 'select id from profiles where couple_id = $1 for update', + 'select id from profiles where couple_id = $1', [profile.couple_id], ); if (members.rows.length > 1) { @@ -299,8 +299,9 @@ export class PostgresCoupleRepository implements CoupleRepository { } // 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. + // 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]); }); } diff --git a/apps/api/test/couples.repository.integration.test.ts b/apps/api/test/couples.repository.integration.test.ts index e7fc324..d0fbed5 100644 --- a/apps/api/test/couples.repository.integration.test.ts +++ b/apps/api/test/couples.repository.integration.test.ts @@ -70,12 +70,30 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () = 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: 0 }); + 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 () => { diff --git a/docs/couple-pairing.md b/docs/couple-pairing.md index 14a1343..4fa4a14 100644 --- a/docs/couple-pairing.md +++ b/docs/couple-pairing.md @@ -199,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 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 -removes only an unjoined invite and retains sealed answers. The five-second -poll surfaces an inline offline state but continues retrying. +removes only an unjoined invite. Sealed answers made before invite creation +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 the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never