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

@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
catalog: DareCatalog | null;
stats: DareStats | null;
loading: boolean;
busyDareId: string | null;
error: string | null;
/** Set when an action was refused for want of premium, so the screen can open the paywall. */
paywallReason: string | null;
clearPaywall: () => void;
refresh: () => Promise<void>;
send: (payload: SendDarePayload) => Promise<boolean>;
respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
submit: (dareId: string) => Promise<void>;
complete: (dareId: string) => Promise<void>;
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
const [stats, setStats] = useState<DareStats | null>(null);
const [loading, setLoading] = useState(true);
const [busyDareId, setBusyDareId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const refresh = useCallback(async () => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
if (cause instanceof ApiError && (cause.status === 402 || cause.status === 403)) {
setPaywallReason(cause.message);
return;
}
setError(cause instanceof ApiError ? cause.message : "Something went wrong");
}, []);
const act = useCallback(
async (dareId: string, operation: () => Promise<unknown>) => {
setBusyDareId(dareId);
try {
await operation();
await refresh();
} catch (cause) {
handleFailure(cause);
} finally {
if (mountedRef.current) setBusyDareId(null);
}
},
[refresh, handleFailure],
);
const send = useCallback(
async (payload: SendDarePayload) => {
try {
await daresApi.send(payload);
await refresh();
return true;
} catch (cause) {
handleFailure(cause);
return false;
}
},
[refresh, handleFailure],
);
return {
active,
history,
catalog,
stats,
loading,
busyDareId,
error,
paywallReason,
clearPaywall: useCallback(() => setPaywallReason(null), []),
refresh,
send,
respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]),
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]),
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
};
}
@@ -0,0 +1,34 @@
import { useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { daresApi } from "../../../lib/daresApi";
/**
* Lightweight counterpart to `useDares` for the home tile: one request on focus
* rather than the four-endpoint poll the full screen runs.
*/
export function useIncomingDareCount(enabled: boolean): number {
const [count, setCount] = useState(0);
useFocusEffect(
useCallback(() => {
if (!enabled) return;
let cancelled = false;
daresApi
.list("active")
.then((result) => {
if (cancelled) return;
setCount(
result.dares.filter(
(dare) => dare.direction === "incoming" && dare.status === "pending",
).length,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [enabled]),
);
return count;
}