diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 078aa3b..4878774 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -1,6 +1,7 @@ name: Verify on: + workflow_dispatch: pull_request: push: branches: [staging, main] @@ -11,10 +12,10 @@ concurrency: jobs: branch-policy: - if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Enforce promotion flow + if: github.event_name == 'pull_request' env: BASE_BRANCH: ${{ github.base_ref }} HEAD_BRANCH: ${{ github.head_ref }} @@ -31,6 +32,9 @@ jobs: echo "Feature pull requests target staging; promotions target main" >&2 exit 1 fi + - name: Confirm direct verification + if: github.event_name != 'pull_request' + run: echo "Direct branch verification requested for $GITHUB_SHA" static: runs-on: ubuntu-latest diff --git a/.nvmrc b/.nvmrc index d5a1596..f5b3751 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.10.0 +20.19.4 diff --git a/apps/admin/package.json b/apps/admin/package.json index 6dcd995..7680d32 100755 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -44,8 +44,8 @@ "tailwind-merge": "^2.2.0" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "~19.1.10", + "@types/react-dom": "~19.1.7", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.16", "postcss": "^8.4.32", diff --git a/apps/admin/src/pages/AppSettingsPage.tsx b/apps/admin/src/pages/AppSettingsPage.tsx index fc9db3c..af9e769 100644 --- a/apps/admin/src/pages/AppSettingsPage.tsx +++ b/apps/admin/src/pages/AppSettingsPage.tsx @@ -187,7 +187,7 @@ export function AppSettingsPage() { Daily Response Limit (Free Users) - 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 @@ -203,14 +203,19 @@ export function AppSettingsPage() { className="max-w-[200px]" />

- 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.

Set to 0 to disable daily limits entirely.

- 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. +

+

+ Does not count toward the limit: editing an existing answer, or + answering a question the partner had already answered (catching up is always free).

