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

+223
View File
@@ -0,0 +1,223 @@
-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications.
--
-- Three concerns land together because the send path depends on all of them:
-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden.
-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history.
-- 3. Outbox rows for every status transition, which is the whole retention argument.
-- `to_regtype` resolves through the active search_path, so this creates the type in
-- the current schema rather than skipping because an unrelated schema already has it.
DO $$
BEGIN
IF to_regtype('content_review_status') IS NULL THEN
CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived');
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE dare_packs
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE dares
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
COMMENT ON COLUMN dare_packs.content_status IS
'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.';
--> statement-breakpoint
COMMENT ON COLUMN dares.content_status IS
'Store-safety review state. Only `allowed` dares are sendable.';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx
ON dare_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dares_content_status_idx
ON dares (pack_id, content_status, intensity);
--> statement-breakpoint
-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check`
-- requires one of the two source columns to survive. Deleting a dare that had ever
-- been sent therefore aborted with a check violation. Snapshotting the text at send
-- time removes the dependency entirely and keeps history stable across content edits.
ALTER TABLE sent_dares
ADD COLUMN IF NOT EXISTS dare_text_snapshot text,
ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer,
ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
--> statement-breakpoint
UPDATE sent_dares sd
SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text),
dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity)
FROM dares d
WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL;
--> statement-breakpoint
UPDATE sent_dares
SET dare_text_snapshot = custom_dare_text,
dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1)
WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares
ALTER COLUMN dare_text_snapshot SET NOT NULL,
ALTER COLUMN dare_intensity_snapshot SET NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_intensity_snapshot_check
CHECK (dare_intensity_snapshot BETWEEN 1 AND 5);
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_participants_distinct_check
CHECK (sender_id <> recipient_id);
--> statement-breakpoint
-- `submitted` closes the loop the original design left open: the recipient performs
-- the dare, so the recipient reports it done and the sender confirms.
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_status_check CHECK (
status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled')
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx
ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted');
--> statement-breakpoint
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION touch_sent_dare();
--> statement-breakpoint
-- Push copy is deliberately generic. Store compliance forbids dare, question,
-- response, or message text in a notification preview.
CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_recipients uuid[];
v_recipient uuid;
v_title text;
v_body text;
v_event text;
BEGIN
IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF;
IF TG_OP = 'INSERT' THEN
v_event := 'sent';
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'New dare';
v_body := 'Your partner sent you a dare';
ELSIF NEW.status IS DISTINCT FROM OLD.status THEN
v_event := NEW.status;
CASE NEW.status
WHEN 'active' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare accepted';
v_body := 'Your partner accepted your dare';
WHEN 'declined' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare declined';
v_body := 'Your partner passed on your dare';
WHEN 'submitted' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare done';
v_body := 'Your partner says they completed your dare';
WHEN 'completed' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare complete';
v_body := 'Your partner confirmed your dare';
WHEN 'cancelled' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare cancelled';
v_body := 'Your partner cancelled the dare';
WHEN 'expired' THEN
-- Both sides lose the dare, so both sides hear about it.
v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id];
v_title := 'Dare expired';
v_body := 'A dare ran out of time';
ELSE
RETURN NEW;
END CASE;
ELSE
RETURN NEW;
END IF;
FOREACH v_recipient IN ARRAY v_recipients LOOP
CONTINUE WHEN NOT EXISTS (
SELECT 1 FROM profiles p
LEFT JOIN notification_preferences np ON np.user_id = p.id
WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true
);
INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload)
VALUES (
'expo',
'dare:' || NEW.id || ':' || v_event || ':' || v_recipient,
v_recipient,
jsonb_build_object(
'title', v_title,
'body', v_body,
'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id)
)
)
ON CONFLICT (dedupe_key) DO NOTHING;
END LOOP;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION queue_dare_operations();
--> statement-breakpoint
-- Mirror the review decision already enforced on the legacy catalogue
-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety
-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so
-- this never overrides a later decision made in the admin portal.
UPDATE dare_packs
SET content_status = 'allowed',
content_review_reason = 'Reviewed as non-sexual romantic gestures'
WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dare_packs
SET content_status = 'archived',
content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares'
WHERE id IN (
'a1b2c3d4-2222-2222-2222-222222222222'::uuid,
'a1b2c3d4-3333-3333-3333-333333333333'::uuid,
'a1b2c3d4-4444-4444-4444-444444444444'::uuid
) AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dares d
SET content_status = dp.content_status,
content_review_reason = CASE
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
ELSE 'Archived with non-store-safe dare pack'
END
FROM dare_packs dp
WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed';