Align prod schema and deploy tooling

This commit is contained in:
jjbrunton committed 2026-01-24 20:16:01 +00:00
1 parent d2db86ec7f
commit 7e2e723880
105 files changed
+19014 -131

No files matched your search

+1
View File
@@ -0,0 +1 @@
main
+1 -1
View File
@@ -8,7 +8,7 @@ max_rows = 1000
[db]
port = 54322
shadow_port = 54320
major_version = 15
major_version = 17
[studio]
enabled = true
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
# Edge Function Secrets
# Copy to .secrets and fill in values for local development
# These are synced to Supabase via GitHub Actions from GitHub Secrets
# RevenueCat - Subscription management
REVENUECAT_WEBHOOK_SECRET=whsec_xxxxx
REVENUECAT_API_KEY=sk_xxxxx
REVENUECAT_ENTITLEMENT_ID=Sauci Pro
# OpenRouter - AI message classification
OPENROUTER_API_KEY=sk-or-xxxxx
# Admin encryption keys - E2EE decryption (for admin tools)
ADMIN_PRIVATE_KEY_JWK={"kty":"EC","crv":"P-256",...}
ADMIN_KEYS_JSON=[{"kty":"EC","crv":"P-256",...}]
# Discord - Notifications
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxxxx
# Auto-provided by Supabase (don't set these):
# SUPABASE_URL
# SUPABASE_ANON_KEY
# SUPABASE_SERVICE_ROLE_KEY
@@ -0,0 +1,283 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const jsonHeaders = { ...corsHeaders, "Content-Type": "application/json" };
type DiscordEmbedField = {
name: string;
value: string;
inline?: boolean;
};
type DiscordEmbed = {
title?: string;
description?: string;
color?: number;
fields?: DiscordEmbedField[];
timestamp?: string;
};
type DiscordMessage = {
content?: string;
embeds?: DiscordEmbed[];
username?: string;
};
type NotificationPayload = Record<string, unknown>;
interface NotificationRequest {
event: string;
payload?: NotificationPayload;
}
const EVENT_COLORS: Record<string, number> = {
new_user: 0x2ecc71,
couple_paired: 0x3498db,
feedback_submitted: 0xf39c12,
};
function asString(value: unknown, fallback = "Unknown"): string {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
if (value === null || value === undefined) {
return fallback;
}
return String(value);
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
if (maxLength <= 3) return value.slice(0, maxLength);
return `${value.slice(0, maxLength - 3)}...`;
}
function formatUserSummary(user: Record<string, unknown> | null | undefined): string {
if (!user) return "Unknown";
const name = asString(user.name, "Unknown");
const email = asString(user.email, "Unknown");
const id = asString(user.id, "Unknown");
return `Name: ${name}\nEmail: ${email}\nID: ${id}`;
}
function buildDiscordMessage(event: string, payload: NotificationPayload): DiscordMessage | null {
const timestamp = asString(payload.created_at, new Date().toISOString());
if (event === "new_user") {
const fields: DiscordEmbedField[] = [
{ name: "User ID", value: asString(payload.user_id), inline: false },
{ name: "Name", value: asString(payload.name), inline: true },
{ name: "Email", value: asString(payload.email), inline: true },
];
return {
content: "New user signup",
username: "Sauci Notifications",
embeds: [
{
title: "New user signup",
color: EVENT_COLORS.new_user,
fields,
timestamp,
},
],
};
}
if (event === "couple_paired") {
const profiles = Array.isArray(payload.profiles) ? payload.profiles : [];
const partnerFields = profiles.slice(0, 2).map((profile, index) => ({
name: `Partner ${index + 1}`,
value: formatUserSummary(asRecord(profile)),
inline: false,
}));
const fields: DiscordEmbedField[] = [
{ name: "Couple ID", value: asString(payload.couple_id), inline: false },
...partnerFields,
];
return {
content: "Couple paired",
username: "Sauci Notifications",
embeds: [
{
title: "Couple paired",
color: EVENT_COLORS.couple_paired,
fields,
timestamp: asString(payload.paired_at, timestamp),
},
],
};
}
if (event === "feedback_submitted") {
const user = asRecord(payload.user);
const description = truncate(asString(payload.description, "No description provided."), 900);
const fields: DiscordEmbedField[] = [
{ name: "Feedback ID", value: asString(payload.feedback_id), inline: false },
{ name: "Type", value: asString(payload.type), inline: true },
{ name: "Status", value: asString(payload.status, "new"), inline: true },
{ name: "Title", value: asString(payload.title), inline: false },
{ name: "User", value: formatUserSummary(user), inline: false },
];
const questionId = asString(payload.question_id, "");
if (questionId) {
fields.push({ name: "Question ID", value: questionId, inline: false });
}
const screenshotUrl = asString(payload.screenshot_url, "");
if (screenshotUrl) {
fields.push({ name: "Screenshot", value: screenshotUrl, inline: false });
}
return {
content: "Feedback submitted",
username: "Sauci Notifications",
embeds: [
{
title: "Feedback submitted",
description,
color: EVENT_COLORS.feedback_submitted,
fields,
timestamp,
},
],
};
}
return null;
}
async function enrichPayload(
supabase: ReturnType<typeof createClient>,
event: string,
payload: NotificationPayload
): Promise<NotificationPayload> {
const coupleId = asString(payload.couple_id, "");
if (event === "couple_paired" && coupleId && !Array.isArray(payload.profiles)) {
const { data: profiles } = await supabase
.from("profiles")
.select("id, name, email, created_at")
.eq("couple_id", coupleId)
.order("created_at", { ascending: true });
if (profiles) {
return { ...payload, profiles };
}
}
const userId = asString(payload.user_id, "");
if (event === "feedback_submitted" && userId && !payload.user) {
const { data: profile } = await supabase
.from("profiles")
.select("id, name, email")
.eq("id", userId)
.maybeSingle();
if (profile) {
return { ...payload, user: profile };
}
}
return payload;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: jsonHeaders,
});
}
const webhookUrl = Deno.env.get("DISCORD_WEBHOOK_URL");
if (!webhookUrl) {
console.warn("DISCORD_WEBHOOK_URL is not configured");
return new Response(JSON.stringify({ success: false, message: "Discord webhook not configured" }), {
status: 200,
headers: jsonHeaders,
});
}
let body: NotificationRequest;
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON payload" }), {
status: 400,
headers: jsonHeaders,
});
}
const event = asString(body.event, "");
if (!event) {
return new Response(JSON.stringify({ error: "Missing event" }), {
status: 400,
headers: jsonHeaders,
});
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const payload = await enrichPayload(supabase, event, asRecord(body.payload));
const message = buildDiscordMessage(event, payload);
if (!message) {
return new Response(JSON.stringify({ error: "Unsupported event" }), {
status: 400,
headers: jsonHeaders,
});
}
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Discord webhook error:", errorText);
return new Response(JSON.stringify({ error: "Failed to send Discord notification" }), {
status: 502,
headers: jsonHeaders,
});
}
return new Response(JSON.stringify({ success: true }), {
headers: jsonHeaders,
});
} catch (error) {
console.error("Error sending Discord notification:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: jsonHeaders,
});
}
});
@@ -11,7 +11,6 @@ CREATE TABLE redemption_codes (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Code redemptions table
CREATE TABLE code_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -20,10 +19,8 @@ CREATE TABLE code_redemptions (
redeemed_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(code_id, user_id)
);
ALTER TABLE redemption_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE code_redemptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins with manage_codes permission can view redemption codes"
ON redemption_codes FOR SELECT
USING (has_permission('manage_codes'::text));
@@ -46,7 +43,6 @@ CREATE POLICY "Super admins can manage redemption codes"
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE POLICY "Users can view own redemptions"
ON code_redemptions FOR SELECT
USING (auth.uid() = user_id);
@@ -60,7 +56,6 @@ CREATE POLICY "Super admins can view all redemptions"
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE OR REPLACE FUNCTION public.redeem_code_by_email(p_email text, p_code text)
RETURNS jsonb
LANGUAGE plpgsql
@@ -133,6 +128,5 @@ BEGIN
RETURN jsonb_build_object('success', true, 'message', 'Code redeemed successfully! You now have premium access.');
END;
$function$;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO PUBLIC;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO anon, authenticated, service_role;
@@ -8,36 +8,29 @@ CREATE TABLE feature_interests (
-- Ensure a user can only express interest in a feature once
UNIQUE(user_id, feature_name)
);
-- Index for querying by feature
CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name);
-- Index for querying by user
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id);
-- Enable RLS
ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY;
-- Users can insert their own interest
CREATE POLICY "Users can express interest in features"
ON feature_interests
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Users can view their own interests
CREATE POLICY "Users can view their own interests"
ON feature_interests
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Users can delete their own interests
CREATE POLICY "Users can remove their interest"
ON feature_interests
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- Grant service role full access for admin queries
GRANT ALL ON feature_interests TO service_role;;
GRANT ALL ON feature_interests TO service_role;
@@ -38,4 +38,4 @@ AS $$
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
WHERE p.id = user_id;
$$;;
$$;
@@ -1,7 +1,5 @@
-- Add RLS policy for admins with gift_premium permission to insert subscriptions
CREATE POLICY "Admins with gift_premium can insert subscriptions"
ON public.subscriptions
FOR INSERT
WITH CHECK (has_permission('gift_premium'));
;
@@ -1,4 +1,3 @@
-- Create a function that admins with gift_premium permission can use to gift premium
-- This bypasses RLS issues by using SECURITY DEFINER and checking permissions internally
CREATE OR REPLACE FUNCTION admin_gift_premium(
@@ -52,7 +51,5 @@ BEGIN
RETURN v_subscription_id;
END;
$$;
-- Grant execute permission to authenticated users (the function checks permissions internally)
GRANT EXECUTE ON FUNCTION admin_gift_premium(UUID, TIMESTAMPTZ) TO authenticated;
;
@@ -14,35 +14,28 @@ CREATE TABLE notification_preferences (
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
);
-- Enable RLS
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
-- RLS policies: users can only manage their own preferences
CREATE POLICY "Users can view their own notification preferences"
ON notification_preferences FOR SELECT
USING (user_id = auth.uid());
USING (user_id = auth.uid());
CREATE POLICY "Users can insert their own notification preferences"
ON notification_preferences FOR INSERT
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update their own notification preferences"
ON notification_preferences FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
-- Super admins can view all preferences
CREATE POLICY "Super admins can view all notification preferences"
ON notification_preferences FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_notification_preferences_updated_at
BEFORE UPDATE ON notification_preferences
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to get notification preferences with defaults
-- Returns preferences if they exist, or creates a default row
CREATE OR REPLACE FUNCTION get_or_create_notification_preferences(p_user_id UUID)
@@ -68,7 +61,6 @@ BEGIN
RETURN v_prefs;
END;
$$;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
@@ -2,8 +2,7 @@
-- Notifies partner when a user answers questions (batched with 1-hour debounce)
-- Add last_active_at to profiles for "is user in app" check
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
-- Create pending activity notifications table
CREATE TABLE pending_activity_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -14,29 +13,23 @@ CREATE TABLE pending_activity_notifications (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset)
);
);
-- Index for efficient cron queries
CREATE INDEX idx_pending_activity_notifications_notify_at
ON pending_activity_notifications(notify_at);
ON pending_activity_notifications(notify_at);
-- Enable RLS
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's pending activity notifications"
ON pending_activity_notifications FOR SELECT
USING (couple_id = get_auth_user_couple_id());
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all pending activity notifications"
ON pending_activity_notifications FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_pending_activity_notifications_updated_at
BEFORE UPDATE ON pending_activity_notifications
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to queue partner activity notifications
-- Called via trigger when a response is inserted or updated
CREATE OR REPLACE FUNCTION queue_partner_activity_notification()
@@ -88,14 +81,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on responses table
CREATE TRIGGER on_response_submitted
AFTER INSERT OR UPDATE OF answer ON responses
FOR EACH ROW
EXECUTE FUNCTION queue_partner_activity_notification();
EXECUTE FUNCTION queue_partner_activity_notification();
-- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule(
'send-partner-activity-notifications',
@@ -107,4 +98,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -4,13 +4,11 @@
-- Add scheduling columns to question_packs
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS scheduled_release_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
-- Index for efficient cron queries
CREATE INDEX idx_question_packs_scheduled_release
ON question_packs(scheduled_release_at)
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
-- Schedule cron job for processing scheduled releases (runs every 5 minutes)
SELECT cron.schedule(
'process-scheduled-releases',
@@ -22,4 +20,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -13,25 +13,20 @@ CREATE TABLE couple_streaks (
streak_celebrated_at INTEGER DEFAULT 0, -- Last milestone that was celebrated
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
);
-- Enable RLS
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's streaks"
ON couple_streaks FOR SELECT
USING (couple_id = get_auth_user_couple_id());
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all streaks"
ON couple_streaks FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_couple_streaks_updated_at
BEFORE UPDATE ON couple_streaks
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to update streak when a response is submitted
CREATE OR REPLACE FUNCTION update_couple_streak()
RETURNS TRIGGER
@@ -150,14 +145,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on responses table
CREATE TRIGGER on_response_update_streak
AFTER INSERT OR UPDATE OF answer ON responses
FOR EACH ROW
EXECUTE FUNCTION update_couple_streak();
EXECUTE FUNCTION update_couple_streak();
-- Function to reset daily flags (called by cron at UTC midnight)
CREATE OR REPLACE FUNCTION reset_daily_streak_flags()
RETURNS void
@@ -172,8 +165,7 @@ BEGIN
user2_answered_today = false
WHERE last_active_date < CURRENT_DATE;
END;
$$;
$$;
-- Schedule cron job for daily flag reset (runs at 00:00 UTC)
SELECT cron.schedule(
'reset-daily-streak-flags',
@@ -181,8 +173,7 @@ SELECT cron.schedule(
$$
SELECT reset_daily_streak_flags();
$$
);
);
-- Schedule cron job for checking streak milestones (runs at 00:05 UTC)
SELECT cron.schedule(
'check-streak-milestones',
@@ -194,4 +185,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -0,0 +1,221 @@
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id uuid DEFAULT NULL::uuid)
RETURNS TABLE(id uuid, text text, partner_text text, is_two_part boolean, pack_id uuid, intensity integer, partner_answered boolean, allowed_couple_genders text[], target_user_genders text[])
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_my_unanswered INTEGER;
v_partner_unanswered INTEGER;
v_net_gap INTEGER;
v_user_max_intensity INTEGER;
v_partner_max_intensity INTEGER;
v_effective_max_intensity INTEGER;
v_use_couple_intensity_gate BOOLEAN;
v_daily_limit INTEGER;
v_daily_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_couple_id, v_user_gender, v_user_max_intensity
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_partner_id, v_partner_gender, v_partner_max_intensity
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Toggle: gate questions by couple-effective intensity
SELECT COALESCE(ac.couple_intensity_gate_enabled, FALSE)
INTO v_use_couple_intensity_gate
FROM app_config ac
LIMIT 1;
v_use_couple_intensity_gate := COALESCE(v_use_couple_intensity_gate, FALSE);
v_effective_max_intensity := CASE
WHEN v_use_couple_intensity_gate AND v_partner_id IS NOT NULL THEN LEAST(v_user_max_intensity, v_partner_max_intensity)
ELSE v_user_max_intensity
END;
-- Check premium access (used for both answer gap and daily limit)
v_has_premium := public.has_premium_access(v_user_id);
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
FROM app_config ac
LIMIT 1;
IF v_daily_limit > 0 THEN
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
SELECT COUNT(*)::INTEGER INTO v_daily_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
IF v_daily_count >= v_daily_limit THEN
RETURN;
END IF;
END IF;
END IF;
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
IF v_gap_threshold > 0 THEN
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
) THEN 1 ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_user_id
) THEN 1 ELSE 0 END), 0)::integer
INTO v_my_unanswered, v_partner_unanswered
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.couple_id = v_couple_id
AND r.user_id IN (v_user_id, v_partner_id)
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
AND (
NOT v_use_couple_intensity_gate
OR q.intensity IS NULL
OR q.intensity <= v_effective_max_intensity
);
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
IF v_net_gap >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
)
AND (
q.intensity IS NULL
OR q.intensity <= v_effective_max_intensity
)
ORDER BY
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) DESC,
q.intensity ASC,
q.id ASC; -- ADDED: Deterministic tiebreaker to ensure both partners see questions in same order
END;
$function$;
@@ -0,0 +1,467 @@
-- Sync migration: Make idempotent for production
-- Extensions
create extension if not exists "hypopg" with schema "extensions";
create extension if not exists "index_advisor" with schema "extensions";
-- Drop policy if exists (will be recreated at the end)
drop policy if exists "Anyone can view questions in visible packs" on "public"."questions";
-- Drop and recreate constraint (idempotent)
alter table "public"."question_packs" drop constraint if exists "question_packs_category_id_fkey";
-- Note: pack_question_stats view is NOT recreated here.
-- The original migration (20260111100000_add_inverse_of_to_questions.sql) creates it correctly.
-- Recreating it causes persistent diff noise due to pg_dump formatting differences.
-- Create tables if not exists
create table if not exists "public"."pack_topics" (
"pack_id" uuid not null,
"topic_id" uuid not null
);
alter table "public"."pack_topics" enable row level security;
create table if not exists "public"."topics" (
"id" uuid not null default gen_random_uuid(),
"name" text not null,
"description" text,
"icon" text,
"sort_order" integer default 0,
"created_at" timestamp with time zone default now()
);
alter table "public"."topics" enable row level security;
-- Add columns if not exists
alter table "public"."admin_users" add column if not exists "permissions" jsonb default '[]'::jsonb;
alter table "public"."admin_users" disable row level security;
alter table "public"."ai_config" add column if not exists "cherry_pick_ensure_intensity_distribution" boolean default true;
alter table "public"."ai_config" add column if not exists "council_selection_mode" text default 'whole_set'::text;
alter table "public"."ai_config" add column if not exists "heuristic_keyword_triggers" text;
alter table "public"."ai_config" add column if not exists "heuristic_min_text_length" integer not null default 12;
alter table "public"."ai_config" add column if not exists "heuristic_record_reason" boolean not null default false;
alter table "public"."ai_config" add column if not exists "heuristic_skip_if_no_alnum" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_skip_media_without_text" boolean not null default false;
alter table "public"."ai_config" add column if not exists "heuristic_use_default_keywords" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_use_default_whitelist" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_whitelist" text;
alter table "public"."ai_config" add column if not exists "heuristic_whitelist_max_length" integer not null default 30;
alter table "public"."ai_config" add column if not exists "heuristics_enabled" boolean not null default false;
-- Create indexes if not exists
CREATE INDEX IF NOT EXISTS idx_pack_topics_pack_id ON public.pack_topics USING btree (pack_id);
CREATE UNIQUE INDEX IF NOT EXISTS pack_topics_pkey ON public.pack_topics USING btree (pack_id, topic_id);
CREATE UNIQUE INDEX IF NOT EXISTS topics_name_key ON public.topics USING btree (name);
CREATE UNIQUE INDEX IF NOT EXISTS topics_pkey ON public.topics USING btree (id);
-- Add constraints if not exists (check existence first)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_pkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_pkey" PRIMARY KEY USING INDEX "pack_topics_pkey";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'topics_pkey') THEN
ALTER TABLE "public"."topics" ADD CONSTRAINT "topics_pkey" PRIMARY KEY USING INDEX "topics_pkey";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_config_council_selection_mode_check') THEN
ALTER TABLE "public"."ai_config" ADD CONSTRAINT "ai_config_council_selection_mode_check"
CHECK ((council_selection_mode = ANY (ARRAY['whole_set'::text, 'cherry_pick'::text])));
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_pack_id_fkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_pack_id_fkey"
FOREIGN KEY (pack_id) REFERENCES public.question_packs(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_topic_id_fkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_topic_id_fkey"
FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'topics_name_key') THEN
ALTER TABLE "public"."topics" ADD CONSTRAINT "topics_name_key" UNIQUE USING INDEX "topics_name_key";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'question_packs_category_id_fkey') THEN
ALTER TABLE "public"."question_packs" ADD CONSTRAINT "question_packs_category_id_fkey"
FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE CASCADE;
END IF;
END $$;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.get_profiles_with_auth_info()
RETURNS TABLE(id uuid, name text, email text, avatar_url text, is_premium boolean, couple_id uuid, created_at timestamp with time zone, last_sign_in_at timestamp with time zone, email_confirmed_at timestamp with time zone)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
p.id,
COALESCE(p.name, au.raw_user_meta_data->>'name') as name,
COALESCE(p.email, au.email) as email,
p.avatar_url,
p.is_premium,
p.couple_id,
p.created_at,
au.last_sign_in_at,
au.email_confirmed_at
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
ORDER BY p.created_at DESC;
$function$
;
CREATE OR REPLACE FUNCTION public.get_user_media_files(user_id uuid)
RETURNS TABLE(id uuid, name text, bucket_id text, created_at timestamp with time zone, metadata jsonb)
LANGUAGE sql
SECURITY DEFINER
AS $function$
SELECT
id,
name,
bucket_id,
created_at,
metadata
FROM storage.objects
WHERE bucket_id = 'chat-media'
AND owner = user_id
ORDER BY created_at DESC;
$function$
;
CREATE OR REPLACE FUNCTION public.get_user_storage_usage()
RETURNS TABLE(owner uuid, total_bytes bigint)
LANGUAGE sql
SECURITY DEFINER
AS $function$
SELECT
owner,
SUM((metadata->>'size')::bigint) as total_bytes
FROM storage.objects
WHERE bucket_id = 'chat-media'
AND owner IS NOT NULL
GROUP BY owner;
$function$
;
CREATE OR REPLACE FUNCTION public.cleanup_expired_videos()
RETURNS TABLE(deleted_count integer, deleted_paths text[])
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
expired_paths text[];
count int;
BEGIN
SELECT array_agg(media_path)
INTO expired_paths
FROM messages
WHERE media_type = 'video'
AND media_expires_at IS NOT NULL
AND media_expires_at < NOW()
AND media_expired = false
AND media_path IS NOT NULL;
WITH updated AS (
UPDATE messages
SET media_expired = true, media_path = NULL
WHERE media_type = 'video'
AND media_expires_at IS NOT NULL
AND media_expires_at < NOW()
AND media_expired = false
AND media_path IS NOT NULL
RETURNING 1
)
SELECT count(*) INTO count FROM updated;
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.get_pack_unique_question_count(pack_uuid uuid)
RETURNS integer
LANGUAGE sql
STABLE
AS $function$
SELECT COUNT(*)::INTEGER
FROM questions
WHERE pack_id = pack_uuid
AND inverse_of IS NULL
AND deleted_at IS NULL;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
INSERT INTO public.profiles (id, created_at, updated_at, onboarding_completed)
VALUES (NEW.id, NOW(), NOW(), false);
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.notify_new_message()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
PERFORM net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-message-notification',
headers := jsonb_build_object(
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'match_id', NEW.match_id,
'sender_id', NEW.user_id
)
);
RETURN NEW;
END;
$function$
;
-- pack_question_stats view creation removed - see comment at top of file
grant delete on table "public"."pack_topics" to "anon";
grant insert on table "public"."pack_topics" to "anon";
grant references on table "public"."pack_topics" to "anon";
grant select on table "public"."pack_topics" to "anon";
grant trigger on table "public"."pack_topics" to "anon";
grant truncate on table "public"."pack_topics" to "anon";
grant update on table "public"."pack_topics" to "anon";
grant delete on table "public"."pack_topics" to "authenticated";
grant insert on table "public"."pack_topics" to "authenticated";
grant references on table "public"."pack_topics" to "authenticated";
grant select on table "public"."pack_topics" to "authenticated";
grant trigger on table "public"."pack_topics" to "authenticated";
grant truncate on table "public"."pack_topics" to "authenticated";
grant update on table "public"."pack_topics" to "authenticated";
grant delete on table "public"."pack_topics" to "service_role";
grant insert on table "public"."pack_topics" to "service_role";
grant references on table "public"."pack_topics" to "service_role";
grant select on table "public"."pack_topics" to "service_role";
grant trigger on table "public"."pack_topics" to "service_role";
grant truncate on table "public"."pack_topics" to "service_role";
grant update on table "public"."pack_topics" to "service_role";
grant delete on table "public"."topics" to "anon";
grant insert on table "public"."topics" to "anon";
grant references on table "public"."topics" to "anon";
grant select on table "public"."topics" to "anon";
grant trigger on table "public"."topics" to "anon";
grant truncate on table "public"."topics" to "anon";
grant update on table "public"."topics" to "anon";
grant delete on table "public"."topics" to "authenticated";
grant insert on table "public"."topics" to "authenticated";
grant references on table "public"."topics" to "authenticated";
grant select on table "public"."topics" to "authenticated";
grant trigger on table "public"."topics" to "authenticated";
grant truncate on table "public"."topics" to "authenticated";
grant update on table "public"."topics" to "authenticated";
grant delete on table "public"."topics" to "service_role";
grant insert on table "public"."topics" to "service_role";
grant references on table "public"."topics" to "service_role";
grant select on table "public"."topics" to "service_role";
grant trigger on table "public"."topics" to "service_role";
grant truncate on table "public"."topics" to "service_role";
grant update on table "public"."topics" to "service_role";
-- Policies: drop if exists then create
drop policy if exists "Admins can view couple pack settings" on "public"."couple_packs";
create policy "Admins can view couple pack settings"
on "public"."couple_packs"
as permissive
for select
to public
using ((public.is_super_admin() OR public.has_permission('view_users'::text)));
drop policy if exists "Admins can delete pack_topics" on "public"."pack_topics";
create policy "Admins can delete pack_topics"
on "public"."pack_topics"
as permissive
for delete
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can insert pack_topics" on "public"."pack_topics";
create policy "Admins can insert pack_topics"
on "public"."pack_topics"
as permissive
for insert
to authenticated
with check ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can update pack_topics" on "public"."pack_topics";
create policy "Admins can update pack_topics"
on "public"."pack_topics"
as permissive
for update
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Pack topics are viewable by authenticated users" on "public"."pack_topics";
create policy "Pack topics are viewable by authenticated users"
on "public"."pack_topics"
as permissive
for select
to authenticated
using (true);
drop policy if exists "Admins can delete topics" on "public"."topics";
create policy "Admins can delete topics"
on "public"."topics"
as permissive
for delete
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can insert topics" on "public"."topics";
create policy "Admins can insert topics"
on "public"."topics"
as permissive
for insert
to authenticated
with check ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can update topics" on "public"."topics";
create policy "Admins can update topics"
on "public"."topics"
as permissive
for update
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Topics are viewable by authenticated users" on "public"."topics";
create policy "Topics are viewable by authenticated users"
on "public"."topics"
as permissive
for select
to authenticated
using (true);
-- This policy was dropped at the start, recreate it
create policy "Anyone can view questions in visible packs"
on "public"."questions"
as permissive
for select
to public
using (((deleted_at IS NULL) AND (pack_id IN ( SELECT question_packs.id
FROM public.question_packs
WHERE (((question_packs.is_public = true) OR ((question_packs.is_premium = true) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.is_premium = true)))))) AND ((question_packs.category_id IS NULL) OR (EXISTS ( SELECT 1
FROM public.categories c
WHERE ((c.id = question_packs.category_id) AND (c.is_public = true))))))))));
-- Storage policies: drop if exists then create
drop policy if exists "Anyone can view avatars" on "storage"."objects";
create policy "Anyone can view avatars"
on "storage"."objects"
as permissive
for select
to public
using ((bucket_id = 'avatars'::text));
drop policy if exists "Users can delete own avatar" on "storage"."objects";
create policy "Users can delete own avatar"
on "storage"."objects"
as permissive
for delete
to public
using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
drop policy if exists "Users can update own avatar" on "storage"."objects";
create policy "Users can update own avatar"
on "storage"."objects"
as permissive
for update
to public
using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
drop policy if exists "Users can upload own avatar" on "storage"."objects";
create policy "Users can upload own avatar"
on "storage"."objects"
as permissive
for insert
to public
with check (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Run content seed against non-production database
#
# Usage: ./run-seed-nonprod.sh
#
# You'll be prompted for the database password.
# Get it from: Supabase Dashboard > Settings > Database > Database password
set -e
# Non-production database details
HOST="aws-0-eu-west-1.pooler.supabase.com"
PORT="6543"
USER="postgres.itbzhrvlgvdmzbnhzhyx"
DB="postgres"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SEED_FILE="$SCRIPT_DIR/content-seed.sql"
if [ ! -f "$SEED_FILE" ]; then
echo "Error: Seed file not found at $SEED_FILE"
exit 1
fi
echo "=== Running content seed against Sauci Non-Production ==="
echo "Host: $HOST"
echo "Database: $DB"
echo ""
echo "This will REPLACE all content (categories, topics, question_packs, questions, dare_packs, dares, pack_topics)"
echo ""
read -p "Continue? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
echo "Running seed file (2782 lines)..."
echo ""
psql "postgresql://$USER@$HOST:$PORT/$DB?sslmode=require" -f "$SEED_FILE"
echo ""
echo "=== Done! ==="
+185
View File
@@ -0,0 +1,185 @@
-- Sync production content to non-production
-- Generated from production database
BEGIN;
-- Clear existing content (in reverse dependency order)
DELETE FROM pack_topics;
DELETE FROM questions;
DELETE FROM dares;
DELETE FROM question_packs;
DELETE FROM dare_packs;
DELETE FROM topics;
DELETE FROM categories;
-- Insert categories
INSERT INTO categories (id, name, description, icon, sort_order, created_at, is_public) VALUES
('c1000000-0000-0000-0000-000000000002', 'Adventure & Travel', 'Explore the world together and create unforgettable memories', 'map-outline', 0, '2025-12-30 20:03:27.663523+00', true),
('fcb36b61-6081-4bf6-9267-1ba9ba75fc08', 'Quality Time', NULL, 'chatbubbles-outline', 1, '2026-01-10 13:27:22.867947+00', true),
('ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da', 'Social Life', 'Figure out how you both like to spend time with others. From quiet dinners to big parties.', 'people-outline', 2, '2026-01-10 18:11:08.278631+00', true),
('ecabc509-9fed-48cb-bd60-c5d830d2491d', 'Long Distance', 'Stay connected and build intimacy when you''re miles apart.', 'airplane-outline', 3, '2026-01-10 16:23:37.389445+00', true),
('9eb47dce-d343-4e24-8402-252f188db2f2', 'Getting Started', 'Discover your compatibility and establish boundaries together', 'flag-outline', 4, '2026-01-10 20:59:35.817421+00', true),
('c1000000-0000-0000-0000-000000000004', 'Romance & Sensuality', 'Deepen your romantic and sensual connection', 'heart-outline', 5, '2025-12-30 20:03:27.663523+00', true),
('1e52e0b2-f853-4b99-9ca8-7231adbc639e', 'Sensual Discovery', 'Discover playful touch and tender moments of connection.', 'compass-outline', 6, '2026-01-10 14:11:33.114458+00', true),
('c1000000-0000-0000-0000-000000000005', 'Fantasy Exploration', 'Discover and share your secret desires', 'moon-outline', 7, '2025-12-30 20:03:27.663523+00', false),
('c1000000-0000-0000-0000-000000000007', 'Bedroom Adventures', 'Explore new ways to connect intimately', 'bed-outline', 8, '2025-12-30 20:03:27.663523+00', false),
('c1000000-0000-0000-0000-000000000006', 'Spicy Challenges', 'Push boundaries with exciting dares and activities', 'flame-outline', 9, '2025-12-30 20:03:27.663523+00', false),
('fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', 'Toy Time', 'Discover playful accessories to spice up your romance.', 'gift-outline', 10, '2026-01-10 14:12:04.292251+00', false),
('d1000000-0000-0000-0000-000000000001', 'Roleplay', 'Step into character and explore fantasies together', 'color-wand-outline', 11, '2026-01-10 22:35:42.743811+00', true),
('46dc3730-703b-4f79-82e5-3e23d0a778d7', 'The Kink Lab', 'A curious, judgment-free space to experiment with new kinks, toys, and physical sensations.', 'flask-outline', 12, '2026-01-01 10:20:07.042855+00', true),
('57da058a-da7f-48d7-9738-30573ad9cb5d', 'Public Thrills', 'Take the excitement outside the bedroom. From subtle touches to risky adventures.', 'eye-outline', 13, '2026-01-10 16:32:50.303497+00', true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
is_public = EXCLUDED.is_public;
-- Insert topics
INSERT INTO topics (id, name, description, icon, sort_order, created_at) VALUES
('d62fe605-7cc7-4df1-803f-dc3c49ed389c', 'Oral', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('f0c04e96-7d0e-4e11-8a04-fd81a458ed0f', 'Exhibitionism', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('8b7c6e32-22bc-4218-b0c8-10cc24357be4', 'Power Play', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('1e9d6cf6-897f-47e6-acd7-50198872cee7', 'Teasing', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('02df8c3f-2db4-4be6-bcc2-d83b319c80b1', 'Adventure', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('92490542-9e98-4f23-ad17-775ead57c4c5', 'Sensory Play', NULL, NULL, 0, '2026-01-01 09:26:13.984335+00'),
('e832c389-8ed7-4807-8942-2a7aca979aca', 'Denial', NULL, NULL, 0, '2026-01-01 09:26:13.984335+00'),
('e9cce268-e378-4a62-accf-1d719a574ba2', 'Public Play', NULL, NULL, 0, '2026-01-10 14:34:39.115419+00'),
('10a437f9-103b-404f-8af0-f578adbe3eb3', 'Toys', NULL, NULL, 0, '2026-01-10 16:03:38.586861+00'),
('70dac37c-93e2-43d7-9ea8-14c4a88fbbdd', 'Bondage', NULL, NULL, 0, '2026-01-10 16:05:40.363422+00'),
('d6bc3fcb-d095-4eac-b28b-dbd45fb769c3', 'Long Distance', NULL, NULL, 0, '2026-01-10 16:27:19.423618+00'),
('9fe84cc4-1826-4920-8d67-9f4ae8692e2b', 'Romance', NULL, NULL, 0, '2026-01-10 16:27:19.423618+00'),
('28cef553-ea0d-4dfa-8515-ddc29e003e73', 'Communication', NULL, NULL, 0, '2026-01-10 16:27:19.423618+00'),
('a4fe63e3-efb8-4351-89a8-3f4bfb531a45', 'Voyeurism', NULL, NULL, 0, '2026-01-10 16:27:46.907948+00'),
('66932305-7888-4272-8b53-7c550e96429a', 'Socializing', NULL, NULL, 0, '2026-01-10 18:12:45.879724+00'),
('2386f22d-5d13-444a-add1-5d7c21fb6fd2', 'Lifestyle', NULL, NULL, 0, '2026-01-10 18:12:45.879724+00'),
('fa537922-46b0-4e64-a69a-174a83898983', 'Role Play', NULL, NULL, 0, '2026-01-10 18:36:50.496586+00'),
('1e2fad8b-0543-4d30-bee2-59478d2872c0', 'Dirty Talk', NULL, NULL, 0, '2026-01-10 18:37:09.687791+00'),
('22eefbc4-49f4-40b5-bf4b-532116678fbd', 'Anal', NULL, NULL, 0, '2026-01-10 18:37:18.866239+00'),
('a3aabe8d-d7b4-4b01-bb45-425a1187dbe5', 'Sex Positions', NULL, NULL, 0, '2026-01-10 18:37:21.669201+00'),
('b001c5f4-50a5-42d4-bbf8-17ba325da5c3', 'Massage', NULL, NULL, 0, '2026-01-10 18:37:44.145124+00'),
('ac37e45d-ed51-4205-ab02-c2632824022f', 'Acts of Service', NULL, NULL, 0, '2026-01-10 21:54:22.972186+00'),
('1618b36a-9130-4e95-b4e5-ac8f3da2ed17', 'Medical Play', NULL, NULL, 0, '2026-01-11 16:15:47.190027+00'),
('4ccfd2d4-d670-42da-91e0-c7681099d9d3', 'Breeding', NULL, NULL, 0, '2026-01-11 16:15:47.190027+00')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order;
-- Insert question_packs
INSERT INTO question_packs (id, name, description, icon, is_premium, is_public, sort_order, created_at, category_id, is_explicit, min_intensity, max_intensity, avg_intensity, scheduled_release_at, release_notified) VALUES
('44295bd5-fda3-4ed6-8aa6-d5c7e811348e', 'Intimate Games', 'Playful activities for the bedroom', 'dice-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000007', true, 3, 5, 3.79, NULL, false),
('42c00d6c-ab1f-42ce-afb0-00839064263b', 'Romantic Gestures', 'Sweet and intimate ways to show love', 'flower-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', false, 2, 4, 2.63, NULL, false),
('d18f6d4a-4a32-4687-8938-2a53a7d82739', 'Starting Out', 'Perfect for beginners, this playful pack introduces simple, romantic ways to explore with toys.', 'rocket-outline', false, true, 0, '2026-01-10 14:12:51.542066+00', 'fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', true, 3, 5, 3.42, NULL, false),
('8edf2abc-dbb5-4a11-ac5c-15ee60aef3b4', 'Forbidden Fantasy Dates', 'Act out bold scenarios like doctor/patient or boss/assistant in immersive, role-play evenings. Dive into uninhibited storytelling for unforgettable nights.', 'color-wand-outline', false, false, 0, '2026-01-01 22:05:09.528276+00', '46dc3730-703b-4f79-82e5-3e23d0a778d7', true, 2, 5, 4.13, NULL, false),
('fd6f2a49-1057-4f30-aab4-7cd185764f7e', 'Staying Close', 'Real daily connection for when you''re apart. Video calls, voice notes, and shared moments.', 'link-outline', false, true, 0, '2026-01-10 16:23:43.207456+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', false, 1, 1, 1.00, NULL, false),
('877d8a39-5229-4263-bffc-d3043015603f', 'Finding Your Vibe', 'Discover what social activities you both enjoy - from quiet nights to big parties.', 'musical-notes-outline', false, true, 0, '2026-01-10 18:11:18.16816+00', 'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da', false, 1, 1, 1.00, NULL, false),
('91dacfed-46ae-462d-aae3-5e540fe4a973', 'Weekend Warriors', 'Local adventures for your days off', 'car-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000002', false, 1, 2, 1.02, NULL, false),
('d2000000-0000-0000-0000-000000000001', 'First Impressions', 'Light roleplay scenarios: strangers, flirty encounters, and playful power dynamics', 'sparkles-outline', false, true, 0, '2026-01-10 22:35:42.743811+00', 'd1000000-0000-0000-0000-000000000001', false, 2, 3, 2.77, NULL, false),
('8da8dcb1-0b9b-4828-9a30-199758b8ade5', 'Date Night Ideas', NULL, 'wine-outline', false, true, 0, '2026-01-10 13:28:59.92781+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, 1, 2, 1.05, NULL, false),
('dd143c55-c0a4-4884-a704-1d4d1325cbc2', 'Pleasure Experiments', 'Try something new together', 'flask-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000006', true, 2, 5, 3.33, NULL, false),
('f0f597b1-889c-4537-beeb-d0f459bb7907', 'Body Mapping', 'Discover where your partner loves to be touched', 'body-outline', false, true, 0, '2026-01-10 19:55:02.290269+00', '1e52e0b2-f853-4b99-9ca8-7231adbc639e', false, 2, 3, 2.18, NULL, false),
('3c17764d-dd11-4103-8a4f-ce6561fd2a1a', 'Boundary Discovery', 'Safely explore new territory together', 'key-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 2, 5, 3.40, NULL, false),
('d5d1b8d2-a868-4150-86bc-da3f8e3e8b00', 'Testing the Waters', 'Subtle thrills in public. Whispered words, secret touches, and hidden tension.', 'water-outline', false, true, 0, '2026-01-10 16:32:57.111957+00', '57da058a-da7f-48d7-9738-30573ad9cb5d', true, 2, 3, 2.27, NULL, false),
('c7244576-f40d-4cdf-804a-788d2b9c1fce', 'Kink Discovery', 'Figure out what you''re both into. Match on interests, then explore the ones that excite you both.', 'compass-outline', true, true, 1, '2026-01-10 18:28:28.212356+00', '46dc3730-703b-4f79-82e5-3e23d0a778d7', true, 2, 5, 4.22, NULL, false),
('bed94ed6-c065-4ff9-9d7f-02de6cade971', 'Sensory Play', 'Heighten your senses and sensations', 'eye-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000007', true, 3, 5, 3.73, NULL, false),
('1d2ed912-1429-4045-a608-35b8db63f9b4', 'Food & Culture', 'Culinary adventures and cultural experiences', 'restaurant-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000002', false, 1, 1, 1.00, NULL, false),
('40838dee-0af4-43c7-9d6c-6228d09f91bc', 'Dream Scenarios', 'The wildest what-ifs you can imagine', 'cloudy-outline', true, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 3, 5, 4.10, NULL, false),
('eb3cbe67-23d8-4c0e-9c6f-dc1583609a26', 'Sensual Touch', 'Explore physical connection and intimacy', 'sparkles-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', true, 2, 4, 2.94, NULL, false),
('374e10a6-6ae9-45c6-bee4-b8be0e617b79', 'Leveling Up Together', 'Take your toy play to the next level with remote-controlled fun, edging, temperature play, and creative combinations.', 'trending-up-outline', true, true, 1, '2026-01-10 16:02:28.36573+00', 'fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', true, 3, 5, 4.12, NULL, false),
('8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c', 'Sensual Massage', 'Learn to relax and arouse your partner through intentional touch', 'hand-left-outline', false, true, 1, '2026-01-10 19:55:02.290269+00', '1e52e0b2-f853-4b99-9ca8-7231adbc639e', false, 2, 3, 2.67, NULL, false),
('19e4ddbd-a4aa-4b26-80bb-53b7187f0eba', 'Missing You', 'Flirty messages, longing, and keeping the spark alive from afar.', 'airplane-outline', false, true, 1, '2026-01-10 16:24:07.455539+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', false, 1, 3, 1.92, NULL, false),
('e11bdfea-ab07-46a9-952d-5c00c8b4ddbb', 'Everyday Moments', 'Small daily rituals and micro-moments that strengthen your bond.', 'sunny-outline', false, true, 1, '2026-01-10 20:21:32.792634+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, 1, 1, 1.00, NULL, false),
('d6128ed8-e209-43db-88eb-e9d0115ba8d7', 'Hosting', 'Find your style for having people over - from casual hangs to dinner parties.', 'home-outline', false, true, 1, '2026-01-10 18:16:36.025997+00', 'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da', false, 1, 1, 1.00, NULL, false),
('7eabdfae-e70d-4be8-9b10-b43fee955312', 'Know Your Limits', 'Discover where you both stand on the basics - from oral to anal to toys and beyond', 'shield-outline', false, true, 1, '2026-01-10 20:59:45.895706+00', '9eb47dce-d343-4e24-8402-252f188db2f2', true, 3, 5, 3.74, NULL, false),
('b2df6b3b-4bb5-4baa-a29c-a3955187511c', 'Tease & Tension', 'Build anticipation and desire', 'flame-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000006', true, 2, 5, 3.43, NULL, false),
('d2000000-0000-0000-0000-000000000002', 'Fantasy Scenarios', 'Classic fantasy roleplay: professional encounters with a sexy twist', 'cloud-outline', false, true, 1, '2026-01-10 22:35:42.743811+00', 'd1000000-0000-0000-0000-000000000001', true, 3, 4, 3.63, NULL, false),
('42331f68-60f5-4768-af26-b42c4e96c867', 'Pushing Limits', 'Getting bolder. Hidden touches, risky locations, and the thrill of almost getting caught.', 'fitness-outline', false, true, 1, '2026-01-10 16:33:31.946182+00', '57da058a-da7f-48d7-9738-30573ad9cb5d', true, 2, 4, 3.40, NULL, false),
('8bf628d0-2a83-4a12-a66b-39b0d6ff1e86', 'Private Line', 'When the late night calls get explicit. Sexting, phone sex, and video intimacy.', 'chatbubble-outline', true, true, 2, '2026-01-10 16:26:51.369772+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', true, 2, 4, 3.38, NULL, false),
('0f2b3400-05d4-40db-b8fa-41c92ab877eb', 'Outdoor Explorers', 'Nature adventures and outdoor activities', 'map-outline', false, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000002', false, 1, 1, 1.00, NULL, false),
('f0a2e7e5-554c-49c4-aa15-42d83b016349', 'Dare to Play', 'Bold intimate dares for adventurous couples', 'dice-outline', true, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000006', true, 2, 5, 3.87, NULL, false),
('035c47a6-858c-46fb-8ee3-84889399dca0', 'Position Exploration', 'New ways to connect physically', 'sync-outline', true, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000007', true, 3, 5, 3.82, NULL, false),
('9049a67a-a103-4ea3-8405-87dcaefc57ab', 'All Eyes On Us', 'High risk, high reward. Sex in risky places and the thrill of being caught.', 'glasses-outline', true, true, 2, '2026-01-10 16:34:06.362741+00', '57da058a-da7f-48d7-9738-30573ad9cb5d', true, 4, 5, 4.66, NULL, false),
('379d6a44-60a3-4ec9-98cc-eda46547cb23', 'Role Play Ideas', 'Characters and scenarios to explore together', 'color-wand-outline', true, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 2, 5, 4.13, NULL, false),
('46288aa4-271c-44cf-bfe2-d25662d3f9f8', 'Date Night Sparks', 'Turn up the heat on your evenings out', 'wine-outline', false, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', true, 2, 4, 2.76, NULL, false),
('f9a59905-50d3-442d-b799-ff64f5b6fd40', 'BDSM', 'A playful guide to exploring your BDSM curiosities together.', 'lock-closed-outline', false, false, 2, '2026-01-10 14:59:13.196109+00', '46dc3730-703b-4f79-82e5-3e23d0a778d7', true, 4, 5, 4.80, NULL, false),
('d2000000-0000-0000-0000-000000000003', 'Deep in Character', 'Advanced power dynamics and intense roleplay scenarios for experienced couples', 'people-outline', true, true, 2, '2026-01-10 22:35:42.743811+00', 'd1000000-0000-0000-0000-000000000001', true, 4, 5, 4.43, NULL, false),
('48150d71-bf95-49b0-924f-e9bcff63fc03', 'Relationship Essentials', 'The foundation of us: exploring the essentials of a healthy, happy relationship.', 'heart-half-outline', false, true, 2, '2026-01-04 14:10:46.042555+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, 1, 1, 1.00, NULL, false),
('d73e9818-4faa-4276-9a93-7c60cff9cdb1', 'The Deep End', 'Advanced toy play for experienced couples: bondage furniture, electro-stim, machines, extended edging, and intense power dynamics.', 'water-outline', true, true, 2, '2026-01-10 16:09:27.096652+00', 'fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', true, 4, 5, 4.82, NULL, false),
('e5aefe29-1ada-4fe5-a4ee-25342917f02c', 'Secret Desires', 'Share your hidden fantasies with your partner', 'eye-off-outline', true, true, 3, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 2, 4, 3.09, NULL, false),
('aea8bb8f-de02-41ad-bd1f-7616780dde30', 'Weekend Adventures', 'A playful weekend getaway with fun activities for two.', 'walk-outline', false, false, 3, '2026-01-10 14:09:45.123904+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, NULL, NULL, NULL, NULL, false),
('07f0da41-8ef7-4a48-8ec9-cef2dabaee73', 'Love Notes', 'Express your deepest feelings', 'mail-outline', false, true, 3, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', true, 2, 4, 2.80, NULL, false),
('7951e8c4-c7d4-4460-a3f5-495ccf5ca0cb', 'Total Control', 'Remote toys, edging, and power dynamics from miles away.', 'hand-right-outline', true, true, 3, '2026-01-10 16:28:54.328298+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', true, 4, 5, 4.76, NULL, false),
('1a138cfd-a8be-46c3-b5da-41e7b57e5ace', 'Language Exploration', 'Explore the power of words and how you express your affection, both in quiet moments and your most intimate moments.', 'chatbubbles-outline', true, false, 4, '2026-01-10 15:34:18.481318+00', 'c1000000-0000-0000-0000-000000000005', true, 3, 5, 3.93, NULL, false)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
is_premium = EXCLUDED.is_premium,
is_public = EXCLUDED.is_public,
sort_order = EXCLUDED.sort_order,
category_id = EXCLUDED.category_id,
is_explicit = EXCLUDED.is_explicit,
min_intensity = EXCLUDED.min_intensity,
max_intensity = EXCLUDED.max_intensity,
avg_intensity = EXCLUDED.avg_intensity;
-- Insert dare_packs
INSERT INTO dare_packs (id, name, description, icon, is_premium, is_public, is_explicit, sort_order, category_id, min_intensity, max_intensity, avg_intensity, created_at) VALUES
('a1b2c3d4-1111-1111-1111-111111111111', 'Romantic Gestures', 'Sweet and thoughtful dares to show your love', '💕', false, true, false, 1, NULL, 1, 3, 2.00, '2026-01-20 21:05:55.039767+00'),
('a1b2c3d4-2222-2222-2222-222222222222', 'Playful Teasing', 'Fun and flirty dares to keep things exciting', '😏', false, true, false, 2, NULL, 2, 4, 2.71, '2026-01-20 21:05:55.039767+00'),
('a1b2c3d4-3333-3333-3333-333333333333', 'Adventure Time', 'Push your boundaries with exciting challenges', '🔥', true, true, false, 3, NULL, 3, 5, 3.71, '2026-01-20 21:05:55.039767+00'),
('a1b2c3d4-4444-4444-4444-444444444444', 'Intimate Moments', 'Sensual dares for close connection', '🌙', true, true, true, 4, NULL, 3, 5, 4.00, '2026-01-20 21:05:55.039767+00')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
is_premium = EXCLUDED.is_premium,
is_public = EXCLUDED.is_public,
is_explicit = EXCLUDED.is_explicit,
sort_order = EXCLUDED.sort_order,
min_intensity = EXCLUDED.min_intensity,
max_intensity = EXCLUDED.max_intensity,
avg_intensity = EXCLUDED.avg_intensity;
-- Insert dares
INSERT INTO dares (id, pack_id, text, intensity, suggested_duration_hours, created_at) VALUES
('267ba4d9-f5cb-4f9c-9529-bb8a5a169634', 'a1b2c3d4-1111-1111-1111-111111111111', 'Write a love note and hide it somewhere your partner will find it', 1, 24, '2026-01-20 21:06:03.390752+00'),
('3d104d0d-604f-4e1e-814d-20a9036db051', 'a1b2c3d4-1111-1111-1111-111111111111', 'Give your partner a 10-minute massage', 1, 2, '2026-01-20 21:06:03.390752+00'),
('0ba8c19d-190f-4a2c-8804-8b4138b6e94f', 'a1b2c3d4-1111-1111-1111-111111111111', 'Cook or order your partner''s favorite meal', 2, 24, '2026-01-20 21:06:03.390752+00'),
('8b2c1546-3053-4de3-a232-ce655c5f72ee', 'a1b2c3d4-1111-1111-1111-111111111111', 'Plan a surprise date night', 2, 48, '2026-01-20 21:06:03.390752+00'),
('ee9ee19a-de01-481d-bb13-288bd0b11b62', 'a1b2c3d4-1111-1111-1111-111111111111', 'Slow dance together to your favorite song', 2, 4, '2026-01-20 21:06:03.390752+00'),
('898aac6b-da8e-4477-85b8-73d99b77cfc7', 'a1b2c3d4-1111-1111-1111-111111111111', 'Create a playlist of songs that remind you of your relationship', 3, 24, '2026-01-20 21:06:03.390752+00'),
('cc64bd86-4c88-4cdc-890a-58cbaca32f25', 'a1b2c3d4-1111-1111-1111-111111111111', 'Recreate your first date', 3, 72, '2026-01-20 21:06:03.390752+00'),
('f08f5a35-2229-4c6c-a6d3-daff27297df2', 'a1b2c3d4-2222-2222-2222-222222222222', 'Send a flirty text every hour for the next 4 hours', 2, 4, '2026-01-20 21:06:10.980367+00'),
('849734a1-2b5d-4d4f-8065-c096ea096e44', 'a1b2c3d4-2222-2222-2222-222222222222', 'Whisper something naughty in your partner''s ear in public', 2, 2, '2026-01-20 21:06:10.980367+00'),
('c1e99ac6-1424-40e1-9288-1995ef8953e2', 'a1b2c3d4-2222-2222-2222-222222222222', 'Give your partner 3 compliments about their appearance', 2, 1, '2026-01-20 21:06:10.980367+00'),
('7c861f12-cb07-4460-b348-b38997ff5176', 'a1b2c3d4-2222-2222-2222-222222222222', 'Send a suggestive photo (keep it tasteful!)', 3, 4, '2026-01-20 21:06:10.980367+00'),
('547d4e7d-3977-4ef4-aa9e-2c24f30bd910', 'a1b2c3d4-2222-2222-2222-222222222222', 'Tease your partner for 10 minutes without letting them touch you', 3, 1, '2026-01-20 21:06:10.980367+00'),
('bf94334a-d9f3-4952-81ba-1d0e9693f0f1', 'a1b2c3d4-2222-2222-2222-222222222222', 'Wear something you know your partner finds irresistible', 3, 12, '2026-01-20 21:06:10.980367+00'),
('860a0377-0f41-4b8d-81d8-2fec35df6b92', 'a1b2c3d4-2222-2222-2222-222222222222', 'Blindfold your partner and feed them dessert', 4, 2, '2026-01-20 21:06:10.980367+00'),
('ca311842-a4c7-49e3-8836-5a423599f352', 'a1b2c3d4-3333-3333-3333-333333333333', 'Try a new activity together that neither of you has done before', 3, 72, '2026-01-20 21:06:18.466438+00'),
('9825ea49-4024-447e-86c9-af4e29d73ed0', 'a1b2c3d4-3333-3333-3333-333333333333', 'Have a spontaneous photoshoot together', 3, 4, '2026-01-20 21:06:18.466438+00'),
('eebb8795-7372-4014-a4cc-d30c2fed9f32', 'a1b2c3d4-3333-3333-3333-333333333333', 'Play truth or dare for 30 minutes', 3, 1, '2026-01-20 21:06:18.466438+00'),
('b99a8825-7c50-4152-bc7b-5b00fa7bf53d', 'a1b2c3d4-3333-3333-3333-333333333333', 'Role-play as strangers meeting for the first time at a bar', 4, 4, '2026-01-20 21:06:18.466438+00'),
('396cd465-f0fd-4f92-a9c5-4e9ce7225b2a', 'a1b2c3d4-3333-3333-3333-333333333333', 'Spend an entire evening without using your phones', 4, 6, '2026-01-20 21:06:18.466438+00'),
('fe7e7bd1-8b87-4859-aac7-5b788b2a54ee', 'a1b2c3d4-3333-3333-3333-333333333333', 'Let your partner pick your outfit for the entire day', 4, 24, '2026-01-20 21:06:18.466438+00'),
('72cba008-ff85-4edb-9d5f-882bca4ff84f', 'a1b2c3d4-3333-3333-3333-333333333333', 'Be your partner''s servant for an hour - do whatever they ask', 5, 1, '2026-01-20 21:06:18.466438+00'),
('860a6797-b4c6-4271-a327-2e26101f8479', 'a1b2c3d4-4444-4444-4444-444444444444', 'Give your partner a sensual full-body massage with oils', 3, 2, '2026-01-20 21:06:26.01169+00'),
('158f04d8-fc3f-448c-856e-369de6b9d98b', 'a1b2c3d4-4444-4444-4444-444444444444', 'Take a bath or shower together', 3, 1, '2026-01-20 21:06:26.01169+00'),
('681890fc-c29f-44a2-8885-1d59d5e24399', 'a1b2c3d4-4444-4444-4444-444444444444', 'Kiss every part of your partner''s body', 4, 1, '2026-01-20 21:06:26.01169+00'),
('4fce4684-5118-48a0-9353-de6320b625dd', 'a1b2c3d4-4444-4444-4444-444444444444', 'Try a new position tonight', 4, 4, '2026-01-20 21:06:26.01169+00'),
('056689e4-3f48-4c11-ae58-0fde6e2cb2a4', 'a1b2c3d4-4444-4444-4444-444444444444', 'Describe your deepest fantasy to your partner', 4, 2, '2026-01-20 21:06:26.01169+00'),
('858acf47-8a71-4f6a-a909-0c07ee2a03b6', 'a1b2c3d4-4444-4444-4444-444444444444', 'Spend 30 minutes on foreplay only', 5, 1, '2026-01-20 21:06:26.01169+00'),
('930ef187-09e5-47fd-ad88-3f4a10a10a74', 'a1b2c3d4-4444-4444-4444-444444444444', 'Let your partner be completely in control tonight', 5, 4, '2026-01-20 21:06:26.01169+00')
ON CONFLICT (id) DO UPDATE SET
pack_id = EXCLUDED.pack_id,
text = EXCLUDED.text,
intensity = EXCLUDED.intensity,
suggested_duration_hours = EXCLUDED.suggested_duration_hours;
COMMIT;
-- Questions will be loaded separately due to large size
-- Run: supabase db execute --file seed-questions.sql