Compare commits

..

5 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
898f838862 feat: Implement temp ref storage 2025-11-05 18:23:14 +00:00
gpt-engineer-app[bot]
b326252138 Refactor: Approve tool use 2025-11-05 18:22:38 +00:00
gpt-engineer-app[bot]
d62b3c2412 feat: Implement temp ref cleanup 2025-11-05 18:15:21 +00:00
gpt-engineer-app[bot]
303853ff94 Add cleanup for temp refs 2025-11-05 18:11:22 +00:00
gpt-engineer-app[bot]
b036fb4785 Add temp ref cleanup 2025-11-05 18:09:44 +00:00
5 changed files with 539 additions and 5 deletions

View File

@@ -4718,6 +4718,38 @@ export type Database = {
}
Relationships: []
}
submission_item_temp_refs: {
Row: {
created_at: string
id: string
ref_order_index: number
ref_type: string
submission_item_id: string
}
Insert: {
created_at?: string
id?: string
ref_order_index: number
ref_type: string
submission_item_id: string
}
Update: {
created_at?: string
id?: string
ref_order_index?: number
ref_type?: string
submission_item_id?: string
}
Relationships: [
{
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
columns: ["submission_item_id"]
isOneToOne: false
referencedRelation: "submission_items"
referencedColumns: ["id"]
},
]
}
submission_items: {
Row: {
action_type: string | null

View File

@@ -359,6 +359,40 @@ serve(withRateLimit(async (req) => {
throw new Error(`Failed to fetch items: ${fetchError.message}`);
}
// Query temporary references for all submission items
const { data: tempRefs, error: tempRefsError } = await supabase
.from('submission_item_temp_refs')
.select('submission_item_id, ref_type, ref_order_index')
.in('submission_item_id', itemIds);
if (tempRefsError) {
edgeLogger.warn('Failed to fetch temp refs', {
action: 'approval_fetch_temp_refs',
submissionId,
error: tempRefsError.message,
requestId: tracking.requestId
});
// Don't throw - continue with empty temp refs (backwards compatibility)
}
// Build a map: itemId -> { _temp_operator_ref: 0, _temp_park_ref: 1, ... }
const tempRefsByItemId = new Map<string, Record<string, number>>();
for (const ref of tempRefs || []) {
if (!tempRefsByItemId.has(ref.submission_item_id)) {
tempRefsByItemId.set(ref.submission_item_id, {});
}
const fieldName = `_temp_${ref.ref_type}_ref`;
tempRefsByItemId.get(ref.submission_item_id)![fieldName] = ref.ref_order_index;
}
edgeLogger.info('Loaded temp refs', {
action: 'approval_temp_refs_loaded',
submissionId,
itemsWithTempRefs: tempRefsByItemId.size,
totalTempRefs: tempRefs?.length || 0,
requestId: tracking.requestId
});
// Get the submitter's user_id from the submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')
@@ -423,25 +457,39 @@ serve(withRateLimit(async (req) => {
let itemData: any;
switch (item.item_type) {
case 'park':
itemData = (item as any).park_submission;
itemData = {
...(item as any).park_submission,
// Merge temp refs for this item
...(tempRefsByItemId.get(item.id) || {})
};
break;
case 'ride':
itemData = (item as any).ride_submission;
itemData = {
...(item as any).ride_submission,
...(tempRefsByItemId.get(item.id) || {})
};
break;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
itemData = (item as any).company_submission;
itemData = {
...(item as any).company_submission,
...(tempRefsByItemId.get(item.id) || {})
};
break;
case 'ride_model':
itemData = (item as any).ride_model_submission;
itemData = {
...(item as any).ride_model_submission,
...(tempRefsByItemId.get(item.id) || {})
};
break;
case 'photo':
// Combine photo_submission with its photo_items array
itemData = {
...(item as any).photo_submission,
photos: (item as any).photo_submission?.photo_items || []
photos: (item as any).photo_submission?.photo_items || [],
...(tempRefsByItemId.get(item.id) || {})
};
break;
default:
@@ -453,6 +501,17 @@ serve(withRateLimit(async (req) => {
// Fallback to item_data if relational data not found (for backwards compatibility)
itemData = item.item_data;
}
// Log if temp refs were found for this item
if (tempRefsByItemId.has(item.id)) {
edgeLogger.info('Item has temp refs', {
action: 'approval_item_temp_refs',
itemId: item.id,
itemType: item.item_type,
tempRefs: tempRefsByItemId.get(item.id),
requestId: tracking.requestId
});
}
// Validate entity data with strict validation, passing original_data for edits
const validation = validateEntityDataStrict(item.item_type, itemData, item.original_data);
@@ -632,6 +691,37 @@ serve(withRateLimit(async (req) => {
}
}
// ✅ CLEANUP: Delete temporary references for approved items
const approvedItemIds = approvalResults.filter(r => r.success).map(r => r.itemId);
if (approvedItemIds.length > 0) {
try {
const { error: cleanupError } = await supabase
.from('submission_item_temp_refs')
.delete()
.in('submission_item_id', approvedItemIds);
if (cleanupError) {
edgeLogger.warn('Failed to cleanup temp refs for approved items', {
requestId: tracking.requestId,
approvedItemIds,
error: cleanupError.message
});
// Don't throw - cleanup failure shouldn't block approval
} else {
edgeLogger.info('Cleaned up temp refs for approved items', {
requestId: tracking.requestId,
count: approvedItemIds.length
});
}
} catch (cleanupErr) {
edgeLogger.warn('Exception during temp ref cleanup', {
requestId: tracking.requestId,
error: cleanupErr instanceof Error ? cleanupErr.message : 'Unknown error'
});
// Continue - don't let cleanup errors affect approval
}
}
// Batch update all rejected items
const rejectedItemIds = approvalResults.filter(r => !r.success).map(r => r.itemId);
if (rejectedItemIds.length > 0) {

View File

@@ -0,0 +1,49 @@
-- =====================================================
-- Automatic Cleanup of Temporary References
-- =====================================================
-- Trigger to delete submission_item_temp_refs when items are approved
-- This acts as a safety net if edge function cleanup fails
-- Function to cleanup temp refs on approval
CREATE OR REPLACE FUNCTION public.cleanup_temp_refs_on_approval()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_deleted_count INTEGER;
BEGIN
-- Only cleanup when status changes to approved
IF NEW.status = 'approved' AND OLD.status != 'approved' THEN
-- Delete all temp refs for this submission item
DELETE FROM public.submission_item_temp_refs
WHERE submission_item_id = NEW.id;
GET DIAGNOSTICS v_deleted_count = ROW_COUNT;
IF v_deleted_count > 0 THEN
RAISE NOTICE 'Cleaned up % temp refs for submission_item %', v_deleted_count, NEW.id;
END IF;
END IF;
RETURN NEW;
EXCEPTION WHEN OTHERS THEN
-- Log but don't block the approval
RAISE NOTICE 'Failed to cleanup temp refs for item %: %', NEW.id, SQLERRM;
RETURN NEW;
END;
$function$;
-- Create trigger on submission_items
CREATE TRIGGER trigger_cleanup_temp_refs_on_approval
AFTER UPDATE OF status ON public.submission_items
FOR EACH ROW
WHEN (NEW.status = 'approved' AND OLD.status IS DISTINCT FROM 'approved')
EXECUTE FUNCTION public.cleanup_temp_refs_on_approval();
COMMENT ON FUNCTION public.cleanup_temp_refs_on_approval() IS
'Automatically deletes temporary reference records when submission items are approved. Acts as safety net for edge function cleanup.';
COMMENT ON TRIGGER trigger_cleanup_temp_refs_on_approval ON public.submission_items IS
'Ensures temp refs are cleaned up even if edge function cleanup fails';

View File

@@ -0,0 +1,85 @@
-- =====================================================
-- Temporary Reference Storage for Composite Submissions
-- =====================================================
-- Stores _temp_*_ref fields extracted from submission items
-- These are used during approval to resolve dependencies
CREATE TABLE IF NOT EXISTS public.submission_item_temp_refs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_item_id UUID NOT NULL REFERENCES public.submission_items(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL CHECK (ref_type IN (
'operator', 'property_owner', 'manufacturer', 'designer',
'park', 'ride', 'ride_model', 'location'
)),
ref_order_index INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX idx_submission_item_temp_refs_item_id
ON public.submission_item_temp_refs(submission_item_id);
CREATE INDEX idx_submission_item_temp_refs_type
ON public.submission_item_temp_refs(ref_type);
-- Composite index for fast lookups during approval
CREATE INDEX idx_submission_item_temp_refs_item_type
ON public.submission_item_temp_refs(submission_item_id, ref_type);
-- Enable RLS
ALTER TABLE public.submission_item_temp_refs ENABLE ROW LEVEL SECURITY;
-- RLS Policy: Moderators can view all temp refs
CREATE POLICY "Moderators can view all temp refs"
ON public.submission_item_temp_refs
FOR SELECT
USING (
is_moderator(auth.uid()) AND
((NOT has_mfa_enabled(auth.uid())) OR has_aal2())
);
-- RLS Policy: Users can view their own temp refs via submission ownership
CREATE POLICY "Users can view their own temp refs"
ON public.submission_item_temp_refs
FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.submission_items si
JOIN public.content_submissions cs ON cs.id = si.submission_id
WHERE si.id = submission_item_temp_refs.submission_item_id
AND cs.user_id = auth.uid()
)
);
-- RLS Policy: System can insert temp refs during submission creation
CREATE POLICY "System can insert temp refs"
ON public.submission_item_temp_refs
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1
FROM public.submission_items si
JOIN public.content_submissions cs ON cs.id = si.submission_id
WHERE si.id = submission_item_temp_refs.submission_item_id
AND cs.user_id = auth.uid()
)
);
-- RLS Policy: Moderators can delete temp refs (manual cleanup if needed)
CREATE POLICY "Moderators can delete temp refs"
ON public.submission_item_temp_refs
FOR DELETE
USING (
is_moderator(auth.uid()) AND
((NOT has_mfa_enabled(auth.uid())) OR has_aal2())
);
COMMENT ON TABLE public.submission_item_temp_refs IS
'Stores temporary reference fields (_temp_operator_ref, etc.) from submission items for composite submissions. Cleaned up automatically when items are approved.';
COMMENT ON COLUMN public.submission_item_temp_refs.ref_type IS
'The entity type being referenced (e.g., operator, park, manufacturer). Extracted from _temp_operator_ref -> operator.';
COMMENT ON COLUMN public.submission_item_temp_refs.ref_order_index IS
'The array index of the referenced item in the submission order. Matches the order_index of the target submission_item.';

View File

@@ -0,0 +1,278 @@
-- =====================================================
-- Update create_submission_with_items to Extract Temp Refs
-- =====================================================
-- Extracts _temp_*_ref fields and stores them in submission_item_temp_refs
CREATE OR REPLACE FUNCTION public.create_submission_with_items(
p_user_id uuid,
p_submission_type text,
p_content jsonb,
p_items jsonb[]
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_submission_id UUID;
v_item JSONB;
v_item_data JSONB;
v_item_type TEXT;
v_action_type TEXT;
v_park_submission_id UUID;
v_company_submission_id UUID;
v_ride_submission_id UUID;
v_ride_model_submission_id UUID;
v_photo_submission_id UUID;
v_timeline_event_submission_id UUID;
v_submission_item_id UUID;
v_temp_ref_key TEXT;
v_temp_ref_value TEXT;
v_ref_type TEXT;
v_ref_order_index INTEGER;
BEGIN
-- Create main submission
INSERT INTO content_submissions (user_id, submission_type, status, approval_mode)
VALUES (p_user_id, p_submission_type, 'pending', 'full')
RETURNING id INTO v_submission_id;
-- Validate items array
IF array_length(p_items, 1) IS NULL OR array_length(p_items, 1) = 0 THEN
RAISE EXCEPTION 'Cannot create submission without items';
END IF;
-- Process each item
FOREACH v_item IN ARRAY p_items
LOOP
v_item_type := (v_item->>'item_type')::TEXT;
v_action_type := (v_item->>'action_type')::TEXT;
v_item_data := v_item->'item_data';
-- Reset IDs for this iteration
v_park_submission_id := NULL;
v_company_submission_id := NULL;
v_ride_submission_id := NULL;
v_ride_model_submission_id := NULL;
v_photo_submission_id := NULL;
v_timeline_event_submission_id := NULL;
-- Create specialized submission records based on item_type
IF v_item_type = 'park' THEN
INSERT INTO park_submissions (
submission_id, name, slug, description, park_type, status,
opening_date, opening_date_precision, closing_date, closing_date_precision,
location_id, operator_id, property_owner_id,
website_url, phone, email,
banner_image_url, banner_image_id, card_image_url, card_image_id
) VALUES (
v_submission_id,
v_item_data->>'name',
v_item_data->>'slug',
v_item_data->>'description',
v_item_data->>'park_type',
v_item_data->>'status',
(v_item_data->>'opening_date')::DATE,
v_item_data->>'opening_date_precision',
(v_item_data->>'closing_date')::DATE,
v_item_data->>'closing_date_precision',
(v_item_data->>'location_id')::UUID,
(v_item_data->>'operator_id')::UUID,
(v_item_data->>'property_owner_id')::UUID,
v_item_data->>'website_url',
v_item_data->>'phone',
v_item_data->>'email',
v_item_data->>'banner_image_url',
v_item_data->>'banner_image_id',
v_item_data->>'card_image_url',
v_item_data->>'card_image_id'
) RETURNING id INTO v_park_submission_id;
ELSIF v_item_type IN ('manufacturer', 'operator', 'property_owner', 'designer') THEN
INSERT INTO company_submissions (
submission_id, name, slug, description, company_type, person_type,
founded_year, founded_date, founded_date_precision,
headquarters_location, website_url, logo_url,
banner_image_url, banner_image_id, card_image_url, card_image_id
) VALUES (
v_submission_id,
v_item_data->>'name',
v_item_data->>'slug',
v_item_data->>'description',
v_item_type,
COALESCE(v_item_data->>'person_type', 'company'),
(v_item_data->>'founded_year')::INTEGER,
(v_item_data->>'founded_date')::DATE,
v_item_data->>'founded_date_precision',
v_item_data->>'headquarters_location',
v_item_data->>'website_url',
v_item_data->>'logo_url',
v_item_data->>'banner_image_url',
v_item_data->>'banner_image_id',
v_item_data->>'card_image_url',
v_item_data->>'card_image_id'
) RETURNING id INTO v_company_submission_id;
ELSIF v_item_type = 'ride' THEN
INSERT INTO ride_submissions (
submission_id, name, slug, description, category, status,
park_id, manufacturer_id, designer_id, ride_model_id,
opening_date, opening_date_precision, closing_date, closing_date_precision,
height_requirement_cm, age_requirement, max_speed_kmh, duration_seconds,
capacity_per_hour, gforce_max, inversions_count, length_meters,
height_meters, drop_meters,
banner_image_url, banner_image_id, card_image_url, card_image_id, image_url,
ride_sub_type, coaster_type, seating_type, intensity_level
) VALUES (
v_submission_id,
v_item_data->>'name',
v_item_data->>'slug',
v_item_data->>'description',
v_item_data->>'category',
v_item_data->>'status',
(v_item_data->>'park_id')::UUID,
(v_item_data->>'manufacturer_id')::UUID,
(v_item_data->>'designer_id')::UUID,
(v_item_data->>'ride_model_id')::UUID,
(v_item_data->>'opening_date')::DATE,
v_item_data->>'opening_date_precision',
(v_item_data->>'closing_date')::DATE,
v_item_data->>'closing_date_precision',
(v_item_data->>'height_requirement_cm')::NUMERIC,
(v_item_data->>'age_requirement')::INTEGER,
(v_item_data->>'max_speed_kmh')::NUMERIC,
(v_item_data->>'duration_seconds')::INTEGER,
(v_item_data->>'capacity_per_hour')::INTEGER,
(v_item_data->>'gforce_max')::NUMERIC,
(v_item_data->>'inversions_count')::INTEGER,
(v_item_data->>'length_meters')::NUMERIC,
(v_item_data->>'height_meters')::NUMERIC,
(v_item_data->>'drop_meters')::NUMERIC,
v_item_data->>'banner_image_url',
v_item_data->>'banner_image_id',
v_item_data->>'card_image_url',
v_item_data->>'card_image_id',
v_item_data->>'image_url',
v_item_data->>'ride_sub_type',
v_item_data->>'coaster_type',
v_item_data->>'seating_type',
v_item_data->>'intensity_level'
) RETURNING id INTO v_ride_submission_id;
ELSIF v_item_type = 'ride_model' THEN
INSERT INTO ride_model_submissions (
submission_id, name, slug, manufacturer_id, category, ride_type, description,
banner_image_url, banner_image_id, card_image_url, card_image_id
) VALUES (
v_submission_id,
v_item_data->>'name',
v_item_data->>'slug',
(v_item_data->>'manufacturer_id')::UUID,
v_item_data->>'category',
v_item_data->>'ride_type',
v_item_data->>'description',
v_item_data->>'banner_image_url',
v_item_data->>'banner_image_id',
v_item_data->>'card_image_url',
v_item_data->>'card_image_id'
) RETURNING id INTO v_ride_model_submission_id;
ELSIF v_item_type = 'photo' THEN
INSERT INTO photo_submissions (
submission_id, entity_type, entity_id, title
) VALUES (
v_submission_id,
v_item_data->>'entity_type',
(v_item_data->>'entity_id')::UUID,
v_item_data->>'title'
) RETURNING id INTO v_photo_submission_id;
ELSIF v_item_type IN ('timeline_event', 'milestone') THEN
INSERT INTO timeline_event_submissions (
submission_id, entity_type, entity_id, event_type, event_date,
event_date_precision, title, description
) VALUES (
v_submission_id,
v_item_data->>'entity_type',
(v_item_data->>'entity_id')::UUID,
v_item_data->>'event_type',
(v_item_data->>'event_date')::DATE,
v_item_data->>'event_date_precision',
v_item_data->>'title',
v_item_data->>'description'
) RETURNING id INTO v_timeline_event_submission_id;
END IF;
-- Insert submission_item with proper foreign key linkage
INSERT INTO submission_items (
submission_id,
item_type,
action_type,
park_submission_id,
company_submission_id,
ride_submission_id,
ride_model_submission_id,
photo_submission_id,
timeline_event_submission_id,
status,
order_index,
depends_on
) VALUES (
v_submission_id,
v_item_type,
v_action_type,
v_park_submission_id,
v_company_submission_id,
v_ride_submission_id,
v_ride_model_submission_id,
v_photo_submission_id,
v_timeline_event_submission_id,
'pending',
COALESCE((v_item->>'order_index')::INTEGER, 0),
(v_item->>'depends_on')::UUID
) RETURNING id INTO v_submission_item_id;
-- Extract and store temp refs from item_data
IF v_item_data IS NOT NULL AND jsonb_typeof(v_item_data) = 'object' THEN
FOR v_temp_ref_key, v_temp_ref_value IN
SELECT key, value::text
FROM jsonb_each_text(v_item_data)
WHERE key LIKE '_temp_%_ref'
AND value ~ '^\d+$'
LOOP
BEGIN
-- Extract ref_type from key (e.g., "_temp_operator_ref" -> "operator")
v_ref_type := substring(v_temp_ref_key from '_temp_(.+)_ref');
v_ref_order_index := v_temp_ref_value::INTEGER;
-- Insert temp ref record
INSERT INTO submission_item_temp_refs (
submission_item_id,
ref_type,
ref_order_index
) VALUES (
v_submission_item_id,
v_ref_type,
v_ref_order_index
);
RAISE NOTICE 'Stored temp ref: item_id=%, ref_type=%, order_index=%',
v_submission_item_id, v_ref_type, v_ref_order_index;
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Failed to store temp ref % for item %: %',
v_temp_ref_key, v_submission_item_id, SQLERRM;
END;
END LOOP;
END IF;
END LOOP;
RETURN v_submission_id;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Submission creation failed for user % (type=%): %', p_user_id, p_submission_type, SQLERRM;
RAISE;
END;
$$;