Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,582 @@
/**
* Moderation Actions
*
* Business logic for performing moderation actions on submissions.
* Handles approval, rejection, and deletion workflows with proper
* error handling and database updates.
*/
import { SupabaseClient } from '@supabase/supabase-js';
import { createTableQuery } from '@/lib/supabaseHelpers';
import type { ModerationItem } from '@/types/moderation';
import { handleError, handleNonCriticalError, getErrorMessage } from '@/lib/errorHandler';
import { invokeWithTracking, invokeBatchWithTracking } from '@/lib/edgeFunctionTracking';
/**
* Type-safe update data for review moderation
* Note: These types document the expected structure. Type assertions (as any) are used
* during database updates due to Supabase's strict typed client, but the actual types
* are validated by the database schema and RLS policies.
*/
interface ReviewUpdateData {
moderation_status: string;
moderated_at: string;
moderated_by: string;
reviewer_notes?: string;
locked_until?: null;
locked_by?: null;
}
/**
* Type-safe update data for submission moderation
* Note: These types document the expected structure. Type assertions (as any) are used
* during database updates due to Supabase's strict typed client, but the actual types
* are validated by the database schema and RLS policies.
*/
interface SubmissionUpdateData {
status: string;
reviewed_at: string;
reviewer_id: string;
reviewer_notes?: string;
locked_until?: null;
locked_by?: null;
}
/**
* Discriminated union for moderation updates (documentation purposes)
*/
type ModerationUpdateData = ReviewUpdateData | SubmissionUpdateData;
/**
* Result of a moderation action
*/
export interface ModerationActionResult {
success: boolean;
message: string;
error?: Error;
shouldRemoveFromQueue: boolean;
}
/**
* Configuration for photo approval
*/
interface PhotoApprovalConfig {
submissionId: string;
moderatorId: string;
moderatorNotes?: string;
}
/**
* Approve a photo submission
*
* Creates photo records in the database and updates submission status.
* Handles both new approvals and re-approvals (where photos already exist).
*
* @param supabase - Supabase client
* @param config - Photo approval configuration
* @returns Action result with success status and message
*/
export async function approvePhotoSubmission(
supabase: SupabaseClient,
config: PhotoApprovalConfig
): Promise<ModerationActionResult> {
try {
// Fetch photo submission from relational tables
const { data: photoSubmission, error: fetchError } = await supabase
.from('photo_submissions')
.select(`
*,
items:photo_submission_items(*),
submission:content_submissions!inner(user_id, status)
`)
.eq('submission_id', config.submissionId)
.single();
if (fetchError || !photoSubmission) {
throw new Error('Failed to fetch photo submission data');
}
if (!photoSubmission.items || photoSubmission.items.length === 0) {
throw new Error('No photos found in submission');
}
// Check if photos already exist for this submission (re-approval case)
const { data: existingPhotos } = await supabase
.from('photos')
.select('id')
.eq('submission_id', config.submissionId);
if (!existingPhotos || existingPhotos.length === 0) {
// Create new photo records from photo_submission_items
const photoRecords = photoSubmission.items.map((item: any) => ({
entity_id: photoSubmission.entity_id,
entity_type: photoSubmission.entity_type,
cloudflare_image_id: item.cloudflare_image_id,
cloudflare_image_url: item.cloudflare_image_url,
title: item.title || null,
caption: item.caption || null,
date_taken: item.date_taken || null,
order_index: item.order_index,
submission_id: photoSubmission.submission_id,
submitted_by: photoSubmission.submission?.user_id,
approved_by: config.moderatorId,
approved_at: new Date().toISOString(),
}));
const { error: insertError } = await supabase
.from('photos')
.insert(photoRecords);
if (insertError) {
throw insertError;
}
}
// Update submission status
const { error: updateError } = await supabase
.from('content_submissions')
.update({
status: 'approved' as const,
reviewer_id: config.moderatorId,
reviewed_at: new Date().toISOString(),
reviewer_notes: config.moderatorNotes,
})
.eq('id', config.submissionId);
if (updateError) {
throw updateError;
}
return {
success: true,
message: `Successfully approved and published ${photoSubmission.items.length} photo(s)`,
shouldRemoveFromQueue: true,
};
} catch (error: unknown) {
handleError(error, {
action: 'Approve Photo Submission',
userId: config.moderatorId,
metadata: { submissionId: config.submissionId }
});
return {
success: false,
message: 'Failed to approve photo submission',
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
shouldRemoveFromQueue: false,
};
}
}
/**
* Approve a submission with submission_items
*
* Uses the edge function to process all pending submission items.
*
* @param supabase - Supabase client
* @param submissionId - Submission ID
* @param itemIds - Array of item IDs to approve
* @returns Action result
*/
/**
* Approve submission items using atomic transaction RPC.
*
* This function uses PostgreSQL's ACID transaction guarantees to ensure
* all-or-nothing approval with automatic rollback on any error.
*
* The approval process is handled entirely within a single database transaction
* via the process_approval_transaction() RPC function, which guarantees:
* - True atomic transactions (all-or-nothing)
* - Automatic rollback on ANY error
* - Network-resilient (edge function crash = auto rollback)
* - Zero orphaned entities
*/
export async function approveSubmissionItems(
supabase: SupabaseClient,
submissionId: string,
itemIds: string[]
): Promise<ModerationActionResult> {
try {
console.log(`[Approval] Processing ${itemIds.length} items via atomic transaction`, {
submissionId,
itemCount: itemIds.length
});
const { data: approvalData, error: approvalError, requestId } = await invokeWithTracking(
'process-selective-approval',
{
itemIds,
submissionId,
}
);
if (approvalError) {
const error = new Error(`Failed to process submission items: ${approvalError.message}`);
handleError(error, {
action: 'Approve Submission Items',
metadata: { submissionId, itemCount: itemIds.length, requestId }
});
throw error;
}
return {
success: true,
message: `Successfully processed ${itemIds.length} item(s)`,
shouldRemoveFromQueue: true,
};
} catch (error: unknown) {
handleError(error, {
action: 'Approve Submission Items',
metadata: { submissionId, itemCount: itemIds.length }
});
return {
success: false,
message: 'Failed to approve submission items',
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
shouldRemoveFromQueue: false,
};
}
}
/**
* Reject a submission with submission_items
*
* Cascades rejection to all pending items.
*
* @param supabase - Supabase client
* @param submissionId - Submission ID
* @param rejectionReason - Reason for rejection
* @returns Action result
*/
export async function rejectSubmissionItems(
supabase: SupabaseClient,
submissionId: string,
rejectionReason?: string
): Promise<ModerationActionResult> {
try {
const { error: rejectError } = await supabase
.from('submission_items')
.update({
status: 'rejected' as const,
rejection_reason: rejectionReason || 'Parent submission rejected',
updated_at: new Date().toISOString(),
})
.eq('submission_id', submissionId)
.eq('status', 'pending');
if (rejectError) {
handleError(rejectError, {
action: 'Reject Submission Items (Cascade)',
metadata: { submissionId }
});
}
return {
success: true,
message: 'Submission items rejected',
shouldRemoveFromQueue: false, // Parent rejection will handle removal
};
} catch (error: unknown) {
handleError(error, {
action: 'Reject Submission Items',
metadata: { submissionId }
});
return {
success: false,
message: 'Failed to reject submission items',
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
shouldRemoveFromQueue: false,
};
}
}
/**
* Configuration for standard moderation actions
*/
export interface ModerationConfig {
item: ModerationItem;
action: 'approved' | 'rejected';
moderatorId: string;
moderatorNotes?: string;
}
/**
* Perform a standard moderation action (approve/reject)
*
* Updates the submission or review status in the database.
* Handles both content_submissions and reviews.
*
* @param supabase - Supabase client
* @param config - Moderation configuration
* @returns Action result
*/
export async function performModerationAction(
supabase: SupabaseClient,
config: ModerationConfig
): Promise<ModerationActionResult> {
const { item, action, moderatorId, moderatorNotes } = config;
try {
// Handle photo submissions specially
if (
action === 'approved' &&
item.type === 'content_submission' &&
item.submission_type === 'photo'
) {
return await approvePhotoSubmission(supabase, {
submissionId: item.id,
moderatorId,
moderatorNotes,
});
}
// Check if this submission has submission_items
if (item.type === 'content_submission') {
const { data: submissionItems, error: itemsError } = await supabase
.from('submission_items')
.select('id, status')
.eq('submission_id', item.id)
.in('status', ['pending', 'rejected']);
if (!itemsError && submissionItems && submissionItems.length > 0) {
if (action === 'approved') {
return await approveSubmissionItems(
supabase,
item.id,
submissionItems.map(i => i.id)
);
} else if (action === 'rejected') {
await rejectSubmissionItems(supabase, item.id, moderatorNotes);
}
}
}
// Standard moderation flow - Build update object with type-appropriate fields
let error: any = null;
let data: any = null;
// Use type-safe table queries based on item type
if (item.type === 'review') {
const reviewUpdate: {
moderation_status: 'approved' | 'rejected' | 'pending';
moderated_at: string;
moderated_by: string;
reviewer_notes?: string;
} = {
moderation_status: action,
moderated_at: new Date().toISOString(),
moderated_by: moderatorId,
...(moderatorNotes && { reviewer_notes: moderatorNotes }),
};
const result = await createTableQuery('reviews')
.update(reviewUpdate)
.eq('id', item.id)
.select();
error = result.error;
data = result.data;
} else {
const submissionUpdate: {
status: 'approved' | 'rejected' | 'pending';
reviewed_at: string;
reviewer_id: string;
reviewer_notes?: string;
} = {
status: action,
reviewed_at: new Date().toISOString(),
reviewer_id: moderatorId,
...(moderatorNotes && { reviewer_notes: moderatorNotes }),
};
const result = await createTableQuery('content_submissions')
.update(submissionUpdate)
.eq('id', item.id)
.select();
error = result.error;
data = result.data;
}
if (error) {
throw error;
}
// Check if the update actually affected any rows
if (!data || data.length === 0) {
throw new Error(
'Failed to update item - no rows affected. You might not have permission to moderate this content.'
);
}
return {
success: true,
message: `Content ${action}`,
shouldRemoveFromQueue: action === 'approved' || action === 'rejected',
};
} catch (error: unknown) {
handleError(error, {
action: `${config.action === 'approved' ? 'Approve' : 'Reject'} Content`,
userId: config.moderatorId,
metadata: { itemType: item.type, itemId: item.id }
});
return {
success: false,
message: `Failed to ${config.action} content`,
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
shouldRemoveFromQueue: false,
};
}
}
/**
* Configuration for submission deletion
*/
export interface DeleteSubmissionConfig {
item: ModerationItem;
deletePhotos?: boolean;
}
/**
* Delete a submission and its associated photos
*
* Extracts photo IDs, deletes them from Cloudflare, then deletes the submission.
*
* @param supabase - Supabase client
* @param config - Deletion configuration
* @returns Action result
*/
export async function deleteSubmission(
supabase: SupabaseClient,
config: DeleteSubmissionConfig
): Promise<ModerationActionResult> {
const { item, deletePhotos = true } = config;
if (item.type !== 'content_submission') {
return {
success: false,
message: 'Can only delete content submissions',
shouldRemoveFromQueue: false,
};
}
try {
let deletedPhotoCount = 0;
let skippedPhotoCount = 0;
// Extract and delete photos if requested
if (deletePhotos) {
const photosArray = item.content?.content?.photos || item.content?.photos;
if (photosArray && Array.isArray(photosArray)) {
const validImageIds: string[] = [];
for (const photo of photosArray) {
let imageId = '';
if (photo.imageId) {
imageId = photo.imageId;
} else if (photo.url && !photo.url.startsWith('blob:')) {
// Try to extract from URL
const uuidRegex =
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
if (uuidRegex.test(photo.url)) {
imageId = photo.url;
} else {
const cloudflareMatch = photo.url.match(
/imagedelivery\.net\/[^\/]+\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i
);
if (cloudflareMatch) {
imageId = cloudflareMatch[1];
}
}
}
if (imageId) {
validImageIds.push(imageId);
} else {
skippedPhotoCount++;
}
}
// Delete photos from Cloudflare
if (validImageIds.length > 0) {
const deleteResults = await invokeBatchWithTracking(
validImageIds.map(imageId => ({
functionName: 'upload-image',
payload: { action: 'delete', imageId },
})),
undefined
);
// Count successful deletions
const successfulDeletions = deleteResults.filter(r => !r.error);
deletedPhotoCount = successfulDeletions.length;
// Log any failures silently (background operation)
const failedDeletions = deleteResults.filter(r => r.error);
if (failedDeletions.length > 0) {
handleNonCriticalError(
new Error(`Failed to delete ${failedDeletions.length} of ${validImageIds.length} photos`),
{
action: 'Delete Submission Photos',
metadata: {
failureCount: failedDeletions.length,
totalAttempted: validImageIds.length,
failedRequestIds: failedDeletions.map(r => r.requestId)
}
}
);
}
}
}
}
// Delete the submission from the database
const { error } = await supabase
.from('content_submissions')
.delete()
.eq('id', item.id);
if (error) {
throw error;
}
// Verify deletion
const { data: checkData, error: checkError } = await supabase
.from('content_submissions')
.select('id')
.eq('id', item.id)
.single();
if (checkData && !checkError) {
throw new Error('Deletion failed - item still exists in database');
}
// Build result message
let message = 'The submission has been permanently deleted';
if (deletedPhotoCount > 0 && skippedPhotoCount > 0) {
message = `The submission and ${deletedPhotoCount} photo(s) have been deleted. ${skippedPhotoCount} photo(s) could not be deleted from storage`;
} else if (deletedPhotoCount > 0) {
message = `The submission and ${deletedPhotoCount} associated photo(s) have been permanently deleted`;
} else if (skippedPhotoCount > 0) {
message = `The submission has been deleted. ${skippedPhotoCount} photo(s) could not be deleted from storage`;
}
return {
success: true,
message,
shouldRemoveFromQueue: true,
};
} catch (error: unknown) {
handleError(error, {
action: 'Delete Submission',
metadata: { submissionId: item.id, deletePhotos }
});
return {
success: false,
message: 'Failed to delete submission',
error: error instanceof Error ? error : new Error('Unknown error'),
shouldRemoveFromQueue: false,
};
}
}

