Merge solo sealed answers that unlock on pairing into staging

This commit is contained in:
jjbrunton committed 2026-08-30 19:49:51 +01:00
commit 7d392e756d
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