Files
Sauci/docs/dares-loop.md
jjbruntonandClaude Fable 5 504eddaf68 feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare,
catalogue or custom. The recipient captures and uploads the proof as a
new couple-scoped 'dare_proof' media kind and attaches it on submit;
the server enforces ownership, kind, couple, and MIME-type match.
The proof renders inline on the dare card for the sender to review
before confirming completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:36:11 +01:00

7.4 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; optional proof_type (none/photo/audio)
POST /v1/dares/:dareId/respond { action: "accept" | "decline" }
POST /v1/dares/:dareId/submit Recipient reports done; { proof_media_id } required when the dare has a proof requirement
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.

Proof of completion

The sender may optionally require proof when sending any dare, catalogue or custom: sent_dares.proof_type is 'none' (default), 'photo', or 'audio'. The recipient uploads the proof first (POST /v1/media/dare_proof — images and audio, 25MB cap, couple-scoped so the sender can view it) and passes the resulting media id to submit. The server enforces the requirement: a proofed dare cannot be submitted without media of the matching type, and the media must be a dare_proof object owned by the recipient in the same couple. Voluntary proof on a 'none' dare is accepted. The proof renders inline on the dare card (photo, or an audio player) via the standard signed-URL flow.

Mobile

Reached from the home screen's DaresTile, which badges pending incoming dares. The screen has Active / Send / History tabs 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.

There is no realtime channel, so the screen polls on a 5s interval — but only while it is focused and the app is foregrounded, and each pass reads the active list alone. History, the catalogue and the stats are re-read only when the active set's ids or statuses actually changed, or on an explicit refresh or a mutation. See Client data freshness.

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. Random-dare and streaks remain unbuilt.