mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Align prod schema and deploy tooling
This commit is contained in:
1 parent
d2db86ec7f
commit
7e2e723880
105 files changed
+19014
-131
No files matched your search
@@ -0,0 +1 @@
|
||||
main
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
$$
|
||||
);
|
||||
);
|
||||
File renamed without changes.
File renamed without changes.
+221
@@ -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])));
|
||||
|
||||
|
||||
|
||||
Executable
+46
@@ -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! ==="
|
||||
@@ -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
|
||||
Reference in new issue
Block a user