View File

@@ -0,0 +1,121 @@
/**
* Moderation Queue Constants
*
* Centralized configuration values for the moderation system.
*/
export const MODERATION_CONSTANTS = {
// TanStack Query configuration
QUERY_STALE_TIME: 30000, // 30 seconds
QUERY_GC_TIME: 5 * 60 * 1000, // 5 minutes
QUERY_RETRY_COUNT: 2,
// Realtime configuration
REALTIME_DEBOUNCE_MS: 500, // 500ms
REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT: 5000, // 5 seconds
// Lock configuration
LOCK_DURATION_MS: 15 * 60 * 1000, // 15 minutes
LOCK_EXTENSION_MS: 10 * 60 * 1000, // 10 minutes
// Cache configuration
MAX_ENTITY_CACHE_SIZE: 500,
MAX_PROFILE_CACHE_SIZE: 500,
// Pagination
DEFAULT_PAGE_SIZE: 25,
MAX_PAGE_SIZE: 100,
// Filter debounce
FILTER_DEBOUNCE_MS: 300,
// Role Labels
ROLE_LABELS: {
admin: 'Administrator',
moderator: 'Moderator',
user: 'User',
superuser: 'Superuser',
} as const,
// Status Labels
STATUS_LABELS: {
pending: 'Pending Review',
approved: 'Approved',
rejected: 'Rejected',
partially_approved: 'Partially Approved',
escalated: 'Escalated',
in_review: 'In Review',
} as const,
// Submission Type Labels
SUBMISSION_TYPE_LABELS: {
park: 'Park',
ride: 'Ride',
company: 'Company',
ride_model: 'Ride Model',
photo: 'Photo',
} as const,
// Report Type Labels
REPORT_TYPE_LABELS: {
spam: 'Spam',
inappropriate: 'Inappropriate Content',
harassment: 'Harassment',
misinformation: 'Misinformation',
fake_info: 'Fake Information',
offensive: 'Offensive Language',
other: 'Other',
} as const,
// Entity Type Labels
ENTITY_TYPE_LABELS: {
park: 'Park',
ride: 'Ride',
company: 'Company',
ride_model: 'Ride Model',
review: 'Review',
profile: 'Profile',
content_submission: 'Content Submission',
} as const,
// Status Colors (for badges)
STATUS_COLORS: {
pending: 'secondary',
approved: 'default',
rejected: 'destructive',
partially_approved: 'outline',
escalated: 'destructive',
in_review: 'secondary',
} as const,
// Report Status Colors
REPORT_STATUS_COLORS: {
pending: 'secondary',
reviewed: 'default',
dismissed: 'outline',
resolved: 'default',
} as const,
} as const;
export type ModerationConstants = typeof MODERATION_CONSTANTS;
// Helper functions for type-safe label access
export function getRoleLabel(role: keyof typeof MODERATION_CONSTANTS.ROLE_LABELS): string {
return MODERATION_CONSTANTS.ROLE_LABELS[role] || role;
}
export function getStatusLabel(status: keyof typeof MODERATION_CONSTANTS.STATUS_LABELS): string {
return MODERATION_CONSTANTS.STATUS_LABELS[status] || status;
}
export function getSubmissionTypeLabel(type: keyof typeof MODERATION_CONSTANTS.SUBMISSION_TYPE_LABELS): string {
return MODERATION_CONSTANTS.SUBMISSION_TYPE_LABELS[type] || type;
}
export function getReportTypeLabel(type: keyof typeof MODERATION_CONSTANTS.REPORT_TYPE_LABELS): string {
return MODERATION_CONSTANTS.REPORT_TYPE_LABELS[type] || type;
}
export function getEntityTypeLabel(type: keyof typeof MODERATION_CONSTANTS.ENTITY_TYPE_LABELS): string {
return MODERATION_CONSTANTS.ENTITY_TYPE_LABELS[type] || type;
}

