chore(staging): integrate verified changes

Combine the Android API 36 release, dares loop, RevenueCat documentation correction, and daily engagement features. Resolve migration ordering and preserve both dares expiry and streak reminder operations.
This commit is contained in:
jjbrunton committed 2026-08-28 20:36:05 +01:00
commit 03443fe898
49 files changed
+3094 -569

No files matched your search

+1 -1
View File
@@ -94,5 +94,5 @@ the old `couple_streaks` table. The API owns both jobs now. Those legacy writers
stopped as part of the cutover procedure in
[backend deployment and branching](operations/backend-deployments.md); if any hosted
project still has them scheduled, an operator unschedules them directly — agents do not
execute remote DDL. Migration `0015_couple_streak_locality.sql` drops the equivalent
execute remote DDL. Migration `0016_couple_streak_locality.sql` drops the equivalent
trigger and functions from the product database defensively.
+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
@@ -21,6 +21,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
@@ -30,7 +31,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`