Merge pull request #4 from jjbrunton/staging

chore(release): promote staging to production
This commit is contained in:
jjbrunton authored and GitHub committed 2026-08-29 00:12:03 +01:00
commit 502452c9e4
106 files changed
+8864 -6192

No files matched your search

+5 -1
View File
@@ -1,6 +1,7 @@
name: Verify
on:
workflow_dispatch:
pull_request:
push:
branches: [staging, main]
@@ -11,10 +12,10 @@ concurrency:
jobs:
branch-policy:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Enforce promotion flow
if: github.event_name == 'pull_request'
env:
BASE_BRANCH: ${{ github.base_ref }}
HEAD_BRANCH: ${{ github.head_ref }}
@@ -31,6 +32,9 @@ jobs:
echo "Feature pull requests target staging; promotions target main" >&2
exit 1
fi
- name: Confirm direct verification
if: github.event_name != 'pull_request'
run: echo "Direct branch verification requested for $GITHUB_SHA"
static:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1 +1 @@
20.10.0
20.19.4
+2 -2
View File
@@ -44,8 +44,8 @@
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
+8 -3
View File
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
Daily Response Limit (Free Users)
</CardTitle>
<CardDescription>
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
Limit the number of questions non-premium users can answer per day, on their own local calendar day
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
Maximum number of questions a non-premium user can answer per UTC day.
Maximum number of questions a non-premium user can answer per day.
Premium users (via personal or partner subscription) bypass this limit.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely.
</p>
<p className="text-xs text-muted-foreground">
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
Resets at midnight in the user's own timezone, as reported by their device.
Users who have not reported a timezone yet fall back to 00:00 UTC.
</p>
<p className="text-xs text-muted-foreground">
<strong>Does not count toward the limit:</strong> editing an existing answer, or
answering a question the partner had already answered (catching up is always free).
</p>
</div>
</CardContent>
+223
View File
@@ -0,0 +1,223 @@
-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications.
--
-- Three concerns land together because the send path depends on all of them:
-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden.
-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history.
-- 3. Outbox rows for every status transition, which is the whole retention argument.
-- `to_regtype` resolves through the active search_path, so this creates the type in
-- the current schema rather than skipping because an unrelated schema already has it.
DO $$
BEGIN
IF to_regtype('content_review_status') IS NULL THEN
CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived');
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE dare_packs
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE dares
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
COMMENT ON COLUMN dare_packs.content_status IS
'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.';
--> statement-breakpoint
COMMENT ON COLUMN dares.content_status IS
'Store-safety review state. Only `allowed` dares are sendable.';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx
ON dare_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dares_content_status_idx
ON dares (pack_id, content_status, intensity);
--> statement-breakpoint
-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check`
-- requires one of the two source columns to survive. Deleting a dare that had ever
-- been sent therefore aborted with a check violation. Snapshotting the text at send
-- time removes the dependency entirely and keeps history stable across content edits.
ALTER TABLE sent_dares
ADD COLUMN IF NOT EXISTS dare_text_snapshot text,
ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer,
ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
--> statement-breakpoint
UPDATE sent_dares sd
SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text),
dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity)
FROM dares d
WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL;
--> statement-breakpoint
UPDATE sent_dares
SET dare_text_snapshot = custom_dare_text,
dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1)
WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares
ALTER COLUMN dare_text_snapshot SET NOT NULL,
ALTER COLUMN dare_intensity_snapshot SET NOT NULL;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check;
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_intensity_snapshot_check
CHECK (dare_intensity_snapshot BETWEEN 1 AND 5);
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_participants_distinct_check
CHECK (sender_id <> recipient_id);
--> statement-breakpoint
-- `submitted` closes the loop the original design left open: the recipient performs
-- the dare, so the recipient reports it done and the sender confirms.
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check;
--> statement-breakpoint
ALTER TABLE sent_dares
ADD CONSTRAINT sent_dares_status_check CHECK (
status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled')
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx
ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted');
--> statement-breakpoint
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION touch_sent_dare();
--> statement-breakpoint
-- Push copy is deliberately generic. Store compliance forbids dare, question,
-- response, or message text in a notification preview.
CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_recipients uuid[];
v_recipient uuid;
v_title text;
v_body text;
v_event text;
BEGIN
IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF;
IF TG_OP = 'INSERT' THEN
v_event := 'sent';
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'New dare';
v_body := 'Your partner sent you a dare';
ELSIF NEW.status IS DISTINCT FROM OLD.status THEN
v_event := NEW.status;
CASE NEW.status
WHEN 'active' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare accepted';
v_body := 'Your partner accepted your dare';
WHEN 'declined' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare declined';
v_body := 'Your partner passed on your dare';
WHEN 'submitted' THEN
v_recipients := ARRAY[NEW.sender_id];
v_title := 'Dare done';
v_body := 'Your partner says they completed your dare';
WHEN 'completed' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare complete';
v_body := 'Your partner confirmed your dare';
WHEN 'cancelled' THEN
v_recipients := ARRAY[NEW.recipient_id];
v_title := 'Dare cancelled';
v_body := 'Your partner cancelled the dare';
WHEN 'expired' THEN
-- Both sides lose the dare, so both sides hear about it.
v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id];
v_title := 'Dare expired';
v_body := 'A dare ran out of time';
ELSE
RETURN NEW;
END CASE;
ELSE
RETURN NEW;
END IF;
FOREACH v_recipient IN ARRAY v_recipients LOOP
CONTINUE WHEN NOT EXISTS (
SELECT 1 FROM profiles p
LEFT JOIN notification_preferences np ON np.user_id = p.id
WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true
);
INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload)
VALUES (
'expo',
'dare:' || NEW.id || ':' || v_event || ':' || v_recipient,
v_recipient,
jsonb_build_object(
'title', v_title,
'body', v_body,
'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id)
)
)
ON CONFLICT (dedupe_key) DO NOTHING;
END LOOP;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares;
--> statement-breakpoint
CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares
FOR EACH ROW EXECUTE FUNCTION queue_dare_operations();
--> statement-breakpoint
-- Mirror the review decision already enforced on the legacy catalogue
-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety
-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so
-- this never overrides a later decision made in the admin portal.
UPDATE dare_packs
SET content_status = 'allowed',
content_review_reason = 'Reviewed as non-sexual romantic gestures'
WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dare_packs
SET content_status = 'archived',
content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares'
WHERE id IN (
'a1b2c3d4-2222-2222-2222-222222222222'::uuid,
'a1b2c3d4-3333-3333-3333-333333333333'::uuid,
'a1b2c3d4-4444-4444-4444-444444444444'::uuid
) AND content_status = 'unreviewed';
--> statement-breakpoint
UPDATE dares d
SET content_status = dp.content_status,
content_review_reason = CASE
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
ELSE 'Archived with non-store-safe dare pack'
END
FROM dare_packs dp
WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed';
@@ -0,0 +1,2 @@
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "timezone" text;--> statement-breakpoint
COMMENT ON COLUMN "profiles"."timezone" IS 'IANA zone reported by the client. NULL falls back to UTC when bucketing the daily response limit.';
@@ -0,0 +1,2 @@
-- The daily response limit counts a user's responses within their current local day.
CREATE INDEX IF NOT EXISTS "responses_user_created_idx" ON "responses" ("user_id", "created_at" DESC);
@@ -0,0 +1,15 @@
-- Streaks are counted in the couple's shared local day, not the server's calendar day.
-- The zone is the first reported timezone in member id order (the same ordering that
-- decides user1/user2), falling back to UTC when neither partner has reported one.
COMMENT ON COLUMN "couple_streaks"."last_active_date" IS 'Couple-local date on which either partner last answered. The answered-today flags are read as false once this is not the couple-local today.';--> statement-breakpoint
COMMENT ON COLUMN "couple_streaks"."last_completed_date" IS 'Couple-local date on which both partners last answered. The streak is alive while this is the couple-local today or yesterday.';--> statement-breakpoint
-- Prunes the evening at-risk scan to couples that actually have a streak to lose.
CREATE INDEX IF NOT EXISTS "couple_streaks_live_idx" ON "couple_streaks" ("last_completed_date") WHERE "current_streak" > 0;--> statement-breakpoint
-- The legacy data plane maintained streaks from a trigger that read auth.uid(). The API
-- owns that write now and runs it in the same transaction as the response, so drop the
-- trigger defensively in case a cutover ever carries these objects across.
DROP TRIGGER IF EXISTS "on_response_update_streak" ON "responses";--> statement-breakpoint
DROP FUNCTION IF EXISTS "update_couple_streak"();--> statement-breakpoint
DROP FUNCTION IF EXISTS "reset_daily_streak_flags"();
@@ -0,0 +1,3 @@
-- A shared streak only changes behaviour if somebody is told it is still open, so the
-- evening at-risk nudge gets its own opt-out rather than riding on milestone celebrations.
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "streak_reminders_enabled" boolean NOT NULL DEFAULT true;
@@ -0,0 +1,80 @@
-- Preserve the reviewed production catalogue and its append-only decision history
-- when importing from the legacy Supabase data plane.
DO $$
BEGIN
IF to_regtype('content_entity_type') IS NULL THEN
CREATE TYPE content_entity_type AS ENUM (
'categories', 'question_packs', 'questions', 'dare_packs', 'dares'
);
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE categories
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 question_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 questions
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
CREATE INDEX IF NOT EXISTS categories_content_status_idx
ON categories (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS question_packs_content_status_idx
ON question_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS questions_content_status_idx
ON questions (pack_id, content_status, intensity);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS content_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type content_entity_type NOT NULL,
entity_id uuid NOT NULL,
previous_status content_review_status NOT NULL,
new_status content_review_status NOT NULL,
reason text NOT NULL,
changed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS content_reviews_entity_idx
ON content_reviews (entity_type, entity_id, created_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS content_reviews_created_at_idx
ON content_reviews (created_at DESC);
--> statement-breakpoint
-- The standalone audit model separates the acting profile from the admin row.
-- Retain legacy descriptive fields rather than dropping production history.
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS changed_fields text[],
ADD COLUMN IF NOT EXISTS admin_role text,
ADD COLUMN IF NOT EXISTS admin_username text;
--> statement-breakpoint
-- A small number of legacy Supabase objects have neither an owner nor a live
-- database reference. Preserve their bytes and metadata without making them
-- addressable through the authenticated media API or assigning false ownership.
CREATE TABLE IF NOT EXISTS legacy_media_quarantine (
id uuid PRIMARY KEY,
original_storage_key text NOT NULL UNIQUE,
bucket_id text NOT NULL,
object_name text NOT NULL,
source_owner_id uuid,
metadata jsonb,
mime_type text NOT NULL,
byte_size integer NOT NULL CHECK (byte_size > 0),
reason text NOT NULL,
source_created_at timestamptz NOT NULL,
source_updated_at timestamptz,
imported_at timestamptz NOT NULL DEFAULT now()
);
+42
View File
@@ -92,6 +92,48 @@
"when": 1787908500000,
"tag": "0012_catalogue_field_parity",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1787909400000,
"tag": "0013_dares_loop",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787994900000,
"tag": "0014_daily_limit_local_reset",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787994960000,
"tag": "0015_daily_limit_response_index",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788081360000,
"tag": "0016_couple_streak_locality",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1788081420000,
"tag": "0017_streak_reminder_preference",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1788081480000,
"tag": "0018_cutover_source_parity",
"breakpoints": true
}
]
}
+4
View File
@@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js';
import { registerAnswerRoutes } from './domains/answers/routes.js';
import type { ChatRepository } from './domains/chat/repository.js';
import { registerChatRoutes } from './domains/chat/routes.js';
import type { DaresRepository } from './domains/dares/repository.js';
import { registerDareRoutes } from './domains/dares/routes.js';
import { registerCoupleRoutes } from './domains/couples/routes.js';
import type { CoupleService } from './domains/couples/service.js';
import type { PacksRepository } from './domains/packs/repository.js';
@@ -35,6 +37,7 @@ export interface AppDependencies {
packsRepository?: PacksRepository;
answersRepository?: AnswersRepository;
chatRepository?: ChatRepository;
daresRepository?: DaresRepository;
profileSettingsRepository?: ProfileSettingsRepository;
accountOperationsService?: AccountOperationsService;
mediaRepository?: MediaRepository;
@@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
+1
View File
@@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', {
email: text('email'),
avatarUrl: text('avatar_url'),
pushToken: text('push_token'),
timezone: text('timezone'),
isPremium: boolean('is_premium').notNull().default(false),
coupleId: uuid('couple_id'),
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
+106 -9
View File
@@ -5,6 +5,33 @@ interface ContextRow { couple_id: string | null; gender: string | null; max_inte
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
interface StreakRow { couple_id: string; current_streak: number; longest_streak: number; last_active_date: string | null; last_completed_date: string | null; user1_answered_today: boolean; user2_answered_today: boolean; streak_celebrated_at: number; created_at: Date; updated_at: Date }
/**
* A streak resolved from one partner's point of view. The stored row is positional
* (user1/user2 by member id order) because the writer has to be deterministic, but a
* client cannot act on "user2 has not answered" without re-deriving that ordering, so
* the API answers in terms of you and your partner instead.
*/
export interface CoupleStreakView {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
// A response to a question the partner answered first is catching up, not new exploration.
// Those neither consume nor are blocked by the daily limit, so the reciprocity loop the
// answer-gap mechanic pushes can never be refused by the meter.
const catchUpExemption = "select 1 from responses pr where pr.question_id=r.question_id and pr.couple_id=r.couple_id and pr.user_id<>r.user_id and pr.created_at<r.created_at";
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
@@ -20,12 +47,13 @@ export interface AnswersRepository {
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<Record<string, unknown> | null>;
streak(userId: string): Promise<CoupleStreakView | null>;
close(): Promise<void>;
}
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
private zones: Promise<Set<string>> | null = null;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
@@ -41,7 +69,7 @@ export class PostgresAnswersRepository implements AnswersRepository {
const p = partner.rows[0];
const config = await this.config();
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
if (!ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
const result = await this.pool.query<QuestionRow>(`
@@ -117,7 +145,10 @@ export class PostgresAnswersRepository implements AnswersRepository {
await this.assertEligible(client,userId,ctx,q);
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
if (!existing.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]);
if (!catchingUp.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at });
}
}
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
@@ -157,12 +188,30 @@ export class PostgresAnswersRepository implements AnswersRepository {
return result.rows[0];
}
/**
* The streak day is the couple's shared local day, not the server's. Both partners have
* to land inside the same calendar date for the day to count, so the zone belongs to the
* couple rather than to whoever happens to be answering: the first reported zone in member
* id order, which is the same ordering that decides user1/user2, falling back to UTC when
* neither partner has reported one. Without this a couple in a western zone loses the
* streak at UTC midnight while it is still the same evening for them.
*/
private async coupleZone(client:Pool|PoolClient,reported:(string|null)[]):Promise<string>{
const zone=reported.find(value=>value);
return zone&&(await this.timezoneNames(client)).has(zone)?zone:'UTC';
}
private async streakDay(client:Pool|PoolClient,zone:string){
const r=await client.query<{today:string;yesterday:string}>("select (now() at time zone $1::text)::date::text today,((now() at time zone $1::text)::date-1)::text yesterday",[zone]);
return r.rows[0];
}
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
const users=await client.query<{id:string;timezone:string|null}>('select id,timezone from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
const user1=users.rows[0].id===userId;
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
const row=locked.rows[0]; const {today,yesterday}=await this.streakDay(client,await this.coupleZone(client,users.rows.map(member=>member.timezone)));
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
if(user1)first=true;else second=true;
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
@@ -190,13 +239,61 @@ export class PostgresAnswersRepository implements AnswersRepository {
}
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
async streak(userId:string):Promise<CoupleStreakView|null>{
const ctx=await this.context(this.pool,userId);
const r=await this.pool.query<StreakRow>(`select couple_id,current_streak,longest_streak,last_active_date::text last_active_date,last_completed_date::text last_completed_date,
user1_answered_today,user2_answered_today,streak_celebrated_at,created_at,updated_at from couple_streaks where couple_id=$1`,[ctx.couple_id]);
const row=r.rows[0]; if(!row)return null;
const members=await this.pool.query<{id:string;name:string|null;timezone:string|null}>('select id,name,timezone from profiles where couple_id=$1 order by id',[ctx.couple_id]);
if(members.rows.length<2)return null;
const zone=await this.coupleZone(this.pool,members.rows.map(member=>member.timezone));
const {today,yesterday}=await this.streakDay(this.pool,zone);
// The row is only rewritten when somebody answers, so a lapsed streak keeps its stored
// number until the next response arrives. Reading applies the same day rules the writer
// does rather than showing a count that the next answer will silently reset to 1.
const active=row.last_active_date===today;
const first=active&&row.user1_answered_today; const second=active&&row.user2_answered_today;
const alive=row.last_completed_date===today||row.last_completed_date===yesterday;
const isUser1=members.rows[0].id===userId; const partner=members.rows[isUser1?1:0];
return {
couple_id:row.couple_id,
current_streak:alive?row.current_streak:0,
longest_streak:row.longest_streak,
last_active_date:row.last_active_date,
last_completed_date:row.last_completed_date,
you_answered_today:isUser1?first:second,
partner_answered_today:isUser1?second:first,
partner_name:partner?.name??null,
timezone:zone,
streak_celebrated_at:row.streak_celebrated_at,
created_at:row.created_at.toISOString(),
updated_at:row.updated_at.toISOString(),
};
}
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
private timezoneNames(client:Pool|PoolClient){
// pg_timezone_names is a ~600-row function scan costing ~12ms. The list is static for the
// life of the process, so load it once instead of on every daily-limit check.
this.zones??=client.query<{name:string}>('select name from pg_timezone_names')
.then(r=>new Set(r.rows.map(row=>row.name)))
.catch(cause=>{this.zones=null;throw cause;});
return this.zones;
}
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
const cfg=await this.config(client);const unlimited={responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
if(cfg.daily_response_limit===0)return unlimited;
// Premium is shared across a couple: either partner's subscription lifts the cap for both.
const profile=await client.query<{premium:boolean;zone:string|null}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium,p.timezone zone from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium,p.timezone',[userId]);
if(profile.rows[0]?.premium)return unlimited;
// The window is the user's local calendar day; an unreported or unrecognised zone falls
// back to UTC. Validating in JS keeps an unknown zone from erroring the query without
// paying for a pg_timezone_names scan inside submit()'s locked transaction.
const reported=profile.rows[0]?.zone;
const zone=reported&&(await this.timezoneNames(client)).has(reported)?reported:'UTC';
const r=await client.query<{count:number;reset_at:string}>(`select count(*)::int count,((date_trunc('day',now() at time zone $2::text)+interval '1 day') at time zone $2::text) reset_at from responses r where r.user_id=$1 and r.created_at>=(date_trunc('day',now() at time zone $2::text) at time zone $2::text) and not exists(${catchUpExemption})`,[userId,zone]);
const count=r.rows[0]?.count??0;
return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:r.rows[0]?new Date(r.rows[0].reset_at).toISOString():null,is_blocked:count>=cfg.daily_response_limit};
}
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
+480
View File
@@ -0,0 +1,480 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
export type DareStatus =
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
export const FREE_WEEKLY_SEND_LIMIT = 3;
/** Durations offered in the send sheet. `null` means no deadline. */
export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const;
export interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
dare_count: number;
}
export interface DareItem {
id: string;
pack_id: string;
text: string;
intensity: number;
suggested_duration_hours: number | null;
}
export interface SentDare {
id: string;
couple_id: string;
dare_id: string | null;
text: string;
intensity: number;
is_custom: boolean;
sender_id: string;
recipient_id: string;
direction: 'incoming' | 'outgoing';
status: DareStatus;
sender_notes: string | null;
sent_at: string;
accepted_at: string | null;
submitted_at: string | null;
completed_at: string | null;
expires_at: string | null;
}
export interface DareEntitlement {
is_premium: boolean;
can_send_custom: boolean;
weekly_send_limit: number | null;
sends_remaining: number | null;
}
export interface DareCatalog {
entitlement: DareEntitlement;
packs: DarePack[];
}
export interface DareStats {
sent: number;
received: number;
completed_together: number;
active: number;
completed_by_me: number;
completed_by_partner: number;
}
export interface SendDareInput {
dare_id?: string | null;
custom_dare_text?: string | null;
custom_dare_intensity?: number | null;
duration_hours?: number | null;
sender_notes?: string | null;
}
export class DaresError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: 400 | 402 | 403 | 404 | 409,
) {
super(message);
}
}
interface ContextRow extends QueryResultRow {
couple_id: string | null;
is_premium: boolean;
hide_nsfw: boolean;
max_intensity: number;
partner_id: string | null;
partner_premium: boolean;
}
interface SentDareRecord extends QueryResultRow {
id: string;
couple_id: string;
dare_id: string | null;
dare_text_snapshot: string;
dare_intensity_snapshot: number;
custom_dare_text: string | null;
sender_id: string;
recipient_id: string;
status: DareStatus;
sender_notes: string | null;
sent_at: Date;
accepted_at: Date | null;
submitted_at: Date | null;
completed_at: Date | null;
expires_at: Date | null;
}
export interface DaresRepository {
getCatalog(userId: string): Promise<DareCatalog>;
listPackDares(userId: string, packId: string): Promise<DareItem[]>;
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
send(userId: string, input: SendDareInput): Promise<SentDare>;
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
submit(userId: string, dareId: string): Promise<SentDare>;
complete(userId: string, dareId: string): Promise<SentDare>;
cancel(userId: string, dareId: string): Promise<SentDare>;
stats(userId: string): Promise<DareStats>;
close(): Promise<void>;
}
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
custom_dare_text, sender_id, recipient_id, status, sender_notes,
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
/** Statuses still awaiting somebody's action; everything else is history. */
const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted'];
function iso(value: Date | null): string | null {
return value?.toISOString() ?? null;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
return {
id: row.id,
couple_id: row.couple_id,
dare_id: row.dare_id,
text: row.dare_text_snapshot,
intensity: row.dare_intensity_snapshot,
is_custom: row.custom_dare_text !== null,
sender_id: row.sender_id,
recipient_id: row.recipient_id,
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
status: row.status,
sender_notes: row.sender_notes,
sent_at: row.sent_at.toISOString(),
accepted_at: iso(row.accepted_at),
submitted_at: iso(row.submitted_at),
completed_at: iso(row.completed_at),
expires_at: iso(row.expires_at),
};
}
export class PostgresDaresRepository implements DaresRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
/**
* Premium is couple-shared, matching the answers domain: either partner paying
* unlocks the catalogue for both.
*/
private async context(userId: string): Promise<ContextRow> {
const result = await this.pool.query<ContextRow>(
`select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity,
partner.id as partner_id,
coalesce(partner.is_premium, false) as partner_premium
from profiles p
left join lateral (
select id, is_premium from profiles other
where other.couple_id = p.couple_id and other.id <> p.id
order by other.id limit 1
) partner on true
where p.id = $1`,
[userId],
);
const row = result.rows[0];
if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404);
return row;
}
private static isPremium(context: ContextRow): boolean {
return context.is_premium || context.partner_premium;
}
private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise<number> {
const result = await client.query<{ count: string }>(
`select count(*) as count from sent_dares
where sender_id = $1 and sent_at > now() - interval '7 days'`,
[userId],
);
return Number(result.rows[0]?.count ?? 0);
}
private async entitlement(context: ContextRow, userId: string): Promise<DareEntitlement> {
const premium = PostgresDaresRepository.isPremium(context);
if (premium) {
return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null };
}
const used = await this.sendsThisWeek(userId);
return {
is_premium: false,
can_send_custom: false,
weekly_send_limit: FREE_WEEKLY_SEND_LIMIT,
sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used),
};
}
async getCatalog(userId: string): Promise<DareCatalog> {
const context = await this.context(userId);
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
const [entitlement, packs] = await Promise.all([
this.entitlement(context, userId),
this.pool.query<DarePack & QueryResultRow>(
`select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit,
dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity,
dp.avg_intensity::double precision as avg_intensity,
count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count
from dare_packs dp
left join categories c on c.id = dp.category_id
left join dares d on d.pack_id = dp.id
where dp.is_public = true
and dp.content_status = 'allowed'
and (dp.category_id is null or c.is_public = true)
and ($1::boolean = false or dp.is_explicit = false)
and (dp.min_intensity is null or dp.min_intensity <= $2::integer)
group by dp.id
having count(d.id) filter (where d.content_status = 'allowed') > 0
order by dp.sort_order, dp.name, dp.id`,
[context.hide_nsfw, maxIntensity],
),
]);
return { entitlement, packs: packs.rows };
}
async listPackDares(userId: string, packId: string): Promise<DareItem[]> {
const context = await this.context(userId);
const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>(
`select dp.is_premium, dp.is_explicit
from dare_packs dp
left join categories c on c.id = dp.category_id
where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed'
and (dp.category_id is null or c.is_public = true)`,
[packId],
);
const row = pack.rows[0];
if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404);
if (row.is_explicit && context.hide_nsfw) {
throw new DaresError('pack_not_found', 'Dare pack not found', 404);
}
if (row.is_premium && !PostgresDaresRepository.isPremium(context)) {
throw new DaresError('premium_required', 'This dare pack requires premium', 402);
}
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
const result = await this.pool.query<DareItem & QueryResultRow>(
`select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours
from dares d
where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer
order by d.intensity, d.created_at, d.id`,
[packId, maxIntensity],
);
return result.rows;
}
async listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]> {
const context = await this.context(userId);
if (!context.couple_id) return [];
const result = await this.pool.query<SentDareRecord>(
`select ${SENT_COLUMNS}
from sent_dares sd
where sd.couple_id = $1
and (sd.status = any($2::text[])) = $3::boolean
order by sd.sent_at desc, sd.id desc
limit 200`,
[context.couple_id, OPEN_STATUSES, filter === 'active'],
);
return result.rows.map((row) => toSentDare(row, userId));
}
async send(userId: string, input: SendDareInput): Promise<SentDare> {
const context = await this.context(userId);
if (!context.couple_id || !context.partner_id) {
throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409);
}
const premium = PostgresDaresRepository.isPremium(context);
const duration = input.duration_hours ?? null;
if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) {
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
}
let text: string;
let intensity: number;
let dareId: string | null = null;
let customText: string | null = null;
let customIntensity: number | null = null;
if (input.dare_id) {
// Re-check entitlement against the pack so a stale client cannot send a locked dare.
const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>(
`select d.text, d.intensity, dp.is_premium, dp.is_explicit
from dares d
join dare_packs dp on dp.id = d.pack_id
left join categories c on c.id = dp.category_id
where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed'
and dp.is_public = true and (dp.category_id is null or c.is_public = true)`,
[input.dare_id],
);
const row = dare.rows[0];
if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404);
if (row.is_explicit && context.hide_nsfw) {
throw new DaresError('dare_not_found', 'Dare not found', 404);
}
if (row.is_premium && !premium) {
throw new DaresError('premium_required', 'This dare requires premium', 402);
}
dareId = input.dare_id;
text = row.text;
intensity = row.intensity;
} else {
if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402);
const trimmed = input.custom_dare_text?.trim();
if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400);
customText = trimmed;
customIntensity = input.custom_dare_intensity ?? 1;
text = trimmed;
intensity = customIntensity;
}
// The quota is a paywall boundary, so the count and the insert have to be atomic:
// locking the sender's profile row serializes concurrent sends by the same user.
return transaction(this.pool, async (client) => {
if (!premium) {
await client.query('select 1 from profiles where id = $1 for update', [userId]);
const used = await this.sendsThisWeek(userId, client);
if (used >= FREE_WEEKLY_SEND_LIMIT) {
throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402);
}
}
const result = await client.query<SentDareRecord>(
`insert into sent_dares (
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
dare_text_snapshot, dare_intensity_snapshot,
sender_id, recipient_id, sender_notes, expires_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
)
returning ${BARE_COLUMNS}`,
[
context.couple_id, dareId, customText, customIntensity, text, intensity,
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
],
);
return toSentDare(result.rows[0]!, userId);
});
}
/**
* Applies a status change, enforcing both the actor's role and the legal
* transitions out of the current status. Notifications are emitted by the
* `operations_dare_changed` trigger, so no enqueue happens here.
*/
private async transition(
userId: string,
dareId: string,
actor: 'sender' | 'recipient',
from: readonly DareStatus[],
to: DareStatus,
timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null,
): Promise<SentDare> {
const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id';
const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : '';
const result = await this.pool.query<SentDareRecord>(
`update sent_dares sd
set status = $3::text${setTimestamp}
where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[])
returning ${SENT_COLUMNS}`,
[dareId, userId, to, from],
);
const row = result.rows[0];
if (row) return toSentDare(row, userId);
// Distinguish "not yours / gone" from "wrong state" so the client can react.
const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>(
'select sender_id, recipient_id, status from sent_dares where id = $1',
[dareId],
);
const found = existing.rows[0];
if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) {
throw new DaresError('dare_not_found', 'Dare not found', 404);
}
const actorId = actor === 'sender' ? found.sender_id : found.recipient_id;
if (actorId !== userId) {
throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403);
}
throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409);
}
async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare> {
return action === 'accept'
? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at')
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
}
async submit(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
}
async complete(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at');
}
async cancel(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null);
}
async stats(userId: string): Promise<DareStats> {
const context = await this.context(userId);
if (!context.couple_id) {
return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 };
}
const result = await this.pool.query<Record<string, string>>(
`select
count(*) filter (where sender_id = $2)::text as sent,
count(*) filter (where recipient_id = $2)::text as received,
count(*) filter (where status = 'completed')::text as completed_together,
count(*) filter (where status = any($3::text[]))::text as active,
count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me,
count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner
from sent_dares where couple_id = $1`,
[context.couple_id, userId, OPEN_STATUSES],
);
const row = result.rows[0] ?? {};
const read = (key: string): number => Number(row[key] ?? 0);
return {
sent: read('sent'),
received: read('received'),
completed_together: read('completed_together'),
active: read('active'),
completed_by_me: read('completed_by_me'),
completed_by_partner: read('completed_by_partner'),
};
}
async close(): Promise<void> {
await this.pool.end();
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js';
type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const sendBody = z
.object({
dare_id: id.nullish(),
custom_dare_text: z.string().trim().min(1).max(500).nullish(),
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
sender_notes: z.string().trim().max(500).nullish(),
})
.strict()
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
message: 'Provide either dare_id or custom_dare_text',
});
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof DaresError) return { ok: false as const, cause };
throw cause;
}
}
export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void {
app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS }));
app.get('/v1/dares/packs', async (c) => {
const response = await result(() => repository.getCatalog(c.get('identity').id));
return response.ok
? c.json(response.value)
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/dares/packs/:packId/dares', async (c) => {
const packId = id.safeParse(c.req.param('packId'));
if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data));
return response.ok
? c.json({ dares: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/dares', async (c) => {
const filter = c.req.query('filter') ?? 'active';
if (filter !== 'active' && filter !== 'history') {
return c.json(error('invalid_filter', 'Filter must be active or history'), 400);
}
const response = await result(() => repository.listDares(c.get('identity').id, filter));
return response.ok
? c.json({ dares: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/dares/stats', async (c) => {
const response = await result(() => repository.stats(c.get('identity').id));
return response.ok
? c.json(response.value)
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/dares', async (c) => {
const body = sendBody.safeParse(await c.req.json().catch(() => ({})));
if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400);
const response = await result(() => repository.send(c.get('identity').id, body.data));
return response.ok
? c.json({ dare: response.value }, 201)
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/dares/:dareId/respond', async (c) => {
const dareId = id.safeParse(c.req.param('dareId'));
const body = respondBody.safeParse(await c.req.json().catch(() => ({})));
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400);
const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action));
return response.ok
? c.json({ dare: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
for (const [path, method] of [
['submit', 'submit'],
['complete', 'complete'],
['cancel', 'cancel'],
] as const) {
app.post(`/v1/dares/:dareId/${path}`, async (c) => {
const dareId = id.safeParse(c.req.param('dareId'));
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
const response = await result(() => repository[method](c.get('identity').id, dareId.data));
return response.ok
? c.json({ dare: response.value })
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
}
}
+79
View File
@@ -0,0 +1,79 @@
import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { profiles } from '../../db/schema.js';
// `couples`, `categories`, and the review-status enum are owned by other verticals.
// Their FKs stay in SQL so this fragment does not import across domain boundaries.
export const darePacks = pgTable('dare_packs', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
icon: text('icon'),
isPremium: boolean('is_premium').notNull().default(false),
isPublic: boolean('is_public').notNull().default(true),
isExplicit: boolean('is_explicit').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
categoryId: uuid('category_id'),
minIntensity: integer('min_intensity'),
maxIntensity: integer('max_intensity'),
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]);
export const dares = pgTable('dares', {
id: uuid('id').primaryKey().defaultRandom(),
packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }),
text: text('text').notNull(),
intensity: integer('intensity').notNull().default(1),
suggestedDurationHours: integer('suggested_duration_hours'),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('dares_pack_idx').on(table.packId),
index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity),
]);
// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's
// history must survive the dare being edited, archived, or deleted.
export const sentDares = pgTable('sent_dares', {
id: uuid('id').primaryKey().defaultRandom(),
coupleId: uuid('couple_id').notNull(),
dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }),
customDareText: text('custom_dare_text'),
customDareIntensity: integer('custom_dare_intensity'),
dareTextSnapshot: text('dare_text_snapshot').notNull(),
dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(),
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('pending'),
senderNotes: text('sender_notes'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
submittedAt: timestamp('submitted_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('sent_dares_couple_idx').on(table.coupleId),
index('sent_dares_recipient_status_idx').on(table.recipientId, table.status),
index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt),
check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`),
check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`),
]);
export const dareMessages = pgTable('dare_messages', {
id: uuid('id').primaryKey().defaultRandom(),
sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }),
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]);
+68 -2
View File
@@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
async produce(now: Date, limit=100): Promise<ProducerSummary> {
return transaction(this.pool, async (client) => {
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
if (!lock.rows[0]?.locked) return empty;
const released=await client.query<{id:string;name:string}>(
@@ -138,12 +138,77 @@ export class PostgresOperationsRepository implements OperationsRepository {
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
// Expiring pending dares too: the original design only swept `active`, so an
// invitation the recipient never opened stayed open forever.
const expiredDares=await client.query(
`with due as (
select id from sent_dares
where expires_at is not null and expires_at<=$1
and status in ('pending','active','submitted')
order by expires_at for update skip locked limit $2
) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]);
const catchup=await this.produceCatchups(client,now,limit);
const streakReminders=await this.produceStreakReminders(client,now);
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup};
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,
catchupReminders:catchup,streakReminders,daresExpired:expiredDares.rowCount??0};
});
}
/**
* A shared streak only changes behaviour if somebody hears that it is open before the day
* closes, so this fires in the couple's own late evening rather than on a server clock.
* Only the partner who still owes an answer is notified, and the copy names the other
* partner as an invitation rather than as a debt: guilt between partners is the failure
* mode for this mechanic, not the goal.
*/
private async produceStreakReminders(client: PoolClient, now: Date): Promise<number> {
const result=await client.query(
`with zones as (select name from pg_timezone_names),
couple_zone as (
select r.couple_id,coalesce(z.name,'UTC') zone from (
select p.couple_id,(array_agg(p.timezone order by p.id) filter (where p.timezone is not null))[1] reported
from profiles p join couple_streaks cs on cs.couple_id=p.couple_id and cs.current_streak>0
group by p.couple_id
) r left join zones z on z.name=r.reported
),
at_risk as (
select cs.couple_id,cs.current_streak,cs.last_active_date,cs.user1_answered_today,cs.user2_answered_today,
($1::timestamptz at time zone cz.zone)::date local_date
from couple_streaks cs join couple_zone cz on cz.couple_id=cs.couple_id
where cs.current_streak>0
and cs.last_completed_date=($1::timestamptz at time zone cz.zone)::date-1
and extract(hour from ($1::timestamptz at time zone cz.zone))>=20
),
sides as (
select ar.couple_id,ar.current_streak,to_char(ar.local_date,'YYYY-MM-DD') local_date,
me.id me_id,me.push_token,nullif(btrim(partner.name),'') partner_name,
case when ar.last_active_date is distinct from ar.local_date then false
when me.id<partner.id then ar.user1_answered_today else ar.user2_answered_today end me_answered,
case when ar.last_active_date is distinct from ar.local_date then false
when me.id<partner.id then ar.user2_answered_today else ar.user1_answered_today end partner_answered
from at_risk ar
join profiles me on me.couple_id=ar.couple_id
join profiles partner on partner.couple_id=ar.couple_id and partner.id<>me.id
)
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id,s.me_id,
jsonb_build_object(
'title',case when s.partner_answered then coalesce(s.partner_name,'Your partner')||' answered today'
else 'Your '||s.current_streak||'-day streak' end,
'body',case when s.partner_answered
then 'You two are '||s.current_streak||' days in together. One question keeps it going.'
else 'You and '||coalesce(s.partner_name,'your partner')||' are '||s.current_streak||' days in. One question each keeps it going.' end,
'data',jsonb_build_object('type','streak_reminder','streak',s.current_streak))
from sides s left join notification_preferences np on np.user_id=s.me_id
where not s.me_answered and s.push_token is not null and coalesce(np.streak_reminders_enabled,true)
and not exists(select 1 from operations_outbox o
where o.dedupe_key='streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id)
on conflict(dedupe_key) do nothing`,[now]);
return result.rowCount??0;
}
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
await client.query(
`with behind as (
@@ -202,6 +267,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end
when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end
when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end
when 'streak_reminder' then case when coalesce(np.streak_reminders_enabled,true) then p.push_token end
when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end
when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end
when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end
+2
View File
@@ -48,4 +48,6 @@ export interface ProducerSummary {
weeklySummaries: number;
unpairedReminders: number;
catchupReminders: number;
streakReminders: number;
daresExpired: number;
}
+26
View File
@@ -10,6 +10,10 @@ export const categories = pgTable('categories', {
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
isPublic: boolean('is_public').notNull().default(true),
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(),
});
@@ -26,6 +30,10 @@ export const questionPacks = pgTable('question_packs', {
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
sortOrder: integer('sort_order').notNull().default(0),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
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('question_packs_category_id_idx').on(table.categoryId)]);
@@ -42,6 +50,10 @@ export const questions = pgTable('questions', {
config: jsonb('config').notNull().default({}),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
inverseOf: uuid('inverse_of'),
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('questions_pack_id_idx').on(table.packId)]);
@@ -64,3 +76,17 @@ export const couplePacks = pgTable('couple_packs', {
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
export const contentReviews = pgTable('content_reviews', {
id: uuid('id').primaryKey().defaultRandom(),
entityType: text('entity_type').notNull(),
entityId: uuid('entity_id').notNull(),
previousStatus: text('previous_status').notNull(),
newStatus: text('new_status').notNull(),
reason: text('reason').notNull(),
changedBy: uuid('changed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('content_reviews_entity_idx').on(table.entityType, table.entityId, table.createdAt),
index('content_reviews_created_at_idx').on(table.createdAt),
]);
@@ -16,9 +16,11 @@ interface PreferencesRow extends QueryResultRow {
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
streak_reminders_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
dares_enabled: boolean;
created_at: Date;
updated_at: Date;
}
@@ -49,6 +51,7 @@ const profileColumns: Record<keyof ProfileUpdate, string> = {
onboarding_version: 'onboarding_version',
public_key_jwk: 'public_key_jwk',
push_token: 'push_token',
timezone: 'timezone',
};
function toPreferences(row: PreferencesRow): NotificationPreferences {
@@ -61,9 +64,11 @@ function toPreferences(row: PreferencesRow): NotificationPreferences {
pack_changes_enabled: row.pack_changes_enabled,
new_packs_enabled: row.new_packs_enabled,
streak_milestones_enabled: row.streak_milestones_enabled,
streak_reminders_enabled: row.streak_reminders_enabled,
weekly_summary_enabled: row.weekly_summary_enabled,
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
catchup_reminders_enabled: row.catchup_reminders_enabled,
dares_enabled: row.dares_enabled,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
};
@@ -18,6 +18,8 @@ const profileUpdateSchema = z.object({
onboarding_version: z.number().int().min(0).optional(),
public_key_jwk: z.record(z.unknown()).nullable().optional(),
push_token: z.string().trim().min(1).max(512).nullable().optional(),
// IANA zone from the client; the daily response limit buckets on the user's local day.
timezone: z.string().trim().min(1).max(64).nullable().optional(),
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
const preferenceSchema = z.object({
@@ -6,9 +6,11 @@ export const notificationPreferenceKeys = [
'pack_changes_enabled',
'new_packs_enabled',
'streak_milestones_enabled',
'streak_reminders_enabled',
'weekly_summary_enabled',
'unpaired_reminders_enabled',
'catchup_reminders_enabled',
'dares_enabled',
] as const;
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
@@ -30,6 +32,7 @@ export interface ProfileUpdate {
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
timezone?: string | null;
}
export interface FeedbackSubmission {
+4
View File
@@ -17,6 +17,7 @@ import {
} from './domains/account-operations/index.js';
import { PostgresAnswersRepository } from './domains/answers/repository.js';
import { PostgresChatRepository } from './domains/chat/repository.js';
import { PostgresDaresRepository } from './domains/dares/repository.js';
import { PostgresCoupleRepository } from './domains/couples/repository.js';
import { CoupleService } from './domains/couples/service.js';
import { PostgresPacksRepository } from './domains/packs/repository.js';
@@ -30,6 +31,7 @@ const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
const chatRepository = new PostgresChatRepository(config.databaseUrl);
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
const accountOperationsService = new AccountOperationsService(
@@ -59,6 +61,7 @@ const app = createApp({
packsRepository,
answersRepository,
chatRepository,
daresRepository,
profileSettingsRepository,
accountOperationsService,
mediaRepository,
@@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise<void> {
packsRepository.close(),
answersRepository.close(),
chatRepository.close(),
daresRepository.close(),
profileSettingsRepository.close(),
accountOperationsRepository.close(),
mediaRepository.close(),
+108 -19
View File
@@ -1,14 +1,31 @@
import type { Pool, PoolClient } from 'pg';
import type { DatabaseMigrationOptions, TableResult } from './types.js';
interface TableSpec { name: string; key: string[]; replace?: boolean }
interface TableSpec {
name: string;
key: string[];
replace?: boolean;
/** Target-column to legacy source-column rename. */
columnMap?: Record<string, string>;
/** Target-column SQL expressions for legacy rows that need a derived value. */
sourceExpressions?: Record<string, string>;
/** Columns populated after all rows exist, for self-referential foreign keys. */
deferredColumns?: string[];
/** Keep a newer standalone-backend row when the same key exists in both systems. */
preserveNewer?: boolean;
/** Singleton runtime configuration is owned by the live target during an initial merge. */
preserveExistingOnInitial?: boolean;
}
export const TABLES: TableSpec[] = [
{ name: 'couples', key: ['id'] }, { name: 'profiles', key: ['id'] },
{ name: 'couples', key: ['id'], sourceExpressions: { invite_code: 'upper("couples"."invite_code")' } },
{ name: 'profiles', key: ['id'], preserveNewer: true },
{ name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] },
{ name: 'questions', key: ['id'] }, { name: 'app_config', key: ['id'], replace: true },
{ name: 'ai_config', key: ['id'], replace: true },
{ name: 'questions', key: ['id'], deferredColumns: ['inverse_of'] },
{ name: 'content_reviews', key: ['id'] },
{ name: 'app_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
{ name: 'ai_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
{ name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] },
@@ -18,8 +35,21 @@ export const TABLES: TableSpec[] = [
{ name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] },
{ name: 'feature_interests', key: ['user_id', 'feature'] }, { name: 'live_draw_sessions', key: ['couple_id'] },
{ name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] },
{ name: 'feature_interests', key: ['user_id', 'feature'], columnMap: { feature: 'feature_name' } },
{
name: 'live_draw_sessions', key: ['couple_id'],
sourceExpressions: {
updated_by: '(select p.id from profiles p where p.couple_id="live_draw_sessions"."couple_id" order by p.created_at,p.id limit 1)',
},
},
{ name: 'catchup_reminder_tracking', key: ['user_id'] },
{
name: 'audit_logs', key: ['id'],
sourceExpressions: {
actor_user_id: '"audit_logs"."admin_user_id"',
admin_user_id: '(select au.id from admin_users au where au.user_id="audit_logs"."admin_user_id" limit 1)',
},
},
];
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
@@ -27,18 +57,42 @@ async function columns(pool: Pool | PoolClient, table: string): Promise<string[]
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]);
return result.rows.map((row) => row.column_name);
}
async function jsonColumns(pool: Pool | PoolClient, table: string): Promise<Set<string>> {
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 and data_type in ('json','jsonb')`, [table]);
return new Set(result.rows.map((row) => row.column_name));
}
async function count(pool: Pool | PoolClient, table: string): Promise<number> {
return Number((await pool.query<{ count: string }>(`select count(*) count from ${quote(table)}`)).rows[0]?.count ?? 0);
}
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[]): Promise<number> {
if (spec.replace) await client.query(`delete from ${quote(spec.name)}`);
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[], jsonNames: Set<string>, finalSync: boolean): Promise<number> {
if (spec.preserveExistingOnInitial && !finalSync && await count(client, spec.name)) return 0;
if (spec.replace && finalSync) await client.query(`delete from ${quote(spec.name)}`);
if (!rows.length) return 0;
const update = names.filter((name) => !spec.key.includes(name));
const deferred = new Set(spec.deferredColumns ?? []);
const immediateNames = names.filter((name) => !deferred.has(name));
const update = immediateNames.filter((name) => !spec.key.includes(name));
const conflict = spec.key.map(quote).join(',');
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}` : 'do nothing';
const freshness = spec.preserveNewer && names.includes('updated_at')
? ` where ${quote(spec.name)}.${quote('updated_at')} is null or (excluded.${quote('updated_at')} is not null and excluded.${quote('updated_at')}>${quote(spec.name)}.${quote('updated_at')})`
: '';
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}${freshness}` : 'do nothing';
for (const row of rows) {
await client.query(`insert into ${quote(spec.name)} (${names.map(quote).join(',')}) values (${names.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, names.map((name) => row[name]));
await client.query(
`insert into ${quote(spec.name)} (${immediateNames.map(quote).join(',')}) values (${immediateNames.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`,
immediateNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]),
);
}
if (deferred.size) {
const deferredNames = names.filter((name) => deferred.has(name));
for (const row of rows) {
const values = deferredNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]);
const whereValues = spec.key.map((name) => row[name]);
await client.query(
`update ${quote(spec.name)} set ${deferredNames.map((name, index) => `${quote(name)}=$${index + 1}`).join(',')} where ${spec.key.map((name, index) => `${quote(name)}=$${deferredNames.length + index + 1}`).join(' and ')}`,
[...values, ...whereValues],
);
}
}
return rows.length;
}
@@ -59,16 +113,24 @@ async function pruneRows(client: PoolClient, spec: TableSpec, sourceRows: Record
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
const results: TableResult[] = [];
for (const spec of TABLES) {
const [sourceColumns, targetColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name)]);
const [sourceColumns, targetColumns, targetJsonColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name), jsonColumns(target, spec.name)]);
if (!sourceColumns.length || !targetColumns.length) {
results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' });
continue;
}
const common = targetColumns.filter((name) => sourceColumns.includes(name));
const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name));
const sourceName = (targetName: string) => spec.columnMap?.[targetName] ?? targetName;
const common = targetColumns.filter((name) => sourceColumns.includes(sourceName(name)) || Boolean(spec.sourceExpressions?.[name]));
const consumedSourceColumns = new Set(common.map(sourceName).filter((name) => sourceColumns.includes(name)));
const sourceOnly = sourceColumns.filter((name) => !consumedSourceColumns.has(name));
if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`);
const sourceRows = (await source.query<Record<string, unknown>>(`select ${common.map(quote).join(',')} from ${quote(spec.name)}`)).rows;
let importedRows = 0; let prunedRows = 0;
const selections = common.map((targetName) => {
const expression = spec.sourceExpressions?.[targetName] ?? quote(sourceName(targetName));
return sourceName(targetName) === targetName && !spec.sourceExpressions?.[targetName]
? expression
: `${expression} as ${quote(targetName)}`;
});
const sourceRows = (await source.query<Record<string, unknown>>(`select ${selections.join(',')} from ${quote(spec.name)}`)).rows;
let importedRows = 0; let prunedRows = 0; let reconciledProfileCouples = 0;
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
if (!options.dryRun && !skip) {
const client = await target.connect();
@@ -77,7 +139,19 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
// Historical imports must not enqueue user notifications, classification,
// Discord events, or other operational side effects.
await client.query("set local sauci.suppress_operations = 'on'");
importedRows = await upsertRows(client, spec, common, sourceRows);
importedRows = await upsertRows(client, spec, common, sourceRows, targetJsonColumns, options.finalSync);
if (spec.name === 'profiles' && !options.finalSync) {
for (const row of sourceRows) {
if (!row.couple_id) continue;
const reconciled = await client.query(
`update profiles p set couple_id=$2
where p.id=$1 and p.couple_id is distinct from $2
and (select count(*) from profiles member where member.couple_id=p.couple_id)<=1`,
[row.id, row.couple_id],
);
reconciledProfileCouples += reconciled.rowCount ?? 0;
}
}
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
await client.query('commit');
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
@@ -85,8 +159,23 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
await options.saveCheckpoint?.(options.checkpoint);
}
const targetRows = await count(target, spec.name);
const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean);
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
const targetKeys = targetColumns.length
? (await target.query<Record<string, unknown>>(`select ${spec.key.map(quote).join(',')} from ${quote(spec.name)}`)).rows
: [];
const keyValue = (row: Record<string, unknown>) => JSON.stringify(spec.key.map((key) => row[key]));
const targetKeySet = new Set(targetKeys.map(keyValue));
const coveredSourceRows = sourceRows.filter((row) => targetKeySet.has(keyValue(row))).length;
const preservedSingleton = Boolean(spec.preserveExistingOnInitial && !options.finalSync && targetRows && !coveredSourceRows);
const parity = options.finalSync ? sourceRows.length === targetRows : preservedSingleton || coveredSourceRows === sourceRows.length;
const preservedTargetRows = Math.max(0, targetRows - coveredSourceRows);
const notes = [
skip ? 'resumed from checkpoint' : '',
preservedSingleton ? 'kept existing live target singleton during initial merge' : '',
reconciledProfileCouples ? `restored ${reconciledProfileCouples} established couple membership(s) over one-person target couples` : '',
!options.finalSync && preservedTargetRows ? `preserved ${preservedTargetRows} target-only row(s)` : '',
sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : '',
].filter(Boolean);
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: parity ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
}
return results;
}
+83 -27
View File
@@ -16,7 +16,9 @@ interface StorageObject {
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback',
};
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// PostgreSQL's uuid type accepts legacy UUID-shaped identifiers whose version
// nibble is not RFC 4122 (the production fixture profile uses version 0).
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
function safeStorageKey(bucket: string, name: string): string {
@@ -50,22 +52,57 @@ export function createSupabaseDownloader(baseUrl: string, serviceRoleKey: string
};
}
async function mediaRelation(target: Pool, object: StorageObject, key: string) {
const first = object.name.split('/')[0];
const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null);
if (!owner) throw new Error(`Cannot determine owner for ${key}`);
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [owner]);
if (!profile.rows[0]) throw new Error(`Storage owner does not exist in target for ${key}`);
let matchId: string | null = null; let questionId: string | null = null;
if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
if (object.bucket_id === 'response-media') {
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
if (uuidPattern.test(candidate)) questionId = candidate;
const legacyPredicate = (column: string) => `(
${column}=$1 or ${column}=$2 or
(right(${column},length($1))=$1 and position('/'||$3||'/' in ${column})>0)
)`;
async function sourceReference(source: Pool, object: StorageObject) {
const values = [object.name, `${object.bucket_id}/${object.name}`, object.bucket_id];
if (object.bucket_id === 'avatars') {
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
`select id owner_id,null::uuid match_id,null::uuid question_id from profiles where ${legacyPredicate('avatar_url')} limit 1`, values,
)).rows[0] ?? null;
}
return { ownerId: owner, coupleId: profile.rows[0].couple_id, matchId, questionId };
if (object.bucket_id === 'chat-media') {
return (await source.query<{ owner_id: string; match_id: string; question_id: null }>(
`select user_id owner_id,match_id,null::uuid question_id from messages where ${legacyPredicate('media_path')} limit 1`, values,
)).rows[0] ?? null;
}
if (object.bucket_id === 'feedback-screenshots') {
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
`select user_id owner_id,null::uuid match_id,null::uuid question_id from feedback where ${legacyPredicate('screenshot_url')} limit 1`, values,
)).rows[0] ?? null;
}
return (await source.query<{ owner_id: string; match_id: null; question_id: string }>(
`select user_id owner_id,null::uuid match_id,question_id from responses
where response_data is not null and ${legacyPredicate("response_data->>'media_path'")} limit 1`, values,
)).rows[0] ?? null;
}
async function rewriteLegacyReference(target: Pool, object: StorageObject): Promise<void> {
async function mediaRelation(source: Pool, target: Pool, object: StorageObject) {
const first = object.name.split('/')[0];
const reference = await sourceReference(source, object);
const ownerCandidates = [object.owner_id, reference?.owner_id, object.bucket_id === 'chat-media' ? null : first]
.filter((value): value is string => Boolean(value && uuidPattern.test(value)));
let ownerId: string | null = null; let coupleId: string | null = null;
for (const candidate of ownerCandidates) {
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [candidate]);
if (profile.rows[0]) { ownerId = candidate; coupleId = profile.rows[0].couple_id; break; }
}
let matchId = reference?.match_id && uuidPattern.test(reference.match_id) ? reference.match_id : null;
if (!matchId && object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
if (matchId && !(await target.query('select 1 from matches where id=$1', [matchId])).rowCount) matchId = null;
let questionId = reference?.question_id && uuidPattern.test(reference.question_id) ? reference.question_id : null;
if (object.bucket_id === 'response-media') {
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
if (!questionId && uuidPattern.test(candidate)) questionId = candidate;
}
if (questionId && !(await target.query('select 1 from questions where id=$1', [questionId])).rowCount) questionId = null;
return { ownerId, coupleId, matchId, questionId };
}
async function rewriteLegacyReference(source: Pool, target: Pool, object: StorageObject): Promise<void> {
const reference = `media:${object.id}`;
const legacyName = object.name;
const legacyKey = `${object.bucket_id}/${legacyName}`;
@@ -78,7 +115,11 @@ async function rewriteLegacyReference(target: Pool, object: StorageObject): Prom
} else if (object.bucket_id === 'chat-media') {
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
} else if (object.bucket_id === 'feedback-screenshots') {
await target.query(`update feedback set screenshot_url=$1 where ${matchesLegacy('screenshot_url')}`, [reference, legacyName, legacyKey]);
const sourceRows = await source.query<{ id: string }>(
`select id from feedback where ${legacyPredicate('screenshot_url')}`,
[legacyName, legacyKey, object.bucket_id],
);
if (sourceRows.rows.length) await target.query('update feedback set screenshot_media_id=$1 where id=any($2::uuid[])', [object.id, sourceRows.rows.map((row) => row.id)]);
} else if (object.bucket_id === 'response-media') {
await target.query(`
update responses
@@ -92,7 +133,7 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
const schema = options.storageSchema ?? 'storage';
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
const objects = await source.query<StorageObject>(`select id,bucket_id,name,owner_id,metadata,created_at,updated_at from ${quote(schema)}.objects where bucket_id=any($1::text[]) order by bucket_id,name`, [Object.keys(BUCKET_KIND)]);
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, copiedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, preservedTargetFiles: 0, copiedFiles: 0, quarantinedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
const sourceKeys = new Set<string>();
for (const object of objects.rows) {
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
@@ -101,10 +142,11 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
const marker = options.checkpoint.storage[key];
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
if (options.dryRun) { result.skippedFiles += 1; continue; }
const registered = await target.query('select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3', [object.id, key, marker.byteSize]);
const registered = await target.query(`select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3
union all select 1 from legacy_media_quarantine where id=$1 and original_storage_key=$2 and byte_size=$3`, [object.id, key, marker.byteSize]);
try {
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
await rewriteLegacyReference(target, object);
await rewriteLegacyReference(source, target, object);
result.skippedFiles += 1;
continue;
}
@@ -112,16 +154,25 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
}
if (options.dryRun) continue;
try {
const relation = await mediaRelation(source, target, object);
const bytes = await options.download(object.bucket_id, object.name);
if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`);
const relation = await mediaRelation(target, object, key);
const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path);
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
await rewriteLegacyReference(target, object);
if (relation.ownerId) {
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
await target.query('delete from legacy_media_quarantine where id=$1', [object.id]);
await rewriteLegacyReference(source, target, object);
} else {
await target.query(`insert into legacy_media_quarantine(id,original_storage_key,bucket_id,object_name,source_owner_id,metadata,mime_type,byte_size,reason,source_created_at,source_updated_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) on conflict(id) do update set original_storage_key=excluded.original_storage_key,bucket_id=excluded.bucket_id,object_name=excluded.object_name,source_owner_id=excluded.source_owner_id,metadata=excluded.metadata,mime_type=excluded.mime_type,byte_size=excluded.byte_size,reason=excluded.reason,source_created_at=excluded.source_created_at,source_updated_at=excluded.source_updated_at`,
[object.id, key, object.bucket_id, object.name, object.owner_id, object.metadata, mime, bytes.byteLength, 'legacy object has no verifiable owner or live reference', object.created_at, object.updated_at]);
await target.query('delete from media_objects where id=$1', [object.id]);
result.quarantinedFiles += 1;
}
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
} catch (error) {
@@ -131,16 +182,21 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
}
if (!options.dryRun) {
if (options.prune) {
const existing = await target.query<{ id: string; storage_key: string }>("select id,storage_key from media_objects where kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
const existing = await target.query<{ id: string; storage_key: string; quarantined: boolean }>(`
select id,storage_key,false quarantined from media_objects where kind=any($1::text[])
union all select id,original_storage_key storage_key,true quarantined from legacy_media_quarantine
`, [Object.values(BUCKET_KIND)]);
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
await target.query('delete from media_objects where id=$1', [row.id]);
await target.query(row.quarantined ? 'delete from legacy_media_quarantine where id=$1' : 'delete from media_objects where id=$1', [row.id]);
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
}
await options.saveCheckpoint?.(options.checkpoint);
}
const registered = await target.query<{ storage_key: string; byte_size: number }>("select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
result.targetFiles = registered.rows.length;
const registered = await target.query<{ storage_key: string; byte_size: number }>(`select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])
union all select original_storage_key storage_key,byte_size from legacy_media_quarantine`, [Object.values(BUCKET_KIND)]);
result.targetFiles = registered.rows.filter((row) => sourceKeys.has(row.storage_key)).length;
result.preservedTargetFiles = registered.rows.length - result.targetFiles;
for (const row of registered.rows) {
try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); }
catch { result.missingOnDisk.push(row.storage_key); }
+2
View File
@@ -22,7 +22,9 @@ export interface TableResult {
export interface StorageResult {
sourceFiles: number;
targetFiles: number;
preservedTargetFiles: number;
copiedFiles: number;
quarantinedFiles: number;
skippedFiles: number;
prunedFiles: number;
failedFiles: number;
+34 -4
View File
@@ -5,13 +5,43 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
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');
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';
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']){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'])",[q,pack,concurrentQ,femaleOnlyQ]);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();});
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','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('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('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});});
it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});});
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});});
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,you_answered_today:true,partner_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
it('answers the streak from the caller side rather than by stored position',async()=>{await pool.query("update profiles set name='Alex' where id=$1",[u2]);await pool.query("update profiles set name='Sam' where id=$1",[u1]);await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=0,last_active_date=null,last_completed_date=null,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);
await repo.submit(u1,{questionId:q,answer:'yes'});
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
await repo.submit(u1,{questionId:q,answer:'yes'});
const expected=await pool.query<{day:string}>("select (now() at time zone 'Pacific/Auckland')::date::text as day");
expect(await repo.streak(u1)).toMatchObject({timezone:'Pacific/Auckland',last_active_date:expected.rows[0].day});
// The lower member id decides the zone, so an unreported zone there falls through to the partner.
await pool.query('update profiles set timezone=null where id=$1',[u1]);
expect((await repo.streak(u1))?.timezone).toBe('America/Los_Angeles');
await pool.query("update profiles set timezone='Not/AZone' where id=$1",[u1]);
expect((await repo.streak(u1))?.timezone).toBe('UTC');
await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);});
it('reports a lapsed streak as broken before the next answer rewrites it',async()=>{await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=5,longest_streak=9,last_active_date=current_date-3,last_completed_date=current_date-3,user1_answered_today=true,user2_answered_today=true where couple_id=$1",[couple]);
expect(await repo.streak(u1)).toMatchObject({current_streak:0,longest_streak:9,you_answered_today:false,partner_answered_today:false});
// A streak completed yesterday is still alive and still shows its count.
await pool.query('update couple_streaks set last_completed_date=current_date-1,last_active_date=current_date-1 where couple_id=$1',[couple]);
expect(await repo.streak(u1)).toMatchObject({current_streak:5,you_answered_today:false});});
const resetLimit=async(limit:number,timezone:string|null=null)=>{await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query('update app_config set daily_response_limit=$1',[limit]);await pool.query('update profiles set timezone=$2 where id=$1',[u1,timezone]);};
it('serializes the daily limit across different questions',async()=>{await resetLimit(1);const results=await Promise.allSettled([repo.submit(u1,{questionId:limitQ1,answer:'yes'}),repo.submit(u1,{questionId:limitQ2,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,remaining:0,is_blocked:true});});
it('exempts catching up on a question the partner answered first',async()=>{await resetLimit(1);await repo.submit(u2,{questionId:catchUpQ,answer:'yes'});await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:catchUpQ,answer:'yes'})).resolves.toBeTruthy();expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit'});});
it('reports the daily limit error with the shape the client renders',async()=>{await resetLimit(1);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit',status:429,details:{daily_limit:1,responses_today:1,remaining:0}});});
it('buckets the window on the user local day and falls back to UTC',async()=>{await resetLimit(5,'Pacific/Auckland');const nz=await repo.dailyLimit(u1);const nzBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'Pacific/Auckland')+interval '1 day') at time zone 'Pacific/Auckland') expected");expect(nz.reset_at).toBe(new Date(nzBoundary.rows[0].expected).toISOString());
const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected");
for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}});
it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);});
});
+3 -1
View File
@@ -15,7 +15,9 @@ function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthId
describe('answer routes',()=>{
it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});});
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);});
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
// The client renders the limit screen straight from this body, so its shape is a contract.
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
});
+2 -1
View File
@@ -28,7 +28,8 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresRepository(isolatedUrl.toString());
const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8');
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');
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
+206
View File
@@ -0,0 +1,206 @@
import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
const url = process.env.DATABASE_URL;
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
if (url && !local) throw new Error('Dare integration tests only permit localhost');
describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
const admin = new Pool({ connectionString: url });
const schema = `dares_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repo: PostgresDaresRepository;
const sender = '11111111-1111-4111-8111-111111111111';
const recipient = '22222222-2222-4222-8222-222222222222';
const outsider = '33333333-3333-4333-8333-333333333333';
const couple = '44444444-4444-4444-8444-444444444444';
const otherCouple = '55555555-5555-4555-8555-555555555555';
const freePack = '66666666-6666-4666-8666-666666666666';
const premiumPack = '77777777-7777-4777-8777-777777777777';
const unreviewedPack = '88888888-8888-4888-8888-888888888888';
const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
beforeAll(async () => {
await admin.query(`create schema "${schema}"`);
const isolated = new URL(url!);
isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() });
repo = new PostgresDaresRepository(isolated.toString());
const dir = new URL('../drizzle/', import.meta.url);
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
for (const name of files) {
const sql = await readFile(new URL(name, dir), 'utf8');
for (const statement of sql.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
}
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']);
await pool.query(
'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)',
[sender, recipient, outsider, couple, otherCouple],
);
await pool.query(
`insert into dare_packs(id,name,is_premium,content_status) values
($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`,
[freePack, premiumPack, unreviewedPack],
);
await pool.query(
`insert into dares(id,pack_id,text,intensity,content_status) values
($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`,
[freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack],
);
});
beforeEach(async () => {
await pool.query('delete from sent_dares');
await pool.query('delete from operations_outbox');
await pool.query('update profiles set is_premium=false');
});
afterAll(async () => {
await repo.close();
await pool.end();
await admin.query(`drop schema "${schema}" cascade`);
await admin.end();
});
it('hides unreviewed packs and dares from the catalogue', async () => {
const catalog = await repo.getCatalog(sender);
const ids = catalog.packs.map((pack) => pack.id);
expect(ids).toContain(freePack);
expect(ids).toContain(premiumPack);
expect(ids).not.toContain(unreviewedPack);
await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' });
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
});
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' });
// Premium is couple-shared: the partner paying unlocks the sender.
await pool.query('update profiles set is_premium=true where id=$1', [recipient]);
expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null });
await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1);
const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 });
expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 });
});
it('enforces the free weekly send allowance without blocking the response side', async () => {
for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) {
await repo.send(sender, { dare_id: freeDare });
}
expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0);
await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' });
// The quota is per-sender, so the recipient can still answer back.
const reply = await repo.send(recipient, { dare_id: freeDare });
expect(reply.direction).toBe('outgoing');
});
it('serializes concurrent sends so the free allowance cannot be raced', async () => {
const attempts = await Promise.allSettled(
Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })),
);
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT);
const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]);
expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT));
});
it('walks the full loop and records each transition exactly once', async () => {
const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' });
expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' });
expect(dare.expires_at).not.toBeNull();
expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active');
expect((await repo.submit(recipient, dare.id)).status).toBe('submitted');
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
const events = await pool.query<{ dedupe_key: string; recipient_id: string }>(
"select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at",
);
expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']);
expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]);
});
it('never puts dare text in a notification payload', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await repo.respond(recipient, dare.id, 'accept');
const leaked = await pool.query<{ count: string }>(
"select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'",
);
expect(leaked.rows[0]!.count).toBe('0');
});
it('respects the dares notification preference', async () => {
await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]);
const dare = await repo.send(sender, { dare_id: freeDare });
const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]);
expect(events.rowCount).toBe(0);
// The sender has not opted out, so their side still fires.
await repo.respond(recipient, dare.id, 'decline');
const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]);
expect(senderEvents.rowCount).toBe(1);
await pool.query('delete from notification_preferences where user_id=$1', [recipient]);
});
it('enforces actor and state on every transition', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' });
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' });
await repo.respond(recipient, dare.id, 'accept');
await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' });
expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled');
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
});
it('keeps history intact when the source dare is deleted', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await pool.query('delete from dares where id=$1', [freeDare]);
const history = await repo.listDares(sender, 'active');
expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 });
await pool.query(
"insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')",
[freeDare, freePack],
);
});
it('scopes listing and stats to the couple', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await repo.respond(recipient, dare.id, 'accept');
await repo.submit(recipient, dare.id);
await repo.complete(sender, dare.id);
expect(await repo.listDares(outsider, 'active')).toEqual([]);
expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 });
expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 });
expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 });
const active = await repo.listDares(sender, 'active');
const history = await repo.listDares(sender, 'history');
expect(active).toEqual([]);
expect(history).toHaveLength(1);
expect(history[0]!.direction).toBe('outgoing');
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
});
it('rejects durations outside the offered presets', async () => {
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
});
it('refuses to send without a partner', async () => {
await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' });
});
});
+117
View File
@@ -0,0 +1,117 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js';
import { registerDareRoutes } from '../src/domains/dares/routes.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const partner = '22222222-2222-4222-8222-222222222222';
const dareId = '33333333-3333-4333-8333-333333333333';
const packId = '44444444-4444-4444-8444-444444444444';
const catalogueDareId = '55555555-5555-4555-8555-555555555555';
const dare: SentDare = {
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
direction: 'outgoing', status: 'pending', sender_notes: null,
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
};
function setup(overrides: Partial<DaresRepository> = {}) {
const repository: DaresRepository = {
getCatalog: vi.fn(async () => ({
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 },
packs: [],
})),
listPackDares: vi.fn(async () => []),
listDares: vi.fn(async () => [dare]),
send: vi.fn(async () => dare),
respond: vi.fn(async () => ({ ...dare, status: 'active' as const })),
submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })),
complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })),
cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })),
stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })),
close: vi.fn(async () => undefined),
...overrides,
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerDareRoutes(app, repository);
return { app, repository };
}
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('dare routes', () => {
it('derives the actor from the bearer identity, never the body', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/dares', {
dare_id: catalogueDareId,
sender_notes: ' be brave ',
});
expect(response.status).toBe(201);
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId }));
});
it('rejects client-supplied ownership fields', async () => {
const { app } = setup();
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner });
expect(response.status).toBe(400);
});
it('requires exactly one of dare_id or custom text', async () => {
const { app } = setup();
expect((await post(app, '/v1/dares', {})).status).toBe(400);
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400);
expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201);
});
it('accepts only preset durations', async () => {
const { app } = setup();
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201);
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400);
});
it('validates identifiers and filters', async () => {
const { app } = setup();
expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400);
expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400);
expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400);
expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400);
});
it('routes each transition to its repository method', async () => {
const { app, repository } = setup();
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
await post(app, `/v1/dares/${dareId}/submit`);
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/complete`);
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/cancel`);
expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId);
});
it('surfaces domain errors with their status codes', async () => {
const { app } = setup({
send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }),
submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }),
});
const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' });
expect(paywalled.status).toBe(402);
expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } });
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
});
it('exposes the duration presets the send sheet offers', async () => {
const { app } = setup();
const response = await app.request('/v1/dares/durations');
expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] });
});
});
+473 -35
View File
@@ -1,38 +1,476 @@
import { randomUUID } from 'node:crypto';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
import type { MigrationCheckpoint } from '../src/migration/types.js';
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Pool } from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
migrateDatabase,
relationalParity,
} from "../src/migration/database.js";
import { migrateStorage, fileDigest } from "../src/migration/storage.js";
import type { MigrationCheckpoint } from "../src/migration/types.js";
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;if(databaseUrl&&!local)throw new Error('Migration integration tests only permit localhost');
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string;
const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666';
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
beforeAll(async()=>{
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);}
await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`);
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
await source.query('alter table profiles add column avatar_url text');
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`);
await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]);
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
const databaseUrl = process.env.DATABASE_URL;
const local = databaseUrl
? ["127.0.0.1", "localhost", "::1"].includes(new URL(databaseUrl).hostname)
: false;
if (databaseUrl && !local)
throw new Error("Migration integration tests only permit localhost");
describe.skipIf(!databaseUrl || !local)("cutover migration fixtures", () => {
const admin = new Pool({ connectionString: databaseUrl });
const suffix = randomUUID().replaceAll("-", "");
const sourceSchema = `migration_source_${suffix}`;
const targetSchema = `migration_target_${suffix}`;
const storageSchema = `migration_storage_${suffix}`;
let source: Pool;
let target: Pool;
let mediaRoot: string;
const couple = "11111111-1111-4111-8111-111111111111",
user = "22222222-2222-4222-8222-222222222222",
pack = "33333333-3333-4333-8333-333333333333",
question = "44444444-4444-4444-8444-444444444444",
inverseQuestion = "45454545-4545-4545-8545-454545454545",
response = "55555555-5555-4555-8555-555555555555",
object = "66666666-6666-4666-8666-666666666666",
adminRow = "77777777-7777-4777-8777-777777777777",
category = "88888888-8888-4888-8888-888888888888",
review = "99999999-9999-4999-8999-999999999999",
audit = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
legacyUser = "a0000000-0000-0000-0000-000000000001",
sourceAppConfig = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const checkpoint = (): MigrationCheckpoint => ({
version: 1,
runId: randomUUID(),
mode: "initial",
completedTables: [],
storage: {},
updatedAt: new Date().toISOString(),
});
beforeAll(async () => {
await admin.query(
`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`,
);
const sourceUrl = new URL(databaseUrl!);
sourceUrl.searchParams.set("options", `-c search_path=${sourceSchema}`);
source = new Pool({ connectionString: sourceUrl.toString() });
const targetUrl = new URL(databaseUrl!);
targetUrl.searchParams.set("options", `-c search_path=${targetSchema}`);
target = new Pool({ connectionString: targetUrl.toString() });
for (let index = 0; index <= 18; index++) {
const files = (await import("node:fs/promises")).readdir(
new URL("../drizzle", import.meta.url),
);
const name = (await files).find((file) =>
file.startsWith(`${String(index).padStart(4, "0")}_`),
);
if (!name) continue;
const sql = await readFile(
new URL(`../drizzle/${name}`, import.meta.url),
"utf8",
);
for (const statement of sql.split("--> statement-breakpoint"))
if (statement.trim()) await target.query(statement);
}
await source.query(
`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table admin_users(id uuid primary key,user_id uuid,role text,created_at timestamptz,created_by uuid,updated_at timestamptz,permissions jsonb);create table categories(id uuid primary key,name text,is_public boolean,sort_order int,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],inverse_of uuid,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table content_reviews(id uuid primary key,entity_type text,entity_id uuid,previous_status text,new_status text,reason text,changed_by uuid,created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);create table feature_interests(id uuid primary key,user_id uuid,feature_name text,created_at timestamptz);create table live_draw_sessions(id uuid primary key,couple_id uuid,strokes jsonb,updated_at timestamptz,created_at timestamptz);create table audit_logs(id uuid primary key,table_name text,record_id uuid,action text,old_values jsonb,new_values jsonb,changed_fields text[],admin_user_id uuid,admin_role text,created_at timestamptz,admin_username text);create table app_config(id uuid primary key,answer_gap_threshold int,daily_response_limit int,updated_at timestamptz);`,
);
await source.query(
`insert into couples values($1,'e5d3012e','2024-01-01T00:00:00Z')`,
[couple],
);
await source.query(
`insert into app_config values($1,99,99,'2024-01-01T00:00:00Z')`,
[sourceAppConfig],
);
await source.query(
`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,
[user, couple],
);
await source.query(
`insert into admin_users values($1,$2,'super_admin',now(),null,now(),$3::jsonb)`,
[adminRow, user, JSON.stringify(["manage_packs"])],
);
await source.query("alter table profiles add column avatar_url text");
await source.query("update profiles set avatar_url=$2 where id=$1", [
user,
`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`,
]);
await source.query(
`insert into categories values($1,'Core',true,1,'allowed','Reviewed',null,null,'2024-01-03T00:00:00Z')`,
[category],
);
await source.query(
`insert into question_packs values($1,'Core',true,false,3.36,'allowed','Reviewed',null,null,'2024-01-04T00:00:00Z')`,
[pack],
);
await source.query(
`insert into questions values($1,$2,'Question',2,array['blindfold'],$3,'allowed','Reviewed',null,null,'2024-01-05T00:00:00Z'),($3,$2,'Inverse',2,array[]::text[],null,'allowed','Reviewed',null,null,'2024-01-05T00:00:01Z')`,
[question, pack, inverseQuestion],
);
await source.query(
`insert into content_reviews values($1,'categories',$2,'unreviewed','allowed','Reviewed',null,'2024-01-03T00:00:00Z')`,
[review, category],
);
await source.query(
`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,
[response, user, question, couple],
);
await source.query(
`insert into feature_interests values(gen_random_uuid(),$1,'daily_dares','2024-01-07T00:00:00Z')`,
[user],
);
await source.query(
`insert into live_draw_sessions values(gen_random_uuid(),$1,'[]'::jsonb,'2024-01-08T00:00:00Z','2024-01-08T00:00:00Z')`,
[couple],
);
await source.query(
`insert into audit_logs values($1,'categories',$2,'UPDATE',null,$3::jsonb,array['content_status'],$4,'super_admin','2024-01-09T00:00:00Z','alice')`,
[audit, category, JSON.stringify({ content_status: "allowed" }), user],
);
await admin.query(
`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`,
);
await admin.query(
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,
[object, `${user}/avatar.png`, user, { size: 4, mimetype: "image/png" }],
);
mediaRoot = await mkdtemp(join(tmpdir(), "sauci-migration-"));
});
afterAll(async () => {
await Promise.all([source?.end(), target?.end()]);
await admin.query(
`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`,
);
await admin.end();
if (mediaRoot) await rm(mediaRoot, { recursive: true, force: true });
});
it("preserves IDs, schema mappings, catalogue reviews, timestamps, side-effect suppression, and FK parity", async () => {
const state = checkpoint();
const result = await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: state,
});
expect(result.find((row) => row.table === "responses")).toMatchObject({
sourceRows: 1,
targetRows: 1,
status: "equal",
});
expect(result.find((row) => row.table === "app_config")).toMatchObject({
sourceRows: 1,
targetRows: 1,
importedRows: 0,
status: "equal",
});
expect(
(await target.query("select id from app_config")).rows[0].id,
).not.toBe(sourceAppConfig);
const row = await target.query("select id,created_at from responses");
expect(row.rows[0].id).toBe(response);
expect(new Date(row.rows[0].created_at).toISOString()).toBe(
"2024-01-06T00:00:00.000Z",
);
expect(
(
await target.query("select invite_code from couples where id=$1", [
couple,
])
).rows[0].invite_code,
).toBe("E5D3012E");
expect(
(
await target.query(
"select avg_intensity,content_status from question_packs where id=$1",
[pack],
)
).rows[0],
).toMatchObject({ avg_intensity: "3.36", content_status: "allowed" });
expect(
(
await target.query(
"select required_props,inverse_of,content_status from questions where id=$1",
[question],
)
).rows[0],
).toMatchObject({
required_props: ["blindfold"],
inverse_of: inverseQuestion,
content_status: "allowed",
});
expect(
(
await target.query(
"select feature from feature_interests where user_id=$1",
[user],
)
).rows[0].feature,
).toBe("daily_dares");
expect(
(
await target.query(
"select updated_by from live_draw_sessions where couple_id=$1",
[couple],
)
).rows[0].updated_by,
).toBe(user);
expect(
(
await target.query(
"select actor_user_id,admin_user_id,changed_fields,admin_role,admin_username from audit_logs where id=$1",
[audit],
)
).rows[0],
).toMatchObject({
actor_user_id: user,
admin_user_id: adminRow,
changed_fields: ["content_status"],
admin_role: "super_admin",
admin_username: "alice",
});
expect(
(
await target.query(
"select count(*)::int count from content_reviews where id=$1",
[review],
)
).rows[0].count,
).toBe(1);
expect(
(await target.query("select count(*)::int count from operations_outbox"))
.rows[0].count,
).toBe(0);
expect(await relationalParity(target)).toEqual({
profiles_without_couple: 0,
responses_with_missing_owner: 0,
matches_with_missing_relation: 0,
messages_with_missing_relation: 0,
couple_mismatch_responses: 0,
});
});
it("keeps a newer live profile during an initial merge", async () => {
const liveCouple = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
await target.query(
"insert into couples(id,invite_code,created_at) values($1,'LIVE0001',now())",
[liveCouple],
);
await target.query(
"update profiles set name='Live Alice',couple_id=$2,updated_at='2026-08-28T00:00:00Z' where id=$1",
[user, liveCouple],
);
await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: checkpoint(),
});
expect(
(
await target.query(
"select name,couple_id,updated_at from profiles where id=$1",
[user],
)
).rows[0],
).toMatchObject({ name: "Live Alice", couple_id: couple });
});
it("dry-run reads and reports without changing the target", async () => {
await source.query(
"update profiles set name='Changed at source' where id=$1",
[user],
);
await migrateDatabase(source, target, {
dryRun: true,
finalSync: false,
prune: false,
checkpoint: checkpoint(),
});
expect(
(await target.query("select name from profiles where id=$1", [user]))
.rows[0].name,
).toBe("Live Alice");
await source.query("update profiles set name='Alice' where id=$1", [user]);
});
it("is resumable and a stopped-source prune restores exact row parity", async () => {
const state = checkpoint();
state.completedTables = ["couples"];
await target.query(
`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`,
);
const resumed = await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: state,
});
expect(resumed.find((row) => row.table === "couples")?.note).toContain(
"resumed from checkpoint",
);
const final = await migrateDatabase(source, target, {
dryRun: false,
finalSync: true,
prune: true,
checkpoint: checkpoint(),
});
expect(final.find((row) => row.table === "couples")).toMatchObject({
sourceRows: 1,
targetRows: 1,
prunedRows: 2,
status: "equal",
});
});
it("copies storage atomically, quarantines unattributable legacy objects, rewrites references, verifies checkpoints, and prunes source-deleted files", async () => {
const legacyAvatar = randomUUID(),
orphanChat = randomUUID(),
unmatchedChat = randomUUID(),
feedbackObject = randomUUID(),
feedbackId = randomUUID();
await source.query(
"insert into profiles values($1,$2,$3,$4,now(),now(),null)",
[legacyUser, "Legacy", "legacy@test", couple],
);
await target.query(
"insert into profiles(id,name,email,couple_id,created_at,updated_at) values($1,$2,$3,$4,now(),now())",
[legacyUser, "Legacy", "legacy@test", couple],
);
await source.query(
"create table messages(user_id uuid,match_id uuid,media_path text);create table feedback(id uuid primary key,user_id uuid,screenshot_url text)",
);
await source.query("insert into feedback values($1,$2,$3)", [
feedbackId,
user,
`https://source.test/storage/v1/object/public/feedback-screenshots/${user}/shot.jpg`,
]);
await target.query(
"insert into feedback(id,user_id,type,title,description) values($1,$2,'bug','Screenshot','Details')",
[feedbackId, user],
);
await admin.query(
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now()),($5,'chat-media',$6,null,$4,now(),now()),($7,'chat-media',$8,$9,$4,now(),now()),($10,'feedback-screenshots',$11,$9,$4,now(),now())`,
[
legacyAvatar,
`${legacyUser}/legacy.jpg`,
legacyUser,
{ size: 4, mimetype: "image/jpeg" },
orphanChat,
`${randomUUID()}/orphan.jpg`,
unmatchedChat,
`${randomUUID()}/chat.jpg`,
user,
feedbackObject,
`${user}/shot.jpg`,
],
);
const state = checkpoint();
const first = await migrateStorage(source, target, {
dryRun: false,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => new Uint8Array([1, 2, 3, 4]),
});
expect(first.failures).toEqual([]);
expect(first).toMatchObject({
sourceFiles: 5,
targetFiles: 5,
copiedFiles: 5,
quarantinedFiles: 1,
failedFiles: 0,
missingOnDisk: [],
});
const path = join(mediaRoot, "avatars", user, "avatar.png");
expect(await fileDigest(path)).toBe(
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a",
);
expect(
(
await target.query(
"select owner_id,kind,byte_size from media_objects where id=$1",
[object],
)
).rows[0],
).toMatchObject({ owner_id: user, kind: "avatar", byte_size: 4 });
expect(
(
await target.query("select owner_id from media_objects where id=$1", [
legacyAvatar,
])
).rows[0].owner_id,
).toBe(legacyUser);
expect(
(
await target.query("select match_id from media_objects where id=$1", [
unmatchedChat,
])
).rows[0].match_id,
).toBeNull();
expect(
(
await target.query(
"select reason from legacy_media_quarantine where id=$1",
[orphanChat],
)
).rowCount,
).toBe(1);
expect(
(
await target.query(
"select screenshot_media_id from feedback where id=$1",
[feedbackId],
)
).rows[0].screenshot_media_id,
).toBe(feedbackObject);
expect(
(
await target.query("select avatar_url from profiles where id=$1", [
user,
])
).rows[0].avatar_url,
).toBe(`media:${object}`);
const resumed = await migrateStorage(source, target, {
dryRun: false,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => {
throw new Error("must not redownload");
},
});
expect(resumed).toMatchObject({
copiedFiles: 0,
skippedFiles: 5,
failedFiles: 0,
});
const stale = "77777777-7777-4777-8777-777777777777";
const staleKey = `avatars/${user}/stale.png`;
await target.query(
"insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",
[stale, user, couple, staleKey],
);
await writeFile(join(mediaRoot, staleKey), new Uint8Array([9]));
await admin.query(
`delete from "${storageSchema}".objects where id=$1`,
[orphanChat],
);
const pruned = await migrateStorage(source, target, {
dryRun: false,
prune: true,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => {
throw new Error("must not redownload");
},
});
expect(pruned).toMatchObject({
sourceFiles: 4,
targetFiles: 4,
prunedFiles: 2,
skippedFiles: 4,
failedFiles: 0,
});
expect(
(await target.query("select 1 from legacy_media_quarantine where id=$1", [orphanChat])).rowCount,
).toBe(0);
});
afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});});
it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});});
it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);});
it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});});
it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});});
});
+39 -1
View File
@@ -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;
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());
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']){
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);
}
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
@@ -68,6 +68,44 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before);
});
it('nudges only the partner who still owes an answer, in the couple evening',async()=>{
// Alice sorts below Bob, so Alice is user1. She has answered today; Bob has not.
await pool.query("update profiles set timezone='UTC' where couple_id=$1",[couple]);
const evening=new Date('2026-08-27T21:00:00Z');
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,last_active_date,last_completed_date,user1_answered_today,user2_answered_today,streak_celebrated_at)
values($1,6,6,'2026-08-27','2026-08-26',true,false,6) on conflict(couple_id) do update set current_streak=6,longest_streak=6,
last_active_date='2026-08-27',last_completed_date='2026-08-26',user1_answered_today=true,user2_answered_today=false,streak_celebrated_at=6`,[couple]);
expect((await repo.produce(evening,100)).streakReminders).toBe(1);
const queued=await pool.query<{recipient_id:string;title:string;body:string}>(
`select recipient_id,payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like 'streak_risk:%'`);
expect(queued.rows).toHaveLength(1);
expect(queued.rows[0]).toMatchObject({recipient_id:bob,title:'Alice answered today'});
expect(queued.rows[0].body).toContain('6 days in');
expect((await pool.query('select 1 from operations_outbox where dedupe_key=$1',[`streak_risk:${couple}:2026-08-27:${bob}`])).rowCount).toBe(1);
// A second tick inside the same couple-local evening must not queue a second nudge.
expect((await repo.produce(evening,100)).streakReminders).toBe(0);
});
it('addresses both partners when neither has answered and honours the preference',async()=>{
const next=new Date('2026-08-28T21:00:00Z');
await pool.query(`update couple_streaks set last_active_date='2026-08-27',last_completed_date='2026-08-27',
user1_answered_today=true,user2_answered_today=true where couple_id=$1`,[couple]);
expect((await repo.produce(next,100)).streakReminders).toBe(2);
const both=await pool.query<{title:string;body:string}>(
`select payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like $1 order by recipient_id`,
[`streak_risk:${couple}:2026-08-28:%`]);
expect(both.rows.map(r=>r.title)).toEqual(['Your 6-day streak','Your 6-day streak']);
expect(both.rows[0].body).toContain('One question each');
await pool.query(`insert into notification_preferences(user_id,streak_reminders_enabled) values($1,false),($2,false)
on conflict(user_id) do update set streak_reminders_enabled=false`,[alice,bob]);
await pool.query(`update couple_streaks set last_active_date='2026-08-28',last_completed_date='2026-08-28' where couple_id=$1`,[couple]);
expect((await repo.produce(new Date('2026-08-29T21:00:00Z'),100)).streakReminders).toBe(0);
await pool.query('update notification_preferences set streak_reminders_enabled=true where user_id in ($1,$2)',[alice,bob]);
});
it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{
await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]);
const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0);
+1 -1
View File
@@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js';
import type { OperationItem } from '../src/domains/operations/types.js';
import { loadWorkerConfig } from '../src/workers/config.js';
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
function repository(items:OperationItem[]):OperationsRepository {
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
@@ -20,9 +20,11 @@ const preferences = {
pack_changes_enabled: true,
new_packs_enabled: true,
streak_milestones_enabled: true,
streak_reminders_enabled: true,
weekly_summary_enabled: true,
unpaired_reminders_enabled: true,
catchup_reminders_enabled: true,
dares_enabled: true,
created_at: '2026-08-27T10:00:00.000Z',
updated_at: '2026-08-27T10:00:00.000Z',
};
+17 -12
View File
@@ -14,6 +14,7 @@ react {
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
@@ -63,9 +64,9 @@ react {
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
* Set this to true in release builds to optimize the app using R8.
*/
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
@@ -78,7 +79,7 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
@@ -91,8 +92,9 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46
versionCode 47
versionName "1.0.5"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
@@ -110,15 +112,18 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
@@ -156,15 +161,15 @@ dependencies {
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
@@ -176,4 +181,4 @@ dependencies {
}
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
apply plugin: 'com.google.firebase.crashlytics'
@@ -2,6 +2,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
@@ -17,7 +21,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:enableOnBackInvokedCallback="false">
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
@@ -8,10 +8,10 @@ import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.facebook.react.common.ReleaseLevel
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
@@ -42,11 +42,12 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
@@ -1,5 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
+2 -19
View File
@@ -1,15 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
ndkVersion = "26.1.10909125"
}
repositories {
google()
mavenCentral()
@@ -19,23 +10,15 @@ buildscript {
classpath 'com.google.gms:google-services:4.4.1'
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
}
maven {
// Android JSC is installed from npm
url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
+14 -4
View File
@@ -35,7 +35,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
@@ -55,6 +55,16 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
android.targetSdkVersion=35
android.kotlinVersion=1.9.25
android.extraMavenRepos=[]
android.targetSdkVersion=36
android.extraMavenRepos=[]
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
android.compileSdkVersion=36
android.buildToolsVersion=36.0.0
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+25 -24
View File
@@ -1,38 +1,39 @@
pluginManagement {
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
def command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'android'
].toList()
ex.autolinkLibrariesFromCommand(command)
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
rootProject.name = 'Sauci'
dependencyResolutionManagement {
versionCatalogs {
reactAndroidLibs {
from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
}
}
}
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
useExpoModules()
expoAutolinking.useExpoModules()
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
includeBuild(expoAutolinking.reactNativeGradlePlugin)
+6 -5
View File
@@ -7,7 +7,7 @@
"icon": "./assets/icon.png",
"scheme": "app.sauci",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
@@ -35,7 +35,7 @@
"NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.",
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
},
"privacyManifest": {
"privacyManifests": {
"NSPrivacyTracking": false,
"NSPrivacyCollectedDataTypes": [
{
@@ -183,7 +183,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 46,
"versionCode": 47,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
@@ -205,8 +205,9 @@
"expo-build-properties",
{
"android": {
"targetSdkVersion": 35,
"kotlinVersion": "1.9.25",
"buildToolsVersion": "36.0.0",
"compileSdkVersion": 36,
"targetSdkVersion": 36,
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
"useLegacyPackaging": false,
"manifestPlaceholders": {
+1 -1
View File
@@ -65,7 +65,7 @@ export default function AppLayout() {
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
+5 -232
View File
@@ -1,232 +1,5 @@
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { colors, spacing, radius, typography, animations } from "../../src/theme";
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
const ACCENT_COLOR = colors.premium.gold;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export default function DaresScreen() {
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
useFeatureInterest("dares");
const scale = useSharedValue(1);
const animatedButtonStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Try playful challenges together and create unforgettable moments.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Provocative challenges</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="thermometer-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Multiple intensity levels</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="people-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Designed for two</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Are you ready to play?</Text>
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
import { DaresScreen } from "../../src/features/dares";
export default function DaresRoute() {
return <DaresScreen />;
}
+17 -4
View File
@@ -4,10 +4,11 @@ import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
import { StreakDisplay } from '../../src/components/StreakDisplay';
import { colors, spacing, typography, radius } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types';
@@ -68,6 +69,7 @@ function groupPacksByCategory(
export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
const { fetchStreak } = useStreakStore();
const { subscription } = useSubscriptionStore();
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
@@ -81,7 +83,8 @@ export default function DiscoveryScreen() {
useFocusEffect(
useCallback(() => {
fetchPacks();
}, [])
fetchStreak();
}, [fetchStreak])
);
// Group packs by category
@@ -114,9 +117,17 @@ export default function DiscoveryScreen() {
showsVerticalScrollIndicator={false}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Shared streak - hidden entirely for couples with nothing running */}
<View style={styles.streakSlot}>
<StreakDisplay delay={50} />
</View>
{/* Live Draw Activity Tile */}
<LiveDrawTile delay={100} />
{/* Dares */}
<DaresTile delay={150} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
@@ -209,6 +220,9 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
textAlign: 'center',
},
streakSlot: {
paddingHorizontal: spacing.lg,
},
bottomSpacer: {
height: spacing.lg,
},
@@ -227,4 +241,3 @@ const styles = StyleSheet.create({
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
},
});
+3 -3
View File
@@ -5,19 +5,19 @@
},
"build": {
"development": {
"node": "20.10.0",
"node": "20.19.4",
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"node": "20.10.0",
"node": "20.19.4",
"distribution": "internal",
"env": {
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
}
},
"production": {
"node": "20.10.0",
"node": "20.19.4",
"autoIncrement": true,
"env": {
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
+1 -31
View File
@@ -1,33 +1,3 @@
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
// Find the project and workspace directories
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
// 1. Watch only relevant folders to improve performance on Windows
// watching the entire workspaceRoot is too slow
config.watchFolders = [
path.resolve(workspaceRoot, 'node_modules'),
path.resolve(workspaceRoot, 'packages/shared')
];
// 2. Let Metro know where to resolve packages and in which order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force Metro to resolve (sub)dependencies from the `node_modules`
// of the workspace root, unless they are installed in the project root.
config.resolver.disableHierarchicalLookup = true;
// 4. Keep extraNodeModules in case we need to add monorepo overrides later
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
};
module.exports = config;
module.exports = getDefaultConfig(__dirname);
+44 -43
View File
@@ -23,8 +23,8 @@
"@bacons/apple-targets": "^0.1.6",
"@bittingz/expo-widgets": "^3.0.2",
"@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "1.23.1",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/cli": "^20.1.1",
"@react-native-firebase/analytics": "^23.8.2",
"@react-native-firebase/app": "^23.8.2",
@@ -33,63 +33,64 @@
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@shopify/react-native-skia": "^1.12.4",
"@shopify/react-native-skia": "2.2.12",
"@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0",
"babel-preset-expo": "~12.0.0",
"babel-preset-expo": "~54.0.12",
"base64-arraybuffer": "^1.0.2",
"expo": "~52.0.0",
"expo-apple-authentication": "~7.1.3",
"expo-asset": "~11.0.5",
"expo-av": "~15.0.2",
"expo-blur": "~14.0.3",
"expo-build-properties": "^0.13.3",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.0",
"expo-dev-client": "~5.0.20",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0",
"expo-local-authentication": "~15.0.2",
"expo-media-library": "~17.0.6",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0",
"expo-sharing": "~13.0.1",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-android-widget": "^0.17.2",
"expo": "~54.0.0",
"expo-apple-authentication": "~8.0.8",
"expo-asset": "~12.0.13",
"expo-av": "~16.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.14",
"expo-dev-client": "~6.0.21",
"expo-file-system": "~19.0.24",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.11",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.9",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.24",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-android-widget": "^0.22.1",
"react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-purchases": "^9.6.12",
"react-native-reanimated": "~3.16.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^4.0.3",
"react-native-web": "~0.19.13",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "~18.3.12",
"@types/jest": "29.5.14",
"@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0",
"eslint": "^8.57.0",
"eslint-config-expo": "~8.0.1",
"eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"jest-expo": "~52.0.0",
"react-test-renderer": "^18.3.1",
"typescript": "^5.3.3"
"jest-expo": "~54.0.18",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
}
}
@@ -0,0 +1,48 @@
import { streakStatus } from '@/components/StreakDisplay';
import type { CoupleStreak } from '@/store/streakStore';
const streak = (overrides: Partial<CoupleStreak>): CoupleStreak => ({
couple_id: 'couple-1',
current_streak: 6,
longest_streak: 9,
last_active_date: '2026-08-28',
last_completed_date: '2026-08-27',
you_answered_today: false,
partner_answered_today: false,
partner_name: 'Alex',
timezone: 'Europe/London',
streak_celebrated_at: 0,
created_at: '2026-08-01T00:00:00.000Z',
updated_at: '2026-08-28T00:00:00.000Z',
...overrides,
});
describe('streakStatus', () => {
it('frames the count as the couple rather than as a personal score', () => {
expect(streakStatus(streak({})).headline).toBe('You and Alex — 6 days in a row');
expect(streakStatus(streak({ current_streak: 1 })).headline).toBe('You and Alex — 1 day in a row');
});
it('names the partner who moved first so the day has somebody to answer to', () => {
expect(streakStatus(streak({ partner_answered_today: true })).status)
.toBe('Alex has answered today — your turn');
expect(streakStatus(streak({ you_answered_today: true })).status)
.toBe('Answered. Waiting on Alex');
});
it('settles once both partners have answered', () => {
expect(streakStatus(streak({ you_answered_today: true, partner_answered_today: true })).status)
.toBe("You've both answered today");
});
it('invites rather than warns when the day is still untouched', () => {
expect(streakStatus(streak({})).status).toBe('One question each keeps it going');
expect(streakStatus(streak({ current_streak: 0 })).status).toBe('Answer together to start a streak');
});
it('falls back to a neutral label when the partner has no name yet', () => {
const anonymous = streak({ partner_name: null, partner_answered_today: true });
expect(streakStatus(anonymous).headline).toBe('You and your partner — 6 days in a row');
expect(streakStatus(anonymous).status).toBe('Your partner has answered today — your turn');
});
});
@@ -0,0 +1,40 @@
import { calculateEffectiveTotal } from "@/features/swipe/utils/useSwipeScreen-helpers";
import type { DailyLimitInfo } from "@/features/swipe/types";
const limit = (overrides: Partial<DailyLimitInfo> = {}): DailyLimitInfo => ({
responses_today: 0,
limit_value: 10,
remaining: 10,
reset_at: "2026-08-29T00:00:00.000Z",
is_blocked: false,
...overrides,
});
describe("calculateEffectiveTotal", () => {
it("clamps the progress bar to the quota left today", () => {
expect(calculateEffectiveTotal(50, null, limit({ responses_today: 8, remaining: 2 }), 3)).toBe(5);
});
it("shows the last card as the last card when one answer remains", () => {
expect(calculateEffectiveTotal(50, null, limit({ responses_today: 9, remaining: 1 }), 7)).toBe(8);
});
it("leaves the total alone when the meter is disabled", () => {
expect(calculateEffectiveTotal(50, null, limit({ limit_value: 0, remaining: 0 }), 3)).toBe(50);
expect(calculateEffectiveTotal(50, null, null, 3)).toBe(50);
});
it("ignores the quota once blocked, since the limit screen takes over", () => {
expect(calculateEffectiveTotal(50, null, limit({ responses_today: 10, remaining: 0, is_blocked: true }), 3)).toBe(50);
});
it("takes whichever of the answer gap and the quota binds first", () => {
const gap = { unanswered: 8, threshold: 10 };
expect(calculateEffectiveTotal(50, gap, limit({ remaining: 5 }), 2)).toBe(4);
expect(calculateEffectiveTotal(50, gap, limit({ remaining: 1 }), 2)).toBe(3);
});
it("never runs fewer questions than the quota allows", () => {
expect(calculateEffectiveTotal(3, null, limit({ remaining: 10 }), 0)).toBe(3);
});
});
@@ -0,0 +1,168 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
import { ApiError, apiClient } from "@/lib/apiClient";
import * as swipeService from "@/features/swipe/services/swipeService";
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
let mockRouteParams: Record<string, string> = {};
const mockFetchStreak = jest.fn(async () => undefined);
jest.mock("expo-router", () => ({
useLocalSearchParams: () => mockRouteParams,
useFocusEffect: () => undefined,
}));
// Stable identities: the hook's effects key off these, so fresh objects per render
// would retrigger the fetch effect forever and the screen would never settle.
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
// Read lazily: the factory runs before this module's own consts are initialised.
const streak = { fetchStreak: () => mockFetchStreak() };
const useStreakStore = () => streak;
useStreakStore.getState = () => streak;
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
});
jest.mock("@/lib/skippedQuestions", () => ({
skipQuestion: jest.fn(),
getSkippedQuestionIds: jest.fn(),
}));
jest.mock("@/features/swipe/services/swipeService", () => ({
fetchRecommendedQuestions: jest.fn(),
fetchPendingQuestions: jest.fn(),
fetchAnswerGapStatus: jest.fn(),
fetchDailyLimitStatus: jest.fn(),
fetchPackContext: jest.fn(),
uploadResponseMedia: jest.fn(),
}));
const questions = [
{ id: "q1", pack_id: "pack-1", question_type: "swipe", text: "One" },
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
];
const openLimit = {
responses_today: 4,
limit_value: 10,
remaining: 6,
reset_at: "2026-08-29T00:00:00.000Z",
is_blocked: false,
};
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
error: { code: "daily_limit", message: "Daily response limit reached" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
});
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
beforeEach(() => {
mockRouteParams = {};
jest.mocked(getSkippedQuestionIds).mockResolvedValue(new Set<string>());
jest.mocked(swipeService.fetchRecommendedQuestions).mockResolvedValue(questions as never);
jest.mocked(swipeService.fetchAnswerGapStatus).mockResolvedValue({
unanswered_by_partner: 0, threshold: 10, is_blocked: false,
});
jest.mocked(swipeService.fetchDailyLimitStatus).mockResolvedValue(openLimit);
});
const renderReady = async () => {
const view = renderHook(() => useSwipeScreen());
await waitFor(() => expect(view.result.current.isLoading).toBe(false));
await waitFor(() => expect(view.result.current.dailyLimitInfo).not.toBeNull());
return view;
};
describe("useSwipeScreen daily limit", () => {
it("holds the question and shows the wall when the server rejects with 429", async () => {
const { result } = await renderReady();
expect(result.current.currentIndex).toBe(0);
jest.spyOn(apiClient, "post").mockRejectedValueOnce(dailyLimit429());
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
// Advancing here would silently discard the question the server refused.
expect(result.current.currentIndex).toBe(0);
expect(result.current.dailyLimitInfo).toMatchObject({
responses_today: 10,
limit_value: 10,
remaining: 0,
reset_at: "2026-08-29T00:00:00.000Z",
is_blocked: true,
});
});
it("still advances past an unrelated submission failure", async () => {
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockRejectedValueOnce(new ApiError("Server error", 500, null));
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.dailyLimitInfo?.is_blocked).toBe(false);
});
it("still reports the limit while browsing a single pack", async () => {
// Pack browsing used to skip the limit read, which showed "all caught up"
// to a capped user instead of the limit screen.
mockRouteParams = { packId: "pack-1" };
jest.mocked(swipeService.fetchPackContext).mockResolvedValue({ name: "Pack", icon: "layers" });
jest.mocked(swipeService.fetchRecommendedQuestions).mockResolvedValue([] as never);
jest.mocked(swipeService.fetchDailyLimitStatus).mockResolvedValue({
...openLimit, responses_today: 10, remaining: 0, is_blocked: true,
});
const { result } = renderHook(() => useSwipeScreen());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.dailyLimitInfo?.is_blocked).toBe(true));
});
it("counts down the meter optimistically on a successful answer", async () => {
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null });
jest.mocked(swipeService.fetchDailyLimitStatus)
.mockResolvedValue({ ...openLimit, responses_today: 5, remaining: 5 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
it("refreshes the shared streak once the answer lands", async () => {
const { result } = await renderReady();
mockFetchStreak.mockClear();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(mockFetchStreak).toHaveBeenCalled();
});
it("records the answer even when the streak refresh throws", async () => {
// A refresh that fails outright must not cost the user the answer they just gave.
const { result } = await renderReady();
mockFetchStreak.mockImplementationOnce(() => { throw new Error("offline"); });
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null });
jest.mocked(swipeService.fetchDailyLimitStatus)
.mockResolvedValue({ ...openLimit, responses_today: 5, remaining: 5 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
});
@@ -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");
});
});
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useDrawingSync } from '@/features/live-draw/hooks/useDrawingSync';
import { useDrawingSync, dedupeById, mergeDrawingChanges } from '@/features/live-draw/hooks/useDrawingSync';
import { appDataApi } from '@/lib/appDataApi';
import { ApiError } from '@/lib/apiClient';
@@ -47,3 +47,37 @@ describe('useDrawingSync API polling', () => {
expect(onLoadStrokes).toHaveBeenLastCalledWith([stroke, partnerStroke, mine]);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
color: '#fff', width: 2, timestamp: 1, isEraser: false,
});
it('keeps the most complete copy of a stroke at its original z-position', () => {
// Mid-draw writes persist a truncated copy; touch-end appends the full one.
const out = dedupeById([at('a', 3), at('mine', 5), at('b', 3), at('mine', 40)]);
expect(out.map(s => s.id)).toEqual(['a', 'mine', 'b']);
expect(out[1].points).toHaveLength(40);
});
it('leaves a set of unique strokes untouched', () => {
const input = [at('a', 1), at('b', 1)];
expect(dedupeById(input)).toEqual(input);
});
it('never emits duplicate ids after a conflict merge', () => {
const merged = mergeDrawingChanges(
[at('a', 2)],
[at('a', 2), at('mine', 3), at('mine', 30)],
[at('a', 2), at('partner', 4)],
);
expect(merged.map(s => s.id)).toEqual(['a', 'partner', 'mine']);
expect(merged.find(s => s.id === 'mine')!.points).toHaveLength(30);
});
it('still drops strokes removed locally', () => {
const merged = mergeDrawingChanges([at('a', 2), at('gone', 2)], [at('a', 2)], [at('a', 2), at('gone', 2)]);
expect(merged.map(s => s.id)).toEqual(['a']);
});
});
@@ -1,7 +1,7 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { useMediaSaver } from '@/hooks/useMediaSaver';
// Mock expo-media-library
@@ -11,7 +11,7 @@ jest.mock('expo-media-library', () => ({
}));
// Mock expo-file-system with all required methods
jest.mock('expo-file-system', () => ({
jest.mock('expo-file-system/legacy', () => ({
readAsStringAsync: jest.fn(async () => ''),
downloadAsync: jest.fn(),
deleteAsync: jest.fn(),
@@ -0,0 +1,67 @@
import { profileSettingsApi } from "@/lib/profileSettingsApi";
import { syncTimezone } from "@/lib/reportedTimezone";
jest.mock("@/lib/profileSettingsApi", () => ({
profileSettingsApi: { updateProfile: jest.fn() },
}));
jest.mock("expo-secure-store", () => {
const store = new Map<string, string>();
return {
__store: store,
getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
setItemAsync: jest.fn(async (key: string, value: string) => { store.set(key, value); }),
};
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const secureStore = require("expo-secure-store") as { __store: Map<string, string> };
const withZone = (zone: string | undefined) => {
jest.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({ timeZone: zone }),
} as never);
};
describe("syncTimezone", () => {
beforeEach(() => {
secureStore.__store.clear();
jest.mocked(profileSettingsApi.updateProfile).mockResolvedValue({ updated: true });
});
it("reports the device zone once and not again until it changes", async () => {
withZone("Europe/London");
await syncTimezone("user-1");
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(1);
expect(profileSettingsApi.updateProfile).toHaveBeenCalledWith({ timezone: "Europe/London" });
withZone("Pacific/Auckland");
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).toHaveBeenNthCalledWith(2, { timezone: "Pacific/Auckland" });
});
it("tracks zones per user, so a shared device does not skip the second account", async () => {
withZone("Europe/London");
await syncTimezone("user-1");
await syncTimezone("user-2");
expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(2);
});
it("does nothing when the platform cannot resolve a zone", async () => {
withZone(undefined);
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).not.toHaveBeenCalled();
});
it("retries on the next launch when the report fails", async () => {
withZone("Europe/London");
jest.mocked(profileSettingsApi.updateProfile).mockRejectedValueOnce(new Error("offline"));
await syncTimezone("user-1");
jest.mocked(profileSettingsApi.updateProfile).mockResolvedValue({ updated: true });
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(2);
});
});
@@ -21,6 +21,7 @@ const preferences = {
weekly_summary_enabled: true,
unpaired_reminders_enabled: true,
catchup_reminders_enabled: true,
streak_reminders_enabled: true,
created_at: '2026-08-27T10:00:00.000Z',
updated_at: '2026-08-27T10:00:00.000Z',
};
+71 -37
View File
@@ -2,9 +2,9 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { useStreakStore } from '../store';
import { colors, spacing, radius, typography } from '../theme';
import type { CoupleStreak } from '../store';
import { colors, spacing, radius, typography, shadows } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
@@ -16,11 +16,34 @@ interface StreakDisplayProps {
}
/**
* Displays the couple's current streak with a flame icon.
* The flame pulses when there's an active streak.
* The count is deliberately framed as a couple's, not a personal score: a shared streak
* is the thing worth keeping. The status line is the working half of that framing - it
* says who the day is waiting on - and the copy stays an invitation, because guilt
* between partners is the failure mode for this mechanic rather than the goal.
*/
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
const named = streak.partner_name?.trim() || null;
// The same fallback reads wrong in both positions, so keep a sentence-start form.
const subject = named ?? 'Your partner';
const object = named ?? 'your partner';
const days = streak.current_streak;
const both = streak.you_answered_today && streak.partner_answered_today;
const headline = days > 0
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
: `You and ${object}`;
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
}
/**
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, isLoading, fetchStreak } = useStreakStore();
const { streak, fetchStreak } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
@@ -29,9 +52,10 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
// Flame pulse animation
const scale = useSharedValue(1);
const hasStreak = (streak?.current_streak ?? 0) > 0;
useEffect(() => {
if (streak && streak.current_streak > 0) {
if (hasStreak) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
@@ -43,25 +67,32 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
} else {
scale.value = 1;
}
}, [streak?.current_streak, scale]);
}, [hasStreak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
// Don't show if loading or no couple
if (isLoading || !streak) {
if (!streak) {
return null;
}
const hasStreak = streak.current_streak > 0;
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
// have not played yet reads as a scolding for a streak they never started.
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
return null;
}
const { headline, status } = streakStatus(streak);
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
// The partner having moved first is the one state the couple can still act on today.
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={styles.compactContainer}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
@@ -80,44 +111,35 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
{/* Main streak display */}
<LinearGradient
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
style={styles.card}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
{/* Flame icon */}
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={32}
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
{/* Streak info */}
<View style={styles.info}>
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
{currentStreak}
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={styles.label}>
day{currentStreak !== 1 ? 's' : ''} together
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{/* Longest streak */}
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</LinearGradient>
</View>
</View>
</Animated.View>
);
@@ -128,10 +150,14 @@ const styles = StyleSheet.create({
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
},
cardAwaiting: {
borderColor: colors.warning,
},
content: {
flexDirection: 'row',
@@ -139,23 +165,27 @@ const styles = StyleSheet.create({
gap: spacing.md,
},
iconContainer: {
width: 48,
height: 48,
width: 44,
height: 44,
borderRadius: radius.full,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
backgroundColor: colors.warningLight,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
count: {
...typography.title1,
headline: {
...typography.headline,
color: colors.text,
},
label: {
status: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
statusAwaiting: {
color: colors.warning,
},
inactiveText: {
color: colors.textTertiary,
@@ -167,7 +197,7 @@ const styles = StyleSheet.create({
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
borderTopColor: colors.border,
},
longestText: {
...typography.caption1,
@@ -178,11 +208,15 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
backgroundColor: colors.warningLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactAwaiting: {
borderWidth: 1,
borderColor: colors.warning,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
@@ -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',
},
});
@@ -1,5 +1,6 @@
export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
export { ContentRow } from './ContentRow';
export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
export { ContentRow } from './ContentRow';
export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile';
@@ -23,7 +23,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
const logo = require("../../../assets/logo.png");
@@ -1,7 +1,7 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { type AnimatedStyleProp } from 'react-native-reanimated';
import Animated, { type AnimatedStyle } from 'react-native-reanimated';
import type { ViewStyle } from 'react-native';
import type { IntensityLevel } from '@/types';
import { HEAT_COLORS } from './constants';
@@ -9,7 +9,7 @@ import { styles } from './styles';
interface IntensitySliderTrackProps {
value: IntensityLevel;
progressStyle: AnimatedStyleProp<ViewStyle>;
progressStyle: AnimatedStyle<ViewStyle>;
}
export function IntensitySliderTrack({ value, progressStyle }: IntensitySliderTrackProps) {
@@ -0,0 +1,248 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../components/ui";
import { Paywall } from "../../components/paywall";
import { colors, featureColors, radius, spacing, typography } from "../../theme";
import { DareCard, DarePackGrid, SendDareSheet } from "./components";
import { useDares } from "./hooks/useDares";
import type { DarePack } from "./types";
const ACCENT = featureColors.dares.accent;
type Tab = "active" | "send" | "history";
const TABS: { key: Tab; label: string }[] = [
{ key: "active", label: "Active" },
{ key: "send", label: "Send" },
{ key: "history", label: "History" },
];
export function DaresScreen() {
const dares = useDares();
const [tab, setTab] = useState<Tab>("active");
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false);
const openPaywall = useCallback(() => {
setSheetOpen(false);
setPaywallOpen(true);
}, []);
const closePaywall = useCallback(() => {
setPaywallOpen(false);
dares.clearPaywall();
}, [dares]);
const entitlement = dares.catalog?.entitlement ?? null;
const premium = entitlement?.is_premium ?? false;
const handleSelectPack = useCallback(
(pack: DarePack) => {
if (pack.is_premium && !premium) {
openPaywall();
return;
}
setSheetPack(pack);
setSheetOpen(true);
},
[premium, openPaywall],
);
const handleWriteCustom = useCallback(() => {
if (!premium) {
openPaywall();
return;
}
setSheetPack(null);
setSheetOpen(true);
}, [premium, openPaywall]);
const handleAction = useCallback(
(dareId: string, action: "accept" | "decline" | "submit" | "complete" | "cancel") => {
if (action === "accept" || action === "decline") {
void dares.respond(dareId, action);
return;
}
void dares[action](dareId);
},
[dares],
);
// A premium refusal from the API opens the paywall even when the client thought
// the action was allowed (stale entitlement, or a plan that lapsed mid-session).
const { paywallReason } = dares;
useEffect(() => {
if (paywallReason) setPaywallOpen(true);
}, [paywallReason]);
const list = tab === "history" ? dares.history : dares.active;
return (
<GradientBackground>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.container}
refreshControl={
<RefreshControl refreshing={false} onRefresh={() => void dares.refresh()} tintColor={ACCENT} />
}
>
<Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text>
{dares.stats ? (
<View style={styles.statsRow}>
<Stat value={dares.stats.completed_together} label="Completed" />
<Stat value={dares.stats.active} label="In play" />
<Stat value={dares.stats.sent} label="You sent" />
</View>
) : null}
{!premium && entitlement?.sends_remaining !== null && entitlement !== null ? (
<Pressable style={styles.quotaBanner} onPress={openPaywall}>
<Ionicons name="flash-outline" size={14} color={ACCENT} />
<Text style={styles.quotaText}>
{entitlement.sends_remaining} of {entitlement.weekly_send_limit} free dares left this week
</Text>
<Text style={styles.quotaCta}>Upgrade</Text>
</Pressable>
) : null}
<View style={styles.tabs}>
{TABS.map(({ key, label }) => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={[styles.tab, tab === key && styles.tabActive]}
>
<Text style={[styles.tabText, tab === key && styles.tabTextActive]}>{label}</Text>
</Pressable>
))}
</View>
{dares.error ? <Text style={styles.error}>{dares.error}</Text> : null}
{dares.loading ? (
<ActivityIndicator color={ACCENT} style={styles.loader} />
) : tab === "send" ? (
<DarePackGrid
packs={dares.catalog?.packs ?? []}
entitlement={entitlement}
onSelectPack={handleSelectPack}
onWriteCustom={handleWriteCustom}
/>
) : list.length === 0 ? (
<Animated.View entering={FadeIn.duration(300)} style={styles.empty}>
<Ionicons name="sparkles-outline" size={28} color={ACCENT} />
<Text style={styles.emptyTitle}>
{tab === "active" ? "No dares in play" : "Nothing here yet"}
</Text>
<Text style={styles.emptyBody}>
{tab === "active"
? "Send your partner a dare and see what happens."
: "Completed and declined dares will show up here."}
</Text>
{tab === "active" ? (
<Pressable style={styles.emptyButton} onPress={() => setTab("send")}>
<Text style={styles.emptyButtonText}>Browse dares</Text>
</Pressable>
) : null}
</Animated.View>
) : (
list.map((dare) => (
<DareCard
key={dare.id}
dare={dare}
busy={dares.busyDareId === dare.id}
onAction={handleAction}
/>
))
)}
</ScrollView>
<SendDareSheet
visible={sheetOpen}
pack={sheetPack}
canSendCustom={entitlement?.can_send_custom ?? false}
onClose={() => setSheetOpen(false)}
onSend={dares.send}
onRequestPremium={openPaywall}
/>
<Paywall
visible={paywallOpen}
onClose={closePaywall}
onSuccess={() => {
closePaywall();
void dares.refresh();
}}
/>
</GradientBackground>
);
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<View style={styles.stat}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1 },
container: { paddingHorizontal: spacing.lg, paddingTop: 60, paddingBottom: 120 },
label: {
...typography.caption1, fontWeight: "600", letterSpacing: 3,
color: ACCENT, marginBottom: spacing.xs,
},
title: { ...typography.largeTitle, color: colors.text, marginBottom: spacing.lg },
statsRow: { flexDirection: "row", gap: spacing.sm, marginBottom: spacing.md },
stat: {
flex: 1, alignItems: "center",
backgroundColor: colors.backgroundLight,
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
paddingVertical: spacing.md,
},
statValue: { ...typography.title2, color: ACCENT },
statLabel: { ...typography.caption2, color: colors.textSecondary, marginTop: 2 },
quotaBanner: {
flexDirection: "row", alignItems: "center", gap: spacing.sm,
backgroundColor: "rgba(212,175,55,0.08)",
borderRadius: radius.md, borderWidth: 1, borderColor: "rgba(212,175,55,0.3)",
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
marginBottom: spacing.md,
},
quotaText: { ...typography.caption1, color: colors.textSecondary, flex: 1 },
quotaCta: { ...typography.caption1, color: ACCENT, fontWeight: "700" },
tabs: {
flexDirection: "row",
backgroundColor: colors.backgroundLight,
borderRadius: radius.full,
padding: 3,
marginBottom: spacing.lg,
},
tab: { flex: 1, alignItems: "center", paddingVertical: spacing.sm, borderRadius: radius.full },
tabActive: { backgroundColor: ACCENT },
tabText: { ...typography.subhead, color: colors.textSecondary, fontWeight: "600" },
tabTextActive: { color: colors.background },
loader: { marginTop: spacing.xl },
error: { ...typography.footnote, color: colors.error, marginBottom: spacing.md },
empty: { alignItems: "center", paddingVertical: spacing.xxl, gap: spacing.sm },
emptyTitle: { ...typography.headline, color: colors.text, marginTop: spacing.sm },
emptyBody: {
...typography.subhead, color: colors.textSecondary,
textAlign: "center", paddingHorizontal: spacing.lg,
},
emptyButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg, paddingVertical: spacing.sm + 2,
borderRadius: radius.full, borderWidth: 1, borderColor: ACCENT,
},
emptyButtonText: { ...typography.subhead, color: ACCENT, fontWeight: "600" },
});
export default DaresScreen;
@@ -0,0 +1,185 @@
import { memo } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { SentDare } from "../types";
const ACCENT = featureColors.dares.accent;
/** The one action each side owns at each step of the loop. */
type Action = "accept" | "decline" | "submit" | "complete" | "cancel";
export interface DareCardProps {
dare: SentDare;
busy: boolean;
onAction: (dareId: string, action: Action) => void;
}
function relativeDeadline(expiresAt: string | null): string | null {
if (!expiresAt) return null;
const remainingMs = new Date(expiresAt).getTime() - Date.now();
if (remainingMs <= 0) return "Time's up";
const hours = Math.floor(remainingMs / 3_600_000);
if (hours >= 48) return `${Math.floor(hours / 24)} days left`;
if (hours >= 1) return `${hours}h left`;
return `${Math.max(1, Math.round(remainingMs / 60_000))}m left`;
}
function statusLabel(dare: SentDare): string {
const them = dare.direction === "incoming" ? "They dared you" : "You dared them";
switch (dare.status) {
case "pending": return dare.direction === "incoming" ? "Waiting on you" : "Waiting on them";
case "active": return dare.direction === "incoming" ? "Your turn" : "They accepted";
case "submitted": return dare.direction === "incoming" ? "Waiting to be confirmed" : "They say it's done";
case "completed": return "Completed";
case "declined": return "Declined";
case "cancelled": return "Cancelled";
case "expired": return "Expired";
default: return them;
}
}
/** Only surfaces buttons the viewer is actually allowed to press. */
function actionsFor(dare: SentDare): { action: Action; label: string; primary?: boolean }[] {
if (dare.direction === "incoming") {
if (dare.status === "pending") {
return [
{ action: "accept", label: "Accept", primary: true },
{ action: "decline", label: "Pass" },
];
}
if (dare.status === "active") return [{ action: "submit", label: "I did it", primary: true }];
return [];
}
if (dare.status === "submitted") {
return [
{ action: "complete", label: "Confirm", primary: true },
{ action: "cancel", label: "Cancel" },
];
}
if (dare.status === "pending" || dare.status === "active") {
return [{ action: "cancel", label: "Cancel" }];
}
return [];
}
export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCardProps) {
const deadline = relativeDeadline(dare.expires_at);
const actions = actionsFor(dare);
return (
<View style={styles.card}>
<View style={styles.header}>
<View style={styles.directionRow}>
<Ionicons
name={dare.direction === "incoming" ? "arrow-down-circle" : "arrow-up-circle"}
size={16}
color={ACCENT}
/>
<Text style={styles.status}>{statusLabel(dare)}</Text>
</View>
<View style={styles.intensityRow}>
{Array.from({ length: 5 }, (_, index) => (
<View
key={index}
style={[styles.pip, index < dare.intensity && styles.pipFilled]}
/>
))}
</View>
</View>
<Text style={styles.text}>{dare.text}</Text>
{dare.sender_notes ? <Text style={styles.notes}>“{dare.sender_notes}”</Text> : null}
{deadline ? (
<View style={styles.deadlineRow}>
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
<Text style={styles.deadline}>{deadline}</Text>
</View>
) : null}
{actions.length > 0 ? (
<View style={styles.actions}>
{busy ? (
<ActivityIndicator size="small" color={ACCENT} />
) : (
actions.map(({ action, label, primary }) => (
<Pressable
key={action}
onPress={() => onAction(dare.id, action)}
style={[styles.button, primary ? styles.buttonPrimary : styles.buttonSecondary]}
>
<Text style={[styles.buttonText, primary && styles.buttonTextPrimary]}>{label}</Text>
</Pressable>
))
)}
</View>
) : null}
</View>
);
});
const styles = StyleSheet.create({
card: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
marginBottom: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.sm,
},
directionRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
status: {
...typography.caption1,
color: ACCENT,
fontWeight: "600",
letterSpacing: 0.5,
},
intensityRow: { flexDirection: "row", gap: 3 },
pip: {
width: 5,
height: 5,
borderRadius: radius.full,
backgroundColor: colors.border,
},
pipFilled: { backgroundColor: ACCENT },
text: { ...typography.body, color: colors.text },
notes: {
...typography.footnote,
color: colors.textSecondary,
fontStyle: "italic",
marginTop: spacing.sm,
},
deadlineRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginTop: spacing.sm,
},
deadline: { ...typography.caption1, color: colors.textTertiary },
actions: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
gap: spacing.sm,
marginTop: spacing.md,
minHeight: 36,
},
button: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
},
buttonPrimary: { backgroundColor: ACCENT, borderColor: ACCENT },
buttonSecondary: { backgroundColor: "transparent", borderColor: colors.border },
buttonText: { ...typography.subhead, fontWeight: "600", color: colors.textSecondary },
buttonTextPrimary: { color: colors.background },
});
@@ -0,0 +1,83 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { DareEntitlement, DarePack } from "../types";
const ACCENT = featureColors.dares.accent;
export interface DarePackGridProps {
packs: DarePack[];
entitlement: DareEntitlement | null;
onSelectPack: (pack: DarePack) => void;
onWriteCustom: () => void;
}
export function DarePackGrid({ packs, entitlement, onSelectPack, onWriteCustom }: DarePackGridProps) {
const premium = entitlement?.is_premium ?? false;
return (
<View>
<Pressable style={styles.customRow} onPress={onWriteCustom}>
<View style={styles.customIcon}>
<Ionicons name={premium ? "create-outline" : "lock-closed"} size={18} color={ACCENT} />
</View>
<View style={styles.customCopy}>
<Text style={styles.customTitle}>Write your own</Text>
<Text style={styles.customSubtitle}>
{premium ? "Make it personal" : "Premium"}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
{packs.map((pack) => {
const locked = pack.is_premium && !premium;
return (
<Pressable key={pack.id} style={styles.packRow} onPress={() => onSelectPack(pack)}>
<Text style={styles.packIcon}>{pack.icon ?? "🔥"}</Text>
<View style={styles.packCopy}>
<View style={styles.packTitleRow}>
<Text style={styles.packTitle}>{pack.name}</Text>
{locked ? <Ionicons name="lock-closed" size={13} color={ACCENT} /> : null}
</View>
<Text style={styles.packSubtitle} numberOfLines={1}>
{pack.description ?? `${pack.dare_count} dares`}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
);
})}
</View>
);
}
const row = {
flexDirection: "row" as const,
alignItems: "center" as const,
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
marginBottom: spacing.sm,
gap: spacing.md,
};
const styles = StyleSheet.create({
customRow: { ...row, borderColor: "rgba(212,175,55,0.35)" },
customIcon: {
width: 36, height: 36, borderRadius: radius.full,
alignItems: "center", justifyContent: "center",
backgroundColor: "rgba(212,175,55,0.12)",
},
customCopy: { flex: 1 },
customTitle: { ...typography.headline, color: colors.text },
customSubtitle: { ...typography.caption1, color: ACCENT, marginTop: 2 },
packRow: row,
packIcon: { fontSize: 26, width: 36, textAlign: "center" },
packCopy: { flex: 1 },
packTitleRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
packTitle: { ...typography.headline, color: colors.text },
packSubtitle: { ...typography.caption1, color: colors.textSecondary, marginTop: 2 },
});
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable,
ScrollView, StyleSheet, Text, TextInput, View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { daresApi } from "../../../lib/daresApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types";
const ACCENT = featureColors.dares.accent;
export interface SendDareSheetProps {
visible: boolean;
pack: DarePack | null;
/** Null while the catalogue is loading; drives the custom-dare affordance. */
canSendCustom: boolean;
onClose: () => void;
onSend: (payload: SendDarePayload) => Promise<boolean>;
onRequestPremium: () => void;
}
export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, onRequestPremium }: SendDareSheetProps) {
const [dares, setDares] = useState<DareItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [customText, setCustomText] = useState("");
const [durationHours, setDurationHours] = useState<number | null>(24);
const [notes, setNotes] = useState("");
const [sending, setSending] = useState(false);
useEffect(() => {
if (!visible || !pack) return;
setLoading(true);
setSelectedId(null);
setCustomText("");
setNotes("");
daresApi
.packDares(pack.id)
.then((result) => setDares(result.dares))
.catch(() => setDares([]))
.finally(() => setLoading(false));
}, [visible, pack]);
const isCustom = pack === null;
const canSubmit = isCustom ? customText.trim().length > 0 : selectedId !== null;
const handleSend = useCallback(async () => {
if (!canSubmit || sending) return;
setSending(true);
const payload: SendDarePayload = isCustom
? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours }
: { dare_id: selectedId!, duration_hours: durationHours };
if (notes.trim()) payload.sender_notes = notes.trim();
const sent = await onSend(payload);
setSending(false);
if (sent) onClose();
}, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]);
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={styles.backdrop}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.sheet}
>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>{isCustom ? "Write a dare" : pack?.name}</Text>
<Pressable onPress={onClose} hitSlop={12}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView style={styles.body} keyboardShouldPersistTaps="handled">
{isCustom ? (
canSendCustom ? (
<TextInput
style={styles.customInput}
value={customText}
onChangeText={setCustomText}
placeholder="Dare your partner to..."
placeholderTextColor={colors.textTertiary}
multiline
maxLength={500}
/>
) : (
<Pressable style={styles.lockedCard} onPress={onRequestPremium}>
<Ionicons name="lock-closed" size={18} color={ACCENT} />
<Text style={styles.lockedText}>Custom dares are a premium feature</Text>
</Pressable>
)
) : loading ? (
<ActivityIndicator color={ACCENT} style={styles.loader} />
) : (
dares.map((dare) => {
const selected = dare.id === selectedId;
return (
<Pressable
key={dare.id}
onPress={() => setSelectedId(dare.id)}
style={[styles.option, selected && styles.optionSelected]}
>
<Text style={[styles.optionText, selected && styles.optionTextSelected]}>
{dare.text}
</Text>
<View style={styles.intensityRow}>
{Array.from({ length: 5 }, (_, index) => (
<View
key={index}
style={[styles.pip, index < dare.intensity && styles.pipFilled]}
/>
))}
</View>
</Pressable>
);
})
)}
<Text style={styles.sectionLabel}>TIME LIMIT</Text>
<View style={styles.durationRow}>
{DURATION_OPTIONS.map((option) => {
const selected = option.hours === durationHours;
return (
<Pressable
key={option.label}
onPress={() => setDurationHours(option.hours)}
style={[styles.chip, selected && styles.chipSelected]}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<Text style={styles.sectionLabel}>ADD A NOTE</Text>
<TextInput
style={styles.notesInput}
value={notes}
onChangeText={setNotes}
placeholder="Optional"
placeholderTextColor={colors.textTertiary}
maxLength={500}
/>
</ScrollView>
<Pressable
onPress={handleSend}
disabled={!canSubmit || sending}
style={[styles.sendButton, (!canSubmit || sending) && styles.sendButtonDisabled]}
>
{sending ? (
<ActivityIndicator size="small" color={colors.background} />
) : (
<Text style={styles.sendButtonText}>Send dare</Text>
)}
</Pressable>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "flex-end" },
sheet: {
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
paddingHorizontal: spacing.lg,
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.lg,
maxHeight: "88%",
},
handle: {
width: 36, height: 4, borderRadius: radius.full,
backgroundColor: colors.border, alignSelf: "center", marginTop: spacing.sm,
},
header: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingVertical: spacing.md,
},
title: { ...typography.title3, color: colors.text },
body: { marginBottom: spacing.md },
loader: { marginVertical: spacing.xl },
option: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
marginBottom: spacing.sm,
},
optionSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.08)" },
optionText: { ...typography.subhead, color: colors.textSecondary },
optionTextSelected: { color: colors.text },
intensityRow: { flexDirection: "row", gap: 3, marginTop: spacing.sm },
pip: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: colors.border },
pipFilled: { backgroundColor: ACCENT },
customInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
minHeight: 100,
textAlignVertical: "top",
},
lockedCard: {
flexDirection: "row", alignItems: "center", gap: spacing.sm,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md, borderWidth: 1, borderColor: ACCENT,
padding: spacing.md,
},
lockedText: { ...typography.subhead, color: ACCENT, fontWeight: "600" },
sectionLabel: {
...typography.caption2,
color: colors.textTertiary,
letterSpacing: 2,
fontWeight: "600",
marginTop: spacing.lg,
marginBottom: spacing.sm,
},
durationRow: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm },
chip: {
paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2,
borderRadius: radius.full, borderWidth: 1, borderColor: colors.border,
},
chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" },
chipText: { ...typography.footnote, color: colors.textSecondary },
chipTextSelected: { color: ACCENT, fontWeight: "600" },
notesInput: {
...typography.subhead,
color: colors.text,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
sendButton: {
backgroundColor: ACCENT,
borderRadius: radius.full,
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: "center",
minHeight: 50,
},
sendButtonDisabled: { opacity: 0.4 },
sendButtonText: { ...typography.headline, color: colors.background, fontWeight: "700" },
});
@@ -0,0 +1,6 @@
export { DareCard } from "./DareCard";
export type { DareCardProps } from "./DareCard";
export { DarePackGrid } from "./DarePackGrid";
export type { DarePackGridProps } from "./DarePackGrid";
export { SendDareSheet } from "./SendDareSheet";
export type { SendDareSheetProps } from "./SendDareSheet";
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
catalog: DareCatalog | null;
stats: DareStats | null;
loading: boolean;
busyDareId: string | null;
error: string | null;
/** Set when an action was refused for want of premium, so the screen can open the paywall. */
paywallReason: string | null;
clearPaywall: () => void;
refresh: () => Promise<void>;
send: (payload: SendDarePayload) => Promise<boolean>;
respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
submit: (dareId: string) => Promise<void>;
complete: (dareId: string) => Promise<void>;
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
const [stats, setStats] = useState<DareStats | null>(null);
const [loading, setLoading] = useState(true);
const [busyDareId, setBusyDareId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const refresh = useCallback(async () => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
if (cause instanceof ApiError && (cause.status === 402 || cause.status === 403)) {
setPaywallReason(cause.message);
return;
}
setError(cause instanceof ApiError ? cause.message : "Something went wrong");
}, []);
const act = useCallback(
async (dareId: string, operation: () => Promise<unknown>) => {
setBusyDareId(dareId);
try {
await operation();
await refresh();
} catch (cause) {
handleFailure(cause);
} finally {
if (mountedRef.current) setBusyDareId(null);
}
},
[refresh, handleFailure],
);
const send = useCallback(
async (payload: SendDarePayload) => {
try {
await daresApi.send(payload);
await refresh();
return true;
} catch (cause) {
handleFailure(cause);
return false;
}
},
[refresh, handleFailure],
);
return {
active,
history,
catalog,
stats,
loading,
busyDareId,
error,
paywallReason,
clearPaywall: useCallback(() => setPaywallReason(null), []),
refresh,
send,
respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]),
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]),
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
};
}
@@ -0,0 +1,34 @@
import { useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { daresApi } from "../../../lib/daresApi";
/**
* Lightweight counterpart to `useDares` for the home tile: one request on focus
* rather than the four-endpoint poll the full screen runs.
*/
export function useIncomingDareCount(enabled: boolean): number {
const [count, setCount] = useState(0);
useFocusEffect(
useCallback(() => {
if (!enabled) return;
let cancelled = false;
daresApi
.list("active")
.then((result) => {
if (cancelled) return;
setCount(
result.dares.filter(
(dare) => dare.direction === "incoming" && dare.status === "pending",
).length,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [enabled]),
);
return count;
}
+4
View File
@@ -0,0 +1,4 @@
export { DaresScreen, default } from "./DaresScreen";
export { useDares } from "./hooks/useDares";
export type { UseDaresReturn } from "./hooks/useDares";
export * from "./types";
+83
View File
@@ -0,0 +1,83 @@
export type DareStatus =
| "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled";
export interface DarePack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
dare_count: number;
}
export interface DareItem {
id: string;
pack_id: string;
text: string;
intensity: number;
suggested_duration_hours: number | null;
}
export interface SentDare {
id: string;
couple_id: string;
dare_id: string | null;
text: string;
intensity: number;
is_custom: boolean;
sender_id: string;
recipient_id: string;
direction: "incoming" | "outgoing";
status: DareStatus;
sender_notes: string | null;
sent_at: string;
accepted_at: string | null;
submitted_at: string | null;
completed_at: string | null;
expires_at: string | null;
}
export interface DareEntitlement {
is_premium: boolean;
can_send_custom: boolean;
weekly_send_limit: number | null;
sends_remaining: number | null;
}
export interface DareCatalog {
entitlement: DareEntitlement;
packs: DarePack[];
}
export interface DareStats {
sent: number;
received: number;
completed_together: number;
active: number;
completed_by_me: number;
completed_by_partner: number;
}
export interface SendDarePayload {
dare_id?: string;
custom_dare_text?: string;
custom_dare_intensity?: number;
duration_hours?: number | null;
sender_notes?: string | null;
}
export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [
{ label: "1 hour", hours: 1 },
{ label: "6 hours", hours: 6 },
{ label: "12 hours", hours: 12 },
{ label: "24 hours", hours: 24 },
{ label: "3 days", hours: 72 },
{ label: "1 week", hours: 168 },
{ label: "No time limit", hours: null },
];
+1
View File
@@ -1,3 +1,4 @@
// Features exports
export * from './chat';
export * from './dares';
export * from './profile';
@@ -44,8 +44,9 @@ export const LiveDrawScreen: React.FC = () => {
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
const canvasRef = useRef<DrawingCanvasHandle>(null);
// Keep track of the current stroke id for sync broadcasts
const currentStrokeIdRef = useRef<string | null>(null);
// The stroke under the finger. Points are pushed in place — nothing about the
// in-progress stroke goes through React state until it is finished.
const currentStrokeRef = useRef<StrokeSegment | null>(null);
const canvasWidth = screenWidth - spacing.md * 2;
const canvasHeight = canvasWidth * 1.2;
@@ -64,6 +65,16 @@ export const LiveDrawScreen: React.FC = () => {
const { makeSnapshot, captureToFile } = useCanvasCapture();
// Mirrors of state read inside the touch handlers. Keeping them in refs means
// the handlers (and therefore the pan gesture) keep a stable identity instead
// of being rebuilt mid-stroke on every render.
const strokesRef = useRef(strokes);
strokesRef.current = strokes;
const strokeStyleRef = useRef({ color: selectedColor, width: brushWidth, isEraser });
strokeStyleRef.current = { color: selectedColor, width: brushWidth, isEraser };
const userIdRef = useRef(user?.id);
userIdRef.current = user?.id;
const handleStrokeStartFromPartner = useCallback(
(stroke: StrokeSegment) => addStroke(stroke),
[addStroke]
@@ -95,57 +106,66 @@ export const LiveDrawScreen: React.FC = () => {
const handleTouchStart = useCallback(
(point: StrokePoint) => {
if (!user?.id) return;
if (strokes.length >= MAX_STROKES) {
const userId = userIdRef.current;
// The gesture starts the live path on the UI thread before we get here,
// so a refused stroke has to be wiped rather than simply not started.
if (!userId) {
canvasRef.current?.clearLiveStroke();
return;
}
if (strokesRef.current.length >= MAX_STROKES) {
canvasRef.current?.clearLiveStroke();
Alert.alert('Canvas Full', 'Save your drawing and start fresh.');
return;
}
const style = strokeStyleRef.current;
const stroke: StrokeSegment = {
id: `${user.id}_${Date.now()}`,
userId: user.id,
id: `${userId}_${Date.now()}`,
userId,
points: [point],
color: selectedColor,
width: brushWidth,
color: style.color,
width: style.width,
timestamp: Date.now(),
isEraser,
isEraser: style.isEraser,
};
currentStrokeIdRef.current = stroke.id;
canvasRef.current?.startStroke(stroke);
sync.broadcastStrokeStart(stroke);
currentStrokeRef.current = stroke;
sync.beginLocalStroke(stroke);
},
[user?.id, selectedColor, brushWidth, isEraser, strokes.length, sync]
[sync]
);
const handleTouchMove = useCallback(
(point: StrokePoint) => {
if (!currentStrokeIdRef.current) return;
canvasRef.current?.addPoint(point);
sync.broadcastStrokeContinue(currentStrokeIdRef.current, [point]);
const stroke = currentStrokeRef.current;
if (!stroke) return;
stroke.points.push(point);
sync.appendLocalPoint(point);
},
[sync]
);
const handleTouchEnd = useCallback(() => {
if (!currentStrokeIdRef.current) return;
const finished = canvasRef.current?.endStroke() ?? null;
currentStrokeIdRef.current = null;
if (finished) {
addStroke(finished);
sync.broadcastStrokeEnd(finished.id);
sync.persistStrokes([...strokes, finished]);
}
}, [addStroke, strokes, sync]);
const finished = currentStrokeRef.current;
currentStrokeRef.current = null;
if (!finished) return;
addStroke(finished);
void sync.endLocalStroke(finished);
// Drop the UI-thread copy only after React has painted the committed
// stroke, otherwise the line blinks out for a frame.
requestAnimationFrame(() => canvasRef.current?.clearLiveStroke());
}, [addStroke, sync]);
const handleUndo = useCallback(() => {
if (!user?.id) return;
const removed = undo(user.id);
if (removed) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
canvasRef.current?.clearLiveStroke();
sync.broadcastUndo(removed.id);
sync.persistStrokes(strokes.filter((s) => s.id !== removed.id));
void sync.persistStrokes(strokesRef.current.filter((s) => s.id !== removed.id));
}
}, [user?.id, undo, strokes, sync]);
}, [user?.id, undo, sync]);
const handleRedo = useCallback(() => {
if (!user?.id) return;
@@ -153,9 +173,9 @@ export const LiveDrawScreen: React.FC = () => {
if (restored) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
sync.broadcastRedo(restored);
sync.persistStrokes([...strokes, restored]);
void sync.persistStrokes([...strokesRef.current, restored]);
}
}, [user?.id, redo, strokes, sync]);
}, [user?.id, redo, sync]);
const handleClear = useCallback(() => {
Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [
@@ -165,8 +185,9 @@ export const LiveDrawScreen: React.FC = () => {
style: 'destructive',
onPress: () => {
clearAll();
canvasRef.current?.clearLiveStroke();
sync.broadcastClearCanvas();
sync.persistStrokes([]);
void sync.persistStrokes([]);
},
},
]);
@@ -203,6 +224,9 @@ export const LiveDrawScreen: React.FC = () => {
strokes={strokes}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
strokeColor={selectedColor}
strokeWidth={brushWidth}
isEraser={isEraser}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
@@ -1,26 +1,28 @@
import React, { useMemo, useRef, useEffect, useCallback, useState, useImperativeHandle, forwardRef } from 'react';
import React, { useMemo, useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
import { StyleSheet, View } from 'react-native';
import {
Canvas,
Path,
Skia,
} from '@shopify/react-native-skia';
import type { SkImage } from '@shopify/react-native-skia';
import type { SkImage, SkPath } from '@shopify/react-native-skia';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import { runOnJS, useDerivedValue, useSharedValue } from 'react-native-reanimated';
import type { StrokeSegment, StrokePoint } from '../types';
import { CANVAS_BACKGROUND } from '../constants';
export interface DrawingCanvasHandle {
startStroke: (stroke: StrokeSegment) => void;
addPoint: (point: StrokePoint) => void;
endStroke: () => StrokeSegment | null;
/** Drops the in-progress stroke rendered on the UI thread. */
clearLiveStroke: () => void;
}
interface DrawingCanvasProps {
strokes: StrokeSegment[];
canvasWidth: number;
canvasHeight: number;
strokeColor: string;
strokeWidth: number;
isEraser: boolean;
onTouchStart: (point: StrokePoint) => void;
onTouchMove: (point: StrokePoint) => void;
onTouchEnd: () => void;
@@ -85,41 +87,15 @@ const StrokePath = React.memo(function StrokePath({
);
});
// Renders the in-progress stroke from a ref, re-rendered only via rafTick
const CurrentStrokePath = React.memo(function CurrentStrokePath({
strokeRef,
canvasWidth,
canvasHeight,
_tick,
}: {
strokeRef: React.RefObject<StrokeSegment | null>;
canvasWidth: number;
canvasHeight: number;
_tick: number; // forces re-render at RAF cadence
}) {
const stroke = strokeRef.current;
if (!stroke) return null;
const path = buildPath(stroke.points, canvasWidth, canvasHeight);
return (
<Path
path={path}
color={stroke.isEraser ? CANVAS_BACKGROUND : stroke.color}
style="stroke"
strokeWidth={stroke.width}
strokeCap="round"
strokeJoin="round"
/>
);
});
export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
function DrawingCanvas(
{
strokes,
canvasWidth,
canvasHeight,
strokeColor,
strokeWidth,
isEraser,
onTouchStart,
onTouchMove,
onTouchEnd,
@@ -128,9 +104,23 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
ref
) {
const canvasRef = useRef<any>(null);
const currentStrokeRef = useRef<StrokeSegment | null>(null);
const rafScheduled = useRef(false);
const [rafTick, setRafTick] = useState(0);
// The in-progress stroke lives entirely on the UI thread: the pan gesture
// extends the path in place, so ink never waits on the JS thread.
const livePath = useSharedValue<SkPath | null>(null);
const livePrev = useSharedValue<StrokePoint | null>(null);
// Doubles as the invalidation signal: the path is mutated in place, so this
// counter is what tells the derived value to re-read it.
const livePointCount = useSharedValue(0);
const liveColor = isEraser ? CANVAS_BACKGROUND : strokeColor;
// Re-derived once per frame that the path changed — not once per point.
const renderedLivePath = useDerivedValue(() => {
const count = livePointCount.value;
const path = livePath.value;
return count > 0 && path ? path.copy() : Skia.Path.Make();
});
useEffect(() => {
if (onCanvasReady) {
@@ -140,52 +130,67 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
}
}, [onCanvasReady]);
const scheduleRafUpdate = useCallback(() => {
if (rafScheduled.current) return;
rafScheduled.current = true;
requestAnimationFrame(() => {
rafScheduled.current = false;
setRafTick((t) => t + 1);
});
}, []);
useImperativeHandle(ref, () => ({
startStroke(stroke: StrokeSegment) {
currentStrokeRef.current = stroke;
scheduleRafUpdate();
clearLiveStroke() {
livePath.value = null;
livePrev.value = null;
livePointCount.value = 0;
},
addPoint(point: StrokePoint) {
const s = currentStrokeRef.current;
if (!s) return;
// Mutate in place — no React state involved
s.points.push(point);
scheduleRafUpdate();
},
endStroke(): StrokeSegment | null {
const s = currentStrokeRef.current;
currentStrokeRef.current = null;
scheduleRafUpdate();
return s;
},
}), [scheduleRafUpdate]);
}), [livePath, livePrev, livePointCount]);
const pan = Gesture.Pan()
.minDistance(0)
.onStart((e) => {
runOnJS(onTouchStart)({
x: e.x / canvasWidth,
y: e.y / canvasHeight,
});
})
.onUpdate((e) => {
runOnJS(onTouchMove)({
x: e.x / canvasWidth,
y: e.y / canvasHeight,
});
})
.onEnd(() => {
runOnJS(onTouchEnd)();
});
const pan = useMemo(
() =>
Gesture.Pan()
.minDistance(0)
.onStart((e) => {
'worklet';
const path = Skia.Path.Make();
path.moveTo(e.x, e.y);
livePath.value = path;
livePrev.value = { x: e.x, y: e.y };
livePointCount.value = 1;
runOnJS(onTouchStart)({ x: e.x / canvasWidth, y: e.y / canvasHeight });
})
.onUpdate((e) => {
'worklet';
const path = livePath.value;
const prev = livePrev.value;
if (path && prev) {
// Same quadratic smoothing as buildPath, applied incrementally.
const midX = (prev.x + e.x) / 2;
const midY = (prev.y + e.y) / 2;
if (livePointCount.value === 1) {
path.lineTo(midX, midY);
} else {
path.quadTo(prev.x, prev.y, midX, midY);
}
livePrev.value = { x: e.x, y: e.y };
livePointCount.value += 1;
}
runOnJS(onTouchMove)({ x: e.x / canvasWidth, y: e.y / canvasHeight });
})
.onEnd(() => {
'worklet';
const path = livePath.value;
const prev = livePrev.value;
if (path && prev) {
// Close the half-segment the incremental midpoints leave at the tip.
path.lineTo(prev.x, prev.y);
livePointCount.value += 1;
}
runOnJS(onTouchEnd)();
}),
[
canvasWidth,
canvasHeight,
onTouchStart,
onTouchMove,
onTouchEnd,
livePath,
livePrev,
livePointCount,
]
);
return (
<GestureDetector gesture={pan}>
@@ -199,11 +204,13 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
canvasHeight={canvasHeight}
/>
))}
<CurrentStrokePath
strokeRef={currentStrokeRef}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
_tick={rafTick}
<Path
path={renderedLivePath}
color={liveColor}
style="stroke"
strokeWidth={strokeWidth}
strokeCap="round"
strokeJoin="round"
/>
</Canvas>
</View>
@@ -23,7 +23,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ViewShot from 'react-native-view-shot';
import * as Sharing from 'expo-sharing';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { colors, gradients, radius, typography, spacing, blur } from '../../../theme';
const logo = require('../../../../assets/logo.png');
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react';
import type { SkImage } from '@shopify/react-native-skia';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
interface UseCanvasCaptureReturn {
makeSnapshot: React.MutableRefObject<(() => SkImage | null) | null>;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import type { StrokeSegment, StrokePoint } from '../types';
@@ -17,9 +17,9 @@ interface UseDrawingSyncConfig {
}
interface UseDrawingSyncReturn {
broadcastStrokeStart: (stroke: StrokeSegment) => void;
broadcastStrokeContinue: (strokeId: string, points: StrokePoint[]) => void;
broadcastStrokeEnd: (strokeId: string) => void;
beginLocalStroke: (stroke: StrokeSegment) => void;
appendLocalPoint: (point: StrokePoint) => void;
endLocalStroke: (stroke: StrokeSegment) => Promise<void>;
broadcastClearCanvas: () => void;
broadcastUndo: (strokeId: string) => void;
broadcastRedo: (stroke: StrokeSegment) => void;
@@ -32,6 +32,14 @@ function conflictState(error: unknown): LiveDrawState | null {
return state && Array.isArray(state.strokes) && Number.isInteger(state.revision) ? state : null;
}
// Keeps the position of the first occurrence (stable z-order) but the value of
// the last (the most complete copy of a stroke that was persisted mid-draw).
export function dedupeById(strokes: StrokeSegment[]): StrokeSegment[] {
const byId = new Map<string, StrokeSegment>();
for (const stroke of strokes) byId.set(stroke.id, stroke);
return [...byId.values()];
}
export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegment[], current: StrokeSegment[]): StrokeSegment[] {
const desiredById = new Map(desired.map(stroke => [stroke.id, stroke]));
const desiredIds = new Set(desiredById.keys());
@@ -40,7 +48,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
const merged = current
.filter(stroke => !removedIds.has(stroke.id))
.map(stroke => desiredById.get(stroke.id) ?? stroke);
return [...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))];
return dedupeById([...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))]);
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
@@ -50,13 +58,38 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
const workingRef = useRef<StrokeSegment[]>([]);
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
const pendingWritesRef = useRef(0);
const throttleRef = useRef<NodeJS.Timeout | null>(null);
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The stroke currently under the finger. Owned here and mutated in place so a
// touch move costs a push, not a rebuild of every stroke on the canvas.
const localStrokeRef = useRef<StrokeSegment | null>(null);
const onLoadStrokesRef = useRef(onLoadStrokes);
onLoadStrokesRef.current = onLoadStrokes;
// workingRef never holds the in-progress stroke; compose it in only when we
// are about to send, so the O(n) copy happens once per write, not per point.
const composeWorking = useCallback((): StrokeSegment[] => {
const local = localStrokeRef.current;
if (!local) return workingRef.current;
return dedupeById([
...workingRef.current,
{ ...local, points: [...local.points] },
]);
}, []);
const acceptState = useCallback((state: LiveDrawState) => {
revisionRef.current = state.revision;
baselineRef.current = state.strokes;
const local = localStrokeRef.current;
if (local) {
// Mid-stroke: adopt the remote strokes for the next write, but do NOT
// push them into React state. The server's copy of the stroke under the
// finger is always behind it, and handing it to setStrokes is what makes
// the line snap backwards while drawing.
workingRef.current = state.strokes.filter(stroke => stroke.id !== local.id);
return;
}
workingRef.current = state.strokes;
onLoadStrokesRef.current(state.strokes);
}, []);
@@ -71,26 +104,27 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
const merged = mergeDrawingChanges(base, desired, current.strokes);
acceptState(await appDataApi.putLiveDraw(merged, current.revision));
}
updateWidget(workingRef.current);
}, [acceptState]);
updateWidget(composeWorking());
}, [acceptState, composeWorking]);
const enqueueWrite = useCallback((strokes: StrokeSegment[]): Promise<void> => {
workingRef.current = strokes;
const desired = dedupeById(strokes);
workingRef.current = desired.filter(stroke => stroke.id !== localStrokeRef.current?.id);
pendingWritesRef.current += 1;
const next = writeQueueRef.current.then(() => writeSnapshot(strokes));
const next = writeQueueRef.current.then(() => writeSnapshot(desired));
writeQueueRef.current = next.catch(error => { console.error('Failed to persist live drawing:', error); });
return next.finally(() => { pendingWritesRef.current -= 1; });
}, [writeSnapshot]);
const scheduleWrite = useCallback((strokes: StrokeSegment[]) => {
workingRef.current = strokes;
if (!throttleRef.current) {
throttleRef.current = setTimeout(() => {
throttleRef.current = null;
void enqueueWrite(workingRef.current);
}, 250);
}
}, [enqueueWrite]);
// Throttled: reads the latest state at fire time rather than capturing a
// freshly built array on every call.
const scheduleWrite = useCallback(() => {
if (throttleRef.current) return;
throttleRef.current = setTimeout(() => {
throttleRef.current = null;
void enqueueWrite(composeWorking());
}, 250);
}, [enqueueWrite, composeWorking]);
useEffect(() => {
if (!coupleId) return;
@@ -112,21 +146,45 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
const broadcastStrokeStart = useCallback((stroke: StrokeSegment) => {
scheduleWrite([...workingRef.current.filter(item => item.id !== stroke.id), stroke]);
}, [scheduleWrite]);
const broadcastStrokeContinue = useCallback((strokeId: string, points: StrokePoint[]) => {
scheduleWrite(workingRef.current.map(stroke => stroke.id === strokeId ? { ...stroke, points: [...stroke.points, ...points] } : stroke));
}, [scheduleWrite]);
const broadcastStrokeEnd = useCallback((_strokeId: string) => undefined, []);
const broadcastClearCanvas = useCallback(() => { workingRef.current = []; }, []);
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = [...workingRef.current.filter(item => item.id !== stroke.id), stroke]; }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
throttleRef.current = null;
return enqueueWrite(strokes);
}, [enqueueWrite]);
return { broadcastStrokeStart, broadcastStrokeContinue, broadcastStrokeEnd, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes };
const beginLocalStroke = useCallback((stroke: StrokeSegment) => {
localStrokeRef.current = { ...stroke, points: [...stroke.points] };
workingRef.current = workingRef.current.filter(item => item.id !== stroke.id);
scheduleWrite();
}, [scheduleWrite]);
const appendLocalPoint = useCallback((point: StrokePoint) => {
localStrokeRef.current?.points.push(point);
scheduleWrite();
}, [scheduleWrite]);
const endLocalStroke = useCallback((stroke: StrokeSegment) => {
localStrokeRef.current = null;
// dedupeById drops the truncated copy the mid-draw writes left behind.
return persistStrokes([...workingRef.current, stroke]);
}, [persistStrokes]);
const broadcastClearCanvas = useCallback(() => {
localStrokeRef.current = null;
workingRef.current = [];
}, []);
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = dedupeById([...workingRef.current, stroke]); }, []);
// Stable identity: the screen's touch handlers depend on this object, and a
// new one every render rebuilt the pan gesture mid-stroke.
return useMemo(() => ({
beginLocalStroke,
appendLocalPoint,
endLocalStroke,
broadcastClearCanvas,
broadcastUndo,
broadcastRedo,
persistStrokes,
}), [beginLocalStroke, appendLocalPoint, endLocalStroke, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes]);
}
@@ -37,6 +37,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
onStreakRemindersToggle: (value: boolean) => updatePreference('streak_reminders_enabled', value),
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_reminders_enabled', value),
@@ -138,6 +139,21 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
<View style={styles.itemDivider} />
{partner && (
<>
<SwitchItem
icon="alarm-outline"
label="Streak Reminders"
description="An evening nudge while your streak is still open"
value={preferences.streak_reminders_enabled}
onValueChange={handlers.onStreakRemindersToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
</>
)}
<SwitchItem
icon="calendar-outline"
label="Weekly Recap"
@@ -115,6 +115,7 @@ const SwipeScreen = () => {
packContext={packContext}
showBackButton={!!packId || mode === 'pending'}
onBack={() => router.back()}
dailyLimitInfo={dailyLimitInfo}
/>
<SwipeCardStack
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
@@ -6,6 +7,7 @@ import { LinearGradient } from "expo-linear-gradient";
import { DecorativeSeparator, GradientBackground } from "../../../components/ui";
import { Paywall } from "../../../components/paywall";
import { colors, radius, spacing, typography } from "../../../theme";
import { Events } from "../../../lib/analytics";
import type { DailyLimitInfo } from "../types";
interface SwipeDailyLimitStateProps {
@@ -27,6 +29,11 @@ export const SwipeDailyLimitState = ({
}: SwipeDailyLimitStateProps) => {
const accent = colors.premium.gold;
// The meter exists to convert; without these the funnel is unmeasurable.
useEffect(() => {
Events.paywallShown('daily_limit');
}, []);
return (
<GradientBackground>
<View style={styles.centerContainer}>
@@ -59,7 +66,7 @@ export const SwipeDailyLimitState = ({
</Animated.View>
<Text style={styles.dailyLimitDescription}>
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
You've answered {dailyLimitInfo.responses_today} questions today!{'\n'}
Let the anticipation build while you wait.
</Text>
@@ -100,8 +107,14 @@ export const SwipeDailyLimitState = ({
<Paywall
visible={showPaywall}
onClose={onClosePaywall}
onSuccess={onPaywallSuccess}
onClose={() => {
Events.paywallClosed('daily_limit', 'dismissed');
onClosePaywall();
}}
onSuccess={() => {
Events.paywallClosed('daily_limit', 'purchased');
onPaywallSuccess();
}}
/>
</GradientBackground>
);
@@ -4,7 +4,12 @@ import Animated, { FadeIn } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { colors, spacing, typography } from "../../../theme";
import { StreakDisplay } from "../../../components/StreakDisplay";
import { useProgressShimmer } from "../hooks/useProgressShimmer";
import type { DailyLimitInfo } from "../types";
/** Remaining answers at or below this switch the meter to its "running out" treatment. */
const SCARCITY_THRESHOLD = 3;
interface SwipeHeaderProps {
currentIndex: number;
@@ -14,6 +19,7 @@ interface SwipeHeaderProps {
packContext: { name: string } | null;
showBackButton: boolean;
onBack: () => void;
dailyLimitInfo?: DailyLimitInfo | null;
}
export const SwipeHeader = ({
@@ -24,11 +30,20 @@ export const SwipeHeader = ({
packContext,
showBackButton,
onBack,
dailyLimitInfo,
}: SwipeHeaderProps) => {
const shimmerStyle = useProgressShimmer();
const total = effectiveTotal || totalQuestions;
const progressPercent = total > 0 ? ((currentIndex + 1) / total) * 100 : 0;
// Catch-up answers are exempt from the cap, so the meter is hidden in pending mode.
const showMeter = mode !== 'pending'
&& !!dailyLimitInfo
&& dailyLimitInfo.limit_value > 0
&& !dailyLimitInfo.is_blocked;
const remaining = dailyLimitInfo?.remaining ?? 0;
const isRunningOut = showMeter && remaining <= SCARCITY_THRESHOLD;
return (
<Animated.View
entering={FadeIn.duration(400)}
@@ -47,6 +62,16 @@ export const SwipeHeader = ({
<Text style={styles.progressText}>
{currentIndex + 1} of {total}
</Text>
{showMeter && (
<View style={[styles.meter, isRunningOut && styles.meterUrgent]}>
{isRunningOut && (
<Ionicons name="hourglass-outline" size={12} color={colors.premium.gold} />
)}
<Text style={[styles.meterText, isRunningOut && styles.meterTextUrgent]}>
{remaining === 1 ? '1 left today' : `${remaining} left today`}
</Text>
</View>
)}
<View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View
style={[
@@ -66,6 +91,11 @@ export const SwipeHeader = ({
</Animated.View>
</View>
</View>
{/* The shared streak sits opposite the back button so the centred
progress column keeps its position. */}
<View style={styles.streakBadge} pointerEvents="none">
<StreakDisplay compact delay={200} />
</View>
</View>
</Animated.View>
);
@@ -87,6 +117,10 @@ const styles = StyleSheet.create({
left: 0,
padding: spacing.sm,
},
streakBadge: {
position: 'absolute',
right: 0,
},
progressContainer: {
alignItems: "center",
},
@@ -100,8 +134,31 @@ const styles = StyleSheet.create({
progressText: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
meter: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
meterUrgent: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
meterText: {
...typography.caption2,
letterSpacing: 0.5,
color: colors.textTertiary,
},
meterTextUrgent: {
color: colors.premium.gold,
fontWeight: '600',
marginLeft: spacing.xs,
},
progressBar: {
width: 160,
height: 6,
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore } from "../../../store";
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { apiClient } from "../../../lib/apiClient";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
import type { AnswerType } from "../../../types";
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
@@ -128,8 +128,16 @@ export const useSwipeScreen = () => {
filtered = data;
}
if (partner && !packId) {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
if (partner) {
// The daily limit applies to pack browsing too, so it is read in both
// modes; the answer gap is deliberately only enforced outside packs.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
} else {
setIsBlocked(false);
setGapInfo(null);
@@ -334,8 +342,40 @@ export const useSwipeScreen = () => {
}
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
} else {
// Catch-up answers are exempt from the cap, but the partner may have
// answered after this screen loaded, so re-read rather than assume.
await checkDailyLimit();
}
// The shared streak turns on today's answers, so a stale badge would show the
// wrong partner state for the rest of the session. Refreshing it must never
// interfere with recording the answer, so it stays off the critical path.
void useStreakStore.getState().fetchStreak().catch(() => undefined);
} catch (error) {
// A 429 means the server rejected this answer outright. Do not advance the
// index, or the question is silently skipped and lost for the day.
if (error instanceof ApiError && error.status === 429) {
// ApiError.details carries the whole response body: { error, details }.
const body = (error.details ?? {}) as {
details?: {
daily_limit?: number;
responses_today?: number;
remaining?: number;
reset_at?: string;
};
};
const details = body.details ?? {};
setDailyLimitInfo(prev => ({
responses_today: details.responses_today ?? prev?.responses_today ?? 0,
limit_value: details.daily_limit ?? prev?.limit_value ?? 0,
remaining: details.remaining ?? 0,
reset_at: details.reset_at ?? prev?.reset_at ?? '',
is_blocked: true,
}));
return;
}
console.error("Failed to submit response", error);
setCurrentIndex(prev => prev + 1);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react';
import { Alert, Platform, ActionSheetIOS } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { uploadMedia } from '../lib/mediaApi';
export interface UseAvatarPickerOptions {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
export function useMediaSaver() {
const [saving, setSaving] = useState(false);
+19
View File
@@ -0,0 +1,19 @@
import { apiClient } from "./apiClient";
import type { DareCatalog, DareItem, DareStats, SendDarePayload, SentDare } from "../features/dares/types";
const dare = (dareId: string) => `/v1/dares/${encodeURIComponent(dareId)}`;
export const daresApi = {
catalog: () => apiClient.get<DareCatalog>("/v1/dares/packs"),
packDares: (packId: string) =>
apiClient.get<{ dares: DareItem[] }>(`/v1/dares/packs/${encodeURIComponent(packId)}/dares`),
list: (filter: "active" | "history") =>
apiClient.get<{ dares: SentDare[] }>(`/v1/dares?filter=${filter}`),
stats: () => apiClient.get<DareStats>("/v1/dares/stats"),
send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload),
respond: (dareId: string, action: "accept" | "decline") =>
apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }),
submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`),
complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`),
cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`),
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { getMediaUrl, mediaId } from './mediaApi';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { Platform } from 'react-native';
// Cache for signed URLs with expiration tracking
+1 -1
View File
@@ -1,5 +1,5 @@
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { decode } from 'base64-arraybuffer';
import { ApiError, apiClient, authenticatedFetch } from './apiClient';
+25 -8
View File
@@ -12,20 +12,37 @@ import { useMatchStore } from "../store/matchStore";
// Configure notification behavior for foreground
// We don't show system alerts since the app handles notifications with in-app toasts
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldPlaySound: false,
handleNotification: async () => ({
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
// Types
export interface NotificationData {
export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
match_id?: string;
message_id?: string;
count?: number;
}
}
const notificationTypes = new Set<NotificationData["type"]>([
"match",
"message",
"match_digest",
"partner_activity",
"nudge",
"weekly_summary",
]);
function isNotificationData(value: unknown): value is NotificationData {
if (!value || typeof value !== "object") return false;
const type = (value as { type?: unknown }).type;
return typeof type === "string" && notificationTypes.has(type as NotificationData["type"]);
}
/**
@@ -219,9 +236,9 @@ export async function checkAndRegisterPushToken(userId: string): Promise<boolean
export function handleNotificationResponse(
response: Notifications.NotificationResponse
): void {
const data = response.notification.request.content.data as NotificationData;
if (!data) {
const data = response.notification.request.content.data;
if (!isNotificationData(data)) {
return;
}
@@ -13,6 +13,7 @@ export interface ProfileUpdate {
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
timezone?: string | null;
}
type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>;
+68
View File
@@ -0,0 +1,68 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { profileSettingsApi } from "./profileSettingsApi";
const STORAGE_KEY_PREFIX = "reported_timezone";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
/** The IANA zone this device is in, or null when the platform can't resolve one. */
export function resolveDeviceTimezone(): string | null {
try {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return zone && zone.length <= 64 ? zone : null;
} catch {
return null;
}
}
async function readStored(key: string): Promise<string | null> {
try {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key);
}
return null;
}
return await SecureStore.getItemAsync(key);
} catch (error) {
console.error("Error reading reported timezone:", error);
return null;
}
}
async function writeStored(key: string, zone: string): Promise<void> {
try {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, zone);
}
return;
}
await SecureStore.setItemAsync(key, zone);
} catch (error) {
console.error("Error saving reported timezone:", error);
}
}
/**
* Report the device timezone to the server so the daily response limit buckets on the
* user's local day. Cached locally, so this costs one write on install and one per
* actual travel event rather than one per foreground.
*/
export async function syncTimezone(userId: string): Promise<void> {
const zone = resolveDeviceTimezone();
if (!zone) return;
const key = buildKey(userId);
if (await readStored(key) === zone) return;
try {
await profileSettingsApi.updateProfile({ timezone: zone });
await writeStored(key, zone);
} catch (error) {
// Non-critical: the server falls back to UTC until this succeeds.
console.error("Failed to sync timezone:", error);
}
}
+5
View File
@@ -3,6 +3,7 @@ import { ApiError, apiClient } from "../lib/apiClient";
import { authClient } from "../lib/authClient";
import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
@@ -200,6 +201,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
// Keep the server's idea of the user's timezone current so the daily
// response limit resets at their local midnight, not 00:00 UTC.
void syncTimezone(userId);
},
}));
@@ -11,6 +11,7 @@ export interface NotificationPreferences {
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
streak_reminders_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
+11 -2
View File
@@ -2,13 +2,22 @@ import { create } from "zustand";
import { apiClient } from "../lib/apiClient";
import { useAuthStore } from "./authStore";
/**
* Resolved from the signed-in partner's point of view by the API. The stored row is
* positional (user1/user2 by member id), which a client cannot act on without
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
*/
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
user1_answered_today: boolean;
user2_answered_today: boolean;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
+14 -1
View File
@@ -103,13 +103,26 @@ jest.mock('expo-image-picker', () => ({
},
}));
jest.mock('expo-file-system', () => ({
jest.mock('expo-file-system/legacy', () => ({
readAsStringAsync: jest.fn(async () => ''),
EncodingType: {
Base64: 'base64',
},
}));
jest.mock('expo-av', () => ({
Audio: {
requestPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
setAudioModeAsync: jest.fn(async () => undefined),
Recording: {
createAsync: jest.fn(),
},
RecordingOptionsPresets: {
HIGH_QUALITY: {},
},
},
}));
jest.mock('react-native-compressor', () => ({
Video: {
compress: jest.fn(async (uri: string) => uri),
+2 -2
View File
@@ -20,8 +20,8 @@
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.35",
+98
View File
@@ -0,0 +1,98 @@
# Couple streaks
A streak counts consecutive days on which **both** partners answered at least one
question. It is deliberately a property of the couple, not of a person: there is no
individual streak anywhere in the product, and there is not meant to be one.
## What counts as a day together
A day counts when both partners record a response to any eligible question on the same
couple-local calendar date. It is **not** tied to one specific question.
This is a product decision, not an implementation shortcut. Tying the streak to a single
daily question would break a couple who answered fifteen other questions and missed that
one, which is exactly the kind of arbitrary loss that makes the mechanic feel punitive. If
a daily question ships, it should be the prompt that reliably produces the answer, not the
only answer that counts.
Answering more than once in a day does nothing extra. Catch-up answers count the same as
new exploration; they are already exempt from the daily response cap.
## The day boundary
The streak day is the **couple's shared local day**. Both partners have to land inside the
same calendar date, so the day boundary cannot belong to whoever happens to be answering.
The zone is the first reported `profiles.timezone` in member id order — the same ordering
that decides the `user1`/`user2` columns — falling back to the other partner's zone, then
to UTC when neither has reported one or the reported zone is not a known IANA name.
Counting in UTC instead would break a couple in a western zone at UTC midnight while it is
still the same evening where they live. Partners in different zones share one boundary
rather than each getting their own, because the day either happened for the couple or it
did not.
## Storage and writes
`couple_streaks` holds one row per couple:
| Column | Meaning |
|---|---|
| `current_streak` | Consecutive completed days. |
| `longest_streak` | High-water mark, never reduced. |
| `last_active_date` | Couple-local date either partner last answered. |
| `last_completed_date` | Couple-local date **both** partners last answered. |
| `user1_answered_today` / `user2_answered_today` | Per-partner flags for `last_active_date`, positional by member id. |
| `streak_celebrated_at` | Highest milestone already notified. |
`PostgresAnswersRepository.touchStreak` maintains the row inside the same transaction as
the response that caused it, so the streak can never disagree with the responses it counts.
There is no trigger and no cron reset: the write path treats the flags as stale whenever
`last_active_date` is not the couple-local today, which makes a separate midnight job
unnecessary and removes a whole class of clock-skew bugs.
The streak is alive while `last_completed_date` is the couple-local today or yesterday.
## Reading
`GET /v1/me/streak` resolves the row from the **caller's** point of view and returns
`you_answered_today`, `partner_answered_today`, and `partner_name`. The stored flags are
positional because the writer has to be deterministic, but a client cannot act on
"user2 has not answered" without re-deriving the id ordering, so the API does it instead.
The read applies the same day rules as the write. A lapsed streak reports
`current_streak: 0` immediately rather than showing its stored number until the next
response silently resets it, and stale per-partner flags read as `false`.
## Notifications
Two producers in `PostgresOperationsRepository`, both queued through the outbox with a
dedupe key and both individually opt-out-able:
| Producer | Fires | Preference |
|---|---|---|
| Milestones | On reaching 7, 14, 30, 60, or 100 days, once per milestone per couple. | `streak_milestones_enabled` |
| At-risk nudge | From 20:00 couple-local, when the streak is alive and the day is incomplete. Only the partner who still owes an answer is notified. | `streak_reminders_enabled` |
The at-risk copy names the partner who has already answered as an invitation
("Alex answered today"), never as a debt. Guilt between partners is the failure mode for a
shared streak, not the goal — the original implementation reset broken streaks silently for
the same reason, and that should stay true of anything added here.
## Surfaces
- Discovery screen: full card, hidden entirely for couples with no live streak and no
activity today, so a dormant pair is never shown a zero.
- Swipe header: compact flame and count.
- Profile settings: both notification toggles, shown only once the user has a partner.
## Legacy data plane
The retired Supabase project scheduled `reset-daily-streak-flags` and
`check-streak-milestones` cron jobs and a `check-streak-milestones` Edge Function against
the old `couple_streaks` table. The API owns both jobs now. Those legacy writers are
stopped as part of the cutover procedure in
[backend deployment and branching](operations/backend-deployments.md); if any hosted
project still has them scheduled, an operator unschedules them directly — agents do not
execute remote DDL. Migration `0016_couple_streak_locality.sql` drops the equivalent
trigger and functions from the product database defensively.
+5
View File
@@ -1,5 +1,10 @@
# Dares Feature - Architecture & Implementation Plan
> **SUPERSEDED.** This is the original 2026-01 plan, kept for its competitor analysis
> and decision history. It describes a Supabase Edge Function + RLS architecture that
> the product no longer uses: mobile talks only to the standalone API. For the shipped
> contract see [Dares loop](dares-loop.md).
> **Status**: Phase 1 Complete (Database + Admin Portal)
> **Created**: 2026-01-08
> **Updated**: 2026-01-08
+139
View File
@@ -0,0 +1,139 @@
# Dares loop
Maintained reference for the dares feature: a couple sends each other challenges,
each state change notifies the other partner, and the pair keeps a shared count of
what they have finished together.
The asynchronous shape is the point. Every transition below is a push notification
to whichever partner is not holding the app.
## Ownership
| Concern | Location |
|---|---|
| Schema, triggers, review parity | `apps/api/drizzle/0013_dares_loop.sql` |
| HTTP contract | `apps/api/src/domains/dares/routes.ts` |
| Domain rules and entitlement | `apps/api/src/domains/dares/repository.ts` |
| Expiry sweep | `apps/api/src/domains/operations/repository.ts` (`produce`) |
| Mobile feature | `apps/mobile/src/features/dares/` |
| Catalogue authoring | `apps/admin/src/pages/content/DarePacksPage.tsx`, `DaresPage.tsx` |
## State machine
```
┌── decline ──► declined
pending ──accept──┤
│ └── accept ───► active ──submit──► submitted ──confirm──► completed
│ │ │
└──────────────── cancel ─────────┴───────────────────┘──► cancelled
(sender only, before completion)
any open status + expires_at in the past ──► expired (worker sweep)
```
Actor rules are enforced server-side, not by hiding buttons:
| Transition | Who | Legal from |
|---|---|---|
| accept / decline | recipient | `pending` |
| submit | recipient | `active` |
| complete | sender | `active`, `submitted` |
| cancel | sender | `pending`, `active`, `submitted` |
The recipient performs the dare, so the recipient reports it done (`submitted`) and
the sender confirms (`completed`). An illegal transition returns `409
invalid_transition`; acting on someone else's dare returns `403 not_permitted`;
a dare outside your couple returns `404`, never a hint that it exists.
## Entitlement
Premium is couple-shared — either partner's subscription unlocks both, matching the
answers domain.
| Tier | Can do |
|---|---|
| Anyone, always | Receive, accept, decline, submit, complete, cancel |
| Free sender | Free packs, `FREE_WEEKLY_SEND_LIMIT` sends per rolling 7 days |
| Premium | All packs, unlimited sends, custom dares |
**Receiving is never gated.** A paywall between a partner and the dare they were just
notified about would break the loop the feature exists to create. The quota is counted
per sender over a rolling window, and counts every send — cancelling does not refund it.
## Content safety
`dare_packs` and `dares` carry `content_status` (`unreviewed` / `allowed` / `archived`),
matching the legacy catalogue contract. The catalogue, the pack detail endpoint, and the
send path each independently require `allowed`, so a stale client cannot send unreviewed
content. New content defaults to `unreviewed` and is therefore invisible until an
administrator reviews it.
Push copy is deliberately generic — `"Your partner sent you a dare"`, never the dare
text. Store compliance forbids question, dare, response, or message text in a
notification preview, and `dares.integration.test.ts` asserts no payload leaks it.
## History durability
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
with a check violation.
## Notifications
`queue_dare_operations` (trigger on `sent_dares`) writes one `operations_outbox` row per
transition with dedupe key `dare:<id>:<event>:<recipient>`, gated on
`notification_preferences.dares_enabled`. Delivery, retry, and Expo ticket handling are
the existing worker's job — see `apps/api/src/domains/operations/README.md`.
`expired` notifies both partners; every other transition notifies the one partner who
did not cause it.
## Expiry
The worker's `produce` pass expires any dare whose `expires_at` has passed while still
`pending`, `active`, or `submitted`, reported as `daresExpired` in `ProducerSummary`.
Pending is included deliberately: an invitation the recipient never opened would
otherwise stay open forever.
Durations offered to the client are fixed presets (1, 6, 12, 24, 72, 168 hours, or none)
and validated server-side.
## HTTP contract
| Method | Path | Notes |
|---|---|---|
| GET | `/v1/dares/packs` | Catalogue plus the caller's entitlement |
| GET | `/v1/dares/packs/:packId/dares` | `402` when the pack is premium and the couple is not |
| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller |
| GET | `/v1/dares/stats` | Shared scoreboard |
| GET | `/v1/dares/durations` | Preset list the send sheet renders |
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both |
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
| POST | `/v1/dares/:dareId/submit` | Recipient reports done |
| POST | `/v1/dares/:dareId/complete` | Sender confirms |
| POST | `/v1/dares/:dareId/cancel` | Sender withdraws |
The actor always comes from the bearer identity; ownership fields in a request body are
rejected.
## Mobile
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
channel), and opens the paywall both proactively and in reaction to a `402` — a client
whose entitlement went stale still lands on the paywall rather than an error.
## Tests
- `apps/api/test/dares.integration.test.ts` — real PostgreSQL: entitlement, quota,
transitions, couple isolation, notification fan-out, no-text-in-push, snapshot durability
- `apps/api/test/dares.routes.test.ts` — HTTP contract and error mapping
- `apps/mobile/src/__tests__/hooks/useDares.test.ts` — loading, actions, paywall routing
## Not in this version
Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox
split from the main match chat was cut in favour of status transitions plus the sender's
note. Photo proof, random-dare, and streaks remain unbuilt.
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more