mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 02:31:13 -05:00
Migrate complex functions in batches
Batch 1 of Phase 2: migrate 3-4 edge functions to use createEdgeFunction wrapper (process-selective-approval, process-selective-rejection, rate-limit-metrics) to enable automatic error logging, CORS, auth, and reduced boilerplate; preserve existing logic where applicable and prepare for subsequent batches.
This commit is contained in:
@@ -1,614 +1,247 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
|
||||||
import {
|
import {
|
||||||
edgeLogger,
|
|
||||||
startSpan,
|
|
||||||
endSpan,
|
|
||||||
addSpanEvent,
|
addSpanEvent,
|
||||||
setSpanAttributes,
|
setSpanAttributes,
|
||||||
getSpanContext,
|
getSpanContext,
|
||||||
logSpan,
|
startSpan,
|
||||||
extractSpanContextFromHeaders,
|
endSpan,
|
||||||
type Span
|
|
||||||
} from '../_shared/logger.ts';
|
} from '../_shared/logger.ts';
|
||||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
import { toError } from '../_shared/errorFormatter.ts';
|
||||||
import {
|
import {
|
||||||
validateApprovalRequest,
|
validateApprovalRequest,
|
||||||
validateSubmissionItems,
|
|
||||||
getSubmissionTableName,
|
|
||||||
getMainTableName,
|
|
||||||
type ValidatedSubmissionItem
|
|
||||||
} from '../_shared/submissionValidation.ts';
|
} from '../_shared/submissionValidation.ts';
|
||||||
import { ValidationError } from '../_shared/typeValidation.ts';
|
import { ValidationError } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY');
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CRITICAL: Validate environment variables at startup
|
|
||||||
// ============================================================================
|
|
||||||
if (!SUPABASE_ANON_KEY) {
|
|
||||||
const errorMsg = 'CRITICAL: SUPABASE_ANON_KEY environment variable is not set!';
|
|
||||||
console.error(errorMsg, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
hasUrl: !!SUPABASE_URL,
|
|
||||||
url: SUPABASE_URL,
|
|
||||||
availableEnvVars: Object.keys(Deno.env.toObject()).filter(k =>
|
|
||||||
k.includes('SUPABASE') || k.includes('URL')
|
|
||||||
)
|
|
||||||
});
|
|
||||||
throw new Error('Missing required environment variable: SUPABASE_ANON_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Edge function initialized successfully', {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
function: 'process-selective-approval',
|
|
||||||
hasUrl: !!SUPABASE_URL,
|
|
||||||
hasKey: !!SUPABASE_ANON_KEY,
|
|
||||||
keyLength: SUPABASE_ANON_KEY.length
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ApprovalRequest {
|
|
||||||
submissionId: string;
|
|
||||||
itemIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main handler function
|
// Main handler function
|
||||||
const handler = async (req: Request) => {
|
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
|
||||||
// ============================================================================
|
const { supabase, user, span: rootSpan, requestId } = context;
|
||||||
// Log every incoming request immediately
|
|
||||||
// ============================================================================
|
setSpanAttributes(rootSpan, {
|
||||||
console.log('Request received', {
|
'user.id': user.id,
|
||||||
timestamp: new Date().toISOString(),
|
'function.name': 'process-selective-approval'
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
headers: {
|
|
||||||
authorization: req.headers.has('Authorization') ? '[PRESENT]' : '[MISSING]',
|
|
||||||
contentType: req.headers.get('Content-Type'),
|
|
||||||
traceparent: req.headers.get('traceparent') || '[NONE]'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle CORS preflight requests
|
// STEP 1: Parse and validate request
|
||||||
if (req.method === 'OPTIONS') {
|
addSpanEvent(rootSpan, 'validation_start');
|
||||||
return new Response(null, {
|
|
||||||
status: 204,
|
let submissionId: string;
|
||||||
headers: corsHeaders
|
let itemIds: string[];
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract parent span context from headers (if present)
|
|
||||||
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
|
||||||
|
|
||||||
// Create root span for this edge function invocation
|
|
||||||
const rootSpan = startSpan(
|
|
||||||
'process-selective-approval',
|
|
||||||
'SERVER',
|
|
||||||
parentSpanContext,
|
|
||||||
{
|
|
||||||
'http.method': 'POST',
|
|
||||||
'function.name': 'process-selective-approval',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const requestId = rootSpan.spanId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STEP 1: Authentication
|
const body = await req.json();
|
||||||
addSpanEvent(rootSpan, 'authentication_start');
|
const validated = validateApprovalRequest(body, requestId);
|
||||||
const authHeader = req.headers.get('Authorization');
|
submissionId = validated.submissionId;
|
||||||
if (!authHeader) {
|
itemIds = validated.itemIds;
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { reason: 'missing_header' });
|
} catch (error) {
|
||||||
endSpan(rootSpan, 'error');
|
if (error instanceof ValidationError) {
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rootSpan, 'validation_failed', {
|
||||||
return new Response(
|
field: error.field,
|
||||||
JSON.stringify({ error: 'Missing Authorization header' }),
|
expected: error.expected,
|
||||||
{
|
received: error.received,
|
||||||
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) {
|
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { error: authError?.message });
|
|
||||||
edgeLogger.warn('Authentication failed', {
|
|
||||||
requestId,
|
|
||||||
error: authError?.message,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
});
|
||||||
endSpan(rootSpan, 'error', authError || new Error('Unauthorized'));
|
throw error; // Will be caught by wrapper
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Unauthorized' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('x-idempotency-key');
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
||||||
|
throw new ValidationError('idempotency_key', 'Missing X-Idempotency-Key header', 'string', 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, { 'user.id': user.id });
|
setSpanAttributes(rootSpan, {
|
||||||
addSpanEvent(rootSpan, 'authentication_success');
|
'submission.id': submissionId,
|
||||||
edgeLogger.info('Approval request received', {
|
'submission.item_count': itemIds.length,
|
||||||
requestId,
|
'idempotency.key': idempotencyKey,
|
||||||
moderatorId: user.id,
|
});
|
||||||
action: 'process_approval'
|
addSpanEvent(rootSpan, 'validation_complete');
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 2: Parse and validate request
|
// STEP 2: Idempotency check
|
||||||
addSpanEvent(rootSpan, 'validation_start');
|
addSpanEvent(rootSpan, 'idempotency_check_start');
|
||||||
|
const { data: existingKey } = await supabase
|
||||||
let submissionId: string;
|
.from('submission_idempotency_keys')
|
||||||
let itemIds: string[];
|
.select('*')
|
||||||
|
.eq('idempotency_key', idempotencyKey)
|
||||||
try {
|
.single();
|
||||||
const body = await req.json();
|
|
||||||
const validated = validateApprovalRequest(body, requestId);
|
if (existingKey?.status === 'completed') {
|
||||||
submissionId = validated.submissionId;
|
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
||||||
itemIds = validated.itemIds;
|
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
||||||
} catch (error) {
|
return new Response(
|
||||||
if (error instanceof ValidationError) {
|
JSON.stringify(existingKey.result_data),
|
||||||
addSpanEvent(rootSpan, 'validation_failed', {
|
{
|
||||||
field: error.field,
|
status: 200,
|
||||||
expected: error.expected,
|
headers: { 'X-Cache-Status': 'HIT' }
|
||||||
received: error.received,
|
|
||||||
});
|
|
||||||
edgeLogger.warn('Request validation failed', {
|
|
||||||
requestId,
|
|
||||||
field: error.field,
|
|
||||||
expected: error.expected,
|
|
||||||
received: error.received,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', error);
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: error.message,
|
|
||||||
field: error.field,
|
|
||||||
requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw error;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('x-idempotency-key');
|
|
||||||
|
|
||||||
if (!idempotencyKey) {
|
// STEP 3: Fetch submission to get submitter_id
|
||||||
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
const { data: submission, error: submissionError } = await supabase
|
||||||
edgeLogger.warn('Missing idempotency key', { requestId });
|
.from('content_submissions')
|
||||||
endSpan(rootSpan, 'error');
|
.select('user_id, status, assigned_to')
|
||||||
logSpan(rootSpan);
|
.eq('id', submissionId)
|
||||||
return new Response(
|
.single();
|
||||||
JSON.stringify({ error: 'Missing X-Idempotency-Key header' }),
|
|
||||||
{
|
if (submissionError || !submission) {
|
||||||
status: 400,
|
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
throw new Error('Submission not found');
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// STEP 4: Verify moderator can approve this submission
|
||||||
|
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||||
|
throw new Error('Submission is locked by another moderator');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
||||||
|
throw new Error('Submission already processed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 5: Register idempotency key as processing (atomic upsert)
|
||||||
|
if (!existingKey) {
|
||||||
|
const { data: insertedKey, error: idempotencyError } = await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.insert({
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
submission_id: submissionId,
|
||||||
|
moderator_id: user.id,
|
||||||
|
item_ids: itemIds,
|
||||||
|
status: 'processing'
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (idempotencyError && idempotencyError.code === '23505') {
|
||||||
|
throw new Error('Another moderator is processing this submission');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, {
|
if (idempotencyError) {
|
||||||
|
throw toError(idempotencyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create child span for RPC transaction
|
||||||
|
const rpcSpan = startSpan(
|
||||||
|
'process_approval_transaction',
|
||||||
|
'DATABASE',
|
||||||
|
getSpanContext(rootSpan),
|
||||||
|
{
|
||||||
|
'db.operation': 'rpc',
|
||||||
|
'db.function': 'process_approval_transaction',
|
||||||
'submission.id': submissionId,
|
'submission.id': submissionId,
|
||||||
'submission.item_count': itemIds.length,
|
'submission.item_count': itemIds.length,
|
||||||
'idempotency.key': idempotencyKey,
|
|
||||||
});
|
|
||||||
addSpanEvent(rootSpan, 'validation_complete');
|
|
||||||
edgeLogger.info('Request validated', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 3: Idempotency check
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_check_start');
|
|
||||||
const { data: existingKey } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.select('*')
|
|
||||||
.eq('idempotency_key', idempotencyKey)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (existingKey?.status === 'completed') {
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
|
||||||
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
|
||||||
edgeLogger.info('Idempotency cache hit', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
cached: true,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
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
|
addSpanEvent(rpcSpan, 'rpc_call_start');
|
||||||
const { data: submission, error: submissionError } = await supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('user_id, status, assigned_to')
|
|
||||||
.eq('id', submissionId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (submissionError || !submission) {
|
// STEP 6: Call RPC function with deadlock retry logic
|
||||||
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
let retryCount = 0;
|
||||||
edgeLogger.error('Submission not found', {
|
const MAX_DEADLOCK_RETRIES = 3;
|
||||||
requestId,
|
let result: any = null;
|
||||||
submissionId,
|
let rpcError: any = null;
|
||||||
error: submissionError?.message,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', submissionError || new Error('Submission not found'));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
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
|
while (retryCount <= MAX_DEADLOCK_RETRIES) {
|
||||||
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
const { data, error } = await supabase.rpc(
|
||||||
edgeLogger.warn('Lock conflict', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
lockedBy: submission.assigned_to,
|
|
||||||
attemptedBy: user.id,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
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)) {
|
|
||||||
edgeLogger.warn('Invalid submission status', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
currentStatus: submission.status,
|
|
||||||
expectedStatuses: ['pending', 'partially_approved'],
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission already processed' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 6: Register idempotency key as processing (atomic upsert)
|
|
||||||
// ✅ CRITICAL FIX: Use ON CONFLICT to prevent race conditions
|
|
||||||
if (!existingKey) {
|
|
||||||
const { data: insertedKey, error: idempotencyError } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.insert({
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
submission_id: submissionId,
|
|
||||||
moderator_id: user.id,
|
|
||||||
item_ids: itemIds,
|
|
||||||
status: 'processing'
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// If conflict occurred, another moderator is processing
|
|
||||||
if (idempotencyError && idempotencyError.code === '23505') {
|
|
||||||
edgeLogger.warn('Idempotency key conflict - another request processing', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
moderatorId: user.id
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Another moderator is processing this submission' }),
|
|
||||||
{ status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idempotencyError) {
|
|
||||||
throw toError(idempotencyError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create child span for RPC transaction
|
|
||||||
const rpcSpan = startSpan(
|
|
||||||
'process_approval_transaction',
|
'process_approval_transaction',
|
||||||
'DATABASE',
|
|
||||||
getSpanContext(rootSpan),
|
|
||||||
{
|
{
|
||||||
'db.operation': 'rpc',
|
p_submission_id: submissionId,
|
||||||
'db.function': 'process_approval_transaction',
|
p_item_ids: itemIds,
|
||||||
'submission.id': submissionId,
|
p_moderator_id: user.id,
|
||||||
'submission.item_count': itemIds.length,
|
p_submitter_id: submission.user_id,
|
||||||
|
p_request_id: requestId,
|
||||||
|
p_trace_id: rootSpan.traceId,
|
||||||
|
p_parent_span_id: rpcSpan.spanId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_start');
|
result = data;
|
||||||
edgeLogger.info('Calling approval transaction RPC', {
|
rpcError = error;
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
if (!rpcError) {
|
||||||
// STEP 7: Call RPC function with deadlock retry logic
|
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
||||||
// ============================================================================
|
'result.status': data?.status,
|
||||||
let retryCount = 0;
|
'items.processed': itemIds.length,
|
||||||
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,
|
|
||||||
p_trace_id: rootSpan.traceId,
|
|
||||||
p_parent_span_id: rpcSpan.spanId
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
result = data;
|
|
||||||
rpcError = error;
|
|
||||||
|
|
||||||
if (!rpcError) {
|
|
||||||
// Success!
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
|
||||||
'result.status': data?.status,
|
|
||||||
'items.processed': itemIds.length,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for deadlock (40P01) or serialization failure (40001)
|
|
||||||
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
|
||||||
retryCount++;
|
|
||||||
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
|
||||||
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
|
||||||
edgeLogger.error('Max deadlock retries exceeded', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
attempt: retryCount,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backoffMs = 100 * Math.pow(2, retryCount);
|
|
||||||
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
|
||||||
edgeLogger.warn('Deadlock detected, retrying', {
|
|
||||||
requestId,
|
|
||||||
attempt: retryCount,
|
|
||||||
maxAttempts: MAX_DEADLOCK_RETRIES,
|
|
||||||
backoffMs,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
await new Promise(r => setTimeout(r, backoffMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-retryable error, break immediately
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enhanced error logging for type mismatches
|
|
||||||
if (rpcError.code === 'P0001' && rpcError.message?.includes('Unknown item type')) {
|
|
||||||
// Extract the unknown type from error message
|
|
||||||
const typeMatch = rpcError.message.match(/Unknown item type: (\w+)/);
|
|
||||||
const unknownType = typeMatch ? typeMatch[1] : 'unknown';
|
|
||||||
|
|
||||||
edgeLogger.error('Entity type mismatch detected', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
unknownType,
|
|
||||||
error: rpcError.message,
|
|
||||||
hint: `The submission contains an item with type '${unknownType}' which is not recognized by process_approval_transaction. ` +
|
|
||||||
`Valid types are: park, ride, manufacturer, operator, property_owner, designer, company, ride_model, photo. ` +
|
|
||||||
`This indicates a data model inconsistency between submission_items and the RPC function.`,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rpcError) {
|
// Check for deadlock (40P01) or serialization failure (40001)
|
||||||
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
||||||
endSpan(rpcSpan, 'error', rpcError);
|
retryCount++;
|
||||||
logSpan(rpcSpan);
|
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
||||||
|
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
||||||
edgeLogger.error('Transaction failed', {
|
break;
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code,
|
|
||||||
retries: retryCount,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'failed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
// Non-blocking - continue with error response even if idempotency update fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endSpan(rootSpan, 'error', rpcError);
|
const backoffMs = 100 * Math.pow(2, retryCount);
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
||||||
|
await new Promise(r => setTimeout(r, backoffMs));
|
||||||
return new Response(
|
continue;
|
||||||
JSON.stringify({
|
|
||||||
error: 'Approval transaction failed',
|
|
||||||
message: rpcError.message,
|
|
||||||
details: rpcError.details,
|
|
||||||
retries: retryCount
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPC succeeded
|
// Non-retryable error
|
||||||
endSpan(rpcSpan, 'ok');
|
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
||||||
logSpan(rpcSpan);
|
error: rpcError.message,
|
||||||
|
errorCode: rpcError.code
|
||||||
setSpanAttributes(rootSpan, {
|
|
||||||
'result.status': result?.status,
|
|
||||||
'result.final_status': result?.status,
|
|
||||||
'retries': retryCount,
|
|
||||||
});
|
|
||||||
edgeLogger.info('Transaction completed successfully', {
|
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
retries: retryCount,
|
|
||||||
newStatus: result?.status,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// STEP 8: Success - update idempotency key
|
if (rpcError) {
|
||||||
|
endSpan(rpcSpan, 'error', rpcError);
|
||||||
|
|
||||||
|
// Update idempotency key to failed
|
||||||
try {
|
try {
|
||||||
await supabase
|
await supabase
|
||||||
.from('submission_idempotency_keys')
|
.from('submission_idempotency_keys')
|
||||||
.update({
|
.update({
|
||||||
status: 'completed',
|
status: 'failed',
|
||||||
result_data: result,
|
error_message: rpcError.message,
|
||||||
completed_at: new Date().toISOString()
|
completed_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('idempotency_key', idempotencyKey);
|
.eq('idempotency_key', idempotencyKey);
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
// Non-blocking
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'completed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
// Non-blocking - transaction succeeded, so continue with success response
|
|
||||||
}
|
}
|
||||||
|
throw toError(rpcError);
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(result),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-Id': requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Enhanced error logging with full details
|
|
||||||
const errorDetails = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
requestId: rootSpan?.spanId || 'unknown',
|
|
||||||
duration: rootSpan?.duration || 0,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
action: 'process_approval'
|
|
||||||
};
|
|
||||||
|
|
||||||
console.error('Uncaught error in handler', errorDetails);
|
|
||||||
|
|
||||||
endSpan(rootSpan, 'error', error instanceof Error ? error : toError(error));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
edgeLogger.error('Unexpected error', errorDetails);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
requestId: rootSpan?.spanId || 'unknown'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPC succeeded
|
||||||
|
endSpan(rpcSpan, 'ok');
|
||||||
|
|
||||||
|
setSpanAttributes(rootSpan, {
|
||||||
|
'result.status': result?.status,
|
||||||
|
'result.final_status': result?.status,
|
||||||
|
'retries': retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// STEP 7: 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) {
|
||||||
|
// Non-blocking - transaction succeeded, so continue with success response
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply rate limiting: 10 requests per minute per IP (moderate tier for moderation actions)
|
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||||
serve(withRateLimit(handler, rateLimiters.moderate, corsHeaders));
|
createEdgeFunction(
|
||||||
|
{
|
||||||
|
name: 'process-selective-approval',
|
||||||
|
requireAuth: true,
|
||||||
|
corsEnabled: true,
|
||||||
|
enableTracing: true,
|
||||||
|
rateLimitTier: 'moderate'
|
||||||
|
},
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,548 +1,249 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
|
||||||
import {
|
import {
|
||||||
edgeLogger,
|
|
||||||
startSpan,
|
|
||||||
endSpan,
|
|
||||||
addSpanEvent,
|
addSpanEvent,
|
||||||
setSpanAttributes,
|
setSpanAttributes,
|
||||||
getSpanContext,
|
getSpanContext,
|
||||||
logSpan,
|
startSpan,
|
||||||
extractSpanContextFromHeaders,
|
endSpan,
|
||||||
type Span
|
|
||||||
} from '../_shared/logger.ts';
|
} from '../_shared/logger.ts';
|
||||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
import { toError } from '../_shared/errorFormatter.ts';
|
||||||
import {
|
import {
|
||||||
validateRejectionRequest,
|
validateRejectionRequest,
|
||||||
type ValidatedRejectionRequest
|
|
||||||
} from '../_shared/submissionValidation.ts';
|
} from '../_shared/submissionValidation.ts';
|
||||||
import { ValidationError } from '../_shared/typeValidation.ts';
|
import { ValidationError } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!;
|
|
||||||
|
|
||||||
interface RejectionRequest {
|
|
||||||
submissionId: string;
|
|
||||||
itemIds: string[];
|
|
||||||
rejectionReason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main handler function
|
// Main handler function
|
||||||
const handler = async (req: Request) => {
|
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
|
||||||
// Handle CORS preflight requests
|
const { supabase, user, span: rootSpan, requestId } = context;
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 204,
|
|
||||||
headers: corsHeaders
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract parent span context from headers (if present)
|
setSpanAttributes(rootSpan, {
|
||||||
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
'user.id': user.id,
|
||||||
|
'function.name': 'process-selective-rejection'
|
||||||
|
});
|
||||||
|
|
||||||
// Create root span for this edge function invocation
|
// STEP 1: Parse and validate request
|
||||||
const rootSpan = startSpan(
|
addSpanEvent(rootSpan, 'validation_start');
|
||||||
'process-selective-rejection',
|
|
||||||
'SERVER',
|
let submissionId: string;
|
||||||
parentSpanContext,
|
let itemIds: string[];
|
||||||
{
|
let rejectionReason: string;
|
||||||
'http.method': 'POST',
|
|
||||||
'function.name': 'process-selective-rejection',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const requestId = rootSpan.spanId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STEP 1: Authentication
|
const body = await req.json();
|
||||||
addSpanEvent(rootSpan, 'authentication_start');
|
const validated = validateRejectionRequest(body, requestId);
|
||||||
const authHeader = req.headers.get('Authorization');
|
submissionId = validated.submissionId;
|
||||||
if (!authHeader) {
|
itemIds = validated.itemIds;
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { reason: 'missing_header' });
|
rejectionReason = validated.rejectionReason;
|
||||||
endSpan(rootSpan, 'error');
|
} catch (error) {
|
||||||
logSpan(rootSpan);
|
if (error instanceof ValidationError) {
|
||||||
return new Response(
|
addSpanEvent(rootSpan, 'validation_failed', {
|
||||||
JSON.stringify({ error: 'Missing Authorization header' }),
|
field: error.field,
|
||||||
{
|
expected: error.expected,
|
||||||
status: 401,
|
received: error.received,
|
||||||
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) {
|
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { error: authError?.message });
|
|
||||||
edgeLogger.warn('Authentication failed', {
|
|
||||||
requestId,
|
|
||||||
error: authError?.message,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
});
|
||||||
endSpan(rootSpan, 'error', authError || new Error('Unauthorized'));
|
throw error; // Will be caught by wrapper
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Unauthorized' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('x-idempotency-key');
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
||||||
|
throw new ValidationError('idempotency_key', 'Missing X-Idempotency-Key header', 'string', 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, { 'user.id': user.id });
|
setSpanAttributes(rootSpan, {
|
||||||
addSpanEvent(rootSpan, 'authentication_success');
|
'submission.id': submissionId,
|
||||||
edgeLogger.info('Rejection request received', {
|
'submission.item_count': itemIds.length,
|
||||||
requestId,
|
'idempotency.key': idempotencyKey,
|
||||||
moderatorId: user.id,
|
});
|
||||||
action: 'process_rejection'
|
addSpanEvent(rootSpan, 'validation_complete');
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 2: Parse and validate request
|
// STEP 2: Idempotency check
|
||||||
addSpanEvent(rootSpan, 'validation_start');
|
addSpanEvent(rootSpan, 'idempotency_check_start');
|
||||||
|
const { data: existingKey } = await supabase
|
||||||
let submissionId: string;
|
.from('submission_idempotency_keys')
|
||||||
let itemIds: string[];
|
.select('*')
|
||||||
let rejectionReason: string;
|
.eq('idempotency_key', idempotencyKey)
|
||||||
|
.single();
|
||||||
try {
|
|
||||||
const body = await req.json();
|
if (existingKey?.status === 'completed') {
|
||||||
const validated = validateRejectionRequest(body, requestId);
|
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
||||||
submissionId = validated.submissionId;
|
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
||||||
itemIds = validated.itemIds;
|
return new Response(
|
||||||
rejectionReason = validated.rejectionReason;
|
JSON.stringify(existingKey.result_data),
|
||||||
} catch (error) {
|
{
|
||||||
if (error instanceof ValidationError) {
|
status: 200,
|
||||||
addSpanEvent(rootSpan, 'validation_failed', {
|
headers: { 'X-Cache-Status': 'HIT' }
|
||||||
field: error.field,
|
|
||||||
expected: error.expected,
|
|
||||||
received: error.received,
|
|
||||||
});
|
|
||||||
edgeLogger.warn('Request validation failed', {
|
|
||||||
requestId,
|
|
||||||
field: error.field,
|
|
||||||
expected: error.expected,
|
|
||||||
received: error.received,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', error);
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: error.message,
|
|
||||||
field: error.field,
|
|
||||||
requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw error;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('x-idempotency-key');
|
|
||||||
|
|
||||||
if (!idempotencyKey) {
|
// STEP 3: Fetch submission to verify
|
||||||
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
const { data: submission, error: submissionError } = await supabase
|
||||||
edgeLogger.warn('Missing idempotency key', { requestId });
|
.from('content_submissions')
|
||||||
endSpan(rootSpan, 'error');
|
.select('user_id, status, assigned_to')
|
||||||
logSpan(rootSpan);
|
.eq('id', submissionId)
|
||||||
return new Response(
|
.single();
|
||||||
JSON.stringify({ error: 'Missing X-Idempotency-Key header' }),
|
|
||||||
{
|
if (submissionError || !submission) {
|
||||||
status: 400,
|
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
throw new Error('Submission not found');
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// STEP 4: Verify moderator can reject this submission
|
||||||
|
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||||
|
throw new Error('Submission is locked by another moderator');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
||||||
|
throw new Error('Submission already processed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 5: Register idempotency key as processing (atomic upsert)
|
||||||
|
if (!existingKey) {
|
||||||
|
const { data: insertedKey, error: idempotencyError } = await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.insert({
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
submission_id: submissionId,
|
||||||
|
moderator_id: user.id,
|
||||||
|
item_ids: itemIds,
|
||||||
|
status: 'processing'
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (idempotencyError && idempotencyError.code === '23505') {
|
||||||
|
throw new Error('Another moderator is processing this submission');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, {
|
if (idempotencyError) {
|
||||||
|
throw toError(idempotencyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create child span for RPC transaction
|
||||||
|
const rpcSpan = startSpan(
|
||||||
|
'process_rejection_transaction',
|
||||||
|
'DATABASE',
|
||||||
|
getSpanContext(rootSpan),
|
||||||
|
{
|
||||||
|
'db.operation': 'rpc',
|
||||||
|
'db.function': 'process_rejection_transaction',
|
||||||
'submission.id': submissionId,
|
'submission.id': submissionId,
|
||||||
'submission.item_count': itemIds.length,
|
'submission.item_count': itemIds.length,
|
||||||
'idempotency.key': idempotencyKey,
|
|
||||||
});
|
|
||||||
addSpanEvent(rootSpan, 'validation_complete');
|
|
||||||
edgeLogger.info('Request validated', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 3: Idempotency check
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_check_start');
|
|
||||||
const { data: existingKey } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.select('*')
|
|
||||||
.eq('idempotency_key', idempotencyKey)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (existingKey?.status === 'completed') {
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
|
||||||
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
|
||||||
edgeLogger.info('Idempotency cache hit', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
cached: true,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
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
|
addSpanEvent(rpcSpan, 'rpc_call_start');
|
||||||
const { data: submission, error: submissionError } = await supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('user_id, status, assigned_to')
|
|
||||||
.eq('id', submissionId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (submissionError || !submission) {
|
// STEP 6: Call RPC function with deadlock retry logic
|
||||||
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
let retryCount = 0;
|
||||||
edgeLogger.error('Submission not found', {
|
const MAX_DEADLOCK_RETRIES = 3;
|
||||||
requestId,
|
let result: any = null;
|
||||||
submissionId,
|
let rpcError: any = null;
|
||||||
error: submissionError?.message,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', submissionError || new Error('Submission not found'));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission not found' }),
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 5: Verify moderator can reject this submission
|
while (retryCount <= MAX_DEADLOCK_RETRIES) {
|
||||||
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
const { data, error } = await supabase.rpc(
|
||||||
edgeLogger.warn('Lock conflict', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
lockedBy: submission.assigned_to,
|
|
||||||
attemptedBy: user.id,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
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)) {
|
|
||||||
edgeLogger.warn('Invalid submission status', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
currentStatus: submission.status,
|
|
||||||
expectedStatuses: ['pending', 'partially_approved'],
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission already processed' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 6: Register idempotency key as processing (atomic upsert)
|
|
||||||
// ✅ CRITICAL FIX: Use ON CONFLICT to prevent race conditions
|
|
||||||
if (!existingKey) {
|
|
||||||
const { data: insertedKey, error: idempotencyError } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.insert({
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
submission_id: submissionId,
|
|
||||||
moderator_id: user.id,
|
|
||||||
item_ids: itemIds,
|
|
||||||
status: 'processing'
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// If conflict occurred, another moderator is processing
|
|
||||||
if (idempotencyError && idempotencyError.code === '23505') {
|
|
||||||
edgeLogger.warn('Idempotency key conflict - another request processing', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
moderatorId: user.id
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Another moderator is processing this submission' }),
|
|
||||||
{ status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idempotencyError) {
|
|
||||||
throw toError(idempotencyError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create child span for RPC transaction
|
|
||||||
const rpcSpan = startSpan(
|
|
||||||
'process_rejection_transaction',
|
'process_rejection_transaction',
|
||||||
'DATABASE',
|
|
||||||
getSpanContext(rootSpan),
|
|
||||||
{
|
{
|
||||||
'db.operation': 'rpc',
|
p_submission_id: submissionId,
|
||||||
'db.function': 'process_rejection_transaction',
|
p_item_ids: itemIds,
|
||||||
'submission.id': submissionId,
|
p_moderator_id: user.id,
|
||||||
'submission.item_count': itemIds.length,
|
p_rejection_reason: rejectionReason,
|
||||||
|
p_request_id: requestId,
|
||||||
|
p_trace_id: rootSpan.traceId,
|
||||||
|
p_parent_span_id: rpcSpan.spanId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_start');
|
result = data;
|
||||||
edgeLogger.info('Calling rejection transaction RPC', {
|
rpcError = error;
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
if (!rpcError) {
|
||||||
// STEP 7: Call RPC function with deadlock retry logic
|
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
||||||
// ============================================================================
|
'result.status': data?.status,
|
||||||
let retryCount = 0;
|
'items.processed': itemIds.length,
|
||||||
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_rejection_transaction',
|
|
||||||
{
|
|
||||||
p_submission_id: submissionId,
|
|
||||||
p_item_ids: itemIds,
|
|
||||||
p_moderator_id: user.id,
|
|
||||||
p_rejection_reason: rejectionReason,
|
|
||||||
p_request_id: requestId,
|
|
||||||
p_trace_id: rootSpan.traceId,
|
|
||||||
p_parent_span_id: rpcSpan.spanId
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
result = data;
|
|
||||||
rpcError = error;
|
|
||||||
|
|
||||||
if (!rpcError) {
|
|
||||||
// Success!
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
|
||||||
'result.status': data?.status,
|
|
||||||
'items.processed': itemIds.length,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for deadlock (40P01) or serialization failure (40001)
|
|
||||||
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
|
||||||
retryCount++;
|
|
||||||
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
|
||||||
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
|
||||||
edgeLogger.error('Max deadlock retries exceeded', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
attempt: retryCount,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backoffMs = 100 * Math.pow(2, retryCount);
|
|
||||||
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
|
||||||
edgeLogger.warn('Deadlock detected, retrying', {
|
|
||||||
requestId,
|
|
||||||
attempt: retryCount,
|
|
||||||
maxAttempts: MAX_DEADLOCK_RETRIES,
|
|
||||||
backoffMs,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
await new Promise(r => setTimeout(r, backoffMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-retryable error, break immediately
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rpcError) {
|
// Check for deadlock (40P01) or serialization failure (40001)
|
||||||
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
||||||
endSpan(rpcSpan, 'error', rpcError);
|
retryCount++;
|
||||||
logSpan(rpcSpan);
|
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
||||||
|
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
||||||
edgeLogger.error('Transaction failed', {
|
break;
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code,
|
|
||||||
retries: retryCount,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'failed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
// Non-blocking - continue with error response even if idempotency update fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endSpan(rootSpan, 'error', rpcError);
|
const backoffMs = 100 * Math.pow(2, retryCount);
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
||||||
|
await new Promise(r => setTimeout(r, backoffMs));
|
||||||
return new Response(
|
continue;
|
||||||
JSON.stringify({
|
|
||||||
error: 'Rejection transaction failed',
|
|
||||||
message: rpcError.message,
|
|
||||||
details: rpcError.details,
|
|
||||||
retries: retryCount
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPC succeeded
|
// Non-retryable error
|
||||||
endSpan(rpcSpan, 'ok');
|
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
||||||
logSpan(rpcSpan);
|
error: rpcError.message,
|
||||||
|
errorCode: rpcError.code
|
||||||
setSpanAttributes(rootSpan, {
|
|
||||||
'result.status': result?.status,
|
|
||||||
'result.final_status': result?.status,
|
|
||||||
'retries': retryCount,
|
|
||||||
});
|
|
||||||
edgeLogger.info('Transaction completed successfully', {
|
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
retries: retryCount,
|
|
||||||
newStatus: result?.status,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// STEP 8: Success - update idempotency key
|
if (rpcError) {
|
||||||
|
endSpan(rpcSpan, 'error', rpcError);
|
||||||
|
|
||||||
|
// Update idempotency key to failed
|
||||||
try {
|
try {
|
||||||
await supabase
|
await supabase
|
||||||
.from('submission_idempotency_keys')
|
.from('submission_idempotency_keys')
|
||||||
.update({
|
.update({
|
||||||
status: 'completed',
|
status: 'failed',
|
||||||
result_data: result,
|
error_message: rpcError.message,
|
||||||
completed_at: new Date().toISOString()
|
completed_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('idempotency_key', idempotencyKey);
|
.eq('idempotency_key', idempotencyKey);
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
// Non-blocking
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'completed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
// Non-blocking - transaction succeeded, so continue with success response
|
|
||||||
}
|
}
|
||||||
|
throw toError(rpcError);
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(result),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-Id': requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
endSpan(rootSpan, 'error', error instanceof Error ? error : toError(error));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
edgeLogger.error('Unexpected error', {
|
|
||||||
requestId,
|
|
||||||
duration: rootSpan.duration,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPC succeeded
|
||||||
|
endSpan(rpcSpan, 'ok');
|
||||||
|
|
||||||
|
setSpanAttributes(rootSpan, {
|
||||||
|
'result.status': result?.status,
|
||||||
|
'result.final_status': result?.status,
|
||||||
|
'retries': retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// STEP 7: 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) {
|
||||||
|
// Non-blocking - transaction succeeded, so continue with success response
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply rate limiting: 10 requests per minute per IP (moderate tier for moderation actions)
|
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||||
serve(withRateLimit(handler, rateLimiters.moderate, corsHeaders));
|
createEdgeFunction(
|
||||||
|
{
|
||||||
|
name: 'process-selective-rejection',
|
||||||
|
requireAuth: true,
|
||||||
|
corsEnabled: true,
|
||||||
|
enableTracing: true,
|
||||||
|
rateLimitTier: 'moderate'
|
||||||
|
},
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
* Requires admin/moderator authentication.
|
* Requires admin/moderator authentication.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from 'jsr:@supabase/supabase-js@2';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { withRateLimit, rateLimiters } from '../_shared/rateLimiter.ts';
|
|
||||||
import {
|
import {
|
||||||
getRecentMetrics,
|
getRecentMetrics,
|
||||||
getMetricsStats,
|
getMetricsStats,
|
||||||
@@ -16,11 +15,6 @@ import {
|
|||||||
clearMetrics,
|
clearMetrics,
|
||||||
} from '../_shared/rateLimitMetrics.ts';
|
} from '../_shared/rateLimitMetrics.ts';
|
||||||
|
|
||||||
const corsHeaders = {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface QueryParams {
|
interface QueryParams {
|
||||||
action?: string;
|
action?: string;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
@@ -30,171 +24,106 @@ interface QueryParams {
|
|||||||
clientIP?: string;
|
clientIP?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handler(req: Request): Promise<Response> {
|
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
|
||||||
// Handle CORS preflight
|
const { supabase, user } = context;
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, { headers: corsHeaders });
|
// Check if user has admin or moderator role
|
||||||
|
const { data: roles } = await supabase
|
||||||
|
.from('user_roles')
|
||||||
|
.select('role')
|
||||||
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
|
const userRoles = roles?.map((r: any) => r.role) || [];
|
||||||
|
const isAuthorized = userRoles.some((role: string) =>
|
||||||
|
['admin', 'moderator', 'superuser'].includes(role)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
throw new Error('Insufficient permissions');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Parse query parameters
|
||||||
// Verify authentication
|
const url = new URL(req.url);
|
||||||
const authHeader = req.headers.get('Authorization');
|
const action = url.searchParams.get('action') || 'stats';
|
||||||
if (!authHeader) {
|
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
|
||||||
return new Response(
|
const timeWindow = parseInt(url.searchParams.get('timeWindow') || '60000', 10);
|
||||||
JSON.stringify({ error: 'Authentication required' }),
|
const functionName = url.searchParams.get('functionName');
|
||||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
const userId = url.searchParams.get('userId');
|
||||||
);
|
const clientIP = url.searchParams.get('clientIP');
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
let responseData: any;
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
|
||||||
global: {
|
|
||||||
headers: { Authorization: authHeader },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get authenticated user
|
// Route to appropriate metrics handler
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
switch (action) {
|
||||||
if (authError || !user) {
|
case 'recent':
|
||||||
return new Response(
|
responseData = {
|
||||||
JSON.stringify({ error: 'Invalid authentication' }),
|
metrics: getRecentMetrics(limit),
|
||||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
count: getRecentMetrics(limit).length,
|
||||||
);
|
};
|
||||||
}
|
break;
|
||||||
|
|
||||||
// Check if user has admin or moderator role
|
case 'stats':
|
||||||
const { data: roles } = await supabase
|
responseData = getMetricsStats(timeWindow);
|
||||||
.from('user_roles')
|
break;
|
||||||
.select('role')
|
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
const userRoles = roles?.map(r => r.role) || [];
|
case 'function':
|
||||||
const isAuthorized = userRoles.some(role =>
|
if (!functionName) {
|
||||||
['admin', 'moderator', 'superuser'].includes(role)
|
throw new Error('functionName parameter required for function action');
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAuthorized) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Insufficient permissions' }),
|
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse query parameters
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const action = url.searchParams.get('action') || 'stats';
|
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
|
|
||||||
const timeWindow = parseInt(url.searchParams.get('timeWindow') || '60000', 10);
|
|
||||||
const functionName = url.searchParams.get('functionName');
|
|
||||||
const userId = url.searchParams.get('userId');
|
|
||||||
const clientIP = url.searchParams.get('clientIP');
|
|
||||||
|
|
||||||
let responseData: any;
|
|
||||||
|
|
||||||
// Route to appropriate metrics handler
|
|
||||||
switch (action) {
|
|
||||||
case 'recent':
|
|
||||||
responseData = {
|
|
||||||
metrics: getRecentMetrics(limit),
|
|
||||||
count: getRecentMetrics(limit).length,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'stats':
|
|
||||||
responseData = getMetricsStats(timeWindow);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'function':
|
|
||||||
if (!functionName) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'functionName parameter required for function action' }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
responseData = {
|
|
||||||
functionName,
|
|
||||||
metrics: getFunctionMetrics(functionName, limit),
|
|
||||||
count: getFunctionMetrics(functionName, limit).length,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'user':
|
|
||||||
if (!userId) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'userId parameter required for user action' }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
responseData = {
|
|
||||||
userId,
|
|
||||||
metrics: getUserMetrics(userId, limit),
|
|
||||||
count: getUserMetrics(userId, limit).length,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ip':
|
|
||||||
if (!clientIP) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'clientIP parameter required for ip action' }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
responseData = {
|
|
||||||
clientIP,
|
|
||||||
metrics: getIPMetrics(clientIP, limit),
|
|
||||||
count: getIPMetrics(clientIP, limit).length,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'clear':
|
|
||||||
// Only superusers can clear metrics
|
|
||||||
const isSuperuser = userRoles.includes('superuser');
|
|
||||||
if (!isSuperuser) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Only superusers can clear metrics' }),
|
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
clearMetrics();
|
|
||||||
responseData = { success: true, message: 'Metrics cleared' };
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid action',
|
|
||||||
validActions: ['recent', 'stats', 'function', 'user', 'ip', 'clear']
|
|
||||||
}),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(responseData),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
responseData = {
|
||||||
|
functionName,
|
||||||
|
metrics: getFunctionMetrics(functionName, limit),
|
||||||
|
count: getFunctionMetrics(functionName, limit).length,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
} catch (error) {
|
case 'user':
|
||||||
console.error('Error in rate-limit-metrics function:', error);
|
if (!userId) {
|
||||||
return new Response(
|
throw new Error('userId parameter required for user action');
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
}
|
||||||
);
|
responseData = {
|
||||||
|
userId,
|
||||||
|
metrics: getUserMetrics(userId, limit),
|
||||||
|
count: getUserMetrics(userId, limit).length,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ip':
|
||||||
|
if (!clientIP) {
|
||||||
|
throw new Error('clientIP parameter required for ip action');
|
||||||
|
}
|
||||||
|
responseData = {
|
||||||
|
clientIP,
|
||||||
|
metrics: getIPMetrics(clientIP, limit),
|
||||||
|
count: getIPMetrics(clientIP, limit).length,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clear':
|
||||||
|
// Only superusers can clear metrics
|
||||||
|
const isSuperuser = userRoles.includes('superuser');
|
||||||
|
if (!isSuperuser) {
|
||||||
|
throw new Error('Only superusers can clear metrics');
|
||||||
|
}
|
||||||
|
clearMetrics();
|
||||||
|
responseData = { success: true, message: 'Metrics cleared' };
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid action. Valid actions: recent, stats, function, user, ip, clear');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Apply rate limiting (lenient tier for admin monitoring)
|
return responseData;
|
||||||
Deno.serve(withRateLimit(handler, rateLimiters.lenient, corsHeaders, 'rate-limit-metrics'));
|
};
|
||||||
|
|
||||||
|
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||||
|
createEdgeFunction(
|
||||||
|
{
|
||||||
|
name: 'rate-limit-metrics',
|
||||||
|
requireAuth: true,
|
||||||
|
corsEnabled: true,
|
||||||
|
enableTracing: false,
|
||||||
|
rateLimitTier: 'lenient'
|
||||||
|
},
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user