diff --git a/src/components/auth/TurnstileCaptcha.tsx b/src/components/auth/TurnstileCaptcha.tsx
index 7c9abd1f..4a9a8c6b 100644
--- a/src/components/auth/TurnstileCaptcha.tsx
+++ b/src/components/auth/TurnstileCaptcha.tsx
@@ -18,7 +18,7 @@ export function TurnstileCaptcha({
onSuccess,
onError,
onExpire,
- siteKey = "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z", // Default test key - replace in production
+ siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY,
theme = 'auto',
size = 'normal',
className = ''
@@ -82,12 +82,12 @@ export function TurnstileCaptcha({
}
}, [loading]);
- if (!siteKey || siteKey === "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z") {
+ if (!siteKey) {
return (
- CAPTCHA is using test keys. Configure VITE_TURNSTILE_SITE_KEY for production.
+ CAPTCHA is not configured. Please set VITE_TURNSTILE_SITE_KEY environment variable.
);
diff --git a/src/components/upload/PhotoUpload.tsx b/src/components/upload/PhotoUpload.tsx
index ae85ef61..bee0f36c 100644
--- a/src/components/upload/PhotoUpload.tsx
+++ b/src/components/upload/PhotoUpload.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback } from 'react';
+import { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
@@ -33,6 +33,7 @@ interface UploadedImage {
url: string;
filename: string;
thumbnailUrl: string;
+ previewUrl?: string;
}
export function PhotoUpload({
@@ -53,6 +54,7 @@ export function PhotoUpload({
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
+ const objectUrlsRef = useRef>(new Set());
const isAvatar = variant === 'avatar';
const isCompact = variant === 'compact';
@@ -60,6 +62,36 @@ export function PhotoUpload({
const totalImages = uploadedImages.length + existingPhotos.length;
const canUploadMore = totalImages < actualMaxFiles;
+ useEffect(() => {
+ return () => {
+ objectUrlsRef.current.forEach(url => {
+ try {
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error('Error revoking object URL:', error);
+ }
+ });
+ objectUrlsRef.current.clear();
+ };
+ }, []);
+
+ const createObjectUrl = (file: File): string => {
+ const url = URL.createObjectURL(file);
+ objectUrlsRef.current.add(url);
+ return url;
+ };
+
+ const revokeObjectUrl = (url: string) => {
+ if (objectUrlsRef.current.has(url)) {
+ try {
+ URL.revokeObjectURL(url);
+ objectUrlsRef.current.delete(url);
+ } catch (error) {
+ console.error('Error revoking object URL:', error);
+ }
+ }
+ };
+
const validateFile = (file: File): string | null => {
// Check file size using configurable limit
const maxSize = maxSizeMB * 1024 * 1024;
@@ -76,85 +108,92 @@ export function PhotoUpload({
return null;
};
- const uploadFile = async (file: File): Promise => {
- // Step 1: Get direct upload URL from our edge function
- const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
- body: {
- metadata: {
- filename: file.name,
- size: file.size,
- type: file.type,
- uploadedAt: new Date().toISOString()
- },
- variant: isAvatar ? 'avatar' : 'public'
- }
- });
-
- if (uploadError) {
- console.error('Upload URL error:', uploadError);
- throw new Error(uploadError.message);
- }
-
- if (!uploadData?.success) {
- throw new Error(uploadData?.error || 'Failed to get upload URL');
- }
-
- const { uploadURL, id } = uploadData;
-
- // Step 2: Upload file directly to Cloudflare
- const formData = new FormData();
- formData.append('file', file);
-
- const uploadResponse = await fetch(uploadURL, {
- method: 'POST',
- body: formData,
- });
-
- if (!uploadResponse.ok) {
- throw new Error('Direct upload to Cloudflare failed');
- }
-
- // Step 3: Poll for upload completion and get final URLs
- const maxAttempts = 60; // 30 seconds maximum wait with faster polling
- let attempts = 0;
-
- while (attempts < maxAttempts) {
- try {
- // Use direct fetch with URL parameters instead of supabase.functions.invoke with body
- const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
- const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, {
- method: 'GET',
- headers: {
- 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`,
- 'Content-Type': 'application/json'
- }
- });
-
- if (response.ok) {
- const statusData = await response.json();
-
- if (statusData?.success && statusData.uploaded && statusData.urls) {
- const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public;
- const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail;
-
- return {
- id: statusData.id,
- url: imageUrl,
- filename: file.name,
- thumbnailUrl: thumbUrl
- };
- }
+ const uploadFile = async (file: File, previewUrl: string): Promise => {
+ try {
+ const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
+ body: {
+ metadata: {
+ filename: file.name,
+ size: file.size,
+ type: file.type,
+ uploadedAt: new Date().toISOString()
+ },
+ variant: isAvatar ? 'avatar' : 'public'
}
- } catch (error) {
- console.error('Status poll error:', error);
+ });
+
+ if (uploadError) {
+ console.error('Upload URL error:', uploadError);
+ revokeObjectUrl(previewUrl);
+ throw new Error(uploadError.message);
}
- // Wait 500ms before checking again (faster polling)
- await new Promise(resolve => setTimeout(resolve, 500));
- attempts++;
- }
+ if (!uploadData?.success) {
+ revokeObjectUrl(previewUrl);
+ throw new Error(uploadData?.error || 'Failed to get upload URL');
+ }
- throw new Error('Upload timeout - image processing took too long');
+ const { uploadURL, id } = uploadData;
+
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const uploadResponse = await fetch(uploadURL, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!uploadResponse.ok) {
+ revokeObjectUrl(previewUrl);
+ throw new Error('Direct upload to Cloudflare failed');
+ }
+
+ const maxAttempts = 60;
+ let attempts = 0;
+
+ while (attempts < maxAttempts) {
+ try {
+ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
+ const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (response.ok) {
+ const statusData = await response.json();
+
+ if (statusData?.success && statusData.uploaded && statusData.urls) {
+ const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public;
+ const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail;
+
+ revokeObjectUrl(previewUrl);
+
+ return {
+ id: statusData.id,
+ url: imageUrl,
+ filename: file.name,
+ thumbnailUrl: thumbUrl,
+ previewUrl: undefined
+ };
+ }
+ }
+ } catch (error) {
+ console.error('Status poll error:', error);
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 500));
+ attempts++;
+ }
+
+ revokeObjectUrl(previewUrl);
+ throw new Error('Upload timeout - image processing took too long');
+ } catch (error) {
+ revokeObjectUrl(previewUrl);
+ throw error;
+ }
};
const handleFiles = async (files: FileList) => {
@@ -172,7 +211,6 @@ export function PhotoUpload({
return;
}
- // Validate all files first
for (const file of filesToUpload) {
const validationError = validateFile(file);
if (validationError) {
@@ -186,8 +224,9 @@ export function PhotoUpload({
setError(null);
onUploadStart?.();
+ const previewUrls: string[] = [];
+
try {
- // Delete old image first if this is an avatar update
if (isAvatar && currentImageId) {
try {
await supabase.functions.invoke('upload-image', {
@@ -196,23 +235,22 @@ export function PhotoUpload({
});
} catch (deleteError) {
console.warn('Failed to delete old avatar:', deleteError);
- // Continue with upload even if deletion fails
}
}
const uploadPromises = filesToUpload.map(async (file, index) => {
setUploadProgress((index / filesToUpload.length) * 100);
- return uploadFile(file);
+ const previewUrl = createObjectUrl(file);
+ previewUrls.push(previewUrl);
+ return uploadFile(file, previewUrl);
});
const results = await Promise.all(uploadPromises);
if (isAvatar) {
- // For avatars, replace all existing images
setUploadedImages(results);
onUploadComplete?.(results.map(img => img.url), results[0]?.id);
} else {
- // For regular uploads, append to existing images
setUploadedImages(prev => [...prev, ...results]);
const allUrls = [...existingPhotos, ...uploadedImages.map(img => img.url), ...results.map(img => img.url)];
onUploadComplete?.(allUrls);
@@ -220,6 +258,7 @@ export function PhotoUpload({
setUploadProgress(100);
} catch (error: any) {
+ previewUrls.forEach(url => revokeObjectUrl(url));
const errorMessage = error.message || 'Upload failed';
setError(errorMessage);
onError?.(errorMessage);
@@ -230,6 +269,11 @@ export function PhotoUpload({
};
const removeImage = (imageId: string) => {
+ const imageToRemove = uploadedImages.find(img => img.id === imageId);
+ if (imageToRemove?.previewUrl) {
+ revokeObjectUrl(imageToRemove.previewUrl);
+ }
+
setUploadedImages(prev => prev.filter(img => img.id !== imageId));
const updatedUrls = [...existingPhotos, ...uploadedImages.filter(img => img.id !== imageId).map(img => img.url)];
onUploadComplete?.(updatedUrls);
diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx
index 5b81df34..54609196 100644
--- a/src/hooks/useAuth.tsx
+++ b/src/hooks/useAuth.tsx
@@ -1,7 +1,8 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
+import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import type { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
import type { Profile } from '@/types/database';
+import { toast } from '@/hooks/use-toast';
interface AuthContextType {
user: User | null;
@@ -23,6 +24,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [pendingEmail, setPendingEmail] = useState(null);
const [previousEmail, setPreviousEmail] = useState(null);
+
+ // Refs for lifecycle and cleanup management
+ const isMountedRef = useRef(true);
+ const profileFetchTimeoutRef = useRef(null);
+ const novuUpdateTimeoutRef = useRef(null);
const fetchProfile = async (userId: string) => {
try {
@@ -34,12 +40,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (error && error.code !== 'PGRST116') {
console.error('Error fetching profile:', error);
+
+ // Show user-friendly error notification
+ if (isMountedRef.current) {
+ toast({
+ title: "Profile Loading Error",
+ description: "Unable to load your profile. Please refresh the page or try again later.",
+ variant: "destructive",
+ });
+ }
return;
}
- setProfile(data as Profile);
+ // Only update state if component is still mounted
+ if (isMountedRef.current) {
+ setProfile(data as Profile);
+ }
} catch (error) {
console.error('Error fetching profile:', error);
+
+ // Show user-friendly error notification
+ if (isMountedRef.current) {
+ toast({
+ title: "Profile Loading Error",
+ description: "An unexpected error occurred while loading your profile.",
+ variant: "destructive",
+ });
+ }
}
};
@@ -52,6 +79,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
+ if (!isMountedRef.current) return;
+
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
@@ -64,6 +93,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
+ if (!isMountedRef.current) return;
+
const currentEmail = session?.user?.email;
const newEmailPending = session?.user?.new_email;
@@ -81,8 +112,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
currentEmail !== previousEmail &&
!newEmailPending
) {
+ // Clear any existing Novu update timeout
+ if (novuUpdateTimeoutRef.current) {
+ clearTimeout(novuUpdateTimeoutRef.current);
+ }
+
// Defer Novu update and notifications to avoid blocking auth
- setTimeout(async () => {
+ novuUpdateTimeoutRef.current = setTimeout(async () => {
+ if (!isMountedRef.current) return;
+
try {
// Update Novu subscriber with confirmed email
const { notificationService } = await import('@/lib/notificationService');
@@ -119,6 +157,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
} catch (error) {
console.error('Error updating Novu after email confirmation:', error);
+ } finally {
+ novuUpdateTimeoutRef.current = null;
}
}, 0);
}
@@ -129,18 +169,40 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
if (session?.user) {
+ // Clear any existing profile fetch timeout
+ if (profileFetchTimeoutRef.current) {
+ clearTimeout(profileFetchTimeoutRef.current);
+ }
+
// Defer profile fetch to avoid deadlock
- setTimeout(() => {
+ profileFetchTimeoutRef.current = setTimeout(() => {
+ if (!isMountedRef.current) return;
fetchProfile(session.user.id);
+ profileFetchTimeoutRef.current = null;
}, 0);
} else {
- setProfile(null);
+ if (isMountedRef.current) {
+ setProfile(null);
+ }
}
setLoading(false);
});
- return () => subscription.unsubscribe();
+ return () => {
+ isMountedRef.current = false;
+ subscription.unsubscribe();
+
+ // Clear any pending timeouts
+ if (profileFetchTimeoutRef.current) {
+ clearTimeout(profileFetchTimeoutRef.current);
+ profileFetchTimeoutRef.current = null;
+ }
+ if (novuUpdateTimeoutRef.current) {
+ clearTimeout(novuUpdateTimeoutRef.current);
+ novuUpdateTimeoutRef.current = null;
+ }
+ };
}, []);
const signOut = async () => {
diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts
index 98cb470e..d13d85a1 100644
--- a/src/hooks/useEntityVersions.ts
+++ b/src/hooks/useEntityVersions.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
@@ -36,9 +36,17 @@ export function useEntityVersions(entityType: string, entityId: string) {
const [currentVersion, setCurrentVersion] = useState(null);
const [loading, setLoading] = useState(true);
const [fieldHistory, setFieldHistory] = useState([]);
+
+ // Track if component is mounted to prevent state updates after unmount
+ const isMountedRef = useRef(true);
+
+ // Track the current channel to prevent duplicate subscriptions
+ const channelRef = useRef | null>(null);
const fetchVersions = async () => {
try {
+ if (!isMountedRef.current) return;
+
setLoading(true);
const { data, error } = await supabase
@@ -62,13 +70,20 @@ export function useEntityVersions(entityType: string, entityId: string) {
changer_profile: profiles?.find(p => p.user_id === v.changed_by)
})) as EntityVersion[];
- setVersions(versionsWithProfiles || []);
- setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
+ // Only update state if component is still mounted
+ if (isMountedRef.current) {
+ setVersions(versionsWithProfiles || []);
+ setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
+ }
} catch (error: any) {
console.error('Error fetching versions:', error);
- toast.error('Failed to load version history');
+ if (isMountedRef.current) {
+ toast.error('Failed to load version history');
+ }
} finally {
- setLoading(false);
+ if (isMountedRef.current) {
+ setLoading(false);
+ }
}
};
@@ -82,10 +97,14 @@ export function useEntityVersions(entityType: string, entityId: string) {
if (error) throw error;
- setFieldHistory(data as FieldChange[] || []);
+ if (isMountedRef.current) {
+ setFieldHistory(data as FieldChange[] || []);
+ }
} catch (error: any) {
console.error('Error fetching field history:', error);
- toast.error('Failed to load field history');
+ if (isMountedRef.current) {
+ toast.error('Failed to load field history');
+ }
}
};
@@ -101,7 +120,9 @@ export function useEntityVersions(entityType: string, entityId: string) {
return data;
} catch (error: any) {
console.error('Error comparing versions:', error);
- toast.error('Failed to compare versions');
+ if (isMountedRef.current) {
+ toast.error('Failed to compare versions');
+ }
return null;
}
};
@@ -121,12 +142,16 @@ export function useEntityVersions(entityType: string, entityId: string) {
if (error) throw error;
- toast.success('Successfully rolled back to previous version');
- await fetchVersions();
+ if (isMountedRef.current) {
+ toast.success('Successfully rolled back to previous version');
+ await fetchVersions();
+ }
return data;
} catch (error: any) {
console.error('Error rolling back version:', error);
- toast.error('Failed to rollback version');
+ if (isMountedRef.current) {
+ toast.error('Failed to rollback version');
+ }
return null;
}
};
@@ -147,11 +172,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
if (error) throw error;
- await fetchVersions();
+ if (isMountedRef.current) {
+ await fetchVersions();
+ }
return data;
} catch (error: any) {
console.error('Error creating version:', error);
- toast.error('Failed to create version');
+ if (isMountedRef.current) {
+ toast.error('Failed to create version');
+ }
return null;
}
};
@@ -164,6 +193,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
// Set up realtime subscription for version changes
useEffect(() => {
+ if (!entityType || !entityId) return;
+
+ // Clean up existing channel if any
+ if (channelRef.current) {
+ supabase.removeChannel(channelRef.current);
+ channelRef.current = null;
+ }
+
+ // Create new channel
const channel = supabase
.channel('entity_versions_changes')
.on(
@@ -175,16 +213,35 @@ export function useEntityVersions(entityType: string, entityId: string) {
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`
},
() => {
- fetchVersions();
+ if (isMountedRef.current) {
+ fetchVersions();
+ }
}
)
.subscribe();
+ channelRef.current = channel;
+
return () => {
- supabase.removeChannel(channel);
+ // Ensure cleanup happens in all scenarios
+ if (channelRef.current) {
+ supabase.removeChannel(channelRef.current).catch((error) => {
+ console.error('Error removing channel:', error);
+ });
+ channelRef.current = null;
+ }
};
}, [entityType, entityId]);
+ // Set mounted ref on mount and cleanup on unmount
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
return {
versions,
currentVersion,
diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts
index 8da037e4..7100569a 100644
--- a/src/integrations/supabase/client.ts
+++ b/src/integrations/supabase/client.ts
@@ -2,8 +2,14 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
-const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co";
-const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4";
+const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
+const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
+
+if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
+ throw new Error(
+ 'Missing Supabase environment variables. Please ensure VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are set in your environment.'
+ );
+}
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
diff --git a/supabase/functions/create-novu-subscriber/index.ts b/supabase/functions/create-novu-subscriber/index.ts
index f53a5996..aaf9822d 100644
--- a/supabase/functions/create-novu-subscriber/index.ts
+++ b/supabase/functions/create-novu-subscriber/index.ts
@@ -24,6 +24,48 @@ serve(async (req) => {
const { subscriberId, email, firstName, lastName, phone, avatar, data } = await req.json();
+ // Validate required fields
+ if (!subscriberId || typeof subscriberId !== 'string' || subscriberId.trim() === '') {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: 'subscriberId is required and must be a non-empty string',
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 400,
+ }
+ );
+ }
+
+ if (!email || typeof email !== 'string' || email.trim() === '') {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: 'email is required and must be a non-empty string',
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 400,
+ }
+ );
+ }
+
+ // Validate email format using regex
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: 'Invalid email format. Please provide a valid email address',
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ status: 400,
+ }
+ );
+ }
+
console.log('Creating Novu subscriber:', { subscriberId, email, firstName });
const subscriber = await novu.subscribers.identify(subscriberId, {
diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts
index d20c47de..a8d330c1 100644
--- a/supabase/functions/process-selective-approval/index.ts
+++ b/supabase/functions/process-selective-approval/index.ts
@@ -56,6 +56,54 @@ serve(async (req) => {
const { itemIds, userId, submissionId }: ApprovalRequest = await req.json();
+ // UUID validation regex
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+ // Validate itemIds
+ if (!itemIds || !Array.isArray(itemIds)) {
+ return new Response(
+ JSON.stringify({ error: 'itemIds is required and must be an array' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ if (itemIds.length === 0) {
+ return new Response(
+ JSON.stringify({ error: 'itemIds must be a non-empty array' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Validate userId
+ if (!userId || typeof userId !== 'string' || userId.trim() === '') {
+ return new Response(
+ JSON.stringify({ error: 'userId is required and must be a non-empty string' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ if (!uuidRegex.test(userId)) {
+ return new Response(
+ JSON.stringify({ error: 'userId must be a valid UUID format' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Validate submissionId
+ if (!submissionId || typeof submissionId !== 'string' || submissionId.trim() === '') {
+ return new Response(
+ JSON.stringify({ error: 'submissionId is required and must be a non-empty string' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ if (!uuidRegex.test(submissionId)) {
+ return new Response(
+ JSON.stringify({ error: 'submissionId must be a valid UUID format' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
console.log('Processing selective approval:', { itemIds, userId, submissionId });
// Fetch all items for the submission
@@ -84,7 +132,13 @@ serve(async (req) => {
// Topologically sort items by dependencies
const sortedItems = topologicalSort(items);
const dependencyMap = new Map();
- const approvalResults = [];
+ const approvalResults: Array<{
+ itemId: string;
+ entityId?: string | null;
+ itemType: string;
+ success: boolean;
+ error?: string;
+}> = [];
// Process items in order
for (const item of sortedItems) {
diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts
index b51c33ff..04e3508a 100644
--- a/supabase/functions/upload-image/index.ts
+++ b/supabase/functions/upload-image/index.ts
@@ -55,11 +55,24 @@ serve(async (req) => {
}
// Delete image from Cloudflare
- const { imageId } = await req.json()
-
- if (!imageId) {
+ let requestBody;
+ try {
+ requestBody = await req.json();
+ } catch (error) {
return new Response(
- JSON.stringify({ error: 'Image ID is required for deletion' }),
+ JSON.stringify({ error: 'Invalid JSON in request body' }),
+ {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' }
+ }
+ )
+ }
+
+ const { imageId } = requestBody;
+
+ if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
+ return new Response(
+ JSON.stringify({ error: 'imageId is required and must be a non-empty string' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
@@ -103,7 +116,25 @@ serve(async (req) => {
if (req.method === 'POST') {
// Request a direct upload URL from Cloudflare
- const { metadata = {}, variant = 'public', requireSignedURLs = false } = await req.json().catch(() => ({}))
+ let requestBody;
+ try {
+ requestBody = await req.json();
+ } catch (error) {
+ requestBody = {};
+ }
+
+ // Validate request body structure
+ if (requestBody && typeof requestBody !== 'object') {
+ return new Response(
+ JSON.stringify({ error: 'Request body must be a valid JSON object' }),
+ {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' }
+ }
+ )
+ }
+
+ const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
// Create FormData for the request (Cloudflare API requires multipart/form-data)
const formData = new FormData()
@@ -159,9 +190,9 @@ serve(async (req) => {
const url = new URL(req.url)
const imageId = url.searchParams.get('id')
- if (!imageId) {
+ if (!imageId || imageId.trim() === '') {
return new Response(
- JSON.stringify({ error: 'Image ID is required' }),
+ JSON.stringify({ error: 'id query parameter is required and must be non-empty' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }