mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 06:11:12 -05:00
feat: Extract business logic for moderation
This commit is contained in:
466
src/lib/moderation/actions.ts
Normal file
466
src/lib/moderation/actions.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { ModerationItem } from '@/types/moderation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
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) {
|
||||||
|
console.error('Photo approval error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to approve photo submission',
|
||||||
|
error: error as 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
|
||||||
|
*/
|
||||||
|
export async function approveSubmissionItems(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
submissionId: string,
|
||||||
|
itemIds: string[]
|
||||||
|
): Promise<ModerationActionResult> {
|
||||||
|
try {
|
||||||
|
const { error: approvalError } = await supabase.functions.invoke(
|
||||||
|
'process-selective-approval',
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
itemIds,
|
||||||
|
submissionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (approvalError) {
|
||||||
|
throw new Error(`Failed to process submission items: ${approvalError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully processed ${itemIds.length} item(s)`,
|
||||||
|
shouldRemoveFromQueue: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submission items approval error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to approve submission items',
|
||||||
|
error: error as 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',
|
||||||
|
rejection_reason: rejectionReason || 'Parent submission rejected',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('submission_id', submissionId)
|
||||||
|
.eq('status', 'pending');
|
||||||
|
|
||||||
|
if (rejectError) {
|
||||||
|
console.error('Failed to cascade rejection:', rejectError);
|
||||||
|
// Don't fail the whole operation, just log it
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Submission items rejected',
|
||||||
|
shouldRemoveFromQueue: false, // Parent rejection will handle removal
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submission items rejection error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to reject submission items',
|
||||||
|
error: error as 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
|
||||||
|
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
|
||||||
|
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
|
||||||
|
const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at';
|
||||||
|
const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id';
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
[statusField]: action,
|
||||||
|
[timestampField]: new Date().toISOString(),
|
||||||
|
[reviewerField]: moderatorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (moderatorNotes) {
|
||||||
|
updateData.reviewer_notes = moderatorNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, data } = await supabase
|
||||||
|
.from(table as any)
|
||||||
|
.update(updateData)
|
||||||
|
.eq('id', item.id)
|
||||||
|
.select();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error('Moderation action error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to ${action} content`,
|
||||||
|
error: error as 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 deletePromises = validImageIds.map(async imageId => {
|
||||||
|
try {
|
||||||
|
await supabase.functions.invoke('upload-image', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: { imageId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete photo ${imageId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(deletePromises);
|
||||||
|
deletedPhotoCount = validImageIds.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
console.error('Error deleting submission:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to delete submission',
|
||||||
|
error: error as Error,
|
||||||
|
shouldRemoveFromQueue: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/lib/moderation/entities.ts
Normal file
202
src/lib/moderation/entities.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* 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 }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: any,
|
||||||
|
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: any[]): {
|
||||||
|
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 any;
|
||||||
|
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',
|
||||||
|
review: 'Review',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[submissionType] || submissionType;
|
||||||
|
}
|
||||||
44
src/lib/moderation/index.ts
Normal file
44
src/lib/moderation/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
fetchUserProfiles,
|
||||||
|
extractUserIds,
|
||||||
|
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';
|
||||||
375
src/lib/moderation/queries.ts
Normal file
375
src/lib/moderation/queries.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* @returns Configured Supabase query builder
|
||||||
|
*/
|
||||||
|
export function buildSubmissionQuery(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
config: QueryConfig
|
||||||
|
) {
|
||||||
|
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
|
||||||
|
|
||||||
|
// Build base query with all needed data
|
||||||
|
let query = supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
submission_type,
|
||||||
|
status,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
user_id,
|
||||||
|
reviewed_at,
|
||||||
|
reviewer_id,
|
||||||
|
reviewer_notes,
|
||||||
|
escalated,
|
||||||
|
assigned_to,
|
||||||
|
locked_until,
|
||||||
|
submission_items (
|
||||||
|
id,
|
||||||
|
item_type,
|
||||||
|
item_data,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('escalated', { ascending: false })
|
||||||
|
.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
|
||||||
|
if (!isAdmin && !isSuperuser) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get total count first
|
||||||
|
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 = (config.currentPage - 1) * config.pageSize;
|
||||||
|
const endIndex = startIndex + config.pageSize - 1;
|
||||||
|
const paginatedQuery = query.range(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const { data: submissions, error: submissionsError } = await paginatedQuery;
|
||||||
|
|
||||||
|
if (submissionsError) {
|
||||||
|
throw submissionsError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
submissions: submissions || [],
|
||||||
|
totalCount: count || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching submissions:', error);
|
||||||
|
return {
|
||||||
|
submissions: [],
|
||||||
|
totalCount: 0,
|
||||||
|
error: error as Error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user profiles for submitters and reviewers
|
||||||
|
*
|
||||||
|
* @param supabase - Supabase client instance
|
||||||
|
* @param userIds - Array of user IDs to fetch profiles for
|
||||||
|
* @returns Map of userId -> profile data
|
||||||
|
*/
|
||||||
|
export async function fetchUserProfiles(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userIds: string[]
|
||||||
|
): Promise<Map<string, any>> {
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: profiles, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('user_id, username, display_name, avatar_url')
|
||||||
|
.in('user_id', userIds);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching user profiles:', error);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(profiles?.map(p => [p.user_id, p]) || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user profiles:', error);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user IDs from submissions for profile fetching
|
||||||
|
*
|
||||||
|
* Collects all unique user IDs (submitters and reviewers) from a list of submissions.
|
||||||
|
*
|
||||||
|
* @param submissions - Array of submission objects
|
||||||
|
* @returns Array of unique user IDs
|
||||||
|
*/
|
||||||
|
export function extractUserIds(submissions: any[]): string[] {
|
||||||
|
const userIds = submissions.map(s => s.user_id).filter(Boolean);
|
||||||
|
const reviewerIds = submissions
|
||||||
|
.map(s => s.reviewer_id)
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
|
return [...new Set([...userIds, ...reviewerIds])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* Fetches counts for different submission states to display in the queue dashboard.
|
||||||
|
*
|
||||||
|
* @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 {
|
||||||
|
// Build base query
|
||||||
|
let baseQuery = supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('status, escalated', { count: 'exact', head: false });
|
||||||
|
|
||||||
|
// Apply access control
|
||||||
|
if (!isAdmin && !isSuperuser) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
baseQuery = baseQuery.or(
|
||||||
|
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: submissions, error } = await baseQuery;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
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) {
|
||||||
|
console.error('Error fetching queue stats:', error);
|
||||||
|
return {
|
||||||
|
pending: 0,
|
||||||
|
flagged: 0,
|
||||||
|
escalated: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user