diff --git a/apps/api/drizzle/0013_dares_loop.sql b/apps/api/drizzle/0013_dares_loop.sql new file mode 100644 index 0000000..f75c178 --- /dev/null +++ b/apps/api/drizzle/0013_dares_loop.sql @@ -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'; diff --git a/apps/api/drizzle/0014_daily_limit_local_reset.sql b/apps/api/drizzle/0014_daily_limit_local_reset.sql new file mode 100644 index 0000000..d5488c6 --- /dev/null +++ b/apps/api/drizzle/0014_daily_limit_local_reset.sql @@ -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.'; diff --git a/apps/api/drizzle/0015_daily_limit_response_index.sql b/apps/api/drizzle/0015_daily_limit_response_index.sql new file mode 100644 index 0000000..38ffe7a --- /dev/null +++ b/apps/api/drizzle/0015_daily_limit_response_index.sql @@ -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); diff --git a/apps/api/drizzle/0016_couple_streak_locality.sql b/apps/api/drizzle/0016_couple_streak_locality.sql new file mode 100644 index 0000000..bc327fd --- /dev/null +++ b/apps/api/drizzle/0016_couple_streak_locality.sql @@ -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"(); diff --git a/apps/api/drizzle/0017_streak_reminder_preference.sql b/apps/api/drizzle/0017_streak_reminder_preference.sql new file mode 100644 index 0000000..3adf996 --- /dev/null +++ b/apps/api/drizzle/0017_streak_reminder_preference.sql @@ -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; diff --git a/apps/api/drizzle/0018_cutover_source_parity.sql b/apps/api/drizzle/0018_cutover_source_parity.sql new file mode 100644 index 0000000..8675844 --- /dev/null +++ b/apps/api/drizzle/0018_cutover_source_parity.sql @@ -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() +); diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 0430787..57d97a9 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -92,6 +92,48 @@ "when": 1787908500000, "tag": "0012_catalogue_field_parity", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787909400000, + "tag": "0013_dares_loop", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1787994900000, + "tag": "0014_daily_limit_local_reset", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1787994960000, + "tag": "0015_daily_limit_response_index", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1788081360000, + "tag": "0016_couple_streak_locality", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1788081420000, + "tag": "0017_streak_reminder_preference", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1788081480000, + "tag": "0018_cutover_source_parity", + "breakpoints": true } ] } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 584fda6..55f6ac8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js'; import { registerAnswerRoutes } from './domains/answers/routes.js'; import type { ChatRepository } from './domains/chat/repository.js'; import { registerChatRoutes } from './domains/chat/routes.js'; +import type { DaresRepository } from './domains/dares/repository.js'; +import { registerDareRoutes } from './domains/dares/routes.js'; import { registerCoupleRoutes } from './domains/couples/routes.js'; import type { CoupleService } from './domains/couples/service.js'; import type { PacksRepository } from './domains/packs/repository.js'; @@ -35,6 +37,7 @@ export interface AppDependencies { packsRepository?: PacksRepository; answersRepository?: AnswersRepository; chatRepository?: ChatRepository; + daresRepository?: DaresRepository; profileSettingsRepository?: ProfileSettingsRepository; accountOperationsService?: AccountOperationsService; mediaRepository?: MediaRepository; @@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository); if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository); if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository); + if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository); if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository); if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService); if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 1db150a..054fc47 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', { email: text('email'), avatarUrl: text('avatar_url'), pushToken: text('push_token'), + timezone: text('timezone'), isPremium: boolean('is_premium').notNull().default(false), coupleId: uuid('couple_id'), gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(), diff --git a/apps/api/src/domains/answers/repository.ts b/apps/api/src/domains/answers/repository.ts index 96bf772..443970c 100644 --- a/apps/api/src/domains/answers/repository.ts +++ b/apps/api/src/domains/answers/repository.ts @@ -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; 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 | 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; markSeen(userId: string, ids: string[]): Promise; archive(userId: string, matchId: string, archived: boolean): Promise; - streak(userId: string): Promise | null>; + streak(userId: string): Promise; close(): Promise; } export class PostgresAnswersRepository implements AnswersRepository { private readonly pool: Pool; + private zones: Promise> | null = null; constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } private async context(client: Pool | PoolClient, userId: string): Promise { @@ -41,7 +69,7 @@ export class PostgresAnswersRepository implements AnswersRepository { const p = partner.rows[0]; const config = await this.config(); if (!packId && p && (await this.answerGap(userId)).is_blocked) return []; - if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return []; + if (!ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return []; const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null; const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity; const result = await this.pool.query(` @@ -117,7 +145,10 @@ export class PostgresAnswersRepository implements AnswersRepository { await this.assertEligible(client,userId,ctx,q); const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]); if (!existing.rowCount) { - const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at }); + const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]); + if (!catchingUp.rowCount) { + const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at }); + } } const response = await client.query(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5) on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]); @@ -157,12 +188,30 @@ export class PostgresAnswersRepository implements AnswersRepository { return result.rows[0]; } + /** + * The streak day is the couple's shared local day, not the server's. Both partners have + * to land inside the same calendar date for the day to count, so the zone belongs to the + * couple rather than to whoever happens to be answering: the first reported zone in member + * id order, which is the same ordering that decides user1/user2, falling back to UTC when + * neither partner has reported one. Without this a couple in a western zone loses the + * streak at UTC midnight while it is still the same evening for them. + */ + private async coupleZone(client:Pool|PoolClient,reported:(string|null)[]):Promise{ + const zone=reported.find(value=>value); + return zone&&(await this.timezoneNames(client)).has(zone)?zone:'UTC'; + } + + private async streakDay(client:Pool|PoolClient,zone:string){ + const r=await client.query<{today:string;yesterday:string}>("select (now() at time zone $1::text)::date::text today,((now() at time zone $1::text)::date-1)::text yesterday",[zone]); + return r.rows[0]; + } + private async touchStreak(client:PoolClient,userId:string,coupleId:string){ - const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return; + const users=await client.query<{id:string;timezone:string|null}>('select id,timezone from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return; const user1=users.rows[0].id===userId; await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]); const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]); - const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0]; + const row=locked.rows[0]; const {today,yesterday}=await this.streakDay(client,await this.coupleZone(client,users.rows.map(member=>member.timezone))); let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false; if(user1)first=true;else second=true; let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date; @@ -190,13 +239,61 @@ export class PostgresAnswersRepository implements AnswersRepository { } async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);} async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);} - async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;} + async streak(userId:string):Promise{ + const ctx=await this.context(this.pool,userId); + const r=await this.pool.query(`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(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];} + private timezoneNames(client:Pool|PoolClient){ + // pg_timezone_names is a ~600-row function scan costing ~12ms. The list is static for the + // life of the process, so load it once instead of on every daily-limit check. + this.zones??=client.query<{name:string}>('select name from pg_timezone_names') + .then(r=>new Set(r.rows.map(row=>row.name))) + .catch(cause=>{this.zones=null;throw cause;}); + return this.zones; + } private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);} private async dailyLimitWith(client:Pool|PoolClient,userId:string){ - const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false}; - const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false}; - const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit}; + const cfg=await this.config(client);const unlimited={responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false}; + if(cfg.daily_response_limit===0)return unlimited; + // Premium is shared across a couple: either partner's subscription lifts the cap for both. + const profile=await client.query<{premium:boolean;zone:string|null}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium,p.timezone zone from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium,p.timezone',[userId]); + if(profile.rows[0]?.premium)return unlimited; + // The window is the user's local calendar day; an unreported or unrecognised zone falls + // back to UTC. Validating in JS keeps an unknown zone from erroring the query without + // paying for a pg_timezone_names scan inside submit()'s locked transaction. + const reported=profile.rows[0]?.zone; + const zone=reported&&(await this.timezoneNames(client)).has(reported)?reported:'UTC'; + const r=await client.query<{count:number;reset_at:string}>(`select count(*)::int count,((date_trunc('day',now() at time zone $2::text)+interval '1 day') at time zone $2::text) reset_at from responses r where r.user_id=$1 and r.created_at>=(date_trunc('day',now() at time zone $2::text) at time zone $2::text) and not exists(${catchUpExemption})`,[userId,zone]); + const count=r.rows[0]?.count??0; + return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:r.rows[0]?new Date(r.rows[0].reset_at).toISOString():null,is_blocked:count>=cfg.daily_response_limit}; } private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){ const partner=await client.query('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]; diff --git a/apps/api/src/domains/dares/repository.ts b/apps/api/src/domains/dares/repository.ts new file mode 100644 index 0000000..c3b04ab --- /dev/null +++ b/apps/api/src/domains/dares/repository.ts @@ -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; + listPackDares(userId: string, packId: string): Promise; + listDares(userId: string, filter: 'active' | 'history'): Promise; + send(userId: string, input: SendDareInput): Promise; + respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise; + submit(userId: string, dareId: string): Promise; + complete(userId: string, dareId: string): Promise; + cancel(userId: string, dareId: string): Promise; + stats(userId: string): Promise; + close(): Promise; +} + +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(pool: Pool, operation: (client: PoolClient) => Promise): Promise { + 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 { + const result = await this.pool.query( + `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 { + 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 { + 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 { + 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( + `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 { + 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( + `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 { + const context = await this.context(userId); + if (!context.couple_id) return []; + const result = await this.pool.query( + `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 { + 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( + `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 { + const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id'; + const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : ''; + const result = await this.pool.query( + `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 { + 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 { + return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at'); + } + + async complete(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at'); + } + + async cancel(userId: string, dareId: string): Promise { + return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null); + } + + async stats(userId: string): Promise { + 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>( + `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 { + await this.pool.end(); + } +} diff --git a/apps/api/src/domains/dares/routes.ts b/apps/api/src/domains/dares/routes.ts new file mode 100644 index 0000000..970b83d --- /dev/null +++ b/apps/api/src/domains/dares/routes.ts @@ -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(operation: () => Promise) { + 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); + }); + } +} diff --git a/apps/api/src/domains/dares/schema.ts b/apps/api/src/domains/dares/schema.ts new file mode 100644 index 0000000..1727489 --- /dev/null +++ b/apps/api/src/domains/dares/schema.ts @@ -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)]); diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index bccf0f7..61baa58 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository { async produce(now: Date, limit=100): Promise { return transaction(this.pool, async (client) => { const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked"); - const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0}; + const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0}; if (!lock.rows[0]?.locked) return empty; const released=await client.query<{id:string;name:string}>( @@ -138,12 +138,77 @@ export class PostgresOperationsRepository implements OperationsRepository { from eligible e on conflict(dedupe_key) do nothing returning recipient_id ) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]); + // Expiring pending dares too: the original design only swept `active`, so an + // invitation the recipient never opened stayed open forever. + const expiredDares=await client.query( + `with due as ( + select id from sent_dares + where expires_at is not null and expires_at<=$1 + and status in ('pending','active','submitted') + order by expires_at for update skip locked limit $2 + ) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]); + const catchup=await this.produceCatchups(client,now,limit); + const streakReminders=await this.produceStreakReminders(client,now); return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0, - packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup}; + packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0, + catchupReminders:catchup,streakReminders,daresExpired:expiredDares.rowCount??0}; }); } + /** + * A shared streak only changes behaviour if somebody hears that it is open before the day + * closes, so this fires in the couple's own late evening rather than on a server clock. + * Only the partner who still owes an answer is notified, and the copy names the other + * partner as an invitation rather than as a debt: guilt between partners is the failure + * mode for this mechanic, not the goal. + */ + private async produceStreakReminders(client: PoolClient, now: Date): Promise { + 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.idme.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 { await client.query( `with behind as ( @@ -202,6 +267,7 @@ export class PostgresOperationsRepository implements OperationsRepository { when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end + when 'streak_reminder' then case when coalesce(np.streak_reminders_enabled,true) then p.push_token end when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end diff --git a/apps/api/src/domains/operations/types.ts b/apps/api/src/domains/operations/types.ts index 9e91b8e..b889e14 100644 --- a/apps/api/src/domains/operations/types.ts +++ b/apps/api/src/domains/operations/types.ts @@ -48,4 +48,6 @@ export interface ProducerSummary { weeklySummaries: number; unpairedReminders: number; catchupReminders: number; + streakReminders: number; + daresExpired: number; } diff --git a/apps/api/src/domains/packs/schema.ts b/apps/api/src/domains/packs/schema.ts index 08eab1d..f8a7718 100644 --- a/apps/api/src/domains/packs/schema.ts +++ b/apps/api/src/domains/packs/schema.ts @@ -10,6 +10,10 @@ export const categories = pgTable('categories', { color: text('color'), sortOrder: integer('sort_order').notNull().default(0), isPublic: boolean('is_public').notNull().default(true), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); @@ -26,6 +30,10 @@ export const questionPacks = pgTable('question_packs', { avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }), sortOrder: integer('sort_order').notNull().default(0), categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]); @@ -42,6 +50,10 @@ export const questions = pgTable('questions', { config: jsonb('config').notNull().default({}), deletedAt: timestamp('deleted_at', { withTimezone: true }), inverseOf: uuid('inverse_of'), + contentStatus: text('content_status').notNull().default('unreviewed'), + contentReviewReason: text('content_review_reason'), + contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), + contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('questions_pack_id_idx').on(table.packId)]); @@ -64,3 +76,17 @@ export const couplePacks = pgTable('couple_packs', { enabled: boolean('enabled').notNull().default(true), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]); + +export const contentReviews = pgTable('content_reviews', { + id: uuid('id').primaryKey().defaultRandom(), + entityType: text('entity_type').notNull(), + entityId: uuid('entity_id').notNull(), + previousStatus: text('previous_status').notNull(), + newStatus: text('new_status').notNull(), + reason: text('reason').notNull(), + changedBy: uuid('changed_by').references(() => profiles.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + index('content_reviews_entity_idx').on(table.entityType, table.entityId, table.createdAt), + index('content_reviews_created_at_idx').on(table.createdAt), +]); diff --git a/apps/api/src/domains/profile-settings/repository.ts b/apps/api/src/domains/profile-settings/repository.ts index 00c042c..81dd59e 100644 --- a/apps/api/src/domains/profile-settings/repository.ts +++ b/apps/api/src/domains/profile-settings/repository.ts @@ -16,9 +16,11 @@ interface PreferencesRow extends QueryResultRow { pack_changes_enabled: boolean; new_packs_enabled: boolean; streak_milestones_enabled: boolean; + streak_reminders_enabled: boolean; weekly_summary_enabled: boolean; unpaired_reminders_enabled: boolean; catchup_reminders_enabled: boolean; + dares_enabled: boolean; created_at: Date; updated_at: Date; } @@ -49,6 +51,7 @@ const profileColumns: Record = { onboarding_version: 'onboarding_version', public_key_jwk: 'public_key_jwk', push_token: 'push_token', + timezone: 'timezone', }; function toPreferences(row: PreferencesRow): NotificationPreferences { @@ -61,9 +64,11 @@ function toPreferences(row: PreferencesRow): NotificationPreferences { pack_changes_enabled: row.pack_changes_enabled, new_packs_enabled: row.new_packs_enabled, streak_milestones_enabled: row.streak_milestones_enabled, + streak_reminders_enabled: row.streak_reminders_enabled, weekly_summary_enabled: row.weekly_summary_enabled, unpaired_reminders_enabled: row.unpaired_reminders_enabled, catchup_reminders_enabled: row.catchup_reminders_enabled, + dares_enabled: row.dares_enabled, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), }; diff --git a/apps/api/src/domains/profile-settings/routes.ts b/apps/api/src/domains/profile-settings/routes.ts index 021b4fb..5c85734 100644 --- a/apps/api/src/domains/profile-settings/routes.ts +++ b/apps/api/src/domains/profile-settings/routes.ts @@ -18,6 +18,8 @@ const profileUpdateSchema = z.object({ onboarding_version: z.number().int().min(0).optional(), public_key_jwk: z.record(z.unknown()).nullable().optional(), push_token: z.string().trim().min(1).max(512).nullable().optional(), + // IANA zone from the client; the daily response limit buckets on the user's local day. + timezone: z.string().trim().min(1).max(64).nullable().optional(), }).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required'); const preferenceSchema = z.object({ diff --git a/apps/api/src/domains/profile-settings/types.ts b/apps/api/src/domains/profile-settings/types.ts index 69d24b8..cb653a1 100644 --- a/apps/api/src/domains/profile-settings/types.ts +++ b/apps/api/src/domains/profile-settings/types.ts @@ -6,9 +6,11 @@ export const notificationPreferenceKeys = [ 'pack_changes_enabled', 'new_packs_enabled', 'streak_milestones_enabled', + 'streak_reminders_enabled', 'weekly_summary_enabled', 'unpaired_reminders_enabled', 'catchup_reminders_enabled', + 'dares_enabled', ] as const; export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number]; @@ -30,6 +32,7 @@ export interface ProfileUpdate { onboarding_version?: number; public_key_jwk?: Record | null; push_token?: string | null; + timezone?: string | null; } export interface FeedbackSubmission { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2918a92..d85eb2f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -17,6 +17,7 @@ import { } from './domains/account-operations/index.js'; import { PostgresAnswersRepository } from './domains/answers/repository.js'; import { PostgresChatRepository } from './domains/chat/repository.js'; +import { PostgresDaresRepository } from './domains/dares/repository.js'; import { PostgresCoupleRepository } from './domains/couples/repository.js'; import { CoupleService } from './domains/couples/service.js'; import { PostgresPacksRepository } from './domains/packs/repository.js'; @@ -30,6 +31,7 @@ const coupleRepository = new PostgresCoupleRepository(config.databaseUrl); const packsRepository = new PostgresPacksRepository(config.databaseUrl); const answersRepository = new PostgresAnswersRepository(config.databaseUrl); const chatRepository = new PostgresChatRepository(config.databaseUrl); +const daresRepository = new PostgresDaresRepository(config.databaseUrl); const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl); const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl); const accountOperationsService = new AccountOperationsService( @@ -59,6 +61,7 @@ const app = createApp({ packsRepository, answersRepository, chatRepository, + daresRepository, profileSettingsRepository, accountOperationsService, mediaRepository, @@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise { packsRepository.close(), answersRepository.close(), chatRepository.close(), + daresRepository.close(), profileSettingsRepository.close(), accountOperationsRepository.close(), mediaRepository.close(), diff --git a/apps/api/src/migration/database.ts b/apps/api/src/migration/database.ts index 9129adf..ab3a056 100644 --- a/apps/api/src/migration/database.ts +++ b/apps/api/src/migration/database.ts @@ -1,14 +1,31 @@ import type { Pool, PoolClient } from 'pg'; import type { DatabaseMigrationOptions, TableResult } from './types.js'; -interface TableSpec { name: string; key: string[]; replace?: boolean } +interface TableSpec { + name: string; + key: string[]; + replace?: boolean; + /** Target-column to legacy source-column rename. */ + columnMap?: Record; + /** Target-column SQL expressions for legacy rows that need a derived value. */ + sourceExpressions?: Record; + /** Columns populated after all rows exist, for self-referential foreign keys. */ + deferredColumns?: string[]; + /** Keep a newer standalone-backend row when the same key exists in both systems. */ + preserveNewer?: boolean; + /** Singleton runtime configuration is owned by the live target during an initial merge. */ + preserveExistingOnInitial?: boolean; +} export const TABLES: TableSpec[] = [ - { name: 'couples', key: ['id'] }, { name: 'profiles', key: ['id'] }, + { name: 'couples', key: ['id'], sourceExpressions: { invite_code: 'upper("couples"."invite_code")' } }, + { name: 'profiles', key: ['id'], preserveNewer: true }, { name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] }, { name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] }, { name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] }, - { name: 'questions', key: ['id'] }, { name: 'app_config', key: ['id'], replace: true }, - { name: 'ai_config', key: ['id'], replace: true }, + { name: 'questions', key: ['id'], deferredColumns: ['inverse_of'] }, + { name: 'content_reviews', key: ['id'] }, + { name: 'app_config', key: ['id'], replace: true, preserveExistingOnInitial: true }, + { name: 'ai_config', key: ['id'], replace: true, preserveExistingOnInitial: true }, { name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] }, { name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] }, { name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] }, @@ -18,8 +35,21 @@ export const TABLES: TableSpec[] = [ { name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] }, { name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] }, { name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] }, - { name: 'feature_interests', key: ['user_id', 'feature'] }, { name: 'live_draw_sessions', key: ['couple_id'] }, - { name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] }, + { name: 'feature_interests', key: ['user_id', 'feature'], columnMap: { feature: 'feature_name' } }, + { + name: 'live_draw_sessions', key: ['couple_id'], + sourceExpressions: { + updated_by: '(select p.id from profiles p where p.couple_id="live_draw_sessions"."couple_id" order by p.created_at,p.id limit 1)', + }, + }, + { name: 'catchup_reminder_tracking', key: ['user_id'] }, + { + name: 'audit_logs', key: ['id'], + sourceExpressions: { + actor_user_id: '"audit_logs"."admin_user_id"', + admin_user_id: '(select au.id from admin_users au where au.user_id="audit_logs"."admin_user_id" limit 1)', + }, + }, ]; const quote = (value: string) => `"${value.replaceAll('"', '""')}"`; @@ -27,18 +57,42 @@ async function columns(pool: Pool | PoolClient, table: string): Promise(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]); return result.rows.map((row) => row.column_name); } +async function jsonColumns(pool: Pool | PoolClient, table: string): Promise> { + 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 { 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[]): Promise { - if (spec.replace) await client.query(`delete from ${quote(spec.name)}`); +async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record[], jsonNames: Set, finalSync: boolean): Promise { + if (spec.preserveExistingOnInitial && !finalSync && await count(client, spec.name)) return 0; + if (spec.replace && finalSync) await client.query(`delete from ${quote(spec.name)}`); if (!rows.length) return 0; - const update = names.filter((name) => !spec.key.includes(name)); + const deferred = new Set(spec.deferredColumns ?? []); + const immediateNames = names.filter((name) => !deferred.has(name)); + const update = immediateNames.filter((name) => !spec.key.includes(name)); const conflict = spec.key.map(quote).join(','); - const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}` : 'do nothing'; + const freshness = spec.preserveNewer && names.includes('updated_at') + ? ` where ${quote(spec.name)}.${quote('updated_at')} is null or (excluded.${quote('updated_at')} is not null and excluded.${quote('updated_at')}>${quote(spec.name)}.${quote('updated_at')})` + : ''; + const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}${freshness}` : 'do nothing'; for (const row of rows) { - await client.query(`insert into ${quote(spec.name)} (${names.map(quote).join(',')}) values (${names.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, names.map((name) => row[name])); + await client.query( + `insert into ${quote(spec.name)} (${immediateNames.map(quote).join(',')}) values (${immediateNames.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, + immediateNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]), + ); + } + if (deferred.size) { + const deferredNames = names.filter((name) => deferred.has(name)); + for (const row of rows) { + const values = deferredNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]); + const whereValues = spec.key.map((name) => row[name]); + await client.query( + `update ${quote(spec.name)} set ${deferredNames.map((name, index) => `${quote(name)}=$${index + 1}`).join(',')} where ${spec.key.map((name, index) => `${quote(name)}=$${deferredNames.length + index + 1}`).join(' and ')}`, + [...values, ...whereValues], + ); + } } return rows.length; } @@ -59,16 +113,24 @@ async function pruneRows(client: PoolClient, spec: TableSpec, sourceRows: Record export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise { const results: TableResult[] = []; for (const spec of TABLES) { - const [sourceColumns, targetColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name)]); + const [sourceColumns, targetColumns, targetJsonColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name), jsonColumns(target, spec.name)]); if (!sourceColumns.length || !targetColumns.length) { results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' }); continue; } - const common = targetColumns.filter((name) => sourceColumns.includes(name)); - const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name)); + const sourceName = (targetName: string) => spec.columnMap?.[targetName] ?? targetName; + const common = targetColumns.filter((name) => sourceColumns.includes(sourceName(name)) || Boolean(spec.sourceExpressions?.[name])); + const consumedSourceColumns = new Set(common.map(sourceName).filter((name) => sourceColumns.includes(name))); + const sourceOnly = sourceColumns.filter((name) => !consumedSourceColumns.has(name)); if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`); - const sourceRows = (await source.query>(`select ${common.map(quote).join(',')} from ${quote(spec.name)}`)).rows; - let importedRows = 0; let prunedRows = 0; + const selections = common.map((targetName) => { + const expression = spec.sourceExpressions?.[targetName] ?? quote(sourceName(targetName)); + return sourceName(targetName) === targetName && !spec.sourceExpressions?.[targetName] + ? expression + : `${expression} as ${quote(targetName)}`; + }); + const sourceRows = (await source.query>(`select ${selections.join(',')} from ${quote(spec.name)}`)).rows; + let importedRows = 0; let prunedRows = 0; let reconciledProfileCouples = 0; const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name); if (!options.dryRun && !skip) { const client = await target.connect(); @@ -77,7 +139,19 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab // Historical imports must not enqueue user notifications, classification, // Discord events, or other operational side effects. await client.query("set local sauci.suppress_operations = 'on'"); - importedRows = await upsertRows(client, spec, common, sourceRows); + importedRows = await upsertRows(client, spec, common, sourceRows, targetJsonColumns, options.finalSync); + if (spec.name === 'profiles' && !options.finalSync) { + for (const row of sourceRows) { + if (!row.couple_id) continue; + const reconciled = await client.query( + `update profiles p set couple_id=$2 + where p.id=$1 and p.couple_id is distinct from $2 + and (select count(*) from profiles member where member.couple_id=p.couple_id)<=1`, + [row.id, row.couple_id], + ); + reconciledProfileCouples += reconciled.rowCount ?? 0; + } + } if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows); await client.query('commit'); } catch (error) { await client.query('rollback'); throw error; } finally { client.release(); } @@ -85,8 +159,23 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab await options.saveCheckpoint?.(options.checkpoint); } const targetRows = await count(target, spec.name); - const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean); - results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) }); + const targetKeys = targetColumns.length + ? (await target.query>(`select ${spec.key.map(quote).join(',')} from ${quote(spec.name)}`)).rows + : []; + const keyValue = (row: Record) => 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; } diff --git a/apps/api/src/migration/storage.ts b/apps/api/src/migration/storage.ts index 8ec435a..a8141b8 100644 --- a/apps/api/src/migration/storage.ts +++ b/apps/api/src/migration/storage.ts @@ -16,7 +16,9 @@ interface StorageObject { const BUCKET_KIND: Record = { avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback', }; -const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// PostgreSQL's uuid type accepts legacy UUID-shaped identifiers whose version +// nibble is not RFC 4122 (the production fixture profile uses version 0). +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const quote = (value: string) => `"${value.replaceAll('"', '""')}"`; function safeStorageKey(bucket: string, name: string): string { @@ -50,22 +52,57 @@ export function createSupabaseDownloader(baseUrl: string, serviceRoleKey: string }; } -async function mediaRelation(target: Pool, object: StorageObject, key: string) { - const first = object.name.split('/')[0]; - const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null); - if (!owner) throw new Error(`Cannot determine owner for ${key}`); - const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [owner]); - if (!profile.rows[0]) throw new Error(`Storage owner does not exist in target for ${key}`); - let matchId: string | null = null; let questionId: string | null = null; - if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first; - if (object.bucket_id === 'response-media') { - const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? ''; - if (uuidPattern.test(candidate)) questionId = candidate; +const legacyPredicate = (column: string) => `( + ${column}=$1 or ${column}=$2 or + (right(${column},length($1))=$1 and position('/'||$3||'/' in ${column})>0) +)`; + +async function sourceReference(source: Pool, object: StorageObject) { + const values = [object.name, `${object.bucket_id}/${object.name}`, object.bucket_id]; + if (object.bucket_id === 'avatars') { + return (await source.query<{ owner_id: string; match_id: null; question_id: null }>( + `select id owner_id,null::uuid match_id,null::uuid question_id from profiles where ${legacyPredicate('avatar_url')} limit 1`, values, + )).rows[0] ?? null; } - return { ownerId: owner, coupleId: profile.rows[0].couple_id, matchId, questionId }; + if (object.bucket_id === 'chat-media') { + return (await source.query<{ owner_id: string; match_id: string; question_id: null }>( + `select user_id owner_id,match_id,null::uuid question_id from messages where ${legacyPredicate('media_path')} limit 1`, values, + )).rows[0] ?? null; + } + if (object.bucket_id === 'feedback-screenshots') { + return (await source.query<{ owner_id: string; match_id: null; question_id: null }>( + `select user_id owner_id,null::uuid match_id,null::uuid question_id from feedback where ${legacyPredicate('screenshot_url')} limit 1`, values, + )).rows[0] ?? null; + } + return (await source.query<{ owner_id: string; match_id: null; question_id: string }>( + `select user_id owner_id,null::uuid match_id,question_id from responses + where response_data is not null and ${legacyPredicate("response_data->>'media_path'")} limit 1`, values, + )).rows[0] ?? null; } -async function rewriteLegacyReference(target: Pool, object: StorageObject): Promise { +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 { const reference = `media:${object.id}`; const legacyName = object.name; const legacyKey = `${object.bucket_id}/${legacyName}`; @@ -78,7 +115,11 @@ async function rewriteLegacyReference(target: Pool, object: StorageObject): Prom } else if (object.bucket_id === 'chat-media') { await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]); } else if (object.bucket_id === 'feedback-screenshots') { - await target.query(`update feedback set screenshot_url=$1 where ${matchesLegacy('screenshot_url')}`, [reference, legacyName, legacyKey]); + const sourceRows = await source.query<{ id: string }>( + `select id from feedback where ${legacyPredicate('screenshot_url')}`, + [legacyName, legacyKey, object.bucket_id], + ); + if (sourceRows.rows.length) await target.query('update feedback set screenshot_media_id=$1 where id=any($2::uuid[])', [object.id, sourceRows.rows.map((row) => row.id)]); } else if (object.bucket_id === 'response-media') { await target.query(` update responses @@ -92,7 +133,7 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag const schema = options.storageSchema ?? 'storage'; if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema'); const objects = await source.query(`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(); for (const object of objects.rows) { const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize; @@ -101,10 +142,11 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag const marker = options.checkpoint.storage[key]; if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) { if (options.dryRun) { result.skippedFiles += 1; continue; } - const registered = await target.query('select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3', [object.id, key, marker.byteSize]); + const registered = await target.query(`select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3 + union all select 1 from legacy_media_quarantine where id=$1 and original_storage_key=$2 and byte_size=$3`, [object.id, key, marker.byteSize]); try { if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) { - await rewriteLegacyReference(target, object); + await rewriteLegacyReference(source, target, object); result.skippedFiles += 1; continue; } @@ -112,16 +154,25 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag } if (options.dryRun) continue; try { + const relation = await mediaRelation(source, target, object); const bytes = await options.download(object.bucket_id, object.name); if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`); - const relation = await mediaRelation(target, object, key); const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path); const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream'; - await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at) - values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`, - [object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]); - await rewriteLegacyReference(target, object); + if (relation.ownerId) { + await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at) + values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`, + [object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]); + await target.query('delete from legacy_media_quarantine where id=$1', [object.id]); + await rewriteLegacyReference(source, target, object); + } else { + await target.query(`insert into legacy_media_quarantine(id,original_storage_key,bucket_id,object_name,source_owner_id,metadata,mime_type,byte_size,reason,source_created_at,source_updated_at) + values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) on conflict(id) do update set original_storage_key=excluded.original_storage_key,bucket_id=excluded.bucket_id,object_name=excluded.object_name,source_owner_id=excluded.source_owner_id,metadata=excluded.metadata,mime_type=excluded.mime_type,byte_size=excluded.byte_size,reason=excluded.reason,source_created_at=excluded.source_created_at,source_updated_at=excluded.source_updated_at`, + [object.id, key, object.bucket_id, object.name, object.owner_id, object.metadata, mime, bytes.byteLength, 'legacy object has no verifiable owner or live reference', object.created_at, object.updated_at]); + await target.query('delete from media_objects where id=$1', [object.id]); + result.quarantinedFiles += 1; + } options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength }; await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength; } catch (error) { @@ -131,16 +182,21 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag } if (!options.dryRun) { if (options.prune) { - const existing = await target.query<{ id: string; storage_key: string }>("select id,storage_key from media_objects where kind=any($1::text[])", [Object.values(BUCKET_KIND)]); + const existing = await target.query<{ id: string; storage_key: string; quarantined: boolean }>(` + select id,storage_key,false quarantined from media_objects where kind=any($1::text[]) + union all select id,original_storage_key storage_key,true quarantined from legacy_media_quarantine + `, [Object.values(BUCKET_KIND)]); for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) { await rm(destination(options.mediaRoot, row.storage_key), { force: true }); - await target.query('delete from media_objects where id=$1', [row.id]); + await target.query(row.quarantined ? 'delete from legacy_media_quarantine where id=$1' : 'delete from media_objects where id=$1', [row.id]); delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1; } await options.saveCheckpoint?.(options.checkpoint); } - const registered = await target.query<{ storage_key: string; byte_size: number }>("select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])", [Object.values(BUCKET_KIND)]); - result.targetFiles = registered.rows.length; + const registered = await target.query<{ storage_key: string; byte_size: number }>(`select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[]) + union all select original_storage_key storage_key,byte_size from legacy_media_quarantine`, [Object.values(BUCKET_KIND)]); + result.targetFiles = registered.rows.filter((row) => sourceKeys.has(row.storage_key)).length; + result.preservedTargetFiles = registered.rows.length - result.targetFiles; for (const row of registered.rows) { try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); } catch { result.missingOnDisk.push(row.storage_key); } diff --git a/apps/api/src/migration/types.ts b/apps/api/src/migration/types.ts index 2d9b0ad..14c039b 100644 --- a/apps/api/src/migration/types.ts +++ b/apps/api/src/migration/types.ts @@ -22,7 +22,9 @@ export interface TableResult { export interface StorageResult { sourceFiles: number; targetFiles: number; + preservedTargetFiles: number; copiedFiles: number; + quarantinedFiles: number; skippedFiles: number; prunedFiles: number; failedFiles: number; diff --git a/apps/api/test/answers.integration.test.ts b/apps/api/test/answers.integration.test.ts index 1d384d5..9530f29 100644 --- a/apps/api/test/answers.integration.test.ts +++ b/apps/api/test/answers.integration.test.ts @@ -5,13 +5,43 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest'; import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'; const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost'); describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository; -const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999'; -beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female'])",[q,pack,concurrentQ,femaleOnlyQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); +const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);}); it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});}); it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);}); it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});}); it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});}); -it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});}); -it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});}); +it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,you_answered_today:true,partner_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});}); +it('answers the streak from the caller side rather than by stored position',async()=>{await pool.query("update profiles set name='Alex' where id=$1",[u2]);await pool.query("update profiles set name='Sam' where id=$1",[u1]);await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=0,last_active_date=null,last_completed_date=null,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]); + await repo.submit(u1,{questionId:q,answer:'yes'}); + expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0}); + expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});}); + +it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']); + await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]); + await repo.submit(u1,{questionId:q,answer:'yes'}); + const expected=await pool.query<{day:string}>("select (now() at time zone 'Pacific/Auckland')::date::text as day"); + expect(await repo.streak(u1)).toMatchObject({timezone:'Pacific/Auckland',last_active_date:expected.rows[0].day}); + // The lower member id decides the zone, so an unreported zone there falls through to the partner. + await pool.query('update profiles set timezone=null where id=$1',[u1]); + expect((await repo.streak(u1))?.timezone).toBe('America/Los_Angeles'); + await pool.query("update profiles set timezone='Not/AZone' where id=$1",[u1]); + expect((await repo.streak(u1))?.timezone).toBe('UTC'); + await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);}); + +it('reports a lapsed streak as broken before the next answer rewrites it',async()=>{await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=5,longest_streak=9,last_active_date=current_date-3,last_completed_date=current_date-3,user1_answered_today=true,user2_answered_today=true where couple_id=$1",[couple]); + expect(await repo.streak(u1)).toMatchObject({current_streak:0,longest_streak:9,you_answered_today:false,partner_answered_today:false}); + // A streak completed yesterday is still alive and still shows its count. + await pool.query('update couple_streaks set last_completed_date=current_date-1,last_active_date=current_date-1 where couple_id=$1',[couple]); + expect(await repo.streak(u1)).toMatchObject({current_streak:5,you_answered_today:false});}); + +const resetLimit=async(limit:number,timezone:string|null=null)=>{await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query('update app_config set daily_response_limit=$1',[limit]);await pool.query('update profiles set timezone=$2 where id=$1',[u1,timezone]);}; +it('serializes the daily limit across different questions',async()=>{await resetLimit(1);const results=await Promise.allSettled([repo.submit(u1,{questionId:limitQ1,answer:'yes'}),repo.submit(u1,{questionId:limitQ2,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,remaining:0,is_blocked:true});}); +it('exempts catching up on a question the partner answered first',async()=>{await resetLimit(1);await repo.submit(u2,{questionId:catchUpQ,answer:'yes'});await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:catchUpQ,answer:'yes'})).resolves.toBeTruthy();expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit'});}); +it('reports the daily limit error with the shape the client renders',async()=>{await resetLimit(1);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit',status:429,details:{daily_limit:1,responses_today:1,remaining:0}});}); +it('buckets the window on the user local day and falls back to UTC',async()=>{await resetLimit(5,'Pacific/Auckland');const nz=await repo.dailyLimit(u1);const nzBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'Pacific/Auckland')+interval '1 day') at time zone 'Pacific/Auckland') expected");expect(nz.reset_at).toBe(new Date(nzBoundary.rows[0].expected).toISOString()); + const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected"); + for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}}); +it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);}); }); diff --git a/apps/api/test/answers.routes.test.ts b/apps/api/test/answers.routes.test.ts index 00a26d0..7a98c43 100644 --- a/apps/api/test/answers.routes.test.ts +++ b/apps/api/test/answers.routes.test.ts @@ -15,7 +15,9 @@ function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthId describe('answer routes',()=>{ it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});}); - it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);}); + it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429); + // The client renders the limit screen straight from this body, so its shape is a contract. + expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});}); it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);}); }); diff --git a/apps/api/test/api.integration.test.ts b/apps/api/test/api.integration.test.ts index 745902f..78e3e7c 100644 --- a/apps/api/test/api.integration.test.ts +++ b/apps/api/test/api.integration.test.ts @@ -28,7 +28,8 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', isolatedUrl.searchParams.set('options', `-c search_path=${schema}`); pool = new Pool({ connectionString: isolatedUrl.toString() }); repository = new PostgresRepository(isolatedUrl.toString()); - const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8'); + const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql'] + .map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n'); for (const statement of migration.split('--> statement-breakpoint')) { if (statement.trim()) await pool.query(statement); } diff --git a/apps/api/test/dares.integration.test.ts b/apps/api/test/dares.integration.test.ts new file mode 100644 index 0000000..01656a1 --- /dev/null +++ b/apps/api/test/dares.integration.test.ts @@ -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' }); + }); +}); diff --git a/apps/api/test/dares.routes.test.ts b/apps/api/test/dares.routes.test.ts new file mode 100644 index 0000000..cc2cb34 --- /dev/null +++ b/apps/api/test/dares.routes.test.ts @@ -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 = {}) { + 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] }); + }); +}); diff --git a/apps/api/test/migration.integration.test.ts b/apps/api/test/migration.integration.test.ts index ab99120..7cc71dc 100644 --- a/apps/api/test/migration.integration.test.ts +++ b/apps/api/test/migration.integration.test.ts @@ -1,38 +1,476 @@ -import { randomUUID } from 'node:crypto'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { Pool } from 'pg'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { migrateDatabase, relationalParity } from '../src/migration/database.js'; -import { migrateStorage, fileDigest } from '../src/migration/storage.js'; -import type { MigrationCheckpoint } from '../src/migration/types.js'; +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + migrateDatabase, + relationalParity, +} from "../src/migration/database.js"; +import { migrateStorage, fileDigest } from "../src/migration/storage.js"; +import type { MigrationCheckpoint } from "../src/migration/types.js"; -const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;if(databaseUrl&&!local)throw new Error('Migration integration tests only permit localhost'); -describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{ - const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string; - const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666'; - const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()}); - beforeAll(async()=>{ - await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`); - const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()}); - const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()}); - for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);} - await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`); - await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]); - await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]); - await source.query('alter table profiles add column avatar_url text'); - await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]); - await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]); - await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]); - await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]); - await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`); - await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]); - mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-')); +const databaseUrl = process.env.DATABASE_URL; +const local = databaseUrl + ? ["127.0.0.1", "localhost", "::1"].includes(new URL(databaseUrl).hostname) + : false; +if (databaseUrl && !local) + throw new Error("Migration integration tests only permit localhost"); +describe.skipIf(!databaseUrl || !local)("cutover migration fixtures", () => { + const admin = new Pool({ connectionString: databaseUrl }); + const suffix = randomUUID().replaceAll("-", ""); + const sourceSchema = `migration_source_${suffix}`; + const targetSchema = `migration_target_${suffix}`; + const storageSchema = `migration_storage_${suffix}`; + let source: Pool; + let target: Pool; + let mediaRoot: string; + const couple = "11111111-1111-4111-8111-111111111111", + user = "22222222-2222-4222-8222-222222222222", + pack = "33333333-3333-4333-8333-333333333333", + question = "44444444-4444-4444-8444-444444444444", + inverseQuestion = "45454545-4545-4545-8545-454545454545", + response = "55555555-5555-4555-8555-555555555555", + object = "66666666-6666-4666-8666-666666666666", + adminRow = "77777777-7777-4777-8777-777777777777", + category = "88888888-8888-4888-8888-888888888888", + review = "99999999-9999-4999-8999-999999999999", + audit = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + legacyUser = "a0000000-0000-0000-0000-000000000001", + sourceAppConfig = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const checkpoint = (): MigrationCheckpoint => ({ + version: 1, + runId: randomUUID(), + mode: "initial", + completedTables: [], + storage: {}, + updatedAt: new Date().toISOString(), + }); + beforeAll(async () => { + await admin.query( + `create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`, + ); + const sourceUrl = new URL(databaseUrl!); + sourceUrl.searchParams.set("options", `-c search_path=${sourceSchema}`); + source = new Pool({ connectionString: sourceUrl.toString() }); + const targetUrl = new URL(databaseUrl!); + targetUrl.searchParams.set("options", `-c search_path=${targetSchema}`); + target = new Pool({ connectionString: targetUrl.toString() }); + for (let index = 0; index <= 18; index++) { + const files = (await import("node:fs/promises")).readdir( + new URL("../drizzle", import.meta.url), + ); + const name = (await files).find((file) => + file.startsWith(`${String(index).padStart(4, "0")}_`), + ); + if (!name) continue; + const sql = await readFile( + new URL(`../drizzle/${name}`, import.meta.url), + "utf8", + ); + for (const statement of sql.split("--> statement-breakpoint")) + if (statement.trim()) await target.query(statement); + } + await source.query( + `create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table admin_users(id uuid primary key,user_id uuid,role text,created_at timestamptz,created_by uuid,updated_at timestamptz,permissions jsonb);create table categories(id uuid primary key,name text,is_public boolean,sort_order int,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],inverse_of uuid,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table content_reviews(id uuid primary key,entity_type text,entity_id uuid,previous_status text,new_status text,reason text,changed_by uuid,created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);create table feature_interests(id uuid primary key,user_id uuid,feature_name text,created_at timestamptz);create table live_draw_sessions(id uuid primary key,couple_id uuid,strokes jsonb,updated_at timestamptz,created_at timestamptz);create table audit_logs(id uuid primary key,table_name text,record_id uuid,action text,old_values jsonb,new_values jsonb,changed_fields text[],admin_user_id uuid,admin_role text,created_at timestamptz,admin_username text);create table app_config(id uuid primary key,answer_gap_threshold int,daily_response_limit int,updated_at timestamptz);`, + ); + await source.query( + `insert into couples values($1,'e5d3012e','2024-01-01T00:00:00Z')`, + [couple], + ); + await source.query( + `insert into app_config values($1,99,99,'2024-01-01T00:00:00Z')`, + [sourceAppConfig], + ); + await source.query( + `insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`, + [user, couple], + ); + await source.query( + `insert into admin_users values($1,$2,'super_admin',now(),null,now(),$3::jsonb)`, + [adminRow, user, JSON.stringify(["manage_packs"])], + ); + await source.query("alter table profiles add column avatar_url text"); + await source.query("update profiles set avatar_url=$2 where id=$1", [ + user, + `https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`, + ]); + await source.query( + `insert into categories values($1,'Core',true,1,'allowed','Reviewed',null,null,'2024-01-03T00:00:00Z')`, + [category], + ); + await source.query( + `insert into question_packs values($1,'Core',true,false,3.36,'allowed','Reviewed',null,null,'2024-01-04T00:00:00Z')`, + [pack], + ); + await source.query( + `insert into questions values($1,$2,'Question',2,array['blindfold'],$3,'allowed','Reviewed',null,null,'2024-01-05T00:00:00Z'),($3,$2,'Inverse',2,array[]::text[],null,'allowed','Reviewed',null,null,'2024-01-05T00:00:01Z')`, + [question, pack, inverseQuestion], + ); + await source.query( + `insert into content_reviews values($1,'categories',$2,'unreviewed','allowed','Reviewed',null,'2024-01-03T00:00:00Z')`, + [review, category], + ); + await source.query( + `insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`, + [response, user, question, couple], + ); + await source.query( + `insert into feature_interests values(gen_random_uuid(),$1,'daily_dares','2024-01-07T00:00:00Z')`, + [user], + ); + await source.query( + `insert into live_draw_sessions values(gen_random_uuid(),$1,'[]'::jsonb,'2024-01-08T00:00:00Z','2024-01-08T00:00:00Z')`, + [couple], + ); + await source.query( + `insert into audit_logs values($1,'categories',$2,'UPDATE',null,$3::jsonb,array['content_status'],$4,'super_admin','2024-01-09T00:00:00Z','alice')`, + [audit, category, JSON.stringify({ content_status: "allowed" }), user], + ); + await admin.query( + `create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`, + ); + await admin.query( + `insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`, + [object, `${user}/avatar.png`, user, { size: 4, mimetype: "image/png" }], + ); + mediaRoot = await mkdtemp(join(tmpdir(), "sauci-migration-")); + }); + afterAll(async () => { + await Promise.all([source?.end(), target?.end()]); + await admin.query( + `drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`, + ); + await admin.end(); + if (mediaRoot) await rm(mediaRoot, { recursive: true, force: true }); + }); + it("preserves IDs, schema mappings, catalogue reviews, timestamps, side-effect suppression, and FK parity", async () => { + const state = checkpoint(); + const result = await migrateDatabase(source, target, { + dryRun: false, + finalSync: false, + prune: false, + checkpoint: state, + }); + expect(result.find((row) => row.table === "responses")).toMatchObject({ + sourceRows: 1, + targetRows: 1, + status: "equal", + }); + expect(result.find((row) => row.table === "app_config")).toMatchObject({ + sourceRows: 1, + targetRows: 1, + importedRows: 0, + status: "equal", + }); + expect( + (await target.query("select id from app_config")).rows[0].id, + ).not.toBe(sourceAppConfig); + const row = await target.query("select id,created_at from responses"); + expect(row.rows[0].id).toBe(response); + expect(new Date(row.rows[0].created_at).toISOString()).toBe( + "2024-01-06T00:00:00.000Z", + ); + expect( + ( + await target.query("select invite_code from couples where id=$1", [ + couple, + ]) + ).rows[0].invite_code, + ).toBe("E5D3012E"); + expect( + ( + await target.query( + "select avg_intensity,content_status from question_packs where id=$1", + [pack], + ) + ).rows[0], + ).toMatchObject({ avg_intensity: "3.36", content_status: "allowed" }); + expect( + ( + await target.query( + "select required_props,inverse_of,content_status from questions where id=$1", + [question], + ) + ).rows[0], + ).toMatchObject({ + required_props: ["blindfold"], + inverse_of: inverseQuestion, + content_status: "allowed", + }); + expect( + ( + await target.query( + "select feature from feature_interests where user_id=$1", + [user], + ) + ).rows[0].feature, + ).toBe("daily_dares"); + expect( + ( + await target.query( + "select updated_by from live_draw_sessions where couple_id=$1", + [couple], + ) + ).rows[0].updated_by, + ).toBe(user); + expect( + ( + await target.query( + "select actor_user_id,admin_user_id,changed_fields,admin_role,admin_username from audit_logs where id=$1", + [audit], + ) + ).rows[0], + ).toMatchObject({ + actor_user_id: user, + admin_user_id: adminRow, + changed_fields: ["content_status"], + admin_role: "super_admin", + admin_username: "alice", + }); + expect( + ( + await target.query( + "select count(*)::int count from content_reviews where id=$1", + [review], + ) + ).rows[0].count, + ).toBe(1); + expect( + (await target.query("select count(*)::int count from operations_outbox")) + .rows[0].count, + ).toBe(0); + expect(await relationalParity(target)).toEqual({ + profiles_without_couple: 0, + responses_with_missing_owner: 0, + matches_with_missing_relation: 0, + messages_with_missing_relation: 0, + couple_mismatch_responses: 0, + }); + }); + it("keeps a newer live profile during an initial merge", async () => { + const liveCouple = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + await target.query( + "insert into couples(id,invite_code,created_at) values($1,'LIVE0001',now())", + [liveCouple], + ); + await target.query( + "update profiles set name='Live Alice',couple_id=$2,updated_at='2026-08-28T00:00:00Z' where id=$1", + [user, liveCouple], + ); + await migrateDatabase(source, target, { + dryRun: false, + finalSync: false, + prune: false, + checkpoint: checkpoint(), + }); + expect( + ( + await target.query( + "select name,couple_id,updated_at from profiles where id=$1", + [user], + ) + ).rows[0], + ).toMatchObject({ name: "Live Alice", couple_id: couple }); + }); + it("dry-run reads and reports without changing the target", async () => { + await source.query( + "update profiles set name='Changed at source' where id=$1", + [user], + ); + await migrateDatabase(source, target, { + dryRun: true, + finalSync: false, + prune: false, + checkpoint: checkpoint(), + }); + expect( + (await target.query("select name from profiles where id=$1", [user])) + .rows[0].name, + ).toBe("Live Alice"); + await source.query("update profiles set name='Alice' where id=$1", [user]); + }); + it("is resumable and a stopped-source prune restores exact row parity", async () => { + const state = checkpoint(); + state.completedTables = ["couples"]; + await target.query( + `insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`, + ); + const resumed = await migrateDatabase(source, target, { + dryRun: false, + finalSync: false, + prune: false, + checkpoint: state, + }); + expect(resumed.find((row) => row.table === "couples")?.note).toContain( + "resumed from checkpoint", + ); + const final = await migrateDatabase(source, target, { + dryRun: false, + finalSync: true, + prune: true, + checkpoint: checkpoint(), + }); + expect(final.find((row) => row.table === "couples")).toMatchObject({ + sourceRows: 1, + targetRows: 1, + prunedRows: 2, + status: "equal", + }); + }); + it("copies storage atomically, quarantines unattributable legacy objects, rewrites references, verifies checkpoints, and prunes source-deleted files", async () => { + const legacyAvatar = randomUUID(), + orphanChat = randomUUID(), + unmatchedChat = randomUUID(), + feedbackObject = randomUUID(), + feedbackId = randomUUID(); + await source.query( + "insert into profiles values($1,$2,$3,$4,now(),now(),null)", + [legacyUser, "Legacy", "legacy@test", couple], + ); + await target.query( + "insert into profiles(id,name,email,couple_id,created_at,updated_at) values($1,$2,$3,$4,now(),now())", + [legacyUser, "Legacy", "legacy@test", couple], + ); + await source.query( + "create table messages(user_id uuid,match_id uuid,media_path text);create table feedback(id uuid primary key,user_id uuid,screenshot_url text)", + ); + await source.query("insert into feedback values($1,$2,$3)", [ + feedbackId, + user, + `https://source.test/storage/v1/object/public/feedback-screenshots/${user}/shot.jpg`, + ]); + await target.query( + "insert into feedback(id,user_id,type,title,description) values($1,$2,'bug','Screenshot','Details')", + [feedbackId, user], + ); + await admin.query( + `insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now()),($5,'chat-media',$6,null,$4,now(),now()),($7,'chat-media',$8,$9,$4,now(),now()),($10,'feedback-screenshots',$11,$9,$4,now(),now())`, + [ + legacyAvatar, + `${legacyUser}/legacy.jpg`, + legacyUser, + { size: 4, mimetype: "image/jpeg" }, + orphanChat, + `${randomUUID()}/orphan.jpg`, + unmatchedChat, + `${randomUUID()}/chat.jpg`, + user, + feedbackObject, + `${user}/shot.jpg`, + ], + ); + const state = checkpoint(); + const first = await migrateStorage(source, target, { + dryRun: false, + mediaRoot, + storageSchema, + checkpoint: state, + download: async () => new Uint8Array([1, 2, 3, 4]), + }); + expect(first.failures).toEqual([]); + expect(first).toMatchObject({ + sourceFiles: 5, + targetFiles: 5, + copiedFiles: 5, + quarantinedFiles: 1, + failedFiles: 0, + missingOnDisk: [], + }); + const path = join(mediaRoot, "avatars", user, "avatar.png"); + expect(await fileDigest(path)).toBe( + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", + ); + expect( + ( + await target.query( + "select owner_id,kind,byte_size from media_objects where id=$1", + [object], + ) + ).rows[0], + ).toMatchObject({ owner_id: user, kind: "avatar", byte_size: 4 }); + expect( + ( + await target.query("select owner_id from media_objects where id=$1", [ + legacyAvatar, + ]) + ).rows[0].owner_id, + ).toBe(legacyUser); + expect( + ( + await target.query("select match_id from media_objects where id=$1", [ + unmatchedChat, + ]) + ).rows[0].match_id, + ).toBeNull(); + expect( + ( + await target.query( + "select reason from legacy_media_quarantine where id=$1", + [orphanChat], + ) + ).rowCount, + ).toBe(1); + expect( + ( + await target.query( + "select screenshot_media_id from feedback where id=$1", + [feedbackId], + ) + ).rows[0].screenshot_media_id, + ).toBe(feedbackObject); + expect( + ( + await target.query("select avatar_url from profiles where id=$1", [ + user, + ]) + ).rows[0].avatar_url, + ).toBe(`media:${object}`); + const resumed = await migrateStorage(source, target, { + dryRun: false, + mediaRoot, + storageSchema, + checkpoint: state, + download: async () => { + throw new Error("must not redownload"); + }, + }); + expect(resumed).toMatchObject({ + copiedFiles: 0, + skippedFiles: 5, + failedFiles: 0, + }); + const stale = "77777777-7777-4777-8777-777777777777"; + const staleKey = `avatars/${user}/stale.png`; + await target.query( + "insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)", + [stale, user, couple, staleKey], + ); + await writeFile(join(mediaRoot, staleKey), new Uint8Array([9])); + await admin.query( + `delete from "${storageSchema}".objects where id=$1`, + [orphanChat], + ); + const pruned = await migrateStorage(source, target, { + dryRun: false, + prune: true, + mediaRoot, + storageSchema, + checkpoint: state, + download: async () => { + throw new Error("must not redownload"); + }, + }); + expect(pruned).toMatchObject({ + sourceFiles: 4, + targetFiles: 4, + prunedFiles: 2, + skippedFiles: 4, + failedFiles: 0, + }); + expect( + (await target.query("select 1 from legacy_media_quarantine where id=$1", [orphanChat])).rowCount, + ).toBe(0); }); - afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});}); - it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});}); - it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);}); - it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});}); - it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});}); }); diff --git a/apps/api/test/operations.integration.test.ts b/apps/api/test/operations.integration.test.ts index 0de3ff2..820297c 100644 --- a/apps/api/test/operations.integration.test.ts +++ b/apps/api/test/operations.integration.test.ts @@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{ const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository; beforeAll(async()=>{ await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString()); - for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql']){ + for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){ const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement); } await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']); @@ -68,6 +68,44 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{ await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before); }); + it('nudges only the partner who still owes an answer, in the couple evening',async()=>{ + // Alice sorts below Bob, so Alice is user1. She has answered today; Bob has not. + await pool.query("update profiles set timezone='UTC' where couple_id=$1",[couple]); + const evening=new Date('2026-08-27T21:00:00Z'); + await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,last_active_date,last_completed_date,user1_answered_today,user2_answered_today,streak_celebrated_at) + values($1,6,6,'2026-08-27','2026-08-26',true,false,6) on conflict(couple_id) do update set current_streak=6,longest_streak=6, + last_active_date='2026-08-27',last_completed_date='2026-08-26',user1_answered_today=true,user2_answered_today=false,streak_celebrated_at=6`,[couple]); + + expect((await repo.produce(evening,100)).streakReminders).toBe(1); + const queued=await pool.query<{recipient_id:string;title:string;body:string}>( + `select recipient_id,payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like 'streak_risk:%'`); + expect(queued.rows).toHaveLength(1); + expect(queued.rows[0]).toMatchObject({recipient_id:bob,title:'Alice answered today'}); + expect(queued.rows[0].body).toContain('6 days in'); + expect((await pool.query('select 1 from operations_outbox where dedupe_key=$1',[`streak_risk:${couple}:2026-08-27:${bob}`])).rowCount).toBe(1); + + // A second tick inside the same couple-local evening must not queue a second nudge. + expect((await repo.produce(evening,100)).streakReminders).toBe(0); + }); + + it('addresses both partners when neither has answered and honours the preference',async()=>{ + const next=new Date('2026-08-28T21:00:00Z'); + await pool.query(`update couple_streaks set last_active_date='2026-08-27',last_completed_date='2026-08-27', + user1_answered_today=true,user2_answered_today=true where couple_id=$1`,[couple]); + expect((await repo.produce(next,100)).streakReminders).toBe(2); + const both=await pool.query<{title:string;body:string}>( + `select payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like $1 order by recipient_id`, + [`streak_risk:${couple}:2026-08-28:%`]); + expect(both.rows.map(r=>r.title)).toEqual(['Your 6-day streak','Your 6-day streak']); + expect(both.rows[0].body).toContain('One question each'); + + await pool.query(`insert into notification_preferences(user_id,streak_reminders_enabled) values($1,false),($2,false) + on conflict(user_id) do update set streak_reminders_enabled=false`,[alice,bob]); + await pool.query(`update couple_streaks set last_active_date='2026-08-28',last_completed_date='2026-08-28' where couple_id=$1`,[couple]); + expect((await repo.produce(new Date('2026-08-29T21:00:00Z'),100)).streakReminders).toBe(0); + await pool.query('update notification_preferences set streak_reminders_enabled=true where user_id in ($1,$2)',[alice,bob]); + }); + it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{ await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]); const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0); diff --git a/apps/api/test/operations.runner.test.ts b/apps/api/test/operations.runner.test.ts index 21c86af..c06900d 100644 --- a/apps/api/test/operations.runner.test.ts +++ b/apps/api/test/operations.runner.test.ts @@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js'; import type { OperationItem } from '../src/domains/operations/types.js'; import { loadWorkerConfig } from '../src/workers/config.js'; -const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0}; +const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0}; function repository(items:OperationItem[]):OperationsRepository { return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined), message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)}; diff --git a/apps/api/test/profile-settings.routes.test.ts b/apps/api/test/profile-settings.routes.test.ts index f62fa74..5b4b337 100644 --- a/apps/api/test/profile-settings.routes.test.ts +++ b/apps/api/test/profile-settings.routes.test.ts @@ -20,9 +20,11 @@ const preferences = { pack_changes_enabled: true, new_packs_enabled: true, streak_milestones_enabled: true, + streak_reminders_enabled: true, weekly_summary_enabled: true, unpaired_reminders_enabled: true, catchup_reminders_enabled: true, + dares_enabled: true, created_at: '2026-08-27T10:00:00.000Z', updated_at: '2026-08-27T10:00:00.000Z', }; diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 7dadd75..3d9126a 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -14,6 +14,7 @@ react { hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() // Use Expo CLI to bundle the app, this ensures the Metro config // works correctly with Expo projects. cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) @@ -63,9 +64,9 @@ react { } /** - * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + * Set this to true in release builds to optimize the app using R8. */ -def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean() +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() /** * The preferred build flavor of JavaScriptCore (JSC) @@ -78,7 +79,7 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea * give correct results when using with locales other than en-US. Note that * this variant is about 6MiB larger per architecture than default. */ -def jscFlavor = 'org.webkit:android-jsc:+' +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' android { ndkVersion rootProject.ext.ndkVersion @@ -91,8 +92,9 @@ android { applicationId 'com.sauci.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 46 + versionCode 47 versionName "1.0.5" + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } signingConfigs { debug { @@ -110,15 +112,18 @@ android { // Caution! In production, you need to generate your own keystore file. // see https://reactnative.dev/docs/signed-apk-android. signingConfig signingConfigs.debug - shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) - minifyEnabled enableProguardInReleaseBuilds + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" - crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true) + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() } } packagingOptions { jniLibs { - useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() } } androidResources { @@ -156,15 +161,15 @@ dependencies { if (isGifEnabled) { // For animated gif support - implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}") + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") } if (isWebpEnabled) { // For webp support - implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}") + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") if (isWebpAnimatedEnabled) { // Animated webp support - implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}") + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") } } @@ -176,4 +181,4 @@ dependencies { } apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.firebase.crashlytics' \ No newline at end of file +apply plugin: 'com.google.firebase.crashlytics' diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 0895015..36c65e1 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + @@ -17,7 +21,7 @@ - + diff --git a/apps/mobile/android/app/src/main/java/com/sauci/app/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/sauci/app/MainApplication.kt index 644f608..d691313 100644 --- a/apps/mobile/android/app/src/main/java/com/sauci/app/MainApplication.kt +++ b/apps/mobile/android/app/src/main/java/com/sauci/app/MainApplication.kt @@ -8,10 +8,10 @@ import com.facebook.react.ReactApplication import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage import com.facebook.react.ReactHost -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost -import com.facebook.react.soloader.OpenSourceMergedSoMapping -import com.facebook.soloader.SoLoader +import com.facebook.react.common.ReleaseLevel import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper @@ -42,11 +42,12 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() - SoLoader.init(this, OpenSourceMergedSoMapping) - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - load() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE } + loadReactNative(this) ApplicationLifecycleDispatcher.onApplicationCreate(this) } diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml index b226501..85c39a6 100644 --- a/apps/mobile/android/app/src/main/res/values/styles.xml +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -1,5 +1,6 @@