View File

@@ -0,0 +1,222 @@
/**
* Entity Resolution Utilities
*
* Functions for resolving entity names and display information
* from cached entity data used in moderation workflows.
*/
/**
* Entity cache structure (matching useEntityCache hook)
*/
interface EntityCache {
rides: Map<string, { id: string; name: string; park_id?: string }>;
parks: Map<string, { id: string; name: string }>;
companies: Map<string, { id: string; name: string }>;
}
/**
* Generic submission content type
*/
interface GenericSubmissionContent {
name?: string;
entity_id?: string;
entity_name?: string;
park_id?: string;
ride_id?: string;
company_id?: string;
manufacturer_id?: string;
designer_id?: string;
operator_id?: string;
property_owner_id?: string;
[key: string]: unknown;
}
/**
* Result of entity name resolution
*/
export interface ResolvedEntityNames {
entityName: string;
parkName?: string;
}
/**
* Resolve entity name and related park name from submission content
*
* This function determines what entity is being modified based on the
* submission type and content, then looks up cached entity data to
* resolve display names.
*
* @param submissionType - Type of submission (e.g., 'ride', 'park', 'manufacturer')
* @param content - Submission content containing entity IDs
* @param entityCache - Cache of entity data
* @returns Resolved entity and park names
*
* @example
* ```tsx
* const { entityName, parkName } = resolveEntityName(
* 'ride',
* { entity_id: 'ride-123' },
* entityCacheRef.current
* );
* // Returns: { entityName: "Steel Vengeance", parkName: "Cedar Point" }
* ```
*/
export function resolveEntityName(
submissionType: string,
content: GenericSubmissionContent | null | undefined,
entityCache: EntityCache
): ResolvedEntityNames {
let entityName = content?.name || 'Unknown';
let parkName: string | undefined;
// Handle ride submissions - look up ride name and park
if (submissionType === 'ride' && content?.entity_id) {
const ride = entityCache.rides.get(content.entity_id);
if (ride) {
entityName = ride.name;
if (ride.park_id) {
const park = entityCache.parks.get(ride.park_id);
if (park) parkName = park.name;
}
}
}
// Handle park submissions
else if (submissionType === 'park' && content?.entity_id) {
const park = entityCache.parks.get(content.entity_id);
if (park) entityName = park.name;
}
// Handle company submissions (manufacturer, operator, designer, property_owner)
else if (
['manufacturer', 'operator', 'designer', 'property_owner'].includes(submissionType) &&
content?.entity_id
) {
const company = entityCache.companies.get(content.entity_id);
if (company) entityName = company.name;
}
// Handle content with ride_id reference
else if (content?.ride_id) {
const ride = entityCache.rides.get(content.ride_id);
if (ride) {
entityName = ride.name;
if (ride.park_id) {
const park = entityCache.parks.get(ride.park_id);
if (park) parkName = park.name;
}
}
}
// Handle content with park_id reference
else if (content?.park_id) {
const park = entityCache.parks.get(content.park_id);
if (park) parkName = park.name;
}
return { entityName, parkName };
}
/**
* Get a display-ready entity identifier string
*
* Combines entity name and park name (if available) into a single
* human-readable string for display in the moderation interface.
*
* @param entityName - Primary entity name
* @param parkName - Optional related park name
* @returns Formatted display string
*
* @example
* ```tsx
* getEntityDisplayName("Steel Vengeance", "Cedar Point")
* // Returns: "Steel Vengeance at Cedar Point"
*
* getEntityDisplayName("Cedar Point")
* // Returns: "Cedar Point"
* ```
*/
export function getEntityDisplayName(
entityName: string,
parkName?: string
): string {
if (parkName) {
return `${entityName} at ${parkName}`;
}
return entityName;
}
/**
* Extract all entity IDs from a list of submissions
*
* Scans through submission content to find all referenced entity IDs,
* grouped by entity type (rides, parks, companies).
*
* @param submissions - Array of submission objects
* @returns Object containing Sets of IDs for each entity type
*/
export function extractEntityIds(submissions: Array<{ content: unknown; submission_type: string }>): {
rideIds: Set<string>;
parkIds: Set<string>;
companyIds: Set<string>;
} {
const rideIds = new Set<string>();
const parkIds = new Set<string>();
const companyIds = new Set<string>();
submissions.forEach(submission => {
const content = submission.content as GenericSubmissionContent | null | undefined;
if (content && typeof content === 'object') {
// Direct entity references
if (content.ride_id) rideIds.add(content.ride_id);
if (content.park_id) parkIds.add(content.park_id);
if (content.company_id) companyIds.add(content.company_id);
// Entity ID based on submission type
if (content.entity_id) {
if (submission.submission_type === 'ride') {
rideIds.add(content.entity_id);
} else if (submission.submission_type === 'park') {
parkIds.add(content.entity_id);
} else if (
['manufacturer', 'operator', 'designer', 'property_owner'].includes(
submission.submission_type
)
) {
companyIds.add(content.entity_id);
}
}
// Company role references
if (content.manufacturer_id) companyIds.add(content.manufacturer_id);
if (content.designer_id) companyIds.add(content.designer_id);
if (content.operator_id) companyIds.add(content.operator_id);
if (content.property_owner_id) companyIds.add(content.property_owner_id);
}
});
return { rideIds, parkIds, companyIds };
}
/**
* Determine submission type display label
*
* Converts internal submission type identifiers to human-readable labels.
*
* @param submissionType - Internal submission type
* @returns Human-readable label
*/
export function getSubmissionTypeLabel(submissionType: string): string {
const labels: Record<string, string> = {
park: 'Park',
ride: 'Ride',
manufacturer: 'Manufacturer',
operator: 'Operator',
designer: 'Designer',
property_owner: 'Property Owner',
ride_model: 'Ride Model',
photo: 'Photo',
photo_delete: 'Photo Deletion',
milestone: 'Timeline Event',
timeline_event: 'Timeline Event',
review: 'Review',
};
return labels[submissionType] || submissionType;
}

