This commit is contained in:
Jamie Brunton committed 2025-12-28 21:56:37 +00:00
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" } }
);
}
});