From ce0b1887765e9e4ecedac1f9b369413155d330df Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 28 Aug 2026 20:03:11 +0100 Subject: [PATCH] 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 --- apps/api/drizzle/0013_dares_loop.sql | 223 ++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/app.ts | 4 + apps/api/src/domains/dares/repository.ts | 480 ++++++++++++++++++ apps/api/src/domains/dares/routes.ts | 110 ++++ apps/api/src/domains/dares/schema.ts | 79 +++ apps/api/src/domains/operations/repository.ts | 14 +- apps/api/src/domains/operations/types.ts | 1 + .../domains/profile-settings/repository.ts | 2 + .../api/src/domains/profile-settings/types.ts | 1 + apps/api/src/index.ts | 4 + apps/api/test/dares.integration.test.ts | 206 ++++++++ apps/api/test/dares.routes.test.ts | 117 +++++ apps/api/test/operations.runner.test.ts | 2 +- apps/api/test/profile-settings.routes.test.ts | 1 + apps/mobile/app/(app)/dares.tsx | 237 +-------- apps/mobile/app/(app)/index.tsx | 5 +- .../src/__tests__/hooks/useDares.test.ts | 92 ++++ .../src/components/discovery/DaresTile.tsx | 123 +++++ apps/mobile/src/components/discovery/index.ts | 11 +- .../mobile/src/features/dares/DaresScreen.tsx | 248 +++++++++ .../features/dares/components/DareCard.tsx | 185 +++++++ .../dares/components/DarePackGrid.tsx | 83 +++ .../dares/components/SendDareSheet.tsx | 255 ++++++++++ .../src/features/dares/components/index.ts | 6 + .../src/features/dares/hooks/useDares.ts | 131 +++++ .../dares/hooks/useIncomingDareCount.ts | 34 ++ apps/mobile/src/features/dares/index.ts | 4 + apps/mobile/src/features/dares/types.ts | 83 +++ apps/mobile/src/features/index.ts | 1 + apps/mobile/src/lib/daresApi.ts | 19 + docs/dares-feature.md | 5 + docs/dares-loop.md | 139 +++++ docs/index.md | 3 +- 34 files changed, 2673 insertions(+), 242 deletions(-) create mode 100644 apps/api/drizzle/0013_dares_loop.sql create mode 100644 apps/api/src/domains/dares/repository.ts create mode 100644 apps/api/src/domains/dares/routes.ts create mode 100644 apps/api/src/domains/dares/schema.ts create mode 100644 apps/api/test/dares.integration.test.ts create mode 100644 apps/api/test/dares.routes.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/useDares.test.ts create mode 100644 apps/mobile/src/components/discovery/DaresTile.tsx create mode 100644 apps/mobile/src/features/dares/DaresScreen.tsx create mode 100644 apps/mobile/src/features/dares/components/DareCard.tsx create mode 100644 apps/mobile/src/features/dares/components/DarePackGrid.tsx create mode 100644 apps/mobile/src/features/dares/components/SendDareSheet.tsx create mode 100644 apps/mobile/src/features/dares/components/index.ts create mode 100644 apps/mobile/src/features/dares/hooks/useDares.ts create mode 100644 apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts create mode 100644 apps/mobile/src/features/dares/index.ts create mode 100644 apps/mobile/src/features/dares/types.ts create mode 100644 apps/mobile/src/lib/daresApi.ts create mode 100644 docs/dares-loop.md diff --git a/apps/api/drizzle/0013_dares_loop.sql b/apps/api/drizzle/0013_dares_loop.sql new file mode 100644 index 0000000..f75c178 --- /dev/null +++ b/apps/api/drizzle/0013_dares_loop.sql @@ -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'; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 0430787..82dc341 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1787908500000, "tag": "0012_catalogue_field_parity", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787909400000, + "tag": "0013_dares_loop", + "breakpoints": true } ] } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 584fda6..55f6ac8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js'; import { registerAnswerRoutes } from './domains/answers/routes.js'; import type { ChatRepository } from './domains/chat/repository.js'; import { registerChatRoutes } from './domains/chat/routes.js'; +import type { DaresRepository } from './domains/dares/repository.js'; +import { registerDareRoutes } from './domains/dares/routes.js'; import { registerCoupleRoutes } from './domains/couples/routes.js'; import type { CoupleService } from './domains/couples/service.js'; import type { PacksRepository } from './domains/packs/repository.js'; @@ -35,6 +37,7 @@ export interface AppDependencies { packsRepository?: PacksRepository; answersRepository?: AnswersRepository; chatRepository?: ChatRepository; + daresRepository?: DaresRepository; profileSettingsRepository?: ProfileSettingsRepository; accountOperationsService?: AccountOperationsService; mediaRepository?: MediaRepository; @@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository); if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository); if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository); + if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository); if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository); if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService); if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage); diff --git a/apps/api/src/domains/dares/repository.ts b/apps/api/src/domains/dares/repository.ts new file mode 100644 index 0000000..c3b04ab --- /dev/null +++ b/apps/api/src/domains/dares/repository.ts @@ -0,0 +1,480 @@ +import { Pool, type PoolClient, type QueryResultRow } from 'pg'; + +export type DareStatus = + | 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled'; + +/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */ +export const FREE_WEEKLY_SEND_LIMIT = 3; + +/** Durations offered in the send sheet. `null` means no deadline. */ +export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const; + +export interface DarePack { + id: string; + name: string; + description: string | null; + icon: string | null; + is_premium: boolean; + is_explicit: boolean; + sort_order: number; + category_id: string | null; + min_intensity: number | null; + max_intensity: number | null; + avg_intensity: number | null; + dare_count: number; +} + +export interface DareItem { + id: string; + pack_id: string; + text: string; + intensity: number; + suggested_duration_hours: number | null; +} + +export interface SentDare { + id: string; + couple_id: string; + dare_id: string | null; + text: string; + intensity: number; + is_custom: boolean; + sender_id: string; + recipient_id: string; + direction: 'incoming' | 'outgoing'; + status: DareStatus; + sender_notes: string | null; + sent_at: string; + accepted_at: string | null; + submitted_at: string | null; + completed_at: string | null; + expires_at: string | null; +} + +export interface DareEntitlement { + is_premium: boolean; + can_send_custom: boolean; + weekly_send_limit: number | null; + sends_remaining: number | null; +} + +export interface DareCatalog { + entitlement: DareEntitlement; + packs: DarePack[]; +} + +export interface DareStats { + sent: number; + received: number; + completed_together: number; + active: number; + completed_by_me: number; + completed_by_partner: number; +} + +export interface SendDareInput { + dare_id?: string | null; + custom_dare_text?: string | null; + custom_dare_intensity?: number | null; + duration_hours?: number | null; + sender_notes?: string | null; +} + +export class DaresError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status: 400 | 402 | 403 | 404 | 409, + ) { + super(message); + } +} + +interface ContextRow extends QueryResultRow { + couple_id: string | null; + is_premium: boolean; + hide_nsfw: boolean; + max_intensity: number; + partner_id: string | null; + partner_premium: boolean; +} + +interface SentDareRecord extends QueryResultRow { + id: string; + couple_id: string; + dare_id: string | null; + dare_text_snapshot: string; + dare_intensity_snapshot: number; + custom_dare_text: string | null; + sender_id: string; + recipient_id: string; + status: DareStatus; + sender_notes: string | null; + sent_at: Date; + accepted_at: Date | null; + submitted_at: Date | null; + completed_at: Date | null; + expires_at: Date | null; +} + +export interface DaresRepository { + getCatalog(userId: string): Promise; + listPackDares(userId: string, packId: string): Promise; + listDares(userId: string, filter: 'active' | 'history'): Promise; + send(userId: string, input: SendDareInput): Promise; + respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise; + submit(userId: string, dareId: string): Promise; + complete(userId: string, dareId: string): Promise; + cancel(userId: string, dareId: string): Promise; + stats(userId: string): Promise; + close(): Promise; +} + +const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot, + custom_dare_text, sender_id, recipient_id, status, sender_notes, + sent_at, accepted_at, submitted_at, completed_at, expires_at`; + +const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', '); + +/** Statuses still awaiting somebody's action; everything else is history. */ +const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted']; + +function iso(value: Date | null): string | null { + return value?.toISOString() ?? null; +} + +async function transaction(pool: Pool, operation: (client: PoolClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query('begin'); + const result = await operation(client); + await client.query('commit'); + return result; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } +} + +function toSentDare(row: SentDareRecord, viewerId: string): SentDare { + return { + id: row.id, + couple_id: row.couple_id, + dare_id: row.dare_id, + text: row.dare_text_snapshot, + intensity: row.dare_intensity_snapshot, + is_custom: row.custom_dare_text !== null, + sender_id: row.sender_id, + recipient_id: row.recipient_id, + direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing', + status: row.status, + sender_notes: row.sender_notes, + sent_at: row.sent_at.toISOString(), + accepted_at: iso(row.accepted_at), + submitted_at: iso(row.submitted_at), + completed_at: iso(row.completed_at), + expires_at: iso(row.expires_at), + }; +} + +export class PostgresDaresRepository implements DaresRepository { + private readonly pool: Pool; + + constructor(databaseUrl: string) { + this.pool = new Pool({ connectionString: databaseUrl }); + } + + /** + * Premium is couple-shared, matching the answers domain: either partner paying + * unlocks the catalogue for both. + */ + private async context(userId: string): Promise { + const result = await this.pool.query( + `select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity, + partner.id as partner_id, + coalesce(partner.is_premium, false) as partner_premium + from profiles p + left join lateral ( + select id, is_premium from profiles other + where other.couple_id = p.couple_id and other.id <> p.id + order by other.id limit 1 + ) partner on true + where p.id = $1`, + [userId], + ); + const row = result.rows[0]; + if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404); + return row; + } + + private static isPremium(context: ContextRow): boolean { + return context.is_premium || context.partner_premium; + } + + private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise { + const result = await client.query<{ count: string }>( + `select count(*) as count from sent_dares + where sender_id = $1 and sent_at > now() - interval '7 days'`, + [userId], + ); + return Number(result.rows[0]?.count ?? 0); + } + + private async entitlement(context: ContextRow, userId: string): Promise { + const premium = PostgresDaresRepository.isPremium(context); + if (premium) { + return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null }; + } + const used = await this.sendsThisWeek(userId); + return { + is_premium: false, + can_send_custom: false, + weekly_send_limit: FREE_WEEKLY_SEND_LIMIT, + sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used), + }; + } + + async getCatalog(userId: string): Promise { + const context = await this.context(userId); + const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity; + const [entitlement, packs] = await Promise.all([ + this.entitlement(context, userId), + this.pool.query( + `select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit, + dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity, + dp.avg_intensity::double precision as avg_intensity, + count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count + from dare_packs dp + left join categories c on c.id = dp.category_id + left join dares d on d.pack_id = dp.id + where dp.is_public = true + and dp.content_status = 'allowed' + and (dp.category_id is null or c.is_public = true) + and ($1::boolean = false or dp.is_explicit = false) + and (dp.min_intensity is null or dp.min_intensity <= $2::integer) + group by dp.id + having count(d.id) filter (where d.content_status = 'allowed') > 0 + order by dp.sort_order, dp.name, dp.id`, + [context.hide_nsfw, maxIntensity], + ), + ]); + return { entitlement, packs: packs.rows }; + } + + async listPackDares(userId: string, packId: string): Promise { + const context = await this.context(userId); + const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>( + `select dp.is_premium, dp.is_explicit + from dare_packs dp + left join categories c on c.id = dp.category_id + where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed' + and (dp.category_id is null or c.is_public = true)`, + [packId], + ); + const row = pack.rows[0]; + if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404); + if (row.is_explicit && context.hide_nsfw) { + throw new DaresError('pack_not_found', 'Dare pack not found', 404); + } + if (row.is_premium && !PostgresDaresRepository.isPremium(context)) { + throw new DaresError('premium_required', 'This dare pack requires premium', 402); + } + const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity; + const result = await this.pool.query( + `select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours + from dares d + where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer + order by d.intensity, d.created_at, d.id`, + [packId, maxIntensity], + ); + return result.rows; + } + + async listDares(userId: string, filter: 'active' | 'history'): Promise { + const context = await this.context(userId); + if (!context.couple_id) return []; + const result = await this.pool.query( + `select ${SENT_COLUMNS} + from sent_dares sd + where sd.couple_id = $1 + and (sd.status = any($2::text[])) = $3::boolean + order by sd.sent_at desc, sd.id desc + limit 200`, + [context.couple_id, OPEN_STATUSES, filter === 'active'], + ); + return result.rows.map((row) => toSentDare(row, userId)); + } + + async send(userId: string, input: SendDareInput): Promise { + const context = await this.context(userId); + if (!context.couple_id || !context.partner_id) { + throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409); + } + const premium = PostgresDaresRepository.isPremium(context); + + const duration = input.duration_hours ?? null; + if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) { + throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400); + } + + let text: string; + let intensity: number; + let dareId: string | null = null; + let customText: string | null = null; + let customIntensity: number | null = null; + + if (input.dare_id) { + // Re-check entitlement against the pack so a stale client cannot send a locked dare. + const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>( + `select d.text, d.intensity, dp.is_premium, dp.is_explicit + from dares d + join dare_packs dp on dp.id = d.pack_id + left join categories c on c.id = dp.category_id + where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed' + and dp.is_public = true and (dp.category_id is null or c.is_public = true)`, + [input.dare_id], + ); + const row = dare.rows[0]; + if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404); + if (row.is_explicit && context.hide_nsfw) { + throw new DaresError('dare_not_found', 'Dare not found', 404); + } + if (row.is_premium && !premium) { + throw new DaresError('premium_required', 'This dare requires premium', 402); + } + dareId = input.dare_id; + text = row.text; + intensity = row.intensity; + } else { + if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402); + const trimmed = input.custom_dare_text?.trim(); + if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400); + customText = trimmed; + customIntensity = input.custom_dare_intensity ?? 1; + text = trimmed; + intensity = customIntensity; + } + + // The quota is a paywall boundary, so the count and the insert have to be atomic: + // locking the sender's profile row serializes concurrent sends by the same user. + return transaction(this.pool, async (client) => { + if (!premium) { + await client.query('select 1 from profiles where id = $1 for update', [userId]); + const used = await this.sendsThisWeek(userId, client); + if (used >= FREE_WEEKLY_SEND_LIMIT) { + throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402); + } + } + + const result = await client.query( + `insert into sent_dares ( + couple_id, dare_id, custom_dare_text, custom_dare_intensity, + dare_text_snapshot, dare_intensity_snapshot, + sender_id, recipient_id, sender_notes, expires_at + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end + ) + returning ${BARE_COLUMNS}`, + [ + context.couple_id, dareId, customText, customIntensity, text, intensity, + userId, context.partner_id, input.sender_notes?.trim() || null, duration, + ], + ); + return toSentDare(result.rows[0]!, userId); + }); + } + + /** + * Applies a status change, enforcing both the actor's role and the legal + * transitions out of the current status. Notifications are emitted by the + * `operations_dare_changed` trigger, so no enqueue happens here. + */ + private async transition( + userId: string, + dareId: string, + actor: 'sender' | 'recipient', + from: readonly DareStatus[], + to: DareStatus, + timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null, + ): Promise { + const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id'; + const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : ''; + const result = await this.pool.query( + `update sent_dares sd + set status = $3::text${setTimestamp} + where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[]) + returning ${SENT_COLUMNS}`, + [dareId, userId, to, from], + ); + const row = result.rows[0]; + if (row) return toSentDare(row, userId); + + // Distinguish "not yours / gone" from "wrong state" so the client can react. + const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>( + 'select sender_id, recipient_id, status from sent_dares where id = $1', + [dareId], + ); + const found = existing.rows[0]; + if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) { + throw new DaresError('dare_not_found', 'Dare not found', 404); + } + const actorId = actor === 'sender' ? found.sender_id : found.recipient_id; + if (actorId !== userId) { + throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403); + } + throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409); + } + + async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise { + return action === 'accept' + ? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at') + : this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null); + } + + async submit(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at'); + } + + async complete(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at'); + } + + async cancel(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null); + } + + async stats(userId: string): Promise { + const context = await this.context(userId); + if (!context.couple_id) { + return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 }; + } + const result = await this.pool.query>( + `select + count(*) filter (where sender_id = $2)::text as sent, + count(*) filter (where recipient_id = $2)::text as received, + count(*) filter (where status = 'completed')::text as completed_together, + count(*) filter (where status = any($3::text[]))::text as active, + count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me, + count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner + from sent_dares where couple_id = $1`, + [context.couple_id, userId, OPEN_STATUSES], + ); + const row = result.rows[0] ?? {}; + const read = (key: string): number => Number(row[key] ?? 0); + return { + sent: read('sent'), + received: read('received'), + completed_together: read('completed_together'), + active: read('active'), + completed_by_me: read('completed_by_me'), + completed_by_partner: read('completed_by_partner'), + }; + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/apps/api/src/domains/dares/routes.ts b/apps/api/src/domains/dares/routes.ts new file mode 100644 index 0000000..970b83d --- /dev/null +++ b/apps/api/src/domains/dares/routes.ts @@ -0,0 +1,110 @@ +import type { ApiErrorResponse } from '@sauci/shared'; +import type { Hono } from 'hono'; +import { z } from 'zod'; +import type { AuthIdentity } from '../../auth.js'; +import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js'; + +type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>; + +const id = z.string().uuid(); + +const sendBody = z + .object({ + dare_id: id.nullish(), + custom_dare_text: z.string().trim().min(1).max(500).nullish(), + custom_dare_intensity: z.number().int().min(1).max(5).nullish(), + duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(), + sender_notes: z.string().trim().max(500).nullish(), + }) + .strict() + .refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), { + message: 'Provide either dare_id or custom_dare_text', + }); + +const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict(); + +function error(code: string, message: string): ApiErrorResponse { + return { error: { code, message } }; +} + +async function result(operation: () => Promise) { + try { + return { ok: true as const, value: await operation() }; + } catch (cause) { + if (cause instanceof DaresError) return { ok: false as const, cause }; + throw cause; + } +} + +export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void { + app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS })); + + app.get('/v1/dares/packs', async (c) => { + const response = await result(() => repository.getCatalog(c.get('identity').id)); + return response.ok + ? c.json(response.value) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.get('/v1/dares/packs/:packId/dares', async (c) => { + const packId = id.safeParse(c.req.param('packId')); + if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400); + const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data)); + return response.ok + ? c.json({ dares: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.get('/v1/dares', async (c) => { + const filter = c.req.query('filter') ?? 'active'; + if (filter !== 'active' && filter !== 'history') { + return c.json(error('invalid_filter', 'Filter must be active or history'), 400); + } + const response = await result(() => repository.listDares(c.get('identity').id, filter)); + return response.ok + ? c.json({ dares: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.get('/v1/dares/stats', async (c) => { + const response = await result(() => repository.stats(c.get('identity').id)); + return response.ok + ? c.json(response.value) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.post('/v1/dares', async (c) => { + const body = sendBody.safeParse(await c.req.json().catch(() => ({}))); + if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400); + const response = await result(() => repository.send(c.get('identity').id, body.data)); + return response.ok + ? c.json({ dare: response.value }, 201) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + app.post('/v1/dares/:dareId/respond', async (c) => { + const dareId = id.safeParse(c.req.param('dareId')); + const body = respondBody.safeParse(await c.req.json().catch(() => ({}))); + if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400); + if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400); + const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action)); + return response.ok + ? c.json({ dare: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + + for (const [path, method] of [ + ['submit', 'submit'], + ['complete', 'complete'], + ['cancel', 'cancel'], + ] as const) { + app.post(`/v1/dares/:dareId/${path}`, async (c) => { + const dareId = id.safeParse(c.req.param('dareId')); + if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400); + const response = await result(() => repository[method](c.get('identity').id, dareId.data)); + return response.ok + ? c.json({ dare: response.value }) + : c.json(error(response.cause.code, response.cause.message), response.cause.status); + }); + } +} diff --git a/apps/api/src/domains/dares/schema.ts b/apps/api/src/domains/dares/schema.ts new file mode 100644 index 0000000..1727489 --- /dev/null +++ b/apps/api/src/domains/dares/schema.ts @@ -0,0 +1,79 @@ +import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { profiles } from '../../db/schema.js'; + +// `couples`, `categories`, and the review-status enum are owned by other verticals. +// Their FKs stay in SQL so this fragment does not import across domain boundaries. +export const darePacks = pgTable('dare_packs', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + description: text('description'), + icon: text('icon'), + isPremium: boolean('is_premium').notNull().default(false), + isPublic: boolean('is_public').notNull().default(true), + isExplicit: boolean('is_explicit').notNull().default(false), + sortOrder: integer('sort_order').notNull().default(0), + categoryId: uuid('category_id'), + minIntensity: integer('min_intensity'), + maxIntensity: integer('max_intensity'), + avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]); + +export const dares = pgTable('dares', { + id: uuid('id').primaryKey().defaultRandom(), + packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }), + text: text('text').notNull(), + intensity: integer('intensity').notNull().default(1), + suggestedDurationHours: integer('suggested_duration_hours'), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + index('dares_pack_idx').on(table.packId), + index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity), +]); + +// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's +// history must survive the dare being edited, archived, or deleted. +export const sentDares = pgTable('sent_dares', { + id: uuid('id').primaryKey().defaultRandom(), + coupleId: uuid('couple_id').notNull(), + dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }), + customDareText: text('custom_dare_text'), + customDareIntensity: integer('custom_dare_intensity'), + dareTextSnapshot: text('dare_text_snapshot').notNull(), + dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(), + senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }), + recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }), + status: text('status').notNull().default('pending'), + senderNotes: text('sender_notes'), + sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(), + acceptedAt: timestamp('accepted_at', { withTimezone: true }), + submittedAt: timestamp('submitted_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + index('sent_dares_couple_idx').on(table.coupleId), + index('sent_dares_recipient_status_idx').on(table.recipientId, table.status), + index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt), + check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`), + check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`), +]); + +export const dareMessages = pgTable('dare_messages', { + id: uuid('id').primaryKey().defaultRandom(), + sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }), + senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }), + content: text('content').notNull(), + readAt: timestamp('read_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]); diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index bccf0f7..f6e01d0 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository { async produce(now: Date, limit=100): Promise { return transaction(this.pool, async (client) => { const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked"); - const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0}; + const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,daresExpired:0}; if (!lock.rows[0]?.locked) return empty; const released=await client.query<{id:string;name:string}>( @@ -138,9 +138,19 @@ export class PostgresOperationsRepository implements OperationsRepository { from eligible e on conflict(dedupe_key) do nothing returning recipient_id ) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]); + // Expiring pending dares too: the original design only swept `active`, so an + // invitation the recipient never opened stayed open forever. + const expiredDares=await client.query( + `with due as ( + select id from sent_dares + where expires_at is not null and expires_at<=$1 + and status in ('pending','active','submitted') + order by expires_at for update skip locked limit $2 + ) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]); + const catchup=await this.produceCatchups(client,now,limit); return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0, - packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup}; + packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup,daresExpired:expiredDares.rowCount??0}; }); } diff --git a/apps/api/src/domains/operations/types.ts b/apps/api/src/domains/operations/types.ts index 9e91b8e..163d070 100644 --- a/apps/api/src/domains/operations/types.ts +++ b/apps/api/src/domains/operations/types.ts @@ -48,4 +48,5 @@ export interface ProducerSummary { weeklySummaries: number; unpairedReminders: number; catchupReminders: number; + daresExpired: number; } diff --git a/apps/api/src/domains/profile-settings/repository.ts b/apps/api/src/domains/profile-settings/repository.ts index 00c042c..b968ab2 100644 --- a/apps/api/src/domains/profile-settings/repository.ts +++ b/apps/api/src/domains/profile-settings/repository.ts @@ -19,6 +19,7 @@ interface PreferencesRow extends QueryResultRow { weekly_summary_enabled: boolean; unpaired_reminders_enabled: boolean; catchup_reminders_enabled: boolean; + dares_enabled: boolean; created_at: Date; updated_at: Date; } @@ -64,6 +65,7 @@ function toPreferences(row: PreferencesRow): NotificationPreferences { weekly_summary_enabled: row.weekly_summary_enabled, unpaired_reminders_enabled: row.unpaired_reminders_enabled, catchup_reminders_enabled: row.catchup_reminders_enabled, + dares_enabled: row.dares_enabled, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), }; diff --git a/apps/api/src/domains/profile-settings/types.ts b/apps/api/src/domains/profile-settings/types.ts index 69d24b8..e27b65f 100644 --- a/apps/api/src/domains/profile-settings/types.ts +++ b/apps/api/src/domains/profile-settings/types.ts @@ -9,6 +9,7 @@ export const notificationPreferenceKeys = [ 'weekly_summary_enabled', 'unpaired_reminders_enabled', 'catchup_reminders_enabled', + 'dares_enabled', ] as const; export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2918a92..d85eb2f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -17,6 +17,7 @@ import { } from './domains/account-operations/index.js'; import { PostgresAnswersRepository } from './domains/answers/repository.js'; import { PostgresChatRepository } from './domains/chat/repository.js'; +import { PostgresDaresRepository } from './domains/dares/repository.js'; import { PostgresCoupleRepository } from './domains/couples/repository.js'; import { CoupleService } from './domains/couples/service.js'; import { PostgresPacksRepository } from './domains/packs/repository.js'; @@ -30,6 +31,7 @@ const coupleRepository = new PostgresCoupleRepository(config.databaseUrl); const packsRepository = new PostgresPacksRepository(config.databaseUrl); const answersRepository = new PostgresAnswersRepository(config.databaseUrl); const chatRepository = new PostgresChatRepository(config.databaseUrl); +const daresRepository = new PostgresDaresRepository(config.databaseUrl); const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl); const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl); const accountOperationsService = new AccountOperationsService( @@ -59,6 +61,7 @@ const app = createApp({ packsRepository, answersRepository, chatRepository, + daresRepository, profileSettingsRepository, accountOperationsService, mediaRepository, @@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise { packsRepository.close(), answersRepository.close(), chatRepository.close(), + daresRepository.close(), profileSettingsRepository.close(), accountOperationsRepository.close(), mediaRepository.close(), diff --git a/apps/api/test/dares.integration.test.ts b/apps/api/test/dares.integration.test.ts new file mode 100644 index 0000000..01656a1 --- /dev/null +++ b/apps/api/test/dares.integration.test.ts @@ -0,0 +1,206 @@ +import { randomUUID } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js'; + +const url = process.env.DATABASE_URL; +const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false; +if (url && !local) throw new Error('Dare integration tests only permit localhost'); + +describe.skipIf(!url || !local)('dares + PostgreSQL', () => { + const admin = new Pool({ connectionString: url }); + const schema = `dares_${randomUUID().replaceAll('-', '')}`; + let pool: Pool; + let repo: PostgresDaresRepository; + + const sender = '11111111-1111-4111-8111-111111111111'; + const recipient = '22222222-2222-4222-8222-222222222222'; + const outsider = '33333333-3333-4333-8333-333333333333'; + const couple = '44444444-4444-4444-8444-444444444444'; + const otherCouple = '55555555-5555-4555-8555-555555555555'; + const freePack = '66666666-6666-4666-8666-666666666666'; + const premiumPack = '77777777-7777-4777-8777-777777777777'; + const unreviewedPack = '88888888-8888-4888-8888-888888888888'; + const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + + beforeAll(async () => { + await admin.query(`create schema "${schema}"`); + const isolated = new URL(url!); + isolated.searchParams.set('options', `-c search_path=${schema}`); + pool = new Pool({ connectionString: isolated.toString() }); + repo = new PostgresDaresRepository(isolated.toString()); + + const dir = new URL('../drizzle/', import.meta.url); + const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort(); + for (const name of files) { + const sql = await readFile(new URL(name, dir), 'utf8'); + for (const statement of sql.split('--> statement-breakpoint')) { + if (statement.trim()) await pool.query(statement); + } + } + + await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']); + await pool.query( + 'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)', + [sender, recipient, outsider, couple, otherCouple], + ); + await pool.query( + `insert into dare_packs(id,name,is_premium,content_status) values + ($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`, + [freePack, premiumPack, unreviewedPack], + ); + await pool.query( + `insert into dares(id,pack_id,text,intensity,content_status) values + ($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`, + [freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack], + ); + }); + + beforeEach(async () => { + await pool.query('delete from sent_dares'); + await pool.query('delete from operations_outbox'); + await pool.query('update profiles set is_premium=false'); + }); + + afterAll(async () => { + await repo.close(); + await pool.end(); + await admin.query(`drop schema "${schema}" cascade`); + await admin.end(); + }); + + it('hides unreviewed packs and dares from the catalogue', async () => { + const catalog = await repo.getCatalog(sender); + const ids = catalog.packs.map((pack) => pack.id); + expect(ids).toContain(freePack); + expect(ids).toContain(premiumPack); + expect(ids).not.toContain(unreviewedPack); + await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' }); + await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' }); + }); + + it('gates premium packs and custom dares, and unlocks when either partner pays', async () => { + await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' }); + await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' }); + await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' }); + + // Premium is couple-shared: the partner paying unlocks the sender. + await pool.query('update profiles set is_premium=true where id=$1', [recipient]); + expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null }); + await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1); + const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 }); + expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 }); + }); + + it('enforces the free weekly send allowance without blocking the response side', async () => { + for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) { + await repo.send(sender, { dare_id: freeDare }); + } + expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0); + await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' }); + + // The quota is per-sender, so the recipient can still answer back. + const reply = await repo.send(recipient, { dare_id: freeDare }); + expect(reply.direction).toBe('outgoing'); + }); + + it('serializes concurrent sends so the free allowance cannot be raced', async () => { + const attempts = await Promise.allSettled( + Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })), + ); + expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT); + const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]); + expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT)); + }); + + it('walks the full loop and records each transition exactly once', async () => { + const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' }); + expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' }); + expect(dare.expires_at).not.toBeNull(); + + expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active'); + expect((await repo.submit(recipient, dare.id)).status).toBe('submitted'); + expect((await repo.complete(sender, dare.id)).status).toBe('completed'); + + const events = await pool.query<{ dedupe_key: string; recipient_id: string }>( + "select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at", + ); + expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']); + expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]); + }); + + it('never puts dare text in a notification payload', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await repo.respond(recipient, dare.id, 'accept'); + const leaked = await pool.query<{ count: string }>( + "select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'", + ); + expect(leaked.rows[0]!.count).toBe('0'); + }); + + it('respects the dares notification preference', async () => { + await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]); + const dare = await repo.send(sender, { dare_id: freeDare }); + const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]); + expect(events.rowCount).toBe(0); + // The sender has not opted out, so their side still fires. + await repo.respond(recipient, dare.id, 'decline'); + const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]); + expect(senderEvents.rowCount).toBe(1); + await pool.query('delete from notification_preferences where user_id=$1', [recipient]); + }); + + it('enforces actor and state on every transition', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' }); + await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' }); + await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' }); + await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' }); + + await repo.respond(recipient, dare.id, 'accept'); + await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' }); + expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled'); + await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' }); + }); + + it('keeps history intact when the source dare is deleted', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await pool.query('delete from dares where id=$1', [freeDare]); + const history = await repo.listDares(sender, 'active'); + expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 }); + await pool.query( + "insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')", + [freeDare, freePack], + ); + }); + + it('scopes listing and stats to the couple', async () => { + const dare = await repo.send(sender, { dare_id: freeDare }); + await repo.respond(recipient, dare.id, 'accept'); + await repo.submit(recipient, dare.id); + await repo.complete(sender, dare.id); + + expect(await repo.listDares(outsider, 'active')).toEqual([]); + expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 }); + expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 }); + expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 }); + + const active = await repo.listDares(sender, 'active'); + const history = await repo.listDares(sender, 'history'); + expect(active).toEqual([]); + expect(history).toHaveLength(1); + expect(history[0]!.direction).toBe('outgoing'); + expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming'); + }); + + it('rejects durations outside the offered presets', async () => { + await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' }); + }); + + it('refuses to send without a partner', async () => { + await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' }); + }); +}); diff --git a/apps/api/test/dares.routes.test.ts b/apps/api/test/dares.routes.test.ts new file mode 100644 index 0000000..cc2cb34 --- /dev/null +++ b/apps/api/test/dares.routes.test.ts @@ -0,0 +1,117 @@ +import { Hono } from 'hono'; +import { describe, expect, it, vi } from 'vitest'; +import type { AuthIdentity } from '../src/auth.js'; +import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js'; +import { registerDareRoutes } from '../src/domains/dares/routes.js'; + +const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null }; +const partner = '22222222-2222-4222-8222-222222222222'; +const dareId = '33333333-3333-4333-8333-333333333333'; +const packId = '44444444-4444-4444-8444-444444444444'; +const catalogueDareId = '55555555-5555-4555-8555-555555555555'; + +const dare: SentDare = { + id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId, + text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner, + direction: 'outgoing', status: 'pending', sender_notes: null, + sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null, +}; + +function setup(overrides: Partial = {}) { + const repository: DaresRepository = { + getCatalog: vi.fn(async () => ({ + entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 }, + packs: [], + })), + listPackDares: vi.fn(async () => []), + listDares: vi.fn(async () => [dare]), + send: vi.fn(async () => dare), + respond: vi.fn(async () => ({ ...dare, status: 'active' as const })), + submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })), + complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })), + cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })), + stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })), + close: vi.fn(async () => undefined), + ...overrides, + }; + const app = new Hono<{ Variables: { identity: AuthIdentity } }>(); + app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); }); + registerDareRoutes(app, repository); + return { app, repository }; +} + +function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +describe('dare routes', () => { + it('derives the actor from the bearer identity, never the body', async () => { + const { app, repository } = setup(); + const response = await post(app, '/v1/dares', { + dare_id: catalogueDareId, + sender_notes: ' be brave ', + }); + expect(response.status).toBe(201); + expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId })); + }); + + it('rejects client-supplied ownership fields', async () => { + const { app } = setup(); + const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner }); + expect(response.status).toBe(400); + }); + + it('requires exactly one of dare_id or custom text', async () => { + const { app } = setup(); + expect((await post(app, '/v1/dares', {})).status).toBe(400); + expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400); + expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201); + }); + + it('accepts only preset durations', async () => { + const { app } = setup(); + expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201); + expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400); + }); + + it('validates identifiers and filters', async () => { + const { app } = setup(); + expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400); + expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400); + expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400); + expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400); + }); + + it('routes each transition to its repository method', async () => { + const { app, repository } = setup(); + await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' }); + expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline'); + await post(app, `/v1/dares/${dareId}/submit`); + expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId); + await post(app, `/v1/dares/${dareId}/complete`); + expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId); + await post(app, `/v1/dares/${dareId}/cancel`); + expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId); + }); + + it('surfaces domain errors with their status codes', async () => { + const { app } = setup({ + send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }), + submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }), + }); + const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' }); + expect(paywalled.status).toBe(402); + expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } }); + expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409); + }); + + it('exposes the duration presets the send sheet offers', async () => { + const { app } = setup(); + const response = await app.request('/v1/dares/durations'); + expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] }); + }); +}); diff --git a/apps/api/test/operations.runner.test.ts b/apps/api/test/operations.runner.test.ts index 21c86af..eedad55 100644 --- a/apps/api/test/operations.runner.test.ts +++ b/apps/api/test/operations.runner.test.ts @@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js'; import type { OperationItem } from '../src/domains/operations/types.js'; import { loadWorkerConfig } from '../src/workers/config.js'; -const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0}; +const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,daresExpired:0}; function repository(items:OperationItem[]):OperationsRepository { return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined), message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)}; diff --git a/apps/api/test/profile-settings.routes.test.ts b/apps/api/test/profile-settings.routes.test.ts index f62fa74..7799167 100644 --- a/apps/api/test/profile-settings.routes.test.ts +++ b/apps/api/test/profile-settings.routes.test.ts @@ -23,6 +23,7 @@ const preferences = { weekly_summary_enabled: true, unpaired_reminders_enabled: true, catchup_reminders_enabled: true, + dares_enabled: true, created_at: '2026-08-27T10:00:00.000Z', updated_at: '2026-08-27T10:00:00.000Z', }; diff --git a/apps/mobile/app/(app)/dares.tsx b/apps/mobile/app/(app)/dares.tsx index f182163..e1106c2 100644 --- a/apps/mobile/app/(app)/dares.tsx +++ b/apps/mobile/app/(app)/dares.tsx @@ -1,232 +1,5 @@ -import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; -import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; -import { GradientBackground } from "../../src/components/ui"; -import { colors, spacing, radius, typography, animations } from "../../src/theme"; -import { useFeatureInterest } from "../../src/hooks/useFeatureInterest"; - -const ACCENT_COLOR = colors.premium.gold; - -const AnimatedPressable = Animated.createAnimatedComponent(Pressable); - -export default function DaresScreen() { - const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } = - useFeatureInterest("dares"); - - const scale = useSharedValue(1); - const animatedButtonStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - const handlePressIn = () => { - scale.value = withSpring(0.96, animations.spring); - }; - - const handlePressOut = () => { - scale.value = withSpring(1, animations.spring); - }; - - return ( - - - - {/* Title section */} - ADVENTURE - Dares - - {/* Decorative separator */} - - - - - - - {/* Coming soon badge */} - - ARRIVING SOON - - - {/* Description */} - - Try playful challenges together and create unforgettable moments. - - - {/* Feature hints */} - - - - Provocative challenges - - - - Multiple intensity levels - - - - Designed for two - - - - {/* Bottom teaser */} - Are you ready to play? - - {/* Interest button - always reserve space to prevent layout shift */} - - {isAuthenticated && !isLoading && ( - - - {isToggling ? ( - - ) : ( - <> - - - {isInterested ? "You're on the list!" : "I'm interested"} - - - )} - - - )} - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingTop: 60, - paddingBottom: Platform.OS === 'ios' ? 120 : 100, - }, - content: { - width: '100%', - alignItems: 'center', - paddingHorizontal: spacing.md, - }, - label: { - ...typography.caption1, - fontWeight: '600', - letterSpacing: 3, - color: ACCENT_COLOR, - textAlign: 'center', - marginBottom: spacing.xs, - }, - title: { - ...typography.largeTitle, - color: colors.text, - textAlign: 'center', - }, - separator: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginVertical: spacing.lg, - width: 140, - }, - separatorLine: { - flex: 1, - height: 1, - backgroundColor: 'rgba(212, 175, 55, 0.3)', - }, - separatorDiamond: { - width: 6, - height: 6, - backgroundColor: ACCENT_COLOR, - transform: [{ rotate: '45deg' }], - marginHorizontal: spacing.md, - opacity: 0.6, - }, - badge: { - backgroundColor: colors.backgroundLight, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: colors.border, - marginBottom: spacing.xl, - }, - interestButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.backgroundLight, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm + 2, - borderRadius: radius.full, - borderWidth: 1, - borderColor: colors.border, - gap: spacing.sm, - }, - interestButtonActive: { - backgroundColor: colors.background, - borderColor: colors.primary, - }, - interestButtonText: { - ...typography.subhead, - fontWeight: '600', - color: ACCENT_COLOR, - }, - badgeText: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 2, - color: ACCENT_COLOR, - }, - description: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.xl, - paddingHorizontal: spacing.md, - }, - features: { - marginBottom: spacing.xl, - }, - featureItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm, - }, - featureText: { - ...typography.subhead, - color: colors.text, - marginLeft: spacing.sm, - }, - teaser: { - ...typography.footnote, - fontStyle: 'italic', - color: colors.textTertiary, - textAlign: 'center', - }, - interestButtonContainer: { - marginTop: spacing.xl, - minHeight: 44, - }, -}); +import { DaresScreen } from "../../src/features/dares"; + +export default function DaresRoute() { + return ; +} diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index 6571a11..9e858d1 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -7,7 +7,7 @@ import { BlurView } from 'expo-blur'; import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; -import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery'; +import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; import { colors, spacing, typography, radius } from '../../src/theme'; import type { QuestionPack, Category } from '../../src/types'; @@ -117,6 +117,9 @@ export default function DiscoveryScreen() { {/* Live Draw Activity Tile */} + {/* Dares */} + + {/* Content Rows by Category */} {packsByCategory.map((group, index) => ( { + if (path.startsWith("/v1/dares?filter=active")) return { dares: [incoming] } as never; + if (path.startsWith("/v1/dares?filter=history")) return { dares: [] } as never; + if (path === "/v1/dares/packs") return catalog as never; + if (path === "/v1/dares/stats") return stats as never; + throw new Error(`unexpected path ${path}`); + }); +} + +describe("useDares", () => { + beforeEach(() => jest.restoreAllMocks()); + + it("loads the inbox, catalogue and stats together", async () => { + mockLoad(); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.active).toHaveLength(1); + expect(result.current.history).toHaveLength(0); + expect(result.current.catalog?.entitlement.sends_remaining).toBe(2); + expect(result.current.stats?.active).toBe(1); + expect(result.current.error).toBeNull(); + }); + + it("posts a response and refreshes", async () => { + mockLoad(); + const postSpy = jest.spyOn(apiClient, "post").mockResolvedValue({ dare: { ...incoming, status: "active" } } as never); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.respond("dare-1", "accept"); + }); + expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" }); + }); + + it("routes a premium refusal to the paywall rather than the error banner", async () => { + mockLoad(); + jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402)); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.send({ custom_dare_text: "mine" }); + }); + expect(result.current.paywallReason).toBe("Custom dares require premium"); + expect(result.current.error).toBeNull(); + + act(() => result.current.clearPaywall()); + expect(result.current.paywallReason).toBeNull(); + }); + + it("surfaces non-premium failures inline", async () => { + mockLoad(); + jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("A pending dare cannot change to submitted", 409)); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.submit("dare-1"); + }); + expect(result.current.error).toBe("A pending dare cannot change to submitted"); + expect(result.current.paywallReason).toBeNull(); + }); + + it("reports a load failure without crashing", async () => { + jest.spyOn(apiClient, "get").mockRejectedValue(new ApiError("offline", 500)); + const { result } = renderHook(() => useDares()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe("offline"); + }); +}); diff --git a/apps/mobile/src/components/discovery/DaresTile.tsx b/apps/mobile/src/components/discovery/DaresTile.tsx new file mode 100644 index 0000000..d4709ab --- /dev/null +++ b/apps/mobile/src/components/discovery/DaresTile.tsx @@ -0,0 +1,123 @@ +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import * as Haptics from 'expo-haptics'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { colors, spacing, radius, typography, shadows, tileColors } from '../../theme'; +import { useAuthStore } from '../../store'; +import { useIncomingDareCount } from '../../features/dares/hooks/useIncomingDareCount'; + +const HORIZONTAL_PADDING = spacing.lg; +const TILE_HEIGHT = 100; + +interface DaresTileProps { + delay?: number; +} + +export function DaresTile({ delay = 0 }: DaresTileProps) { + const { user } = useAuthStore(); + const paired = Boolean(user?.couple_id); + const pending = useIncomingDareCount(paired); + + if (!paired) return null; + + const handlePress = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + router.push('/(app)/dares' as never); + }; + + return ( + + + + + + + + + Dares + + {pending > 0 + ? `${pending} dare${pending === 1 ? '' : 's'} waiting for you` + : 'Challenge your partner to something playful'} + + + + {pending > 0 ? ( + + {pending} + + ) : ( + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: spacing.lg, + }, + tileWrapper: { + paddingHorizontal: HORIZONTAL_PADDING, + }, + tile: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: tileColors.amber, + borderRadius: radius.lg, + padding: spacing.md, + height: TILE_HEIGHT, + overflow: 'hidden', + position: 'relative', + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + content: { + flex: 1, + }, + title: { + ...typography.headline, + color: colors.text, + fontWeight: '700', + }, + subtitle: { + ...typography.caption1, + color: 'rgba(255, 255, 255, 0.8)', + marginTop: 2, + }, + badge: { + minWidth: 24, + height: 24, + paddingHorizontal: 6, + borderRadius: radius.full, + backgroundColor: colors.background, + alignItems: 'center', + justifyContent: 'center', + }, + badgeText: { + ...typography.caption1, + color: colors.text, + fontWeight: '700', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.05)', + borderRadius: radius.lg, + pointerEvents: 'none', + }, +}); diff --git a/apps/mobile/src/components/discovery/index.ts b/apps/mobile/src/components/discovery/index.ts index 680efc9..e1c5f2e 100644 --- a/apps/mobile/src/components/discovery/index.ts +++ b/apps/mobile/src/components/discovery/index.ts @@ -1,5 +1,6 @@ -export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile'; -export { ContentRow } from './ContentRow'; -export { CompactHeader } from './CompactHeader'; -export { CircularProgress } from './CircularProgress'; -export { LiveDrawTile } from './LiveDrawTile'; +export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile'; +export { ContentRow } from './ContentRow'; +export { CompactHeader } from './CompactHeader'; +export { CircularProgress } from './CircularProgress'; +export { LiveDrawTile } from './LiveDrawTile'; +export { DaresTile } from './DaresTile'; diff --git a/apps/mobile/src/features/dares/DaresScreen.tsx b/apps/mobile/src/features/dares/DaresScreen.tsx new file mode 100644 index 0000000..16f1606 --- /dev/null +++ b/apps/mobile/src/features/dares/DaresScreen.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import Animated, { FadeIn } from "react-native-reanimated"; +import { GradientBackground } from "../../components/ui"; +import { Paywall } from "../../components/paywall"; +import { colors, featureColors, radius, spacing, typography } from "../../theme"; +import { DareCard, DarePackGrid, SendDareSheet } from "./components"; +import { useDares } from "./hooks/useDares"; +import type { DarePack } from "./types"; + +const ACCENT = featureColors.dares.accent; + +type Tab = "active" | "send" | "history"; + +const TABS: { key: Tab; label: string }[] = [ + { key: "active", label: "Active" }, + { key: "send", label: "Send" }, + { key: "history", label: "History" }, +]; + +export function DaresScreen() { + const dares = useDares(); + const [tab, setTab] = useState("active"); + const [sheetPack, setSheetPack] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [paywallOpen, setPaywallOpen] = useState(false); + + const openPaywall = useCallback(() => { + setSheetOpen(false); + setPaywallOpen(true); + }, []); + + const closePaywall = useCallback(() => { + setPaywallOpen(false); + dares.clearPaywall(); + }, [dares]); + + const entitlement = dares.catalog?.entitlement ?? null; + const premium = entitlement?.is_premium ?? false; + + const handleSelectPack = useCallback( + (pack: DarePack) => { + if (pack.is_premium && !premium) { + openPaywall(); + return; + } + setSheetPack(pack); + setSheetOpen(true); + }, + [premium, openPaywall], + ); + + const handleWriteCustom = useCallback(() => { + if (!premium) { + openPaywall(); + return; + } + setSheetPack(null); + setSheetOpen(true); + }, [premium, openPaywall]); + + const handleAction = useCallback( + (dareId: string, action: "accept" | "decline" | "submit" | "complete" | "cancel") => { + if (action === "accept" || action === "decline") { + void dares.respond(dareId, action); + return; + } + void dares[action](dareId); + }, + [dares], + ); + + // A premium refusal from the API opens the paywall even when the client thought + // the action was allowed (stale entitlement, or a plan that lapsed mid-session). + const { paywallReason } = dares; + useEffect(() => { + if (paywallReason) setPaywallOpen(true); + }, [paywallReason]); + + const list = tab === "history" ? dares.history : dares.active; + + return ( + + void dares.refresh()} tintColor={ACCENT} /> + } + > + ADVENTURE + Dares + + {dares.stats ? ( + + + + + + ) : null} + + {!premium && entitlement?.sends_remaining !== null && entitlement !== null ? ( + + + + {entitlement.sends_remaining} of {entitlement.weekly_send_limit} free dares left this week + + Upgrade + + ) : null} + + + {TABS.map(({ key, label }) => ( + setTab(key)} + style={[styles.tab, tab === key && styles.tabActive]} + > + {label} + + ))} + + + {dares.error ? {dares.error} : null} + + {dares.loading ? ( + + ) : tab === "send" ? ( + + ) : list.length === 0 ? ( + + + + {tab === "active" ? "No dares in play" : "Nothing here yet"} + + + {tab === "active" + ? "Send your partner a dare and see what happens." + : "Completed and declined dares will show up here."} + + {tab === "active" ? ( + setTab("send")}> + Browse dares + + ) : null} + + ) : ( + list.map((dare) => ( + + )) + )} + + + setSheetOpen(false)} + onSend={dares.send} + onRequestPremium={openPaywall} + /> + + { + closePaywall(); + void dares.refresh(); + }} + /> + + ); +} + +function Stat({ value, label }: { value: number; label: string }) { + return ( + + {value} + {label} + + ); +} + +const styles = StyleSheet.create({ + scroll: { flex: 1 }, + container: { paddingHorizontal: spacing.lg, paddingTop: 60, paddingBottom: 120 }, + label: { + ...typography.caption1, fontWeight: "600", letterSpacing: 3, + color: ACCENT, marginBottom: spacing.xs, + }, + title: { ...typography.largeTitle, color: colors.text, marginBottom: spacing.lg }, + statsRow: { flexDirection: "row", gap: spacing.sm, marginBottom: spacing.md }, + stat: { + flex: 1, alignItems: "center", + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, + paddingVertical: spacing.md, + }, + statValue: { ...typography.title2, color: ACCENT }, + statLabel: { ...typography.caption2, color: colors.textSecondary, marginTop: 2 }, + quotaBanner: { + flexDirection: "row", alignItems: "center", gap: spacing.sm, + backgroundColor: "rgba(212,175,55,0.08)", + borderRadius: radius.md, borderWidth: 1, borderColor: "rgba(212,175,55,0.3)", + paddingHorizontal: spacing.md, paddingVertical: spacing.sm, + marginBottom: spacing.md, + }, + quotaText: { ...typography.caption1, color: colors.textSecondary, flex: 1 }, + quotaCta: { ...typography.caption1, color: ACCENT, fontWeight: "700" }, + tabs: { + flexDirection: "row", + backgroundColor: colors.backgroundLight, + borderRadius: radius.full, + padding: 3, + marginBottom: spacing.lg, + }, + tab: { flex: 1, alignItems: "center", paddingVertical: spacing.sm, borderRadius: radius.full }, + tabActive: { backgroundColor: ACCENT }, + tabText: { ...typography.subhead, color: colors.textSecondary, fontWeight: "600" }, + tabTextActive: { color: colors.background }, + loader: { marginTop: spacing.xl }, + error: { ...typography.footnote, color: colors.error, marginBottom: spacing.md }, + empty: { alignItems: "center", paddingVertical: spacing.xxl, gap: spacing.sm }, + emptyTitle: { ...typography.headline, color: colors.text, marginTop: spacing.sm }, + emptyBody: { + ...typography.subhead, color: colors.textSecondary, + textAlign: "center", paddingHorizontal: spacing.lg, + }, + emptyButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, paddingVertical: spacing.sm + 2, + borderRadius: radius.full, borderWidth: 1, borderColor: ACCENT, + }, + emptyButtonText: { ...typography.subhead, color: ACCENT, fontWeight: "600" }, +}); + +export default DaresScreen; diff --git a/apps/mobile/src/features/dares/components/DareCard.tsx b/apps/mobile/src/features/dares/components/DareCard.tsx new file mode 100644 index 0000000..6ef0f57 --- /dev/null +++ b/apps/mobile/src/features/dares/components/DareCard.tsx @@ -0,0 +1,185 @@ +import { memo } from "react"; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { colors, featureColors, radius, spacing, typography } from "../../../theme"; +import type { SentDare } from "../types"; + +const ACCENT = featureColors.dares.accent; + +/** The one action each side owns at each step of the loop. */ +type Action = "accept" | "decline" | "submit" | "complete" | "cancel"; + +export interface DareCardProps { + dare: SentDare; + busy: boolean; + onAction: (dareId: string, action: Action) => void; +} + +function relativeDeadline(expiresAt: string | null): string | null { + if (!expiresAt) return null; + const remainingMs = new Date(expiresAt).getTime() - Date.now(); + if (remainingMs <= 0) return "Time's up"; + const hours = Math.floor(remainingMs / 3_600_000); + if (hours >= 48) return `${Math.floor(hours / 24)} days left`; + if (hours >= 1) return `${hours}h left`; + return `${Math.max(1, Math.round(remainingMs / 60_000))}m left`; +} + +function statusLabel(dare: SentDare): string { + const them = dare.direction === "incoming" ? "They dared you" : "You dared them"; + switch (dare.status) { + case "pending": return dare.direction === "incoming" ? "Waiting on you" : "Waiting on them"; + case "active": return dare.direction === "incoming" ? "Your turn" : "They accepted"; + case "submitted": return dare.direction === "incoming" ? "Waiting to be confirmed" : "They say it's done"; + case "completed": return "Completed"; + case "declined": return "Declined"; + case "cancelled": return "Cancelled"; + case "expired": return "Expired"; + default: return them; + } +} + +/** Only surfaces buttons the viewer is actually allowed to press. */ +function actionsFor(dare: SentDare): { action: Action; label: string; primary?: boolean }[] { + if (dare.direction === "incoming") { + if (dare.status === "pending") { + return [ + { action: "accept", label: "Accept", primary: true }, + { action: "decline", label: "Pass" }, + ]; + } + if (dare.status === "active") return [{ action: "submit", label: "I did it", primary: true }]; + return []; + } + if (dare.status === "submitted") { + return [ + { action: "complete", label: "Confirm", primary: true }, + { action: "cancel", label: "Cancel" }, + ]; + } + if (dare.status === "pending" || dare.status === "active") { + return [{ action: "cancel", label: "Cancel" }]; + } + return []; +} + +export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCardProps) { + const deadline = relativeDeadline(dare.expires_at); + const actions = actionsFor(dare); + + return ( + + + + + {statusLabel(dare)} + + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + + + {dare.text} + + {dare.sender_notes ? β€œ{dare.sender_notes}” : null} + + {deadline ? ( + + + {deadline} + + ) : null} + + {actions.length > 0 ? ( + + {busy ? ( + + ) : ( + actions.map(({ action, label, primary }) => ( + onAction(dare.id, action)} + style={[styles.button, primary ? styles.buttonPrimary : styles.buttonSecondary]} + > + {label} + + )) + )} + + ) : null} + + ); +}); + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.backgroundLight, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + marginBottom: spacing.md, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.sm, + }, + directionRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs }, + status: { + ...typography.caption1, + color: ACCENT, + fontWeight: "600", + letterSpacing: 0.5, + }, + intensityRow: { flexDirection: "row", gap: 3 }, + pip: { + width: 5, + height: 5, + borderRadius: radius.full, + backgroundColor: colors.border, + }, + pipFilled: { backgroundColor: ACCENT }, + text: { ...typography.body, color: colors.text }, + notes: { + ...typography.footnote, + color: colors.textSecondary, + fontStyle: "italic", + marginTop: spacing.sm, + }, + deadlineRow: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + marginTop: spacing.sm, + }, + deadline: { ...typography.caption1, color: colors.textTertiary }, + actions: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + gap: spacing.sm, + marginTop: spacing.md, + minHeight: 36, + }, + button: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + }, + buttonPrimary: { backgroundColor: ACCENT, borderColor: ACCENT }, + buttonSecondary: { backgroundColor: "transparent", borderColor: colors.border }, + buttonText: { ...typography.subhead, fontWeight: "600", color: colors.textSecondary }, + buttonTextPrimary: { color: colors.background }, +}); diff --git a/apps/mobile/src/features/dares/components/DarePackGrid.tsx b/apps/mobile/src/features/dares/components/DarePackGrid.tsx new file mode 100644 index 0000000..f01e212 --- /dev/null +++ b/apps/mobile/src/features/dares/components/DarePackGrid.tsx @@ -0,0 +1,83 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { colors, featureColors, radius, spacing, typography } from "../../../theme"; +import type { DareEntitlement, DarePack } from "../types"; + +const ACCENT = featureColors.dares.accent; + +export interface DarePackGridProps { + packs: DarePack[]; + entitlement: DareEntitlement | null; + onSelectPack: (pack: DarePack) => void; + onWriteCustom: () => void; +} + +export function DarePackGrid({ packs, entitlement, onSelectPack, onWriteCustom }: DarePackGridProps) { + const premium = entitlement?.is_premium ?? false; + + return ( + + + + + + + Write your own + + {premium ? "Make it personal" : "Premium"} + + + + + + {packs.map((pack) => { + const locked = pack.is_premium && !premium; + return ( + onSelectPack(pack)}> + {pack.icon ?? "πŸ”₯"} + + + {pack.name} + {locked ? : null} + + + {pack.description ?? `${pack.dare_count} dares`} + + + + + ); + })} + + ); +} + +const row = { + flexDirection: "row" as const, + alignItems: "center" as const, + backgroundColor: colors.backgroundLight, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.md, +}; + +const styles = StyleSheet.create({ + customRow: { ...row, borderColor: "rgba(212,175,55,0.35)" }, + customIcon: { + width: 36, height: 36, borderRadius: radius.full, + alignItems: "center", justifyContent: "center", + backgroundColor: "rgba(212,175,55,0.12)", + }, + customCopy: { flex: 1 }, + customTitle: { ...typography.headline, color: colors.text }, + customSubtitle: { ...typography.caption1, color: ACCENT, marginTop: 2 }, + packRow: row, + packIcon: { fontSize: 26, width: 36, textAlign: "center" }, + packCopy: { flex: 1 }, + packTitleRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs }, + packTitle: { ...typography.headline, color: colors.text }, + packSubtitle: { ...typography.caption1, color: colors.textSecondary, marginTop: 2 }, +}); diff --git a/apps/mobile/src/features/dares/components/SendDareSheet.tsx b/apps/mobile/src/features/dares/components/SendDareSheet.tsx new file mode 100644 index 0000000..afcb49a --- /dev/null +++ b/apps/mobile/src/features/dares/components/SendDareSheet.tsx @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, + ScrollView, StyleSheet, Text, TextInput, View, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { daresApi } from "../../../lib/daresApi"; +import { colors, featureColors, radius, spacing, typography } from "../../../theme"; +import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types"; + +const ACCENT = featureColors.dares.accent; + +export interface SendDareSheetProps { + visible: boolean; + pack: DarePack | null; + /** Null while the catalogue is loading; drives the custom-dare affordance. */ + canSendCustom: boolean; + onClose: () => void; + onSend: (payload: SendDarePayload) => Promise; + onRequestPremium: () => void; +} + +export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, onRequestPremium }: SendDareSheetProps) { + const [dares, setDares] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [customText, setCustomText] = useState(""); + const [durationHours, setDurationHours] = useState(24); + const [notes, setNotes] = useState(""); + const [sending, setSending] = useState(false); + + useEffect(() => { + if (!visible || !pack) return; + setLoading(true); + setSelectedId(null); + setCustomText(""); + setNotes(""); + daresApi + .packDares(pack.id) + .then((result) => setDares(result.dares)) + .catch(() => setDares([])) + .finally(() => setLoading(false)); + }, [visible, pack]); + + const isCustom = pack === null; + const canSubmit = isCustom ? customText.trim().length > 0 : selectedId !== null; + + const handleSend = useCallback(async () => { + if (!canSubmit || sending) return; + setSending(true); + const payload: SendDarePayload = isCustom + ? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours } + : { dare_id: selectedId!, duration_hours: durationHours }; + if (notes.trim()) payload.sender_notes = notes.trim(); + const sent = await onSend(payload); + setSending(false); + if (sent) onClose(); + }, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]); + + return ( + + + + + + {isCustom ? "Write a dare" : pack?.name} + + + + + + + {isCustom ? ( + canSendCustom ? ( + + ) : ( + + + Custom dares are a premium feature + + ) + ) : loading ? ( + + ) : ( + dares.map((dare) => { + const selected = dare.id === selectedId; + return ( + setSelectedId(dare.id)} + style={[styles.option, selected && styles.optionSelected]} + > + + {dare.text} + + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + + ); + }) + )} + + TIME LIMIT + + {DURATION_OPTIONS.map((option) => { + const selected = option.hours === durationHours; + return ( + setDurationHours(option.hours)} + style={[styles.chip, selected && styles.chipSelected]} + > + + {option.label} + + + ); + })} + + + ADD A NOTE + + + + + {sending ? ( + + ) : ( + Send dare + )} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "flex-end" }, + sheet: { + backgroundColor: colors.background, + borderTopLeftRadius: radius.xxl, + borderTopRightRadius: radius.xxl, + paddingHorizontal: spacing.lg, + paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.lg, + maxHeight: "88%", + }, + handle: { + width: 36, height: 4, borderRadius: radius.full, + backgroundColor: colors.border, alignSelf: "center", marginTop: spacing.sm, + }, + header: { + flexDirection: "row", alignItems: "center", justifyContent: "space-between", + paddingVertical: spacing.md, + }, + title: { ...typography.title3, color: colors.text }, + body: { marginBottom: spacing.md }, + loader: { marginVertical: spacing.xl }, + option: { + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + marginBottom: spacing.sm, + }, + optionSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.08)" }, + optionText: { ...typography.subhead, color: colors.textSecondary }, + optionTextSelected: { color: colors.text }, + intensityRow: { flexDirection: "row", gap: 3, marginTop: spacing.sm }, + pip: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: colors.border }, + pipFilled: { backgroundColor: ACCENT }, + customInput: { + ...typography.body, + color: colors.text, + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + minHeight: 100, + textAlignVertical: "top", + }, + lockedCard: { + flexDirection: "row", alignItems: "center", gap: spacing.sm, + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, borderWidth: 1, borderColor: ACCENT, + padding: spacing.md, + }, + lockedText: { ...typography.subhead, color: ACCENT, fontWeight: "600" }, + sectionLabel: { + ...typography.caption2, + color: colors.textTertiary, + letterSpacing: 2, + fontWeight: "600", + marginTop: spacing.lg, + marginBottom: spacing.sm, + }, + durationRow: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }, + chip: { + paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2, + borderRadius: radius.full, borderWidth: 1, borderColor: colors.border, + }, + chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" }, + chipText: { ...typography.footnote, color: colors.textSecondary }, + chipTextSelected: { color: ACCENT, fontWeight: "600" }, + notesInput: { + ...typography.subhead, + color: colors.text, + backgroundColor: colors.backgroundLight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + }, + sendButton: { + backgroundColor: ACCENT, + borderRadius: radius.full, + paddingVertical: spacing.md, + alignItems: "center", + justifyContent: "center", + minHeight: 50, + }, + sendButtonDisabled: { opacity: 0.4 }, + sendButtonText: { ...typography.headline, color: colors.background, fontWeight: "700" }, +}); diff --git a/apps/mobile/src/features/dares/components/index.ts b/apps/mobile/src/features/dares/components/index.ts new file mode 100644 index 0000000..206ca0b --- /dev/null +++ b/apps/mobile/src/features/dares/components/index.ts @@ -0,0 +1,6 @@ +export { DareCard } from "./DareCard"; +export type { DareCardProps } from "./DareCard"; +export { DarePackGrid } from "./DarePackGrid"; +export type { DarePackGridProps } from "./DarePackGrid"; +export { SendDareSheet } from "./SendDareSheet"; +export type { SendDareSheetProps } from "./SendDareSheet"; diff --git a/apps/mobile/src/features/dares/hooks/useDares.ts b/apps/mobile/src/features/dares/hooks/useDares.ts new file mode 100644 index 0000000..9c21627 --- /dev/null +++ b/apps/mobile/src/features/dares/hooks/useDares.ts @@ -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; + send: (payload: SendDarePayload) => Promise; + respond: (dareId: string, action: "accept" | "decline") => Promise; + submit: (dareId: string) => Promise; + complete: (dareId: string) => Promise; + cancel: (dareId: string) => Promise; +} + +export function useDares(): UseDaresReturn { + const [active, setActive] = useState([]); + const [history, setHistory] = useState([]); + const [catalog, setCatalog] = useState(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [busyDareId, setBusyDareId] = useState(null); + const [error, setError] = useState(null); + const [paywallReason, setPaywallReason] = useState(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) => { + 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]), + }; +} diff --git a/apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts b/apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts new file mode 100644 index 0000000..d440cbb --- /dev/null +++ b/apps/mobile/src/features/dares/hooks/useIncomingDareCount.ts @@ -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; +} diff --git a/apps/mobile/src/features/dares/index.ts b/apps/mobile/src/features/dares/index.ts new file mode 100644 index 0000000..670c43c --- /dev/null +++ b/apps/mobile/src/features/dares/index.ts @@ -0,0 +1,4 @@ +export { DaresScreen, default } from "./DaresScreen"; +export { useDares } from "./hooks/useDares"; +export type { UseDaresReturn } from "./hooks/useDares"; +export * from "./types"; diff --git a/apps/mobile/src/features/dares/types.ts b/apps/mobile/src/features/dares/types.ts new file mode 100644 index 0000000..06632a9 --- /dev/null +++ b/apps/mobile/src/features/dares/types.ts @@ -0,0 +1,83 @@ +export type DareStatus = + | "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled"; + +export interface DarePack { + id: string; + name: string; + description: string | null; + icon: string | null; + is_premium: boolean; + is_explicit: boolean; + sort_order: number; + category_id: string | null; + min_intensity: number | null; + max_intensity: number | null; + avg_intensity: number | null; + dare_count: number; +} + +export interface DareItem { + id: string; + pack_id: string; + text: string; + intensity: number; + suggested_duration_hours: number | null; +} + +export interface SentDare { + id: string; + couple_id: string; + dare_id: string | null; + text: string; + intensity: number; + is_custom: boolean; + sender_id: string; + recipient_id: string; + direction: "incoming" | "outgoing"; + status: DareStatus; + sender_notes: string | null; + sent_at: string; + accepted_at: string | null; + submitted_at: string | null; + completed_at: string | null; + expires_at: string | null; +} + +export interface DareEntitlement { + is_premium: boolean; + can_send_custom: boolean; + weekly_send_limit: number | null; + sends_remaining: number | null; +} + +export interface DareCatalog { + entitlement: DareEntitlement; + packs: DarePack[]; +} + +export interface DareStats { + sent: number; + received: number; + completed_together: number; + active: number; + completed_by_me: number; + completed_by_partner: number; +} + +export interface SendDarePayload { + dare_id?: string; + custom_dare_text?: string; + custom_dare_intensity?: number; + duration_hours?: number | null; + sender_notes?: string | null; +} + +export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [ + { label: "1 hour", hours: 1 }, + { label: "6 hours", hours: 6 }, + { label: "12 hours", hours: 12 }, + { label: "24 hours", hours: 24 }, + { label: "3 days", hours: 72 }, + { label: "1 week", hours: 168 }, + { label: "No time limit", hours: null }, +]; diff --git a/apps/mobile/src/features/index.ts b/apps/mobile/src/features/index.ts index 6a63247..8615c14 100644 --- a/apps/mobile/src/features/index.ts +++ b/apps/mobile/src/features/index.ts @@ -1,3 +1,4 @@ // Features exports export * from './chat'; +export * from './dares'; export * from './profile'; diff --git a/apps/mobile/src/lib/daresApi.ts b/apps/mobile/src/lib/daresApi.ts new file mode 100644 index 0000000..654d212 --- /dev/null +++ b/apps/mobile/src/lib/daresApi.ts @@ -0,0 +1,19 @@ +import { apiClient } from "./apiClient"; +import type { DareCatalog, DareItem, DareStats, SendDarePayload, SentDare } from "../features/dares/types"; + +const dare = (dareId: string) => `/v1/dares/${encodeURIComponent(dareId)}`; + +export const daresApi = { + catalog: () => apiClient.get("/v1/dares/packs"), + packDares: (packId: string) => + apiClient.get<{ dares: DareItem[] }>(`/v1/dares/packs/${encodeURIComponent(packId)}/dares`), + list: (filter: "active" | "history") => + apiClient.get<{ dares: SentDare[] }>(`/v1/dares?filter=${filter}`), + stats: () => apiClient.get("/v1/dares/stats"), + send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload), + respond: (dareId: string, action: "accept" | "decline") => + apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }), + submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`), + complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`), + cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`), +}; diff --git a/docs/dares-feature.md b/docs/dares-feature.md index 5cd464c..4453d3f 100644 --- a/docs/dares-feature.md +++ b/docs/dares-feature.md @@ -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 diff --git a/docs/dares-loop.md b/docs/dares-loop.md new file mode 100644 index 0000000..a4f6b59 --- /dev/null +++ b/docs/dares-loop.md @@ -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:::`, 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. diff --git a/docs/index.md b/docs/index.md index 8dedc45..6803d34 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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`