mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
a7470310ae
commit
ce0b188776
34 files changed
+2673
-242
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user