mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Update
This commit is contained in:
1 parent
adab288a3a
commit
d1ca114285
10 files changed
+985
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (req.method !== "DELETE") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Method not allowed" }),
|
||||
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
// Get user from JWT
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing authorization header" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
if (authError || !user) {
|
||||
console.error("Auth Error:", authError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's profile and couple_id
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("couple_id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile?.couple_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "You are not in a relationship" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const coupleId = profile.couple_id;
|
||||
|
||||
// Get all match IDs for this couple (needed for storage cleanup)
|
||||
const { data: matches } = await supabase
|
||||
.from("matches")
|
||||
.select("id")
|
||||
.eq("couple_id", coupleId);
|
||||
|
||||
const matchIds = matches?.map(m => m.id) || [];
|
||||
|
||||
// Delete all chat media from storage for each match
|
||||
for (const matchId of matchIds) {
|
||||
const { data: files } = await supabase.storage
|
||||
.from("chat-media")
|
||||
.list(matchId);
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const filePaths = files.map(f => `${matchId}/${f.name}`);
|
||||
await supabase.storage
|
||||
.from("chat-media")
|
||||
.remove(filePaths);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear couple_id from both partners' profiles first
|
||||
const { error: updateError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ couple_id: null })
|
||||
.eq("couple_id", coupleId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Failed to clear couple_id from profiles:", updateError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to clear relationship from profiles" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the couple record (cascades to responses, matches, messages, couple_packs)
|
||||
const { error: deleteError } = await supabase
|
||||
.from("couples")
|
||||
.delete()
|
||||
.eq("id", coupleId);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Failed to delete couple:", deleteError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to delete relationship data" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Relationship data deleted successfully" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
// RevenueCat webhook secret (set in dashboard)
|
||||
const REVENUECAT_WEBHOOK_SECRET = Deno.env.get("REVENUECAT_WEBHOOK_SECRET");
|
||||
|
||||
interface RevenueCatEvent {
|
||||
api_version: string;
|
||||
event: {
|
||||
type: string;
|
||||
id: string;
|
||||
app_user_id: string;
|
||||
original_app_user_id: string;
|
||||
product_id: string;
|
||||
entitlement_ids: string[];
|
||||
purchased_at_ms: number;
|
||||
expiration_at_ms?: number;
|
||||
original_transaction_id: string;
|
||||
store: string;
|
||||
environment: string;
|
||||
cancel_reason?: string;
|
||||
grace_period_expiration_at_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type SubscriptionStatus = "active" | "cancelled" | "expired" | "billing_issue" | "paused";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Method not allowed" }),
|
||||
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify webhook authorization
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (REVENUECAT_WEBHOOK_SECRET && authHeader !== `Bearer ${REVENUECAT_WEBHOOK_SECRET}`) {
|
||||
console.error("Invalid webhook authorization");
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const payload: RevenueCatEvent = await req.json();
|
||||
const event = payload.event;
|
||||
|
||||
console.log(`Processing RevenueCat event: ${event.type} for user: ${event.app_user_id}`);
|
||||
|
||||
// Check for duplicate event (idempotency)
|
||||
const { data: existingEvent } = await supabase
|
||||
.from("revenuecat_webhook_events")
|
||||
.select("id")
|
||||
.eq("event_id", event.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingEvent) {
|
||||
console.log(`Duplicate event ${event.id}, skipping`);
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Duplicate event" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Record the event
|
||||
await supabase.from("revenuecat_webhook_events").insert({
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
app_user_id: event.app_user_id,
|
||||
payload: payload,
|
||||
});
|
||||
|
||||
// The app_user_id should be the Supabase user UUID
|
||||
const userId = event.app_user_id;
|
||||
|
||||
// Map event type to subscription status
|
||||
let status: SubscriptionStatus;
|
||||
switch (event.type) {
|
||||
case "INITIAL_PURCHASE":
|
||||
case "RENEWAL":
|
||||
case "UNCANCELLATION":
|
||||
case "PRODUCT_CHANGE":
|
||||
status = "active";
|
||||
break;
|
||||
case "CANCELLATION":
|
||||
status = "cancelled";
|
||||
break;
|
||||
case "EXPIRATION":
|
||||
status = "expired";
|
||||
break;
|
||||
case "BILLING_ISSUE":
|
||||
status = "billing_issue";
|
||||
break;
|
||||
case "SUBSCRIPTION_PAUSED":
|
||||
status = "paused";
|
||||
break;
|
||||
case "TEST":
|
||||
// Test event - just acknowledge
|
||||
console.log("Test event received");
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Test event received" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Event type not handled" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the user exists
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
console.error(`User not found: ${userId}`);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "User not found", userId }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert subscription record
|
||||
const subscriptionData = {
|
||||
user_id: userId,
|
||||
revenuecat_app_user_id: event.app_user_id,
|
||||
product_id: event.product_id,
|
||||
status: status,
|
||||
entitlement_ids: event.entitlement_ids || [],
|
||||
purchased_at: new Date(event.purchased_at_ms).toISOString(),
|
||||
expires_at: event.expiration_at_ms
|
||||
? new Date(event.expiration_at_ms).toISOString()
|
||||
: null,
|
||||
original_transaction_id: event.original_transaction_id,
|
||||
store: event.store,
|
||||
is_sandbox: event.environment !== "PRODUCTION",
|
||||
cancel_reason: event.cancel_reason || null,
|
||||
grace_period_expires_at: event.grace_period_expiration_at_ms
|
||||
? new Date(event.grace_period_expiration_at_ms).toISOString()
|
||||
: null,
|
||||
};
|
||||
|
||||
const { error: upsertError } = await supabase
|
||||
.from("subscriptions")
|
||||
.upsert(subscriptionData, {
|
||||
onConflict: "user_id,original_transaction_id",
|
||||
});
|
||||
|
||||
if (upsertError) {
|
||||
console.error("Error upserting subscription:", upsertError);
|
||||
// Don't return error - the trigger will still sync premium status
|
||||
}
|
||||
|
||||
// The sync_premium_status trigger will automatically update profiles.is_premium
|
||||
|
||||
console.log(`Successfully processed ${event.type} for user ${userId}, status: ${status}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface NotificationBody {
|
||||
match_id: string;
|
||||
sender_id: string;
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const { match_id, sender_id }: NotificationBody = await req.json();
|
||||
|
||||
if (!match_id || !sender_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing match_id or sender_id" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the match to find the couple_id
|
||||
const { data: match, error: matchError } = await supabase
|
||||
.from("matches")
|
||||
.select("couple_id")
|
||||
.eq("id", match_id)
|
||||
.single();
|
||||
|
||||
if (matchError || !match) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Match not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the recipient's push token (partner, not sender)
|
||||
const { data: recipient, error: recipientError } = await supabase
|
||||
.from("profiles")
|
||||
.select("push_token, name")
|
||||
.eq("couple_id", match.couple_id)
|
||||
.neq("id", sender_id)
|
||||
.single();
|
||||
|
||||
if (recipientError || !recipient?.push_token) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Recipient has no push token" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get sender name
|
||||
const { data: sender } = await supabase
|
||||
.from("profiles")
|
||||
.select("name")
|
||||
.eq("id", sender_id)
|
||||
.single();
|
||||
|
||||
// Get the most recent message from this sender in this match
|
||||
const { data: message } = await supabase
|
||||
.from("messages")
|
||||
.select("id, content, media_path")
|
||||
.eq("match_id", match_id)
|
||||
.eq("user_id", sender_id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
const senderName = sender?.name || "Your partner";
|
||||
const messagePreview = message?.content
|
||||
? message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content
|
||||
: "Sent an image";
|
||||
|
||||
// Build push message
|
||||
const pushMessage: ExpoPushMessage = {
|
||||
to: recipient.push_token,
|
||||
title: senderName,
|
||||
body: messagePreview,
|
||||
sound: "default",
|
||||
data: {
|
||||
type: "message",
|
||||
match_id,
|
||||
message_id: message?.id,
|
||||
},
|
||||
};
|
||||
|
||||
await sendExpoPushNotifications([pushMessage]);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
// RevenueCat API key (secret key, not public)
|
||||
const REVENUECAT_API_KEY = Deno.env.get("REVENUECAT_API_KEY");
|
||||
const ENTITLEMENT_ID = Deno.env.get("REVENUECAT_ENTITLEMENT_ID") || "Sauci Pro";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user from JWT
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing authorization" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
// Verify the JWT and get user
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid token" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Checking subscription for user: ${user.id}`);
|
||||
console.log(`Using entitlement ID: ${ENTITLEMENT_ID}`);
|
||||
console.log(`API key present: ${!!REVENUECAT_API_KEY}`);
|
||||
|
||||
// Fetch subscription status from RevenueCat API (server-side verification)
|
||||
const rcResponse = await fetch(
|
||||
`https://api.revenuecat.com/v1/subscribers/${user.id}`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bearer ${REVENUECAT_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`RevenueCat API response status: ${rcResponse.status}`);
|
||||
|
||||
if (!rcResponse.ok) {
|
||||
const errorText = await rcResponse.text();
|
||||
console.error("RevenueCat API error:", errorText);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to verify subscription", details: errorText }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const rcData = await rcResponse.json();
|
||||
console.log("RevenueCat subscriber data:", JSON.stringify(rcData.subscriber?.entitlements || {}));
|
||||
|
||||
const entitlements = rcData.subscriber?.entitlements || {};
|
||||
const entitlement = entitlements[ENTITLEMENT_ID];
|
||||
|
||||
console.log(`Found entitlement "${ENTITLEMENT_ID}":`, entitlement ? "yes" : "no");
|
||||
if (entitlement) {
|
||||
console.log(`Expires: ${entitlement.expires_date}`);
|
||||
}
|
||||
|
||||
// Check if entitlement is active
|
||||
const isActive = entitlement &&
|
||||
new Date(entitlement.expires_date) > new Date();
|
||||
|
||||
console.log(`User ${user.id} subscription status: ${isActive ? "active" : "inactive"}`);
|
||||
|
||||
// Update the database
|
||||
const { error: updateError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ is_premium: isActive })
|
||||
.eq("id", user.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Database update error:", updateError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to update status" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, is_premium: isActive }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Create feedback type enum
|
||||
CREATE TYPE public.feedback_type AS ENUM ('bug', 'feature_request', 'general');
|
||||
|
||||
-- Create feedback status enum (for future admin use)
|
||||
CREATE TYPE public.feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved', 'closed');
|
||||
|
||||
-- Create feedback table
|
||||
CREATE TABLE public.feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
|
||||
-- Feedback content
|
||||
type public.feedback_type NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
screenshot_url TEXT,
|
||||
|
||||
-- Device info (JSONB for flexibility)
|
||||
device_info JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Admin/tracking fields
|
||||
status public.feedback_status NOT NULL DEFAULT 'new',
|
||||
admin_notes TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_feedback_user_id ON public.feedback(user_id);
|
||||
CREATE INDEX idx_feedback_type ON public.feedback(type);
|
||||
CREATE INDEX idx_feedback_status ON public.feedback(status);
|
||||
CREATE INDEX idx_feedback_created_at ON public.feedback(created_at DESC);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies
|
||||
-- Users can insert their own feedback
|
||||
CREATE POLICY "Users can create feedback"
|
||||
ON public.feedback FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Users can view their own feedback
|
||||
CREATE POLICY "Users can view own feedback"
|
||||
ON public.feedback FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can update their own feedback (only if still 'new' status)
|
||||
CREATE POLICY "Users can update own pending feedback"
|
||||
ON public.feedback FOR UPDATE
|
||||
USING (auth.uid() = user_id AND status = 'new');
|
||||
|
||||
-- Create storage bucket for feedback screenshots
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Storage RLS policies for feedback screenshots
|
||||
CREATE POLICY "Users can upload feedback screenshots"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Create subscription status enum
|
||||
CREATE TYPE public.subscription_status AS ENUM (
|
||||
'active',
|
||||
'cancelled',
|
||||
'expired',
|
||||
'billing_issue',
|
||||
'paused'
|
||||
);
|
||||
|
||||
-- Create subscriptions table
|
||||
CREATE TABLE public.subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
revenuecat_app_user_id TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL,
|
||||
status public.subscription_status NOT NULL DEFAULT 'active',
|
||||
entitlement_ids TEXT[] DEFAULT '{}',
|
||||
purchased_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
original_transaction_id TEXT,
|
||||
store TEXT DEFAULT 'APP_STORE',
|
||||
is_sandbox BOOLEAN DEFAULT FALSE,
|
||||
cancel_reason TEXT,
|
||||
grace_period_expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, original_transaction_id)
|
||||
);
|
||||
|
||||
-- Create webhook events table for idempotency
|
||||
CREATE TABLE public.revenuecat_webhook_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id TEXT UNIQUE NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
app_user_id TEXT NOT NULL,
|
||||
processed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
payload JSONB
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
|
||||
CREATE INDEX idx_subscriptions_status ON public.subscriptions(status);
|
||||
CREATE INDEX idx_subscriptions_expires_at ON public.subscriptions(expires_at);
|
||||
CREATE INDEX idx_webhook_events_event_id ON public.revenuecat_webhook_events(event_id);
|
||||
|
||||
-- Update updated_at trigger for subscriptions
|
||||
CREATE TRIGGER update_subscriptions_updated_at
|
||||
BEFORE UPDATE ON public.subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for subscriptions
|
||||
CREATE POLICY "Users can view own subscriptions"
|
||||
ON public.subscriptions FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Service role only for webhook events (no user access needed)
|
||||
-- Webhook events are written by service role only
|
||||
|
||||
-- Function to sync is_premium flag to profiles
|
||||
CREATE OR REPLACE FUNCTION public.sync_premium_status()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Update the profile's is_premium based on active subscription
|
||||
UPDATE public.profiles
|
||||
SET is_premium = EXISTS (
|
||||
SELECT 1 FROM public.subscriptions
|
||||
WHERE user_id = NEW.user_id
|
||||
AND status = 'active'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
)
|
||||
WHERE id = NEW.user_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Trigger to auto-sync premium status on subscription changes
|
||||
CREATE TRIGGER sync_premium_on_subscription_change
|
||||
AFTER INSERT OR UPDATE ON public.subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status();
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Update RLS policies to allow partner premium access
|
||||
-- When either user OR their partner has is_premium = TRUE, both can access premium packs
|
||||
|
||||
-- Drop existing premium pack policy
|
||||
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
|
||||
|
||||
-- Create new policy that checks couple premium status
|
||||
-- Access granted if: pack is not premium OR user is premium OR partner is premium
|
||||
CREATE POLICY "Users or partners with premium can view premium packs"
|
||||
ON public.question_packs FOR SELECT
|
||||
USING (
|
||||
is_premium = FALSE OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND (
|
||||
p.is_premium = TRUE OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.profiles partner
|
||||
WHERE partner.couple_id = p.couple_id
|
||||
AND partner.couple_id IS NOT NULL
|
||||
AND partner.id != p.id
|
||||
AND partner.is_premium = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Update questions policy similarly
|
||||
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
|
||||
|
||||
CREATE POLICY "Users can view questions in accessible packs"
|
||||
ON public.questions FOR SELECT
|
||||
USING (
|
||||
pack_id IN (
|
||||
SELECT qp.id FROM public.question_packs qp
|
||||
WHERE qp.is_public = TRUE
|
||||
AND (
|
||||
qp.is_premium = FALSE OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND (
|
||||
p.is_premium = TRUE OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.profiles partner
|
||||
WHERE partner.couple_id = p.couple_id
|
||||
AND partner.couple_id IS NOT NULL
|
||||
AND partner.id != p.id
|
||||
AND partner.is_premium = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Create a helper function to check premium access for a user
|
||||
-- This can be used in application code and other policies
|
||||
CREATE OR REPLACE FUNCTION public.has_premium_access(check_user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
user_premium BOOLEAN;
|
||||
partner_premium BOOLEAN;
|
||||
user_couple_id UUID;
|
||||
BEGIN
|
||||
-- Get user's premium status and couple_id
|
||||
SELECT is_premium, couple_id INTO user_premium, user_couple_id
|
||||
FROM public.profiles
|
||||
WHERE id = check_user_id;
|
||||
|
||||
-- If user is premium, return true
|
||||
IF user_premium = TRUE THEN
|
||||
RETURN TRUE;
|
||||
END IF;
|
||||
|
||||
-- If user has no couple, return false
|
||||
IF user_couple_id IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- Check if partner is premium
|
||||
SELECT is_premium INTO partner_premium
|
||||
FROM public.profiles
|
||||
WHERE couple_id = user_couple_id
|
||||
AND id != check_user_id
|
||||
LIMIT 1;
|
||||
|
||||
RETURN COALESCE(partner_premium, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
|
||||
Reference in new issue
Block a user