diff --git a/supabase/functions/process-selective-approval-v2/cors.ts b/supabase/functions/process-selective-approval-v2/cors.ts new file mode 100644 index 00000000..14d9864d --- /dev/null +++ b/supabase/functions/process-selective-approval-v2/cors.ts @@ -0,0 +1,4 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; diff --git a/supabase/functions/process-selective-approval-v2/index.ts b/supabase/functions/process-selective-approval-v2/index.ts new file mode 100644 index 00000000..b212dffd --- /dev/null +++ b/supabase/functions/process-selective-approval-v2/index.ts @@ -0,0 +1,188 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; + +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com'; +const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!; + +interface ApprovalRequest { + submissionId: string; + itemIds: string[]; + idempotencyKey: string; +} + +serve(async (req) => { + // Generate request ID for tracking + const requestId = crypto.randomUUID(); + + try { + // STEP 1: Authentication + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response( + JSON.stringify({ error: 'Missing Authorization header' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: authHeader } } + }); + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return new Response( + JSON.stringify({ error: 'Unauthorized' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.log(`[${requestId}] Approval request from moderator ${user.id}`); + + // STEP 2: Parse request + const body: ApprovalRequest = await req.json(); + const { submissionId, itemIds, idempotencyKey } = body; + + if (!submissionId || !itemIds || itemIds.length === 0) { + return new Response( + JSON.stringify({ error: 'Missing required fields: submissionId, itemIds' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // STEP 3: Idempotency check + const { data: existingKey } = await supabase + .from('submission_idempotency_keys') + .select('*') + .eq('idempotency_key', idempotencyKey) + .single(); + + if (existingKey?.status === 'completed') { + console.log(`[${requestId}] Idempotency key already processed, returning cached result`); + return new Response( + JSON.stringify(existingKey.result_data), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Cache-Status': 'HIT' + } + } + ); + } + + // STEP 4: Fetch submission to get submitter_id + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .select('user_id, status, assigned_to') + .eq('id', submissionId) + .single(); + + if (submissionError || !submission) { + console.error(`[${requestId}] Submission not found:`, submissionError); + return new Response( + JSON.stringify({ error: 'Submission not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // STEP 5: Verify moderator can approve this submission + if (submission.assigned_to && submission.assigned_to !== user.id) { + console.error(`[${requestId}] Submission locked by another moderator`); + return new Response( + JSON.stringify({ error: 'Submission is locked by another moderator' }), + { status: 409, headers: { 'Content-Type': 'application/json' } } + ); + } + + if (!['pending', 'partially_approved'].includes(submission.status)) { + console.error(`[${requestId}] Invalid submission status: ${submission.status}`); + return new Response( + JSON.stringify({ error: 'Submission already processed' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // STEP 6: Register idempotency key as processing + if (!existingKey) { + await supabase.from('submission_idempotency_keys').insert({ + idempotency_key: idempotencyKey, + submission_id: submissionId, + moderator_id: user.id, + status: 'processing' + }); + } + + console.log(`[${requestId}] Calling process_approval_transaction RPC`); + + // ============================================================================ + // STEP 7: Call RPC function - entire approval in single atomic transaction + // ============================================================================ + const { data: result, error: rpcError } = await supabase.rpc( + 'process_approval_transaction', + { + p_submission_id: submissionId, + p_item_ids: itemIds, + p_moderator_id: user.id, + p_submitter_id: submission.user_id, + p_request_id: requestId + } + ); + + if (rpcError) { + // Transaction failed - EVERYTHING rolled back automatically by PostgreSQL + console.error(`[${requestId}] Approval transaction failed:`, rpcError); + + // Update idempotency key to failed + await supabase + .from('submission_idempotency_keys') + .update({ + status: 'failed', + error_message: rpcError.message, + completed_at: new Date().toISOString() + }) + .eq('idempotency_key', idempotencyKey); + + return new Response( + JSON.stringify({ + error: 'Approval transaction failed', + message: rpcError.message, + details: rpcError.details + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.log(`[${requestId}] Transaction completed successfully:`, result); + + // STEP 8: Success - update idempotency key + await supabase + .from('submission_idempotency_keys') + .update({ + status: 'completed', + result_data: result, + completed_at: new Date().toISOString() + }) + .eq('idempotency_key', idempotencyKey); + + return new Response( + JSON.stringify(result), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Request-Id': requestId + } + } + ); + + } catch (error) { + console.error(`[${requestId}] Unexpected error:`, error); + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +});