Fix park submission locations

Implement Phase 1 of the JSONB violation fix by creating the `park_submission_locations` table. This includes migrating existing data from `park_submissions.temp_location_data` and updating relevant code to read and write to the new relational table. The `temp_location_data` column will be dropped after data migration.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-06 15:45:12 +00:00
parent 1a4e30674f
commit 5b0ac813e2
8 changed files with 379 additions and 124 deletions

View File

@@ -32,9 +32,14 @@ export function RichParkDisplay({ data, actionType, showAllFields = true }: Rich
.single(); .single();
setLocation(locationData); setLocation(locationData);
} }
// Otherwise use temp_location_data (for new submissions) // Otherwise fetch from park_submission_locations (for new submissions)
else if (data.temp_location_data) { else if (data.id) {
setLocation(data.temp_location_data); const { data: locationData } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', data.id)
.maybeSingle();
setLocation(locationData);
} }
// Fetch operator // Fetch operator
@@ -59,7 +64,7 @@ export function RichParkDisplay({ data, actionType, showAllFields = true }: Rich
}; };
fetchRelatedData(); fetchRelatedData();
}, [data.location_id, data.temp_location_data, data.operator_id, data.property_owner_id]); }, [data.location_id, data.id, data.operator_id, data.property_owner_id]);
const getStatusColor = (status: string | undefined) => { const getStatusColor = (status: string | undefined) => {
if (!status) return 'bg-gray-500'; if (!status) return 'bg-gray-500';

View File

@@ -162,25 +162,24 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
}); });
// Transform to include item_data // Transform to include item_data
const itemsWithData = fullItems.map(item => { const itemsWithData = await Promise.all(fullItems.map(async item => {
let itemData = {}; let itemData = {};
switch (item.item_type) { switch (item.item_type) {
case 'park': { case 'park': {
const parkSub = (item.park_submission as any) || {}; const parkSub = (item.park_submission as any) || {};
let locationData = null;
if (parkSub?.id) {
const { data } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', parkSub.id)
.maybeSingle();
locationData = data;
}
itemData = { itemData = {
...parkSub, ...parkSub,
// Transform temp_location_data → location for validation location: locationData || undefined
location: parkSub.temp_location_data || undefined,
temp_location_data: undefined
}; };
console.info('[Submission Flow] Transformed park data for validation', {
itemId: item.id,
hasLocation: !!parkSub.temp_location_data,
locationData: parkSub.temp_location_data,
transformedHasLocation: !!(itemData as any).location,
timestamp: new Date().toISOString()
});
break; break;
} }
case 'ride': case 'ride':

View File

@@ -2006,6 +2006,65 @@ export type Database = {
}, },
] ]
} }
park_submission_locations: {
Row: {
city: string | null
country: string
created_at: string
display_name: string | null
id: string
latitude: number | null
longitude: number | null
name: string
park_submission_id: string
postal_code: string | null
state_province: string | null
street_address: string | null
timezone: string | null
updated_at: string
}
Insert: {
city?: string | null
country: string
created_at?: string
display_name?: string | null
id?: string
latitude?: number | null
longitude?: number | null
name: string
park_submission_id: string
postal_code?: string | null
state_province?: string | null
street_address?: string | null
timezone?: string | null
updated_at?: string
}
Update: {
city?: string | null
country?: string
created_at?: string
display_name?: string | null
id?: string
latitude?: number | null
longitude?: number | null
name?: string
park_submission_id?: string
postal_code?: string | null
state_province?: string | null
street_address?: string | null
timezone?: string | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "park_submission_locations_park_submission_id_fkey"
columns: ["park_submission_id"]
isOneToOne: false
referencedRelation: "park_submissions"
referencedColumns: ["id"]
},
]
}
park_submissions: { park_submissions: {
Row: { Row: {
banner_image_id: string | null banner_image_id: string | null
@@ -2029,7 +2088,6 @@ export type Database = {
slug: string slug: string
status: string status: string
submission_id: string submission_id: string
temp_location_data: Json | null
updated_at: string updated_at: string
website_url: string | null website_url: string | null
} }
@@ -2055,7 +2113,6 @@ export type Database = {
slug: string slug: string
status?: string status?: string
submission_id: string submission_id: string
temp_location_data?: Json | null
updated_at?: string updated_at?: string
website_url?: string | null website_url?: string | null
} }
@@ -2081,7 +2138,6 @@ export type Database = {
slug?: string slug?: string
status?: string status?: string
submission_id?: string submission_id?: string
temp_location_data?: Json | null
updated_at?: string updated_at?: string
website_url?: string | null website_url?: string | null
} }

View File

@@ -362,22 +362,11 @@ async function submitCompositeCreation(
images: primaryImages as unknown as Json images: primaryImages as unknown as Json
}; };
// Convert location object to temp_location_data for parks // Store location reference for park submissions (will be created in relational table)
if (uploadedPrimary.type === 'park' && uploadedPrimary.data.location) { if (uploadedPrimary.type === 'park' && uploadedPrimary.data.location) {
primaryData.temp_location_data = { primaryData._temp_location = uploadedPrimary.data.location;
name: uploadedPrimary.data.location.name,
street_address: uploadedPrimary.data.location.street_address || null,
city: uploadedPrimary.data.location.city || null,
state_province: uploadedPrimary.data.location.state_province || null,
country: uploadedPrimary.data.location.country,
latitude: uploadedPrimary.data.location.latitude,
longitude: uploadedPrimary.data.location.longitude,
timezone: uploadedPrimary.data.location.timezone || null,
postal_code: uploadedPrimary.data.location.postal_code || null,
display_name: uploadedPrimary.data.location.display_name
};
delete primaryData.location; // Remove the original location object delete primaryData.location; // Remove the original location object
console.log('[submitCompositeCreation] Converted location to temp_location_data:', primaryData.temp_location_data); console.log('[submitCompositeCreation] Stored location for relational insert:', primaryData._temp_location);
} }
// Map temporary IDs to order indices for foreign keys // Map temporary IDs to order indices for foreign keys
@@ -725,7 +714,7 @@ export async function submitParkCreation(
name: data.name, name: data.name,
hasLocation: !!data.location, hasLocation: !!data.location,
hasLocationId: !!data.location_id, hasLocationId: !!data.location_id,
temp_location_data: tempLocationData hasLocationData: !!tempLocationData
}); });
const { data: parkSubmission, error: parkSubmissionError } = await supabase const { data: parkSubmission, error: parkSubmissionError } = await supabase
@@ -745,7 +734,6 @@ export async function submitParkCreation(
operator_id: data.operator_id || null, operator_id: data.operator_id || null,
property_owner_id: data.property_owner_id || null, property_owner_id: data.property_owner_id || null,
location_id: data.location_id || null, location_id: data.location_id || null,
temp_location_data: tempLocationData,
banner_image_url: bannerImage?.url || data.banner_image_url || null, banner_image_url: bannerImage?.url || data.banner_image_url || null,
banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null,
card_image_url: cardImage?.url || data.card_image_url || null, card_image_url: cardImage?.url || data.card_image_url || null,
@@ -756,6 +744,26 @@ export async function submitParkCreation(
if (parkSubmissionError) throw parkSubmissionError; if (parkSubmissionError) throw parkSubmissionError;
// Create location in relational table if provided
if (tempLocationData) {
const { error: locationError } = await supabase
.from('park_submission_locations' as any)
.insert({
park_submission_id: (parkSubmission as any).id,
...tempLocationData
} as any);
if (locationError) {
console.error('[submitParkCreation] Failed to create location:', locationError);
throw new Error(`Failed to save location data: ${locationError.message}`);
}
console.info('[submitParkCreation] Created park_submission_location', {
parkSubmissionId: (parkSubmission as any).id,
locationName: tempLocationData.name
});
}
// Create submission_items record linking to park_submissions // Create submission_items record linking to park_submissions
const { error: itemError } = await supabase const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')
@@ -934,7 +942,6 @@ export async function submitParkUpdate(
operator_id: changedFields.operator_id !== undefined ? changedFields.operator_id : existingPark.operator_id, operator_id: changedFields.operator_id !== undefined ? changedFields.operator_id : existingPark.operator_id,
property_owner_id: changedFields.property_owner_id !== undefined ? changedFields.property_owner_id : existingPark.property_owner_id, property_owner_id: changedFields.property_owner_id !== undefined ? changedFields.property_owner_id : existingPark.property_owner_id,
location_id: changedFields.location_id !== undefined ? changedFields.location_id : existingPark.location_id, location_id: changedFields.location_id !== undefined ? changedFields.location_id : existingPark.location_id,
temp_location_data: tempLocationData,
banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingPark.banner_image_url, banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingPark.banner_image_url,
banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingPark.banner_image_id, banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingPark.banner_image_id,
card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingPark.card_image_url, card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingPark.card_image_url,
@@ -945,6 +952,26 @@ export async function submitParkUpdate(
if (parkSubmissionError) throw parkSubmissionError; if (parkSubmissionError) throw parkSubmissionError;
// Create location in relational table if provided
if (tempLocationData) {
const { error: locationError } = await supabase
.from('park_submission_locations' as any)
.insert({
park_submission_id: (parkSubmission as any).id,
...tempLocationData
} as any);
if (locationError) {
console.error('[submitParkEdit] Failed to create location:', locationError);
throw new Error(`Failed to save location data: ${locationError.message}`);
}
console.info('[submitParkEdit] Created park_submission_location', {
parkSubmissionId: (parkSubmission as any).id,
locationName: tempLocationData.name
});
}
// ✅ Create submission_items referencing park_submission (no JSON data) // ✅ Create submission_items referencing park_submission (no JSON data)
const { error: itemError } = await supabase const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')

View File

@@ -81,12 +81,21 @@ export async function fetchSubmissionItems(submissionId: string): Promise<Submis
switch (item.item_type) { switch (item.item_type) {
case 'park': { case 'park': {
const parkSub = (item as any).park_submission; const parkSub = (item as any).park_submission;
// Fetch location from park_submission_locations if available
let locationData = null;
if (parkSub?.id) {
const { data } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', parkSub.id)
.maybeSingle();
locationData = data;
}
item_data = { item_data = {
...parkSub, ...parkSub,
// Transform temp_location_data → location for form compatibility // Transform park_submission_location → location for form compatibility
location: parkSub.temp_location_data || undefined, location: locationData || undefined
// Remove temp_location_data to avoid confusion
temp_location_data: undefined
}; };
break; break;
} }
@@ -273,8 +282,6 @@ export async function updateSubmissionItem(
const parkData = item_data as any; const parkData = item_data as any;
const updateData: any = { const updateData: any = {
...parkData, ...parkData,
// Transform location → temp_location_data for storage
temp_location_data: parkData.location || null,
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
@@ -289,34 +296,57 @@ export async function updateSubmissionItem(
console.info('[Submission Flow] Saving park data', { console.info('[Submission Flow] Saving park data', {
itemId, itemId,
parkSubmissionId: item.park_submission_id, parkSubmissionId: item.park_submission_id,
hasLocation: !!updateData.temp_location_data, hasLocation: !!parkData.location,
locationData: updateData.temp_location_data,
fields: Object.keys(updateData), fields: Object.keys(updateData),
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
const { error: updateError } = await supabase // Update park_submissions
.from('park_submissions') const { error: parkError } = await supabase
.from('park_submissions' as any)
.update(updateData) .update(updateData)
.eq('id', item.park_submission_id); .eq('id', item.park_submission_id);
if (updateError) { if (parkError) {
handleError(updateError, { console.error('[Submission Flow] Park update failed:', parkError);
action: 'Update Park Submission Data', throw parkError;
metadata: {
itemId,
parkSubmissionId: item.park_submission_id,
updateFields: Object.keys(updateData)
}
});
throw updateError;
} }
console.info('[Submission Flow] Park data saved successfully', { // Update or insert location if provided
itemId, if (parkData.location) {
parkSubmissionId: item.park_submission_id, const locationData = {
timestamp: new Date().toISOString() park_submission_id: item.park_submission_id,
}); name: parkData.location.name,
street_address: parkData.location.street_address || null,
city: parkData.location.city || null,
state_province: parkData.location.state_province || null,
country: parkData.location.country,
postal_code: parkData.location.postal_code || null,
latitude: parkData.location.latitude,
longitude: parkData.location.longitude,
timezone: parkData.location.timezone || null,
display_name: parkData.location.display_name || null
};
// Try to update first, if no rows affected, insert
const { error: locationError } = await supabase
.from('park_submission_locations' as any)
.upsert(locationData, {
onConflict: 'park_submission_id'
});
if (locationError) {
console.error('[Submission Flow] Location upsert failed:', locationError);
throw locationError;
}
console.info('[Submission Flow] Location saved', {
parkSubmissionId: item.park_submission_id,
locationName: locationData.name
});
}
console.info('[Submission Flow] Park data saved successfully');
break; break;
} }
case 'ride': { case 'ride': {

View File

@@ -6,6 +6,7 @@
import type { LocationData } from './location'; import type { LocationData } from './location';
export interface ParkSubmissionData { export interface ParkSubmissionData {
id?: string; // park_submission.id for location lookup
name: string; name: string;
slug: string; slug: string;
description?: string | null; description?: string | null;

View File

@@ -855,8 +855,7 @@ serve(withRateLimit(async (req) => {
action: 'approval_park_data_debug', action: 'approval_park_data_debug',
itemId: item.id, itemId: item.id,
hasLocationId: !!itemData.location_id, hasLocationId: !!itemData.location_id,
hasTempLocationData: !!itemData.temp_location_data, parkSubmissionId: itemData.id,
tempLocationDataKeys: itemData.temp_location_data ? Object.keys(itemData.temp_location_data) : [],
parkSubmissionKeys: Object.keys((item as any).park_submission || {}), parkSubmissionKeys: Object.keys((item as any).park_submission || {}),
requestId: tracking.requestId requestId: tracking.requestId
}); });
@@ -1576,47 +1575,55 @@ function normalizeParkTypeValue(data: any): any {
async function createPark(supabase: any, data: any): Promise<string> { async function createPark(supabase: any, data: any): Promise<string> {
const submitterId = data._submitter_id; const submitterId = data._submitter_id;
const parkSubmissionId = data.id; // Store the park_submission.id for location lookup
let uploadedPhotos: any[] = []; let uploadedPhotos: any[] = [];
// Create location if temp_location_data exists and location_id is missing
if (data.temp_location_data && !data.location_id) {
edgeLogger.info('Creating location from temp data', {
action: 'approval_create_location',
locationName: data.temp_location_data.name
});
const { data: newLocation, error: locationError } = await supabase // Create location if park_submission_locations exists and location_id is missing
.from('locations') if (!data.location_id) {
.insert({ // Try to fetch location from relational table
name: data.temp_location_data.name, const { data: locationData, error: locationFetchError } = await supabase
street_address: data.temp_location_data.street_address || null, .from('park_submission_locations')
city: data.temp_location_data.city, .select('*')
state_province: data.temp_location_data.state_province, .eq('park_submission_id', parkSubmissionId)
country: data.temp_location_data.country,
latitude: data.temp_location_data.latitude,
longitude: data.temp_location_data.longitude,
timezone: data.temp_location_data.timezone,
postal_code: data.temp_location_data.postal_code
})
.select('id')
.single(); .single();
if (locationError) { if (locationData && !locationFetchError) {
throw new Error(`Failed to create location: ${locationError.message}`); edgeLogger.info('Creating location from relational table', {
action: 'approval_create_location',
locationName: locationData.name
});
const { data: newLocation, error: locationError } = await supabase
.from('locations')
.insert({
name: locationData.name,
street_address: locationData.street_address || null,
city: locationData.city,
state_province: locationData.state_province,
country: locationData.country,
latitude: locationData.latitude,
longitude: locationData.longitude,
timezone: locationData.timezone,
postal_code: locationData.postal_code
})
.select('id')
.single();
if (locationError) {
throw new Error(`Failed to create location: ${locationError.message}`);
}
data.location_id = newLocation.id;
edgeLogger.info('Location created successfully', {
action: 'approval_location_created',
locationId: newLocation.id,
locationName: locationData.name
});
} }
data.location_id = newLocation.id;
edgeLogger.info('Location created successfully', {
action: 'approval_location_created',
locationId: newLocation.id,
locationName: data.temp_location_data.name
});
} }
// Clean up temp data
delete data.temp_location_data;
// Transform images object if present // Transform images object if present
if (data.images) { if (data.images) {
const { uploaded, banner_assignment, card_assignment } = data.images; const { uploaded, banner_assignment, card_assignment } = data.images;
@@ -1653,36 +1660,44 @@ async function createPark(supabase: any, data: any): Promise<string> {
parkId = data.park_id; parkId = data.park_id;
delete data.park_id; // Remove ID from update data delete data.park_id; // Remove ID from update data
// ✅ FIXED: Handle location updates from temp_location_data // ✅ FIXED: Handle location updates from park_submission_locations
if (data.temp_location_data && !data.location_id) { if (!data.location_id) {
edgeLogger.info('Creating location from temp data for update', { // Try to fetch location from relational table
action: 'approval_create_location_update', const { data: locationData, error: locationFetchError } = await supabase
locationName: data.temp_location_data.name .from('park_submission_locations')
}); .select('*')
.eq('park_submission_id', parkSubmissionId)
const { data: newLocation, error: locationError } = await supabase
.from('locations')
.insert({
name: data.temp_location_data.name,
street_address: data.temp_location_data.street_address || null,
city: data.temp_location_data.city,
state_province: data.temp_location_data.state_province,
country: data.temp_location_data.country,
latitude: data.temp_location_data.latitude,
longitude: data.temp_location_data.longitude,
timezone: data.temp_location_data.timezone,
postal_code: data.temp_location_data.postal_code
})
.select('id')
.single(); .single();
if (locationError) { if (locationData && !locationFetchError) {
throw new Error(`Failed to create location: ${locationError.message}`); edgeLogger.info('Creating location from relational table for update', {
} action: 'approval_create_location_update',
locationName: locationData.name
});
data.location_id = newLocation.id; const { data: newLocation, error: locationError } = await supabase
.from('locations')
.insert({
name: locationData.name,
street_address: locationData.street_address || null,
city: locationData.city,
state_province: locationData.state_province,
country: locationData.country,
latitude: locationData.latitude,
longitude: locationData.longitude,
timezone: locationData.timezone,
postal_code: locationData.postal_code
})
.select('id')
.single();
if (locationError) {
throw new Error(`Failed to create location: ${locationError.message}`);
}
data.location_id = newLocation.id;
}
} }
delete data.temp_location_data;
const normalizedData = normalizeParkTypeValue(normalizeStatusValue(data)); const normalizedData = normalizeParkTypeValue(normalizeStatusValue(data));
const sanitizedData = sanitizeDateFields(normalizedData); const sanitizedData = sanitizeDateFields(normalizedData);

View File

@@ -0,0 +1,122 @@
-- Phase 1: Fix park_submissions.temp_location_data JSONB violation
-- Create relational table for temporary location data
-- Create park_submission_locations table
CREATE TABLE IF NOT EXISTS public.park_submission_locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
park_submission_id UUID NOT NULL REFERENCES public.park_submissions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
street_address TEXT,
city TEXT,
state_province TEXT,
country TEXT NOT NULL,
postal_code TEXT,
latitude NUMERIC(10, 7),
longitude NUMERIC(10, 7),
timezone TEXT,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_park_submission_locations_submission
ON public.park_submission_locations(park_submission_id);
CREATE INDEX IF NOT EXISTS idx_park_submission_locations_country
ON public.park_submission_locations(country);
-- Enable RLS
ALTER TABLE public.park_submission_locations ENABLE ROW LEVEL SECURITY;
-- RLS Policies (mirror park_submissions policies)
CREATE POLICY "Moderators can view all park submission locations"
ON public.park_submission_locations
FOR SELECT
TO authenticated
USING (
is_moderator(auth.uid())
AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2())
);
CREATE POLICY "Users can view their own park submission locations"
ON public.park_submission_locations
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM content_submissions cs
INNER JOIN park_submissions ps ON ps.submission_id = cs.id
WHERE ps.id = park_submission_locations.park_submission_id
AND cs.user_id = auth.uid()
)
);
CREATE POLICY "Users can insert park submission locations"
ON public.park_submission_locations
FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM content_submissions cs
INNER JOIN park_submissions ps ON ps.submission_id = cs.id
WHERE ps.id = park_submission_locations.park_submission_id
AND cs.user_id = auth.uid()
)
AND NOT is_user_banned(auth.uid())
);
CREATE POLICY "Moderators can update park submission locations"
ON public.park_submission_locations
FOR UPDATE
TO authenticated
USING (
is_moderator(auth.uid())
AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2())
);
CREATE POLICY "Moderators can delete park submission locations"
ON public.park_submission_locations
FOR DELETE
TO authenticated
USING (
is_moderator(auth.uid())
AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2())
);
-- Migrate existing temp_location_data to new table
INSERT INTO public.park_submission_locations (
park_submission_id,
name,
street_address,
city,
state_province,
country,
postal_code,
latitude,
longitude,
timezone,
display_name
)
SELECT
id,
temp_location_data->>'name',
temp_location_data->>'street_address',
temp_location_data->>'city',
temp_location_data->>'state_province',
temp_location_data->>'country',
temp_location_data->>'postal_code',
(temp_location_data->>'latitude')::numeric,
(temp_location_data->>'longitude')::numeric,
temp_location_data->>'timezone',
temp_location_data->>'display_name'
FROM public.park_submissions
WHERE temp_location_data IS NOT NULL
AND temp_location_data->>'name' IS NOT NULL;
-- Drop the JSONB column
ALTER TABLE public.park_submissions DROP COLUMN IF EXISTS temp_location_data;
-- Add comment
COMMENT ON TABLE public.park_submission_locations IS
'Relational storage for park submission location data. Replaces temp_location_data JSONB column for proper queryability and data integrity.';