mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge remote-tracking branch 'origin/staging' into task/android-api-36
This commit is contained in:
commit
1482305810
34 files changed
+2666
-235
No files matched your search
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
@@ -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};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DaresScreen, default } from "./DaresScreen";
|
||||
export { useDares } from "./hooks/useDares";
|
||||
export type { UseDaresReturn } from "./hooks/useDares";
|
||||
export * from "./types";
|
||||
@@ -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,3 +1,4 @@
|
||||
// Features exports
|
||||
export * from './chat';
|
||||
export * from './dares';
|
||||
export * from './profile';
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user