mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 07:51:12 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
582
src-old/lib/moderation/actions.ts
Normal file
582
src-old/lib/moderation/actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
121
src-old/lib/moderation/constants.ts
Normal file
121
src-old/lib/moderation/constants.ts
Normal 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;
|
||||
}
|
||||
222
src-old/lib/moderation/entities.ts
Normal file
222
src-old/lib/moderation/entities.ts
Normal 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;
|
||||
}
|
||||
68
src-old/lib/moderation/index.ts
Normal file
68
src-old/lib/moderation/index.ts
Normal 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';
|
||||
236
src-old/lib/moderation/lockAutoRelease.ts
Normal file
236
src-old/lib/moderation/lockAutoRelease.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
91
src-old/lib/moderation/lockHelpers.ts
Normal file
91
src-old/lib/moderation/lockHelpers.ts
Normal 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';
|
||||
}
|
||||
110
src-old/lib/moderation/lockMonitor.ts
Normal file
110
src-old/lib/moderation/lockMonitor.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
455
src-old/lib/moderation/queries.ts
Normal file
455
src-old/lib/moderation/queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
209
src-old/lib/moderation/realtime.ts
Normal file
209
src-old/lib/moderation/realtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
64
src-old/lib/moderation/typeGuards.ts
Normal file
64
src-old/lib/moderation/typeGuards.ts
Normal 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';
|
||||
}
|
||||
121
src-old/lib/moderation/validation.ts
Normal file
121
src-old/lib/moderation/validation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user