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'; import { corsHeaders } from './cors.ts'; import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'; 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; } // Main handler function const handler = async (req: Request) => { // Handle CORS preflight requests if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders }); } // 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: { ...corsHeaders, '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: { ...corsHeaders, '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: { ...corsHeaders, '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: { ...corsHeaders, '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: { ...corsHeaders, '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: { ...corsHeaders, '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: { ...corsHeaders, '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 with deadlock retry logic // ============================================================================ let retryCount = 0; const MAX_DEADLOCK_RETRIES = 3; let result: any = null; let rpcError: any = null; while (retryCount <= MAX_DEADLOCK_RETRIES) { const { data, error } = 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 } ); result = data; rpcError = error; if (!rpcError) { // Success! break; } // Check for deadlock (40P01) or serialization failure (40001) if (rpcError.code === '40P01' || rpcError.code === '40001') { retryCount++; if (retryCount > MAX_DEADLOCK_RETRIES) { console.error(`[${requestId}] Max deadlock retries exceeded`); break; } const backoffMs = 100 * Math.pow(2, retryCount); console.log(`[${requestId}] Deadlock detected, retrying in ${backoffMs}ms (attempt ${retryCount}/${MAX_DEADLOCK_RETRIES})`); await new Promise(r => setTimeout(r, backoffMs)); continue; } // Non-retryable error, break immediately break; } 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, retries: retryCount }), { status: 500, headers: { ...corsHeaders, '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: { ...corsHeaders, '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: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } }; // Apply rate limiting: 10 requests per minute per IP (standard tier) serve(withRateLimit(handler, rateLimiters.standard, corsHeaders));