Merge remote-tracking branch 'origin/staging' into task/android-api-36

This commit is contained in:
jjbrunton committed 2026-08-28 20:22:16 +01:00
commit 1482305810
34 files changed
+2666 -235

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications.
--
-- Three concerns land together because the send path depends on all of them:
-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden.
-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history.
-- 3. Outbox rows for every status transition, which is the whole retention argument.
-- `to_regtype` resolves through the active search_path, so this creates the type in
-- the current schema rather than skipping because an unrelated schema already has it.
DO $$
BEGIN
IF to_regtype('content_review_status') IS NULL THEN
CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived');
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE dare_packs
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE dares
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
COMMENT ON COLUMN dare_packs.content_status IS
'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.';
--> statement-breakpoint
COMMENT ON COLUMN dares.content_status IS
'Store-safety review state. Only `allowed` dares are sendable.';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx
ON dare_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dares_content_status_idx
ON dares (pack_id, content_status, intensity);
--> statement-breakpoint
-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check`
-- requires one of the two source columns to survive. Deleting a dare that had ever
-- been sent therefore aborted with a check violation. Snapshotting the text at send
-- time removes the dependency entirely and keeps history stable across content edits.
ALTER TABLE sent_dares
ADD COLUMN IF NOT EXISTS dare_text_snapshot text,
ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer,
ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
--> statement-breakpoint
UPDATE sent_dares sd
SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text),
dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity)
FROM dares d
WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL;
--> statement-breakpoint
UPDATE sent_dares
SET dare_text_snapshot = custom_dare_text,
dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1)
WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares
ALTER COLUMN dare_text_snapshot SET NOT NULL,
ALTER COLUMN dare_intensity_snapshot SET NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_intensity_snapshot_check
CHECK (dare_intensity_snapshot BETWEEN 1 AND 5);
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_participants_distinct_check
CHECK (sender_id <> recipient_id);
--> statement-breakpoint
-- `submitted` closes the loop the original design left open: the recipient performs
-- the dare, so the recipient reports it done and the sender confirms.
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_status_check CHECK (
status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled')
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx
ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted');
--> statement-breakpoint
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION touch_sent_dare();
--> statement-breakpoint
-- Push copy is deliberately generic. Store compliance forbids dare, question,
-- response, or message text in a notification preview.
CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_recipients uuid[];
v_recipient uuid;
v_title text;
v_body text;
v_event text;
BEGIN
IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF;
IF TG_OP = 'INSERT' THEN
v_event := 'sent';
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'New dare';
v_body := 'Your partner sent you a dare';
ELSIF NEW.status IS DISTINCT FROM OLD.status THEN
v_event := NEW.status;
CASE NEW.status
WHEN 'active' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare accepted';
v_body := 'Your partner accepted your dare';
WHEN 'declined' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare declined';
v_body := 'Your partner passed on your dare';
WHEN 'submitted' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare done';
v_body := 'Your partner says they completed your dare';
WHEN 'completed' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare complete';
v_body := 'Your partner confirmed your dare';
WHEN 'cancelled' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare cancelled';
v_body := 'Your partner cancelled the dare';
WHEN 'expired' THEN
-- Both sides lose the dare, so both sides hear about it.
v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id];
v_title := 'Dare expired';
v_body := 'A dare ran out of time';
ELSE
RETURN NEW;
END CASE;
ELSE
RETURN NEW;
END IF;
FOREACH v_recipient IN ARRAY v_recipients LOOP
CONTINUE WHEN NOT EXISTS (
SELECT 1 FROM profiles p
LEFT JOIN notification_preferences np ON np.user_id = p.id
WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true
);
INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload)
VALUES (
'expo',
'dare:' || NEW.id || ':' || v_event || ':' || v_recipient,
v_recipient,
jsonb_build_object(
'title', v_title,
'body', v_body,
'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id)
)
)
ON CONFLICT (dedupe_key) DO NOTHING;
END LOOP;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION queue_dare_operations();
--> statement-breakpoint
-- Mirror the review decision already enforced on the legacy catalogue
-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety
-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so
-- this never overrides a later decision made in the admin portal.
UPDATE dare_packs
SET content_status = 'allowed',
content_review_reason = 'Reviewed as non-sexual romantic gestures'
WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dare_packs
SET content_status = 'archived',
content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares'
WHERE id IN (
'a1b2c3d4-2222-2222-2222-222222222222'::uuid,
'a1b2c3d4-3333-3333-3333-333333333333'::uuid,
'a1b2c3d4-4444-4444-4444-444444444444'::uuid
) AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dares d
SET content_status = dp.content_status,
content_review_reason = CASE
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
ELSE 'Archived with non-store-safe dare pack'
END
FROM dare_packs dp
WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed';
+7
View File
@@ -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
}
]
}
+4
View File
@@ -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);
+480
View File
@@ -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<DareCatalog>;
listPackDares(userId: string, packId: string): Promise<DareItem[]>;
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
send(userId: string, input: SendDareInput): Promise<SentDare>;
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
submit(userId: string, dareId: string): Promise<SentDare>;
complete(userId: string, dareId: string): Promise<SentDare>;
cancel(userId: string, dareId: string): Promise<SentDare>;
stats(userId: string): Promise<DareStats>;
close(): Promise<void>;
}
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<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
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<ContextRow> {
const result = await this.pool.query<ContextRow>(
`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<number> {
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<DareEntitlement> {
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<DareCatalog> {
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<DarePack & QueryResultRow>(
`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<DareItem[]> {
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<DareItem & QueryResultRow>(
`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<SentDare[]> {
const context = await this.context(userId);
if (!context.couple_id) return [];
const result = await this.pool.query<SentDareRecord>(
`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<SentDare> {
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<SentDareRecord>(
`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<SentDare> {
const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id';
const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : '';
const result = await this.pool.query<SentDareRecord>(
`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<SentDare> {
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<SentDare> {
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
}
async complete(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at');
}
async cancel(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null);
}
async stats(userId: string): Promise<DareStats> {
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<Record<string, string>>(
`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<void> {
await this.pool.end();
}
}
+110
View File
@@ -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<T>(operation: () => Promise<T>) {
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);
});
}
}
+79
View File
@@ -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)]);
+12 -2
View File
@@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
async produce(now: Date, limit=100): Promise<ProducerSummary> {
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};
});
}
+1
View File
@@ -48,4 +48,5 @@ export interface ProducerSummary {
weeklySummaries: number;
unpairedReminders: number;
catchupReminders: number;
daresExpired: number;
}
@@ -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(),
};
@@ -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];
+4
View File
@@ -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<void> {
packsRepository.close(),
answersRepository.close(),
chatRepository.close(),
daresRepository.close(),
profileSettingsRepository.close(),
accountOperationsRepository.close(),
mediaRepository.close(),
+206
View File
@@ -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' });
});
});
+117
View File
@@ -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<DaresRepository> = {}) {
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] });
});
});
+1 -1
View File
@@ -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)};
@@ -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',
};
+3 -230
View File
@@ -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";
import { DaresScreen } from "../../src/features/dares";
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 (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Try playful challenges together and create unforgettable moments.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Provocative challenges</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="thermometer-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Multiple intensity levels</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="people-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Designed for two</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Are you ready to play?</Text>
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
);
export default function DaresRoute() {
return <DaresScreen />;
}
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,
},
});
+4 -1
View File
@@ -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 */}
<LiveDrawTile delay={100} />
{/* Dares */}
<DaresTile delay={150} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
@@ -0,0 +1,92 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useDares } from "@/features/dares/hooks/useDares";
import { ApiError, apiClient } from "@/lib/apiClient";
import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2,
is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming",
status: "pending", sender_notes: null, sent_at: "2026-08-28T10:00:00.000Z",
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
};
const catalog = {
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 2 },
packs: [],
};
const stats = { sent: 1, received: 1, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 };
function mockLoad() {
return jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
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");
});
});
@@ -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 (
<Animated.View
entering={FadeInDown.delay(delay).duration(500).springify()}
style={styles.container}
>
<TouchableOpacity onPress={handlePress} activeOpacity={0.85} style={styles.tileWrapper}>
<View style={[styles.tile, shadows.md]}>
<View style={styles.iconContainer}>
<Ionicons name="flame" size={24} color="rgba(255, 255, 255, 0.9)" />
</View>
<View style={styles.content}>
<Text style={styles.title}>Dares</Text>
<Text style={styles.subtitle}>
{pending > 0
? `${pending} dare${pending === 1 ? '' : 's'} waiting for you`
: 'Challenge your partner to something playful'}
</Text>
</View>
{pending > 0 ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{pending}</Text>
</View>
) : (
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
)}
<View style={styles.overlay} />
</View>
</TouchableOpacity>
</Animated.View>
);
}
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',
},
});
@@ -3,3 +3,4 @@ export { ContentRow } from './ContentRow';
export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile';
@@ -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<Tab>("active");
const [sheetPack, setSheetPack] = useState<DarePack | null>(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 (
<GradientBackground>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.container}
refreshControl={
<RefreshControl refreshing={false} onRefresh={() => void dares.refresh()} tintColor={ACCENT} />
}
>
<Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text>
{dares.stats ? (
<View style={styles.statsRow}>
<Stat value={dares.stats.completed_together} label="Completed" />
<Stat value={dares.stats.active} label="In play" />
<Stat value={dares.stats.sent} label="You sent" />
</View>
) : null}
{!premium && entitlement?.sends_remaining !== null && entitlement !== null ? (
<Pressable style={styles.quotaBanner} onPress={openPaywall}>
<Ionicons name="flash-outline" size={14} color={ACCENT} />
<Text style={styles.quotaText}>
{entitlement.sends_remaining} of {entitlement.weekly_send_limit} free dares left this week
</Text>
<Text style={styles.quotaCta}>Upgrade</Text>
</Pressable>
) : null}
<View style={styles.tabs}>
{TABS.map(({ key, label }) => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={[styles.tab, tab === key && styles.tabActive]}
>
<Text style={[styles.tabText, tab === key && styles.tabTextActive]}>{label}</Text>
</Pressable>
))}
</View>
{dares.error ? <Text style={styles.error}>{dares.error}</Text> : null}
{dares.loading ? (
<ActivityIndicator color={ACCENT} style={styles.loader} />
) : tab === "send" ? (
<DarePackGrid
packs={dares.catalog?.packs ?? []}
entitlement={entitlement}
onSelectPack={handleSelectPack}
onWriteCustom={handleWriteCustom}
/>
) : list.length === 0 ? (
<Animated.View entering={FadeIn.duration(300)} style={styles.empty}>
<Ionicons name="sparkles-outline" size={28} color={ACCENT} />
<Text style={styles.emptyTitle}>
{tab === "active" ? "No dares in play" : "Nothing here yet"}
</Text>
<Text style={styles.emptyBody}>
{tab === "active"
? "Send your partner a dare and see what happens."
: "Completed and declined dares will show up here."}
</Text>
{tab === "active" ? (
<Pressable style={styles.emptyButton} onPress={() => setTab("send")}>
<Text style={styles.emptyButtonText}>Browse dares</Text>
</Pressable>
) : null}
</Animated.View>
) : (
list.map((dare) => (
<DareCard
key={dare.id}
dare={dare}
busy={dares.busyDareId === dare.id}
onAction={handleAction}
/>
))
)}
</ScrollView>
<SendDareSheet
visible={sheetOpen}
pack={sheetPack}
canSendCustom={entitlement?.can_send_custom ?? false}
onClose={() => setSheetOpen(false)}
onSend={dares.send}
onRequestPremium={openPaywall}
/>
<Paywall
visible={paywallOpen}
onClose={closePaywall}
onSuccess={() => {
closePaywall();
void dares.refresh();
}}
/>
</GradientBackground>
);
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<View style={styles.stat}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
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;
@@ -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 (
<View style={styles.card}>
<View style={styles.header}>
<View style={styles.directionRow}>
<Ionicons
name={dare.direction === "incoming" ? "arrow-down-circle" : "arrow-up-circle"}
size={16}
color={ACCENT}
/>
<Text style={styles.status}>{statusLabel(dare)}</Text>
</View>
<View style={styles.intensityRow}>
{Array.from({ length: 5 }, (_, index) => (
<View
key={index}
style={[styles.pip, index < dare.intensity && styles.pipFilled]}
/>
))}
</View>
</View>
<Text style={styles.text}>{dare.text}</Text>
{dare.sender_notes ? <Text style={styles.notes}>“{dare.sender_notes}”</Text> : null}
{deadline ? (
<View style={styles.deadlineRow}>
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
<Text style={styles.deadline}>{deadline}</Text>
</View>
) : null}
{actions.length > 0 ? (
<View style={styles.actions}>
{busy ? (
<ActivityIndicator size="small" color={ACCENT} />
) : (
actions.map(({ action, label, primary }) => (
<Pressable
key={action}
onPress={() => onAction(dare.id, action)}
style={[styles.button, primary ? styles.buttonPrimary : styles.buttonSecondary]}
>
<Text style={[styles.buttonText, primary && styles.buttonTextPrimary]}>{label}</Text>
</Pressable>
))
)}
</View>
) : null}
</View>
);
});
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 },
});
@@ -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 (
<View>
<Pressable style={styles.customRow} onPress={onWriteCustom}>
<View style={styles.customIcon}>
<Ionicons name={premium ? "create-outline" : "lock-closed"} size={18} color={ACCENT} />
</View>
<View style={styles.customCopy}>
<Text style={styles.customTitle}>Write your own</Text>
<Text style={styles.customSubtitle}>
{premium ? "Make it personal" : "Premium"}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
{packs.map((pack) => {
const locked = pack.is_premium && !premium;
return (
<Pressable key={pack.id} style={styles.packRow} onPress={() => onSelectPack(pack)}>
<Text style={styles.packIcon}>{pack.icon ?? "🔥"}</Text>
<View style={styles.packCopy}>
<View style={styles.packTitleRow}>
<Text style={styles.packTitle}>{pack.name}</Text>
{locked ? <Ionicons name="lock-closed" size={13} color={ACCENT} /> : null}
</View>
<Text style={styles.packSubtitle} numberOfLines={1}>
{pack.description ?? `${pack.dare_count} dares`}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
);
})}
</View>
);
}
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 },
});
@@ -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<boolean>;
onRequestPremium: () => void;
}
export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, onRequestPremium }: SendDareSheetProps) {
const [dares, setDares] = useState<DareItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [customText, setCustomText] = useState("");
const [durationHours, setDurationHours] = useState<number | null>(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 (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={styles.backdrop}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.sheet}
>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>{isCustom ? "Write a dare" : pack?.name}</Text>
<Pressable onPress={onClose} hitSlop={12}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView style={styles.body} keyboardShouldPersistTaps="handled">
{isCustom ? (
canSendCustom ? (
<TextInput
style={styles.customInput}
value={customText}
onChangeText={setCustomText}
placeholder="Dare your partner to..."
placeholderTextColor={colors.textTertiary}
multiline
maxLength={500}
/>
) : (
<Pressable style={styles.lockedCard} onPress={onRequestPremium}>
<Ionicons name="lock-closed" size={18} color={ACCENT} />
<Text style={styles.lockedText}>Custom dares are a premium feature</Text>
</Pressable>
)
) : loading ? (
<ActivityIndicator color={ACCENT} style={styles.loader} />
) : (
dares.map((dare) => {
const selected = dare.id === selectedId;
return (
<Pressable
key={dare.id}
onPress={() => setSelectedId(dare.id)}
style={[styles.option, selected && styles.optionSelected]}
>
<Text style={[styles.optionText, selected && styles.optionTextSelected]}>
{dare.text}
</Text>
<View style={styles.intensityRow}>
{Array.from({ length: 5 }, (_, index) => (
<View
key={index}
style={[styles.pip, index < dare.intensity && styles.pipFilled]}
/>
))}
</View>
</Pressable>
);
})
)}
<Text style={styles.sectionLabel}>TIME LIMIT</Text>
<View style={styles.durationRow}>
{DURATION_OPTIONS.map((option) => {
const selected = option.hours === durationHours;
return (
<Pressable
key={option.label}
onPress={() => setDurationHours(option.hours)}
style={[styles.chip, selected && styles.chipSelected]}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<Text style={styles.sectionLabel}>ADD A NOTE</Text>
<TextInput
style={styles.notesInput}
value={notes}
onChangeText={setNotes}
placeholder="Optional"
placeholderTextColor={colors.textTertiary}
maxLength={500}
/>
</ScrollView>
<Pressable
onPress={handleSend}
disabled={!canSubmit || sending}
style={[styles.sendButton, (!canSubmit || sending) && styles.sendButtonDisabled]}
>
{sending ? (
<ActivityIndicator size="small" color={colors.background} />
) : (
<Text style={styles.sendButtonText}>Send dare</Text>
)}
</Pressable>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
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" },
});
@@ -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";
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
catalog: DareCatalog | null;
stats: DareStats | null;
loading: boolean;
busyDareId: string | null;
error: string | null;
/** Set when an action was refused for want of premium, so the screen can open the paywall. */
paywallReason: string | null;
clearPaywall: () => void;
refresh: () => Promise<void>;
send: (payload: SendDarePayload) => Promise<boolean>;
respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
submit: (dareId: string) => Promise<void>;
complete: (dareId: string) => Promise<void>;
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
const [stats, setStats] = useState<DareStats | null>(null);
const [loading, setLoading] = useState(true);
const [busyDareId, setBusyDareId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const refresh = useCallback(async () => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
if (cause instanceof ApiError && (cause.status === 402 || cause.status === 403)) {
setPaywallReason(cause.message);
return;
}
setError(cause instanceof ApiError ? cause.message : "Something went wrong");
}, []);
const act = useCallback(
async (dareId: string, operation: () => Promise<unknown>) => {
setBusyDareId(dareId);
try {
await operation();
await refresh();
} catch (cause) {
handleFailure(cause);
} finally {
if (mountedRef.current) setBusyDareId(null);
}
},
[refresh, handleFailure],
);
const send = useCallback(
async (payload: SendDarePayload) => {
try {
await daresApi.send(payload);
await refresh();
return true;
} catch (cause) {
handleFailure(cause);
return false;
}
},
[refresh, handleFailure],
);
return {
active,
history,
catalog,
stats,
loading,
busyDareId,
error,
paywallReason,
clearPaywall: useCallback(() => setPaywallReason(null), []),
refresh,
send,
respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]),
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]),
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
};
}
@@ -0,0 +1,34 @@
import { useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { daresApi } from "../../../lib/daresApi";
/**
* Lightweight counterpart to `useDares` for the home tile: one request on focus
* rather than the four-endpoint poll the full screen runs.
*/
export function useIncomingDareCount(enabled: boolean): number {
const [count, setCount] = useState(0);
useFocusEffect(
useCallback(() => {
if (!enabled) return;
let cancelled = false;
daresApi
.list("active")
.then((result) => {
if (cancelled) return;
setCount(
result.dares.filter(
(dare) => dare.direction === "incoming" && dare.status === "pending",
).length,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [enabled]),
);
return count;
}
+4
View File
@@ -0,0 +1,4 @@
export { DaresScreen, default } from "./DaresScreen";
export { useDares } from "./hooks/useDares";
export type { UseDaresReturn } from "./hooks/useDares";
export * from "./types";
+83
View File
@@ -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 },
];
+1
View File
@@ -1,3 +1,4 @@
// Features exports
export * from './chat';
export * from './dares';
export * from './profile';
+19
View File
@@ -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<DareCatalog>("/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<DareStats>("/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`),
};
+5
View File
@@ -1,5 +1,10 @@
# Dares Feature - Architecture & Implementation Plan
> **SUPERSEDED.** This is the original 2026-01 plan, kept for its competitor analysis
> and decision history. It describes a Supabase Edge Function + RLS architecture that
> the product no longer uses: mobile talks only to the standalone API. For the shipped
> contract see [Dares loop](dares-loop.md).
> **Status**: Phase 1 Complete (Database + Admin Portal)
> **Created**: 2026-01-08
> **Updated**: 2026-01-08
+139
View File
@@ -0,0 +1,139 @@
# Dares loop
Maintained reference for the dares feature: a couple sends each other challenges,
each state change notifies the other partner, and the pair keeps a shared count of
what they have finished together.
The asynchronous shape is the point. Every transition below is a push notification
to whichever partner is not holding the app.
## Ownership
| Concern | Location |
|---|---|
| Schema, triggers, review parity | `apps/api/drizzle/0013_dares_loop.sql` |
| HTTP contract | `apps/api/src/domains/dares/routes.ts` |
| Domain rules and entitlement | `apps/api/src/domains/dares/repository.ts` |
| Expiry sweep | `apps/api/src/domains/operations/repository.ts` (`produce`) |
| Mobile feature | `apps/mobile/src/features/dares/` |
| Catalogue authoring | `apps/admin/src/pages/content/DarePacksPage.tsx`, `DaresPage.tsx` |
## State machine
```
┌── decline ──► declined
pending ──accept──┤
│ └── accept ───► active ──submit──► submitted ──confirm──► completed
│ │ │
└──────────────── cancel ─────────┴───────────────────┘──► cancelled
(sender only, before completion)
any open status + expires_at in the past ──► expired (worker sweep)
```
Actor rules are enforced server-side, not by hiding buttons:
| Transition | Who | Legal from |
|---|---|---|
| accept / decline | recipient | `pending` |
| submit | recipient | `active` |
| complete | sender | `active`, `submitted` |
| cancel | sender | `pending`, `active`, `submitted` |
The recipient performs the dare, so the recipient reports it done (`submitted`) and
the sender confirms (`completed`). An illegal transition returns `409
invalid_transition`; acting on someone else's dare returns `403 not_permitted`;
a dare outside your couple returns `404`, never a hint that it exists.
## Entitlement
Premium is couple-shared — either partner's subscription unlocks both, matching the
answers domain.
| Tier | Can do |
|---|---|
| Anyone, always | Receive, accept, decline, submit, complete, cancel |
| Free sender | Free packs, `FREE_WEEKLY_SEND_LIMIT` sends per rolling 7 days |
| Premium | All packs, unlimited sends, custom dares |
**Receiving is never gated.** A paywall between a partner and the dare they were just
notified about would break the loop the feature exists to create. The quota is counted
per sender over a rolling window, and counts every send — cancelling does not refund it.
## Content safety
`dare_packs` and `dares` carry `content_status` (`unreviewed` / `allowed` / `archived`),
matching the legacy catalogue contract. The catalogue, the pack detail endpoint, and the
send path each independently require `allowed`, so a stale client cannot send unreviewed
content. New content defaults to `unreviewed` and is therefore invisible until an
administrator reviews it.
Push copy is deliberately generic — `"Your partner sent you a dare"`, never the dare
text. Store compliance forbids question, dare, response, or message text in a
notification preview, and `dares.integration.test.ts` asserts no payload leaks it.
## History durability
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
with a check violation.
## Notifications
`queue_dare_operations` (trigger on `sent_dares`) writes one `operations_outbox` row per
transition with dedupe key `dare:<id>:<event>:<recipient>`, gated on
`notification_preferences.dares_enabled`. Delivery, retry, and Expo ticket handling are
the existing worker's job — see `apps/api/src/domains/operations/README.md`.
`expired` notifies both partners; every other transition notifies the one partner who
did not cause it.
## Expiry
The worker's `produce` pass expires any dare whose `expires_at` has passed while still
`pending`, `active`, or `submitted`, reported as `daresExpired` in `ProducerSummary`.
Pending is included deliberately: an invitation the recipient never opened would
otherwise stay open forever.
Durations offered to the client are fixed presets (1, 6, 12, 24, 72, 168 hours, or none)
and validated server-side.
## HTTP contract
| Method | Path | Notes |
|---|---|---|
| GET | `/v1/dares/packs` | Catalogue plus the caller's entitlement |
| GET | `/v1/dares/packs/:packId/dares` | `402` when the pack is premium and the couple is not |
| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller |
| GET | `/v1/dares/stats` | Shared scoreboard |
| GET | `/v1/dares/durations` | Preset list the send sheet renders |
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both |
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
| POST | `/v1/dares/:dareId/submit` | Recipient reports done |
| POST | `/v1/dares/:dareId/complete` | Sender confirms |
| POST | `/v1/dares/:dareId/cancel` | Sender withdraws |
The actor always comes from the bearer identity; ownership fields in a request body are
rejected.
## Mobile
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
channel), and opens the paywall both proactively and in reaction to a `402` — a client
whose entitlement went stale still lands on the paywall rather than an error.
## Tests
- `apps/api/test/dares.integration.test.ts` — real PostgreSQL: entitlement, quota,
transitions, couple isolation, notification fan-out, no-text-in-push, snapshot durability
- `apps/api/test/dares.routes.test.ts` — HTTP contract and error mapping
- `apps/mobile/src/__tests__/hooks/useDares.test.ts` — loading, actions, paywall routing
## Not in this version
Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox
split from the main match chat was cut in favour of status transitions plus the sender's
note. Photo proof, random-dare, and streaks remain unbuilt.
+2 -1
View File
@@ -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`