mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
chore(staging): integrate verified changes
Combine the Android API 36 release, dares loop, RevenueCat documentation correction, and daily engagement features. Resolve migration ordering and preserve both dares expiry and streak reminder operations.
This commit is contained in:
commit
03443fe898
49 files changed
+3087
-562
No files matched your search
@@ -44,8 +44,8 @@
|
|||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "~19.1.7",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
|||||||
@@ -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';
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -96,29 +96,36 @@
|
|||||||
{
|
{
|
||||||
"idx": 13,
|
"idx": 13,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1787994900000,
|
"when": 1787909400000,
|
||||||
"tag": "0013_daily_limit_local_reset",
|
"tag": "0013_dares_loop",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 14,
|
"idx": 14,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1787994960000,
|
"when": 1787994900000,
|
||||||
"tag": "0014_daily_limit_response_index",
|
"tag": "0014_daily_limit_local_reset",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 15,
|
"idx": 15,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1788081360000,
|
"when": 1787994960000,
|
||||||
"tag": "0015_couple_streak_locality",
|
"tag": "0015_daily_limit_response_index",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 16,
|
"idx": 16,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
|
"when": 1788081360000,
|
||||||
|
"tag": "0016_couple_streak_locality",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
"when": 1788081420000,
|
"when": 1788081420000,
|
||||||
"tag": "0016_streak_reminder_preference",
|
"tag": "0017_streak_reminder_preference",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js';
|
|||||||
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||||
import type { ChatRepository } from './domains/chat/repository.js';
|
import type { ChatRepository } from './domains/chat/repository.js';
|
||||||
import { registerChatRoutes } from './domains/chat/routes.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 { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
import type { CoupleService } from './domains/couples/service.js';
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
import type { PacksRepository } from './domains/packs/repository.js';
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -35,6 +37,7 @@ export interface AppDependencies {
|
|||||||
packsRepository?: PacksRepository;
|
packsRepository?: PacksRepository;
|
||||||
answersRepository?: AnswersRepository;
|
answersRepository?: AnswersRepository;
|
||||||
chatRepository?: ChatRepository;
|
chatRepository?: ChatRepository;
|
||||||
|
daresRepository?: DaresRepository;
|
||||||
profileSettingsRepository?: ProfileSettingsRepository;
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
accountOperationsService?: AccountOperationsService;
|
accountOperationsService?: AccountOperationsService;
|
||||||
mediaRepository?: MediaRepository;
|
mediaRepository?: MediaRepository;
|
||||||
@@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
|||||||
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||||
|
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
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> {
|
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||||
return transaction(this.pool, async (client) => {
|
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 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,streakReminders:0};
|
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
||||||
if (!lock.rows[0]?.locked) return empty;
|
if (!lock.rows[0]?.locked) return empty;
|
||||||
|
|
||||||
const released=await client.query<{id:string;name:string}>(
|
const released=await client.query<{id:string;name:string}>(
|
||||||
@@ -138,11 +138,21 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
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]);
|
) 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);
|
const catchup=await this.produceCatchups(client,now,limit);
|
||||||
const streakReminders=await this.produceStreakReminders(client,now);
|
const streakReminders=await this.produceStreakReminders(client,now);
|
||||||
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
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,
|
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,
|
||||||
catchupReminders:catchup,streakReminders};
|
catchupReminders:catchup,streakReminders,daresExpired:expiredDares.rowCount??0};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,4 +49,5 @@ export interface ProducerSummary {
|
|||||||
unpairedReminders: number;
|
unpairedReminders: number;
|
||||||
catchupReminders: number;
|
catchupReminders: number;
|
||||||
streakReminders: number;
|
streakReminders: number;
|
||||||
|
daresExpired: number;
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ interface PreferencesRow extends QueryResultRow {
|
|||||||
weekly_summary_enabled: boolean;
|
weekly_summary_enabled: boolean;
|
||||||
unpaired_reminders_enabled: boolean;
|
unpaired_reminders_enabled: boolean;
|
||||||
catchup_reminders_enabled: boolean;
|
catchup_reminders_enabled: boolean;
|
||||||
|
dares_enabled: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ function toPreferences(row: PreferencesRow): NotificationPreferences {
|
|||||||
weekly_summary_enabled: row.weekly_summary_enabled,
|
weekly_summary_enabled: row.weekly_summary_enabled,
|
||||||
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
||||||
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
||||||
|
dares_enabled: row.dares_enabled,
|
||||||
created_at: row.created_at.toISOString(),
|
created_at: row.created_at.toISOString(),
|
||||||
updated_at: row.updated_at.toISOString(),
|
updated_at: row.updated_at.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const notificationPreferenceKeys = [
|
|||||||
'weekly_summary_enabled',
|
'weekly_summary_enabled',
|
||||||
'unpaired_reminders_enabled',
|
'unpaired_reminders_enabled',
|
||||||
'catchup_reminders_enabled',
|
'catchup_reminders_enabled',
|
||||||
|
'dares_enabled',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from './domains/account-operations/index.js';
|
} from './domains/account-operations/index.js';
|
||||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||||
|
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||||
import { CoupleService } from './domains/couples/service.js';
|
import { CoupleService } from './domains/couples/service.js';
|
||||||
import { PostgresPacksRepository } from './domains/packs/repository.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 packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
||||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
||||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
||||||
|
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
|
||||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
||||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
||||||
const accountOperationsService = new AccountOperationsService(
|
const accountOperationsService = new AccountOperationsService(
|
||||||
@@ -59,6 +61,7 @@ const app = createApp({
|
|||||||
packsRepository,
|
packsRepository,
|
||||||
answersRepository,
|
answersRepository,
|
||||||
chatRepository,
|
chatRepository,
|
||||||
|
daresRepository,
|
||||||
profileSettingsRepository,
|
profileSettingsRepository,
|
||||||
accountOperationsService,
|
accountOperationsService,
|
||||||
mediaRepository,
|
mediaRepository,
|
||||||
@@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
packsRepository.close(),
|
packsRepository.close(),
|
||||||
answersRepository.close(),
|
answersRepository.close(),
|
||||||
chatRepository.close(),
|
chatRepository.close(),
|
||||||
|
daresRepository.close(),
|
||||||
profileSettingsRepository.close(),
|
profileSettingsRepository.close(),
|
||||||
accountOperationsRepository.close(),
|
accountOperationsRepository.close(),
|
||||||
mediaRepository.close(),
|
mediaRepository.close(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/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('Answer integration tests only permit localhost');
|
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('Answer integration tests only permit localhost');
|
||||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0013_daily_limit_local_reset.sql','0014_daily_limit_response_index.sql','0015_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
|
|||||||
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
||||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||||
repository = new PostgresRepository(isolatedUrl.toString());
|
repository = new PostgresRepository(isolatedUrl.toString());
|
||||||
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0013_daily_limit_local_reset.sql']
|
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql']
|
||||||
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
|
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
|
||||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||||
if (statement.trim()) await pool.query(statement);
|
if (statement.trim()) await pool.query(statement);
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
|
|||||||
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
||||||
beforeAll(async()=>{
|
beforeAll(async()=>{
|
||||||
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
|
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
|
||||||
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0013_daily_limit_local_reset.sql','0015_couple_streak_locality.sql','0016_streak_reminder_preference.sql']){
|
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){
|
||||||
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
|
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'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)',[couple,'ABCD2345']);
|
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js';
|
|||||||
import type { OperationItem } from '../src/domains/operations/types.js';
|
import type { OperationItem } from '../src/domains/operations/types.js';
|
||||||
import { loadWorkerConfig } from '../src/workers/config.js';
|
import { loadWorkerConfig } from '../src/workers/config.js';
|
||||||
|
|
||||||
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0};
|
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
||||||
function repository(items:OperationItem[]):OperationsRepository {
|
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),
|
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)};
|
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const preferences = {
|
|||||||
weekly_summary_enabled: true,
|
weekly_summary_enabled: true,
|
||||||
unpaired_reminders_enabled: true,
|
unpaired_reminders_enabled: true,
|
||||||
catchup_reminders_enabled: true,
|
catchup_reminders_enabled: true,
|
||||||
|
dares_enabled: true,
|
||||||
created_at: '2026-08-27T10:00:00.000Z',
|
created_at: '2026-08-27T10:00:00.000Z',
|
||||||
updated_at: '2026-08-27T10:00:00.000Z',
|
updated_at: '2026-08-27T10:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 46
|
versionCode 47
|
||||||
versionName "1.0.5"
|
versionName "1.0.5"
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 46,
|
"versionCode": 47,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function AppLayout() {
|
|||||||
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
||||||
const { fetchStreak } = useStreakStore();
|
const { fetchStreak } = useStreakStore();
|
||||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
const messageTimeoutRef = useRef<number | null>(null);
|
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const hasCheckedInitialBiometric = useRef(false);
|
const hasCheckedInitialBiometric = useRef(false);
|
||||||
const wentToBackgroundAt = useRef<number | null>(null);
|
const wentToBackgroundAt = useRef<number | null>(null);
|
||||||
|
|||||||
@@ -1,232 +1,5 @@
|
|||||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
import { DaresScreen } from "../../src/features/dares";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
|
||||||
import { GradientBackground } from "../../src/components/ui";
|
|
||||||
import { colors, spacing, radius, typography, animations } from "../../src/theme";
|
|
||||||
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
|
|
||||||
|
|
||||||
const ACCENT_COLOR = colors.premium.gold;
|
export default function DaresRoute() {
|
||||||
|
return <DaresScreen />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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, useStreakStore, useSubscriptionStore } from '../../src/store';
|
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
|
||||||
import { GradientBackground } from '../../src/components/ui';
|
import { GradientBackground } from '../../src/components/ui';
|
||||||
import { Paywall } from '../../src/components/paywall';
|
import { Paywall } from '../../src/components/paywall';
|
||||||
import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery';
|
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
||||||
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||||
import type { QuestionPack, Category } from '../../src/types';
|
import type { QuestionPack, Category } from '../../src/types';
|
||||||
@@ -125,6 +125,9 @@ export default function DiscoveryScreen() {
|
|||||||
{/* Live Draw Activity Tile */}
|
{/* Live Draw Activity Tile */}
|
||||||
<LiveDrawTile delay={100} />
|
<LiveDrawTile delay={100} />
|
||||||
|
|
||||||
|
{/* Dares */}
|
||||||
|
<DaresTile delay={150} />
|
||||||
|
|
||||||
{/* Content Rows by Category */}
|
{/* Content Rows by Category */}
|
||||||
{packsByCategory.map((group, index) => (
|
{packsByCategory.map((group, index) => (
|
||||||
<ContentRow
|
<ContentRow
|
||||||
@@ -238,4 +241,3 @@ const styles = StyleSheet.create({
|
|||||||
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
|
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 { CompactHeader } from './CompactHeader';
|
||||||
export { CircularProgress } from './CircularProgress';
|
export { CircularProgress } from './CircularProgress';
|
||||||
export { LiveDrawTile } from './LiveDrawTile';
|
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
|
// Features exports
|
||||||
export * from './chat';
|
export * from './chat';
|
||||||
|
export * from './dares';
|
||||||
export * from './profile';
|
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`),
|
||||||
|
};
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "~19.1.7",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "14.2.35",
|
"eslint-config-next": "14.2.35",
|
||||||
|
|||||||
@@ -94,5 +94,5 @@ the old `couple_streaks` table. The API owns both jobs now. Those legacy writers
|
|||||||
stopped as part of the cutover procedure in
|
stopped as part of the cutover procedure in
|
||||||
[backend deployment and branching](operations/backend-deployments.md); if any hosted
|
[backend deployment and branching](operations/backend-deployments.md); if any hosted
|
||||||
project still has them scheduled, an operator unschedules them directly — agents do not
|
project still has them scheduled, an operator unschedules them directly — agents do not
|
||||||
execute remote DDL. Migration `0015_couple_streak_locality.sql` drops the equivalent
|
execute remote DDL. Migration `0016_couple_streak_locality.sql` drops the equivalent
|
||||||
trigger and functions from the product database defensively.
|
trigger and functions from the product database defensively.
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Dares Feature - Architecture & Implementation Plan
|
# 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)
|
> **Status**: Phase 1 Complete (Database + Admin Portal)
|
||||||
> **Created**: 2026-01-08
|
> **Created**: 2026-01-08
|
||||||
> **Updated**: 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
@@ -21,6 +21,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [Subscriptions](subscription-system.md)
|
- [Subscriptions](subscription-system.md)
|
||||||
- [Redemption codes](product/redemption.md)
|
- [Redemption codes](product/redemption.md)
|
||||||
- [Mobile release process](releasing.md)
|
- [Mobile release process](releasing.md)
|
||||||
|
- [Dares loop](dares-loop.md)
|
||||||
|
|
||||||
## Feature and implementation references
|
## Feature and implementation references
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ partially implemented states. Confirm claims against current code.
|
|||||||
- `admin-question-types-plan.md`
|
- `admin-question-types-plan.md`
|
||||||
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
|
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
|
||||||
- `content-balance-implementation.md`
|
- `content-balance-implementation.md`
|
||||||
- `dares-feature.md`
|
- `dares-feature.md` (superseded by [Dares loop](dares-loop.md))
|
||||||
- `live-draw-feature-plan.md`
|
- `live-draw-feature-plan.md`
|
||||||
- `NEW_QUESTION_TYPES.md`
|
- `NEW_QUESTION_TYPES.md`
|
||||||
- `oversized-file-inventory.md`
|
- `oversized-file-inventory.md`
|
||||||
|
|||||||
Generated
+380
-308
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@ const path = require('path');
|
|||||||
|
|
||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
const MOBILE_NM = path.join(ROOT, 'apps', 'mobile', 'node_modules');
|
const MOBILE_NM = path.join(ROOT, 'apps', 'mobile', 'node_modules');
|
||||||
|
const WEB_NM = path.join(ROOT, 'apps', 'web', 'node_modules');
|
||||||
const ROOT_NM = path.join(ROOT, 'node_modules');
|
const ROOT_NM = path.join(ROOT, 'node_modules');
|
||||||
|
|
||||||
// Packages that must live in apps/mobile/node_modules, not root
|
// Packages that must live in apps/mobile/node_modules, not root
|
||||||
@@ -40,3 +41,25 @@ for (const pkg of PACKAGES_TO_FIX) {
|
|||||||
fs.renameSync(rootPkg, localPkg);
|
fs.renameSync(rootPkg, localPkg);
|
||||||
console.log(`fix-hoisting: moved ${pkg} → apps/mobile/node_modules/`);
|
console.log(`fix-hoisting: moved ${pkg} → apps/mobile/node_modules/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Next and its React-facing tooling must resolve the web workspace's React 18
|
||||||
|
// runtime, not the React 19 runtime hoisted for Expo SDK 54 / React Native.
|
||||||
|
for (const pkg of ['next', 'styled-jsx', 'eslint-config-next']) {
|
||||||
|
const rootPkg = path.join(ROOT_NM, pkg);
|
||||||
|
const localPkg = path.join(WEB_NM, pkg);
|
||||||
|
if (!fs.existsSync(rootPkg) || fs.existsSync(localPkg)) continue;
|
||||||
|
|
||||||
|
fs.mkdirSync(WEB_NM, { recursive: true });
|
||||||
|
fs.renameSync(rootPkg, localPkg);
|
||||||
|
console.log(`fix-hoisting: moved ${pkg} → apps/web/node_modules/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localNext = path.join(WEB_NM, 'next');
|
||||||
|
if (fs.existsSync(localNext)) {
|
||||||
|
const localBinDir = path.join(WEB_NM, '.bin');
|
||||||
|
const localBin = path.join(localBinDir, 'next');
|
||||||
|
fs.mkdirSync(localBinDir, { recursive: true });
|
||||||
|
if (!fs.existsSync(localBin)) {
|
||||||
|
fs.symlinkSync(path.join('..', 'next', 'dist', 'bin', 'next'), localBin);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user