View File

@@ -0,0 +1,68 @@
/**
* Moderation Library
*
* Centralized exports for all moderation-related utilities.
* Provides business logic for moderation workflows, queries, and entity resolution.
*/
// Query builders and data fetching
export {
buildSubmissionQuery,
buildCountQuery,
fetchSubmissions,
isLockedByOther,
getQueueStats,
} from './queries';
export type { QueryConfig, FetchSubmissionsResult } from './queries';
// Entity resolution
export {
resolveEntityName,
getEntityDisplayName,
extractEntityIds,
getSubmissionTypeLabel,
} from './entities';
export type { ResolvedEntityNames } from './entities';
// Moderation actions
export {
approvePhotoSubmission,
approveSubmissionItems,
rejectSubmissionItems,
performModerationAction,
deleteSubmission,
} from './actions';
export type {
ModerationActionResult,
ModerationConfig,
DeleteSubmissionConfig,
} from './actions';
// Removed - sorting functionality deleted
// Realtime subscription utilities
export {
matchesEntityFilter,
matchesStatusFilter,
hasItemChanged,
extractChangedFields,
buildModerationItem,
} from './realtime';
// Lock management utilities
export {
canClaimSubmission,
isActiveLock,
getLockStatus,
formatLockExpiry,
getLockUrgency,
} from './lockHelpers';
export type { LockStatus, LockUrgency } from './lockHelpers';
// Constants
export { MODERATION_CONSTANTS } from './constants';
export type { ModerationConstants } from './constants';

View File

