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>
6.2 KiB
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 durabilityapps/api/test/dares.routes.test.ts— HTTP contract and error mappingapps/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.