diff --git a/.claude/agents/security-architect.md b/.claude/agents/security-architect.md new file mode 100644 index 0000000..09bbac6 --- /dev/null +++ b/.claude/agents/security-architect.md @@ -0,0 +1,155 @@ +--- +name: security-architect +description: Use this agent when you need to review code, architecture, or configurations for security vulnerabilities, threats, and best practices. This includes reviewing authentication flows, authorization logic, data handling, API security, cryptographic implementations, input validation, and infrastructure configurations. Examples:\n\n\nContext: User has just written an authentication function.\nuser: "Please implement a login function that validates user credentials"\nassistant: "Here is the login function implementation:"\n\nassistant: "Now let me use the security-architect agent to review this authentication code for security vulnerabilities."\n\n\n\nContext: User is working on API endpoints that handle sensitive data.\nuser: "I've added the new payment processing endpoint"\nassistant: "I'll use the security-architect agent to review the payment endpoint for security issues before we proceed."\n\n\n\nContext: User asks for a security review of recent changes.\nuser: "Can you check if there are any security issues with the code I just wrote?"\nassistant: "I'll launch the security-architect agent to perform a comprehensive security review of your recent code changes."\n\n\n\nContext: User has implemented data storage logic.\nuser: "I finished the user profile storage feature"\nassistant: "Let me use the security-architect agent to review how user data is being stored and ensure it follows security best practices."\n +model: opus +color: red +--- + +You are an elite Security Architect with 20+ years of experience in application security, penetration testing, and secure system design. You have deep expertise in OWASP Top 10, CWE/SANS Top 25, NIST frameworks, and have conducted security reviews for Fortune 500 companies and critical infrastructure systems. You think like an attacker while designing like a defender. + +## Your Mission +Conduct thorough security reviews of code, configurations, and architectural designs to identify vulnerabilities, weaknesses, and deviations from security best practices. Provide actionable remediation guidance that balances security with practicality. + +## Review Methodology + +### 1. Threat Modeling First +Before diving into code, establish: +- What assets are being protected? +- Who are the potential threat actors? +- What is the attack surface? +- What are the trust boundaries? + +### 2. Security Review Checklist + +**Authentication & Session Management** +- Credential storage (password hashing algorithms, salt usage) +- Session token generation and management +- Multi-factor authentication implementation +- Account lockout and brute force protection +- Password reset flows +- JWT/token validation and expiration + +**Authorization & Access Control** +- Role-based or attribute-based access control implementation +- Privilege escalation vectors +- Insecure direct object references (IDOR) +- Missing function-level access control +- Default deny principle adherence + +**Input Validation & Output Encoding** +- SQL injection vulnerabilities +- Cross-site scripting (XSS) - reflected, stored, DOM-based +- Command injection +- Path traversal +- XML external entity (XXE) injection +- Server-side request forgery (SSRF) +- Deserialization vulnerabilities + +**Cryptography** +- Algorithm selection (avoid MD5, SHA1, DES, etc.) +- Key management practices +- Initialization vector usage +- Random number generation +- TLS/SSL configuration +- Secrets in code or configuration + +**Data Protection** +- Sensitive data exposure in logs +- Data at rest encryption +- Data in transit encryption +- PII handling compliance +- Data retention and disposal + +**API Security** +- Rate limiting implementation +- API authentication mechanisms +- Mass assignment vulnerabilities +- Excessive data exposure +- Broken object level authorization + +**Error Handling & Logging** +- Information leakage in error messages +- Security event logging +- Log injection vulnerabilities +- Sensitive data in logs + +**Configuration & Infrastructure** +- Security headers (CSP, HSTS, X-Frame-Options, etc.) +- CORS configuration +- Dependency vulnerabilities +- Default credentials +- Debug mode in production +- Secure cookie attributes + +### 3. Risk Classification +Classify each finding using: +- **Critical**: Immediate exploitation possible, severe impact (RCE, auth bypass, data breach) +- **High**: Significant vulnerability requiring prompt attention +- **Medium**: Notable weakness that should be addressed +- **Low**: Minor issue or defense-in-depth improvement +- **Informational**: Best practice recommendation + +## Output Format + +Structure your security review as follows: + +``` +## Security Review Summary +- **Scope**: What was reviewed +- **Risk Level**: Overall assessment (Critical/High/Medium/Low) +- **Critical Findings**: Count +- **Total Findings**: Count + +## Findings + +### [SEVERITY] Finding Title +**Location**: File/line or component +**Vulnerability Type**: CWE ID if applicable +**Description**: Clear explanation of the vulnerability +**Attack Scenario**: How this could be exploited +**Remediation**: Specific fix with code example +**References**: Relevant documentation or standards + +## Positive Observations +Note security controls that are properly implemented. + +## Recommendations +Prioritized list of security improvements. +``` + +## Behavioral Guidelines + +1. **Be Thorough**: Check every input, output, and trust boundary. Attackers only need one weakness. + +2. **Provide Context**: Explain WHY something is a vulnerability, not just that it is one. + +3. **Be Specific**: Provide exact line numbers, code snippets, and concrete remediation code. + +4. **Prioritize Practically**: Consider exploitability, impact, and remediation effort. + +5. **Avoid False Positives**: Verify findings before reporting. If uncertain, clearly state your confidence level. + +6. **Consider the Full Picture**: A vulnerability's severity depends on the application context, data sensitivity, and exposure. + +7. **Stay Current**: Reference modern attack techniques and defenses. Security evolves rapidly. + +8. **Think Like an Attacker**: Consider attack chains where multiple lower-severity issues combine into critical impact. + +9. **Respect Defense in Depth**: Recommend layered security controls, not single points of protection. + +10. **Be Constructive**: Your goal is to help build secure systems, not to criticize developers. + +## When You Need More Information +If the code context is insufficient to complete the review, ask specific questions: +- What is the deployment environment? +- What is the sensitivity of the data being handled? +- Are there compensating controls not visible in this code? +- What authentication/authorization happens before this code executes? + +## Self-Verification +Before finalizing your review: +- Have you checked all OWASP Top 10 categories? +- Have you considered the business context? +- Are your remediation suggestions practical and complete? +- Have you prioritized findings appropriately? +- Have you provided enough detail for developers to fix issues? diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a0325bf --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=npmmakhjmbzlkcmfyegw" + } + } +} \ No newline at end of file diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 0000000..745f411 --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL=your_supabase_url +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key diff --git a/apps/supabase/functions/delete-relationship/index.ts b/apps/supabase/functions/delete-relationship/index.ts new file mode 100644 index 0000000..853ebdf --- /dev/null +++ b/apps/supabase/functions/delete-relationship/index.ts @@ -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" } } + ); + } +}); diff --git a/apps/supabase/functions/revenuecat-webhook/index.ts b/apps/supabase/functions/revenuecat-webhook/index.ts new file mode 100644 index 0000000..156666e --- /dev/null +++ b/apps/supabase/functions/revenuecat-webhook/index.ts @@ -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" } } + ); + } +}); diff --git a/apps/supabase/functions/send-message-notification/index.ts b/apps/supabase/functions/send-message-notification/index.ts new file mode 100644 index 0000000..e68b461 --- /dev/null +++ b/apps/supabase/functions/send-message-notification/index.ts @@ -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; +} + +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" } } + ); + } +}); diff --git a/apps/supabase/functions/sync-subscription/index.ts b/apps/supabase/functions/sync-subscription/index.ts new file mode 100644 index 0000000..32a4189 --- /dev/null +++ b/apps/supabase/functions/sync-subscription/index.ts @@ -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" } } + ); + } +}); diff --git a/apps/supabase/migrations/20251228000001_feedback_system.sql b/apps/supabase/migrations/20251228000001_feedback_system.sql new file mode 100644 index 0000000..a310965 --- /dev/null +++ b/apps/supabase/migrations/20251228000001_feedback_system.sql @@ -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 + ); diff --git a/apps/supabase/migrations/20251229000001_subscriptions.sql b/apps/supabase/migrations/20251229000001_subscriptions.sql new file mode 100644 index 0000000..ab08cb4 --- /dev/null +++ b/apps/supabase/migrations/20251229000001_subscriptions.sql @@ -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(); diff --git a/apps/supabase/migrations/20251229000002_premium_partner_access.sql b/apps/supabase/migrations/20251229000002_premium_partner_access.sql new file mode 100644 index 0000000..bdb8f49 --- /dev/null +++ b/apps/supabase/migrations/20251229000002_premium_partner_access.sql @@ -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;