@@ -0,0 +1,236 @@
/**
* Lock Auto-Release Mechanism
*
* Automatically releases submission locks when operations fail, timeout,
* or are abandoned by moderators. Prevents deadlocks and improves queue flow.
*
* Part of Sacred Pipeline Phase 4: Transaction Resilience
*/
import { supabase } from '@/lib/supabaseClient';
import { logger } from '@/lib/logger';
import { isTimeoutError } from '@/lib/timeoutDetection';
import { toast } from '@/hooks/use-toast';
export interface LockReleaseOptions {
submissionId: string;
moderatorId: string;
reason: 'timeout' | 'error' | 'abandoned' | 'manual';
error?: unknown;
silent?: boolean; // Don't show toast notification
}
/**
* Release a lock on a submission
*/
export async function releaseLock(options: LockReleaseOptions): Promise<boolean> {
const { submissionId, moderatorId, reason, error, silent = false } = options;
try {
// Call Supabase RPC to release lock
const { error: releaseError } = await supabase.rpc('release_submission_lock', {
submission_id: submissionId,
moderator_id: moderatorId,
});
if (releaseError) {
logger.error('Failed to release lock', {
submissionId,
moderatorId,
reason,
error: releaseError,
});
if (!silent) {
toast({
title: 'Lock Release Failed',
description: 'Failed to release submission lock. It will expire automatically.',
variant: 'destructive',
});
}
return false;
}
logger.info('Lock released', {
submissionId,
moderatorId,
reason,
hasError: !!error,
});
if (!silent) {
const message = getLockReleaseMessage(reason);
toast({
title: 'Lock Released',
description: message,
});
}
return true;
} catch (err) {
logger.error('Exception while releasing lock', {
submissionId,
moderatorId,
reason,
error: err,
});
return false;
}
}
/**
* Auto-release lock when an operation fails
*
* @param submissionId - Submission ID
* @param moderatorId - Moderator ID
* @param error - Error that triggered the release
*/
export async function autoReleaseLockOnError(
submissionId: string,
moderatorId: string,
error: unknown
): Promise<void> {
const isTimeout = isTimeoutError(error);
logger.warn('Auto-releasing lock due to error', {
submissionId,
moderatorId,
isTimeout,
error: error instanceof Error ? error.message : String(error),
});
await releaseLock({
submissionId,
moderatorId,
reason: isTimeout ? 'timeout' : 'error',
error,
silent: false, // Show notification for transparency
});
}
/**
* Auto-release lock when moderator abandons review
* Triggered by navigation away, tab close, or inactivity
*/
export async function autoReleaseLockOnAbandon(
submissionId: string,
moderatorId: string
): Promise<void> {
logger.info('Auto-releasing lock due to abandonment', {
submissionId,
moderatorId,
});
await releaseLock({
submissionId,
moderatorId,
reason: 'abandoned',
silent: true, // Silent for better UX
});
}
/**
* Setup auto-release on page unload (user navigates away or closes tab)
*/
export function setupAutoReleaseOnUnload(
submissionId: string,
moderatorId: string
): () => void {
const handleUnload = () => {
// Use sendBeacon for reliable unload requests
const payload = JSON.stringify({
submission_id: submissionId,
moderator_id: moderatorId,
});
// Try to call RPC via sendBeacon (more reliable on unload)
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/rpc/release_submission_lock`;
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon(url, blob);
logger.info('Scheduled lock release on unload', {
submissionId,
moderatorId,
});
};
// Add listeners
window.addEventListener('beforeunload', handleUnload);
window.addEventListener('pagehide', handleUnload);
// Return cleanup function
return () => {
window.removeEventListener('beforeunload', handleUnload);
window.removeEventListener('pagehide', handleUnload);
};
}
/**
* Monitor inactivity and auto-release after timeout
*
* @param submissionId - Submission ID
* @param moderatorId - Moderator ID
* @param inactivityMinutes - Minutes of inactivity before release (default: 10)
* @returns Cleanup function
*/
export function setupInactivityAutoRelease(
submissionId: string,
moderatorId: string,
inactivityMinutes: number = 10
): () => void {
let inactivityTimer: NodeJS.Timeout | null = null;
const resetTimer = () => {
if (inactivityTimer) {
clearTimeout(inactivityTimer);
}
inactivityTimer = setTimeout(() => {
logger.warn('Inactivity timeout - auto-releasing lock', {
submissionId,
moderatorId,
inactivityMinutes,
});
autoReleaseLockOnAbandon(submissionId, moderatorId);
}, inactivityMinutes * 60 * 1000);
};
// Track user activity
const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart'];
activityEvents.forEach(event => {
window.addEventListener(event, resetTimer, { passive: true });
});
// Start timer
resetTimer();
// Return cleanup function
return () => {
if (inactivityTimer) {
clearTimeout(inactivityTimer);
}
activityEvents.forEach(event => {
window.removeEventListener(event, resetTimer);
});
};
}
/**
* Get user-friendly lock release message
*/
function getLockReleaseMessage(reason: LockReleaseOptions['reason']): string {
switch (reason) {
case 'timeout':
return 'Lock released due to timeout. The submission is available for other moderators.';
case 'error':
return 'Lock released due to an error. You can reclaim it to continue reviewing.';
case 'abandoned':
return 'Lock released. The submission is back in the queue.';
case 'manual':
return 'Lock released successfully.';
}
}

View File

@@ -0,0 +1,91 @@
/**
* Lock Management Utilities
*
* Helper functions for managing submission locks and lock state.
*/
/**
* Check if a submission can be claimed by the current user
*/
export function canClaimSubmission(
submission: { assigned_to: string | null; locked_until: string | null },
currentUserId: string
): boolean {
// Can claim if unassigned
if (!submission.assigned_to) return true;
// Can claim if no lock time set
if (!submission.locked_until) return true;
// Can claim if lock expired
if (new Date(submission.locked_until) < new Date()) return true;
// Already claimed by current user - cannot claim again
if (submission.assigned_to === currentUserId) return false;
return false;
}
/**
* Check if a submission has an active lock
*/
export function isActiveLock(
assignedTo: string | null,
lockedUntil: string | null
): boolean {
if (!assignedTo || !lockedUntil) return false;
return new Date(lockedUntil) > new Date();
}
/**
* Get lock status indicator for a submission
*/
export type LockStatus = 'locked_by_me' | 'locked_by_other' | 'unlocked' | 'expired';
export function getLockStatus(
submission: { assigned_to: string | null; locked_until: string | null },
currentUserId: string
): LockStatus {
if (!submission.assigned_to || !submission.locked_until) {
return 'unlocked';
}
const lockExpired = new Date(submission.locked_until) < new Date();
if (lockExpired) {
return 'expired';
}
if (submission.assigned_to === currentUserId) {
return 'locked_by_me';
}
return 'locked_by_other';
}
/**
* Format lock expiry time as MM:SS
*/
export function formatLockExpiry(lockedUntil: string): string {
const expiresAt = new Date(lockedUntil);
const now = new Date();
const msLeft = expiresAt.getTime() - now.getTime();
if (msLeft <= 0) return 'Expired';
const minutes = Math.floor(msLeft / 60000);
const seconds = Math.floor((msLeft % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Calculate lock urgency level based on time remaining
*/
export type LockUrgency = 'critical' | 'warning' | 'normal';
export function getLockUrgency(timeLeftMs: number): LockUrgency {
if (timeLeftMs < 2 * 60 * 1000) return 'critical'; // < 2 min
if (timeLeftMs < 5 * 60 * 1000) return 'warning'; // < 5 min
return 'normal';
}

View File

@@ -0,0 +1,110 @@
/**
* Moderation Lock Monitor
*
* Monitors lock expiry and provides automatic renewal prompts for moderators.
* Prevents loss of work due to expired locks.
*/
import { useEffect } from 'react';
import type { ModerationState } from '../moderationStateMachine';
import type { ModerationAction } from '../moderationStateMachine';
import { hasActiveLock, needsLockRenewal } from '../moderationStateMachine';
import { toast } from '@/hooks/use-toast';
import { supabase } from '@/lib/supabaseClient';
import { handleNonCriticalError } from '../errorHandler';
/**
* Hook to monitor lock status and warn about expiry
*
* @param state - Current moderation state
* @param dispatch - State machine dispatch function
* @param itemId - ID of the locked item (optional, for manual extension)
* @returns Extension function to manually extend lock
*/
export function useLockMonitor(
state: ModerationState,
dispatch: React.Dispatch<ModerationAction>,
itemId?: string
): { extendLock: () => Promise<void> } {
useEffect(() => {
if (!hasActiveLock(state)) {
return;
}
const checkInterval = setInterval(() => {
if (needsLockRenewal(state)) {
// Dispatch lock expiry warning
dispatch({ type: 'LOCK_EXPIRED' });
// Show toast with extension option
toast({
title: 'Lock Expiring Soon',
description: 'Your lock on this submission will expire in less than 2 minutes. Click below to extend.',
duration: Infinity,
});
// Also call extension function automatically after showing toast
if (itemId) {
setTimeout(() => {
handleExtendLock(itemId, dispatch);
}, 100);
}
}
}, 30000); // Check every 30 seconds
return () => clearInterval(checkInterval);
}, [state, dispatch, itemId]);
const extendLock = async () => {
if (itemId) {
await handleExtendLock(itemId, dispatch);
}
};
return { extendLock };
}
/**
* Extend the lock on a submission
*
* @param submissionId - Submission ID
* @param dispatch - State machine dispatch function
*/
export async function handleExtendLock(
submissionId: string,
dispatch: React.Dispatch<ModerationAction>
) {
try {
// Call Supabase to extend lock (assumes 15 minute extension)
const { error } = await supabase
.from('content_submissions')
.update({
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
})
.eq('id', submissionId);
if (error) throw error;
// Update state machine with new lock time
dispatch({
type: 'LOCK_ACQUIRED',
payload: { lockExpires: new Date(Date.now() + 15 * 60 * 1000).toISOString() },
});
toast({
title: 'Lock Extended',
description: 'You have 15 more minutes to complete your review.',
});
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Extend Lock',
metadata: { submissionId }
});
toast({
title: 'Extension Failed',
description: 'Could not extend lock. Please save your work and re-claim the item.',
variant: 'destructive',
});
}
}

View File

@@ -0,0 +1,455 @@
/**
* Moderation Queue Query Builder
*
* Constructs Supabase queries for fetching and filtering moderation queue items.
* Handles complex filtering logic, pagination, and entity resolution.
*/
import { SupabaseClient } from '@supabase/supabase-js';
import type {
EntityFilter,
StatusFilter,
QueueTab,
SortConfig,
} from '@/types/moderation';
/**
* Query configuration for building submission queries
*/
export interface QueryConfig {
entityFilter: EntityFilter;
statusFilter: StatusFilter;
tab: QueueTab;
userId: string;
isAdmin: boolean;
isSuperuser: boolean;
currentPage: number;
pageSize: number;
sortConfig?: SortConfig;
}
/**
* Result from fetching submissions
*/
export interface FetchSubmissionsResult {
submissions: any[];
totalCount: number;
error?: Error;
}
/**
* Build a Supabase query for content submissions based on filters
*
* Applies tab-based filtering (main queue vs archive), entity type filtering,
* status filtering, and access control (admin vs moderator view).
*
* @param supabase - Supabase client instance
* @param config - Query configuration
* @param skipModeratorFilter - Skip the moderator access control filter
* @returns Configured Supabase query builder
*/
export function buildSubmissionQuery(
supabase: SupabaseClient,
config: QueryConfig,
skipModeratorFilter = false
) {
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
// Use optimized view with pre-joined profiles and entity data
let query = supabase
.from('moderation_queue_with_entities')
.select('*');
// CRITICAL: Multi-level ordering
// Level 1: Always sort by escalated first (descending) - escalated items always appear at top
query = query.order('escalated', { ascending: false });
// Level 2: Apply user-selected sort (if provided)
if (config.sortConfig) {
query = query.order(config.sortConfig.field, {
ascending: config.sortConfig.direction === 'asc'
});
}
// Level 3: Tertiary sort by created_at as tiebreaker (if not already primary sort)
if (!config.sortConfig || config.sortConfig.field !== 'created_at') {
query = query.order('created_at', { ascending: true });
}
// Apply tab-based status filtering
if (tab === 'mainQueue') {
// Main queue: pending, flagged, partially_approved submissions
if (statusFilter === 'all') {
query = query.in('status', ['pending', 'flagged', 'partially_approved']);
} else if (statusFilter === 'pending') {
query = query.in('status', ['pending', 'partially_approved']);
} else {
query = query.eq('status', statusFilter);
}
} else {
// Archive: approved or rejected submissions
if (statusFilter === 'all') {
query = query.in('status', ['approved', 'rejected']);
} else {
query = query.eq('status', statusFilter);
}
}
// Apply entity type filter
if (entityFilter === 'photos') {
query = query.eq('submission_type', 'photo');
} else if (entityFilter === 'submissions') {
query = query.neq('submission_type', 'photo');
}
// 'all' and 'reviews' filters don't add any conditions
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
// Admins see all submissions
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
if (!isAdmin && !isSuperuser && !skipModeratorFilter) {
const now = new Date().toISOString();
// Single filter approach (used by getQueueStats)
query = query.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}`
);
}
return query;
}
/**
* Build a count query with the same filters as the main query
*
* Used for pagination to get total number of items matching the filter criteria.
*
* @param supabase - Supabase client instance
* @param config - Query configuration
* @returns Configured count query
*/
export function buildCountQuery(
supabase: SupabaseClient,
config: QueryConfig
) {
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
let countQuery = supabase
.from('content_submissions')
.select('*', { count: 'exact', head: true });
// Apply same filters as main query
if (tab === 'mainQueue') {
if (statusFilter === 'all') {
countQuery = countQuery.in('status', ['pending', 'flagged', 'partially_approved']);
} else if (statusFilter === 'pending') {
countQuery = countQuery.in('status', ['pending', 'partially_approved']);
} else {
countQuery = countQuery.eq('status', statusFilter);
}
} else {
if (statusFilter === 'all') {
countQuery = countQuery.in('status', ['approved', 'rejected']);
} else {
countQuery = countQuery.eq('status', statusFilter);
}
}
if (entityFilter === 'photos') {
countQuery = countQuery.eq('submission_type', 'photo');
} else if (entityFilter === 'submissions') {
countQuery = countQuery.neq('submission_type', 'photo');
}
// Note: Count query not used for non-admin users (multi-query approach handles count)
if (!isAdmin && !isSuperuser) {
const now = new Date().toISOString();
countQuery = countQuery.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}`
);
}
return countQuery;
}
/**
* Fetch submissions with pagination and all required data
*
* Executes the query and returns both the submissions and total count.
* Handles errors gracefully and returns them in the result object.
*
* @param supabase - Supabase client instance
* @param config - Query configuration
* @returns Submissions data and total count
*
* @example
* ```tsx
* const { submissions, totalCount, error } = await fetchSubmissions(supabase, {
* entityFilter: 'all',
* statusFilter: 'pending',
* tab: 'mainQueue',
* userId: user.id,
* isAdmin: false,
* isSuperuser: false,
* currentPage: 1,
* pageSize: 25
* });
* ```
*/
export async function fetchSubmissions(
supabase: SupabaseClient,
config: QueryConfig
): Promise<FetchSubmissionsResult> {
try {
const { userId, isAdmin, isSuperuser, currentPage, pageSize } = config;
// For non-admin users, use multi-query approach to avoid complex OR filters
if (!isAdmin && !isSuperuser) {
return await fetchSubmissionsMultiQuery(supabase, config);
}
// Admin path: use single query with count
const countQuery = buildCountQuery(supabase, config);
const { count, error: countError } = await countQuery;
if (countError) {
throw countError;
}
// Build main query with pagination
const query = buildSubmissionQuery(supabase, config);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize - 1;
const paginatedQuery = query.range(startIndex, endIndex);
// Execute query
const { data: submissions, error: submissionsError } = await paginatedQuery;
if (submissionsError) {
throw submissionsError;
}
// Enrich submissions with type field for UI conditional logic
const enrichedSubmissions = (submissions || []).map(sub => ({
...sub,
type: 'content_submission' as const,
}));
return {
submissions: enrichedSubmissions,
totalCount: count || 0,
};
} catch (error: unknown) {
return {
submissions: [],
totalCount: 0,
error: error as Error,
};
}
}
/**
* Fetch submissions using multi-query approach for non-admin users
*
* Executes three separate queries to avoid complex OR filters:
* 1. Unclaimed items (assigned_to is null)
* 2. Expired locks (locked_until < now, not assigned to current user)
* 3. Items assigned to current user
*
* Results are merged, deduplicated, sorted, and paginated.
*/
async function fetchSubmissionsMultiQuery(
supabase: SupabaseClient,
config: QueryConfig
): Promise<FetchSubmissionsResult> {
const { userId, currentPage, pageSize } = config;
const now = new Date().toISOString();
try {
// Build three separate queries
// Query 1: Unclaimed items
const query1 = buildSubmissionQuery(supabase, config, true).is('assigned_to', null);
// Query 2: Expired locks (not mine)
const query2 = buildSubmissionQuery(supabase, config, true)
.not('assigned_to', 'is', null)
.neq('assigned_to', userId)
.lt('locked_until', now);
// Query 3: My claimed items
const query3 = buildSubmissionQuery(supabase, config, true).eq('assigned_to', userId);
// Execute all queries in parallel
const [result1, result2, result3] = await Promise.all([
query1,
query2,
query3,
]);
// Check for errors
if (result1.error) throw result1.error;
if (result2.error) throw result2.error;
if (result3.error) throw result3.error;
// Merge all submissions
const allSubmissions = [
...(result1.data || []),
...(result2.data || []),
...(result3.data || []),
];
// Deduplicate by ID
const uniqueMap = new Map();
allSubmissions.forEach(sub => {
if (!uniqueMap.has(sub.id)) {
uniqueMap.set(sub.id, sub);
}
});
const uniqueSubmissions = Array.from(uniqueMap.values());
// Apply sorting (same logic as buildSubmissionQuery)
uniqueSubmissions.sort((a, b) => {
// Level 1: Escalated first
if (a.escalated !== b.escalated) {
return b.escalated ? 1 : -1;
}
// Level 2: Custom sort (if provided)
if (config.sortConfig) {
const field = config.sortConfig.field;
const ascending = config.sortConfig.direction === 'asc';
const aVal = a[field];
const bVal = b[field];
if (aVal !== bVal) {
if (aVal == null) return 1;
if (bVal == null) return -1;
const comparison = aVal < bVal ? -1 : 1;
return ascending ? comparison : -comparison;
}
}
// Level 3: Tiebreaker by created_at
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
// Apply pagination
const totalCount = uniqueSubmissions.length;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedSubmissions = uniqueSubmissions.slice(startIndex, endIndex);
// Enrich with type field
const enrichedSubmissions = paginatedSubmissions.map(sub => ({
...sub,
type: 'content_submission' as const,
}));
return {
submissions: enrichedSubmissions,
totalCount,
};
} catch (error: unknown) {
return {
submissions: [],
totalCount: 0,
error: error as Error,
};
}
}
/**
* Check if a submission is locked by another moderator
*
* @param submission - Submission object
* @param currentUserId - Current user's ID
* @returns True if locked by another user
*/
export function isLockedByOther(
submission: any,
currentUserId: string
): boolean {
if (!submission.locked_until || !submission.assigned_to) {
return false;
}
const lockExpiry = new Date(submission.locked_until);
const now = new Date();
// Lock is expired
if (lockExpiry < now) {
return false;
}
// Locked by current user
if (submission.assigned_to === currentUserId) {
return false;
}
// Locked by someone else
return true;
}
/**
* Get queue statistics (optimized with aggregation query)
*
* Fetches counts for different submission states to display in the queue dashboard.
* Uses a single aggregation query instead of fetching all data and filtering client-side.
*
* @param supabase - Supabase client instance
* @param userId - Current user's ID
* @param isAdmin - Whether user is admin
* @param isSuperuser - Whether user is superuser
* @returns Object with various queue statistics
*/
export async function getQueueStats(
supabase: SupabaseClient,
userId: string,
isAdmin: boolean,
isSuperuser: boolean
) {
try {
// Optimized: Use aggregation directly in database
let statsQuery = supabase
.from('content_submissions')
.select('status, escalated');
// Apply access control using simple OR filter
if (!isAdmin && !isSuperuser) {
const now = new Date().toISOString();
// Show: unclaimed items OR items with expired locks OR my items
statsQuery = statsQuery.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}`
);
}
const { data: submissions, error } = await statsQuery;
if (error) {
throw error;
}
// Calculate statistics (still done client-side but with minimal data transfer)
const pending = submissions?.filter(s => s.status === 'pending' || s.status === 'partially_approved').length || 0;
const flagged = submissions?.filter(s => s.status === 'flagged').length || 0;
const escalated = submissions?.filter(s => s.escalated).length || 0;
const total = submissions?.length || 0;
return {
pending,
flagged,
escalated,
total,
};
} catch (error: unknown) {
// Error already logged in caller, just return defaults
return {
pending: 0,
flagged: 0,
escalated: 0,
total: 0,
};
}
}

