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