From 109b91bdc826fbfa838f014b705edf128770fa81 Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 20 Nov 2025 09:51:15 +0530 Subject: [PATCH] feat(moderation): Add delete action for hackathons and events in admin panel - Add 'delete' action support to hackathon moderation endpoint alongside approve/reject - Implement permanent deletion of hackathons with cache invalidation - Update moderation queue to display both pending and deleted hackathons - Extend event moderation to include deleted events in retrieval - Add deletion capability to HackathonModerationQueue component UI - Update moderation service to filter by pending and deleted approval statuses - Refactor indentation and code formatting in moderation route handlers - Update type definitions to support delete action in moderation workflows - Enable admins to permanently remove flagged or problematic content from the platform --- app/api/admin/moderation/events/route.ts | 2 +- .../admin/moderation/hackathons/[id]/route.ts | 178 ++++++++++-------- app/api/admin/moderation/hackathons/route.ts | 2 +- app/api/events/[slug]/route.ts | 23 ++- app/api/hackathons/[id]/route.ts | 62 +++++- app/dashboard/company/[slug]/events/page.tsx | 22 ++- .../company/[slug]/hackathons/page.tsx | 38 ++-- .../moderation/HackathonModerationQueue.tsx | 79 +++++--- lib/services/events.ts | 62 +++++- lib/services/moderation-service.ts | 2 +- types/events.ts | 2 +- types/hackathons.ts | 2 +- 12 files changed, 329 insertions(+), 145 deletions(-) diff --git a/app/api/admin/moderation/events/route.ts b/app/api/admin/moderation/events/route.ts index 08e7268c..bdc9e1fe 100644 --- a/app/api/admin/moderation/events/route.ts +++ b/app/api/admin/moderation/events/route.ts @@ -14,7 +14,7 @@ export const GET = withPlatformAdmin(async (request: NextRequest) => { const limit = parseInt(searchParams.get('limit') || '20') const offset = parseInt(searchParams.get('offset') || '0') - // Get pending events from moderation service + // Get pending and deleted events from moderation service const { events, total } = await moderationService.getPendingEvents({ limit, offset, diff --git a/app/api/admin/moderation/hackathons/[id]/route.ts b/app/api/admin/moderation/hackathons/[id]/route.ts index 61e7e2e2..db68cb88 100644 --- a/app/api/admin/moderation/hackathons/[id]/route.ts +++ b/app/api/admin/moderation/hackathons/[id]/route.ts @@ -19,96 +19,114 @@ export async function POST(request: NextRequest, context: RouteContext) { return withPlatformAdmin(async () => { try { const { id } = await context.params - const body = await request.json() - const { action, reason } = body + const body = await request.json() + const { action, reason } = body - if (!action || !['approve', 'reject'].includes(action)) { - return NextResponse.json( - { success: false, error: 'Invalid action' }, - { status: 400 } - ) - } + if (!action || !['approve', 'reject', 'delete'].includes(action)) { + return NextResponse.json( + { success: false, error: 'Invalid action' }, + { status: 400 } + ) + } - if (action === 'reject' && !reason) { - return NextResponse.json( - { success: false, error: 'Rejection reason is required' }, - { status: 400 } - ) - } + if (action === 'reject' && !reason) { + return NextResponse.json( + { success: false, error: 'Rejection reason is required' }, + { status: 400 } + ) + } - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ) - } + if (!user) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } - // Get the hackathon - const { data: hackathon, error: fetchError } = await supabase - .from('hackathons') - .select('*') - .eq('id', id) - .single() + // Get the hackathon + const { data: hackathon, error: fetchError } = await supabase + .from('hackathons') + .select('*') + .eq('id', id) + .single() - if (fetchError || !hackathon) { - return NextResponse.json( - { success: false, error: 'Hackathon not found' }, - { status: 404 } - ) - } + if (fetchError || !hackathon) { + return NextResponse.json( + { success: false, error: 'Hackathon not found' }, + { status: 404 } + ) + } - // Update hackathon based on action - const updateData: { - updated_at: string - approval_status?: string - approved_by?: string - approved_at?: string - status?: string - rejection_reason?: string | null - } = { - updated_at: new Date().toISOString(), - } + // Update hackathon based on action + const updateData: { + updated_at: string + approval_status?: string + approved_by?: string + approved_at?: string + status?: string + rejection_reason?: string | null + } = { + updated_at: new Date().toISOString(), + } - if (action === 'approve') { - updateData.approval_status = 'approved' - updateData.approved_by = user.id - updateData.approved_at = new Date().toISOString() - updateData.status = 'live' - updateData.rejection_reason = null - } else if (action === 'reject') { - updateData.approval_status = 'rejected' - updateData.rejection_reason = reason - updateData.status = 'draft' - } + if (action === 'approve') { + updateData.approval_status = 'approved' + updateData.approved_by = user.id + updateData.approved_at = new Date().toISOString() + updateData.status = 'live' + updateData.rejection_reason = null + } else if (action === 'reject') { + updateData.approval_status = 'rejected' + updateData.rejection_reason = reason + updateData.status = 'draft' + } else if (action === 'delete') { + // Permanently delete the hackathon + const { error: deleteError } = await supabase + .from('hackathons') + .delete() + .eq('id', id) - const { error: updateError } = await supabase - .from('hackathons') - .update(updateData) - .eq('id', id) + if (deleteError) { + throw deleteError + } - if (updateError) { - throw updateError - } + // Invalidate caches + await UnifiedCache.purgeByTags(['content', 'api']) + + return NextResponse.json({ + success: true, + message: 'Hackathon permanently deleted', + }) + } + + const { error: updateError } = await supabase + .from('hackathons') + .update(updateData) + .eq('id', id) - // Invalidate caches - await UnifiedCache.purgeByTags(['content', 'api']) - - return NextResponse.json({ - success: true, - message: `Hackathon ${action}d successfully`, - }) - } catch (error) { - console.error('Error in hackathon moderation action:', error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to execute action', - }, - { status: 500 } - ) - } + if (updateError) { + throw updateError + } + + // Invalidate caches + await UnifiedCache.purgeByTags(['content', 'api']) + + return NextResponse.json({ + success: true, + message: `Hackathon ${action}d successfully`, + }) + } catch (error) { + console.error('Error in hackathon moderation action:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to execute action', + }, + { status: 500 } + ) + } })(request) } diff --git a/app/api/admin/moderation/hackathons/route.ts b/app/api/admin/moderation/hackathons/route.ts index 895ba1ba..373d402e 100644 --- a/app/api/admin/moderation/hackathons/route.ts +++ b/app/api/admin/moderation/hackathons/route.ts @@ -23,7 +23,7 @@ export const GET = withPlatformAdmin(async (request: NextRequest) => { *, company:companies(*) `, { count: 'exact' }) - .eq('approval_status', 'pending') + .in('approval_status', ['pending', 'deleted']) .order('created_at', { ascending: false }) .range(offset, offset + limit - 1) diff --git a/app/api/events/[slug]/route.ts b/app/api/events/[slug]/route.ts index 6311202e..231f0a80 100644 --- a/app/api/events/[slug]/route.ts +++ b/app/api/events/[slug]/route.ts @@ -18,7 +18,7 @@ export async function GET( // Try to get from cache first const cacheKey = `event:${slug}`; const cached = await UnifiedCache.get(cacheKey); - + if (cached) { return UnifiedCache.createResponse(cached, 'API_STANDARD'); } @@ -40,7 +40,7 @@ export async function GET( } catch (error) { console.error('Error in GET /api/events/[slug]:', error); - + if (error instanceof EventError) { return NextResponse.json( { error: error.message, code: error.code }, @@ -112,7 +112,7 @@ export async function PUT( } catch (error) { console.error('Error in PUT /api/events/[slug]:', error); - + if (error instanceof EventError) { return NextResponse.json( { error: error.message, code: error.code }, @@ -167,19 +167,26 @@ export async function DELETE( } // Delete event using service - await eventsService.deleteEvent(existingEvent.id, user.id); + const result = await eventsService.deleteEvent(existingEvent.id, user.id) // Invalidate caches - await UnifiedCache.purgeByTags(['content', 'api']); + await UnifiedCache.purgeByTags(['content', 'api']) return NextResponse.json( - { message: 'Event deleted successfully' }, + { + success: true, + message: result.soft_delete + ? 'Event marked for deletion. Admin approval required.' + : 'Event deleted successfully', + soft_delete: result.soft_delete + }, { status: 200 } - ); + ) + ; } catch (error) { console.error('Error in DELETE /api/events/[slug]:', error); - + if (error instanceof EventError) { return NextResponse.json( { error: error.message, code: error.code }, diff --git a/app/api/hackathons/[id]/route.ts b/app/api/hackathons/[id]/route.ts index 2081a2c5..30b18325 100644 --- a/app/api/hackathons/[id]/route.ts +++ b/app/api/hackathons/[id]/route.ts @@ -182,13 +182,71 @@ export async function DELETE(_request: NextRequest, { params }: RouteContext) { ) } - console.log('🗑️ Attempting to delete hackathon...') + // Check if hackathon is approved (live) - use soft delete + if (existingHackathon.approval_status === 'approved') { + console.log('🔄 Hackathon is approved - marking for deletion (soft delete)') + + // Mark as deleted instead of hard deleting + const { error: updateError } = await supabase + .from('hackathons') + .update({ + approval_status: 'deleted', + updated_at: new Date().toISOString() + }) + .eq('id', existingHackathon.id) + + if (updateError) { + console.error('❌ Error marking hackathon for deletion:', updateError) + throw new Error('Failed to mark hackathon for deletion') + } + + console.log('✅ Hackathon marked for deletion - requires admin approval') + + // Notify admins about the deletion request + const hackathonId = existingHackathon.id + if (hackathonId) { + const { data: adminUsers } = await supabase + .from('profiles') + .select('id') + .eq('role', 'admin') + + if (adminUsers && adminUsers.length > 0) { + const notifications = adminUsers.map(admin => ({ + user_id: admin.id, + company_id: existingHackathon.company_id, + type: 'hackathon_deleted' as const, + title: 'Hackathon Deletion Request', + message: `"${existingHackathon.title}" has been marked for deletion and requires approval`, + action_url: `/admin/moderation/hackathons/${hackathonId}`, + action_label: 'Review Deletion', + hackathon_id: hackathonId.toString(), + metadata: { + hackathon_title: existingHackathon.title, + hackathon_slug: existingHackathon.slug + } + })) + + await supabase.from('notifications').insert(notifications) + console.log(`📧 Notified ${adminUsers.length} admin(s) about deletion request`) + } + } + + return NextResponse.json({ + success: true, + message: 'Hackathon marked for deletion. Admin approval required.', + soft_delete: true + }) + } + + // For draft/pending hackathons, allow hard delete + console.log('🗑️ Hackathon is draft/pending - performing hard delete') await hackathonsService.deleteHackathon(id) console.log('✅ Hackathon deleted successfully') return NextResponse.json({ success: true, - message: 'Hackathon deleted successfully' + message: 'Hackathon deleted successfully', + soft_delete: false }) } catch (error) { console.error('❌ Error in DELETE /api/hackathons/[id]:', error) diff --git a/app/dashboard/company/[slug]/events/page.tsx b/app/dashboard/company/[slug]/events/page.tsx index 8240e031..61d788eb 100644 --- a/app/dashboard/company/[slug]/events/page.tsx +++ b/app/dashboard/company/[slug]/events/page.tsx @@ -99,13 +99,16 @@ export default function CompanyEventsPage() { } } + // Filter out deleted items for stats (summary cards should only show active items) + const activeEvents = events.filter(e => e.approval_status !== 'deleted') + const stats = { - total: events.length, - approved: events.filter(e => e.approval_status === 'approved').length, - pending: events.filter(e => e.approval_status === 'pending').length, - draft: events.filter(e => e.status === 'draft').length, - totalViews: events.reduce((sum, e) => sum + (e.views || 0), 0), - totalRegistrations: events.reduce((sum, e) => sum + (e.registered || 0), 0), + total: activeEvents.length, + approved: activeEvents.filter(e => e.approval_status === 'approved').length, + pending: activeEvents.filter(e => e.approval_status === 'pending').length, + draft: activeEvents.filter(e => e.status === 'draft').length, + totalViews: activeEvents.reduce((sum, e) => sum + (e.views || 0), 0), + totalRegistrations: activeEvents.reduce((sum, e) => sum + (e.registered || 0), 0), } const getApprovalBadge = (status: string) => { @@ -138,6 +141,13 @@ export default function CompanyEventsPage() { Changes Requested ) + case 'deleted': + return ( + + + Deleted + + ) default: return {status} } diff --git a/app/dashboard/company/[slug]/hackathons/page.tsx b/app/dashboard/company/[slug]/hackathons/page.tsx index 1b985cc0..b021cb39 100644 --- a/app/dashboard/company/[slug]/hackathons/page.tsx +++ b/app/dashboard/company/[slug]/hackathons/page.tsx @@ -29,7 +29,7 @@ interface Hackathon { excerpt: string category: string status: string - approval_status: 'draft' | 'pending' | 'approved' | 'rejected' | 'changes_requested' + approval_status: 'draft' | 'pending' | 'approved' | 'rejected' | 'changes_requested' | 'deleted' date: string time: string duration: string @@ -57,7 +57,7 @@ export default function CompanyHackathonsPage() { setLoading(true) // Fetch all hackathons (not just approved) for company members const response = await fetch(`/api/companies/${currentCompany.slug}/hackathons?status=all&limit=100`) - + if (!response.ok) { throw new Error('Failed to fetch hackathons') } @@ -101,7 +101,7 @@ export default function CompanyHackathonsPage() { toast.success('Hackathon deleted successfully') setDeleteDialogOpen(false) setHackathonToDelete(null) - + // Refresh the list fetchHackathons() } catch (error) { @@ -125,13 +125,16 @@ export default function CompanyHackathonsPage() { hackathon.category?.toLowerCase().includes(searchTerm.toLowerCase()) ) + // Filter out deleted items for stats (summary cards should only show active items) + const activeHackathons = hackathons.filter(h => h.approval_status !== 'deleted') + const stats = { - total: hackathons.length, - approved: hackathons.filter(h => h.approval_status === 'approved').length, - pending: hackathons.filter(h => h.approval_status === 'pending').length, - draft: hackathons.filter(h => h.status === 'draft').length, - totalViews: hackathons.reduce((sum, h) => sum + (h.views || 0), 0), - totalRegistrations: hackathons.reduce((sum, h) => sum + (h.registered || 0), 0), + total: activeHackathons.length, + approved: activeHackathons.filter(h => h.approval_status === 'approved').length, + pending: activeHackathons.filter(h => h.approval_status === 'pending').length, + draft: activeHackathons.filter(h => h.status === 'draft').length, + totalViews: activeHackathons.reduce((sum, h) => sum + (h.views || 0), 0), + totalRegistrations: activeHackathons.reduce((sum, h) => sum + (h.registered || 0), 0), } const getApprovalBadge = (status: string) => { @@ -164,6 +167,13 @@ export default function CompanyHackathonsPage() { Changes Requested ) + case 'deleted': + return ( + + + Deleted + + ) default: return {status} } @@ -185,7 +195,7 @@ export default function CompanyHackathonsPage() { return {hackathon.approval_status} } } - + // If approved, show event status switch (hackathon.status) { case 'live': @@ -393,9 +403,9 @@ export default function CompanyHackathonsPage() { )} - - - + {hackathon.approval_status === 'deleted' ? ( + // Show delete button for soft-deleted items + + ) : ( + // Show approve/reject buttons for pending items + <> + + + + )} @@ -299,7 +322,9 @@ export function HackathonModerationQueue() { {pendingAction?.type === "approve" ? "This hackathon will be published and visible to all users." - : "This hackathon will be rejected and the company will be notified."} + : pendingAction?.type === "delete" + ? "This will permanently delete the hackathon. This action cannot be undone." + : "This hackathon will be rejected and the company will be notified."} {pendingAction?.type === "reject" && ( @@ -325,7 +350,7 @@ export function HackathonModerationQueue() { : "bg-red-600 hover:bg-red-700" } > - {pendingAction?.type === "approve" ? "Approve" : "Reject"} + {pendingAction?.type === "approve" ? "Approve" : pendingAction?.type === "delete" ? "Permanently Delete" : "Reject"} diff --git a/lib/services/events.ts b/lib/services/events.ts index be166f0f..359f9f4b 100644 --- a/lib/services/events.ts +++ b/lib/services/events.ts @@ -550,18 +550,73 @@ class EventsService { /** * Delete an event * @param id Event ID - * @param _userId ID of the user deleting the event + * @param _userId ID of the user deleting the event (unused but kept for API consistency) */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - async deleteEvent(id: number, _userId: string): Promise { + async deleteEvent(id: number, _userId: string): Promise<{ soft_delete: boolean }> { const supabase = await createClient() - // Get existing event to verify ownership + // Get existing event to verify ownership and check approval status const existingEvent = await this.getEventById(id) if (!existingEvent) { throw new EventError('Event not found', EventErrorCodes.NOT_FOUND, 404) } + // Check if event is approved (live) - use soft delete + if (existingEvent.approval_status === 'approved') { + console.log('🔄 Event is approved - marking for deletion (soft delete)') + + // Mark as deleted instead of hard deleting + const { error: updateError } = await supabase + .from('events') + .update({ + approval_status: 'deleted', + updated_at: new Date().toISOString() + }) + .eq('id', id) + + if (updateError) { + console.error('❌ Error marking event for deletion:', updateError) + throw new EventError('Failed to mark event for deletion', EventErrorCodes.NOT_FOUND, 500) + } + + console.log('✅ Event marked for deletion - requires admin approval') + + // Notify admins about the deletion request + const eventId = existingEvent.id + if (eventId) { + const { data: adminUsers } = await supabase + .from('profiles') + .select('id') + .eq('role', 'admin') + + if (adminUsers && adminUsers.length > 0) { + const notifications = adminUsers.map(admin => ({ + user_id: admin.id, + company_id: existingEvent.company_id, + type: 'event_deleted' as const, + title: 'Event Deletion Request', + message: `"${existingEvent.title}" has been marked for deletion and requires approval`, + action_url: `/admin/moderation/events/${eventId}`, + action_label: 'Review Deletion', + event_id: eventId.toString(), + metadata: { + event_title: existingEvent.title, + event_slug: existingEvent.slug + } + })) + + await supabase.from('notifications').insert(notifications) + console.log(`📧 Notified ${adminUsers.length} admin(s) about deletion request`) + } + } + + clearCache() + return { soft_delete: true } + } + + // For draft/pending events, allow hard delete + console.log('🗑️ Event is draft/pending - performing hard delete') const { error } = await supabase .from('events') .delete() @@ -573,6 +628,7 @@ class EventsService { } clearCache() + return { soft_delete: false } } /** diff --git a/lib/services/moderation-service.ts b/lib/services/moderation-service.ts index a1bcb120..447fc360 100644 --- a/lib/services/moderation-service.ts +++ b/lib/services/moderation-service.ts @@ -81,7 +81,7 @@ class ModerationService { `, { count: 'exact' } ) - .eq('approval_status', 'pending') + .in('approval_status', ['pending', 'deleted']) .order('created_at', { ascending: true }) .range(offset, offset + limit - 1) diff --git a/types/events.ts b/types/events.ts index 9f4d7197..80e568cc 100644 --- a/types/events.ts +++ b/types/events.ts @@ -55,7 +55,7 @@ export interface Event { company_id?: string company?: Company created_by?: string - approval_status: 'pending' | 'approved' | 'rejected' | 'changes_requested' + approval_status: 'draft' | 'pending' | 'approved' | 'rejected' | 'changes_requested' | 'deleted' approved_by?: string approved_at?: string rejection_reason?: string diff --git a/types/hackathons.ts b/types/hackathons.ts index fe9f2814..5b22a253 100644 --- a/types/hackathons.ts +++ b/types/hackathons.ts @@ -54,7 +54,7 @@ export interface Hackathon { company_id?: string company?: Company created_by?: string - approval_status: 'pending' | 'approved' | 'rejected' | 'changes_requested' + approval_status: 'draft' | 'pending' | 'approved' | 'rejected' | 'changes_requested' | 'deleted' approved_by?: string approved_at?: string rejection_reason?: string