View File

@@ -0,0 +1,209 @@
/**
* Realtime Subscription Utilities
*
* Helper functions for processing realtime subscription events in the moderation queue.
*/
import type { ModerationItem, EntityFilter, StatusFilter } from '@/types/moderation';
interface SubmissionContent {
name?: string;
[key: string]: any;
}
/**
* Check if a submission matches the entity filter
*/
export function matchesEntityFilter(
submission: { submission_type: string },
entityFilter: EntityFilter
): boolean {
if (entityFilter === 'all') return true;
if (entityFilter === 'photos') {
return submission.submission_type === 'photo';
}
if (entityFilter === 'submissions') {
return submission.submission_type !== 'photo';
}
if (entityFilter === 'reviews') {
return submission.submission_type === 'review';
}
return false;
}
/**
* Check if a submission matches the status filter
*/
export function matchesStatusFilter(
submission: { status: string },
statusFilter: StatusFilter
): boolean {
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') {
return ['pending', 'partially_approved'].includes(submission.status);
}
return statusFilter === submission.status;
}
/**
* Deep comparison of ModerationItem fields to detect actual changes
*/
export function hasItemChanged(
current: ModerationItem,
updated: ModerationItem
): boolean {
// Check critical fields
if (
current.status !== updated.status ||
current.reviewed_at !== updated.reviewed_at ||
current.reviewer_notes !== updated.reviewer_notes ||
current.assigned_to !== updated.assigned_to ||
current.locked_until !== updated.locked_until ||
current.escalated !== updated.escalated
) {
return true;
}
// Check submission_items
if (current.submission_items?.length !== updated.submission_items?.length) {
return true;
}
// Check content (one level deep for performance)
if (current.content && updated.content) {
// Compare content reference first
if (current.content !== updated.content) {
const currentKeys = Object.keys(current.content).sort();
const updatedKeys = Object.keys(updated.content).sort();
// Different number of keys = changed
if (currentKeys.length !== updatedKeys.length) {
return true;
}
// Different key names = changed
if (!currentKeys.every((key, i) => key === updatedKeys[i])) {
return true;
}
// Check each key's value
for (const key of currentKeys) {
if (current.content[key] !== updated.content[key]) {
return true;
}
}
}
}
return false;
}
/**
* Extract only changed fields for minimal updates
*/
export function extractChangedFields(
current: ModerationItem,
updated: ModerationItem
): Partial<ModerationItem> {
const changes: Partial<ModerationItem> = {};
if (current.status !== updated.status) {
changes.status = updated.status;
}
if (current.reviewed_at !== updated.reviewed_at) {
changes.reviewed_at = updated.reviewed_at;
}
if (current.reviewer_notes !== updated.reviewer_notes) {
changes.reviewer_notes = updated.reviewer_notes;
}
if (current.assigned_to !== updated.assigned_to) {
changes.assigned_to = updated.assigned_to;
}
if (current.locked_until !== updated.locked_until) {
changes.locked_until = updated.locked_until;
}
if (current.escalated !== updated.escalated) {
changes.escalated = updated.escalated;
}
// Check content changes
if (current.content !== updated.content) {
changes.content = updated.content;
}
// Check submission_items
if (updated.submission_items) {
changes.submission_items = updated.submission_items;
}
return changes;
}
/**
* Build a full ModerationItem from submission data
*/
export function buildModerationItem(
submission: any,
profile?: any,
entityName?: string,
parkName?: string
): ModerationItem {
return {
id: submission.id,
type: 'content_submission',
content: submission.content,
// Handle both created_at (from view) and submitted_at (from realtime)
created_at: submission.created_at || submission.submitted_at,
submitted_at: submission.submitted_at,
// Support both user_id and submitter_id
user_id: submission.user_id || submission.submitter_id,
submitter_id: submission.submitter_id || submission.user_id,
status: submission.status,
submission_type: submission.submission_type,
// Use new profile structure from view if available
submitter_profile: submission.submitter_profile || (profile ? {
user_id: submission.user_id || submission.submitter_id,
username: profile.username,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
} : undefined),
reviewer_profile: submission.reviewer_profile,
assigned_profile: submission.assigned_profile,
// Legacy support: create user_profile from submitter_profile
user_profile: submission.submitter_profile ? {
username: submission.submitter_profile.username,
display_name: submission.submitter_profile.display_name,
avatar_url: submission.submitter_profile.avatar_url,
} : (profile ? {
username: profile.username,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
} : undefined),
entity_name: entityName || (submission.content as SubmissionContent)?.name || 'Unknown',
park_name: parkName,
reviewed_at: submission.reviewed_at || undefined,
reviewer_notes: submission.reviewer_notes || undefined,
escalated: submission.escalated || false,
assigned_to: submission.assigned_to || undefined,
locked_until: submission.locked_until || undefined,
submission_items: submission.submission_items || undefined,
};
}

