feat(dares): ship the dares loop to mobile

Completes the half-built dares feature: send a dare, respond, report done,
confirm, and get a push notification on every transition.

Backend (apps/api):
- New `dares` domain with couple-shared premium entitlement, a free weekly
  send allowance, and server-enforced actor/state rules on every transition.
- `submitted` status so the recipient reports completion and the sender
  confirms, rather than the sender guessing.
- Notifications ride the existing operations outbox via a trigger on
  `sent_dares`. Push copy is generic; store compliance forbids dare text in a
  preview and a test asserts no payload leaks it.
- Worker expiry sweep now covers `pending` as well as `active`/`submitted`;
  an invitation nobody opened previously stayed open forever.

Fixes two latent defects:
- Deleting any dare that had been sent aborted with a check violation, because
  `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check`
  required it. Dare text is now snapshotted at send time, which also keeps a
  couple's history stable across catalogue edits.
- The free send allowance was raceable: concurrent requests all read the same
  count and every one succeeded. The count and insert are now serialized on
  the sender's profile row.

Also ports `content_status` review metadata to the API catalogue, which had no
store-safety gating at all, and mirrors the review decision already enforced on
the legacy catalogue.

Mobile: dares screen (active/send/history), send sheet with duration presets,
home tile badging incoming dares, paywall on both proactive and 402 paths.

Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against
local PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 5 committed 2026-08-28 20:03:11 +01:00
1 parent a7470310ae
commit ce0b188776
34 files changed
+2673 -242

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Dares Feature - Architecture & Implementation Plan
> **SUPERSEDED.** This is the original 2026-01 plan, kept for its competitor analysis
> and decision history. It describes a Supabase Edge Function + RLS architecture that
> the product no longer uses: mobile talks only to the standalone API. For the shipped
> contract see [Dares loop](dares-loop.md).
> **Status**: Phase 1 Complete (Database + Admin Portal)
> **Created**: 2026-01-08
> **Updated**: 2026-01-08
+139
View File
@@ -0,0 +1,139 @@
# Dares loop
Maintained reference for the dares feature: a couple sends each other challenges,
each state change notifies the other partner, and the pair keeps a shared count of
what they have finished together.
The asynchronous shape is the point. Every transition below is a push notification
to whichever partner is not holding the app.
## Ownership
| Concern | Location |
|---|---|
| Schema, triggers, review parity | `apps/api/drizzle/0013_dares_loop.sql` |
| HTTP contract | `apps/api/src/domains/dares/routes.ts` |
| Domain rules and entitlement | `apps/api/src/domains/dares/repository.ts` |
| Expiry sweep | `apps/api/src/domains/operations/repository.ts` (`produce`) |
| Mobile feature | `apps/mobile/src/features/dares/` |
| Catalogue authoring | `apps/admin/src/pages/content/DarePacksPage.tsx`, `DaresPage.tsx` |
## State machine
```
┌── decline ──► declined
pending ──accept──┤
│ └── accept ───► active ──submit──► submitted ──confirm──► completed
│ │ │
└──────────────── cancel ─────────┴───────────────────┘──► cancelled
(sender only, before completion)
any open status + expires_at in the past ──► expired (worker sweep)
```
Actor rules are enforced server-side, not by hiding buttons:
| Transition | Who | Legal from |
|---|---|---|
| accept / decline | recipient | `pending` |
| submit | recipient | `active` |
| complete | sender | `active`, `submitted` |
| cancel | sender | `pending`, `active`, `submitted` |
The recipient performs the dare, so the recipient reports it done (`submitted`) and
the sender confirms (`completed`). An illegal transition returns `409
invalid_transition`; acting on someone else's dare returns `403 not_permitted`;
a dare outside your couple returns `404`, never a hint that it exists.
## Entitlement
Premium is couple-shared — either partner's subscription unlocks both, matching the
answers domain.
| Tier | Can do |
|---|---|
| Anyone, always | Receive, accept, decline, submit, complete, cancel |
| Free sender | Free packs, `FREE_WEEKLY_SEND_LIMIT` sends per rolling 7 days |
| Premium | All packs, unlimited sends, custom dares |
**Receiving is never gated.** A paywall between a partner and the dare they were just
notified about would break the loop the feature exists to create. The quota is counted
per sender over a rolling window, and counts every send — cancelling does not refund it.
## Content safety
`dare_packs` and `dares` carry `content_status` (`unreviewed` / `allowed` / `archived`),
matching the legacy catalogue contract. The catalogue, the pack detail endpoint, and the
send path each independently require `allowed`, so a stale client cannot send unreviewed
content. New content defaults to `unreviewed` and is therefore invisible until an
administrator reviews it.
Push copy is deliberately generic — `"Your partner sent you a dare"`, never the dare
text. Store compliance forbids question, dare, response, or message text in a
notification preview, and `dares.integration.test.ts` asserts no payload leaks it.
## History durability
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
with a check violation.
## Notifications
`queue_dare_operations` (trigger on `sent_dares`) writes one `operations_outbox` row per
transition with dedupe key `dare:<id>:<event>:<recipient>`, gated on
`notification_preferences.dares_enabled`. Delivery, retry, and Expo ticket handling are
the existing worker's job — see `apps/api/src/domains/operations/README.md`.
`expired` notifies both partners; every other transition notifies the one partner who
did not cause it.
## Expiry
The worker's `produce` pass expires any dare whose `expires_at` has passed while still
`pending`, `active`, or `submitted`, reported as `daresExpired` in `ProducerSummary`.
Pending is included deliberately: an invitation the recipient never opened would
otherwise stay open forever.
Durations offered to the client are fixed presets (1, 6, 12, 24, 72, 168 hours, or none)
and validated server-side.
## HTTP contract
| Method | Path | Notes |
|---|---|---|
| GET | `/v1/dares/packs` | Catalogue plus the caller's entitlement |
| GET | `/v1/dares/packs/:packId/dares` | `402` when the pack is premium and the couple is not |
| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller |
| GET | `/v1/dares/stats` | Shared scoreboard |
| GET | `/v1/dares/durations` | Preset list the send sheet renders |
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both |
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
| POST | `/v1/dares/:dareId/submit` | Recipient reports done |
| POST | `/v1/dares/:dareId/complete` | Sender confirms |
| POST | `/v1/dares/:dareId/cancel` | Sender withdraws |
The actor always comes from the bearer identity; ownership fields in a request body are
rejected.
## Mobile
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
channel), and opens the paywall both proactively and in reaction to a `402` — a client
whose entitlement went stale still lands on the paywall rather than an error.
## Tests
- `apps/api/test/dares.integration.test.ts` — real PostgreSQL: entitlement, quota,
transitions, couple isolation, notification fan-out, no-text-in-push, snapshot durability
- `apps/api/test/dares.routes.test.ts` — HTTP contract and error mapping
- `apps/mobile/src/__tests__/hooks/useDares.test.ts` — loading, actions, paywall routing
## Not in this version
Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox
split from the main match chat was cut in favour of status transitions plus the sender's
note. Photo proof, random-dare, and streaks remain unbuilt.
+2 -1
View File
@@ -20,6 +20,7 @@ historical notes. Agents should load only the section relevant to their task.
- [Subscriptions](subscription-system.md)
- [Redemption codes](product/redemption.md)
- [Mobile release process](releasing.md)
- [Dares loop](dares-loop.md)
## Feature and implementation references
@@ -29,7 +30,7 @@ partially implemented states. Confirm claims against current code.
- `admin-question-types-plan.md`
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
- `content-balance-implementation.md`
- `dares-feature.md`
- `dares-feature.md` (superseded by [Dares loop](dares-loop.md))
- `live-draw-feature-plan.md`
- `NEW_QUESTION_TYPES.md`
- `oversized-file-inventory.md`