mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18: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
+8831
-6159
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
name: Verify
|
name: Verify
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [staging, main]
|
branches: [staging, main]
|
||||||
@@ -11,10 +12,10 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
branch-policy:
|
branch-policy:
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Enforce promotion flow
|
- name: Enforce promotion flow
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
env:
|
env:
|
||||||
BASE_BRANCH: ${{ github.base_ref }}
|
BASE_BRANCH: ${{ github.base_ref }}
|
||||||
HEAD_BRANCH: ${{ github.head_ref }}
|
HEAD_BRANCH: ${{ github.head_ref }}
|
||||||
@@ -31,6 +32,9 @@ jobs:
|
|||||||
echo "Feature pull requests target staging; promotions target main" >&2
|
echo "Feature pull requests target staging; promotions target main" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
- name: Confirm direct verification
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: echo "Direct branch verification requested for $GITHUB_SHA"
|
||||||
|
|
||||||
static:
|
static:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "~19.1.7",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
|
|||||||
Daily Response Limit (Free Users)
|
Daily Response Limit (Free Users)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
|
|||||||
className="max-w-[200px]"
|
className="max-w-[200px]"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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.
|
Premium users (via personal or partner subscription) bypass this limit.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Set to <strong>0</strong> to disable daily limits entirely.
|
Set to <strong>0</strong> to disable daily limits entirely.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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,
|
"when": 1787908500000,
|
||||||
"tag": "0012_catalogue_field_parity",
|
"tag": "0012_catalogue_field_parity",
|
||||||
"breakpoints": true
|
"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 { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||||
import type { ChatRepository } from './domains/chat/repository.js';
|
import type { ChatRepository } from './domains/chat/repository.js';
|
||||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||||
|
import type { DaresRepository } from './domains/dares/repository.js';
|
||||||
|
import { registerDareRoutes } from './domains/dares/routes.js';
|
||||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
import type { CoupleService } from './domains/couples/service.js';
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
import type { PacksRepository } from './domains/packs/repository.js';
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -35,6 +37,7 @@ export interface AppDependencies {
|
|||||||
packsRepository?: PacksRepository;
|
packsRepository?: PacksRepository;
|
||||||
answersRepository?: AnswersRepository;
|
answersRepository?: AnswersRepository;
|
||||||
chatRepository?: ChatRepository;
|
chatRepository?: ChatRepository;
|
||||||
|
daresRepository?: DaresRepository;
|
||||||
profileSettingsRepository?: ProfileSettingsRepository;
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
accountOperationsService?: AccountOperationsService;
|
accountOperationsService?: AccountOperationsService;
|
||||||
mediaRepository?: MediaRepository;
|
mediaRepository?: MediaRepository;
|
||||||
@@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
|||||||
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||||
|
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', {
|
|||||||
email: text('email'),
|
email: text('email'),
|
||||||
avatarUrl: text('avatar_url'),
|
avatarUrl: text('avatar_url'),
|
||||||
pushToken: text('push_token'),
|
pushToken: text('push_token'),
|
||||||
|
timezone: text('timezone'),
|
||||||
isPremium: boolean('is_premium').notNull().default(false),
|
isPremium: boolean('is_premium').notNull().default(false),
|
||||||
coupleId: uuid('couple_id'),
|
coupleId: uuid('couple_id'),
|
||||||
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
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 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 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 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 SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
||||||
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
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 }>;
|
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
||||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||||
archive(userId: string, matchId: string, archived: boolean): 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>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PostgresAnswersRepository implements AnswersRepository {
|
export class PostgresAnswersRepository implements AnswersRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private zones: Promise<Set<string>> | null = null;
|
||||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||||
|
|
||||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
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 p = partner.rows[0];
|
||||||
const config = await this.config();
|
const config = await this.config();
|
||||||
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
|
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 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 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>(`
|
const result = await this.pool.query<QuestionRow>(`
|
||||||
@@ -117,7 +145,10 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
await this.assertEligible(client,userId,ctx,q);
|
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]);
|
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) {
|
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)
|
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]);
|
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];
|
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){
|
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;
|
const user1=users.rows[0].id===userId;
|
||||||
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
|
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 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;
|
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;
|
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;
|
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 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 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 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 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){
|
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 cfg=await this.config(client);const unlimited={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};
|
if(cfg.daily_response_limit===0)return unlimited;
|
||||||
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};
|
// 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){
|
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];
|
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> {
|
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||||
return transaction(this.pool, async (client) => {
|
return transaction(this.pool, async (client) => {
|
||||||
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
||||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
||||||
if (!lock.rows[0]?.locked) return empty;
|
if (!lock.rows[0]?.locked) return empty;
|
||||||
|
|
||||||
const released=await client.query<{id:string;name:string}>(
|
const released=await client.query<{id:string;name:string}>(
|
||||||
@@ -138,12 +138,77 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
||||||
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||||
|
|
||||||
|
// Expiring pending dares too: the original design only swept `active`, so an
|
||||||
|
// invitation the recipient never opened stayed open forever.
|
||||||
|
const expiredDares=await client.query(
|
||||||
|
`with due as (
|
||||||
|
select id from sent_dares
|
||||||
|
where expires_at is not null and expires_at<=$1
|
||||||
|
and status in ('pending','active','submitted')
|
||||||
|
order by expires_at for update skip locked limit $2
|
||||||
|
) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]);
|
||||||
|
|
||||||
const catchup=await this.produceCatchups(client,now,limit);
|
const catchup=await this.produceCatchups(client,now,limit);
|
||||||
|
const streakReminders=await this.produceStreakReminders(client,now);
|
||||||
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
||||||
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,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> {
|
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
|
||||||
await client.query(
|
await client.query(
|
||||||
`with behind as (
|
`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 '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 '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_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 '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 '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
|
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;
|
weeklySummaries: number;
|
||||||
unpairedReminders: number;
|
unpairedReminders: number;
|
||||||
catchupReminders: number;
|
catchupReminders: number;
|
||||||
|
streakReminders: number;
|
||||||
|
daresExpired: number;
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,10 @@ export const categories = pgTable('categories', {
|
|||||||
color: text('color'),
|
color: text('color'),
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
isPublic: boolean('is_public').notNull().default(true),
|
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(),
|
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' }),
|
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
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(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
||||||
|
|
||||||
@@ -42,6 +50,10 @@ export const questions = pgTable('questions', {
|
|||||||
config: jsonb('config').notNull().default({}),
|
config: jsonb('config').notNull().default({}),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
inverseOf: uuid('inverse_of'),
|
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(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
|
}, (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),
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
|
}, (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;
|
pack_changes_enabled: boolean;
|
||||||
new_packs_enabled: boolean;
|
new_packs_enabled: boolean;
|
||||||
streak_milestones_enabled: boolean;
|
streak_milestones_enabled: boolean;
|
||||||
|
streak_reminders_enabled: boolean;
|
||||||
weekly_summary_enabled: boolean;
|
weekly_summary_enabled: boolean;
|
||||||
unpaired_reminders_enabled: boolean;
|
unpaired_reminders_enabled: boolean;
|
||||||
catchup_reminders_enabled: boolean;
|
catchup_reminders_enabled: boolean;
|
||||||
|
dares_enabled: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@ const profileColumns: Record<keyof ProfileUpdate, string> = {
|
|||||||
onboarding_version: 'onboarding_version',
|
onboarding_version: 'onboarding_version',
|
||||||
public_key_jwk: 'public_key_jwk',
|
public_key_jwk: 'public_key_jwk',
|
||||||
push_token: 'push_token',
|
push_token: 'push_token',
|
||||||
|
timezone: 'timezone',
|
||||||
};
|
};
|
||||||
|
|
||||||
function toPreferences(row: PreferencesRow): NotificationPreferences {
|
function toPreferences(row: PreferencesRow): NotificationPreferences {
|
||||||
@@ -61,9 +64,11 @@ function toPreferences(row: PreferencesRow): NotificationPreferences {
|
|||||||
pack_changes_enabled: row.pack_changes_enabled,
|
pack_changes_enabled: row.pack_changes_enabled,
|
||||||
new_packs_enabled: row.new_packs_enabled,
|
new_packs_enabled: row.new_packs_enabled,
|
||||||
streak_milestones_enabled: row.streak_milestones_enabled,
|
streak_milestones_enabled: row.streak_milestones_enabled,
|
||||||
|
streak_reminders_enabled: row.streak_reminders_enabled,
|
||||||
weekly_summary_enabled: row.weekly_summary_enabled,
|
weekly_summary_enabled: row.weekly_summary_enabled,
|
||||||
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
||||||
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
||||||
|
dares_enabled: row.dares_enabled,
|
||||||
created_at: row.created_at.toISOString(),
|
created_at: row.created_at.toISOString(),
|
||||||
updated_at: row.updated_at.toISOString(),
|
updated_at: row.updated_at.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const profileUpdateSchema = z.object({
|
|||||||
onboarding_version: z.number().int().min(0).optional(),
|
onboarding_version: z.number().int().min(0).optional(),
|
||||||
public_key_jwk: z.record(z.unknown()).nullable().optional(),
|
public_key_jwk: z.record(z.unknown()).nullable().optional(),
|
||||||
push_token: z.string().trim().min(1).max(512).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');
|
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
|
||||||
|
|
||||||
const preferenceSchema = z.object({
|
const preferenceSchema = z.object({
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ export const notificationPreferenceKeys = [
|
|||||||
'pack_changes_enabled',
|
'pack_changes_enabled',
|
||||||
'new_packs_enabled',
|
'new_packs_enabled',
|
||||||
'streak_milestones_enabled',
|
'streak_milestones_enabled',
|
||||||
|
'streak_reminders_enabled',
|
||||||
'weekly_summary_enabled',
|
'weekly_summary_enabled',
|
||||||
'unpaired_reminders_enabled',
|
'unpaired_reminders_enabled',
|
||||||
'catchup_reminders_enabled',
|
'catchup_reminders_enabled',
|
||||||
|
'dares_enabled',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
||||||
@@ -30,6 +32,7 @@ export interface ProfileUpdate {
|
|||||||
onboarding_version?: number;
|
onboarding_version?: number;
|
||||||
public_key_jwk?: Record<string, unknown> | null;
|
public_key_jwk?: Record<string, unknown> | null;
|
||||||
push_token?: string | null;
|
push_token?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackSubmission {
|
export interface FeedbackSubmission {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from './domains/account-operations/index.js';
|
} from './domains/account-operations/index.js';
|
||||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||||
|
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||||
import { CoupleService } from './domains/couples/service.js';
|
import { CoupleService } from './domains/couples/service.js';
|
||||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -30,6 +31,7 @@ const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
|
|||||||
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
||||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
||||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
||||||
|
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
|
||||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
||||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
||||||
const accountOperationsService = new AccountOperationsService(
|
const accountOperationsService = new AccountOperationsService(
|
||||||
@@ -59,6 +61,7 @@ const app = createApp({
|
|||||||
packsRepository,
|
packsRepository,
|
||||||
answersRepository,
|
answersRepository,
|
||||||
chatRepository,
|
chatRepository,
|
||||||
|
daresRepository,
|
||||||
profileSettingsRepository,
|
profileSettingsRepository,
|
||||||
accountOperationsService,
|
accountOperationsService,
|
||||||
mediaRepository,
|
mediaRepository,
|
||||||
@@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
packsRepository.close(),
|
packsRepository.close(),
|
||||||
answersRepository.close(),
|
answersRepository.close(),
|
||||||
chatRepository.close(),
|
chatRepository.close(),
|
||||||
|
daresRepository.close(),
|
||||||
profileSettingsRepository.close(),
|
profileSettingsRepository.close(),
|
||||||
accountOperationsRepository.close(),
|
accountOperationsRepository.close(),
|
||||||
mediaRepository.close(),
|
mediaRepository.close(),
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import type { Pool, PoolClient } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
import type { DatabaseMigrationOptions, TableResult } from './types.js';
|
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[] = [
|
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: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
|
||||||
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
|
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
|
||||||
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_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: 'questions', key: ['id'], deferredColumns: ['inverse_of'] },
|
||||||
{ name: 'ai_config', key: ['id'], replace: true },
|
{ 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: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
|
||||||
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
|
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
|
||||||
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', 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: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
|
||||||
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
|
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
|
||||||
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', 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: 'feature_interests', key: ['user_id', 'feature'], columnMap: { feature: 'feature_name' } },
|
||||||
{ name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] },
|
{
|
||||||
|
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('"', '""')}"`;
|
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]);
|
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);
|
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> {
|
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);
|
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> {
|
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[], jsonNames: Set<string>, finalSync: boolean): Promise<number> {
|
||||||
if (spec.replace) await client.query(`delete from ${quote(spec.name)}`);
|
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;
|
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 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) {
|
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;
|
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[]> {
|
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
|
||||||
const results: TableResult[] = [];
|
const results: TableResult[] = [];
|
||||||
for (const spec of TABLES) {
|
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) {
|
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' });
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const common = targetColumns.filter((name) => sourceColumns.includes(name));
|
const sourceName = (targetName: string) => spec.columnMap?.[targetName] ?? targetName;
|
||||||
const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name));
|
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`);
|
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;
|
const selections = common.map((targetName) => {
|
||||||
let importedRows = 0; let prunedRows = 0;
|
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);
|
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
|
||||||
if (!options.dryRun && !skip) {
|
if (!options.dryRun && !skip) {
|
||||||
const client = await target.connect();
|
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,
|
// Historical imports must not enqueue user notifications, classification,
|
||||||
// Discord events, or other operational side effects.
|
// Discord events, or other operational side effects.
|
||||||
await client.query("set local sauci.suppress_operations = 'on'");
|
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);
|
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
|
} 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);
|
await options.saveCheckpoint?.(options.checkpoint);
|
||||||
}
|
}
|
||||||
const targetRows = await count(target, spec.name);
|
const targetRows = await count(target, spec.name);
|
||||||
const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean);
|
const targetKeys = targetColumns.length
|
||||||
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
|
? (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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ interface StorageObject {
|
|||||||
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
|
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
|
||||||
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': '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('"', '""')}"`;
|
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
||||||
|
|
||||||
function safeStorageKey(bucket: string, name: string): string {
|
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 legacyPredicate = (column: string) => `(
|
||||||
const first = object.name.split('/')[0];
|
${column}=$1 or ${column}=$2 or
|
||||||
const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null);
|
(right(${column},length($1))=$1 and position('/'||$3||'/' in ${column})>0)
|
||||||
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}`);
|
async function sourceReference(source: Pool, object: StorageObject) {
|
||||||
let matchId: string | null = null; let questionId: string | null = null;
|
const values = [object.name, `${object.bucket_id}/${object.name}`, object.bucket_id];
|
||||||
if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
|
if (object.bucket_id === 'avatars') {
|
||||||
if (object.bucket_id === 'response-media') {
|
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
|
||||||
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
|
`select id owner_id,null::uuid match_id,null::uuid question_id from profiles where ${legacyPredicate('avatar_url')} limit 1`, values,
|
||||||
if (uuidPattern.test(candidate)) questionId = candidate;
|
)).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 reference = `media:${object.id}`;
|
||||||
const legacyName = object.name;
|
const legacyName = object.name;
|
||||||
const legacyKey = `${object.bucket_id}/${legacyName}`;
|
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') {
|
} else if (object.bucket_id === 'chat-media') {
|
||||||
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
|
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
|
||||||
} else if (object.bucket_id === 'feedback-screenshots') {
|
} 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') {
|
} else if (object.bucket_id === 'response-media') {
|
||||||
await target.query(`
|
await target.query(`
|
||||||
update responses
|
update responses
|
||||||
@@ -92,7 +133,7 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
const schema = options.storageSchema ?? 'storage';
|
const schema = options.storageSchema ?? 'storage';
|
||||||
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
|
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 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>();
|
const sourceKeys = new Set<string>();
|
||||||
for (const object of objects.rows) {
|
for (const object of objects.rows) {
|
||||||
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
|
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];
|
const marker = options.checkpoint.storage[key];
|
||||||
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
|
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
|
||||||
if (options.dryRun) { result.skippedFiles += 1; continue; }
|
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 {
|
try {
|
||||||
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
|
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
|
||||||
await rewriteLegacyReference(target, object);
|
await rewriteLegacyReference(source, target, object);
|
||||||
result.skippedFiles += 1;
|
result.skippedFiles += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -112,16 +154,25 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
}
|
}
|
||||||
if (options.dryRun) continue;
|
if (options.dryRun) continue;
|
||||||
try {
|
try {
|
||||||
|
const relation = await mediaRelation(source, target, object);
|
||||||
const bytes = await options.download(object.bucket_id, object.name);
|
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}`);
|
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 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 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';
|
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
|
||||||
|
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)
|
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`,
|
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]);
|
[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);
|
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 };
|
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
|
||||||
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
|
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,16 +182,21 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
}
|
}
|
||||||
if (!options.dryRun) {
|
if (!options.dryRun) {
|
||||||
if (options.prune) {
|
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)) {
|
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
|
||||||
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
|
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;
|
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
|
||||||
}
|
}
|
||||||
await options.saveCheckpoint?.(options.checkpoint);
|
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)]);
|
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[])
|
||||||
result.targetFiles = registered.rows.length;
|
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) {
|
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); }
|
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); }
|
catch { result.missingOnDisk.push(row.storage_key); }
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export interface TableResult {
|
|||||||
export interface StorageResult {
|
export interface StorageResult {
|
||||||
sourceFiles: number;
|
sourceFiles: number;
|
||||||
targetFiles: number;
|
targetFiles: number;
|
||||||
|
preservedTargetFiles: number;
|
||||||
copiedFiles: number;
|
copiedFiles: number;
|
||||||
|
quarantinedFiles: number;
|
||||||
skippedFiles: number;
|
skippedFiles: number;
|
||||||
prunedFiles: number;
|
prunedFiles: number;
|
||||||
failedFiles: number;
|
failedFiles: number;
|
||||||
|
|||||||
@@ -5,13 +5,43 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
|||||||
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
|
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
|
||||||
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
||||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999';
|
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']){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();});
|
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||||
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('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('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('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('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('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',()=>{
|
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('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);});
|
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}`);
|
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
||||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||||
repository = new PostgresRepository(isolatedUrl.toString());
|
repository = new PostgresRepository(isolatedUrl.toString());
|
||||||
const migration = await 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')) {
|
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||||
if (statement.trim()) await pool.query(statement);
|
if (statement.trim()) await pool.query(statement);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
|
||||||
|
if (url && !local) throw new Error('Dare integration tests only permit localhost');
|
||||||
|
|
||||||
|
describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||||
|
const admin = new Pool({ connectionString: url });
|
||||||
|
const schema = `dares_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
let pool: Pool;
|
||||||
|
let repo: PostgresDaresRepository;
|
||||||
|
|
||||||
|
const sender = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const recipient = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const outsider = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const couple = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const otherCouple = '55555555-5555-4555-8555-555555555555';
|
||||||
|
const freePack = '66666666-6666-4666-8666-666666666666';
|
||||||
|
const premiumPack = '77777777-7777-4777-8777-777777777777';
|
||||||
|
const unreviewedPack = '88888888-8888-4888-8888-888888888888';
|
||||||
|
const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||||
|
const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
|
const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await admin.query(`create schema "${schema}"`);
|
||||||
|
const isolated = new URL(url!);
|
||||||
|
isolated.searchParams.set('options', `-c search_path=${schema}`);
|
||||||
|
pool = new Pool({ connectionString: isolated.toString() });
|
||||||
|
repo = new PostgresDaresRepository(isolated.toString());
|
||||||
|
|
||||||
|
const dir = new URL('../drizzle/', import.meta.url);
|
||||||
|
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
||||||
|
for (const name of files) {
|
||||||
|
const sql = await readFile(new URL(name, dir), 'utf8');
|
||||||
|
for (const statement of sql.split('--> statement-breakpoint')) {
|
||||||
|
if (statement.trim()) await pool.query(statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']);
|
||||||
|
await pool.query(
|
||||||
|
'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)',
|
||||||
|
[sender, recipient, outsider, couple, otherCouple],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`insert into dare_packs(id,name,is_premium,content_status) values
|
||||||
|
($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`,
|
||||||
|
[freePack, premiumPack, unreviewedPack],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`insert into dares(id,pack_id,text,intensity,content_status) values
|
||||||
|
($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`,
|
||||||
|
[freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await pool.query('delete from sent_dares');
|
||||||
|
await pool.query('delete from operations_outbox');
|
||||||
|
await pool.query('update profiles set is_premium=false');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await repo.close();
|
||||||
|
await pool.end();
|
||||||
|
await admin.query(`drop schema "${schema}" cascade`);
|
||||||
|
await admin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides unreviewed packs and dares from the catalogue', async () => {
|
||||||
|
const catalog = await repo.getCatalog(sender);
|
||||||
|
const ids = catalog.packs.map((pack) => pack.id);
|
||||||
|
expect(ids).toContain(freePack);
|
||||||
|
expect(ids).toContain(premiumPack);
|
||||||
|
expect(ids).not.toContain(unreviewedPack);
|
||||||
|
await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' });
|
||||||
|
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
||||||
|
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
|
||||||
|
// Premium is couple-shared: the partner paying unlocks the sender.
|
||||||
|
await pool.query('update profiles set is_premium=true where id=$1', [recipient]);
|
||||||
|
expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null });
|
||||||
|
await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1);
|
||||||
|
const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 });
|
||||||
|
expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the free weekly send allowance without blocking the response side', async () => {
|
||||||
|
for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) {
|
||||||
|
await repo.send(sender, { dare_id: freeDare });
|
||||||
|
}
|
||||||
|
expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0);
|
||||||
|
await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' });
|
||||||
|
|
||||||
|
// The quota is per-sender, so the recipient can still answer back.
|
||||||
|
const reply = await repo.send(recipient, { dare_id: freeDare });
|
||||||
|
expect(reply.direction).toBe('outgoing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent sends so the free allowance cannot be raced', async () => {
|
||||||
|
const attempts = await Promise.allSettled(
|
||||||
|
Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })),
|
||||||
|
);
|
||||||
|
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT);
|
||||||
|
const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]);
|
||||||
|
expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks the full loop and records each transition exactly once', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' });
|
||||||
|
expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' });
|
||||||
|
expect(dare.expires_at).not.toBeNull();
|
||||||
|
|
||||||
|
expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active');
|
||||||
|
expect((await repo.submit(recipient, dare.id)).status).toBe('submitted');
|
||||||
|
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||||
|
|
||||||
|
const events = await pool.query<{ dedupe_key: string; recipient_id: string }>(
|
||||||
|
"select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at",
|
||||||
|
);
|
||||||
|
expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']);
|
||||||
|
expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never puts dare text in a notification payload', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
const leaked = await pool.query<{ count: string }>(
|
||||||
|
"select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'",
|
||||||
|
);
|
||||||
|
expect(leaked.rows[0]!.count).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the dares notification preference', async () => {
|
||||||
|
await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]);
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]);
|
||||||
|
expect(events.rowCount).toBe(0);
|
||||||
|
// The sender has not opted out, so their side still fires.
|
||||||
|
await repo.respond(recipient, dare.id, 'decline');
|
||||||
|
const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]);
|
||||||
|
expect(senderEvents.rowCount).toBe(1);
|
||||||
|
await pool.query('delete from notification_preferences where user_id=$1', [recipient]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces actor and state on every transition', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' });
|
||||||
|
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||||
|
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||||
|
await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||||
|
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' });
|
||||||
|
expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled');
|
||||||
|
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps history intact when the source dare is deleted', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await pool.query('delete from dares where id=$1', [freeDare]);
|
||||||
|
const history = await repo.listDares(sender, 'active');
|
||||||
|
expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 });
|
||||||
|
await pool.query(
|
||||||
|
"insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')",
|
||||||
|
[freeDare, freePack],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes listing and stats to the couple', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
await repo.submit(recipient, dare.id);
|
||||||
|
await repo.complete(sender, dare.id);
|
||||||
|
|
||||||
|
expect(await repo.listDares(outsider, 'active')).toEqual([]);
|
||||||
|
expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 });
|
||||||
|
expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 });
|
||||||
|
expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 });
|
||||||
|
|
||||||
|
const active = await repo.listDares(sender, 'active');
|
||||||
|
const history = await repo.listDares(sender, 'history');
|
||||||
|
expect(active).toEqual([]);
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0]!.direction).toBe('outgoing');
|
||||||
|
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects durations outside the offered presets', async () => {
|
||||||
|
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to send without a partner', async () => {
|
||||||
|
await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthIdentity } from '../src/auth.js';
|
||||||
|
import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js';
|
||||||
|
import { registerDareRoutes } from '../src/domains/dares/routes.js';
|
||||||
|
|
||||||
|
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||||
|
const partner = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const dareId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const packId = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
||||||
|
|
||||||
|
const dare: SentDare = {
|
||||||
|
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||||
|
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||||
|
direction: 'outgoing', status: 'pending', sender_notes: null,
|
||||||
|
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||||
|
const repository: DaresRepository = {
|
||||||
|
getCatalog: vi.fn(async () => ({
|
||||||
|
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 },
|
||||||
|
packs: [],
|
||||||
|
})),
|
||||||
|
listPackDares: vi.fn(async () => []),
|
||||||
|
listDares: vi.fn(async () => [dare]),
|
||||||
|
send: vi.fn(async () => dare),
|
||||||
|
respond: vi.fn(async () => ({ ...dare, status: 'active' as const })),
|
||||||
|
submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })),
|
||||||
|
complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })),
|
||||||
|
cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })),
|
||||||
|
stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||||
|
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||||
|
registerDareRoutes(app, repository);
|
||||||
|
return { app, repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||||
|
return app.request(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dare routes', () => {
|
||||||
|
it('derives the actor from the bearer identity, never the body', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/dares', {
|
||||||
|
dare_id: catalogueDareId,
|
||||||
|
sender_notes: ' be brave ',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects client-supplied ownership fields', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires exactly one of dare_id or custom text', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
expect((await post(app, '/v1/dares', {})).status).toBe(400);
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400);
|
||||||
|
expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only preset durations', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201);
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates identifiers and filters', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400);
|
||||||
|
expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400);
|
||||||
|
expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes each transition to its repository method', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||||
|
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||||
|
await post(app, `/v1/dares/${dareId}/submit`);
|
||||||
|
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
|
await post(app, `/v1/dares/${dareId}/complete`);
|
||||||
|
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
|
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||||
|
expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces domain errors with their status codes', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }),
|
||||||
|
submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }),
|
||||||
|
});
|
||||||
|
const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' });
|
||||||
|
expect(paywalled.status).toBe(402);
|
||||||
|
expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } });
|
||||||
|
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the duration presets the send sheet offers', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
const response = await app.request('/v1/dares/durations');
|
||||||
|
expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,476 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import { join } from 'node:path';
|
import { join } from "node:path";
|
||||||
import { Pool } from 'pg';
|
import { Pool } from "pg";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
|
import {
|
||||||
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
|
migrateDatabase,
|
||||||
import type { MigrationCheckpoint } from '../src/migration/types.js';
|
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');
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
|
const local = databaseUrl
|
||||||
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;
|
? ["127.0.0.1", "localhost", "::1"].includes(new URL(databaseUrl).hostname)
|
||||||
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';
|
: false;
|
||||||
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
|
if (databaseUrl && !local)
|
||||||
beforeAll(async()=>{
|
throw new Error("Migration integration tests only permit localhost");
|
||||||
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
|
describe.skipIf(!databaseUrl || !local)("cutover migration fixtures", () => {
|
||||||
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
|
const admin = new Pool({ connectionString: databaseUrl });
|
||||||
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
|
const suffix = randomUUID().replaceAll("-", "");
|
||||||
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);}
|
const sourceSchema = `migration_source_${suffix}`;
|
||||||
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);`);
|
const targetSchema = `migration_target_${suffix}`;
|
||||||
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
|
const storageSchema = `migration_storage_${suffix}`;
|
||||||
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
|
let source: Pool;
|
||||||
await source.query('alter table profiles add column avatar_url text');
|
let target: Pool;
|
||||||
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
|
let mediaRoot: string;
|
||||||
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
|
const couple = "11111111-1111-4111-8111-111111111111",
|
||||||
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
|
user = "22222222-2222-4222-8222-222222222222",
|
||||||
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
|
pack = "33333333-3333-4333-8333-333333333333",
|
||||||
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)`);
|
question = "44444444-4444-4444-8444-444444444444",
|
||||||
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'}]);
|
inverseQuestion = "45454545-4545-4545-8545-454545454545",
|
||||||
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
|
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;
|
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
||||||
beforeAll(async()=>{
|
beforeAll(async()=>{
|
||||||
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
|
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
|
||||||
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql']){
|
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){
|
||||||
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
|
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
|
||||||
}
|
}
|
||||||
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
|
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
|
||||||
@@ -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);
|
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()=>{
|
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]);
|
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);
|
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 type { OperationItem } from '../src/domains/operations/types.js';
|
||||||
import { loadWorkerConfig } from '../src/workers/config.js';
|
import { loadWorkerConfig } from '../src/workers/config.js';
|
||||||
|
|
||||||
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
||||||
function repository(items:OperationItem[]):OperationsRepository {
|
function repository(items:OperationItem[]):OperationsRepository {
|
||||||
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
|
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
|
||||||
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
|
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ const preferences = {
|
|||||||
pack_changes_enabled: true,
|
pack_changes_enabled: true,
|
||||||
new_packs_enabled: true,
|
new_packs_enabled: true,
|
||||||
streak_milestones_enabled: true,
|
streak_milestones_enabled: true,
|
||||||
|
streak_reminders_enabled: true,
|
||||||
weekly_summary_enabled: true,
|
weekly_summary_enabled: true,
|
||||||
unpaired_reminders_enabled: true,
|
unpaired_reminders_enabled: true,
|
||||||
catchup_reminders_enabled: true,
|
catchup_reminders_enabled: true,
|
||||||
|
dares_enabled: true,
|
||||||
created_at: '2026-08-27T10:00:00.000Z',
|
created_at: '2026-08-27T10:00:00.000Z',
|
||||||
updated_at: '2026-08-27T10:00:00.000Z',
|
updated_at: '2026-08-27T10:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
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()
|
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
|
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||||
// works correctly with Expo projects.
|
// 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())
|
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)
|
* 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
|
* give correct results when using with locales other than en-US. Note that
|
||||||
* this variant is about 6MiB larger per architecture than default.
|
* 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 {
|
android {
|
||||||
ndkVersion rootProject.ext.ndkVersion
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
@@ -91,8 +92,9 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 46
|
versionCode 47
|
||||||
versionName "1.0.5"
|
versionName "1.0.5"
|
||||||
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
@@ -110,15 +112,18 @@ android {
|
|||||||
// Caution! In production, you need to generate your own keystore file.
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
// see https://reactnative.dev/docs/signed-apk-android.
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||||
minifyEnabled enableProguardInReleaseBuilds
|
shrinkResources enableShrinkResources.toBoolean()
|
||||||
|
minifyEnabled enableMinifyInReleaseBuilds
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
|
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||||
|
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||||
|
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
@@ -156,15 +161,15 @@ dependencies {
|
|||||||
|
|
||||||
if (isGifEnabled) {
|
if (isGifEnabled) {
|
||||||
// For animated gif support
|
// 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) {
|
if (isWebpEnabled) {
|
||||||
// For webp support
|
// For webp support
|
||||||
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
|
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||||
if (isWebpAnimatedEnabled) {
|
if (isWebpAnimatedEnabled) {
|
||||||
// Animated webp support
|
// Animated webp support
|
||||||
implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
|
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
<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_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.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</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_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="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"/>
|
<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.ReactNativeHost
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ReactHost
|
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.defaults.DefaultReactNativeHost
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.common.ReleaseLevel
|
||||||
import com.facebook.soloader.SoLoader
|
|
||||||
|
|
||||||
import expo.modules.ApplicationLifecycleDispatcher
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
import expo.modules.ReactNativeHostWrapper
|
import expo.modules.ReactNativeHostWrapper
|
||||||
@@ -42,11 +42,12 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
} catch (e: IllegalArgumentException) {
|
||||||
load()
|
ReleaseLevel.STABLE
|
||||||
}
|
}
|
||||||
|
loadReactNative(this)
|
||||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
<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:textColor">@android:color/black</item>
|
||||||
<item name="android:editTextStyle">@style/ResetEditText</item>
|
<item name="android:editTextStyle">@style/ResetEditText</item>
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
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 {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -19,23 +10,15 @@ buildscript {
|
|||||||
classpath 'com.google.gms:google-services:4.4.1'
|
classpath 'com.google.gms:google-services:4.4.1'
|
||||||
classpath('com.android.tools.build:gradle')
|
classpath('com.android.tools.build:gradle')
|
||||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
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"
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
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()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url 'https://www.jitpack.io' }
|
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
|
# your application. You should enable this flag either if you want
|
||||||
# to write custom TurboModules/Fabric components OR use libraries that
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
# are providing them.
|
# are providing them.
|
||||||
newArchEnabled=false
|
newArchEnabled=true
|
||||||
|
|
||||||
# Use this property to enable or disable the Hermes JS engine.
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
# If set to false, you will be using JSC instead.
|
# 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.
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
android.targetSdkVersion=35
|
android.targetSdkVersion=36
|
||||||
android.kotlinVersion=1.9.25
|
|
||||||
android.extraMavenRepos=[]
|
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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
pluginManagement {
|
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 ->
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||||
ex.autolinkLibrariesFromCommand()
|
ex.autolinkLibrariesFromCommand()
|
||||||
} else {
|
} else {
|
||||||
def command = [
|
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||||
'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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'Sauci'
|
rootProject.name = 'Sauci'
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
expoAutolinking.useExpoModules()
|
||||||
versionCatalogs {
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
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()
|
|
||||||
|
|
||||||
include ':app'
|
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",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": false,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"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.",
|
"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."
|
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
|
||||||
},
|
},
|
||||||
"privacyManifest": {
|
"privacyManifests": {
|
||||||
"NSPrivacyTracking": false,
|
"NSPrivacyTracking": false,
|
||||||
"NSPrivacyCollectedDataTypes": [
|
"NSPrivacyCollectedDataTypes": [
|
||||||
{
|
{
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 46,
|
"versionCode": 47,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -205,8 +205,9 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"android": {
|
"android": {
|
||||||
"targetSdkVersion": 35,
|
"buildToolsVersion": "36.0.0",
|
||||||
"kotlinVersion": "1.9.25",
|
"compileSdkVersion": 36,
|
||||||
|
"targetSdkVersion": 36,
|
||||||
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||||
"useLegacyPackaging": false,
|
"useLegacyPackaging": false,
|
||||||
"manifestPlaceholders": {
|
"manifestPlaceholders": {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function AppLayout() {
|
|||||||
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
||||||
const { fetchStreak } = useStreakStore();
|
const { fetchStreak } = useStreakStore();
|
||||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const hasCheckedInitialBiometric = useRef(false);
|
const hasCheckedInitialBiometric = useRef(false);
|
||||||
const wentToBackgroundAt = useRef<number | null>(null);
|
const wentToBackgroundAt = useRef<number | null>(null);
|
||||||
|
|||||||
@@ -1,232 +1,5 @@
|
|||||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
import { DaresScreen } from "../../src/features/dares";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
|
||||||
import { GradientBackground } from "../../src/components/ui";
|
|
||||||
import { colors, spacing, radius, typography, animations } from "../../src/theme";
|
|
||||||
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
|
|
||||||
|
|
||||||
const ACCENT_COLOR = colors.premium.gold;
|
export default function DaresRoute() {
|
||||||
|
return <DaresScreen />;
|
||||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
||||||
|
|
||||||
export default function DaresScreen() {
|
|
||||||
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
|
|
||||||
useFeatureInterest("dares");
|
|
||||||
|
|
||||||
const scale = useSharedValue(1);
|
|
||||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ scale: scale.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handlePressIn = () => {
|
|
||||||
scale.value = withSpring(0.96, animations.spring);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePressOut = () => {
|
|
||||||
scale.value = withSpring(1, animations.spring);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GradientBackground>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.duration(600).springify()}
|
|
||||||
style={styles.content}
|
|
||||||
>
|
|
||||||
{/* Title section */}
|
|
||||||
<Text style={styles.label}>ADVENTURE</Text>
|
|
||||||
<Text style={styles.title}>Dares</Text>
|
|
||||||
|
|
||||||
{/* Decorative separator */}
|
|
||||||
<View style={styles.separator}>
|
|
||||||
<View style={styles.separatorLine} />
|
|
||||||
<View style={styles.separatorDiamond} />
|
|
||||||
<View style={styles.separatorLine} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Coming soon badge */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.delay(300).duration(400)}
|
|
||||||
style={styles.badge}
|
|
||||||
>
|
|
||||||
<Text style={styles.badgeText}>ARRIVING SOON</Text>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Text style={styles.description}>
|
|
||||||
Try playful challenges together and create unforgettable moments.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Feature hints */}
|
|
||||||
<View style={styles.features}>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Provocative challenges</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="thermometer-outline" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Multiple intensity levels</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="people-outline" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Designed for two</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom teaser */}
|
|
||||||
<Text style={styles.teaser}>Are you ready to play?</Text>
|
|
||||||
|
|
||||||
{/* Interest button - always reserve space to prevent layout shift */}
|
|
||||||
<View style={styles.interestButtonContainer}>
|
|
||||||
{isAuthenticated && !isLoading && (
|
|
||||||
<Animated.View entering={FadeIn.delay(500).duration(400)}>
|
|
||||||
<AnimatedPressable
|
|
||||||
onPress={toggleInterest}
|
|
||||||
onPressIn={handlePressIn}
|
|
||||||
onPressOut={handlePressOut}
|
|
||||||
disabled={isToggling}
|
|
||||||
style={[
|
|
||||||
styles.interestButton,
|
|
||||||
isInterested && styles.interestButtonActive,
|
|
||||||
animatedButtonStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isToggling ? (
|
|
||||||
<ActivityIndicator size="small" color={ACCENT_COLOR} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons
|
|
||||||
name={isInterested ? "checkmark-circle" : "notifications-outline"}
|
|
||||||
size={16}
|
|
||||||
color={ACCENT_COLOR}
|
|
||||||
/>
|
|
||||||
<Text style={styles.interestButtonText}>
|
|
||||||
{isInterested ? "You're on the list!" : "I'm interested"}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatedPressable>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</GradientBackground>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingTop: 60,
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
...typography.caption1,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 3,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
...typography.largeTitle,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginVertical: spacing.lg,
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
separatorLine: {
|
|
||||||
flex: 1,
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: 'rgba(212, 175, 55, 0.3)',
|
|
||||||
},
|
|
||||||
separatorDiamond: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
transform: [{ rotate: '45deg' }],
|
|
||||||
marginHorizontal: spacing.md,
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
badge: {
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
interestButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm + 2,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
interestButtonActive: {
|
|
||||||
backgroundColor: colors.background,
|
|
||||||
borderColor: colors.primary,
|
|
||||||
},
|
|
||||||
interestButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
badgeText: {
|
|
||||||
...typography.caption2,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 2,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
...typography.body,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
featureItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
featureText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.text,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
teaser: {
|
|
||||||
...typography.footnote,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
color: colors.textTertiary,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
interestButtonContainer: {
|
|
||||||
marginTop: spacing.xl,
|
|
||||||
minHeight: 44,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -4,10 +4,11 @@ import { useFocusEffect } from 'expo-router';
|
|||||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
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 { GradientBackground } from '../../src/components/ui';
|
||||||
import { Paywall } from '../../src/components/paywall';
|
import { Paywall } from '../../src/components/paywall';
|
||||||
import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery';
|
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
||||||
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||||
import type { QuestionPack, Category } from '../../src/types';
|
import type { QuestionPack, Category } from '../../src/types';
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ function groupPacksByCategory(
|
|||||||
export default function DiscoveryScreen() {
|
export default function DiscoveryScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
||||||
|
const { fetchStreak } = useStreakStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const [showPaywall, setShowPaywall] = useState(false);
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
@@ -81,7 +83,8 @@ export default function DiscoveryScreen() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchPacks();
|
fetchPacks();
|
||||||
}, [])
|
fetchStreak();
|
||||||
|
}, [fetchStreak])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group packs by category
|
// Group packs by category
|
||||||
@@ -114,9 +117,17 @@ export default function DiscoveryScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
<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 */}
|
{/* Live Draw Activity Tile */}
|
||||||
<LiveDrawTile delay={100} />
|
<LiveDrawTile delay={100} />
|
||||||
|
|
||||||
|
{/* Dares */}
|
||||||
|
<DaresTile delay={150} />
|
||||||
|
|
||||||
{/* Content Rows by Category */}
|
{/* Content Rows by Category */}
|
||||||
{packsByCategory.map((group, index) => (
|
{packsByCategory.map((group, index) => (
|
||||||
<ContentRow
|
<ContentRow
|
||||||
@@ -209,6 +220,9 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
streakSlot: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: spacing.lg,
|
height: spacing.lg,
|
||||||
},
|
},
|
||||||
@@ -227,4 +241,3 @@ const styles = StyleSheet.create({
|
|||||||
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
|
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5,19 +5,19 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
"node": "20.10.0",
|
"node": "20.19.4",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"node": "20.10.0",
|
"node": "20.19.4",
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
|
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"node": "20.10.0",
|
"node": "20.19.4",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
|
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
|
||||||
|
|||||||
@@ -1,33 +1,3 @@
|
|||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Find the project and workspace directories
|
module.exports = getDefaultConfig(__dirname);
|
||||||
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;
|
|
||||||
+44
-43
@@ -23,8 +23,8 @@
|
|||||||
"@bacons/apple-targets": "^0.1.6",
|
"@bacons/apple-targets": "^0.1.6",
|
||||||
"@bittingz/expo-widgets": "^3.0.2",
|
"@bittingz/expo-widgets": "^3.0.2",
|
||||||
"@expo/ngrok": "^4.1.0",
|
"@expo/ngrok": "^4.1.0",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-community/cli": "^20.1.1",
|
"@react-native-community/cli": "^20.1.1",
|
||||||
"@react-native-firebase/analytics": "^23.8.2",
|
"@react-native-firebase/analytics": "^23.8.2",
|
||||||
"@react-native-firebase/app": "^23.8.2",
|
"@react-native-firebase/app": "^23.8.2",
|
||||||
@@ -33,63 +33,64 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
"@react-navigation/native-stack": "^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",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"babel-preset-expo": "~12.0.0",
|
"babel-preset-expo": "~54.0.12",
|
||||||
"base64-arraybuffer": "^1.0.2",
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"expo": "~52.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-apple-authentication": "~7.1.3",
|
"expo-apple-authentication": "~8.0.8",
|
||||||
"expo-asset": "~11.0.5",
|
"expo-asset": "~12.0.13",
|
||||||
"expo-av": "~15.0.2",
|
"expo-av": "~16.0.8",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-blur": "~15.0.8",
|
||||||
"expo-build-properties": "^0.13.3",
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-clipboard": "~7.0.1",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~17.0.0",
|
"expo-constants": "~18.0.14",
|
||||||
"expo-dev-client": "~5.0.20",
|
"expo-dev-client": "~6.0.21",
|
||||||
"expo-file-system": "~18.0.12",
|
"expo-file-system": "~19.0.24",
|
||||||
"expo-font": "~13.0.4",
|
"expo-font": "~14.0.12",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~2.0.7",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-picker": "~16.0.6",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~15.0.8",
|
||||||
"expo-linking": "~7.0.0",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-local-authentication": "~15.0.2",
|
"expo-local-authentication": "~17.0.9",
|
||||||
"expo-media-library": "~17.0.6",
|
"expo-media-library": "~18.2.1",
|
||||||
"expo-notifications": "~0.29.0",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-router": "~4.0.0",
|
"expo-router": "~6.0.24",
|
||||||
"expo-secure-store": "~14.0.0",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sharing": "~13.0.1",
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "18.3.1",
|
"react": "19.1.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.76.9",
|
"react-native": "0.81.5",
|
||||||
"react-native-android-widget": "^0.17.2",
|
"react-native-android-widget": "^0.22.1",
|
||||||
"react-native-compressor": "^1.13.0",
|
"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-purchases": "^9.6.12",
|
||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.8.0",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-view-shot": "^4.0.3",
|
"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"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0",
|
"@babel/core": "^7.24.0",
|
||||||
"@testing-library/jest-native": "^5.4.3",
|
"@testing-library/jest-native": "^5.4.3",
|
||||||
"@testing-library/react-native": "^13.3.3",
|
"@testing-library/react-native": "^13.3.3",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-expo": "~8.0.1",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-expo": "~52.0.0",
|
"jest-expo": "~54.0.18",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "19.1.0",
|
||||||
"typescript": "^5.3.3"
|
"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 { 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 { appDataApi } from '@/lib/appDataApi';
|
||||||
import { ApiError } from '@/lib/apiClient';
|
import { ApiError } from '@/lib/apiClient';
|
||||||
|
|
||||||
@@ -47,3 +47,37 @@ describe('useDrawingSync API polling', () => {
|
|||||||
expect(onLoadStrokes).toHaveBeenLastCalledWith([stroke, partnerStroke, mine]);
|
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 { renderHook, act, waitFor } from '@testing-library/react-native';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
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';
|
import { useMediaSaver } from '@/hooks/useMediaSaver';
|
||||||
|
|
||||||
// Mock expo-media-library
|
// Mock expo-media-library
|
||||||
@@ -11,7 +11,7 @@ jest.mock('expo-media-library', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock expo-file-system with all required methods
|
// Mock expo-file-system with all required methods
|
||||||
jest.mock('expo-file-system', () => ({
|
jest.mock('expo-file-system/legacy', () => ({
|
||||||
readAsStringAsync: jest.fn(async () => ''),
|
readAsStringAsync: jest.fn(async () => ''),
|
||||||
downloadAsync: jest.fn(),
|
downloadAsync: jest.fn(),
|
||||||
deleteAsync: 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,
|
weekly_summary_enabled: true,
|
||||||
unpaired_reminders_enabled: true,
|
unpaired_reminders_enabled: true,
|
||||||
catchup_reminders_enabled: true,
|
catchup_reminders_enabled: true,
|
||||||
|
streak_reminders_enabled: true,
|
||||||
created_at: '2026-08-27T10:00:00.000Z',
|
created_at: '2026-08-27T10:00:00.000Z',
|
||||||
updated_at: '2026-08-27T10:00:00.000Z',
|
updated_at: '2026-08-27T10:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import React, { useEffect } from 'react';
|
|||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import { useStreakStore } from '../store';
|
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 {
|
interface StreakDisplayProps {
|
||||||
/** Whether to show the "longest streak" indicator */
|
/** Whether to show the "longest streak" indicator */
|
||||||
@@ -16,11 +16,34 @@ interface StreakDisplayProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays the couple's current streak with a flame icon.
|
* The count is deliberately framed as a couple's, not a personal score: a shared streak
|
||||||
* The flame pulses when there's an active 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) {
|
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||||
const { streak, isLoading, fetchStreak } = useStreakStore();
|
const { streak, fetchStreak } = useStreakStore();
|
||||||
|
|
||||||
// Fetch streak on mount
|
// Fetch streak on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,9 +52,10 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
|
|||||||
|
|
||||||
// Flame pulse animation
|
// Flame pulse animation
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
|
const hasStreak = (streak?.current_streak ?? 0) > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (streak && streak.current_streak > 0) {
|
if (hasStreak) {
|
||||||
scale.value = withRepeat(
|
scale.value = withRepeat(
|
||||||
withSequence(
|
withSequence(
|
||||||
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||||
@@ -43,25 +67,32 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
|
|||||||
} else {
|
} else {
|
||||||
scale.value = 1;
|
scale.value = 1;
|
||||||
}
|
}
|
||||||
}, [streak?.current_streak, scale]);
|
}, [hasStreak, scale]);
|
||||||
|
|
||||||
const flameStyle = useAnimatedStyle(() => ({
|
const flameStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ scale: scale.value }],
|
transform: [{ scale: scale.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Don't show if loading or no couple
|
if (!streak) {
|
||||||
if (isLoading || !streak) {
|
|
||||||
return null;
|
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 currentStreak = streak.current_streak;
|
||||||
const longestStreak = streak.longest_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) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
|
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
|
||||||
<View style={styles.compactContainer}>
|
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
|
||||||
<Animated.View style={flameStyle}>
|
<Animated.View style={flameStyle}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="flame"
|
name="flame"
|
||||||
@@ -80,44 +111,35 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
|
|||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
|
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Main streak display */}
|
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
|
||||||
<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.content}>
|
<View style={styles.content}>
|
||||||
{/* Flame icon */}
|
|
||||||
<Animated.View style={[styles.iconContainer, flameStyle]}>
|
<Animated.View style={[styles.iconContainer, flameStyle]}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="flame"
|
name="flame"
|
||||||
size={32}
|
size={28}
|
||||||
color={hasStreak ? colors.warning : colors.textTertiary}
|
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Streak info */}
|
|
||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
|
<Text style={styles.headline} numberOfLines={2}>
|
||||||
{currentStreak}
|
{headline}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.label}>
|
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
|
||||||
day{currentStreak !== 1 ? 's' : ''} together
|
{status}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Longest streak */}
|
|
||||||
{showLongest && longestStreak > 0 && (
|
{showLongest && longestStreak > 0 && (
|
||||||
<View style={styles.longest}>
|
<View style={styles.longest}>
|
||||||
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
|
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
|
||||||
<Text style={styles.longestText}>
|
<Text style={styles.longestText}>
|
||||||
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
|
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</LinearGradient>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -128,10 +150,14 @@ const styles = StyleSheet.create({
|
|||||||
marginVertical: spacing.sm,
|
marginVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.glass.border,
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
cardAwaiting: {
|
||||||
|
borderColor: colors.warning,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -139,23 +165,27 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
width: 48,
|
width: 44,
|
||||||
height: 48,
|
height: 44,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
backgroundColor: colors.warningLight,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
count: {
|
headline: {
|
||||||
...typography.title1,
|
...typography.headline,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
label: {
|
status: {
|
||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
statusAwaiting: {
|
||||||
|
color: colors.warning,
|
||||||
},
|
},
|
||||||
inactiveText: {
|
inactiveText: {
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
@@ -167,7 +197,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.sm,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.glass.border,
|
borderTopColor: colors.border,
|
||||||
},
|
},
|
||||||
longestText: {
|
longestText: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
@@ -178,11 +208,15 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
backgroundColor: colors.warningLight,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
|
compactAwaiting: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
},
|
||||||
compactCount: {
|
compactCount: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
fontWeight: '600',
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,3 +3,4 @@ export { ContentRow } from './ContentRow';
|
|||||||
export { CompactHeader } from './CompactHeader';
|
export { CompactHeader } from './CompactHeader';
|
||||||
export { CircularProgress } from './CircularProgress';
|
export { CircularProgress } from './CircularProgress';
|
||||||
export { LiveDrawTile } from './LiveDrawTile';
|
export { LiveDrawTile } from './LiveDrawTile';
|
||||||
|
export { DaresTile } from './DaresTile';
|
||||||
@@ -23,7 +23,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ViewShot from "react-native-view-shot";
|
import ViewShot from "react-native-view-shot";
|
||||||
import * as Sharing from "expo-sharing";
|
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";
|
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
|
||||||
|
|
||||||
const logo = require("../../../assets/logo.png");
|
const logo = require("../../../assets/logo.png");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
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 { ViewStyle } from 'react-native';
|
||||||
import type { IntensityLevel } from '@/types';
|
import type { IntensityLevel } from '@/types';
|
||||||
import { HEAT_COLORS } from './constants';
|
import { HEAT_COLORS } from './constants';
|
||||||
@@ -9,7 +9,7 @@ import { styles } from './styles';
|
|||||||
|
|
||||||
interface IntensitySliderTrackProps {
|
interface IntensitySliderTrackProps {
|
||||||
value: IntensityLevel;
|
value: IntensityLevel;
|
||||||
progressStyle: AnimatedStyleProp<ViewStyle>;
|
progressStyle: AnimatedStyle<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntensitySliderTrack({ value, progressStyle }: IntensitySliderTrackProps) {
|
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
|
// Features exports
|
||||||
export * from './chat';
|
export * from './chat';
|
||||||
|
export * from './dares';
|
||||||
export * from './profile';
|
export * from './profile';
|
||||||
@@ -44,8 +44,9 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
|
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
|
||||||
|
|
||||||
const canvasRef = useRef<DrawingCanvasHandle>(null);
|
const canvasRef = useRef<DrawingCanvasHandle>(null);
|
||||||
// Keep track of the current stroke id for sync broadcasts
|
// The stroke under the finger. Points are pushed in place — nothing about the
|
||||||
const currentStrokeIdRef = useRef<string | null>(null);
|
// in-progress stroke goes through React state until it is finished.
|
||||||
|
const currentStrokeRef = useRef<StrokeSegment | null>(null);
|
||||||
|
|
||||||
const canvasWidth = screenWidth - spacing.md * 2;
|
const canvasWidth = screenWidth - spacing.md * 2;
|
||||||
const canvasHeight = canvasWidth * 1.2;
|
const canvasHeight = canvasWidth * 1.2;
|
||||||
@@ -64,6 +65,16 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
|
|
||||||
const { makeSnapshot, captureToFile } = useCanvasCapture();
|
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(
|
const handleStrokeStartFromPartner = useCallback(
|
||||||
(stroke: StrokeSegment) => addStroke(stroke),
|
(stroke: StrokeSegment) => addStroke(stroke),
|
||||||
[addStroke]
|
[addStroke]
|
||||||
@@ -95,57 +106,66 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
|
|
||||||
const handleTouchStart = useCallback(
|
const handleTouchStart = useCallback(
|
||||||
(point: StrokePoint) => {
|
(point: StrokePoint) => {
|
||||||
if (!user?.id) return;
|
const userId = userIdRef.current;
|
||||||
if (strokes.length >= MAX_STROKES) {
|
// 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.');
|
Alert.alert('Canvas Full', 'Save your drawing and start fresh.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const style = strokeStyleRef.current;
|
||||||
const stroke: StrokeSegment = {
|
const stroke: StrokeSegment = {
|
||||||
id: `${user.id}_${Date.now()}`,
|
id: `${userId}_${Date.now()}`,
|
||||||
userId: user.id,
|
userId,
|
||||||
points: [point],
|
points: [point],
|
||||||
color: selectedColor,
|
color: style.color,
|
||||||
width: brushWidth,
|
width: style.width,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
isEraser,
|
isEraser: style.isEraser,
|
||||||
};
|
};
|
||||||
currentStrokeIdRef.current = stroke.id;
|
currentStrokeRef.current = stroke;
|
||||||
canvasRef.current?.startStroke(stroke);
|
sync.beginLocalStroke(stroke);
|
||||||
sync.broadcastStrokeStart(stroke);
|
|
||||||
},
|
},
|
||||||
[user?.id, selectedColor, brushWidth, isEraser, strokes.length, sync]
|
[sync]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback(
|
const handleTouchMove = useCallback(
|
||||||
(point: StrokePoint) => {
|
(point: StrokePoint) => {
|
||||||
if (!currentStrokeIdRef.current) return;
|
const stroke = currentStrokeRef.current;
|
||||||
canvasRef.current?.addPoint(point);
|
if (!stroke) return;
|
||||||
sync.broadcastStrokeContinue(currentStrokeIdRef.current, [point]);
|
stroke.points.push(point);
|
||||||
|
sync.appendLocalPoint(point);
|
||||||
},
|
},
|
||||||
[sync]
|
[sync]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(() => {
|
const handleTouchEnd = useCallback(() => {
|
||||||
if (!currentStrokeIdRef.current) return;
|
const finished = currentStrokeRef.current;
|
||||||
const finished = canvasRef.current?.endStroke() ?? null;
|
currentStrokeRef.current = null;
|
||||||
currentStrokeIdRef.current = null;
|
if (!finished) return;
|
||||||
if (finished) {
|
|
||||||
addStroke(finished);
|
addStroke(finished);
|
||||||
sync.broadcastStrokeEnd(finished.id);
|
void sync.endLocalStroke(finished);
|
||||||
sync.persistStrokes([...strokes, finished]);
|
// Drop the UI-thread copy only after React has painted the committed
|
||||||
}
|
// stroke, otherwise the line blinks out for a frame.
|
||||||
}, [addStroke, strokes, sync]);
|
requestAnimationFrame(() => canvasRef.current?.clearLiveStroke());
|
||||||
|
}, [addStroke, sync]);
|
||||||
|
|
||||||
const handleUndo = useCallback(() => {
|
const handleUndo = useCallback(() => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
const removed = undo(user.id);
|
const removed = undo(user.id);
|
||||||
if (removed) {
|
if (removed) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
canvasRef.current?.clearLiveStroke();
|
||||||
sync.broadcastUndo(removed.id);
|
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(() => {
|
const handleRedo = useCallback(() => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
@@ -153,9 +173,9 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
if (restored) {
|
if (restored) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
sync.broadcastRedo(restored);
|
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(() => {
|
const handleClear = useCallback(() => {
|
||||||
Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [
|
Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [
|
||||||
@@ -165,8 +185,9 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
clearAll();
|
clearAll();
|
||||||
|
canvasRef.current?.clearLiveStroke();
|
||||||
sync.broadcastClearCanvas();
|
sync.broadcastClearCanvas();
|
||||||
sync.persistStrokes([]);
|
void sync.persistStrokes([]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -203,6 +224,9 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
strokes={strokes}
|
strokes={strokes}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
canvasHeight={canvasHeight}
|
canvasHeight={canvasHeight}
|
||||||
|
strokeColor={selectedColor}
|
||||||
|
strokeWidth={brushWidth}
|
||||||
|
isEraser={isEraser}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
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 { StyleSheet, View } from 'react-native';
|
||||||
import {
|
import {
|
||||||
Canvas,
|
Canvas,
|
||||||
Path,
|
Path,
|
||||||
Skia,
|
Skia,
|
||||||
} from '@shopify/react-native-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 { 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 type { StrokeSegment, StrokePoint } from '../types';
|
||||||
import { CANVAS_BACKGROUND } from '../constants';
|
import { CANVAS_BACKGROUND } from '../constants';
|
||||||
|
|
||||||
export interface DrawingCanvasHandle {
|
export interface DrawingCanvasHandle {
|
||||||
startStroke: (stroke: StrokeSegment) => void;
|
/** Drops the in-progress stroke rendered on the UI thread. */
|
||||||
addPoint: (point: StrokePoint) => void;
|
clearLiveStroke: () => void;
|
||||||
endStroke: () => StrokeSegment | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DrawingCanvasProps {
|
interface DrawingCanvasProps {
|
||||||
strokes: StrokeSegment[];
|
strokes: StrokeSegment[];
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
|
strokeColor: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
isEraser: boolean;
|
||||||
onTouchStart: (point: StrokePoint) => void;
|
onTouchStart: (point: StrokePoint) => void;
|
||||||
onTouchMove: (point: StrokePoint) => void;
|
onTouchMove: (point: StrokePoint) => void;
|
||||||
onTouchEnd: () => 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>(
|
export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
|
||||||
function DrawingCanvas(
|
function DrawingCanvas(
|
||||||
{
|
{
|
||||||
strokes,
|
strokes,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
|
strokeColor,
|
||||||
|
strokeWidth,
|
||||||
|
isEraser,
|
||||||
onTouchStart,
|
onTouchStart,
|
||||||
onTouchMove,
|
onTouchMove,
|
||||||
onTouchEnd,
|
onTouchEnd,
|
||||||
@@ -128,9 +104,23 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
|
|||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const canvasRef = useRef<any>(null);
|
const canvasRef = useRef<any>(null);
|
||||||
const currentStrokeRef = useRef<StrokeSegment | null>(null);
|
|
||||||
const rafScheduled = useRef(false);
|
// The in-progress stroke lives entirely on the UI thread: the pan gesture
|
||||||
const [rafTick, setRafTick] = useState(0);
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (onCanvasReady) {
|
if (onCanvasReady) {
|
||||||
@@ -140,52 +130,67 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
|
|||||||
}
|
}
|
||||||
}, [onCanvasReady]);
|
}, [onCanvasReady]);
|
||||||
|
|
||||||
const scheduleRafUpdate = useCallback(() => {
|
|
||||||
if (rafScheduled.current) return;
|
|
||||||
rafScheduled.current = true;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
rafScheduled.current = false;
|
|
||||||
setRafTick((t) => t + 1);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
startStroke(stroke: StrokeSegment) {
|
clearLiveStroke() {
|
||||||
currentStrokeRef.current = stroke;
|
livePath.value = null;
|
||||||
scheduleRafUpdate();
|
livePrev.value = null;
|
||||||
|
livePointCount.value = 0;
|
||||||
},
|
},
|
||||||
addPoint(point: StrokePoint) {
|
}), [livePath, livePrev, livePointCount]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const pan = Gesture.Pan()
|
const pan = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
.minDistance(0)
|
.minDistance(0)
|
||||||
.onStart((e) => {
|
.onStart((e) => {
|
||||||
runOnJS(onTouchStart)({
|
'worklet';
|
||||||
x: e.x / canvasWidth,
|
const path = Skia.Path.Make();
|
||||||
y: e.y / canvasHeight,
|
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) => {
|
.onUpdate((e) => {
|
||||||
runOnJS(onTouchMove)({
|
'worklet';
|
||||||
x: e.x / canvasWidth,
|
const path = livePath.value;
|
||||||
y: e.y / canvasHeight,
|
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(() => {
|
.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)();
|
runOnJS(onTouchEnd)();
|
||||||
});
|
}),
|
||||||
|
[
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
onTouchStart,
|
||||||
|
onTouchMove,
|
||||||
|
onTouchEnd,
|
||||||
|
livePath,
|
||||||
|
livePrev,
|
||||||
|
livePointCount,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={pan}>
|
<GestureDetector gesture={pan}>
|
||||||
@@ -199,11 +204,13 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
|
|||||||
canvasHeight={canvasHeight}
|
canvasHeight={canvasHeight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<CurrentStrokePath
|
<Path
|
||||||
strokeRef={currentStrokeRef}
|
path={renderedLivePath}
|
||||||
canvasWidth={canvasWidth}
|
color={liveColor}
|
||||||
canvasHeight={canvasHeight}
|
style="stroke"
|
||||||
_tick={rafTick}
|
strokeWidth={strokeWidth}
|
||||||
|
strokeCap="round"
|
||||||
|
strokeJoin="round"
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import ViewShot from 'react-native-view-shot';
|
import ViewShot from 'react-native-view-shot';
|
||||||
import * as Sharing from 'expo-sharing';
|
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';
|
import { colors, gradients, radius, typography, spacing, blur } from '../../../theme';
|
||||||
|
|
||||||
const logo = require('../../../../assets/logo.png');
|
const logo = require('../../../../assets/logo.png');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import type { SkImage } from '@shopify/react-native-skia';
|
import type { SkImage } from '@shopify/react-native-skia';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
|
||||||
interface UseCanvasCaptureReturn {
|
interface UseCanvasCaptureReturn {
|
||||||
makeSnapshot: React.MutableRefObject<(() => SkImage | null) | null>;
|
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 { ApiError } from '../../../lib/apiClient';
|
||||||
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
||||||
import type { StrokeSegment, StrokePoint } from '../types';
|
import type { StrokeSegment, StrokePoint } from '../types';
|
||||||
@@ -17,9 +17,9 @@ interface UseDrawingSyncConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UseDrawingSyncReturn {
|
interface UseDrawingSyncReturn {
|
||||||
broadcastStrokeStart: (stroke: StrokeSegment) => void;
|
beginLocalStroke: (stroke: StrokeSegment) => void;
|
||||||
broadcastStrokeContinue: (strokeId: string, points: StrokePoint[]) => void;
|
appendLocalPoint: (point: StrokePoint) => void;
|
||||||
broadcastStrokeEnd: (strokeId: string) => void;
|
endLocalStroke: (stroke: StrokeSegment) => Promise<void>;
|
||||||
broadcastClearCanvas: () => void;
|
broadcastClearCanvas: () => void;
|
||||||
broadcastUndo: (strokeId: string) => void;
|
broadcastUndo: (strokeId: string) => void;
|
||||||
broadcastRedo: (stroke: StrokeSegment) => 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;
|
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[] {
|
export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegment[], current: StrokeSegment[]): StrokeSegment[] {
|
||||||
const desiredById = new Map(desired.map(stroke => [stroke.id, stroke]));
|
const desiredById = new Map(desired.map(stroke => [stroke.id, stroke]));
|
||||||
const desiredIds = new Set(desiredById.keys());
|
const desiredIds = new Set(desiredById.keys());
|
||||||
@@ -40,7 +48,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
|
|||||||
const merged = current
|
const merged = current
|
||||||
.filter(stroke => !removedIds.has(stroke.id))
|
.filter(stroke => !removedIds.has(stroke.id))
|
||||||
.map(stroke => desiredById.get(stroke.id) ?? stroke);
|
.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 {
|
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
|
||||||
@@ -50,13 +58,38 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
|||||||
const workingRef = useRef<StrokeSegment[]>([]);
|
const workingRef = useRef<StrokeSegment[]>([]);
|
||||||
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
|
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
const pendingWritesRef = useRef(0);
|
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);
|
const onLoadStrokesRef = useRef(onLoadStrokes);
|
||||||
onLoadStrokesRef.current = 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) => {
|
const acceptState = useCallback((state: LiveDrawState) => {
|
||||||
revisionRef.current = state.revision;
|
revisionRef.current = state.revision;
|
||||||
baselineRef.current = state.strokes;
|
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;
|
workingRef.current = state.strokes;
|
||||||
onLoadStrokesRef.current(state.strokes);
|
onLoadStrokesRef.current(state.strokes);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -71,26 +104,27 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
|||||||
const merged = mergeDrawingChanges(base, desired, current.strokes);
|
const merged = mergeDrawingChanges(base, desired, current.strokes);
|
||||||
acceptState(await appDataApi.putLiveDraw(merged, current.revision));
|
acceptState(await appDataApi.putLiveDraw(merged, current.revision));
|
||||||
}
|
}
|
||||||
updateWidget(workingRef.current);
|
updateWidget(composeWorking());
|
||||||
}, [acceptState]);
|
}, [acceptState, composeWorking]);
|
||||||
|
|
||||||
const enqueueWrite = useCallback((strokes: StrokeSegment[]): Promise<void> => {
|
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;
|
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); });
|
writeQueueRef.current = next.catch(error => { console.error('Failed to persist live drawing:', error); });
|
||||||
return next.finally(() => { pendingWritesRef.current -= 1; });
|
return next.finally(() => { pendingWritesRef.current -= 1; });
|
||||||
}, [writeSnapshot]);
|
}, [writeSnapshot]);
|
||||||
|
|
||||||
const scheduleWrite = useCallback((strokes: StrokeSegment[]) => {
|
// Throttled: reads the latest state at fire time rather than capturing a
|
||||||
workingRef.current = strokes;
|
// freshly built array on every call.
|
||||||
if (!throttleRef.current) {
|
const scheduleWrite = useCallback(() => {
|
||||||
|
if (throttleRef.current) return;
|
||||||
throttleRef.current = setTimeout(() => {
|
throttleRef.current = setTimeout(() => {
|
||||||
throttleRef.current = null;
|
throttleRef.current = null;
|
||||||
void enqueueWrite(workingRef.current);
|
void enqueueWrite(composeWorking());
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}, [enqueueWrite, composeWorking]);
|
||||||
}, [enqueueWrite]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coupleId) return;
|
if (!coupleId) return;
|
||||||
@@ -112,21 +146,45 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
|||||||
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
|
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
|
||||||
}, [coupleId, userId, acceptState]);
|
}, [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[]) => {
|
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
|
||||||
if (throttleRef.current) clearTimeout(throttleRef.current);
|
if (throttleRef.current) clearTimeout(throttleRef.current);
|
||||||
throttleRef.current = null;
|
throttleRef.current = null;
|
||||||
return enqueueWrite(strokes);
|
return enqueueWrite(strokes);
|
||||||
}, [enqueueWrite]);
|
}, [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),
|
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
|
||||||
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
|
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
|
||||||
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_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),
|
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
|
||||||
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
|
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
|
||||||
onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_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} />
|
<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
|
<SwitchItem
|
||||||
icon="calendar-outline"
|
icon="calendar-outline"
|
||||||
label="Weekly Recap"
|
label="Weekly Recap"
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const SwipeScreen = () => {
|
|||||||
packContext={packContext}
|
packContext={packContext}
|
||||||
showBackButton={!!packId || mode === 'pending'}
|
showBackButton={!!packId || mode === 'pending'}
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
|
dailyLimitInfo={dailyLimitInfo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SwipeCardStack
|
<SwipeCardStack
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
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 { DecorativeSeparator, GradientBackground } from "../../../components/ui";
|
||||||
import { Paywall } from "../../../components/paywall";
|
import { Paywall } from "../../../components/paywall";
|
||||||
import { colors, radius, spacing, typography } from "../../../theme";
|
import { colors, radius, spacing, typography } from "../../../theme";
|
||||||
|
import { Events } from "../../../lib/analytics";
|
||||||
import type { DailyLimitInfo } from "../types";
|
import type { DailyLimitInfo } from "../types";
|
||||||
|
|
||||||
interface SwipeDailyLimitStateProps {
|
interface SwipeDailyLimitStateProps {
|
||||||
@@ -27,6 +29,11 @@ export const SwipeDailyLimitState = ({
|
|||||||
}: SwipeDailyLimitStateProps) => {
|
}: SwipeDailyLimitStateProps) => {
|
||||||
const accent = colors.premium.gold;
|
const accent = colors.premium.gold;
|
||||||
|
|
||||||
|
// The meter exists to convert; without these the funnel is unmeasurable.
|
||||||
|
useEffect(() => {
|
||||||
|
Events.paywallShown('daily_limit');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<View style={styles.centerContainer}>
|
<View style={styles.centerContainer}>
|
||||||
@@ -59,7 +66,7 @@ export const SwipeDailyLimitState = ({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Text style={styles.dailyLimitDescription}>
|
<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.
|
Let the anticipation build while you wait.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -100,8 +107,14 @@ export const SwipeDailyLimitState = ({
|
|||||||
|
|
||||||
<Paywall
|
<Paywall
|
||||||
visible={showPaywall}
|
visible={showPaywall}
|
||||||
onClose={onClosePaywall}
|
onClose={() => {
|
||||||
onSuccess={onPaywallSuccess}
|
Events.paywallClosed('daily_limit', 'dismissed');
|
||||||
|
onClosePaywall();
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
Events.paywallClosed('daily_limit', 'purchased');
|
||||||
|
onPaywallSuccess();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import Animated, { FadeIn } from "react-native-reanimated";
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
|
||||||
import { colors, spacing, typography } from "../../../theme";
|
import { colors, spacing, typography } from "../../../theme";
|
||||||
|
import { StreakDisplay } from "../../../components/StreakDisplay";
|
||||||
import { useProgressShimmer } from "../hooks/useProgressShimmer";
|
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 {
|
interface SwipeHeaderProps {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
@@ -14,6 +19,7 @@ interface SwipeHeaderProps {
|
|||||||
packContext: { name: string } | null;
|
packContext: { name: string } | null;
|
||||||
showBackButton: boolean;
|
showBackButton: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
dailyLimitInfo?: DailyLimitInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SwipeHeader = ({
|
export const SwipeHeader = ({
|
||||||
@@ -24,11 +30,20 @@ export const SwipeHeader = ({
|
|||||||
packContext,
|
packContext,
|
||||||
showBackButton,
|
showBackButton,
|
||||||
onBack,
|
onBack,
|
||||||
|
dailyLimitInfo,
|
||||||
}: SwipeHeaderProps) => {
|
}: SwipeHeaderProps) => {
|
||||||
const shimmerStyle = useProgressShimmer();
|
const shimmerStyle = useProgressShimmer();
|
||||||
const total = effectiveTotal || totalQuestions;
|
const total = effectiveTotal || totalQuestions;
|
||||||
const progressPercent = total > 0 ? ((currentIndex + 1) / total) * 100 : 0;
|
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 (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(400)}
|
entering={FadeIn.duration(400)}
|
||||||
@@ -47,6 +62,16 @@ export const SwipeHeader = ({
|
|||||||
<Text style={styles.progressText}>
|
<Text style={styles.progressText}>
|
||||||
{currentIndex + 1} of {total}
|
{currentIndex + 1} of {total}
|
||||||
</Text>
|
</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]}>
|
<View style={[styles.progressBar, styles.progressBarPremium]}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
@@ -66,6 +91,11 @@ export const SwipeHeader = ({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -87,6 +117,10 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
padding: spacing.sm,
|
padding: spacing.sm,
|
||||||
},
|
},
|
||||||
|
streakBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
progressContainer: {
|
progressContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
@@ -100,8 +134,31 @@ const styles = StyleSheet.create({
|
|||||||
progressText: {
|
progressText: {
|
||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
meter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
marginBottom: spacing.sm,
|
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: {
|
progressBar: {
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 6,
|
height: 6,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
import { useAuthStore, usePacksStore } from "../../../store";
|
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
|
||||||
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
||||||
import { apiClient } from "../../../lib/apiClient";
|
import { ApiError, apiClient } from "../../../lib/apiClient";
|
||||||
import { Events } from "../../../lib/analytics";
|
import { Events } from "../../../lib/analytics";
|
||||||
import type { AnswerType } from "../../../types";
|
import type { AnswerType } from "../../../types";
|
||||||
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
|
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
|
||||||
@@ -128,8 +128,16 @@ export const useSwipeScreen = () => {
|
|||||||
filtered = data;
|
filtered = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partner && !packId) {
|
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()]);
|
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsBlocked(false);
|
setIsBlocked(false);
|
||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
@@ -334,8 +342,40 @@ export const useSwipeScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
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) {
|
} 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);
|
console.error("Failed to submit response", error);
|
||||||
setCurrentIndex(prev => prev + 1);
|
setCurrentIndex(prev => prev + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { Alert, Platform, ActionSheetIOS } from 'react-native';
|
import { Alert, Platform, ActionSheetIOS } from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
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';
|
import { uploadMedia } from '../lib/mediaApi';
|
||||||
|
|
||||||
export interface UseAvatarPickerOptions {
|
export interface UseAvatarPickerOptions {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
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() {
|
export function useMediaSaver() {
|
||||||
const [saving, setSaving] = useState(false);
|
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 { getMediaUrl, mediaId } from './mediaApi';
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
// Cache for signed URLs with expiration tracking
|
// Cache for signed URLs with expiration tracking
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from 'react-native';
|
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 { decode } from 'base64-arraybuffer';
|
||||||
import { ApiError, apiClient, authenticatedFetch } from './apiClient';
|
import { ApiError, apiClient, authenticatedFetch } from './apiClient';
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { useMatchStore } from "../store/matchStore";
|
|||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: false,
|
shouldShowAlert: false,
|
||||||
|
shouldShowBanner: false,
|
||||||
|
shouldShowList: false,
|
||||||
shouldPlaySound: false,
|
shouldPlaySound: false,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -27,6 +29,21 @@ export interface NotificationData {
|
|||||||
count?: number;
|
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"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request notification permissions and get Expo push token.
|
* Request notification permissions and get Expo push token.
|
||||||
@@ -219,9 +236,9 @@ export async function checkAndRegisterPushToken(userId: string): Promise<boolean
|
|||||||
export function handleNotificationResponse(
|
export function handleNotificationResponse(
|
||||||
response: Notifications.NotificationResponse
|
response: Notifications.NotificationResponse
|
||||||
): void {
|
): void {
|
||||||
const data = response.notification.request.content.data as NotificationData;
|
const data = response.notification.request.content.data;
|
||||||
|
|
||||||
if (!data) {
|
if (!isNotificationData(data)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface ProfileUpdate {
|
|||||||
onboarding_version?: number;
|
onboarding_version?: number;
|
||||||
public_key_jwk?: Record<string, unknown> | null;
|
public_key_jwk?: Record<string, unknown> | null;
|
||||||
push_token?: string | null;
|
push_token?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>;
|
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 { authClient } from "../lib/authClient";
|
||||||
import { coupleApi } from "../lib/coupleApi";
|
import { coupleApi } from "../lib/coupleApi";
|
||||||
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
||||||
|
import { syncTimezone } from "../lib/reportedTimezone";
|
||||||
import { Events } from "../lib/analytics";
|
import { Events } from "../lib/analytics";
|
||||||
import type { Profile, Couple } from "@/types";
|
import type { Profile, Couple } from "@/types";
|
||||||
|
|
||||||
@@ -200,6 +201,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// Silently fail - this is not critical
|
// Silently fail - this is not critical
|
||||||
console.error("Failed to update last_active_at:", error);
|
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;
|
pack_changes_enabled: boolean;
|
||||||
new_packs_enabled: boolean;
|
new_packs_enabled: boolean;
|
||||||
streak_milestones_enabled: boolean;
|
streak_milestones_enabled: boolean;
|
||||||
|
streak_reminders_enabled: boolean;
|
||||||
weekly_summary_enabled: boolean;
|
weekly_summary_enabled: boolean;
|
||||||
unpaired_reminders_enabled: boolean;
|
unpaired_reminders_enabled: boolean;
|
||||||
catchup_reminders_enabled: boolean;
|
catchup_reminders_enabled: boolean;
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { create } from "zustand";
|
|||||||
import { apiClient } from "../lib/apiClient";
|
import { apiClient } from "../lib/apiClient";
|
||||||
import { useAuthStore } from "./authStore";
|
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 {
|
export interface CoupleStreak {
|
||||||
couple_id: string;
|
couple_id: string;
|
||||||
current_streak: number;
|
current_streak: number;
|
||||||
longest_streak: number;
|
longest_streak: number;
|
||||||
last_active_date: string | null;
|
last_active_date: string | null;
|
||||||
user1_answered_today: boolean;
|
last_completed_date: string | null;
|
||||||
user2_answered_today: boolean;
|
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;
|
streak_celebrated_at: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 () => ''),
|
readAsStringAsync: jest.fn(async () => ''),
|
||||||
EncodingType: {
|
EncodingType: {
|
||||||
Base64: 'base64',
|
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', () => ({
|
jest.mock('react-native-compressor', () => ({
|
||||||
Video: {
|
Video: {
|
||||||
compress: jest.fn(async (uri: string) => uri),
|
compress: jest.fn(async (uri: string) => uri),
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "~19.1.7",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "14.2.35",
|
"eslint-config-next": "14.2.35",
|
||||||
|
|||||||
@@ -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
|
# Dares Feature - Architecture & Implementation Plan
|
||||||
|
|
||||||
|
> **SUPERSEDED.** This is the original 2026-01 plan, kept for its competitor analysis
|
||||||
|
> and decision history. It describes a Supabase Edge Function + RLS architecture that
|
||||||
|
> the product no longer uses: mobile talks only to the standalone API. For the shipped
|
||||||
|
> contract see [Dares loop](dares-loop.md).
|
||||||
|
|
||||||
> **Status**: Phase 1 Complete (Database + Admin Portal)
|
> **Status**: Phase 1 Complete (Database + Admin Portal)
|
||||||
> **Created**: 2026-01-08
|
> **Created**: 2026-01-08
|
||||||
> **Updated**: 2026-01-08
|
> **Updated**: 2026-01-08
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Dares loop
|
||||||
|
|
||||||
|
Maintained reference for the dares feature: a couple sends each other challenges,
|
||||||
|
each state change notifies the other partner, and the pair keeps a shared count of
|
||||||
|
what they have finished together.
|
||||||
|
|
||||||
|
The asynchronous shape is the point. Every transition below is a push notification
|
||||||
|
to whichever partner is not holding the app.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---|---|
|
||||||
|
| Schema, triggers, review parity | `apps/api/drizzle/0013_dares_loop.sql` |
|
||||||
|
| HTTP contract | `apps/api/src/domains/dares/routes.ts` |
|
||||||
|
| Domain rules and entitlement | `apps/api/src/domains/dares/repository.ts` |
|
||||||
|
| Expiry sweep | `apps/api/src/domains/operations/repository.ts` (`produce`) |
|
||||||
|
| Mobile feature | `apps/mobile/src/features/dares/` |
|
||||||
|
| Catalogue authoring | `apps/admin/src/pages/content/DarePacksPage.tsx`, `DaresPage.tsx` |
|
||||||
|
|
||||||
|
## State machine
|
||||||
|
|
||||||
|
```
|
||||||
|
┌── decline ──► declined
|
||||||
|
pending ──accept──┤
|
||||||
|
│ └── accept ───► active ──submit──► submitted ──confirm──► completed
|
||||||
|
│ │ │
|
||||||
|
└──────────────── cancel ─────────┴───────────────────┘──► cancelled
|
||||||
|
(sender only, before completion)
|
||||||
|
|
||||||
|
any open status + expires_at in the past ──► expired (worker sweep)
|
||||||
|
```
|
||||||
|
|
||||||
|
Actor rules are enforced server-side, not by hiding buttons:
|
||||||
|
|
||||||
|
| Transition | Who | Legal from |
|
||||||
|
|---|---|---|
|
||||||
|
| accept / decline | recipient | `pending` |
|
||||||
|
| submit | recipient | `active` |
|
||||||
|
| complete | sender | `active`, `submitted` |
|
||||||
|
| cancel | sender | `pending`, `active`, `submitted` |
|
||||||
|
|
||||||
|
The recipient performs the dare, so the recipient reports it done (`submitted`) and
|
||||||
|
the sender confirms (`completed`). An illegal transition returns `409
|
||||||
|
invalid_transition`; acting on someone else's dare returns `403 not_permitted`;
|
||||||
|
a dare outside your couple returns `404`, never a hint that it exists.
|
||||||
|
|
||||||
|
## Entitlement
|
||||||
|
|
||||||
|
Premium is couple-shared — either partner's subscription unlocks both, matching the
|
||||||
|
answers domain.
|
||||||
|
|
||||||
|
| Tier | Can do |
|
||||||
|
|---|---|
|
||||||
|
| Anyone, always | Receive, accept, decline, submit, complete, cancel |
|
||||||
|
| Free sender | Free packs, `FREE_WEEKLY_SEND_LIMIT` sends per rolling 7 days |
|
||||||
|
| Premium | All packs, unlimited sends, custom dares |
|
||||||
|
|
||||||
|
**Receiving is never gated.** A paywall between a partner and the dare they were just
|
||||||
|
notified about would break the loop the feature exists to create. The quota is counted
|
||||||
|
per sender over a rolling window, and counts every send — cancelling does not refund it.
|
||||||
|
|
||||||
|
## Content safety
|
||||||
|
|
||||||
|
`dare_packs` and `dares` carry `content_status` (`unreviewed` / `allowed` / `archived`),
|
||||||
|
matching the legacy catalogue contract. The catalogue, the pack detail endpoint, and the
|
||||||
|
send path each independently require `allowed`, so a stale client cannot send unreviewed
|
||||||
|
content. New content defaults to `unreviewed` and is therefore invisible until an
|
||||||
|
administrator reviews it.
|
||||||
|
|
||||||
|
Push copy is deliberately generic — `"Your partner sent you a dare"`, never the dare
|
||||||
|
text. Store compliance forbids question, dare, response, or message text in a
|
||||||
|
notification preview, and `dares.integration.test.ts` asserts no payload leaks it.
|
||||||
|
|
||||||
|
## History durability
|
||||||
|
|
||||||
|
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
|
||||||
|
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
|
||||||
|
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
|
||||||
|
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
|
||||||
|
with a check violation.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
`queue_dare_operations` (trigger on `sent_dares`) writes one `operations_outbox` row per
|
||||||
|
transition with dedupe key `dare:<id>:<event>:<recipient>`, gated on
|
||||||
|
`notification_preferences.dares_enabled`. Delivery, retry, and Expo ticket handling are
|
||||||
|
the existing worker's job — see `apps/api/src/domains/operations/README.md`.
|
||||||
|
|
||||||
|
`expired` notifies both partners; every other transition notifies the one partner who
|
||||||
|
did not cause it.
|
||||||
|
|
||||||
|
## Expiry
|
||||||
|
|
||||||
|
The worker's `produce` pass expires any dare whose `expires_at` has passed while still
|
||||||
|
`pending`, `active`, or `submitted`, reported as `daresExpired` in `ProducerSummary`.
|
||||||
|
Pending is included deliberately: an invitation the recipient never opened would
|
||||||
|
otherwise stay open forever.
|
||||||
|
|
||||||
|
Durations offered to the client are fixed presets (1, 6, 12, 24, 72, 168 hours, or none)
|
||||||
|
and validated server-side.
|
||||||
|
|
||||||
|
## HTTP contract
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/v1/dares/packs` | Catalogue plus the caller's entitlement |
|
||||||
|
| GET | `/v1/dares/packs/:packId/dares` | `402` when the pack is premium and the couple is not |
|
||||||
|
| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller |
|
||||||
|
| GET | `/v1/dares/stats` | Shared scoreboard |
|
||||||
|
| GET | `/v1/dares/durations` | Preset list the send sheet renders |
|
||||||
|
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both |
|
||||||
|
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
|
||||||
|
| POST | `/v1/dares/:dareId/submit` | Recipient reports done |
|
||||||
|
| POST | `/v1/dares/:dareId/complete` | Sender confirms |
|
||||||
|
| POST | `/v1/dares/:dareId/cancel` | Sender withdraws |
|
||||||
|
|
||||||
|
The actor always comes from the bearer identity; ownership fields in a request body are
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
|
||||||
|
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
|
||||||
|
channel), and opens the paywall both proactively and in reaction to a `402` — a client
|
||||||
|
whose entitlement went stale still lands on the paywall rather than an error.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `apps/api/test/dares.integration.test.ts` — real PostgreSQL: entitlement, quota,
|
||||||
|
transitions, couple isolation, notification fan-out, no-text-in-push, snapshot durability
|
||||||
|
- `apps/api/test/dares.routes.test.ts` — HTTP contract and error mapping
|
||||||
|
- `apps/mobile/src/__tests__/hooks/useDares.test.ts` — loading, actions, paywall routing
|
||||||
|
|
||||||
|
## Not in this version
|
||||||
|
|
||||||
|
Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox
|
||||||
|
split from the main match chat was cut in favour of status transitions plus the sender's
|
||||||
|
note. Photo proof, random-dare, and streaks remain unbuilt.
|
||||||
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