View File

@@ -0,0 +1,64 @@
/**
* Type Guard Functions for Moderation Queue
*
* Provides runtime type checking for submission item data.
* Enables type-safe handling of different entity types.
*/
import type {
SubmissionItemData,
ParkItemData,
RideItemData,
CompanyItemData,
RideModelItemData,
PhotoItemData,
} from '@/types/moderation';
/**
* Check if item data is for a park
*/
export function isParkItemData(data: SubmissionItemData): data is ParkItemData {
return 'park_type' in data && 'name' in data;
}
/**
* Check if item data is for a ride
*/
export function isRideItemData(data: SubmissionItemData): data is RideItemData {
return ('ride_id' in data || 'park_id' in data) && 'ride_type' in data;
}
/**
* Check if item data is for a company
*/
export function isCompanyItemData(data: SubmissionItemData): data is CompanyItemData {
return 'company_type' in data && !('park_type' in data) && !('ride_type' in data);
}
/**
* Check if item data is for a ride model
*/
export function isRideModelItemData(data: SubmissionItemData): data is RideModelItemData {
return 'model_type' in data && 'manufacturer_id' in data;
}
/**
* Check if item data is for a photo
*/
export function isPhotoItemData(data: SubmissionItemData): data is PhotoItemData {
return 'photo_url' in data;
}
/**
* Get the entity type from item data (for validation and display)
*/
export function getEntityTypeFromItemData(data: SubmissionItemData): string {
if (isParkItemData(data)) return 'park';
if (isRideItemData(data)) return 'ride';
if (isCompanyItemData(data)) {
return data.company_type; // 'manufacturer', 'designer', etc.
}
if (isRideModelItemData(data)) return 'ride_model';
if (isPhotoItemData(data)) return 'photo';
return 'unknown';
}

