Files
thrilltrack-explorer/src/lib/submissionItemsService.ts
2025-09-30 14:06:03 +00:00

777 lines
21 KiB
TypeScript

import { supabase } from '@/integrations/supabase/client';
export interface SubmissionItemWithDeps {
id: string;
submission_id: string;
item_type: string;
item_data: any;
original_data: any;
status: 'pending' | 'approved' | 'rejected';
depends_on: string | null;
order_index: number;
approved_entity_id: string | null;
rejection_reason: string | null;
created_at: string;
updated_at: string;
dependencies?: SubmissionItemWithDeps[];
dependents?: SubmissionItemWithDeps[];
}
export interface DependencyConflict {
itemId: string;
type: 'missing_parent' | 'rejected_parent' | 'circular_dependency';
message: string;
suggestions: Array<{
action: 'link_existing' | 'cascade_reject' | 'escalate' | 'create_parent';
label: string;
entityId?: string;
}>;
}
/**
* Fetch all items for a submission with their dependencies
*/
export async function fetchSubmissionItems(submissionId: string): Promise<SubmissionItemWithDeps[]> {
const { data, error } = await supabase
.from('submission_items')
.select('*')
.eq('submission_id', submissionId)
.order('order_index', { ascending: true });
if (error) throw error;
// Cast the data to the correct type
return (data || []).map(item => ({
...item,
status: item.status as 'pending' | 'approved' | 'rejected',
})) as SubmissionItemWithDeps[];
}
/**
* Build dependency tree for submission items
*/
export function buildDependencyTree(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
const itemMap = new Map(items.map(item => [item.id, { ...item, dependencies: [], dependents: [] }]));
// Build relationships
items.forEach(item => {
if (item.depends_on) {
const parent = itemMap.get(item.depends_on);
const child = itemMap.get(item.id);
if (parent && child) {
parent.dependents = parent.dependents || [];
parent.dependents.push(child);
child.dependencies = child.dependencies || [];
child.dependencies.push(parent);
}
}
});
return Array.from(itemMap.values());
}
/**
* Detect dependency conflicts for selective approval
*/
export async function detectDependencyConflicts(
items: SubmissionItemWithDeps[],
selectedItemIds: string[]
): Promise<DependencyConflict[]> {
const conflicts: DependencyConflict[] = [];
const selectedSet = new Set(selectedItemIds);
for (const item of items) {
// Check if parent is rejected but child is selected
if (item.depends_on && selectedSet.has(item.id)) {
const parent = items.find(i => i.id === item.depends_on);
if (parent && (parent.status === 'rejected' || !selectedSet.has(parent.id))) {
// Find existing entities that could be linked
const suggestions: DependencyConflict['suggestions'] = [];
// Suggest creating parent
if (parent.status !== 'rejected') {
suggestions.push({
action: 'create_parent',
label: `Also approve ${parent.item_type}: ${parent.item_data.name}`,
});
}
// Suggest linking to existing entity based on type
if (parent.item_type === 'park') {
const { data: parks } = await supabase
.from('parks')
.select('id, name')
.ilike('name', `%${parent.item_data.name}%`)
.limit(3);
parks?.forEach(park => {
suggestions.push({
action: 'link_existing',
label: `Link to existing park: ${park.name}`,
entityId: park.id,
});
});
} else if (['manufacturer', 'operator', 'property_owner', 'designer'].includes(parent.item_type)) {
const { data: companies } = await supabase
.from('companies')
.select('id, name')
.eq('company_type', parent.item_type)
.ilike('name', `%${parent.item_data.name}%`)
.limit(3);
companies?.forEach(company => {
suggestions.push({
action: 'link_existing',
label: `Link to existing ${parent.item_type}: ${company.name}`,
entityId: company.id,
});
});
} else if (parent.item_type === 'ride_model') {
const { data: models } = await supabase
.from('ride_models')
.select('id, name')
.ilike('name', `%${parent.item_data.name}%`)
.limit(3);
models?.forEach(model => {
suggestions.push({
action: 'link_existing',
label: `Link to existing ride model: ${model.name}`,
entityId: model.id,
});
});
}
suggestions.push({
action: 'escalate',
label: 'Escalate to admin for resolution',
});
conflicts.push({
itemId: item.id,
type: parent.status === 'rejected' ? 'rejected_parent' : 'missing_parent',
message: `Cannot approve ${item.item_type} without its parent ${parent.item_type}`,
suggestions,
});
}
}
}
// Check for circular dependencies
const circularDeps = detectCircularDependencies(items);
circularDeps.forEach(itemId => {
conflicts.push({
itemId,
type: 'circular_dependency',
message: 'Circular dependency detected in submission items',
suggestions: [
{
action: 'escalate',
label: 'Escalate for manual review',
},
],
});
});
return conflicts;
}
/**
* Detect circular dependencies
*/
function detectCircularDependencies(items: SubmissionItemWithDeps[]): string[] {
const circular: string[] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
function hasCycle(itemId: string): boolean {
visited.add(itemId);
recursionStack.add(itemId);
const item = items.find(i => i.id === itemId);
if (item?.depends_on) {
if (!visited.has(item.depends_on)) {
if (hasCycle(item.depends_on)) {
return true;
}
} else if (recursionStack.has(item.depends_on)) {
return true;
}
}
recursionStack.delete(itemId);
return false;
}
items.forEach(item => {
if (!visited.has(item.id) && hasCycle(item.id)) {
circular.push(item.id);
}
});
return circular;
}
/**
* Link submission item to existing entity
*/
export async function linkToExistingEntity(
itemId: string,
entityId: string,
entityType: string
): Promise<void> {
const { data: item, error: fetchError } = await supabase
.from('submission_items')
.select('*')
.eq('id', itemId)
.single();
if (fetchError || !item) throw fetchError || new Error('Item not found');
// Update item_data to mark it as linked to existing entity
const currentItemData = (item.item_data as any) || {};
const updatedItemData = {
...currentItemData,
_linked_entity_id: entityId,
_skip_creation: true,
};
const { error: updateError } = await supabase
.from('submission_items')
.update({
item_data: updatedItemData,
approved_entity_id: entityId,
updated_at: new Date().toISOString(),
})
.eq('id', itemId);
if (updateError) throw updateError;
}
/**
* Resolve conflicts based on user selections
*/
export async function resolveConflicts(
conflicts: DependencyConflict[],
resolutions: Record<string, string>,
items: SubmissionItemWithDeps[],
userId: string
): Promise<{ updatedItems: SubmissionItemWithDeps[]; newConflicts: DependencyConflict[] }> {
if (!userId) {
throw new Error('User authentication required to resolve conflicts');
}
const updatedItems = [...items];
const newConflicts: DependencyConflict[] = [];
for (const conflict of conflicts) {
const resolution = resolutions[conflict.itemId];
if (!resolution) continue;
const suggestion = conflict.suggestions.find(s => s.action === resolution);
if (!suggestion) continue;
try {
switch (suggestion.action) {
case 'create_parent': {
// Add parent item to be approved along with child
const item = items.find(i => i.id === conflict.itemId);
if (item?.depends_on) {
const parent = items.find(i => i.id === item.depends_on);
if (parent && parent.status === 'pending') {
// Mark parent for approval (will be handled in approval flow)
const idx = updatedItems.findIndex(i => i.id === parent.id);
if (idx >= 0) {
updatedItems[idx] = {
...updatedItems[idx],
status: 'pending' as const
};
}
}
}
break;
}
case 'link_existing': {
// Link to existing entity
if (suggestion.entityId) {
const item = items.find(i => i.id === conflict.itemId);
if (item?.depends_on) {
await linkToExistingEntity(item.depends_on, suggestion.entityId, item.item_type);
// Update local copy
const parentIdx = updatedItems.findIndex(i => i.id === item.depends_on);
if (parentIdx >= 0) {
updatedItems[parentIdx] = {
...updatedItems[parentIdx],
approved_entity_id: suggestion.entityId,
item_data: {
...updatedItems[parentIdx].item_data,
_linked_entity_id: suggestion.entityId,
_skip_creation: true,
},
};
}
}
}
break;
}
case 'escalate': {
// Escalate submission for admin review
const item = items.find(i => i.id === conflict.itemId);
if (item?.submission_id) {
await escalateSubmission(
item.submission_id,
`Dependency conflict: ${conflict.message}`,
userId
);
}
break;
}
case 'cascade_reject': {
// This would reject the item and its dependents
const item = items.find(i => i.id === conflict.itemId);
if (item) {
await rejectSubmissionItems(
[item],
'Rejected due to parent dependency conflict',
userId,
true
);
}
break;
}
}
} catch (error) {
console.error(`Error resolving conflict for item ${conflict.itemId}:`, error);
newConflicts.push({
...conflict,
message: `Failed to resolve: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
}
return { updatedItems, newConflicts };
}
/**
* Edit a submission item
* Moderators can edit directly, regular users trigger auto-escalation
*/
export async function editSubmissionItem(
itemId: string,
newData: any,
userId: string
): Promise<void> {
// Check user permissions
const { data: userRoles } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', userId);
const isModerator = userRoles?.some(r =>
['moderator', 'admin', 'superuser'].includes(r.role)
);
// Get current item
const { data: currentItem, error: fetchError } = await supabase
.from('submission_items')
.select('*')
.eq('id', itemId)
.single();
if (fetchError || !currentItem) {
throw new Error('Failed to fetch submission item');
}
// Preserve original data if not already saved
const originalData = currentItem.original_data || currentItem.item_data;
if (isModerator) {
// Moderators can edit directly
const { error } = await supabase
.from('submission_items')
.update({
item_data: newData,
original_data: originalData,
updated_at: new Date().toISOString()
})
.eq('id', itemId);
if (error) {
console.error('Error updating submission item:', error);
throw new Error(`Failed to update item: ${error.message}`);
}
// Log the edit
await supabase
.from('admin_audit_log')
.insert({
admin_user_id: userId,
target_user_id: currentItem.submission_id,
action: 'edit_submission_item',
details: {
item_id: itemId,
item_type: currentItem.item_type,
changes: newData
}
});
} else {
// Regular users trigger auto-escalation
const { data: submission } = await supabase
.from('content_submissions')
.select('id, status')
.eq('id', currentItem.submission_id)
.single();
if (submission && submission.status !== 'escalated') {
await escalateSubmission(
currentItem.submission_id,
`User requested edit for ${currentItem.item_type} item`,
userId
);
}
// Update item with edit request
const { error } = await supabase
.from('submission_items')
.update({
item_data: newData,
original_data: originalData,
status: 'pending',
updated_at: new Date().toISOString()
})
.eq('id', itemId);
if (error) {
console.error('Error updating submission item:', error);
throw new Error(`Failed to update item: ${error.message}`);
}
}
}
/**
* Update individual submission item status
*/
export async function updateSubmissionItem(
itemId: string,
updates: Partial<SubmissionItemWithDeps>
): Promise<void> {
const { error } = await supabase
.from('submission_items')
.update(updates)
.eq('id', itemId);
if (error) throw error;
}
/**
* Approve multiple items with dependency handling
*/
export async function approveSubmissionItems(
items: SubmissionItemWithDeps[],
userId: string
): Promise<void> {
if (!userId) {
throw new Error('User authentication required to approve items');
}
// Sort by dependency order (parents first)
const sortedItems = topologicalSort(items);
// Track entity ID mappings for dependency resolution
const entityIdMap = new Map<string, string>();
for (const item of sortedItems) {
let entityId: string | null = null;
// Check if item is linked to existing entity (skip creation)
if (item.item_data._skip_creation && item.item_data._linked_entity_id) {
entityId = item.item_data._linked_entity_id;
entityIdMap.set(item.id, entityId);
} else {
// Resolve dependencies using entityIdMap
const resolvedItemData = resolveDependencies(item.item_data, entityIdMap);
// Create the entity based on type
switch (item.item_type) {
case 'park':
entityId = await createPark(resolvedItemData);
break;
case 'ride':
entityId = await createRide(resolvedItemData);
break;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
entityId = await createCompany(resolvedItemData, item.item_type);
break;
case 'ride_model':
entityId = await createRideModel(resolvedItemData);
break;
case 'photo':
entityId = await approvePhotos(resolvedItemData);
break;
}
if (entityId) {
entityIdMap.set(item.id, entityId);
}
}
// Update item status
await updateSubmissionItem(item.id, {
status: 'approved',
approved_entity_id: entityId,
});
}
}
/**
* Resolve dependencies in item data using entity ID mappings
*/
function resolveDependencies(itemData: any, entityIdMap: Map<string, string>): any {
const resolved = { ...itemData };
// Map common dependency fields
const dependencyFields = [
'park_id',
'manufacturer_id',
'operator_id',
'property_owner_id',
'designer_id',
'ride_model_id',
];
dependencyFields.forEach(field => {
if (resolved[field] && entityIdMap.has(resolved[field])) {
resolved[field] = entityIdMap.get(resolved[field]);
}
});
return resolved;
}
/**
* Topological sort for dependency-ordered processing
*/
function topologicalSort(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
const sorted: SubmissionItemWithDeps[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
function visit(item: SubmissionItemWithDeps) {
if (temp.has(item.id)) {
throw new Error('Circular dependency detected');
}
if (visited.has(item.id)) return;
temp.add(item.id);
if (item.dependencies) {
item.dependencies.forEach(dep => visit(dep));
}
temp.delete(item.id);
visited.add(item.id);
sorted.push(item);
}
items.forEach(item => {
if (!visited.has(item.id)) {
visit(item);
}
});
return sorted;
}
/**
* Helper functions to create entities
*/
async function createPark(data: any): Promise<string> {
const { data: park, error } = await supabase
.from('parks')
.insert(data)
.select('id')
.single();
if (error) throw error;
return park.id;
}
async function createRide(data: any): Promise<string> {
const { data: ride, error } = await supabase
.from('rides')
.insert(data)
.select('id')
.single();
if (error) throw error;
return ride.id;
}
async function createCompany(data: any, companyType: string): Promise<string> {
const { data: company, error } = await supabase
.from('companies')
.insert({ ...data, company_type: companyType })
.select('id')
.single();
if (error) throw error;
return company.id;
}
async function createRideModel(data: any): Promise<string> {
const { data: model, error } = await supabase
.from('ride_models')
.insert(data)
.select('id')
.single();
if (error) throw error;
return model.id;
}
async function approvePhotos(data: any): Promise<string> {
// Photos are already uploaded to Cloudflare
// Just need to associate them with the entity
return data.photos?.[0]?.url || '';
}
/**
* Reject multiple items with optional cascade to dependents
*/
export async function rejectSubmissionItems(
items: SubmissionItemWithDeps[],
reason: string,
userId: string,
cascade: boolean = true
): Promise<void> {
if (!userId) {
throw new Error('User authentication required to reject items');
}
if (!reason || !reason.trim()) {
throw new Error('Rejection reason is required');
}
const itemsToReject = new Set<string>(items.map(i => i.id));
// If cascading, collect all dependent items
if (cascade) {
for (const item of items) {
await collectDependents(item, itemsToReject);
}
}
// Update all items to rejected status
const updates = Array.from(itemsToReject).map(async (itemId) => {
const { error } = await supabase
.from('submission_items')
.update({
status: 'rejected',
rejection_reason: reason,
updated_at: new Date().toISOString(),
})
.eq('id', itemId);
if (error) {
console.error(`Error rejecting item ${itemId}:`, error);
throw error;
}
});
await Promise.all(updates);
// Update parent submission status
const submissionId = items[0]?.submission_id;
if (submissionId) {
await updateSubmissionStatusAfterRejection(submissionId);
}
}
async function collectDependents(
item: SubmissionItemWithDeps,
rejectedSet: Set<string>
): Promise<void> {
if (item.dependents && item.dependents.length > 0) {
for (const dependent of item.dependents) {
rejectedSet.add(dependent.id);
await collectDependents(dependent, rejectedSet);
}
}
}
async function updateSubmissionStatusAfterRejection(submissionId: string): Promise<void> {
// Get all items for this submission
const { data: allItems, error: fetchError } = await supabase
.from('submission_items')
.select('status')
.eq('submission_id', submissionId);
if (fetchError) {
console.error('Error fetching submission items:', fetchError);
return;
}
if (!allItems || allItems.length === 0) return;
const statuses = allItems.map(i => i.status);
const allRejected = statuses.every(s => s === 'rejected');
const allApproved = statuses.every(s => s === 'approved');
const anyPending = statuses.some(s => s === 'pending');
let newStatus: string;
if (allRejected) {
newStatus = 'rejected';
} else if (allApproved) {
newStatus = 'approved';
} else if (anyPending) {
newStatus = 'pending';
} else {
newStatus = 'partially_approved';
}
const { error: updateError } = await supabase
.from('content_submissions')
.update({
status: newStatus,
updated_at: new Date().toISOString(),
})
.eq('id', submissionId);
if (updateError) {
console.error('Error updating submission status:', updateError);
}
}
/**
* Escalate submission for admin review
*/
export async function escalateSubmission(
submissionId: string,
reason: string,
userId: string
): Promise<void> {
if (!userId) {
throw new Error('User authentication required to escalate submission');
}
const { error } = await supabase
.from('content_submissions')
.update({
status: 'pending',
escalation_reason: reason,
escalated_by: userId,
reviewer_notes: `Escalated: ${reason}`,
})
.eq('id', submissionId);
if (error) throw error;
}