mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Merge pull request #4 from jjbrunton/staging
chore(release): promote staging to production
This commit is contained in:
106 files changed
+8864
-6192
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
|
||||
export type DareStatus =
|
||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||
|
||||
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
|
||||
export const FREE_WEEKLY_SEND_LIMIT = 3;
|
||||
|
||||
/** Durations offered in the send sheet. `null` means no deadline. */
|
||||
export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const;
|
||||
|
||||
export interface DarePack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
dare_count: number;
|
||||
}
|
||||
|
||||
export interface DareItem {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
text: string;
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
}
|
||||
|
||||
export interface SentDare {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
dare_id: string | null;
|
||||
text: string;
|
||||
intensity: number;
|
||||
is_custom: boolean;
|
||||
sender_id: string;
|
||||
recipient_id: string;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
sent_at: string;
|
||||
accepted_at: string | null;
|
||||
submitted_at: string | null;
|
||||
completed_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface DareEntitlement {
|
||||
is_premium: boolean;
|
||||
can_send_custom: boolean;
|
||||
weekly_send_limit: number | null;
|
||||
sends_remaining: number | null;
|
||||
}
|
||||
|
||||
export interface DareCatalog {
|
||||
entitlement: DareEntitlement;
|
||||
packs: DarePack[];
|
||||
}
|
||||
|
||||
export interface DareStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
completed_together: number;
|
||||
active: number;
|
||||
completed_by_me: number;
|
||||
completed_by_partner: number;
|
||||
}
|
||||
|
||||
export interface SendDareInput {
|
||||
dare_id?: string | null;
|
||||
custom_dare_text?: string | null;
|
||||
custom_dare_intensity?: number | null;
|
||||
duration_hours?: number | null;
|
||||
sender_notes?: string | null;
|
||||
}
|
||||
|
||||
export class DaresError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly status: 400 | 402 | 403 | 404 | 409,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface ContextRow extends QueryResultRow {
|
||||
couple_id: string | null;
|
||||
is_premium: boolean;
|
||||
hide_nsfw: boolean;
|
||||
max_intensity: number;
|
||||
partner_id: string | null;
|
||||
partner_premium: boolean;
|
||||
}
|
||||
|
||||
interface SentDareRecord extends QueryResultRow {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
dare_id: string | null;
|
||||
dare_text_snapshot: string;
|
||||
dare_intensity_snapshot: number;
|
||||
custom_dare_text: string | null;
|
||||
sender_id: string;
|
||||
recipient_id: string;
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
sent_at: Date;
|
||||
accepted_at: Date | null;
|
||||
submitted_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
expires_at: Date | null;
|
||||
}
|
||||
|
||||
export interface DaresRepository {
|
||||
getCatalog(userId: string): Promise<DareCatalog>;
|
||||
listPackDares(userId: string, packId: string): Promise<DareItem[]>;
|
||||
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
|
||||
send(userId: string, input: SendDareInput): Promise<SentDare>;
|
||||
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
|
||||
submit(userId: string, dareId: string): Promise<SentDare>;
|
||||
complete(userId: string, dareId: string): Promise<SentDare>;
|
||||
cancel(userId: string, dareId: string): Promise<SentDare>;
|
||||
stats(userId: string): Promise<DareStats>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
|
||||
custom_dare_text, sender_id, recipient_id, status, sender_notes,
|
||||
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
|
||||
|
||||
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
|
||||
|
||||
/** Statuses still awaiting somebody's action; everything else is history. */
|
||||
const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted'];
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
return value?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await operation(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
||||
return {
|
||||
id: row.id,
|
||||
couple_id: row.couple_id,
|
||||
dare_id: row.dare_id,
|
||||
text: row.dare_text_snapshot,
|
||||
intensity: row.dare_intensity_snapshot,
|
||||
is_custom: row.custom_dare_text !== null,
|
||||
sender_id: row.sender_id,
|
||||
recipient_id: row.recipient_id,
|
||||
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
|
||||
status: row.status,
|
||||
sender_notes: row.sender_notes,
|
||||
sent_at: row.sent_at.toISOString(),
|
||||
accepted_at: iso(row.accepted_at),
|
||||
submitted_at: iso(row.submitted_at),
|
||||
completed_at: iso(row.completed_at),
|
||||
expires_at: iso(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
export class PostgresDaresRepository implements DaresRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium is couple-shared, matching the answers domain: either partner paying
|
||||
* unlocks the catalogue for both.
|
||||
*/
|
||||
private async context(userId: string): Promise<ContextRow> {
|
||||
const result = await this.pool.query<ContextRow>(
|
||||
`select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity,
|
||||
partner.id as partner_id,
|
||||
coalesce(partner.is_premium, false) as partner_premium
|
||||
from profiles p
|
||||
left join lateral (
|
||||
select id, is_premium from profiles other
|
||||
where other.couple_id = p.couple_id and other.id <> p.id
|
||||
order by other.id limit 1
|
||||
) partner on true
|
||||
where p.id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static isPremium(context: ContextRow): boolean {
|
||||
return context.is_premium || context.partner_premium;
|
||||
}
|
||||
|
||||
private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise<number> {
|
||||
const result = await client.query<{ count: string }>(
|
||||
`select count(*) as count from sent_dares
|
||||
where sender_id = $1 and sent_at > now() - interval '7 days'`,
|
||||
[userId],
|
||||
);
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
private async entitlement(context: ContextRow, userId: string): Promise<DareEntitlement> {
|
||||
const premium = PostgresDaresRepository.isPremium(context);
|
||||
if (premium) {
|
||||
return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null };
|
||||
}
|
||||
const used = await this.sendsThisWeek(userId);
|
||||
return {
|
||||
is_premium: false,
|
||||
can_send_custom: false,
|
||||
weekly_send_limit: FREE_WEEKLY_SEND_LIMIT,
|
||||
sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used),
|
||||
};
|
||||
}
|
||||
|
||||
async getCatalog(userId: string): Promise<DareCatalog> {
|
||||
const context = await this.context(userId);
|
||||
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
|
||||
const [entitlement, packs] = await Promise.all([
|
||||
this.entitlement(context, userId),
|
||||
this.pool.query<DarePack & QueryResultRow>(
|
||||
`select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit,
|
||||
dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity,
|
||||
dp.avg_intensity::double precision as avg_intensity,
|
||||
count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count
|
||||
from dare_packs dp
|
||||
left join categories c on c.id = dp.category_id
|
||||
left join dares d on d.pack_id = dp.id
|
||||
where dp.is_public = true
|
||||
and dp.content_status = 'allowed'
|
||||
and (dp.category_id is null or c.is_public = true)
|
||||
and ($1::boolean = false or dp.is_explicit = false)
|
||||
and (dp.min_intensity is null or dp.min_intensity <= $2::integer)
|
||||
group by dp.id
|
||||
having count(d.id) filter (where d.content_status = 'allowed') > 0
|
||||
order by dp.sort_order, dp.name, dp.id`,
|
||||
[context.hide_nsfw, maxIntensity],
|
||||
),
|
||||
]);
|
||||
return { entitlement, packs: packs.rows };
|
||||
}
|
||||
|
||||
async listPackDares(userId: string, packId: string): Promise<DareItem[]> {
|
||||
const context = await this.context(userId);
|
||||
const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>(
|
||||
`select dp.is_premium, dp.is_explicit
|
||||
from dare_packs dp
|
||||
left join categories c on c.id = dp.category_id
|
||||
where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed'
|
||||
and (dp.category_id is null or c.is_public = true)`,
|
||||
[packId],
|
||||
);
|
||||
const row = pack.rows[0];
|
||||
if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404);
|
||||
if (row.is_explicit && context.hide_nsfw) {
|
||||
throw new DaresError('pack_not_found', 'Dare pack not found', 404);
|
||||
}
|
||||
if (row.is_premium && !PostgresDaresRepository.isPremium(context)) {
|
||||
throw new DaresError('premium_required', 'This dare pack requires premium', 402);
|
||||
}
|
||||
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
|
||||
const result = await this.pool.query<DareItem & QueryResultRow>(
|
||||
`select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours
|
||||
from dares d
|
||||
where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer
|
||||
order by d.intensity, d.created_at, d.id`,
|
||||
[packId, maxIntensity],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]> {
|
||||
const context = await this.context(userId);
|
||||
if (!context.couple_id) return [];
|
||||
const result = await this.pool.query<SentDareRecord>(
|
||||
`select ${SENT_COLUMNS}
|
||||
from sent_dares sd
|
||||
where sd.couple_id = $1
|
||||
and (sd.status = any($2::text[])) = $3::boolean
|
||||
order by sd.sent_at desc, sd.id desc
|
||||
limit 200`,
|
||||
[context.couple_id, OPEN_STATUSES, filter === 'active'],
|
||||
);
|
||||
return result.rows.map((row) => toSentDare(row, userId));
|
||||
}
|
||||
|
||||
async send(userId: string, input: SendDareInput): Promise<SentDare> {
|
||||
const context = await this.context(userId);
|
||||
if (!context.couple_id || !context.partner_id) {
|
||||
throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409);
|
||||
}
|
||||
const premium = PostgresDaresRepository.isPremium(context);
|
||||
|
||||
const duration = input.duration_hours ?? null;
|
||||
if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) {
|
||||
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
|
||||
}
|
||||
|
||||
let text: string;
|
||||
let intensity: number;
|
||||
let dareId: string | null = null;
|
||||
let customText: string | null = null;
|
||||
let customIntensity: number | null = null;
|
||||
|
||||
if (input.dare_id) {
|
||||
// Re-check entitlement against the pack so a stale client cannot send a locked dare.
|
||||
const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>(
|
||||
`select d.text, d.intensity, dp.is_premium, dp.is_explicit
|
||||
from dares d
|
||||
join dare_packs dp on dp.id = d.pack_id
|
||||
left join categories c on c.id = dp.category_id
|
||||
where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed'
|
||||
and dp.is_public = true and (dp.category_id is null or c.is_public = true)`,
|
||||
[input.dare_id],
|
||||
);
|
||||
const row = dare.rows[0];
|
||||
if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
if (row.is_explicit && context.hide_nsfw) {
|
||||
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
}
|
||||
if (row.is_premium && !premium) {
|
||||
throw new DaresError('premium_required', 'This dare requires premium', 402);
|
||||
}
|
||||
dareId = input.dare_id;
|
||||
text = row.text;
|
||||
intensity = row.intensity;
|
||||
} else {
|
||||
if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402);
|
||||
const trimmed = input.custom_dare_text?.trim();
|
||||
if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400);
|
||||
customText = trimmed;
|
||||
customIntensity = input.custom_dare_intensity ?? 1;
|
||||
text = trimmed;
|
||||
intensity = customIntensity;
|
||||
}
|
||||
|
||||
// The quota is a paywall boundary, so the count and the insert have to be atomic:
|
||||
// locking the sender's profile row serializes concurrent sends by the same user.
|
||||
return transaction(this.pool, async (client) => {
|
||||
if (!premium) {
|
||||
await client.query('select 1 from profiles where id = $1 for update', [userId]);
|
||||
const used = await this.sendsThisWeek(userId, client);
|
||||
if (used >= FREE_WEEKLY_SEND_LIMIT) {
|
||||
throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query<SentDareRecord>(
|
||||
`insert into sent_dares (
|
||||
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
|
||||
dare_text_snapshot, dare_intensity_snapshot,
|
||||
sender_id, recipient_id, sender_notes, expires_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
|
||||
)
|
||||
returning ${BARE_COLUMNS}`,
|
||||
[
|
||||
context.couple_id, dareId, customText, customIntensity, text, intensity,
|
||||
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
|
||||
],
|
||||
);
|
||||
return toSentDare(result.rows[0]!, userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a status change, enforcing both the actor's role and the legal
|
||||
* transitions out of the current status. Notifications are emitted by the
|
||||
* `operations_dare_changed` trigger, so no enqueue happens here.
|
||||
*/
|
||||
private async transition(
|
||||
userId: string,
|
||||
dareId: string,
|
||||
actor: 'sender' | 'recipient',
|
||||
from: readonly DareStatus[],
|
||||
to: DareStatus,
|
||||
timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null,
|
||||
): Promise<SentDare> {
|
||||
const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id';
|
||||
const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : '';
|
||||
const result = await this.pool.query<SentDareRecord>(
|
||||
`update sent_dares sd
|
||||
set status = $3::text${setTimestamp}
|
||||
where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[])
|
||||
returning ${SENT_COLUMNS}`,
|
||||
[dareId, userId, to, from],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (row) return toSentDare(row, userId);
|
||||
|
||||
// Distinguish "not yours / gone" from "wrong state" so the client can react.
|
||||
const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>(
|
||||
'select sender_id, recipient_id, status from sent_dares where id = $1',
|
||||
[dareId],
|
||||
);
|
||||
const found = existing.rows[0];
|
||||
if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) {
|
||||
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
}
|
||||
const actorId = actor === 'sender' ? found.sender_id : found.recipient_id;
|
||||
if (actorId !== userId) {
|
||||
throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403);
|
||||
}
|
||||
throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409);
|
||||
}
|
||||
|
||||
async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare> {
|
||||
return action === 'accept'
|
||||
? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at')
|
||||
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
|
||||
}
|
||||
|
||||
async submit(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
|
||||
}
|
||||
|
||||
async complete(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at');
|
||||
}
|
||||
|
||||
async cancel(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null);
|
||||
}
|
||||
|
||||
async stats(userId: string): Promise<DareStats> {
|
||||
const context = await this.context(userId);
|
||||
if (!context.couple_id) {
|
||||
return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 };
|
||||
}
|
||||
const result = await this.pool.query<Record<string, string>>(
|
||||
`select
|
||||
count(*) filter (where sender_id = $2)::text as sent,
|
||||
count(*) filter (where recipient_id = $2)::text as received,
|
||||
count(*) filter (where status = 'completed')::text as completed_together,
|
||||
count(*) filter (where status = any($3::text[]))::text as active,
|
||||
count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me,
|
||||
count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner
|
||||
from sent_dares where couple_id = $1`,
|
||||
[context.couple_id, userId, OPEN_STATUSES],
|
||||
);
|
||||
const row = result.rows[0] ?? {};
|
||||
const read = (key: string): number => Number(row[key] ?? 0);
|
||||
return {
|
||||
sent: read('sent'),
|
||||
received: read('received'),
|
||||
completed_together: read('completed_together'),
|
||||
active: read('active'),
|
||||
completed_by_me: read('completed_by_me'),
|
||||
completed_by_partner: read('completed_by_partner'),
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js';
|
||||
|
||||
type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
const id = z.string().uuid();
|
||||
|
||||
const sendBody = z
|
||||
.object({
|
||||
dare_id: id.nullish(),
|
||||
custom_dare_text: z.string().trim().min(1).max(500).nullish(),
|
||||
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
|
||||
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
|
||||
sender_notes: z.string().trim().max(500).nullish(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
|
||||
message: 'Provide either dare_id or custom_dare_text',
|
||||
});
|
||||
|
||||
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function result<T>(operation: () => Promise<T>) {
|
||||
try {
|
||||
return { ok: true as const, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof DaresError) return { ok: false as const, cause };
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void {
|
||||
app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS }));
|
||||
|
||||
app.get('/v1/dares/packs', async (c) => {
|
||||
const response = await result(() => repository.getCatalog(c.get('identity').id));
|
||||
return response.ok
|
||||
? c.json(response.value)
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/dares/packs/:packId/dares', async (c) => {
|
||||
const packId = id.safeParse(c.req.param('packId'));
|
||||
if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data));
|
||||
return response.ok
|
||||
? c.json({ dares: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/dares', async (c) => {
|
||||
const filter = c.req.query('filter') ?? 'active';
|
||||
if (filter !== 'active' && filter !== 'history') {
|
||||
return c.json(error('invalid_filter', 'Filter must be active or history'), 400);
|
||||
}
|
||||
const response = await result(() => repository.listDares(c.get('identity').id, filter));
|
||||
return response.ok
|
||||
? c.json({ dares: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/dares/stats', async (c) => {
|
||||
const response = await result(() => repository.stats(c.get('identity').id));
|
||||
return response.ok
|
||||
? c.json(response.value)
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/dares', async (c) => {
|
||||
const body = sendBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400);
|
||||
const response = await result(() => repository.send(c.get('identity').id, body.data));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value }, 201)
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/dares/:dareId/respond', async (c) => {
|
||||
const dareId = id.safeParse(c.req.param('dareId'));
|
||||
const body = respondBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||
if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400);
|
||||
const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
for (const [path, method] of [
|
||||
['submit', 'submit'],
|
||||
['complete', 'complete'],
|
||||
['cancel', 'cancel'],
|
||||
] as const) {
|
||||
app.post(`/v1/dares/:dareId/${path}`, async (c) => {
|
||||
const dareId = id.safeParse(c.req.param('dareId'));
|
||||
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||
const response = await result(() => repository[method](c.get('identity').id, dareId.data));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { profiles } from '../../db/schema.js';
|
||||
|
||||
// `couples`, `categories`, and the review-status enum are owned by other verticals.
|
||||
// Their FKs stay in SQL so this fragment does not import across domain boundaries.
|
||||
export const darePacks = pgTable('dare_packs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
icon: text('icon'),
|
||||
isPremium: boolean('is_premium').notNull().default(false),
|
||||
isPublic: boolean('is_public').notNull().default(true),
|
||||
isExplicit: boolean('is_explicit').notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
categoryId: uuid('category_id'),
|
||||
minIntensity: integer('min_intensity'),
|
||||
maxIntensity: integer('max_intensity'),
|
||||
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }),
|
||||
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||
contentReviewReason: text('content_review_reason'),
|
||||
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]);
|
||||
|
||||
export const dares = pgTable('dares', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }),
|
||||
text: text('text').notNull(),
|
||||
intensity: integer('intensity').notNull().default(1),
|
||||
suggestedDurationHours: integer('suggested_duration_hours'),
|
||||
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||
contentReviewReason: text('content_review_reason'),
|
||||
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index('dares_pack_idx').on(table.packId),
|
||||
index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity),
|
||||
]);
|
||||
|
||||
// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's
|
||||
// history must survive the dare being edited, archived, or deleted.
|
||||
export const sentDares = pgTable('sent_dares', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
coupleId: uuid('couple_id').notNull(),
|
||||
dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }),
|
||||
customDareText: text('custom_dare_text'),
|
||||
customDareIntensity: integer('custom_dare_intensity'),
|
||||
dareTextSnapshot: text('dare_text_snapshot').notNull(),
|
||||
dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(),
|
||||
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull().default('pending'),
|
||||
senderNotes: text('sender_notes'),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||
submittedAt: timestamp('submitted_at', { withTimezone: true }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index('sent_dares_couple_idx').on(table.coupleId),
|
||||
index('sent_dares_recipient_status_idx').on(table.recipientId, table.status),
|
||||
index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt),
|
||||
check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`),
|
||||
check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`),
|
||||
]);
|
||||
|
||||
export const dareMessages = pgTable('dare_messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }),
|
||||
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
content: text('content').notNull(),
|
||||
readAt: timestamp('read_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]);
|
||||
@@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,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
|
||||
|
||||
@@ -48,4 +48,6 @@ export interface ProducerSummary {
|
||||
weeklySummaries: number;
|
||||
unpairedReminders: number;
|
||||
catchupReminders: number;
|
||||
streakReminders: number;
|
||||
daresExpired: number;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);});
|
||||
});
|
||||
@@ -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);});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
|
||||
if (url && !local) throw new Error('Dare integration tests only permit localhost');
|
||||
|
||||
describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||
const admin = new Pool({ connectionString: url });
|
||||
const schema = `dares_${randomUUID().replaceAll('-', '')}`;
|
||||
let pool: Pool;
|
||||
let repo: PostgresDaresRepository;
|
||||
|
||||
const sender = '11111111-1111-4111-8111-111111111111';
|
||||
const recipient = '22222222-2222-4222-8222-222222222222';
|
||||
const outsider = '33333333-3333-4333-8333-333333333333';
|
||||
const couple = '44444444-4444-4444-8444-444444444444';
|
||||
const otherCouple = '55555555-5555-4555-8555-555555555555';
|
||||
const freePack = '66666666-6666-4666-8666-666666666666';
|
||||
const premiumPack = '77777777-7777-4777-8777-777777777777';
|
||||
const unreviewedPack = '88888888-8888-4888-8888-888888888888';
|
||||
const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
|
||||
beforeAll(async () => {
|
||||
await admin.query(`create schema "${schema}"`);
|
||||
const isolated = new URL(url!);
|
||||
isolated.searchParams.set('options', `-c search_path=${schema}`);
|
||||
pool = new Pool({ connectionString: isolated.toString() });
|
||||
repo = new PostgresDaresRepository(isolated.toString());
|
||||
|
||||
const dir = new URL('../drizzle/', import.meta.url);
|
||||
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
||||
for (const name of files) {
|
||||
const sql = await readFile(new URL(name, dir), 'utf8');
|
||||
for (const statement of sql.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await pool.query(statement);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']);
|
||||
await pool.query(
|
||||
'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)',
|
||||
[sender, recipient, outsider, couple, otherCouple],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into dare_packs(id,name,is_premium,content_status) values
|
||||
($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`,
|
||||
[freePack, premiumPack, unreviewedPack],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into dares(id,pack_id,text,intensity,content_status) values
|
||||
($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`,
|
||||
[freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack],
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pool.query('delete from sent_dares');
|
||||
await pool.query('delete from operations_outbox');
|
||||
await pool.query('update profiles set is_premium=false');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await repo.close();
|
||||
await pool.end();
|
||||
await admin.query(`drop schema "${schema}" cascade`);
|
||||
await admin.end();
|
||||
});
|
||||
|
||||
it('hides unreviewed packs and dares from the catalogue', async () => {
|
||||
const catalog = await repo.getCatalog(sender);
|
||||
const ids = catalog.packs.map((pack) => pack.id);
|
||||
expect(ids).toContain(freePack);
|
||||
expect(ids).toContain(premiumPack);
|
||||
expect(ids).not.toContain(unreviewedPack);
|
||||
await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' });
|
||||
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||
});
|
||||
|
||||
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
||||
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||
await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' });
|
||||
|
||||
// Premium is couple-shared: the partner paying unlocks the sender.
|
||||
await pool.query('update profiles set is_premium=true where id=$1', [recipient]);
|
||||
expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null });
|
||||
await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1);
|
||||
const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 });
|
||||
expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 });
|
||||
});
|
||||
|
||||
it('enforces the free weekly send allowance without blocking the response side', async () => {
|
||||
for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) {
|
||||
await repo.send(sender, { dare_id: freeDare });
|
||||
}
|
||||
expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0);
|
||||
await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' });
|
||||
|
||||
// The quota is per-sender, so the recipient can still answer back.
|
||||
const reply = await repo.send(recipient, { dare_id: freeDare });
|
||||
expect(reply.direction).toBe('outgoing');
|
||||
});
|
||||
|
||||
it('serializes concurrent sends so the free allowance cannot be raced', async () => {
|
||||
const attempts = await Promise.allSettled(
|
||||
Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })),
|
||||
);
|
||||
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT);
|
||||
const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]);
|
||||
expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT));
|
||||
});
|
||||
|
||||
it('walks the full loop and records each transition exactly once', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' });
|
||||
expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' });
|
||||
expect(dare.expires_at).not.toBeNull();
|
||||
|
||||
expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active');
|
||||
expect((await repo.submit(recipient, dare.id)).status).toBe('submitted');
|
||||
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||
|
||||
const events = await pool.query<{ dedupe_key: string; recipient_id: string }>(
|
||||
"select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at",
|
||||
);
|
||||
expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']);
|
||||
expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]);
|
||||
});
|
||||
|
||||
it('never puts dare text in a notification payload', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
const leaked = await pool.query<{ count: string }>(
|
||||
"select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'",
|
||||
);
|
||||
expect(leaked.rows[0]!.count).toBe('0');
|
||||
});
|
||||
|
||||
it('respects the dares notification preference', async () => {
|
||||
await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]);
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]);
|
||||
expect(events.rowCount).toBe(0);
|
||||
// The sender has not opted out, so their side still fires.
|
||||
await repo.respond(recipient, dare.id, 'decline');
|
||||
const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]);
|
||||
expect(senderEvents.rowCount).toBe(1);
|
||||
await pool.query('delete from notification_preferences where user_id=$1', [recipient]);
|
||||
});
|
||||
|
||||
it('enforces actor and state on every transition', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' });
|
||||
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||
await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' });
|
||||
expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled');
|
||||
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||
});
|
||||
|
||||
it('keeps history intact when the source dare is deleted', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await pool.query('delete from dares where id=$1', [freeDare]);
|
||||
const history = await repo.listDares(sender, 'active');
|
||||
expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 });
|
||||
await pool.query(
|
||||
"insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')",
|
||||
[freeDare, freePack],
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes listing and stats to the couple', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
await repo.submit(recipient, dare.id);
|
||||
await repo.complete(sender, dare.id);
|
||||
|
||||
expect(await repo.listDares(outsider, 'active')).toEqual([]);
|
||||
expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 });
|
||||
expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 });
|
||||
expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 });
|
||||
|
||||
const active = await repo.listDares(sender, 'active');
|
||||
const history = await repo.listDares(sender, 'history');
|
||||
expect(active).toEqual([]);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]!.direction).toBe('outgoing');
|
||||
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||
});
|
||||
|
||||
it('rejects durations outside the offered presets', async () => {
|
||||
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||
});
|
||||
|
||||
it('refuses to send without a partner', async () => {
|
||||
await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthIdentity } from '../src/auth.js';
|
||||
import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js';
|
||||
import { registerDareRoutes } from '../src/domains/dares/routes.js';
|
||||
|
||||
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||
const partner = '22222222-2222-4222-8222-222222222222';
|
||||
const dareId = '33333333-3333-4333-8333-333333333333';
|
||||
const packId = '44444444-4444-4444-8444-444444444444';
|
||||
const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
const dare: SentDare = {
|
||||
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||
direction: 'outgoing', status: 'pending', sender_notes: null,
|
||||
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||
};
|
||||
|
||||
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||
const repository: DaresRepository = {
|
||||
getCatalog: vi.fn(async () => ({
|
||||
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 },
|
||||
packs: [],
|
||||
})),
|
||||
listPackDares: vi.fn(async () => []),
|
||||
listDares: vi.fn(async () => [dare]),
|
||||
send: vi.fn(async () => dare),
|
||||
respond: vi.fn(async () => ({ ...dare, status: 'active' as const })),
|
||||
submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })),
|
||||
complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })),
|
||||
cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })),
|
||||
stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })),
|
||||
close: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||
registerDareRoutes(app, repository);
|
||||
return { app, repository };
|
||||
}
|
||||
|
||||
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('dare routes', () => {
|
||||
it('derives the actor from the bearer identity, never the body', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await post(app, '/v1/dares', {
|
||||
dare_id: catalogueDareId,
|
||||
sender_notes: ' be brave ',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId }));
|
||||
});
|
||||
|
||||
it('rejects client-supplied ownership fields', async () => {
|
||||
const { app } = setup();
|
||||
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('requires exactly one of dare_id or custom text', async () => {
|
||||
const { app } = setup();
|
||||
expect((await post(app, '/v1/dares', {})).status).toBe(400);
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400);
|
||||
expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201);
|
||||
});
|
||||
|
||||
it('accepts only preset durations', async () => {
|
||||
const { app } = setup();
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201);
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('validates identifiers and filters', async () => {
|
||||
const { app } = setup();
|
||||
expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400);
|
||||
expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400);
|
||||
expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400);
|
||||
expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('routes each transition to its repository method', async () => {
|
||||
const { app, repository } = setup();
|
||||
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||
await post(app, `/v1/dares/${dareId}/submit`);
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
||||
await post(app, `/v1/dares/${dareId}/complete`);
|
||||
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||
expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId);
|
||||
});
|
||||
|
||||
it('surfaces domain errors with their status codes', async () => {
|
||||
const { app } = setup({
|
||||
send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }),
|
||||
submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }),
|
||||
});
|
||||
const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' });
|
||||
expect(paywalled.status).toBe(402);
|
||||
expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } });
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it('exposes the duration presets the send sheet offers', async () => {
|
||||
const { app } = setup();
|
||||
const response = await app.request('/v1/dares/durations');
|
||||
expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] });
|
||||
});
|
||||
});
|
||||
@@ -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});});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,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
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DaresScreen, default } from "./DaresScreen";
|
||||
export { useDares } from "./hooks/useDares";
|
||||
export type { UseDaresReturn } from "./hooks/useDares";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,83 @@
|
||||
export type DareStatus =
|
||||
| "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled";
|
||||
|
||||
export interface DarePack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
dare_count: number;
|
||||
}
|
||||
|
||||
export interface DareItem {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
text: string;
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
}
|
||||
|
||||
export interface SentDare {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
dare_id: string | null;
|
||||
text: string;
|
||||
intensity: number;
|
||||
is_custom: boolean;
|
||||
sender_id: string;
|
||||
recipient_id: string;
|
||||
direction: "incoming" | "outgoing";
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
sent_at: string;
|
||||
accepted_at: string | null;
|
||||
submitted_at: string | null;
|
||||
completed_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface DareEntitlement {
|
||||
is_premium: boolean;
|
||||
can_send_custom: boolean;
|
||||
weekly_send_limit: number | null;
|
||||
sends_remaining: number | null;
|
||||
}
|
||||
|
||||
export interface DareCatalog {
|
||||
entitlement: DareEntitlement;
|
||||
packs: DarePack[];
|
||||
}
|
||||
|
||||
export interface DareStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
completed_together: number;
|
||||
active: number;
|
||||
completed_by_me: number;
|
||||
completed_by_partner: number;
|
||||
}
|
||||
|
||||
export interface SendDarePayload {
|
||||
dare_id?: string;
|
||||
custom_dare_text?: string;
|
||||
custom_dare_intensity?: number;
|
||||
duration_hours?: number | null;
|
||||
sender_notes?: string | null;
|
||||
}
|
||||
|
||||
export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [
|
||||
{ label: "1 hour", hours: 1 },
|
||||
{ label: "6 hours", hours: 6 },
|
||||
{ label: "12 hours", hours: 12 },
|
||||
{ label: "24 hours", hours: 24 },
|
||||
{ label: "3 days", hours: 72 },
|
||||
{ label: "1 week", hours: 168 },
|
||||
{ label: "No time limit", hours: null },
|
||||
];
|
||||
@@ -1,3 +1,4 @@
|
||||
// Features exports
|
||||
export * from './chat';
|
||||
export * from './dares';
|
||||
export * from './profile';
|
||||
@@ -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,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,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);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiClient } from "./apiClient";
|
||||
import type { DareCatalog, DareItem, DareStats, SendDarePayload, SentDare } from "../features/dares/types";
|
||||
|
||||
const dare = (dareId: string) => `/v1/dares/${encodeURIComponent(dareId)}`;
|
||||
|
||||
export const daresApi = {
|
||||
catalog: () => apiClient.get<DareCatalog>("/v1/dares/packs"),
|
||||
packDares: (packId: string) =>
|
||||
apiClient.get<{ dares: DareItem[] }>(`/v1/dares/packs/${encodeURIComponent(packId)}/dares`),
|
||||
list: (filter: "active" | "history") =>
|
||||
apiClient.get<{ dares: SentDare[] }>(`/v1/dares?filter=${filter}`),
|
||||
stats: () => apiClient.get<DareStats>("/v1/dares/stats"),
|
||||
send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload),
|
||||
respond: (dareId: string, action: "accept" | "decline") =>
|
||||
apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }),
|
||||
submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`),
|
||||
complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`),
|
||||
cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`),
|
||||
};
|
||||
@@ -1,5 +1,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,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';
|
||||
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Dares Feature - Architecture & Implementation Plan
|
||||
|
||||
> **SUPERSEDED.** This is the original 2026-01 plan, kept for its competitor analysis
|
||||
> and decision history. It describes a Supabase Edge Function + RLS architecture that
|
||||
> the product no longer uses: mobile talks only to the standalone API. For the shipped
|
||||
> contract see [Dares loop](dares-loop.md).
|
||||
|
||||
> **Status**: Phase 1 Complete (Database + Admin Portal)
|
||||
> **Created**: 2026-01-08
|
||||
> **Updated**: 2026-01-08
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Dares loop
|
||||
|
||||
Maintained reference for the dares feature: a couple sends each other challenges,
|
||||
each state change notifies the other partner, and the pair keeps a shared count of
|
||||
what they have finished together.
|
||||
|
||||
The asynchronous shape is the point. Every transition below is a push notification
|
||||
to whichever partner is not holding the app.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Location |
|
||||
|---|---|
|
||||
| Schema, triggers, review parity | `apps/api/drizzle/0013_dares_loop.sql` |
|
||||
| HTTP contract | `apps/api/src/domains/dares/routes.ts` |
|
||||
| Domain rules and entitlement | `apps/api/src/domains/dares/repository.ts` |
|
||||
| Expiry sweep | `apps/api/src/domains/operations/repository.ts` (`produce`) |
|
||||
| Mobile feature | `apps/mobile/src/features/dares/` |
|
||||
| Catalogue authoring | `apps/admin/src/pages/content/DarePacksPage.tsx`, `DaresPage.tsx` |
|
||||
|
||||
## State machine
|
||||
|
||||
```
|
||||
┌── decline ──► declined
|
||||
pending ──accept──┤
|
||||
│ └── accept ───► active ──submit──► submitted ──confirm──► completed
|
||||
│ │ │
|
||||
└──────────────── cancel ─────────┴───────────────────┘──► cancelled
|
||||
(sender only, before completion)
|
||||
|
||||
any open status + expires_at in the past ──► expired (worker sweep)
|
||||
```
|
||||
|
||||
Actor rules are enforced server-side, not by hiding buttons:
|
||||
|
||||
| Transition | Who | Legal from |
|
||||
|---|---|---|
|
||||
| accept / decline | recipient | `pending` |
|
||||
| submit | recipient | `active` |
|
||||
| complete | sender | `active`, `submitted` |
|
||||
| cancel | sender | `pending`, `active`, `submitted` |
|
||||
|
||||
The recipient performs the dare, so the recipient reports it done (`submitted`) and
|
||||
the sender confirms (`completed`). An illegal transition returns `409
|
||||
invalid_transition`; acting on someone else's dare returns `403 not_permitted`;
|
||||
a dare outside your couple returns `404`, never a hint that it exists.
|
||||
|
||||
## Entitlement
|
||||
|
||||
Premium is couple-shared — either partner's subscription unlocks both, matching the
|
||||
answers domain.
|
||||
|
||||
| Tier | Can do |
|
||||
|---|---|
|
||||
| Anyone, always | Receive, accept, decline, submit, complete, cancel |
|
||||
| Free sender | Free packs, `FREE_WEEKLY_SEND_LIMIT` sends per rolling 7 days |
|
||||
| Premium | All packs, unlimited sends, custom dares |
|
||||
|
||||
**Receiving is never gated.** A paywall between a partner and the dare they were just
|
||||
notified about would break the loop the feature exists to create. The quota is counted
|
||||
per sender over a rolling window, and counts every send — cancelling does not refund it.
|
||||
|
||||
## Content safety
|
||||
|
||||
`dare_packs` and `dares` carry `content_status` (`unreviewed` / `allowed` / `archived`),
|
||||
matching the legacy catalogue contract. The catalogue, the pack detail endpoint, and the
|
||||
send path each independently require `allowed`, so a stale client cannot send unreviewed
|
||||
content. New content defaults to `unreviewed` and is therefore invisible until an
|
||||
administrator reviews it.
|
||||
|
||||
Push copy is deliberately generic — `"Your partner sent you a dare"`, never the dare
|
||||
text. Store compliance forbids question, dare, response, or message text in a
|
||||
notification preview, and `dares.integration.test.ts` asserts no payload leaks it.
|
||||
|
||||
## History durability
|
||||
|
||||
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
|
||||
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
|
||||
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
|
||||
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
|
||||
with a check violation.
|
||||
|
||||
## Notifications
|
||||
|
||||
`queue_dare_operations` (trigger on `sent_dares`) writes one `operations_outbox` row per
|
||||
transition with dedupe key `dare:<id>:<event>:<recipient>`, gated on
|
||||
`notification_preferences.dares_enabled`. Delivery, retry, and Expo ticket handling are
|
||||
the existing worker's job — see `apps/api/src/domains/operations/README.md`.
|
||||
|
||||
`expired` notifies both partners; every other transition notifies the one partner who
|
||||
did not cause it.
|
||||
|
||||
## Expiry
|
||||
|
||||
The worker's `produce` pass expires any dare whose `expires_at` has passed while still
|
||||
`pending`, `active`, or `submitted`, reported as `daresExpired` in `ProducerSummary`.
|
||||
Pending is included deliberately: an invitation the recipient never opened would
|
||||
otherwise stay open forever.
|
||||
|
||||
Durations offered to the client are fixed presets (1, 6, 12, 24, 72, 168 hours, or none)
|
||||
and validated server-side.
|
||||
|
||||
## HTTP contract
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/v1/dares/packs` | Catalogue plus the caller's entitlement |
|
||||
| GET | `/v1/dares/packs/:packId/dares` | `402` when the pack is premium and the couple is not |
|
||||
| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller |
|
||||
| GET | `/v1/dares/stats` | Shared scoreboard |
|
||||
| GET | `/v1/dares/durations` | Preset list the send sheet renders |
|
||||
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both |
|
||||
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
|
||||
| POST | `/v1/dares/:dareId/submit` | Recipient reports done |
|
||||
| POST | `/v1/dares/:dareId/complete` | Sender confirms |
|
||||
| POST | `/v1/dares/:dareId/cancel` | Sender withdraws |
|
||||
|
||||
The actor always comes from the bearer identity; ownership fields in a request body are
|
||||
rejected.
|
||||
|
||||
## Mobile
|
||||
|
||||
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
|
||||
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
|
||||
channel), and opens the paywall both proactively and in reaction to a `402` — a client
|
||||
whose entitlement went stale still lands on the paywall rather than an error.
|
||||
|
||||
## Tests
|
||||
|
||||
- `apps/api/test/dares.integration.test.ts` — real PostgreSQL: entitlement, quota,
|
||||
transitions, couple isolation, notification fan-out, no-text-in-push, snapshot durability
|
||||
- `apps/api/test/dares.routes.test.ts` — HTTP contract and error mapping
|
||||
- `apps/mobile/src/__tests__/hooks/useDares.test.ts` — loading, actions, paywall routing
|
||||
|
||||
## Not in this version
|
||||
|
||||
Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox
|
||||
split from the main match chat was cut in favour of status transitions plus the sender's
|
||||
note. Photo proof, random-dare, and streaks remain unbuilt.
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user