mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02: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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user