View File

@@ -0,0 +1,121 @@
/**
* Runtime Data Validation for Moderation Queue
*
* Uses Zod to validate data shapes from the database at runtime.
* Prevents runtime errors if database schema changes unexpectedly.
*/
import { z } from 'zod';
import { handleError } from '@/lib/errorHandler';
// Profile schema (matches database JSONB structure)
const ProfileSchema = z.object({
user_id: z.string().uuid(),
username: z.string(),
display_name: z.string().optional().nullable(),
avatar_url: z.string().optional().nullable(),
});
// Legacy profile schema (for backward compatibility)
const LegacyProfileSchema = z.object({
username: z.string(),
display_name: z.string().optional().nullable(),
avatar_url: z.string().optional().nullable(),
});
// Submission item schema
const SubmissionItemSchema = z.object({
id: z.string().uuid(),
status: z.string(),
item_type: z.string().optional(),
item_data: z.record(z.string(), z.any()).optional().nullable(),
// Typed FK columns (optional, only one will be populated)
park_submission_id: z.string().uuid().optional().nullable(),
ride_submission_id: z.string().uuid().optional().nullable(),
photo_submission_id: z.string().uuid().optional().nullable(),
company_submission_id: z.string().uuid().optional().nullable(),
ride_model_submission_id: z.string().uuid().optional().nullable(),
timeline_event_submission_id: z.string().uuid().optional().nullable(),
action_type: z.enum(['create', 'edit', 'delete']).optional(),
original_data: z.record(z.string(), z.any()).optional().nullable(),
error_message: z.string().optional().nullable(),
});
// Main moderation item schema
export const ModerationItemSchema = z.object({
id: z.string().uuid(),
status: z.enum(['pending', 'approved', 'rejected', 'partially_approved', 'flagged']),
type: z.string(),
submission_type: z.string(),
// Accept both created_at and submitted_at for flexibility
created_at: z.string(),
submitted_at: z.string().optional(),
updated_at: z.string().optional().nullable(),
reviewed_at: z.string().optional().nullable(),
content: z.record(z.string(), z.any()).optional().nullable(),
// User fields (support both old and new naming)
submitter_id: z.string().uuid().optional(),
user_id: z.string().uuid().optional(),
assigned_to: z.string().uuid().optional().nullable(),
locked_until: z.string().optional().nullable(),
reviewed_by: z.string().uuid().optional().nullable(),
reviewer_notes: z.string().optional().nullable(),
// Escalation fields
escalated: z.boolean().optional().default(false),
escalation_reason: z.string().optional().nullable(),
// Profile objects (new structure from view)
submitter_profile: ProfileSchema.optional().nullable(),
assigned_profile: ProfileSchema.optional().nullable(),
reviewer_profile: ProfileSchema.optional().nullable(),
// Legacy profile support
user_profile: LegacyProfileSchema.optional().nullable(),
// Submission items
submission_items: z.array(SubmissionItemSchema).optional().nullable(),
// Entity names
entity_name: z.string().optional(),
park_name: z.string().optional(),
});
export const ModerationItemArraySchema = z.array(ModerationItemSchema);
/**
* Validate moderation items array
*
* @param data - Data to validate
* @returns Validation result with typed data or error
*/
export function validateModerationItems(data: unknown): {
success: boolean;
data?: any[];
error?: string
} {
const result = ModerationItemArraySchema.safeParse(data);
if (!result.success) {
handleError(result.error, {
action: 'Data validation failed',
metadata: {
errors: result.error.issues.slice(0, 5)
}
});
return {
success: false,
error: 'Received invalid data format from server. Please refresh the page.',
};
}
return {
success: true,
data: result.data,
};
}