Add streaks, scheduled releases, and notification settings

- Couple streaks with milestone notifications
- Scheduled pack releases with admin UI
- Notification preferences screen
- Refactored profile settings screens
- Partner activity notifications
This commit is contained in:
jjbrunton committed 2026-01-17 16:38:47 +00:00
1 parent 12f312421b
commit 494ed17fe2
82 files changed
+4189 -725

No files matched your search

@@ -0,0 +1,206 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
/**
* Check for streak milestones and send celebration notifications.
* Called by pg_cron at 00:05 UTC daily.
*
* Milestones: 7, 14, 30, 60, 100 days
*
* Key principle: Celebrate achievements, never guilt-trip about breaks.
*/
const MILESTONE_DAYS = [7, 14, 30, 60, 100];
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
// Expo accepts max 100 messages per request
const chunks: ExpoPushMessage[][] = [];
for (let i = 0; i < messages.length; i += 100) {
chunks.push(messages.slice(i, i + 100));
}
for (const chunk of chunks) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(chunk),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
}
}
}
function getStreakMessage(days: number): { title: string; body: string } {
switch (days) {
case 7:
return {
title: "1 week streak!",
body: "You and your partner have been connecting for 7 days straight. Keep it up!",
};
case 14:
return {
title: "2 week streak!",
body: "Two weeks of daily connection. You two are on fire!",
};
case 30:
return {
title: "1 month streak!",
body: "A whole month of daily connection! You're building something special.",
};
case 60:
return {
title: "2 month streak!",
body: "60 days of daily connection. Your commitment is inspiring!",
};
case 100:
return {
title: "100 day streak!",
body: "100 days! You've made connection a habit. Incredible!",
};
default:
return {
title: `${days} day streak!`,
body: `${days} days of connecting with your partner. Amazing!`,
};
}
}
Deno.serve(async (req) => {
// Only allow POST (from cron) or GET (for manual testing)
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
try {
// Get all streaks that hit a milestone and haven't been celebrated yet
const { data: streaks, error: fetchError } = await supabase
.from("couple_streaks")
.select("couple_id, current_streak, streak_celebrated_at")
.in("current_streak", MILESTONE_DAYS)
.filter("current_streak", "gt", supabase.rpc("coalesce_int", { val: "streak_celebrated_at", fallback: 0 }));
// Actually, we need a different approach since we can't use coalesce in filters
// Let's fetch all and filter in code
const { data: allStreaks, error: allStreaksError } = await supabase
.from("couple_streaks")
.select("couple_id, current_streak, streak_celebrated_at");
if (allStreaksError) {
console.error("Error fetching streaks:", allStreaksError);
return new Response(
JSON.stringify({ error: allStreaksError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
// Filter for uncelebrated milestones
const uncelebratedMilestones = (allStreaks || []).filter(streak =>
MILESTONE_DAYS.includes(streak.current_streak) &&
streak.current_streak > (streak.streak_celebrated_at || 0)
);
if (uncelebratedMilestones.length === 0) {
return new Response(
JSON.stringify({ celebrated: 0, message: "No milestones to celebrate" }),
{ headers: { "Content-Type": "application/json" } }
);
}
const messages: ExpoPushMessage[] = [];
const celebratedCouples: string[] = [];
for (const streak of uncelebratedMilestones) {
// Get both partners' profiles
const { data: profiles, error: profilesError } = await supabase
.from("profiles")
.select(`
id,
push_token,
notification_preferences:notification_preferences(streak_milestones_enabled)
`)
.eq("couple_id", streak.couple_id);
if (profilesError) {
console.error(`Error fetching profiles for couple ${streak.couple_id}:`, profilesError);
continue;
}
const { title, body } = getStreakMessage(streak.current_streak);
for (const profile of profiles || []) {
// Check notification preferences
const prefs = Array.isArray(profile.notification_preferences)
? profile.notification_preferences[0]
: profile.notification_preferences;
// Skip if user has explicitly disabled streak milestone notifications
if (prefs && prefs.streak_milestones_enabled === false) {
continue;
}
if (profile.push_token) {
messages.push({
to: profile.push_token,
title,
body,
sound: "default",
data: {
type: "streak_milestone",
streak: streak.current_streak,
},
});
}
}
celebratedCouples.push(streak.couple_id);
// Mark milestone as celebrated
await supabase
.from("couple_streaks")
.update({ streak_celebrated_at: streak.current_streak })
.eq("couple_id", streak.couple_id);
}
// Send all notifications
if (messages.length > 0) {
await sendExpoPushNotifications(messages);
}
console.log(`Celebrated ${celebratedCouples.length} milestones, sent ${messages.length} notifications`);
return new Response(
JSON.stringify({
celebrated: celebratedCouples.length,
notificationsSent: messages.length,
}),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Unexpected error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,170 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
/**
* Process scheduled pack releases.
* Called by pg_cron every 5 minutes to check for packs with scheduled_release_at in the past.
*
* When a pack's release time arrives:
* 1. Set is_public = true to make the pack available
* 2. Send notification to all users who have new_packs_enabled
* 3. Set release_notified = true to prevent duplicate notifications
*/
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
// Expo accepts max 100 messages per request
const chunks: ExpoPushMessage[][] = [];
for (let i = 0; i < messages.length; i += 100) {
chunks.push(messages.slice(i, i + 100));
}
for (const chunk of chunks) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(chunk),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
// Continue with other chunks even if one fails
}
}
}
Deno.serve(async (req) => {
// Only allow POST (from cron) or GET (for manual testing)
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
try {
// Get all packs due for release
const { data: duePacks, error: fetchError } = await supabase
.from("question_packs")
.select("id, name, description")
.eq("is_public", false)
.eq("release_notified", false)
.not("scheduled_release_at", "is", null)
.lte("scheduled_release_at", new Date().toISOString());
if (fetchError) {
console.error("Error fetching due packs:", fetchError);
return new Response(
JSON.stringify({ error: fetchError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
if (!duePacks || duePacks.length === 0) {
return new Response(
JSON.stringify({ released: 0, message: "No packs due for release" }),
{ headers: { "Content-Type": "application/json" } }
);
}
const packIds = duePacks.map(p => p.id);
// Make packs public
const { error: updateError } = await supabase
.from("question_packs")
.update({
is_public: true,
release_notified: true,
})
.in("id", packIds);
if (updateError) {
console.error("Error updating packs:", updateError);
return new Response(
JSON.stringify({ error: updateError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
// Get all users with push tokens who want new pack notifications
// We join with notification_preferences and filter for new_packs_enabled = true (or null, which defaults to true)
const { data: users, error: usersError } = await supabase
.from("profiles")
.select(`
id,
push_token,
notification_preferences:notification_preferences(new_packs_enabled)
`)
.not("push_token", "is", null);
if (usersError) {
console.error("Error fetching users:", usersError);
// Packs are already released, just log error and continue
}
const messages: ExpoPushMessage[] = [];
if (users && users.length > 0) {
for (const user of users) {
// Check notification preferences
// notification_preferences is an array (due to how Supabase returns joined data)
const prefs = Array.isArray(user.notification_preferences)
? user.notification_preferences[0]
: user.notification_preferences;
// Skip if user has explicitly disabled new_packs notifications
if (prefs && prefs.new_packs_enabled === false) {
continue;
}
// Send notification for each released pack
for (const pack of duePacks) {
messages.push({
to: user.push_token!,
title: "New pack available",
body: `${pack.name} is now available to play!`,
sound: "default",
data: { type: "new_pack", packId: pack.id },
});
}
}
}
// Send all notifications
if (messages.length > 0) {
await sendExpoPushNotifications(messages);
}
console.log(`Released ${duePacks.length} packs, sent ${messages.length} notifications`);
return new Response(
JSON.stringify({
released: duePacks.length,
packNames: duePacks.map(p => p.name),
notificationsSent: messages.length,
}),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Unexpected error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
@@ -0,0 +1,199 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
/**
* Send batched partner activity notifications.
* Called by pg_cron every 5 minutes to check for pending notifications
* where notify_at has passed.
*
* Notifications are sent when a user answers questions. The notification
* is delayed by 1 hour, with the timer resetting on each new response.
* This prevents spam when a user answers multiple questions in quick succession.
*
* Skips sending if the partner was active in the last 15 minutes (they're already in app).
*/
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(messages),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
throw new Error(`Failed to send push notification: ${error}`);
}
return response.json();
}
Deno.serve(async (req) => {
// Only allow POST (from cron) or GET (for manual testing)
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
try {
// Get all pending notifications where notify_at has passed
const { data: pendingNotifications, error: fetchError } = await supabase
.from("pending_activity_notifications")
.select("id, couple_id, active_user_id, response_count, notify_at")
.lte("notify_at", new Date().toISOString());
if (fetchError) {
console.error("Error fetching pending notifications:", fetchError);
return new Response(
JSON.stringify({ error: fetchError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
if (!pendingNotifications || pendingNotifications.length === 0) {
return new Response(
JSON.stringify({ sent: 0, message: "No pending notifications" }),
{ headers: { "Content-Type": "application/json" } }
);
}
const messages: ExpoPushMessage[] = [];
const processedIds: string[] = [];
const skippedPartnerActive: string[] = [];
const skippedNoToken: string[] = [];
const skippedPrefsDisabled: string[] = [];
// Time threshold for "partner is active" check (15 minutes ago)
const activeThreshold = new Date(Date.now() - 15 * 60 * 1000).toISOString();
for (const notification of pendingNotifications) {
// Get the partner (not the user who made changes)
const { data: partner, error: partnerError } = await supabase
.from("profiles")
.select("id, push_token, name, last_active_at")
.eq("couple_id", notification.couple_id)
.neq("id", notification.active_user_id)
.maybeSingle();
if (partnerError) {
console.error(
`Error fetching partner for couple ${notification.couple_id}:`,
partnerError
);
continue;
}
// Skip if partner was active recently (they're already in the app)
if (partner?.last_active_at && partner.last_active_at > activeThreshold) {
console.log(
`Skipping notification for couple ${notification.couple_id}: partner was active recently`
);
skippedPartnerActive.push(notification.id);
processedIds.push(notification.id); // Still delete the pending notification
continue;
}
// Skip if no push token
if (!partner?.push_token) {
skippedNoToken.push(notification.id);
processedIds.push(notification.id);
continue;
}
// Check notification preferences
const { data: prefs } = await supabase
.from("notification_preferences")
.select("partner_activity_enabled")
.eq("user_id", partner.id)
.maybeSingle();
// Skip if partner has disabled partner_activity notifications
if (prefs && prefs.partner_activity_enabled === false) {
skippedPrefsDisabled.push(notification.id);
processedIds.push(notification.id);
continue;
}
// Get the active user's name for the notification
const { data: activeUser } = await supabase
.from("profiles")
.select("name")
.eq("id", notification.active_user_id)
.maybeSingle();
const userName = activeUser?.name || "Your partner";
const responseCount = notification.response_count;
// Craft the notification message
const body = responseCount === 1
? `${userName} answered a question`
: `${userName} answered ${responseCount} questions`;
messages.push({
to: partner.push_token,
title: "Partner activity",
body,
sound: "default",
data: { type: "partner_activity" },
});
processedIds.push(notification.id);
}
// Send all notifications
if (messages.length > 0) {
await sendExpoPushNotifications(messages);
}
// Delete processed notifications
if (processedIds.length > 0) {
const { error: deleteError } = await supabase
.from("pending_activity_notifications")
.delete()
.in("id", processedIds);
if (deleteError) {
console.error("Error deleting processed notifications:", deleteError);
}
}
console.log(`Sent ${messages.length} partner activity notifications, skipped: partner active=${skippedPartnerActive.length}, no token=${skippedNoToken.length}, prefs disabled=${skippedPrefsDisabled.length}`);
return new Response(
JSON.stringify({
sent: messages.length,
processed: processedIds.length,
skipped: {
partnerActive: skippedPartnerActive.length,
noToken: skippedNoToken.length,
prefsDisabled: skippedPrefsDisabled.length,
},
}),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Unexpected error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
@@ -5,7 +5,6 @@
-- EXTENSIONS
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
-- ============================================================================
-- CUSTOM TYPES
-- ============================================================================
@@ -16,7 +15,6 @@ CREATE TYPE feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved
CREATE TYPE subscription_status AS ENUM ('active', 'cancelled', 'expired', 'billing_issue', 'paused');
CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin');
CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
-- ============================================================================
-- TABLES
-- ============================================================================
@@ -27,7 +25,6 @@ CREATE TABLE couples (
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Profiles table (extends auth.users)
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
@@ -40,11 +37,11 @@ CREATE TABLE profiles (
updated_at TIMESTAMPTZ DEFAULT now(),
email TEXT,
gender TEXT,
show_explicit_content BOOLEAN DEFAULT false,
show_explicit_content BOOLEAN DEFAULT true,
onboarding_completed BOOLEAN DEFAULT false,
usage_reason TEXT,
CONSTRAINT valid_gender CHECK (gender IS NULL OR gender = ANY(ARRAY['male', 'female', 'non-binary', 'prefer-not-to-say']))
);
-- Categories table
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -54,7 +51,6 @@ CREATE TABLE categories (
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Question packs table
CREATE TABLE question_packs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -68,7 +64,6 @@ CREATE TABLE question_packs (
category_id UUID REFERENCES categories(id),
is_explicit BOOLEAN NOT NULL DEFAULT false
);
-- Questions table
CREATE TABLE questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -80,10 +75,8 @@ CREATE TABLE questions (
allowed_couple_genders TEXT[],
target_user_genders TEXT[]
);
COMMENT ON COLUMN questions.allowed_couple_genders IS 'Array of allowed couple gender pairings (e.g., ["female+male", "female+female"]). Null means all couple types allowed.';
COMMENT ON COLUMN questions.target_user_genders IS 'Array of genders that can see this question first (as initiator). NULL = all genders. Partners still see it via partner_text after their partner answers.';
-- Responses table
CREATE TABLE responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -94,7 +87,6 @@ CREATE TABLE responses (
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, question_id)
);
-- Matches table
CREATE TABLE matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -105,7 +97,6 @@ CREATE TABLE matches (
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id, question_id)
);
-- Messages table
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
@@ -119,7 +110,6 @@ CREATE TABLE messages (
delivered_at TIMESTAMPTZ,
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
);
-- Couple packs table (which packs a couple has enabled)
CREATE TABLE couple_packs (
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
@@ -128,7 +118,6 @@ CREATE TABLE couple_packs (
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (couple_id, pack_id)
);
-- Feedback table
CREATE TABLE feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -144,7 +133,6 @@ CREATE TABLE feedback (
updated_at TIMESTAMPTZ DEFAULT now(),
question_id UUID REFERENCES questions(id) ON DELETE SET NULL
);
-- Subscriptions table
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -164,7 +152,6 @@ CREATE TABLE subscriptions (
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, original_transaction_id)
);
-- RevenueCat webhook events table (for idempotency)
CREATE TABLE revenuecat_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -174,7 +161,6 @@ CREATE TABLE revenuecat_webhook_events (
processed_at TIMESTAMPTZ DEFAULT now(),
payload JSONB
);
-- Admin users table
CREATE TABLE admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -184,7 +170,6 @@ CREATE TABLE admin_users (
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Audit logs table
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -198,7 +183,6 @@ CREATE TABLE audit_logs (
admin_role admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================================
-- INDEXES
-- ============================================================================
@@ -225,7 +209,6 @@ CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id);
CREATE INDEX idx_audit_logs_admin_user_id ON audit_logs(admin_user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
-- ============================================================================
-- FUNCTIONS
-- ============================================================================
@@ -240,7 +223,6 @@ SET search_path = ''
AS $$
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
$$;
-- Check if user is any admin
CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN
@@ -255,7 +237,6 @@ BEGIN
);
END;
$$;
-- Check if user is super admin
CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN
@@ -271,7 +252,6 @@ BEGIN
);
END;
$$;
-- Get admin role for user
CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid())
RETURNS admin_role
@@ -287,7 +267,6 @@ BEGIN
RETURN user_role;
END;
$$;
-- Check if user or partner has premium access
CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID)
RETURNS BOOLEAN
@@ -322,7 +301,6 @@ BEGIN
RETURN COALESCE(partner_premium, FALSE);
END;
$$;
-- Get recommended questions for a user
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
@@ -447,7 +425,6 @@ BEGIN
);
END;
$$;
-- Get pack teaser questions (bypasses premium for preview)
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
@@ -469,7 +446,6 @@ BEGIN
LIMIT 3;
END;
$$;
-- Handle new user signup (create profile)
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER
@@ -488,7 +464,6 @@ BEGIN
RETURN NEW;
END;
$$;
-- Update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER
@@ -500,7 +475,6 @@ BEGIN
RETURN NEW;
END;
$$;
-- Check couple size (max 2 members)
CREATE OR REPLACE FUNCTION check_couple_size()
RETURNS TRIGGER
@@ -516,7 +490,6 @@ BEGIN
RETURN NEW;
END;
$$;
-- Sync premium status from subscriptions
CREATE OR REPLACE FUNCTION sync_premium_status()
RETURNS TRIGGER
@@ -537,7 +510,6 @@ BEGIN
RETURN NEW;
END;
$$;
-- Disable premium packs when subscription is lost
CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss()
RETURNS TRIGGER
@@ -566,7 +538,6 @@ BEGIN
RETURN NEW;
END;
$$;
-- Log admin actions
CREATE OR REPLACE FUNCTION log_admin_action(
p_table_name TEXT,
@@ -611,7 +582,6 @@ BEGIN
RETURN log_id;
END;
$$;
-- Notify on new message (calls edge function)
CREATE OR REPLACE FUNCTION notify_new_message()
RETURNS TRIGGER
@@ -634,7 +604,6 @@ BEGIN
RETURN NEW;
END;
$$;
-- ============================================================================
-- TRIGGERS
-- ============================================================================
@@ -643,41 +612,33 @@ $$;
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER enforce_couple_size
BEFORE INSERT OR UPDATE OF couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
CREATE TRIGGER disable_premium_packs_on_sub_loss
AFTER UPDATE OF is_premium ON profiles
FOR EACH ROW
WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium)
EXECUTE FUNCTION disable_premium_packs_on_subscription_loss();
-- Subscription triggers
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER sync_premium_on_subscription_change
AFTER INSERT OR UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION sync_premium_status();
-- Admin users trigger
CREATE TRIGGER update_admin_users_updated_at
BEFORE UPDATE ON admin_users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Message notification trigger
CREATE TRIGGER on_new_message
AFTER INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION notify_new_message();
-- Auth trigger to create profile on signup
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- ============================================================================
-- ENABLE ROW LEVEL SECURITY
-- ============================================================================
@@ -695,102 +656,81 @@ ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- ============================================================================
-- RLS POLICIES: COUPLES
-- ============================================================================
CREATE POLICY "Authenticated users can create couples"
ON couples FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Users can view own couple"
ON couples FOR SELECT
USING (id = get_auth_user_couple_id());
CREATE POLICY "Users can lookup couple by exact invite code"
ON couples FOR SELECT
USING (id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all couples"
ON couples FOR SELECT
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: PROFILES
-- ============================================================================
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can view partner profile"
ON profiles FOR SELECT
USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Super admins can view all profiles"
ON profiles FOR SELECT
USING (is_super_admin());
CREATE POLICY "Super admins can update all profiles"
ON profiles FOR UPDATE
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: CATEGORIES
-- ============================================================================
CREATE POLICY "Categories are viewable by everyone"
ON categories FOR SELECT
USING (true);
CREATE POLICY "Admins can view all categories"
ON categories FOR SELECT
USING (is_admin());
CREATE POLICY "Admins can insert categories"
ON categories FOR INSERT
WITH CHECK (is_admin());
CREATE POLICY "Admins can update categories"
ON categories FOR UPDATE
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Admins can delete categories"
ON categories FOR DELETE
USING (is_admin());
-- ============================================================================
-- RLS POLICIES: QUESTION PACKS
-- ============================================================================
CREATE POLICY "Anyone can view public packs"
ON question_packs FOR SELECT
USING (is_public = true);
CREATE POLICY "Premium users can view premium packs"
ON question_packs FOR SELECT
USING (is_premium = false OR EXISTS (
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
));
CREATE POLICY "Admins can view all question packs"
ON question_packs FOR SELECT
USING (is_admin());
CREATE POLICY "Admins can insert question packs"
ON question_packs FOR INSERT
WITH CHECK (is_admin());
CREATE POLICY "Admins can update question packs"
ON question_packs FOR UPDATE
USING (is_admin());
CREATE POLICY "Admins can delete question packs"
ON question_packs FOR DELETE
USING (is_admin());
-- ============================================================================
-- RLS POLICIES: QUESTIONS
-- ============================================================================
@@ -802,23 +742,18 @@ CREATE POLICY "Anyone can view questions in visible packs"
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
))
));
CREATE POLICY "Admins can view all questions"
ON questions FOR SELECT
USING (is_admin());
CREATE POLICY "Admins can insert questions"
ON questions FOR INSERT
WITH CHECK (is_admin());
CREATE POLICY "Admins can update questions"
ON questions FOR UPDATE
USING (is_admin());
CREATE POLICY "Admins can delete questions"
ON questions FOR DELETE
USING (is_admin());
-- ============================================================================
-- RLS POLICIES: RESPONSES
-- ============================================================================
@@ -827,42 +762,33 @@ CREATE POLICY "Users can view couple responses"
USING (user_id = auth.uid() OR couple_id IN (
SELECT couple_id FROM profiles WHERE id = auth.uid()
));
CREATE POLICY "Users can insert own responses"
ON responses FOR INSERT
WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can update own responses"
ON responses FOR UPDATE
USING (user_id = auth.uid());
CREATE POLICY "Users can delete own responses"
ON responses FOR DELETE
USING (user_id = auth.uid());
CREATE POLICY "Super admins can view all responses"
ON responses FOR SELECT
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: MATCHES
-- ============================================================================
CREATE POLICY "Users can view couple matches"
ON matches FOR SELECT
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can update match seen status"
ON matches FOR UPDATE
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can delete own couple matches"
ON matches FOR DELETE
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all matches"
ON matches FOR SELECT
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: MESSAGES
-- ============================================================================
@@ -873,7 +799,6 @@ CREATE POLICY "Users can view messages in their matches"
WHERE m.id = messages.match_id
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
));
CREATE POLICY "Users can insert messages in their matches"
ON messages FOR INSERT
WITH CHECK (auth.uid() = user_id AND EXISTS (
@@ -881,7 +806,6 @@ CREATE POLICY "Users can insert messages in their matches"
WHERE m.id = messages.match_id
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
));
CREATE POLICY "Users can mark messages as read"
ON messages FOR UPDATE
USING (auth.uid() <> user_id AND EXISTS (
@@ -890,15 +814,12 @@ CREATE POLICY "Users can mark messages as read"
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
))
WITH CHECK (auth.uid() <> user_id);
CREATE POLICY "Users can delete own messages"
ON messages FOR DELETE
USING (user_id = auth.uid());
CREATE POLICY "Super admins can view all messages"
ON messages FOR SELECT
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: COUPLE PACKS
-- ============================================================================
@@ -908,67 +829,54 @@ CREATE POLICY "Couples can view their own pack settings"
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
));
CREATE POLICY "Couples can modify their own pack settings"
ON couple_packs FOR ALL
USING (EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
));
CREATE POLICY "Users can delete own couple pack settings"
ON couple_packs FOR DELETE
USING (EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
));
-- ============================================================================
-- RLS POLICIES: FEEDBACK
-- ============================================================================
CREATE POLICY "Users can create feedback"
ON feedback FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can view own feedback"
ON feedback FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can update own pending feedback"
ON feedback FOR UPDATE
USING (auth.uid() = user_id AND status = 'new');
CREATE POLICY "Super admins can view all feedback"
ON feedback FOR SELECT
USING (is_super_admin());
CREATE POLICY "Super admins can update all feedback"
ON feedback FOR UPDATE
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: SUBSCRIPTIONS
-- ============================================================================
CREATE POLICY "Users can view own subscriptions"
ON subscriptions FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Super admins can view all subscriptions"
ON subscriptions FOR SELECT
USING (is_super_admin());
CREATE POLICY "Super admins can insert subscriptions"
ON subscriptions FOR INSERT
WITH CHECK (is_super_admin());
CREATE POLICY "Super admins can update subscriptions"
ON subscriptions FOR UPDATE
USING (is_super_admin());
CREATE POLICY "Super admins can delete subscriptions"
ON subscriptions FOR DELETE
USING (is_super_admin());
-- ============================================================================
-- RLS POLICIES: REVENUECAT WEBHOOK EVENTS
-- ============================================================================
@@ -981,14 +889,12 @@ CREATE POLICY "Super admins can delete subscriptions"
CREATE POLICY "Admin users can read own record"
ON admin_users FOR SELECT
USING (auth.uid() = user_id);
-- ============================================================================
-- RLS POLICIES: AUDIT LOGS
-- ============================================================================
CREATE POLICY "Super admins can view audit logs"
ON audit_logs FOR SELECT
USING (is_super_admin());
-- ============================================================================
-- REALTIME SUBSCRIPTIONS
-- ============================================================================
@@ -996,18 +902,15 @@ ALTER PUBLICATION supabase_realtime ADD TABLE profiles;
ALTER PUBLICATION supabase_realtime ADD TABLE matches;
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs;
-- ============================================================================
-- STORAGE BUCKETS
-- ============================================================================
INSERT INTO storage.buckets (id, name, public)
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
ON CONFLICT (id) DO NOTHING;
INSERT INTO storage.buckets (id, name, public)
VALUES ('chat-media', 'chat-media', false)
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- STORAGE POLICIES
-- ============================================================================
@@ -1019,14 +922,12 @@ CREATE POLICY "Users can upload feedback screenshots"
bucket_id = 'feedback-screenshots'
AND (storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Users can view own feedback screenshots"
ON storage.objects FOR SELECT TO authenticated
USING (
bucket_id = 'feedback-screenshots'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Chat media policies
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
CREATE POLICY "Users can view chat media in their matches"
@@ -1040,7 +941,6 @@ CREATE POLICY "Users can view chat media in their matches"
AND p.id = auth.uid()
)
);
CREATE POLICY "Users can upload chat media to their matches"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (
@@ -1052,7 +952,6 @@ CREATE POLICY "Users can upload chat media to their matches"
AND p.id = auth.uid()
)
);
CREATE POLICY "Users can delete their own chat media"
ON storage.objects FOR DELETE TO authenticated
USING (
@@ -1064,7 +963,6 @@ CREATE POLICY "Users can delete their own chat media"
AND p.id = auth.uid()
)
);
CREATE POLICY "Admins can view all chat media"
ON storage.objects FOR SELECT TO authenticated
USING (
@@ -4,10 +4,9 @@
-- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path
-- Drop existing broken policies
DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects;
DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects;
DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects;
DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects;
-- Recreate with explicit table qualification to avoid column name ambiguity
CREATE POLICY "Users can upload chat media to their matches"
ON storage.objects FOR INSERT TO authenticated
@@ -19,8 +18,7 @@ CREATE POLICY "Users can upload chat media to their matches"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid()
)
);
);
CREATE POLICY "Users can view chat media in their matches"
ON storage.objects FOR SELECT TO authenticated
USING (
@@ -31,8 +29,7 @@ CREATE POLICY "Users can view chat media in their matches"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid()
)
);
);
CREATE POLICY "Users can delete their own chat media"
ON storage.objects FOR DELETE TO authenticated
USING (
@@ -43,4 +40,4 @@ CREATE POLICY "Users can delete their own chat media"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid()
)
);
);
@@ -22,17 +22,13 @@ CREATE TABLE IF NOT EXISTS ai_config (
updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id)
);
-- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true));
-- Insert default config row
INSERT INTO ai_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING;
-- Enable RLS
ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY;
-- Only super admins can read/write (checked via admin_users table)
CREATE POLICY "Super admins can read ai_config"
ON ai_config FOR SELECT
@@ -43,7 +39,6 @@ CREATE POLICY "Super admins can read ai_config"
AND admin_users.role = 'super_admin'
)
);
CREATE POLICY "Super admins can update ai_config"
ON ai_config FOR UPDATE
USING (
@@ -53,7 +48,6 @@ CREATE POLICY "Super admins can update ai_config"
AND admin_users.role = 'super_admin'
)
);
-- Create function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_ai_config_timestamp()
RETURNS TRIGGER AS $$
@@ -63,13 +57,11 @@ BEGIN
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger to auto-update timestamp
DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config;
CREATE TRIGGER ai_config_updated_at
BEFORE UPDATE ON ai_config
FOR EACH ROW
EXECUTE FUNCTION update_ai_config_timestamp();
-- Add comment for documentation
COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.';
@@ -5,17 +5,14 @@
-- Add the new column (migrate existing single generator model to array format)
ALTER TABLE ai_config
ADD COLUMN IF NOT EXISTS council_generators jsonb;
-- Set default value based on existing council_generator_model or default
UPDATE ai_config
SET council_generators = jsonb_build_array(
jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet'))
)
WHERE council_generators IS NULL;
-- Set the default for new rows
ALTER TABLE ai_config
ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb;
-- Add comment for documentation
COMMENT ON COLUMN ai_config.council_generators IS 'Array of generator configurations. Each object has a "model" field. Default is one generator with Claude 3.5 Sonnet.';
@@ -12,37 +12,29 @@ CREATE TABLE IF NOT EXISTS app_config (
updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id)
);
-- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true));
-- Insert default config row
INSERT INTO app_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING;
-- Enable RLS
ALTER TABLE app_config ENABLE ROW LEVEL SECURITY;
-- All authenticated users can read (needed for mobile app)
CREATE POLICY "Authenticated users can read app_config"
ON app_config FOR SELECT
USING (auth.role() = 'authenticated');
-- Only super admins can update
CREATE POLICY "Super admins can update app_config"
ON app_config FOR UPDATE
USING (is_super_admin());
-- Create trigger to auto-update timestamp (reuses existing function)
CREATE TRIGGER app_config_updated_at
BEFORE UPDATE ON app_config
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- Add comment for documentation
COMMENT ON TABLE app_config IS 'Singleton table storing mobile app configuration. Readable by all authenticated users, writable by super admins only.';
COMMENT ON COLUMN app_config.answer_gap_threshold IS 'Maximum number of questions a user can answer ahead of their partner. Set to 0 to disable answer spam prevention.';
-- ============================================================================
-- RPC FUNCTION: get_answer_gap_status
-- Returns the number of questions user has answered that partner hasn't,
@@ -131,5 +123,4 @@ BEGIN
RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold);
END;
$$;
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.';
@@ -163,5 +163,4 @@ BEGIN
);
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user. When user is too far ahead of partner (answer gap threshold exceeded), returns empty. Old apps see "All caught up!", new apps can call get_answer_gap_status to determine blocking reason.';
@@ -2,20 +2,15 @@
-- Videos expire 30 days after being viewed by the recipient
-- Add media type to distinguish images from videos
ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video'));
ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video'));
-- Track when media expires (set when video is viewed: media_viewed_at + 30 days)
ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ;
-- Track if media was deleted due to expiration
ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false;
ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false;
-- Backfill existing media as images
UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL;
UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL;
-- Enable pg_cron extension for scheduled cleanup
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
-- Create cleanup function that handles both DB updates and storage deletion tracking
CREATE OR REPLACE FUNCTION cleanup_expired_videos()
RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$
@@ -48,12 +43,11 @@ BEGIN
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Schedule daily cleanup at 3 AM UTC
-- The database function marks videos as expired, edge function handles storage deletion
SELECT cron.schedule(
'cleanup-expired-videos',
'0 3 * * *',
'SELECT * FROM cleanup_expired_videos()'
);
);
@@ -30,29 +30,24 @@ BEGIN
RETURN v_permissions @> jsonb_build_array(check_permission);
END;
$$;
-- Update RLS policies to use permissions
-- Profiles
CREATE POLICY "Admins with view_users permission can view all profiles"
ON profiles FOR SELECT
USING (has_permission('view_users'));
-- Messages
CREATE POLICY "Admins with view_chats permission can view all messages"
ON messages FOR SELECT
USING (has_permission('view_chats'));
-- Responses
CREATE POLICY "Admins with view_responses permission can view all responses"
ON responses FOR SELECT
USING (has_permission('view_responses'));
-- Matches
CREATE POLICY "Admins with view_matches permission can view all matches"
ON matches FOR SELECT
USING (has_permission('view_matches'));
-- Audit Logs
CREATE POLICY "Admins with view_audit_logs permission can view audit logs"
ON audit_logs FOR SELECT
@@ -3,7 +3,6 @@
CREATE POLICY "Admins with manage_feedback permission can view all feedback"
ON feedback FOR SELECT
USING (has_permission('manage_feedback'));
CREATE POLICY "Admins with manage_feedback permission can update all feedback"
ON feedback FOR UPDATE
USING (has_permission('manage_feedback'));
@@ -1,17 +1,138 @@
-- Add RLS policies for redemption codes
-- Redemption codes table
CREATE TABLE redemption_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
description TEXT,
max_uses INTEGER DEFAULT 1,
current_uses INTEGER DEFAULT 0,
expires_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
created_by UUID REFERENCES auth.users(id),
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(),
code_id UUID NOT NULL REFERENCES redemption_codes(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
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'));
USING (has_permission('manage_codes'::text));
CREATE POLICY "Admins with manage_codes permission can insert redemption codes"
ON redemption_codes FOR INSERT
WITH CHECK (has_permission('manage_codes'));
WITH CHECK (has_permission('manage_codes'::text));
CREATE POLICY "Admins with manage_codes permission can update redemption codes"
ON redemption_codes FOR UPDATE
USING (has_permission('manage_codes'));
USING (has_permission('manage_codes'::text));
CREATE POLICY "Admins with manage_codes permission can delete redemption codes"
ON redemption_codes FOR DELETE
USING (has_permission('manage_codes'));
USING (has_permission('manage_codes'::text));
CREATE POLICY "Super admins can manage redemption codes"
ON redemption_codes FOR ALL
USING (
EXISTS (
SELECT 1
FROM admin_users
WHERE admin_users.user_id = auth.uid()
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);
CREATE POLICY "Super admins can view all redemptions"
ON code_redemptions FOR SELECT
USING (
EXISTS (
SELECT 1
FROM admin_users
WHERE admin_users.user_id = auth.uid()
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
SECURITY DEFINER
AS $function$
DECLARE
v_code_record record;
v_user_record record;
v_existing_redemption record;
BEGIN
-- Find the user by email
SELECT id INTO v_user_record
FROM public.profiles
WHERE LOWER(email) = LOWER(p_email);
IF v_user_record IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'No account found with this email address');
END IF;
-- Find the code
SELECT * INTO v_code_record
FROM public.redemption_codes
WHERE UPPER(code) = UPPER(p_code);
IF v_code_record IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid redemption code');
END IF;
-- Check if code is active
IF NOT v_code_record.is_active THEN
RETURN jsonb_build_object('success', false, 'error', 'This code is no longer active');
END IF;
-- Check if code has expired
IF v_code_record.expires_at IS NOT NULL AND v_code_record.expires_at < now() THEN
RETURN jsonb_build_object('success', false, 'error', 'This code has expired');
END IF;
-- Check if code has uses remaining
IF v_code_record.max_uses IS NOT NULL AND v_code_record.current_uses >= v_code_record.max_uses THEN
RETURN jsonb_build_object('success', false, 'error', 'This code has reached its maximum number of uses');
END IF;
-- Check if user has already redeemed this code
SELECT * INTO v_existing_redemption
FROM public.code_redemptions
WHERE code_id = v_code_record.id AND user_id = v_user_record.id;
IF v_existing_redemption IS NOT NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'You have already redeemed this code');
END IF;
-- All checks passed - redeem the code
-- 1. Create redemption record
INSERT INTO public.code_redemptions (code_id, user_id)
VALUES (v_code_record.id, v_user_record.id);
-- 2. Increment current_uses
UPDATE public.redemption_codes
SET current_uses = current_uses + 1,
updated_at = now()
WHERE id = v_code_record.id;
-- 3. Set user as premium
UPDATE public.profiles
SET is_premium = true,
updated_at = now()
WHERE id = v_user_record.id;
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;
@@ -1,27 +1,43 @@
-- Track user opt-ins for upcoming/coming-soon features
-- Table to track user interest in upcoming features
CREATE TABLE feature_interests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, feature_name)
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature_name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Ensure a user can only express interest in a feature once
UNIQUE(user_id, feature_name)
);
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id);
-- 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;
CREATE POLICY "Users can view their own interests"
ON feature_interests FOR SELECT
USING (auth.uid() = user_id);
-- Users can insert their own interest
CREATE POLICY "Users can express interest in features"
ON feature_interests FOR INSERT
WITH CHECK (auth.uid() = user_id);
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
USING (auth.uid() = user_id);
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;;
@@ -25,5 +25,4 @@ BEGIN
ORDER BY opt_in_count DESC, fi.feature_name ASC;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated;
@@ -3,20 +3,15 @@
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
-- Encrypted message content (base64 encoded ciphertext)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
-- Triple-wrapped encryption keys and metadata
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
-- Add comment for documentation
COMMENT ON COLUMN messages.version IS
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
-- Constraint: v2 messages must have encryption fields
DO $$
BEGIN
@@ -3,10 +3,8 @@
-- RSA public key in JWK format (JSON Web Key)
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
-- Index for efficient lookups when encrypting for partner
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
WHERE public_key_jwk IS NOT NULL;
COMMENT ON COLUMN profiles.public_key_jwk IS
'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.';
@@ -9,14 +9,11 @@ CREATE TABLE IF NOT EXISTS master_keys (
rotated_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true
);
-- Add table comment
COMMENT ON TABLE master_keys IS
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
-- Enable RLS
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
-- Super admins can view master keys
DO $$
BEGIN
@@ -29,7 +26,6 @@ BEGIN
USING (is_super_admin(auth.uid()));
END IF;
END $$;
-- Super admins can insert master keys
DO $$
BEGIN
@@ -42,7 +38,6 @@ BEGIN
WITH CHECK (is_super_admin(auth.uid()));
END IF;
END $$;
-- Super admins can update master keys
DO $$
BEGIN
@@ -55,7 +50,6 @@ BEGIN
USING (is_super_admin(auth.uid()));
END IF;
END $$;
-- Allow all authenticated users to read active master key public keys (needed for encryption)
DO $$
BEGIN
@@ -1,8 +1,7 @@
-- Update the content_or_media constraint to allow E2EE messages
-- E2EE messages have encrypted_content instead of content
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
-- v1 (plaintext): must have content or media_path
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL))
@@ -12,4 +11,4 @@ ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
OR
-- Legacy messages without version: require content or media_path
(version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL))
);
);
@@ -2,7 +2,6 @@
ALTER TABLE messages
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
ADD COLUMN flag_reason text;
-- Add classifier configuration to ai_config table
ALTER TABLE ai_config
ADD COLUMN classifier_enabled boolean DEFAULT true,
@@ -22,7 +21,6 @@ If the content is safe (including consensual adult sexual content), respond with
If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}.
Response must be valid JSON.';
-- Function to trigger classification
CREATE OR REPLACE FUNCTION trigger_classify_message()
RETURNS TRIGGER
@@ -54,13 +52,11 @@ BEGIN
RETURN NEW;
END;
$$;
-- Create trigger on messages table
CREATE TRIGGER on_message_created_classify
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION trigger_classify_message();
-- Add comments
COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.';
COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).';
@@ -1,9 +1,7 @@
-- Add category column to messages table
ALTER TABLE messages
ADD COLUMN category text;
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
-- Update the default classifier prompt in ai_config
UPDATE ai_config
SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message.
@@ -1,6 +1,5 @@
-- Add deleted_at column to messages table for "delete for everyone"
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
-- Create message_deletions table for "delete for self" (per-user deletions)
CREATE TABLE message_deletions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -8,22 +7,18 @@ CREATE TABLE message_deletions (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
deleted_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(message_id, user_id)
);
);
-- Index for efficient lookup when filtering messages
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id);
CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id);
CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
-- Enable RLS on message_deletions
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
-- RLS Policies for message_deletions
-- Users can view their own deletions (needed for filtering)
CREATE POLICY "Users can view own deletions"
ON message_deletions FOR SELECT
USING (user_id = auth.uid());
USING (user_id = auth.uid());
-- Users can insert their own deletions (for messages in their matches)
CREATE POLICY "Users can delete messages for self"
ON message_deletions FOR INSERT
@@ -36,17 +31,14 @@ CREATE POLICY "Users can delete messages for self"
WHERE m.id = message_deletions.message_id
AND p.id = auth.uid()
)
);
);
-- Update messages RLS policy to allow authors to set deleted_at
CREATE POLICY "Authors can soft delete messages for everyone"
ON messages FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
-- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
-- Comments for documentation
COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.';
COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';
COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.';
COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';
@@ -1,6 +1,5 @@
-- Create enum for report reasons
CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other');
CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other');
-- Create message_reports table
CREATE TABLE message_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -14,23 +13,19 @@ CREATE TABLE message_reports (
-- Prevent duplicate reports from same user
UNIQUE(message_id, reporter_id)
);
);
-- Indexes for efficient queries
CREATE INDEX idx_message_reports_status ON message_reports(status);
CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC);
CREATE INDEX idx_message_reports_message ON message_reports(message_id);
CREATE INDEX idx_message_reports_status ON message_reports(status);
CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC);
CREATE INDEX idx_message_reports_message ON message_reports(message_id);
-- Enable RLS
ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Users can view their own reports
CREATE POLICY "Users can view own reports"
ON message_reports FOR SELECT
USING (reporter_id = auth.uid());
USING (reporter_id = auth.uid());
-- Users can create reports for messages in their couple's matches (but NOT their own messages)
CREATE POLICY "Users can report partner messages"
ON message_reports FOR INSERT
@@ -44,22 +39,18 @@ CREATE POLICY "Users can report partner messages"
AND p.id = auth.uid()
AND m.user_id != auth.uid() -- Cannot report own messages
)
);
);
-- Admins can view all reports
CREATE POLICY "Admins can view all reports"
ON message_reports FOR SELECT
USING (is_admin());
USING (is_admin());
-- Admins can update reports (for review status)
CREATE POLICY "Admins can update reports"
ON message_reports FOR UPDATE
USING (is_admin());
USING (is_admin());
-- Add to realtime for live updates in admin
ALTER PUBLICATION supabase_realtime ADD TABLE message_reports;
ALTER PUBLICATION supabase_realtime ADD TABLE message_reports;
-- Comments
COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).';
COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).';
COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.';
COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).';
COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).';
COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.';
@@ -2,4 +2,4 @@
-- This allows Supabase to join with profiles table in admin queries
ALTER TABLE message_reports
ADD CONSTRAINT message_reports_reporter_profile_fk
FOREIGN KEY (reporter_id) REFERENCES profiles(id) ON DELETE CASCADE;
FOREIGN KEY (reporter_id) REFERENCES profiles(id) ON DELETE CASCADE;
@@ -3,19 +3,16 @@
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2
CHECK (max_intensity >= 1 AND max_intensity <= 5);
UPDATE profiles
SET max_intensity = CASE
WHEN show_explicit_content IS TRUE THEN 5
ELSE 2
END
WHERE max_intensity IS NULL;
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS min_intensity INTEGER,
ADD COLUMN IF NOT EXISTS max_intensity INTEGER,
ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2);
CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID)
RETURNS void
LANGUAGE plpgsql
@@ -46,7 +43,6 @@ BEGIN
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION handle_question_intensity_change()
RETURNS TRIGGER
LANGUAGE plpgsql
@@ -66,13 +62,10 @@ BEGIN
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions;
CREATE TRIGGER question_pack_intensity_stats
AFTER INSERT OR UPDATE OR DELETE ON questions
FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change();
UPDATE question_packs qp
SET min_intensity = stats.min_intensity,
max_intensity = stats.max_intensity,
@@ -169,5 +169,4 @@ BEGIN
);
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When user is too far ahead of partner (answer gap threshold exceeded), returns empty.';
@@ -1,14 +1,12 @@
-- Add admin_username column to audit_logs table
ALTER TABLE audit_logs
ADD COLUMN admin_username TEXT;
-- Backfill existing audit logs with admin names from profiles
UPDATE audit_logs
SET admin_username = COALESCE(p.name, p.email, 'Unknown')
FROM profiles p
WHERE audit_logs.admin_user_id = p.id
AND audit_logs.admin_username IS NULL;
-- Update the log_admin_action function to populate admin_username from profiles
CREATE OR REPLACE FUNCTION log_admin_action(
p_table_name TEXT,
@@ -13,7 +13,6 @@ CREATE TYPE dare_status AS ENUM (
'declined', -- Recipient declined
'cancelled' -- Sender cancelled before completion
);
-- ============================================================================
-- TABLES
-- ============================================================================
@@ -34,17 +33,14 @@ CREATE TABLE dare_packs (
avg_intensity NUMERIC(3,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level';
COMMENT ON COLUMN dare_packs.min_intensity IS 'Auto-calculated minimum intensity from dares in this pack';
COMMENT ON COLUMN dare_packs.max_intensity IS 'Auto-calculated maximum intensity from dares in this pack';
COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack';
-- Create indexes for dare_packs
CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order);
CREATE INDEX idx_dare_packs_visibility ON dare_packs(is_public, is_premium);
CREATE INDEX idx_dare_packs_category ON dare_packs(category_id);
-- Dares table (individual dare content)
CREATE TABLE dares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -54,15 +50,12 @@ CREATE TABLE dares (
suggested_duration_hours INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dares IS 'Individual dares within dare packs';
COMMENT ON COLUMN dares.intensity IS 'Difficulty/spiciness level from 1 (mild) to 5 (intense)';
COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)';
-- Create indexes for dares
CREATE INDEX idx_dares_pack_id ON dares(pack_id);
CREATE INDEX idx_dares_intensity ON dares(intensity);
-- Sent dares table (tracks dare instances between partners)
CREATE TABLE sent_dares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -81,21 +74,18 @@ CREATE TABLE sent_dares (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL)
);
COMMENT ON TABLE sent_dares IS 'Dare instances sent between partners in a couple';
COMMENT ON COLUMN sent_dares.dare_id IS 'Reference to the dare from a pack (null for custom dares)';
COMMENT ON COLUMN sent_dares.custom_dare_text IS 'Text for custom dares created by premium users';
COMMENT ON COLUMN sent_dares.custom_dare_intensity IS 'Intensity for custom dares (1-5)';
COMMENT ON COLUMN sent_dares.expires_at IS 'Deadline for completing the dare (null = no time limit)';
COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare';
-- Create indexes for sent_dares
CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id);
CREATE INDEX idx_sent_dares_sender_id ON sent_dares(sender_id);
CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_id);
CREATE INDEX idx_sent_dares_status ON sent_dares(status);
CREATE INDEX idx_sent_dares_expires_at ON sent_dares(expires_at) WHERE expires_at IS NOT NULL AND status = 'active';
-- Dare messages table (chat for each dare)
CREATE TABLE dare_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -105,13 +95,10 @@ CREATE TABLE dare_messages (
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare';
-- Create indexes for dare_messages
CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id);
CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
@@ -136,12 +123,10 @@ BEGIN
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Create trigger on dares table
CREATE TRIGGER dare_intensity_stats_trigger
AFTER INSERT OR UPDATE OR DELETE ON dares
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
-- ============================================================================
-- STATS FUNCTIONS
-- ============================================================================
@@ -163,7 +148,6 @@ BEGIN
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get dare stats for a couple
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
RETURNS TABLE (
@@ -179,7 +163,6 @@ BEGIN
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
@@ -189,7 +172,6 @@ ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY;
ALTER TABLE dares ENABLE ROW LEVEL SECURITY;
ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY;
ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
-- -------------------------
-- dare_packs policies
-- -------------------------
@@ -198,7 +180,6 @@ ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public dare packs are viewable by everyone"
ON dare_packs FOR SELECT
USING (is_public = true);
-- Admins can view all dare packs
CREATE POLICY "Admins can view all dare packs"
ON dare_packs FOR SELECT
@@ -207,7 +188,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can insert dare packs
CREATE POLICY "Admins can insert dare packs"
ON dare_packs FOR INSERT
@@ -216,7 +196,6 @@ WITH CHECK (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can update dare packs
CREATE POLICY "Admins can update dare packs"
ON dare_packs FOR UPDATE
@@ -225,7 +204,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can delete dare packs
CREATE POLICY "Admins can delete dare packs"
ON dare_packs FOR DELETE
@@ -234,7 +212,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- -------------------------
-- dares policies
-- -------------------------
@@ -253,7 +230,6 @@ USING (
)
)
);
-- Admins can view all dares
CREATE POLICY "Admins can view all dares"
ON dares FOR SELECT
@@ -262,7 +238,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can insert dares
CREATE POLICY "Admins can insert dares"
ON dares FOR INSERT
@@ -271,7 +246,6 @@ WITH CHECK (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can update dares
CREATE POLICY "Admins can update dares"
ON dares FOR UPDATE
@@ -280,7 +254,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- Admins can delete dares
CREATE POLICY "Admins can delete dares"
ON dares FOR DELETE
@@ -289,7 +262,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- -------------------------
-- sent_dares policies
-- -------------------------
@@ -298,7 +270,6 @@ USING (
CREATE POLICY "Users can view their couple's sent dares"
ON sent_dares FOR SELECT
USING (couple_id = get_auth_user_couple_id());
-- Users can insert sent dares (sender must be the authenticated user)
CREATE POLICY "Users can send dares to their partner"
ON sent_dares FOR INSERT
@@ -306,12 +277,10 @@ WITH CHECK (
sender_id = auth.uid()
AND couple_id = get_auth_user_couple_id()
);
-- Users can update sent dares in their couple
CREATE POLICY "Users can update their couple's sent dares"
ON sent_dares FOR UPDATE
USING (couple_id = get_auth_user_couple_id());
-- Admins can view all sent dares
CREATE POLICY "Admins can view all sent dares"
ON sent_dares FOR SELECT
@@ -320,7 +289,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- -------------------------
-- dare_messages policies
-- -------------------------
@@ -335,7 +303,6 @@ USING (
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Users can insert messages for their couple's dares
CREATE POLICY "Users can send messages for their dares"
ON dare_messages FOR INSERT
@@ -347,7 +314,6 @@ WITH CHECK (
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Users can update messages in their dares (for read_at)
CREATE POLICY "Users can update message read status"
ON dare_messages FOR UPDATE
@@ -358,7 +324,6 @@ USING (
AND sd.couple_id = get_auth_user_couple_id()
)
);
-- Admins can view all dare messages
CREATE POLICY "Admins can view all dare messages"
ON dare_messages FOR SELECT
@@ -367,7 +332,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
)
);
-- ============================================================================
-- REALTIME
-- ============================================================================
@@ -6,21 +6,17 @@ CREATE TABLE match_archives (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
archived_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(match_id, user_id)
);
);
-- Index for efficient lookup when filtering matches
CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id);
CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id);
-- Enable RLS on match_archives
ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY;
ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY;
-- RLS Policies for match_archives
-- Users can view their own archives (needed for filtering)
CREATE POLICY "Users can view own archives"
ON match_archives FOR SELECT
USING (user_id = auth.uid());
USING (user_id = auth.uid());
-- Users can insert their own archives (for matches in their couples)
CREATE POLICY "Users can archive matches"
ON match_archives FOR INSERT
@@ -32,15 +28,12 @@ CREATE POLICY "Users can archive matches"
WHERE m.id = match_archives.match_id
AND p.id = auth.uid()
)
);
);
-- Users can delete their own archives (for unarchiving)
CREATE POLICY "Users can unarchive matches"
ON match_archives FOR DELETE
USING (user_id = auth.uid());
USING (user_id = auth.uid());
-- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE match_archives;
ALTER PUBLICATION supabase_realtime ADD TABLE match_archives;
-- Documentation
COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.';
COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.';
@@ -1,13 +1,11 @@
-- Add visibility toggle for categories
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true;
-- Categories: hide non-public categories from non-admins
DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories;
CREATE POLICY "Categories are viewable by everyone"
ON public.categories FOR SELECT
USING (is_public = true);
-- Question packs: require public categories for visibility
DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs;
CREATE POLICY "Anyone can view public packs"
@@ -24,7 +22,6 @@ CREATE POLICY "Anyone can view public packs"
)
)
);
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
CREATE POLICY "Premium users can view premium packs"
ON public.question_packs FOR SELECT
@@ -44,7 +41,6 @@ CREATE POLICY "Premium users can view premium packs"
)
)
);
-- Questions: require visible packs and public categories
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Anyone can view questions in visible packs"
@@ -71,7 +67,6 @@ CREATE POLICY "Anyone can view questions in visible packs"
)
)
);
-- Ensure recommendation queries honor category visibility
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
@@ -223,7 +218,6 @@ BEGIN
);
END;
$$;
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
LANGUAGE plpgsql
@@ -201,5 +201,4 @@ BEGIN
);
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When daily limit or answer gap threshold is exceeded for non-premium users, returns empty.';
@@ -4,9 +4,7 @@
-- Add daily_response_limit column to app_config
ALTER TABLE app_config
ADD COLUMN daily_response_limit INTEGER DEFAULT 0;
COMMENT ON COLUMN app_config.daily_response_limit IS 'Maximum number of questions a non-premium user can answer per UTC day. Set to 0 to disable daily limits.';
-- ============================================================================
-- RPC FUNCTION: get_daily_response_limit_status
-- Returns the current daily response limit status for the authenticated user
@@ -73,5 +71,4 @@ BEGIN
(v_count >= v_limit);
END;
$$;
COMMENT ON FUNCTION get_daily_response_limit_status() IS 'Returns daily response limit status for the current user. Premium users and disabled limits return is_blocked=false.';
@@ -1,5 +1,4 @@
ALTER TABLE public.questions
ADD COLUMN required_props TEXT[];
COMMENT ON COLUMN public.questions.required_props IS
'Array of props/accessories required to perform the activity.';
@@ -223,6 +223,5 @@ BEGIN
) DESC,
RANDOM();
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.';
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.';
@@ -103,10 +103,8 @@ BEGIN
-- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility)
RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold);
END;
$$;
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.';
$$;
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.';
-- ============================================================================
-- Part 2: Update get_recommended_questions() to prioritize partner-answered questions
-- ============================================================================
@@ -336,6 +334,5 @@ BEGIN
) DESC,
q.intensity ASC;
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.';
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.';
@@ -4,16 +4,16 @@
-- The "primary" question has inverse_of = NULL
-- The "secondary" (inverse) question points to its primary
-- Add the column
ALTER TABLE questions
ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL;
-- Add a comment to document the column
COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.';
-- Create an index for efficient lookups
CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL;
-- Add the columns
ALTER TABLE questions
ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL;
ALTER TABLE questions
ADD COLUMN deleted_at TIMESTAMPTZ;
-- Add comments to document the columns
COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.';
COMMENT ON COLUMN questions.deleted_at IS 'Soft delete timestamp. NULL means the question is active.';
-- Create an index for efficient lookups
CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL;
-- Create a function to get unique question count for a pack
-- Unique count = questions that are NOT inverses of another question (i.e., inverse_of IS NULL)
CREATE OR REPLACE FUNCTION get_pack_unique_question_count(pack_uuid UUID)
@@ -26,11 +26,9 @@ AS $$
WHERE pack_id = pack_uuid
AND inverse_of IS NULL
AND deleted_at IS NULL;
$$;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated;
-- Create a view to get pack stats including both total and unique counts
CREATE OR REPLACE VIEW pack_question_stats AS
SELECT
@@ -39,7 +37,6 @@ SELECT
COUNT(*) FILTER (WHERE inverse_of IS NULL AND deleted_at IS NULL) AS unique_questions,
COUNT(*) FILTER (WHERE inverse_of IS NOT NULL AND deleted_at IS NULL) AS inverse_questions
FROM questions
GROUP BY pack_id;
GROUP BY pack_id;
-- Grant select on the view
GRANT SELECT ON pack_question_stats TO authenticated;
GRANT SELECT ON pack_question_stats TO authenticated;
@@ -1,42 +1,41 @@
-- Add indexes for unindexed foreign keys
-- These improve JOIN and DELETE performance
CREATE INDEX IF NOT EXISTS idx_admin_users_created_by ON public.admin_users(created_by);
CREATE INDEX IF NOT EXISTS idx_ai_config_updated_by ON public.ai_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_app_config_updated_by ON public.app_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_code_redemptions_user_id ON public.code_redemptions(user_id);
CREATE INDEX IF NOT EXISTS idx_couple_packs_pack_id ON public.couple_packs(pack_id);
CREATE INDEX IF NOT EXISTS idx_dare_messages_sender_id ON public.dare_messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_matches_question_id ON public.matches(question_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reporter_id ON public.message_reports(reporter_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reviewed_by ON public.message_reports(reviewed_by);
CREATE INDEX IF NOT EXISTS idx_messages_match_id ON public.messages(match_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id);
CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by);
CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id);
CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id);
CREATE INDEX IF NOT EXISTS idx_admin_users_created_by ON public.admin_users(created_by);
CREATE INDEX IF NOT EXISTS idx_ai_config_updated_by ON public.ai_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_app_config_updated_by ON public.app_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_code_redemptions_user_id ON public.code_redemptions(user_id);
CREATE INDEX IF NOT EXISTS idx_couple_packs_pack_id ON public.couple_packs(pack_id);
CREATE INDEX IF NOT EXISTS idx_dare_messages_sender_id ON public.dare_messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_matches_question_id ON public.matches(question_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reporter_id ON public.message_reports(reporter_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reviewed_by ON public.message_reports(reviewed_by);
CREATE INDEX IF NOT EXISTS idx_messages_match_id ON public.messages(match_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id);
CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by);
CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id);
CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id);
-- Drop unused indexes (identified by Supabase advisor as never used)
-- These are just consuming storage with no benefit
DROP INDEX IF EXISTS public.idx_feedback_type;
DROP INDEX IF EXISTS public.idx_feedback_status;
DROP INDEX IF EXISTS public.idx_subscriptions_expires_at;
DROP INDEX IF EXISTS public.idx_webhook_events_event_id;
DROP INDEX IF EXISTS public.idx_audit_logs_record_id;
DROP INDEX IF EXISTS public.idx_audit_logs_action;
DROP INDEX IF EXISTS public.idx_pack_topics_topic_id;
DROP INDEX IF EXISTS public.idx_feature_interests_feature_name;
DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key;
DROP INDEX IF EXISTS public.idx_messages_deleted_at;
DROP INDEX IF EXISTS public.idx_message_reports_created;
DROP INDEX IF EXISTS public.idx_dare_packs_visibility;
DROP INDEX IF EXISTS public.idx_dares_pack_id;
DROP INDEX IF EXISTS public.idx_dares_intensity;
DROP INDEX IF EXISTS public.idx_sent_dares_sender_id;
DROP INDEX IF EXISTS public.idx_sent_dares_recipient_id;
DROP INDEX IF EXISTS public.idx_sent_dares_status;
DROP INDEX IF EXISTS public.idx_sent_dares_expires_at;
DROP INDEX IF EXISTS public.idx_dare_messages_sent_dare_id;
DROP INDEX IF EXISTS public.idx_dare_messages_created_at;
DROP INDEX IF EXISTS public.idx_questions_deleted_at;
DROP INDEX IF EXISTS public.idx_feedback_type;
DROP INDEX IF EXISTS public.idx_feedback_status;
DROP INDEX IF EXISTS public.idx_subscriptions_expires_at;
DROP INDEX IF EXISTS public.idx_webhook_events_event_id;
DROP INDEX IF EXISTS public.idx_audit_logs_record_id;
DROP INDEX IF EXISTS public.idx_audit_logs_action;
DROP INDEX IF EXISTS public.idx_pack_topics_topic_id;
DROP INDEX IF EXISTS public.idx_feature_interests_feature_name;
DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key;
DROP INDEX IF EXISTS public.idx_messages_deleted_at;
DROP INDEX IF EXISTS public.idx_message_reports_created;
DROP INDEX IF EXISTS public.idx_dare_packs_visibility;
DROP INDEX IF EXISTS public.idx_dares_pack_id;
DROP INDEX IF EXISTS public.idx_dares_intensity;
DROP INDEX IF EXISTS public.idx_sent_dares_sender_id;
DROP INDEX IF EXISTS public.idx_sent_dares_recipient_id;
DROP INDEX IF EXISTS public.idx_sent_dares_status;
DROP INDEX IF EXISTS public.idx_sent_dares_expires_at;
DROP INDEX IF EXISTS public.idx_dare_messages_sent_dare_id;
DROP INDEX IF EXISTS public.idx_dare_messages_created_at;
DROP INDEX IF EXISTS public.idx_questions_deleted_at;
@@ -10,29 +10,23 @@ CREATE TABLE pending_pack_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_pack_notifications_notify_at
ON pending_pack_notifications(notify_at);
ON pending_pack_notifications(notify_at);
-- Enable RLS
ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's pending notifications"
ON pending_pack_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 notifications"
ON pending_pack_notifications FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_pending_pack_notifications_updated_at
BEFORE UPDATE ON pending_pack_notifications
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to queue pack change notifications
CREATE OR REPLACE FUNCTION queue_pack_change_notification()
RETURNS TRIGGER
@@ -79,14 +73,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on couple_packs
CREATE TRIGGER on_couple_pack_enabled
AFTER INSERT OR UPDATE OF enabled ON couple_packs
FOR EACH ROW
EXECUTE FUNCTION queue_pack_change_notification();
EXECUTE FUNCTION queue_pack_change_notification();
-- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule(
'send-pack-change-notifications',
@@ -98,4 +90,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -0,0 +1,41 @@
-- Create RPC function to get a single profile with auth info
CREATE OR REPLACE FUNCTION get_profile_with_auth_info(user_id uuid)
RETURNS TABLE (
id uuid,
name text,
email text,
avatar_url text,
is_premium boolean,
couple_id uuid,
created_at timestamptz,
gender text,
usage_reason text,
show_explicit_content boolean,
onboarding_completed boolean,
max_intensity smallint,
last_sign_in_at timestamptz,
email_confirmed_at timestamptz
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
p.id,
COALESCE(p.name, au.raw_user_meta_data->>'name')::text as name,
COALESCE(p.email, au.email)::text as email,
p.avatar_url::text,
p.is_premium,
p.couple_id,
p.created_at,
p.gender::text,
p.usage_reason::text,
p.show_explicit_content,
p.onboarding_completed,
p.max_intensity,
au.last_sign_in_at,
au.email_confirmed_at
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
WHERE p.id = user_id;
$$;;
@@ -0,0 +1,7 @@
-- 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'));
;
@@ -0,0 +1,58 @@
-- 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(
target_user_id UUID,
expires_at_param TIMESTAMPTZ DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_subscription_id UUID;
v_caller_role admin_role;
v_caller_permissions JSONB;
BEGIN
-- Check if caller has permission
SELECT role, permissions INTO v_caller_role, v_caller_permissions
FROM admin_users
WHERE user_id = auth.uid();
-- Must be super_admin OR have gift_premium permission
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Access denied: not an admin user';
END IF;
IF v_caller_role != 'super_admin' AND NOT (v_caller_permissions @> '["gift_premium"]'::jsonb) THEN
RAISE EXCEPTION 'Access denied: missing gift_premium permission';
END IF;
-- Insert the subscription
INSERT INTO subscriptions (
user_id,
revenuecat_app_user_id,
product_id,
status,
store,
purchased_at,
expires_at
) VALUES (
target_user_id,
'admin_grant',
'admin_premium',
'active',
'manual',
NOW(),
expires_at_param
)
RETURNING id INTO v_subscription_id;
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;
;
@@ -0,0 +1,74 @@
-- Add notification preferences table
-- Allows users to opt out of specific notification types (opt-out model, all enabled by default)
CREATE TABLE notification_preferences (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
-- Core notifications (matching + messaging)
matches_enabled BOOLEAN DEFAULT true,
messages_enabled BOOLEAN DEFAULT true,
-- Re-engagement notifications
partner_activity_enabled BOOLEAN DEFAULT true,
pack_changes_enabled BOOLEAN DEFAULT true,
new_packs_enabled BOOLEAN DEFAULT true,
streak_milestones_enabled BOOLEAN DEFAULT true,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
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());
CREATE POLICY "Users can insert their own notification preferences"
ON notification_preferences FOR INSERT
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());
-- Super admins can view all preferences
CREATE POLICY "Super admins can view all notification preferences"
ON notification_preferences FOR SELECT
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();
-- 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)
RETURNS notification_preferences
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_prefs notification_preferences;
BEGIN
-- Try to get existing preferences
SELECT * INTO v_prefs
FROM notification_preferences
WHERE user_id = p_user_id;
-- If not found, create default preferences
IF NOT FOUND THEN
INSERT INTO notification_preferences (user_id)
VALUES (p_user_id)
RETURNING * INTO v_prefs;
END IF;
RETURN v_prefs;
END;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
@@ -0,0 +1,110 @@
-- Add partner activity notification system
-- 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;
-- Create pending activity notifications table
CREATE TABLE pending_activity_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
active_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
response_count INTEGER DEFAULT 1,
notify_at TIMESTAMPTZ NOT NULL,
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);
-- Enable RLS
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());
CREATE POLICY "Super admins can view all pending activity notifications"
ON pending_activity_notifications FOR SELECT
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();
-- Function to queue partner activity notifications
-- Called via trigger when a response is inserted or updated
CREATE OR REPLACE FUNCTION queue_partner_activity_notification()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
-- Get the user making the response
v_user_id := auth.uid();
-- Skip if no auth context (service role operations)
IF v_user_id IS NULL THEN
RETURN NEW;
END IF;
-- Get user's couple_id
SELECT couple_id INTO v_couple_id
FROM profiles
WHERE id = v_user_id;
-- Skip if user is not in a couple
IF v_couple_id IS NULL THEN
RETURN NEW;
END IF;
-- UPSERT: Insert new notification or reset timer and increment count
INSERT INTO pending_activity_notifications (
couple_id,
active_user_id,
response_count,
notify_at
)
VALUES (
v_couple_id,
v_user_id,
1,
now() + interval '1 hour'
)
ON CONFLICT (couple_id) DO UPDATE SET
active_user_id = EXCLUDED.active_user_id,
response_count = pending_activity_notifications.response_count + 1,
notify_at = now() + interval '1 hour',
updated_at = now();
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();
-- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule(
'send-partner-activity-notifications',
'*/5 * * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-partner-activity-notification',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
@@ -0,0 +1,25 @@
-- Add scheduled pack release system
-- Allows admins to schedule pack releases and automatically notifies users
-- 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;
-- 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;
-- Schedule cron job for processing scheduled releases (runs every 5 minutes)
SELECT cron.schedule(
'process-scheduled-releases',
'*/5 * * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/process-scheduled-releases',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
@@ -0,0 +1,197 @@
-- Add gentle streaks system
-- Tracks consecutive days both partners answered questions
-- Celebrates milestones without guilt-tripping on breaks
-- Create couple_streaks table
CREATE TABLE couple_streaks (
couple_id UUID PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
current_streak INTEGER DEFAULT 0,
longest_streak INTEGER DEFAULT 0,
last_active_date DATE,
user1_answered_today BOOLEAN DEFAULT false,
user2_answered_today BOOLEAN DEFAULT false,
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;
-- RLS policies
CREATE POLICY "Users can view their couple's streaks"
ON couple_streaks FOR SELECT
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());
-- Updated_at trigger
CREATE TRIGGER update_couple_streaks_updated_at
BEFORE UPDATE ON couple_streaks
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
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_today DATE;
v_streak RECORD;
v_user_is_user1 BOOLEAN;
BEGIN
-- Get the user making the response
v_user_id := auth.uid();
-- Skip if no auth context
IF v_user_id IS NULL THEN
RETURN NEW;
END IF;
-- Get user's couple_id
SELECT couple_id INTO v_couple_id
FROM profiles
WHERE id = v_user_id;
-- Skip if user is not in a couple
IF v_couple_id IS NULL THEN
RETURN NEW;
END IF;
v_today := CURRENT_DATE;
-- Get or create streak record
INSERT INTO couple_streaks (couple_id, last_active_date)
VALUES (v_couple_id, v_today)
ON CONFLICT (couple_id) DO NOTHING;
SELECT * INTO v_streak
FROM couple_streaks
WHERE couple_id = v_couple_id
FOR UPDATE;
-- Determine if this user is "user1" or "user2"
-- We use a deterministic method: compare UUIDs
SELECT id INTO v_partner_id
FROM profiles
WHERE couple_id = v_couple_id AND id != v_user_id;
-- User with smaller UUID is user1
v_user_is_user1 := v_user_id < COALESCE(v_partner_id, v_user_id);
-- Check if streak needs to be reset (missed a day)
IF v_streak.last_active_date IS NOT NULL AND v_streak.last_active_date < v_today - 1 THEN
-- Streak is broken - silently reset (no guilt-tripping!)
UPDATE couple_streaks
SET
current_streak = 0,
user1_answered_today = CASE WHEN v_user_is_user1 THEN true ELSE false END,
user2_answered_today = CASE WHEN v_user_is_user1 THEN false ELSE true END,
last_active_date = v_today
WHERE couple_id = v_couple_id;
RETURN NEW;
END IF;
-- Check if this is a new day
IF v_streak.last_active_date IS NULL OR v_streak.last_active_date < v_today THEN
-- New day: reset daily flags, keep streak
UPDATE couple_streaks
SET
user1_answered_today = CASE WHEN v_user_is_user1 THEN true ELSE false END,
user2_answered_today = CASE WHEN v_user_is_user1 THEN false ELSE true END,
last_active_date = v_today
WHERE couple_id = v_couple_id;
ELSE
-- Same day: mark this user as having answered
IF v_user_is_user1 THEN
UPDATE couple_streaks
SET user1_answered_today = true
WHERE couple_id = v_couple_id;
ELSE
UPDATE couple_streaks
SET user2_answered_today = true
WHERE couple_id = v_couple_id;
END IF;
END IF;
-- Check if BOTH partners have now answered today
SELECT * INTO v_streak
FROM couple_streaks
WHERE couple_id = v_couple_id;
IF v_streak.user1_answered_today AND v_streak.user2_answered_today THEN
-- Both answered! Increment streak if not already counted for today
IF v_streak.last_active_date = v_today AND v_streak.current_streak = 0 THEN
-- First time both answered on a new streak
UPDATE couple_streaks
SET
current_streak = 1,
longest_streak = GREATEST(longest_streak, 1)
WHERE couple_id = v_couple_id;
ELSIF v_streak.last_active_date = v_today THEN
-- Already counted for today, nothing to do
NULL;
ELSE
-- Continuing streak from previous day
UPDATE couple_streaks
SET
current_streak = current_streak + 1,
longest_streak = GREATEST(longest_streak, current_streak + 1)
WHERE couple_id = v_couple_id;
END IF;
END IF;
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();
-- Function to reset daily flags (called by cron at UTC midnight)
CREATE OR REPLACE FUNCTION reset_daily_streak_flags()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
BEGIN
UPDATE couple_streaks
SET
user1_answered_today = false,
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',
'0 0 * * *',
$$
SELECT reset_daily_streak_flags();
$$
);
-- Schedule cron job for checking streak milestones (runs at 00:05 UTC)
SELECT cron.schedule(
'check-streak-milestones',
'5 0 * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/check-streak-milestones',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);