feat: Implement composite submission system

This commit is contained in:
gpt-engineer-app[bot]
2025-11-02 19:51:20 +00:00
parent 0f742f36b6
commit 3c6d6a3bdf
5 changed files with 422 additions and 11 deletions

View File

@@ -9,6 +9,26 @@ import { logger } from './logger';
import { getErrorMessage } from './errorHandler';
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
// ============================================
// COMPOSITE SUBMISSION TYPES
// ============================================
interface CompositeSubmissionDependency {
type: 'park' | 'ride' | 'company' | 'ride_model';
data: any;
tempId: string;
companyType?: 'manufacturer' | 'designer' | 'operator' | 'property_owner';
parentTempId?: string; // For linking ride_model to manufacturer
}
interface CompositeSubmissionData {
primaryEntity: {
type: 'park' | 'ride';
data: any;
};
dependencies: CompositeSubmissionDependency[];
}
/**
* ═══════════════════════════════════════════════════════════════════
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
@@ -154,6 +174,172 @@ export interface RideModelFormData {
card_image_id?: string;
}
/**
* ═══════════════════════════════════════════════════════════════════
* COMPOSITE SUBMISSION HANDLER
* ═══════════════════════════════════════════════════════════════════
*
* Creates a single submission containing multiple related entities
* (e.g., Park + Operator, Ride + Manufacturer + Model)
*
* All entities are submitted atomically through the moderation queue,
* with dependency tracking to ensure correct approval order.
*
* @param primaryEntity - The main entity being created (park or ride)
* @param dependencies - Array of dependent entities (companies, models)
* @param userId - The ID of the user submitting
* @returns Object containing submitted boolean and submissionId
*/
async function submitCompositeCreation(
primaryEntity: { type: 'park' | 'ride'; data: any },
dependencies: CompositeSubmissionDependency[],
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 all pending images for all entities
const uploadedEntities = await Promise.all([
...dependencies.map(async (dep) => {
if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) {
const uploadedImages = await uploadPendingImages(dep.data.images.uploaded);
return {
...dep,
data: {
...dep.data,
images: { ...dep.data.images, uploaded: uploadedImages }
}
};
}
return dep;
}),
(async () => {
if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) {
const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded);
return {
...primaryEntity,
data: {
...primaryEntity.data,
images: { ...primaryEntity.data.images, uploaded: uploadedImages }
}
};
}
return primaryEntity;
})()
]);
const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[];
const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity;
// Build submission items array with dependencies first
const submissionItems: any[] = [];
const tempIdMap = new Map<string, number>(); // Maps tempId to order_index
// Add dependency items (companies, models) first
let orderIndex = 0;
for (const dep of uploadedDependencies) {
const itemType = dep.type === 'company' ? dep.companyType : dep.type;
tempIdMap.set(dep.tempId, orderIndex);
const itemData: any = {
...extractChangedFields(dep.data, {}),
images: dep.data.images as unknown as Json
};
// Add company_type for company entities
if (dep.type === 'company') {
itemData.company_type = dep.companyType;
}
// Add manufacturer dependency for ride models
if (dep.type === 'ride_model' && dep.parentTempId) {
const parentOrderIndex = tempIdMap.get(dep.parentTempId);
if (parentOrderIndex !== undefined) {
itemData._temp_manufacturer_ref = parentOrderIndex;
}
}
submissionItems.push({
item_type: itemType,
action_type: 'create' as const,
item_data: itemData,
status: 'pending' as const,
order_index: orderIndex,
depends_on: null // Dependencies don't have parents
});
orderIndex++;
}
// Add primary entity last
const primaryData: any = {
...extractChangedFields(uploadedPrimary.data, {}),
images: uploadedPrimary.data.images as unknown as Json
};
// Map temporary IDs to order indices for foreign keys
if (uploadedPrimary.type === 'park') {
if (uploadedPrimary.data.operator_id?.startsWith('temp-')) {
const opIndex = tempIdMap.get('temp-operator');
if (opIndex !== undefined) primaryData._temp_operator_ref = opIndex;
delete primaryData.operator_id;
}
if (uploadedPrimary.data.property_owner_id?.startsWith('temp-')) {
const ownerIndex = tempIdMap.get('temp-property-owner');
if (ownerIndex !== undefined) primaryData._temp_property_owner_ref = ownerIndex;
delete primaryData.property_owner_id;
}
} else if (uploadedPrimary.type === 'ride') {
if (uploadedPrimary.data.manufacturer_id?.startsWith('temp-')) {
const mfgIndex = tempIdMap.get('temp-manufacturer');
if (mfgIndex !== undefined) primaryData._temp_manufacturer_ref = mfgIndex;
delete primaryData.manufacturer_id;
}
if (uploadedPrimary.data.ride_model_id?.startsWith('temp-')) {
const modelIndex = tempIdMap.get('temp-ride-model');
if (modelIndex !== undefined) primaryData._temp_ride_model_ref = modelIndex;
delete primaryData.ride_model_id;
}
}
submissionItems.push({
item_type: uploadedPrimary.type,
action_type: 'create' as const,
item_data: primaryData,
status: 'pending' as const,
order_index: orderIndex,
depends_on: null // Will be set by RPC based on refs
});
// Use RPC to create submission with items atomically
const { data: result, error } = await supabase.rpc('create_submission_with_items', {
p_user_id: userId,
p_submission_type: uploadedPrimary.type,
p_content: { action: 'create' } as unknown as Json,
p_items: submissionItems as unknown as Json[]
});
if (error) {
logger.error('Composite submission failed', {
action: 'composite_submission',
primaryType: uploadedPrimary.type,
dependencyCount: dependencies.length,
error: getErrorMessage(error)
});
throw new Error(`Failed to create composite submission: ${getErrorMessage(error)}`);
}
return { submitted: true, submissionId: result };
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
@@ -174,9 +360,41 @@ export interface RideModelFormData {
* @returns Object containing submitted boolean and submissionId
*/
export async function submitParkCreation(
data: ParkFormData,
data: ParkFormData & { _compositeSubmission?: any },
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Check for composite submission with dependencies
if (data._compositeSubmission) {
const dependencies: CompositeSubmissionDependency[] = [];
if (data._compositeSubmission.new_operator) {
dependencies.push({
type: 'company',
data: { ...data._compositeSubmission.new_operator, company_type: 'operator' },
tempId: 'temp-operator',
companyType: 'operator'
});
}
if (data._compositeSubmission.new_property_owner) {
dependencies.push({
type: 'company',
data: { ...data._compositeSubmission.new_property_owner, company_type: 'property_owner' },
tempId: 'temp-property-owner',
companyType: 'property_owner'
});
}
if (dependencies.length > 0) {
return submitCompositeCreation(
{ type: 'park', data: data._compositeSubmission.park },
dependencies,
userId
);
}
}
// Standard single-entity creation
// Check if user is banned
const { data: profile } = await supabase
.from('profiles')
@@ -368,9 +586,41 @@ export async function submitParkUpdate(
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideCreation(
data: RideFormData,
data: RideFormData & { _tempNewManufacturer?: any; _tempNewRideModel?: any },
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Check for composite submission with dependencies
if (data._tempNewManufacturer || data._tempNewRideModel) {
const dependencies: CompositeSubmissionDependency[] = [];
if (data._tempNewManufacturer) {
dependencies.push({
type: 'company',
data: { ...data._tempNewManufacturer, company_type: 'manufacturer' },
tempId: 'temp-manufacturer',
companyType: 'manufacturer'
});
}
if (data._tempNewRideModel) {
dependencies.push({
type: 'ride_model',
data: data._tempNewRideModel,
tempId: 'temp-ride-model',
parentTempId: data._tempNewManufacturer ? 'temp-manufacturer' : undefined
});
}
if (dependencies.length > 0) {
return submitCompositeCreation(
{ type: 'ride', data },
dependencies,
userId
);
}
}
// Standard single-entity creation
// Check if user is banned
const { data: profile } = await supabase
.from('profiles')