diff --git a/app/(admin)/admin/projects/page.tsx b/app/(admin)/admin/projects/page.tsx index 66fc9108..eee13dd6 100644 --- a/app/(admin)/admin/projects/page.tsx +++ b/app/(admin)/admin/projects/page.tsx @@ -6,6 +6,7 @@ 'use client'; import ApproveDialog from '@/components/dialogs/ApproveDialog'; +import DeleteProjectDialog from '@/components/dialogs/DeleteProjectDialog'; import DetailsDialog from '@/components/dialogs/DetailsDialog'; import RejectDialog from '@/components/dialogs/RejectDialog'; import { ApprovedProjectsTable } from '@/components/tables/ApprovedProjectsTable'; @@ -16,6 +17,7 @@ import ApprovedProjectsTableSkeleton from '@/components/tables/skeletons/Approve import RejectedProjectsTableSkeleton from '@/components/tables/skeletons/RejectedProjectsTableSkeleton'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useDeleteProject } from '@/hooks/project/useDeleteProject'; import { useGetProjectsByApprovalStatus } from '@/hooks/project/useGetProjectsByApprovalStatus'; import { useToggleProjectFeature } from '@/hooks/project/useToggleProjectFeature'; import { ApprovedProject, PendingProject, RejectedProject } from '@/types/project/response'; @@ -29,6 +31,7 @@ import { Button } from '@/components/ui/button'; import BulkApproveDialog from '@/components/dialogs/BulkApproveDialog'; import DuplicatePendingProjectsDialog from '@/components/dialogs/DuplicatePendingProjectsDialog'; import { useSession } from 'next-auth/react'; +import { toast } from 'sonner'; const projectStatuses = ['IDEA', 'RESEARCH', 'MVP', 'DEPLOYED', 'STARTUP']; @@ -39,7 +42,8 @@ export default function ProjectManagement() { const [detailsDialog, setDetailsDialog] = useState(false); const [bulkApproveDialog, setBulkApproveDialog] = useState(false); const [duplicateDialog, setDuplicateDialog] = useState(false); - const [currentProject, setCurrentProject] = useState(null); + const [deleteDialog, setDeleteDialog] = useState(false); + const [currentProject, setCurrentProject] = useState(null); const [currentTab, setCurrentTab] = useState<'pending' | 'approved' | 'rejected'>('pending'); const [lastFetchedTime, setLastFetchedTime] = useState(''); const [searchQuery, setSearchQuery] = useState(''); @@ -59,6 +63,18 @@ export default function ProjectManagement() { const { data: session } = useSession(); const isAdmin = session?.user?.role === 'ADMIN'; + const { deleteProject, loading: isDeleting } = useDeleteProject({ + onSuccess: () => { + toast.success('Project deleted successfully'); + refreshRejected(); + setDeleteDialog(false); + setCurrentProject(null); + }, + onError: () => { + toast.error('Failed to delete project'); + }, + }); + // Debounce search query to avoid too many API calls useEffect(() => { const timer = setTimeout(() => { @@ -183,6 +199,23 @@ export default function ProjectManagement() { setDetailsDialog(true); }; + const handleDelete = (project: RejectedProject) => { + setCurrentProject(project); + setDeleteDialog(true); + }; + + const handleConfirmDelete = async () => { + if (!currentProject?.id) { + return; + } + + try { + await deleteProject(currentProject.id); + } catch { + // Error feedback is handled by the delete hook; keep the rejection contained to this UI flow. + } + }; + const { toggleFeature, isLoading: isFeatureToggling } = useToggleProjectFeature({ onSuccess: () => refreshApproved() }); @@ -310,6 +343,8 @@ export default function ProjectManagement() { setRejectedSort((prev) => toggleSort(prev, column))} @@ -402,7 +437,7 @@ export default function ProjectManagement() { )} @@ -435,6 +470,17 @@ export default function ProjectManagement() { onRejected={refreshPending} /> )} + + {deleteDialog && currentProject && ( + + )} ); } diff --git a/app/api/admin/projects/[projectId]/delete/route.ts b/app/api/admin/projects/[projectId]/delete/route.ts new file mode 100644 index 00000000..4f1f7883 --- /dev/null +++ b/app/api/admin/projects/[projectId]/delete/route.ts @@ -0,0 +1,226 @@ +import { prisma } from "@/prisma/prismaClient"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { ProjectApprovalStatus, AuditAction } from "@/types/prisma-types"; +import { BlobServiceClient } from "@azure/storage-blob"; +import { createAuditLog } from "@/lib/audit"; + +/** + * Extracts the blob name from an Azure Blob Storage URL. + * URL format: https://{account}.blob.core.windows.net/{container}/{blobName} + */ +function extractBlobName(url: string | null | undefined): string | null { + if (!url) return null; + try { + const urlObj = new URL(url); + // Path is like /{container}/{blobName} + const pathParts = urlObj.pathname.split("/").filter(Boolean); + if (pathParts.length >= 2) { + // Join all parts after the container name to handle virtual folders (e.g., "projects/cover/abc.png") + return pathParts.slice(1).join('/'); + } + return null; + } catch { + return null; + } +} + +/** + * Deletes a blob from Azure Blob Storage. + * Fails silently to avoid blocking project deletion. + */ +async function deleteBlob(blobName: string): Promise { + // Use server-only env vars for sensitive credentials + const account = process.env.AZURE_STORAGE_ACCOUNT_NAME; + const container = process.env.AZURE_STORAGE_CONTAINER_NAME; + const sas = process.env.AZURE_SAS_TOKEN; + + if (!account || !container || !sas) { + console.warn("Azure blob storage not configured, skipping blob deletion"); + return; + } + + try { + const blobServiceClient = new BlobServiceClient( + `https://${account}.blob.core.windows.net/?${sas}` + ); + const containerClient = blobServiceClient.getContainerClient(container); + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + await blockBlobClient.deleteIfExists(); + } catch (error) { + console.error(`Failed to delete blob ${blobName}:`, error); + // Fail silently - don't block project deletion + } +} + +export async function DELETE( + req: NextRequest, + context: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await context.params; + const startTime = Date.now(); + + if (!projectId) { + return NextResponse.json( + { error: "Project ID is required" }, + { status: 400 } + ); + } + + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Role-based authorization check - only ADMIN can delete projects + const { role } = session.user; + if (role !== "ADMIN") { + return NextResponse.json( + { error: "Forbidden. Only administrators can delete projects." }, + { status: 403 } + ); + } + + const userId = session.user.id; + + try { + // Fetch only the fields needed for deletion checks and blob cleanup + const project = await prisma.projectMetadata.findUnique({ + where: { project_id: projectId }, + select: { + title: true, + sdgp_year: true, + group_num: true, + cover_image: true, + logo: true, + projectContent: { + select: { + content_id: true, + status: { + select: { + approved_status: true, + }, + }, + team: { + select: { + profile_image: true, + }, + }, + }, + }, + }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Only allow deletion of rejected projects + if (project.projectContent?.status?.approved_status !== ProjectApprovalStatus.REJECTED) { + return NextResponse.json( + { error: "Only rejected projects can be deleted" }, + { status: 409 } + ); + } + + // Collect blob URLs for deletion + const blobsToDelete: string[] = []; + + // Add cover image and logo + const coverBlobName = extractBlobName(project.cover_image); + if (coverBlobName) blobsToDelete.push(coverBlobName); + + const logoBlobName = extractBlobName(project.logo); + if (logoBlobName) blobsToDelete.push(logoBlobName); + + // Add team member profile images + if (project.projectContent?.team) { + for (const member of project.projectContent.team) { + const profileBlobName = extractBlobName(member.profile_image); + if (profileBlobName) blobsToDelete.push(profileBlobName); + } + } + + // Delete in the correct order due to FK constraints (onDelete: Restrict) + // All child tables must be deleted before their parent + await prisma.$transaction(async (tx) => { + const contentId = project.projectContent?.content_id; + + if (contentId) { + // Delete ProjectDetails (depends on ProjectContent) + await tx.projectDetails.deleteMany({ + where: { content_id: contentId }, + }); + + // Delete ProjectStatus (depends on ProjectContent) + await tx.projectStatus.deleteMany({ + where: { content_id: contentId }, + }); + + // Delete ProjectAssociation records (depends on ProjectContent) + await tx.projectAssociation.deleteMany({ + where: { content_id: contentId }, + }); + + // Delete ProjectSlide records (depends on ProjectContent) + await tx.projectSlide.deleteMany({ + where: { content_id: contentId }, + }); + + // Delete ProjectTeam records (depends on ProjectContent) + await tx.projectTeam.deleteMany({ + where: { content_id: contentId }, + }); + + // Delete ProjectSocialLink records (depends on ProjectContent) + await tx.projectSocialLink.deleteMany({ + where: { content_id: contentId }, + }); + + // Delete ProjectContent (depends on ProjectMetadata) + await tx.projectContent.delete({ + where: { content_id: contentId }, + }); + } + + // Delete ProjectMetadata (will cascade delete Awards via relation) + await tx.projectMetadata.delete({ + where: { project_id: projectId }, + }); + }); + + // Attempt blob cleanup before returning so deletion is reliable in serverless runtimes + // Note: deleteBlob handles errors internally and logs them, so Promise.all won't reject + await Promise.all(blobsToDelete.map(deleteBlob)); + + // Create audit log entry for this deletion + await createAuditLog({ + action: AuditAction.PROJECT_DELETED, + userId, + entityType: "PROJECT", + entityId: projectId, + metadata: { + title: project.title, + groupNumber: project.group_num, + sdgpYear: project.sdgp_year, + blobsDeleted: blobsToDelete.length, + deletionDurationMs: Date.now() - startTime, + }, + request: req, + }); + + console.log(`Project ${projectId} deleted successfully by user ${userId}`); + + return NextResponse.json({ + success: true, + message: "Project deleted successfully", + }); + } catch (error: any) { + console.error("Error deleting project:", error); + return NextResponse.json( + { error: "Failed to delete project", details: error.message }, + { status: 500 } + ); + } +} diff --git a/components/dialogs/DeleteProjectDialog.tsx b/components/dialogs/DeleteProjectDialog.tsx new file mode 100644 index 00000000..ce11d3f1 --- /dev/null +++ b/components/dialogs/DeleteProjectDialog.tsx @@ -0,0 +1,70 @@ +// © 2026 SDGP.lk +// Licensed under the GNU Affero General Public License v3.0 or later, +// with an additional restriction: Non-commercial use only. +// See for details. +import React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface DeleteProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + loading?: boolean; + projectTitle?: string; + groupNumber?: string; +} + +export default function DeleteProjectDialog({ + open, + onOpenChange, + onConfirm, + loading, + projectTitle, + groupNumber, +}: DeleteProjectDialogProps) { + // Prevent dialog close while delete is in progress + const handleOpenChange = (newOpen: boolean) => { + if (loading) return; + onOpenChange(newOpen); + }; + + return ( + + loading && e.preventDefault()} + onPointerDownOutside={(e) => loading && e.preventDefault()} + > + + Delete Project? + +
+ {projectTitle && groupNumber ? ( + <> + You are about to delete "{projectTitle}" (Group {groupNumber}). +

+ + ) : null} + This will permanently delete: +
    +
  • All project details, slides, and team information
  • +
  • All associated images from storage
  • +
  • Any awards linked to this project
  • +
+
+ This action cannot be undone. +
+
+
+ + + + +
+
+ ); +} diff --git a/components/tables/RejectedProjectsTable.tsx b/components/tables/RejectedProjectsTable.tsx index ed9d7413..7b7facd4 100644 --- a/components/tables/RejectedProjectsTable.tsx +++ b/components/tables/RejectedProjectsTable.tsx @@ -3,11 +3,13 @@ import { Button } from '@/components/ui/button'; import { Pagination, PaginationPrevious, PaginationNext } from '@/components/ui/pagination'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { RejectedProject } from '@/types/project/response'; -import { ArrowUpDown, ChevronDown, ChevronUp } from 'lucide-react'; +import { ArrowUpDown, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'; interface RejectedProjectsTableProps { projects: RejectedProject[]; onViewDetails: (project: RejectedProject) => void; + onDelete?: (project: RejectedProject) => void; + showDeleteButton?: boolean; sortBy: string; sortDir: 'asc' | 'desc'; onSortChange: (column: string) => void; @@ -20,6 +22,8 @@ interface RejectedProjectsTableProps { export function RejectedProjectsTable({ projects, onViewDetails, + onDelete, + showDeleteButton = false, sortBy, sortDir, onSortChange, @@ -91,9 +95,26 @@ export function RejectedProjectsTable({ - +
+ + {showDeleteButton && onDelete && ( + + + + + Delete Project + + )} +
))} diff --git a/hooks/project/useDeleteProject.ts b/hooks/project/useDeleteProject.ts new file mode 100644 index 00000000..64b79015 --- /dev/null +++ b/hooks/project/useDeleteProject.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +interface UseDeleteProjectOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export function useDeleteProject(options: UseDeleteProjectOptions = {}) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteProject = async (projectId: number | string) => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/admin/projects/${projectId}/delete`, { + method: 'DELETE', + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to delete project'); + } + + options.onSuccess?.(); + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to delete project'); + setError(error); + options.onError?.(error); + throw error; + } finally { + setLoading(false); + } + }; + + return { + deleteProject, + loading, + error, + }; +} diff --git a/lib/audit.ts b/lib/audit.ts new file mode 100644 index 00000000..6f3ae52c --- /dev/null +++ b/lib/audit.ts @@ -0,0 +1,57 @@ +// © 2026 SDGP.lk +// Licensed under the GNU Affero General Public License v3.0 or later, +// with an additional restriction: Non-commercial use only. +// See for details. + +import { prisma } from "@/prisma/prismaClient"; +import { AuditAction, Prisma } from "@prisma/client"; +import { NextRequest } from "next/server"; + +interface CreateAuditLogParams { + action: AuditAction; + userId: string; + entityType: string; + entityId: string; + metadata?: Prisma.InputJsonValue; + request?: NextRequest; +} + +/** + * Creates an audit log entry for administrative actions. + * This provides a permanent record of who performed what action and when. + */ +export async function createAuditLog({ + action, + userId, + entityType, + entityId, + metadata, + request, +}: CreateAuditLogParams): Promise { + try { + const ipAddress = request + ? request.headers.get("x-forwarded-for")?.split(",")[0].trim() || + request.headers.get("x-real-ip") || + null + : null; + + const userAgent = request + ? request.headers.get("user-agent") || null + : null; + + await prisma.auditLog.create({ + data: { + action, + userId, + entityType, + entityId, + metadata: metadata ?? Prisma.JsonNull, + ipAddress, + userAgent, + }, + }); + } catch (error) { + // Log the error but don't throw - audit logging should never block the main operation + console.error("Failed to create audit log:", error); + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70462eb4..795ba077 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -149,6 +149,20 @@ enum ApprovalStatus { REJECTED } +enum AuditAction { + PROJECT_DELETED + PROJECT_APPROVED + PROJECT_REJECTED + PROJECT_FEATURED + PROJECT_UNFEATURED + AWARD_APPROVED + AWARD_REJECTED + COMPETITION_APPROVED + COMPETITION_REJECTED + BLOG_POST_APPROVED + BLOG_POST_REJECTED +} + // ===================== NextAuth Adapter Models ===================== enum EmailOutboxStatus { PENDING @@ -230,6 +244,7 @@ model User { rejectedCompetitions Competition[] @relation("RejectedCompetitions") approvedBlogPosts BlogPost[] @relation("BlogPostsApprovedBy") rejectedBlogPosts BlogPost[] @relation("BlogPostsRejectedBy") + auditLogs AuditLog[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -535,3 +550,22 @@ model BlogAuthor { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model AuditLog { + id String @id @default(uuid()) + action AuditAction + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + entityType String // e.g., "PROJECT", "AWARD", "COMPETITION", "BLOG_POST" + entityId String // The ID of the affected entity + metadata Json? // Additional context (e.g., project title, group number, etc.) + ipAddress String? + userAgent String? + + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([action]) + @@index([entityType, entityId]) + @@index([createdAt]) +} diff --git a/types/prisma-types.ts b/types/prisma-types.ts index 124e6d76..62e36f08 100644 --- a/types/prisma-types.ts +++ b/types/prisma-types.ts @@ -133,4 +133,18 @@ export enum ApprovalStatus { PENDING = "PENDING", APPROVED = "APPROVED", REJECTED = "REJECTED", +} + +export enum AuditAction { + PROJECT_DELETED = "PROJECT_DELETED", + PROJECT_APPROVED = "PROJECT_APPROVED", + PROJECT_REJECTED = "PROJECT_REJECTED", + PROJECT_FEATURED = "PROJECT_FEATURED", + PROJECT_UNFEATURED = "PROJECT_UNFEATURED", + AWARD_APPROVED = "AWARD_APPROVED", + AWARD_REJECTED = "AWARD_REJECTED", + COMPETITION_APPROVED = "COMPETITION_APPROVED", + COMPETITION_REJECTED = "COMPETITION_REJECTED", + BLOG_POST_APPROVED = "BLOG_POST_APPROVED", + BLOG_POST_REJECTED = "BLOG_POST_REJECTED", } \ No newline at end of file