feat(pairing): solo sealed answers that unlock on pairing

Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:34:42 +01:00
1 parent f22d4a3649
commit 09255da7db
26 files changed
+510 -75

No files matched your search

+48
View File
@@ -16,6 +16,54 @@ A user can be in one of three states:
| **Waiting** | Set | `null` | Created couple, waiting for partner |
| **Paired** | Set | Exists | Both partners joined |
Unpaired and Waiting are not dead ends: both states can answer questions
immediately through the standalone API (`POST /v1/responses`). See
[Solo answering and sealed answers](#solo-answering-and-sealed-answers) below.
## Solo answering and sealed answers
An Unpaired or Waiting user answers straight into the swipe flow instead of
being blocked until a partner joins. `responses.couple_id` is nullable
(`apps/api/drizzle/0021_solo_sealed_answers.sql`): a response submitted with no
couple is inserted with `couple_id IS NULL` and is called a **sealed answer**.
It is banked, not lost, and counts toward the daily response limit like any
other answer, but no match reconciliation runs for it (there is no partner
response to compare against).
`GET /v1/couple` reports `sealed_count`, the caller's count of responses with
`couple_id IS NULL`, alongside `couple` and `partner`, so the client can show
"N sealed answers" before and while waiting to pair. `POST /v1/responses` also
returns `sealed_count` in its response body for a solo submission (its `match`
is always `null`).
### Claim on pair
`PostgresCoupleRepository.join()` (`apps/api/src/domains/couples/repository.ts`)
is the only place membership grows from one member to two. In the same
transaction that adds the second member, it:
1. Reassigns every sealed answer (`couple_id IS NULL`) belonging to either
member to the new couple.
2. For every question where both members now have an answer, computes the
match type with the same rule `calculateMatchType` uses for live answers
and upserts into `matches` (respecting the `(couple_id, question_id)`
uniqueness), so a couple's history of solo answers surfaces as matches the
moment they pair, not only for answers given after pairing.
A user who re-pairs after their previous couple was deleted only has
`couple_id IS NULL` answers left to claim (the old couple's responses were
cascade-deleted with it), which this handles the same way as first-time
pairing.
### Push nudge escalation
The unpaired push reminder (`apps/api/src/domains/operations/repository.ts`,
`produce()`) reads each eligible recipient's sealed count and leads with it
once it is greater than zero: "You have N sealed answers waiting. Invite your
partner to unlock what you said about them." Recipients with no sealed
answers yet see the original invite-code copy. The existing 3-day cadence and
18:00-local gate are unchanged.
## Edge Functions
### `manage-couple` (POST)
+7 -2
View File
@@ -139,11 +139,16 @@ const sorted = filtered.sort((a, b) => {
| Scenario | Behavior |
|----------|----------|
| No packs enabled | Shows "No packs enabled" prompt |
| No packs enabled | Shows "No packs enabled" prompt (paired couples only; a couple with no configured packs has not chosen yet) |
| All questions answered | Shows "All caught up!" with refresh button |
| No partner paired | Shows pairing prompt |
| No partner paired | Answers straight into the swipe flow instead of a blocking prompt. Every answer is sealed (`couple_id IS NULL`, no match). A persistent banner reports the sealed count with an invite CTA. See [Solo answering and sealed answers](couple-pairing.md#solo-answering-and-sealed-answers). |
| Pack disabled mid-session | Client filters out questions from disabled packs |
An unpaired user has no `couple_packs` row to configure (it is keyed to
`couple_id`), so `recommended()`'s "unconfigured couple" fallback (every
public pack) is what they always see; the pack-selection screen only applies
once paired.
## Configuration
| Setting | Value | Location |
+8 -1
View File
@@ -68,7 +68,7 @@ erDiagram
uuid id PK
uuid user_id FK
uuid question_id FK
uuid couple_id FK
uuid couple_id FK "nullable: null is a sealed solo answer, claimed at pairing"
answer_type answer "yes/no/maybe"
timestamptz created_at
}
@@ -196,10 +196,17 @@ allowed.
5. Match triggers push notification to both partners
6. Match unlocks chat thread in `messages` table
Without a couple yet, step 3 has no partner response to check: the response is
saved with `couple_id IS NULL` (a sealed answer) and no match is created. See
[Solo answering and sealed answers](couple-pairing.md#solo-answering-and-sealed-answers).
### Couple Pairing Flow
1. First user creates couple, gets `invite_code`
2. Second user joins via `manage-couple` edge function with invite code
3. Both profiles' `couple_id` set to same couple
4. Every sealed answer (`couple_id IS NULL`) belonging to either member is
claimed into the couple, and matches are computed for every question both
members have now answered
### Pack Selection Flow
1. Couples can enable/disable packs via `couple_packs` junction table