Files
thrilltrack-explorer/supabase/functions/process-selective-approval/index.ts
Claude 571bf07b84 Fix critical error handling gaps in submission pipeline
Addressed real error handling issues identified during comprehensive
pipeline review:

1. **process-selective-approval edge function**
   - Added try-catch blocks around idempotency key updates (lines 216-262)
   - Prevents silent failures when updating submission status tracking
   - Updates are now non-blocking to ensure proper response delivery

2. **submissionItemsService.ts**
   - Added error logging before throwing in fetchSubmissionItems (line 75-81)
   - Added error handling for park location fetch failures (lines 99-107)
   - Location fetch errors are now logged as non-critical and don't block
     submission item retrieval

3. **notify-moderators-submission edge function**
   - Added error handling for notification log insert (lines 216-236)
   - Log failures are now non-blocking and properly logged
   - Ensures notification delivery isn't blocked by logging issues

4. **upload-image edge function**
   - Fixed CORS headers scope issue (line 127)
   - Moved corsHeaders definition outside try block
   - Prevents undefined reference in catch block error responses

All changes maintain backward compatibility and improve pipeline
resilience without altering functionality. Error handling is now
consistent with non-blocking patterns for auxiliary operations.
2025-11-08 03:47:54 +00:00

296 lines
8.6 KiB
TypeScript

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
try {
await supabase
.from('submission_idempotency_keys')
.update({
status: 'failed',
error_message: rpcError.message,
completed_at: new Date().toISOString()
})
.eq('idempotency_key', idempotencyKey);
} catch (updateError) {
console.error(`[${requestId}] Failed to update idempotency key to failed:`, updateError);
// Non-blocking - continue with error response even if idempotency update fails
}
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
try {
await supabase
.from('submission_idempotency_keys')
.update({
status: 'completed',
result_data: result,
completed_at: new Date().toISOString()
})
.eq('idempotency_key', idempotencyKey);
} catch (updateError) {
console.error(`[${requestId}] Failed to update idempotency key to completed:`, updateError);
// Non-blocking - transaction succeeded, so continue with success response
}
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));