mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 17:51:12 -05:00
feat: Implement comprehensive request tracking and state management
This commit is contained in:
114
src/lib/edgeFunctionTracking.ts
Normal file
114
src/lib/edgeFunctionTracking.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Edge Function Request Tracking Wrapper
|
||||||
|
*
|
||||||
|
* Wraps Supabase function invocations with request tracking for debugging and monitoring.
|
||||||
|
* Provides correlation IDs for tracing requests across the system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { trackRequest } from './requestTracking';
|
||||||
|
import { getErrorMessage } from './errorHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke a Supabase edge function with request tracking
|
||||||
|
*
|
||||||
|
* @param functionName - Name of the edge function to invoke
|
||||||
|
* @param payload - Request payload
|
||||||
|
* @param userId - User ID for tracking (optional)
|
||||||
|
* @param parentRequestId - Parent request ID for chaining (optional)
|
||||||
|
* @param traceId - Trace ID for distributed tracing (optional)
|
||||||
|
* @returns Response data with requestId
|
||||||
|
*/
|
||||||
|
export async function invokeWithTracking<T = any>(
|
||||||
|
functionName: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
userId?: string,
|
||||||
|
parentRequestId?: string,
|
||||||
|
traceId?: string
|
||||||
|
): Promise<{ data: T | null; error: any; requestId: string; duration: number }> {
|
||||||
|
try {
|
||||||
|
const { result, requestId, duration } = await trackRequest(
|
||||||
|
{
|
||||||
|
endpoint: `/functions/${functionName}`,
|
||||||
|
method: 'POST',
|
||||||
|
userId,
|
||||||
|
parentRequestId,
|
||||||
|
traceId,
|
||||||
|
},
|
||||||
|
async (context) => {
|
||||||
|
// Include client request ID in payload for correlation
|
||||||
|
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
||||||
|
body: { ...payload, clientRequestId: context.requestId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data: result, error: null, requestId, duration };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
// On error, we don't have tracking info, so create basic response
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: { message: errorMessage },
|
||||||
|
requestId: 'unknown',
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke multiple edge functions in parallel with batch tracking
|
||||||
|
*
|
||||||
|
* Uses a shared trace ID to correlate all operations.
|
||||||
|
*
|
||||||
|
* @param operations - Array of function invocation configurations
|
||||||
|
* @param userId - User ID for tracking
|
||||||
|
* @returns Array of results with their request IDs
|
||||||
|
*/
|
||||||
|
export async function invokeBatchWithTracking<T = any>(
|
||||||
|
operations: Array<{
|
||||||
|
functionName: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}>,
|
||||||
|
userId?: string
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
functionName: string;
|
||||||
|
data: T | null;
|
||||||
|
error: any;
|
||||||
|
requestId: string;
|
||||||
|
duration: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const traceId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
operations.map(async (op) => {
|
||||||
|
const result = await invokeWithTracking<T>(
|
||||||
|
op.functionName,
|
||||||
|
op.payload,
|
||||||
|
userId,
|
||||||
|
undefined,
|
||||||
|
traceId
|
||||||
|
);
|
||||||
|
return { functionName: op.functionName, ...result };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
functionName: operations[index].functionName,
|
||||||
|
data: null,
|
||||||
|
error: { message: result.reason?.message || 'Unknown error' },
|
||||||
|
requestId: 'unknown',
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { invokeWithTracking } from './edgeFunctionTracking';
|
||||||
import type { UploadedImage } from '@/components/upload/EntityMultiImageUploader';
|
import type { UploadedImage } from '@/components/upload/EntityMultiImageUploader';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
export interface CloudflareUploadResponse {
|
export interface CloudflareUploadResponse {
|
||||||
result: {
|
result: {
|
||||||
@@ -25,16 +27,28 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
|||||||
if (image.isLocal && image.file) {
|
if (image.isLocal && image.file) {
|
||||||
const fileName = image.file.name;
|
const fileName = image.file.name;
|
||||||
|
|
||||||
// Step 1: Get upload URL from our Supabase Edge Function
|
// Step 1: Get upload URL from our Supabase Edge Function (with tracking)
|
||||||
const { data: uploadUrlData, error: urlError } = await supabase.functions.invoke('upload-image', {
|
const { data: uploadUrlData, error: urlError, requestId } = await invokeWithTracking(
|
||||||
body: { action: 'get-upload-url' }
|
'upload-image',
|
||||||
});
|
{ action: 'get-upload-url' }
|
||||||
|
);
|
||||||
|
|
||||||
if (urlError || !uploadUrlData?.uploadURL) {
|
if (urlError || !uploadUrlData?.uploadURL) {
|
||||||
console.error(`imageUploadHelper.uploadPendingImages: Failed to get upload URL for "${fileName}":`, urlError);
|
logger.error('Failed to get upload URL', {
|
||||||
|
action: 'upload_pending_images',
|
||||||
|
fileName,
|
||||||
|
requestId,
|
||||||
|
error: urlError?.message || 'Unknown error',
|
||||||
|
});
|
||||||
throw new Error(`Failed to get upload URL for "${fileName}": ${urlError?.message || 'Unknown error'}`);
|
throw new Error(`Failed to get upload URL for "${fileName}": ${urlError?.message || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Got upload URL', {
|
||||||
|
action: 'upload_pending_images',
|
||||||
|
fileName,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2: Upload file directly to Cloudflare
|
// Step 2: Upload file directly to Cloudflare
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', image.file);
|
formData.append('file', image.file);
|
||||||
@@ -46,17 +60,31 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
|||||||
|
|
||||||
if (!uploadResponse.ok) {
|
if (!uploadResponse.ok) {
|
||||||
const errorText = await uploadResponse.text();
|
const errorText = await uploadResponse.text();
|
||||||
console.error(`imageUploadHelper.uploadPendingImages: Upload failed for "${fileName}" (status ${uploadResponse.status}):`, errorText);
|
logger.error('Cloudflare upload failed', {
|
||||||
|
action: 'upload_pending_images',
|
||||||
|
fileName,
|
||||||
|
status: uploadResponse.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
throw new Error(`Upload failed for "${fileName}" (status ${uploadResponse.status}): ${errorText}`);
|
throw new Error(`Upload failed for "${fileName}" (status ${uploadResponse.status}): ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: CloudflareUploadResponse = await uploadResponse.json();
|
const result: CloudflareUploadResponse = await uploadResponse.json();
|
||||||
|
|
||||||
if (!result.success || !result.result) {
|
if (!result.success || !result.result) {
|
||||||
console.error(`imageUploadHelper.uploadPendingImages: Cloudflare upload unsuccessful for "${fileName}"`);
|
logger.error('Cloudflare upload unsuccessful', {
|
||||||
|
action: 'upload_pending_images',
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
throw new Error(`Cloudflare upload returned unsuccessful response for "${fileName}"`);
|
throw new Error(`Cloudflare upload returned unsuccessful response for "${fileName}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Image uploaded successfully', {
|
||||||
|
action: 'upload_pending_images',
|
||||||
|
fileName,
|
||||||
|
imageId: result.result.id,
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up object URL
|
// Clean up object URL
|
||||||
URL.revokeObjectURL(image.url);
|
URL.revokeObjectURL(image.url);
|
||||||
|
|
||||||
@@ -106,13 +134,18 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
|||||||
// If any uploads failed, clean up ONLY newly uploaded images and throw error
|
// If any uploads failed, clean up ONLY newly uploaded images and throw error
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
if (newlyUploadedImageIds.length > 0) {
|
if (newlyUploadedImageIds.length > 0) {
|
||||||
console.error(`imageUploadHelper.uploadPendingImages: Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`);
|
logger.error('Some uploads failed, cleaning up', {
|
||||||
|
action: 'upload_pending_images',
|
||||||
|
newlyUploadedCount: newlyUploadedImageIds.length,
|
||||||
|
failureCount: errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
// Attempt cleanup in parallel with detailed error tracking
|
// Attempt cleanup in parallel with detailed error tracking
|
||||||
const cleanupResults = await Promise.allSettled(
|
const cleanupResults = await Promise.allSettled(
|
||||||
newlyUploadedImageIds.map(imageId =>
|
newlyUploadedImageIds.map(imageId =>
|
||||||
supabase.functions.invoke('upload-image', {
|
invokeWithTracking('upload-image', {
|
||||||
body: { action: 'delete', imageId }
|
action: 'delete',
|
||||||
|
imageId,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -120,13 +153,17 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
|||||||
// Track cleanup failures for better debugging
|
// Track cleanup failures for better debugging
|
||||||
const cleanupFailures = cleanupResults.filter(r => r.status === 'rejected');
|
const cleanupFailures = cleanupResults.filter(r => r.status === 'rejected');
|
||||||
if (cleanupFailures.length > 0) {
|
if (cleanupFailures.length > 0) {
|
||||||
console.error(
|
logger.error('Failed to cleanup images', {
|
||||||
`imageUploadHelper.uploadPendingImages: Failed to cleanup ${cleanupFailures.length} of ${newlyUploadedImageIds.length} images.`,
|
action: 'upload_pending_images_cleanup',
|
||||||
'These images may remain orphaned in Cloudflare:',
|
cleanupFailures: cleanupFailures.length,
|
||||||
newlyUploadedImageIds.filter((_, i) => cleanupResults[i].status === 'rejected')
|
totalCleanup: newlyUploadedImageIds.length,
|
||||||
);
|
orphanedImages: newlyUploadedImageIds.filter((_, i) => cleanupResults[i].status === 'rejected'),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(`imageUploadHelper.uploadPendingImages: Successfully cleaned up ${newlyUploadedImageIds.length} images.`);
|
logger.info('Successfully cleaned up images', {
|
||||||
|
action: 'upload_pending_images_cleanup',
|
||||||
|
cleanedCount: newlyUploadedImageIds.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createTableQuery } from '@/lib/supabaseHelpers';
|
|||||||
import type { ModerationItem } from '@/types/moderation';
|
import type { ModerationItem } from '@/types/moderation';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { invokeWithTracking, invokeBatchWithTracking } from '@/lib/edgeFunctionTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe update data for review moderation
|
* Type-safe update data for review moderation
|
||||||
@@ -184,20 +185,32 @@ export async function approveSubmissionItems(
|
|||||||
itemIds: string[]
|
itemIds: string[]
|
||||||
): Promise<ModerationActionResult> {
|
): Promise<ModerationActionResult> {
|
||||||
try {
|
try {
|
||||||
const { error: approvalError } = await supabase.functions.invoke(
|
const { data: approvalData, error: approvalError, requestId } = await invokeWithTracking(
|
||||||
'process-selective-approval',
|
'process-selective-approval',
|
||||||
{
|
{
|
||||||
body: {
|
|
||||||
itemIds,
|
itemIds,
|
||||||
submissionId,
|
submissionId,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (approvalError) {
|
if (approvalError) {
|
||||||
|
logger.error('Submission items approval failed via edge function', {
|
||||||
|
action: 'approve_submission_items',
|
||||||
|
submissionId,
|
||||||
|
itemCount: itemIds.length,
|
||||||
|
requestId,
|
||||||
|
error: approvalError.message,
|
||||||
|
});
|
||||||
throw new Error(`Failed to process submission items: ${approvalError.message}`);
|
throw new Error(`Failed to process submission items: ${approvalError.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Submission items approved successfully', {
|
||||||
|
action: 'approve_submission_items',
|
||||||
|
submissionId,
|
||||||
|
itemCount: itemIds.length,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully processed ${itemIds.length} item(s)`,
|
message: `Successfully processed ${itemIds.length} item(s)`,
|
||||||
@@ -478,24 +491,28 @@ export async function deleteSubmission(
|
|||||||
|
|
||||||
// Delete photos from Cloudflare
|
// Delete photos from Cloudflare
|
||||||
if (validImageIds.length > 0) {
|
if (validImageIds.length > 0) {
|
||||||
const deletePromises = validImageIds.map(async imageId => {
|
const deleteResults = await invokeBatchWithTracking(
|
||||||
try {
|
validImageIds.map(imageId => ({
|
||||||
await supabase.functions.invoke('upload-image', {
|
functionName: 'upload-image',
|
||||||
method: 'DELETE',
|
payload: { action: 'delete', imageId },
|
||||||
body: { imageId },
|
})),
|
||||||
});
|
undefined
|
||||||
} catch (photoDeleteError: unknown) {
|
);
|
||||||
const errorMessage = getErrorMessage(photoDeleteError);
|
|
||||||
logger.error('Photo deletion failed', {
|
// Count successful deletions
|
||||||
action: 'delete_submission_photo',
|
const successfulDeletions = deleteResults.filter(r => !r.error);
|
||||||
imageId,
|
deletedPhotoCount = successfulDeletions.length;
|
||||||
error: errorMessage
|
|
||||||
|
// Log any failures
|
||||||
|
const failedDeletions = deleteResults.filter(r => r.error);
|
||||||
|
if (failedDeletions.length > 0) {
|
||||||
|
logger.error('Some photo deletions failed', {
|
||||||
|
action: 'delete_submission_photos',
|
||||||
|
failureCount: failedDeletions.length,
|
||||||
|
totalAttempted: validImageIds.length,
|
||||||
|
failedRequestIds: failedDeletions.map(r => r.requestId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.allSettled(deletePromises);
|
|
||||||
deletedPhotoCount = validImageIds.length;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/lib/moderation/lockMonitor.ts
Normal file
108
src/lib/moderation/lockMonitor.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Moderation Lock Monitor
|
||||||
|
*
|
||||||
|
* Monitors lock expiry and provides automatic renewal prompts for moderators.
|
||||||
|
* Prevents loss of work due to expired locks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { ModerationState } from '../moderationStateMachine';
|
||||||
|
import type { ModerationAction } from '../moderationStateMachine';
|
||||||
|
import { hasActiveLock, needsLockRenewal } from '../moderationStateMachine';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to monitor lock status and warn about expiry
|
||||||
|
*
|
||||||
|
* @param state - Current moderation state
|
||||||
|
* @param dispatch - State machine dispatch function
|
||||||
|
* @param itemId - ID of the locked item (optional, for manual extension)
|
||||||
|
*/
|
||||||
|
export function useLockMonitor(
|
||||||
|
state: ModerationState,
|
||||||
|
dispatch: React.Dispatch<ModerationAction>,
|
||||||
|
itemId?: string
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveLock(state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (needsLockRenewal(state)) {
|
||||||
|
logger.warn('Lock expiring soon', {
|
||||||
|
action: 'lock_expiry_warning',
|
||||||
|
itemId,
|
||||||
|
lockExpires: state.status === 'locked' || state.status === 'reviewing'
|
||||||
|
? state.lockExpires
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch lock expiry warning
|
||||||
|
dispatch({ type: 'LOCK_EXPIRED' });
|
||||||
|
|
||||||
|
// Show toast with extension option
|
||||||
|
toast({
|
||||||
|
title: 'Lock Expiring Soon',
|
||||||
|
description: 'Your lock on this submission will expire in less than 2 minutes. Click to extend.',
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 30000); // Check every 30 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(checkInterval);
|
||||||
|
}, [state, dispatch, itemId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the lock on a submission
|
||||||
|
*
|
||||||
|
* @param submissionId - Submission ID
|
||||||
|
* @param dispatch - State machine dispatch function
|
||||||
|
*/
|
||||||
|
async function handleExtendLock(
|
||||||
|
submissionId: string,
|
||||||
|
dispatch: React.Dispatch<ModerationAction>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Call Supabase to extend lock (assumes 15 minute extension)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.update({
|
||||||
|
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', submissionId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Update state machine with new lock time
|
||||||
|
dispatch({
|
||||||
|
type: 'LOCK_ACQUIRED',
|
||||||
|
payload: { lockExpires: new Date(Date.now() + 15 * 60 * 1000).toISOString() },
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Lock Extended',
|
||||||
|
description: 'You have 15 more minutes to complete your review.',
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Lock extended successfully', {
|
||||||
|
action: 'lock_extended',
|
||||||
|
submissionId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to extend lock', {
|
||||||
|
action: 'extend_lock_error',
|
||||||
|
submissionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Extension Failed',
|
||||||
|
description: 'Could not extend lock. Please save your work and re-claim the item.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||||
import { edgeLogger } from '../_shared/logger.ts';
|
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
@@ -8,6 +8,8 @@ const corsHeaders = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
|
const tracking = startRequest();
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return new Response(null, { headers: corsHeaders });
|
return new Response(null, { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,7 @@ serve(async (req) => {
|
|||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Cancelling deletion request', { action: 'cancel_deletion', userId: user.id });
|
edgeLogger.info('Cancelling deletion request', { action: 'cancel_deletion', userId: user.id, requestId: tracking.requestId });
|
||||||
|
|
||||||
// Find pending deletion request
|
// Find pending deletion request
|
||||||
const { data: deletionRequest, error: requestError } = await supabaseClient
|
const { data: deletionRequest, error: requestError } = await supabaseClient
|
||||||
@@ -101,29 +103,34 @@ serve(async (req) => {
|
|||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
edgeLogger.info('Cancellation confirmation email sent', { action: 'cancel_deletion_email', userId: user.id });
|
edgeLogger.info('Cancellation confirmation email sent', { action: 'cancel_deletion_email', userId: user.id, requestId: tracking.requestId });
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
edgeLogger.error('Failed to send email', { action: 'cancel_deletion_email', userId: user.id });
|
edgeLogger.error('Failed to send email', { action: 'cancel_deletion_email', userId: user.id, requestId: tracking.requestId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
edgeLogger.info('Deletion cancelled successfully', { action: 'cancel_deletion_success', userId: user.id, requestId: tracking.requestId, duration });
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Account deletion cancelled successfully',
|
message: 'Account deletion cancelled successfully',
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: error instanceof Error ? error.message : String(error) });
|
const duration = endRequest(tracking);
|
||||||
|
edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: error instanceof Error ? error.message : String(error), requestId: tracking.requestId, duration });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: error.message }),
|
JSON.stringify({ error: error.message, requestId: tracking.requestId }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
import { Novu } from "npm:@novu/api@1.6.0";
|
import { Novu } from "npm:@novu/api@1.6.0";
|
||||||
|
|
||||||
// TODO: In production, restrict CORS to specific domains
|
|
||||||
// For now, allowing all origins for development flexibility
|
|
||||||
// Example production config: 'Access-Control-Allow-Origin': 'https://yourdomain.com'
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Simple request tracking
|
||||||
|
const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() });
|
||||||
|
const endRequest = (tracking: { start: number }) => Date.now() - tracking.start;
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
|
const tracking = startRequest();
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return new Response(null, { headers: corsHeaders });
|
return new Response(null, { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
@@ -197,28 +200,32 @@ serve(async (req) => {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Subscriber created successfully:', subscriber.data);
|
const duration = endRequest(tracking);
|
||||||
|
console.log('Subscriber created successfully:', subscriber.data, { requestId: tracking.requestId, duration });
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
subscriberId: subscriber.data._id,
|
subscriberId: subscriber.data._id,
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
status: 200,
|
status: 200,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error creating Novu subscriber:', error);
|
const duration = endRequest(tracking);
|
||||||
|
console.error('Error creating Novu subscriber:', error, { requestId: tracking.requestId, duration });
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
status: 500,
|
status: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
|
import { startRequest, endRequest } from '../_shared/logger.ts';
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
@@ -73,6 +74,8 @@ async function ensureUniqueUsername(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
|
const tracking = startRequest();
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return new Response(null, { headers: corsHeaders });
|
return new Response(null, { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
@@ -188,9 +191,10 @@ Deno.serve(async (req) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (profile?.avatar_image_id) {
|
if (profile?.avatar_image_id) {
|
||||||
console.log('[OAuth Profile] Avatar already exists, skipping');
|
const duration = endRequest(tracking);
|
||||||
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), {
|
console.log('[OAuth Profile] Avatar already exists, skipping', { requestId: tracking.requestId, duration });
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists', requestId: tracking.requestId }), {
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,22 +330,27 @@ Deno.serve(async (req) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[OAuth Profile] Profile updated successfully');
|
console.log('[OAuth Profile] Profile updated successfully', { requestId: tracking.requestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
console.log('[OAuth Profile] Processing complete', { requestId: tracking.requestId, duration });
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
avatar_uploaded: !!cloudflareImageId,
|
avatar_uploaded: !!cloudflareImageId,
|
||||||
profile_updated: Object.keys(updateData).length > 0,
|
profile_updated: Object.keys(updateData).length > 0,
|
||||||
|
requestId: tracking.requestId
|
||||||
}), {
|
}), {
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[OAuth Profile] Error:', error);
|
const duration = endRequest(tracking);
|
||||||
return new Response(JSON.stringify({ error: error.message }), {
|
console.error('[OAuth Profile] Error:', error, { requestId: tracking.requestId, duration });
|
||||||
|
return new Response(JSON.stringify({ error: error.message, requestId: tracking.requestId }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
import { edgeLogger } from '../_shared/logger.ts'
|
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'
|
||||||
|
|
||||||
// Environment-aware CORS configuration
|
// Environment-aware CORS configuration
|
||||||
const getAllowedOrigin = (requestOrigin: string | null): string | null => {
|
const getAllowedOrigin = (requestOrigin: string | null): string | null => {
|
||||||
@@ -70,12 +70,13 @@ const createAuthenticatedSupabaseClient = (authHeader: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
|
const tracking = startRequest();
|
||||||
const requestOrigin = req.headers.get('origin');
|
const requestOrigin = req.headers.get('origin');
|
||||||
const allowedOrigin = getAllowedOrigin(requestOrigin);
|
const allowedOrigin = getAllowedOrigin(requestOrigin);
|
||||||
|
|
||||||
// Check if this is a CORS request with a disallowed origin
|
// Check if this is a CORS request with a disallowed origin
|
||||||
if (requestOrigin && !allowedOrigin) {
|
if (requestOrigin && !allowedOrigin) {
|
||||||
edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin });
|
edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin, requestId: tracking.requestId });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Origin not allowed',
|
error: 'Origin not allowed',
|
||||||
@@ -160,19 +161,26 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (profile.banned) {
|
if (profile.banned) {
|
||||||
|
const duration = endRequest(tracking);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Account suspended',
|
error: 'Account suspended',
|
||||||
message: 'Account suspended. Contact support for assistance.'
|
message: 'Account suspended. Contact support for assistance.',
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: {
|
||||||
|
...corsHeaders,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Request-ID': tracking.requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete image from Cloudflare
|
// Delete image from Cloudflare
|
||||||
|
edgeLogger.info('Deleting image', { action: 'delete_image', requestId: tracking.requestId });
|
||||||
let requestBody;
|
let requestBody;
|
||||||
try {
|
try {
|
||||||
requestBody = await req.json();
|
requestBody = await req.json();
|
||||||
@@ -280,10 +288,12 @@ serve(async (req) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
edgeLogger.info('Image deleted successfully', { action: 'delete_image', requestId: tracking.requestId, duration });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: true, deleted: true }),
|
JSON.stringify({ success: true, deleted: true, requestId: tracking.requestId }),
|
||||||
{
|
{
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -344,19 +354,22 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (profile.banned) {
|
if (profile.banned) {
|
||||||
|
const duration = endRequest(tracking);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Account suspended',
|
error: 'Account suspended',
|
||||||
message: 'Account suspended. Contact support for assistance.'
|
message: 'Account suspended. Contact support for assistance.',
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request a direct upload URL from Cloudflare
|
// Request a direct upload URL from Cloudflare
|
||||||
|
edgeLogger.info('Requesting upload URL', { action: 'request_upload_url', requestId: tracking.requestId });
|
||||||
let requestBody;
|
let requestBody;
|
||||||
try {
|
try {
|
||||||
requestBody = await req.json();
|
requestBody = await req.json();
|
||||||
@@ -448,14 +461,17 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return the upload URL and image ID to the client
|
// Return the upload URL and image ID to the client
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
edgeLogger.info('Upload URL created', { action: 'upload_url_success', requestId: tracking.requestId, duration });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
uploadURL: directUploadResult.result.uploadURL,
|
uploadURL: directUploadResult.result.uploadURL,
|
||||||
id: directUploadResult.result.id,
|
id: directUploadResult.result.id,
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -569,10 +585,13 @@ serve(async (req) => {
|
|||||||
|
|
||||||
// Return the image details with convenient URLs
|
// Return the image details with convenient URLs
|
||||||
const result = imageResult.result
|
const result = imageResult.result
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
|
||||||
// Construct proper imagedelivery.net URLs using account hash and image ID
|
// Construct proper imagedelivery.net URLs using account hash and image ID
|
||||||
const baseUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${result.id}`
|
const baseUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${result.id}`
|
||||||
|
|
||||||
|
edgeLogger.info('Image status retrieved', { action: 'get_image_status', requestId: tracking.requestId, duration });
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -587,36 +606,41 @@ serve(async (req) => {
|
|||||||
medium: `${baseUrl}/medium`,
|
medium: `${baseUrl}/medium`,
|
||||||
large: `${baseUrl}/large`,
|
large: `${baseUrl}/large`,
|
||||||
avatar: `${baseUrl}/avatar`,
|
avatar: `${baseUrl}/avatar`,
|
||||||
} : null
|
} : null,
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duration = endRequest(tracking);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Method not allowed',
|
error: 'Method not allowed',
|
||||||
message: 'HTTP method not supported for this endpoint'
|
message: 'HTTP method not supported for this endpoint',
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
const duration = endRequest(tracking);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
||||||
console.error('[Upload] Error:', { error: errorMessage });
|
edgeLogger.error('Upload function error', { action: 'upload_error', requestId: tracking.requestId, duration, error: errorMessage });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: errorMessage
|
message: errorMessage,
|
||||||
|
requestId: tracking.requestId
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user