diff --git a/app/(admin)/admin/projects/page.tsx b/app/(admin)/admin/projects/page.tsx index 66fc9108..2eee2582 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,6 +42,7 @@ export default function ProjectManagement() { const [detailsDialog, setDetailsDialog] = useState(false); const [bulkApproveDialog, setBulkApproveDialog] = useState(false); const [duplicateDialog, setDuplicateDialog] = useState(false); + const [deleteDialog, setDeleteDialog] = useState(false); const [currentProject, setCurrentProject] = useState(null); const [currentTab, setCurrentTab] = useState<'pending' | 'approved' | 'rejected'>('pending'); const [lastFetchedTime, setLastFetchedTime] = 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))} @@ -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..35bf816c --- /dev/null +++ b/app/api/admin/projects/[projectId]/delete/route.ts @@ -0,0 +1,209 @@ +import { prisma } from "@/prisma/prismaClient"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { Role } from "@/types/prisma-types"; +import { BlobServiceClient } from "@azure/storage-blob"; + +/** + * 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 + 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 || process.env.NEXT_PUBLIC_AZURE_STORAGE_ACCOUNT_NAME; + const container = process.env.AZURE_STORAGE_CONTAINER_NAME || process.env.NEXT_PUBLIC_AZURE_STORAGE_CONTAINER_NAME; + const sas = process.env.AZURE_SAS_TOKEN || process.env.NEXT_PUBLIC_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; + + if (!projectId) { + return NextResponse.json( + { error: "Project ID is required" }, + { status: 400 } + ); + } + + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check user role in DB - only ADMIN can delete projects + const user = await prisma.user.findUnique({ + where: { user_id: session.user.id }, + select: { role: true }, + }); + + if (!user || user.role !== Role.ADMIN) { + return NextResponse.json( + { error: "Forbidden. Only administrators can delete projects." }, + { status: 403 } + ); + } + + try { + // Fetch the project with all related data for deletion + const project = await prisma.projectMetadata.findUnique({ + where: { project_id: projectId }, + include: { + projectContent: { + include: { + projectDetails: true, + status: true, + associations: true, + slides: true, + team: true, + socialLinks: true, + }, + }, + }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Only allow deletion of rejected projects + if (project.projectContent?.status?.approved_status !== "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 + const blobDeletionResults = await Promise.allSettled( + blobsToDelete.map(deleteBlob) + ); + + const blobDeletionErrors = blobDeletionResults.filter( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + + if (blobDeletionErrors.length > 0) { + console.error("Error deleting one or more blobs:", blobDeletionErrors); + } + + console.log(`Project ${projectId} deleted successfully by user ${session.user.id}`); + + return NextResponse.json({ + success: true, + message: "Project deleted successfully", + }); + } catch (error) { + console.error("Error deleting project:", error); + return NextResponse.json( + { error: "Internal server error" }, + { 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..43b05d33 --- /dev/null +++ b/hooks/project/useDeleteProject.ts @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import axios from 'axios'; + +interface UseDeleteProjectOptions { + onSuccess?: () => void; + onError?: (error: any) => void; +} + +export function useDeleteProject(options: UseDeleteProjectOptions = {}) { + const { onSuccess, onError } = options; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const deleteProject = async (projectId: string | number) => { + setLoading(true); + setError(null); + setSuccess(false); + try { + await axios.delete(`/api/admin/projects/${projectId}/delete`); + setSuccess(true); + onSuccess && onSuccess(); + } catch (err: any) { + const errorMessage = err.response?.data?.error || err.message || 'Failed to delete project'; + setError(errorMessage); + onError && onError(err); + throw err; + } finally { + setLoading(false); + } + }; + + const clearState = () => { + setError(null); + setSuccess(false); + }; + + return { + deleteProject, + loading, + error, + success, + clearState, + }; +}