mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 11:31:12 -05:00
1414 lines
42 KiB
TypeScript
1414 lines
42 KiB
TypeScript
import { supabase } from '@/integrations/supabase/client';
|
|
import type { Json } from '@/integrations/supabase/types';
|
|
import { ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
|
|
import { uploadPendingImages } from './imageUploadHelper';
|
|
import type { ProcessedImage } from './supabaseHelpers';
|
|
import { extractChangedFields } from './submissionChangeDetection';
|
|
import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data';
|
|
import { logger } from './logger';
|
|
import { getErrorMessage } from './errorHandler';
|
|
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
|
|
|
|
/**
|
|
* ═══════════════════════════════════════════════════════════════════
|
|
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
|
|
* ═══════════════════════════════════════════════════════════════════
|
|
*
|
|
* ⚠️ NEVER STORE JSON IN SQL COLUMNS ⚠️
|
|
*
|
|
* content_submissions.content should ONLY contain:
|
|
* ✅ action: 'create' | 'edit' | 'delete'
|
|
* ✅ Minimal reference IDs (entity_id, parent_id, etc.) - MAX 3 fields
|
|
* ❌ NO actual form data
|
|
* ❌ NO submission content
|
|
* ❌ NO large objects
|
|
*
|
|
* ALL actual data MUST go in:
|
|
* ✅ submission_items.item_data (new data)
|
|
* ✅ submission_items.original_data (for edits)
|
|
* ✅ Specialized relational tables:
|
|
* - photo_submissions + photo_submission_items
|
|
* - park_submissions
|
|
* - ride_submissions
|
|
* - company_submissions
|
|
* - ride_model_submissions
|
|
*
|
|
* If your data is relational, model it relationally.
|
|
* JSON blobs destroy:
|
|
* - Queryability (can't filter/join)
|
|
* - Performance (slower, larger)
|
|
* - Data integrity (no constraints)
|
|
* - Maintainability (impossible to refactor)
|
|
*
|
|
* EXAMPLES:
|
|
*
|
|
* ✅ CORRECT:
|
|
* content: { action: 'create' }
|
|
* content: { action: 'edit', park_id: uuid }
|
|
* content: { action: 'delete', photo_id: uuid }
|
|
*
|
|
* ❌ WRONG:
|
|
* content: { name: '...', description: '...', ...formData }
|
|
* content: { photos: [...], metadata: {...} }
|
|
* content: data // entire object dump
|
|
*
|
|
* ═══════════════════════════════════════════════════════════════════
|
|
*/
|
|
|
|
export interface ParkFormData {
|
|
name: string;
|
|
slug: string;
|
|
description?: string;
|
|
park_type: string;
|
|
status: string;
|
|
opening_date?: string;
|
|
closing_date?: string;
|
|
website_url?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
operator_id?: string;
|
|
property_owner_id?: string;
|
|
|
|
// Location can be stored as object for new submissions or ID for editing
|
|
location?: {
|
|
name: string;
|
|
city?: string;
|
|
state_province?: string;
|
|
country: string;
|
|
postal_code?: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
timezone?: string;
|
|
display_name: string;
|
|
};
|
|
location_id?: string;
|
|
|
|
images?: ImageAssignments;
|
|
banner_image_url?: string;
|
|
banner_image_id?: string;
|
|
card_image_url?: string;
|
|
card_image_id?: string;
|
|
}
|
|
|
|
export interface RideFormData {
|
|
name: string;
|
|
slug: string;
|
|
description?: string;
|
|
category: string;
|
|
status: string;
|
|
park_id: string;
|
|
manufacturer_id?: string;
|
|
designer_id?: string;
|
|
ride_model_id?: string;
|
|
opening_date?: string;
|
|
closing_date?: string;
|
|
max_speed_kmh?: number;
|
|
max_height_meters?: number;
|
|
length_meters?: number;
|
|
duration_seconds?: number;
|
|
capacity_per_hour?: number;
|
|
height_requirement?: number;
|
|
age_requirement?: number;
|
|
inversions?: number;
|
|
drop_height_meters?: number;
|
|
max_g_force?: number;
|
|
intensity_level?: string;
|
|
coaster_type?: string;
|
|
seating_type?: string;
|
|
ride_sub_type?: string;
|
|
images?: ImageAssignments;
|
|
banner_image_url?: string;
|
|
banner_image_id?: string;
|
|
card_image_url?: string;
|
|
card_image_id?: string;
|
|
}
|
|
|
|
export interface CompanyFormData {
|
|
name: string;
|
|
slug: string;
|
|
description?: string;
|
|
person_type: 'company' | 'individual' | 'firm' | 'organization';
|
|
founded_year?: number;
|
|
founded_date?: string;
|
|
founded_date_precision?: string;
|
|
headquarters_location?: string;
|
|
website_url?: string;
|
|
images?: ImageAssignments;
|
|
banner_image_url?: string;
|
|
banner_image_id?: string;
|
|
card_image_url?: string;
|
|
card_image_id?: string;
|
|
}
|
|
|
|
export interface RideModelFormData {
|
|
name: string;
|
|
slug: string;
|
|
manufacturer_id: string;
|
|
category: string;
|
|
ride_type?: string;
|
|
description?: string;
|
|
images?: ImageAssignments;
|
|
banner_image_url?: string;
|
|
banner_image_id?: string;
|
|
card_image_url?: string;
|
|
card_image_id?: string;
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits a new park for creation through the moderation queue.
|
|
* This is the ONLY correct way to create parks.
|
|
*
|
|
* DO NOT use direct database inserts:
|
|
* ❌ await supabase.from('parks').insert(data) // BYPASSES MODERATION!
|
|
* ✅ await submitParkCreation(data, userId) // CORRECT
|
|
*
|
|
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
|
|
*
|
|
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
|
|
*
|
|
* @see docs/SUBMISSION_FLOW.md for complete documentation
|
|
* @param data - The park form data to submit
|
|
* @param userId - The ID of the user submitting the park
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitParkCreation(
|
|
data: ParkFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile?.banned) {
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Park image upload failed', {
|
|
action: 'park_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'park',
|
|
content: {
|
|
action: 'create'
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual park data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'park',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits an update to an existing park through the moderation queue.
|
|
* This is the ONLY correct way to update parks.
|
|
*
|
|
* DO NOT use direct database updates:
|
|
* ❌ await supabase.from('parks').update(data) // BYPASSES MODERATION!
|
|
* ✅ await submitParkUpdate(parkId, data, userId) // CORRECT
|
|
*
|
|
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
|
|
*
|
|
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
|
|
*
|
|
* @see docs/SUBMISSION_FLOW.md for complete documentation
|
|
* @param parkId - The ID of the park to update
|
|
* @param data - The updated park form data
|
|
* @param userId - The ID of the user submitting the update
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitParkUpdate(
|
|
parkId: string,
|
|
data: ParkFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile?.banned) {
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Fetch existing park data first
|
|
const { data: existingPark, error: fetchError } = await supabase
|
|
.from('parks')
|
|
.select('id, name, slug, description, park_type, status, opening_date, opening_date_precision, closing_date, closing_date_precision, website_url, phone, email, location_id, operator_id, property_owner_id, banner_image_url, banner_image_id, card_image_url, card_image_id')
|
|
.eq('id', parkId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch park: ${fetchError.message}`);
|
|
if (!existingPark) throw new Error('Park not found');
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Park image upload failed', {
|
|
action: 'park_update',
|
|
parkId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'park',
|
|
content: {
|
|
action: 'edit',
|
|
park_id: parkId
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual park data AND original data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'park',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingPark),
|
|
park_id: parkId, // Always include for relational integrity
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingPark)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits a new ride for creation through the moderation queue.
|
|
* This is the ONLY correct way to create rides.
|
|
*
|
|
* DO NOT use direct database inserts:
|
|
* ❌ await supabase.from('rides').insert(data) // BYPASSES MODERATION!
|
|
* ✅ await submitRideCreation(data, userId) // CORRECT
|
|
*
|
|
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
|
|
*
|
|
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
|
|
*
|
|
* @see docs/SUBMISSION_FLOW.md for complete documentation
|
|
* @param data - The ride form data to submit
|
|
* @param userId - The ID of the user submitting the ride
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitRideCreation(
|
|
data: RideFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile?.banned) {
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Ride image upload failed', {
|
|
action: 'ride_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'ride',
|
|
content: {
|
|
action: 'create'
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual ride data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'ride',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits an update to an existing ride through the moderation queue.
|
|
* This is the ONLY correct way to update rides.
|
|
*
|
|
* DO NOT use direct database updates:
|
|
* ❌ await supabase.from('rides').update(data) // BYPASSES MODERATION!
|
|
* ✅ await submitRideUpdate(rideId, data, userId) // CORRECT
|
|
*
|
|
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
|
|
*
|
|
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
|
|
*
|
|
* @see docs/SUBMISSION_FLOW.md for complete documentation
|
|
* @param rideId - The ID of the ride to update
|
|
* @param data - The updated ride form data
|
|
* @param userId - The ID of the user submitting the update
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitRideUpdate(
|
|
rideId: string,
|
|
data: RideFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile?.banned) {
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Fetch existing ride data first
|
|
const { data: existingRide, error: fetchError } = await supabase
|
|
.from('rides')
|
|
.select('*')
|
|
.eq('id', rideId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch ride: ${fetchError.message}`);
|
|
if (!existingRide) throw new Error('Ride not found');
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Ride image upload failed', {
|
|
action: 'ride_update',
|
|
rideId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'ride',
|
|
content: {
|
|
action: 'edit',
|
|
ride_id: rideId
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual ride data AND original data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'ride',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingRide),
|
|
ride_id: rideId, // Always include for relational integrity
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingRide)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits a new ride model for creation through the moderation queue.
|
|
* This is the ONLY correct way to create ride models.
|
|
*
|
|
* DO NOT use direct database inserts:
|
|
* ❌ await supabase.from('ride_models').insert(data) // BYPASSES MODERATION!
|
|
* ✅ await submitRideModelCreation(data, userId) // CORRECT
|
|
*
|
|
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
|
|
*
|
|
* @param data - The ride model form data to submit
|
|
* @param userId - The ID of the user submitting the ride model
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitRideModelCreation(
|
|
data: RideModelFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile?.banned) {
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Ride model image upload failed', {
|
|
action: 'ride_model_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'ride_model',
|
|
content: {
|
|
action: 'create'
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual ride model data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'ride_model',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits a ride model update through the moderation queue.
|
|
* This is the ONLY correct way to update ride models.
|
|
*/
|
|
export async function submitRideModelUpdate(
|
|
rideModelId: string,
|
|
data: RideModelFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Check if user is banned
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile?.banned) {
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Fetch existing ride model
|
|
const { data: existingModel, error: fetchError } = await supabase
|
|
.from('ride_models')
|
|
.select('*')
|
|
.eq('id', rideModelId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch ride model: ${fetchError.message}`);
|
|
if (!existingModel) throw new Error('Ride model not found');
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Ride model image upload failed', {
|
|
action: 'ride_model_update',
|
|
rideModelId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'ride_model',
|
|
content: {
|
|
action: 'edit',
|
|
ride_model_id: rideModelId
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual ride model data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'ride_model',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingModel),
|
|
ride_model_id: rideModelId, // Always include for relational integrity
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingModel)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits a new manufacturer for creation through the moderation queue.
|
|
*/
|
|
export async function submitManufacturerCreation(
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'manufacturer_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'manufacturer',
|
|
content: { action: 'create' },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'manufacturer',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
company_type: 'manufacturer',
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitManufacturerUpdate(
|
|
companyId: string,
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
const { data: existingCompany, error: fetchError } = await supabase
|
|
.from('companies')
|
|
.select('*')
|
|
.eq('id', companyId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch manufacturer: ${fetchError.message}`);
|
|
if (!existingCompany) throw new Error('Manufacturer not found');
|
|
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'manufacturer_update',
|
|
companyId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'manufacturer',
|
|
content: { action: 'edit', company_id: companyId },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'manufacturer',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
|
|
company_id: companyId, // Always include for relational integrity
|
|
company_type: 'manufacturer', // Always include for entity type discrimination
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitDesignerCreation(
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'designer_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'designer',
|
|
content: { action: 'create' },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'designer',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
company_type: 'designer',
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitDesignerUpdate(
|
|
companyId: string,
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
const { data: existingCompany, error: fetchError } = await supabase
|
|
.from('companies')
|
|
.select('*')
|
|
.eq('id', companyId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch designer: ${fetchError.message}`);
|
|
if (!existingCompany) throw new Error('Designer not found');
|
|
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'designer_update',
|
|
companyId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'designer',
|
|
content: { action: 'edit', company_id: companyId },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'designer',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
|
|
company_id: companyId, // Always include for relational integrity
|
|
company_type: 'designer', // Always include for entity type discrimination
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitOperatorCreation(
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'operator_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'operator',
|
|
content: { action: 'create' },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'operator',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
company_type: 'operator',
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitOperatorUpdate(
|
|
companyId: string,
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
const { data: existingCompany, error: fetchError } = await supabase
|
|
.from('companies')
|
|
.select('*')
|
|
.eq('id', companyId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch operator: ${fetchError.message}`);
|
|
if (!existingCompany) throw new Error('Operator not found');
|
|
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'operator_update',
|
|
companyId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'operator',
|
|
content: { action: 'edit', company_id: companyId },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'operator',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
|
|
company_id: companyId, // Always include for relational integrity
|
|
company_type: 'operator', // Always include for entity type discrimination
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitPropertyOwnerCreation(
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'property_owner_creation',
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'property_owner',
|
|
content: { action: 'create' },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'property_owner',
|
|
action_type: 'create',
|
|
item_data: {
|
|
...extractChangedFields(data, {}),
|
|
company_type: 'property_owner',
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
export async function submitPropertyOwnerUpdate(
|
|
companyId: string,
|
|
data: CompanyFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
const { data: existingCompany, error: fetchError } = await supabase
|
|
.from('companies')
|
|
.select('*')
|
|
.eq('id', companyId)
|
|
.single();
|
|
|
|
if (fetchError) throw new Error(`Failed to fetch property owner: ${fetchError.message}`);
|
|
if (!existingCompany) throw new Error('Property owner not found');
|
|
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = { ...data.images, uploaded: uploadedImages };
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Company image upload failed', {
|
|
action: 'property_owner_update',
|
|
companyId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error(`Failed to upload images: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: 'property_owner',
|
|
content: { action: 'edit', company_id: companyId },
|
|
status: 'pending' as const
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: 'property_owner',
|
|
action_type: 'edit',
|
|
item_data: {
|
|
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
|
|
company_id: companyId, // Always include for relational integrity
|
|
company_type: 'property_owner', // Always include for entity type discrimination
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits a new timeline event for an entity through the moderation queue.
|
|
*
|
|
* DO NOT write directly to entity_timeline_events:
|
|
* ❌ await supabase.from('entity_timeline_events').insert(data) // BYPASSES MODERATION!
|
|
* ✅ await submitTimelineEvent(entityType, entityId, data, userId) // CORRECT
|
|
*
|
|
* Flow: User Submit → Moderation Queue → Approval → Database Write
|
|
*
|
|
* @param entityType - Type of entity (park, ride, company)
|
|
* @param entityId - ID of the entity
|
|
* @param data - The timeline event form data
|
|
* @param userId - The ID of the user submitting
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitTimelineEvent(
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
data: TimelineEventFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Validate user
|
|
if (!userId) {
|
|
throw new Error('User ID is required for timeline event submission');
|
|
}
|
|
|
|
// Create submission content (minimal reference data only)
|
|
const content: Json = {
|
|
action: 'create',
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
};
|
|
|
|
// Create the main submission record
|
|
// Use atomic RPC function to create submission + items in transaction
|
|
const itemData: Record<string, any> = {
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
event_type: data.event_type,
|
|
event_date: data.event_date.toISOString().split('T')[0],
|
|
event_date_precision: data.event_date_precision,
|
|
title: data.title,
|
|
description: data.description,
|
|
from_value: data.from_value,
|
|
to_value: data.to_value,
|
|
from_entity_id: data.from_entity_id,
|
|
to_entity_id: data.to_entity_id,
|
|
from_location_id: data.from_location_id,
|
|
to_location_id: data.to_location_id,
|
|
is_public: true,
|
|
};
|
|
|
|
const items = [{
|
|
item_type: 'timeline_event',
|
|
action_type: 'create',
|
|
item_data: itemData,
|
|
order_index: 0,
|
|
}];
|
|
|
|
const { data: submissionId, error } = await supabase
|
|
.rpc('create_submission_with_items', {
|
|
p_user_id: userId,
|
|
p_submission_type: 'timeline_event',
|
|
p_content: content,
|
|
p_items: items as unknown as Json[],
|
|
});
|
|
|
|
if (error || !submissionId) {
|
|
const errorMessage = getErrorMessage(error);
|
|
logger.error('Timeline event submission failed', {
|
|
action: 'create_timeline_event',
|
|
userId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error('Failed to submit timeline event for review');
|
|
}
|
|
|
|
return {
|
|
submitted: true,
|
|
submissionId: submissionId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
|
*
|
|
* Submits an update to an existing timeline event through the moderation queue.
|
|
*
|
|
* @param eventId - ID of the existing timeline event
|
|
* @param data - The updated timeline event data
|
|
* @param userId - The ID of the user submitting the update
|
|
* @returns Object containing submitted boolean and submissionId
|
|
*/
|
|
export async function submitTimelineEventUpdate(
|
|
eventId: string,
|
|
data: TimelineEventFormData,
|
|
userId: string
|
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
|
// Fetch original event
|
|
const { data: originalEvent, error: fetchError } = await supabase
|
|
.from('entity_timeline_events')
|
|
.select('*')
|
|
.eq('id', eventId)
|
|
.single();
|
|
|
|
if (fetchError || !originalEvent) {
|
|
throw new Error('Failed to fetch original timeline event');
|
|
}
|
|
|
|
// Extract only changed fields from form data
|
|
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
|
|
|
|
const itemData: Record<string, unknown> = {
|
|
...changedFields,
|
|
// Always include entity reference (for FK integrity)
|
|
entity_type: originalEvent.entity_type,
|
|
entity_id: originalEvent.entity_id,
|
|
is_public: true,
|
|
};
|
|
|
|
// Use atomic RPC function to create submission and item together
|
|
const { data: result, error: rpcError } = await supabase.rpc(
|
|
'create_submission_with_items',
|
|
{
|
|
p_user_id: userId,
|
|
p_submission_type: 'timeline_event',
|
|
p_content: {
|
|
action: 'edit',
|
|
event_id: eventId,
|
|
entity_type: originalEvent.entity_type,
|
|
} as unknown as Json,
|
|
p_items: [
|
|
{
|
|
item_type: 'timeline_event',
|
|
action_type: 'edit',
|
|
item_data: itemData,
|
|
original_data: originalEvent,
|
|
status: 'pending' as const,
|
|
order_index: 0,
|
|
}
|
|
] as unknown as Json[],
|
|
}
|
|
);
|
|
|
|
if (rpcError || !result) {
|
|
const errorMessage = getErrorMessage(rpcError);
|
|
logger.error('Timeline event update failed', {
|
|
action: 'update_timeline_event',
|
|
eventId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error('Failed to submit timeline event update');
|
|
}
|
|
|
|
return {
|
|
submitted: true,
|
|
submissionId: result,
|
|
};
|
|
}
|
|
|
|
|
|
export async function deleteTimelineEvent(
|
|
eventId: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
// First verify the event exists and user has permission
|
|
const { data: event, error: fetchError } = await supabase
|
|
.from('entity_timeline_events')
|
|
.select('created_by, approved_by')
|
|
.eq('id', eventId)
|
|
.single();
|
|
|
|
if (fetchError) {
|
|
const errorMessage = getErrorMessage(fetchError);
|
|
logger.error('Timeline event fetch failed', {
|
|
action: 'delete_timeline_event',
|
|
eventId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error('Timeline event not found');
|
|
}
|
|
|
|
if (!event) {
|
|
throw new Error('Timeline event not found');
|
|
}
|
|
|
|
// Only allow deletion of own unapproved events
|
|
if (event.created_by !== userId) {
|
|
throw new Error('You can only delete your own timeline events');
|
|
}
|
|
|
|
if (event.approved_by !== null) {
|
|
throw new Error('Cannot delete approved timeline events');
|
|
}
|
|
|
|
// Delete the event
|
|
const { error: deleteError } = await supabase
|
|
.from('entity_timeline_events')
|
|
.delete()
|
|
.eq('id', eventId);
|
|
|
|
if (deleteError) {
|
|
const errorMessage = getErrorMessage(deleteError);
|
|
logger.error('Timeline event deletion failed', {
|
|
action: 'delete_timeline_event',
|
|
eventId,
|
|
error: errorMessage
|
|
});
|
|
throw new Error('Failed to delete timeline event');
|
|
}
|
|
|
|
logger.info('Timeline event deleted', {
|
|
action: 'delete_timeline_event',
|
|
eventId,
|
|
userId
|
|
});
|
|
}
|