Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions app/(admin)/admin/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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'];

Expand All @@ -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<any>(null);
Comment thread
Sithumli marked this conversation as resolved.
Outdated
const [currentTab, setCurrentTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
const [lastFetchedTime, setLastFetchedTime] = useState<string>('');
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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()
});
Expand Down Expand Up @@ -310,6 +343,8 @@ export default function ProjectManagement() {
<RejectedProjectsTable
projects={rejectedProjects}
onViewDetails={handleViewDetails}
onDelete={handleDelete}
showDeleteButton={isAdmin}
sortBy={rejectedSort.by}
sortDir={rejectedSort.dir}
onSortChange={(column) => setRejectedSort((prev) => toggleSort(prev, column))}
Expand Down Expand Up @@ -435,6 +470,17 @@ export default function ProjectManagement() {
onRejected={refreshPending}
/>
)}

{deleteDialog && currentProject && (
<DeleteProjectDialog
open={deleteDialog}
onOpenChange={setDeleteDialog}
onConfirm={handleConfirmDelete}
loading={isDeleting}
projectTitle={currentProject.title}
groupNumber={currentProject.groupNumber}
/>
)}
</div>
);
}
206 changes: 206 additions & 0 deletions app/api/admin/projects/[projectId]/delete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
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 { 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 (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<void> {
// Use server-only env vars for sensitive credentials (fallback to NEXT_PUBLIC_ for backwards compatibility)
const account = process.env.AZURE_STORAGE_ACCOUNT_NAME || process.env.NEXT_PUBLIC_AZURE_STORAGE_ACCOUNT_NAME;
Comment thread
Sithumli marked this conversation as resolved.
Outdated
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;
Comment thread
Sithumli marked this conversation as resolved.
Outdated

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
}
Comment thread
Sithumli marked this conversation as resolved.
}

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) {
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 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,
Comment thread
Sithumli marked this conversation as resolved.
Outdated
},
},
},
});

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 }
);
Comment thread
Sithumli marked this conversation as resolved.
}

// 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 ${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 }
);
}
}
70 changes: 70 additions & 0 deletions components/dialogs/DeleteProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/agpl-3.0.html> 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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
onEscapeKeyDown={(e) => loading && e.preventDefault()}
onPointerDownOutside={(e) => loading && e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Delete Project?</DialogTitle>
<DialogDescription asChild>
<div>
{projectTitle && groupNumber ? (
<>
You are about to delete <b>&quot;{projectTitle}&quot;</b> (Group {groupNumber}).
<br /><br />
</>
) : null}
This will permanently delete:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>All project details, slides, and team information</li>
<li>All associated images from storage</li>
<li>Any awards linked to this project</li>
</ul>
<br />
<span className="text-destructive font-semibold">This action cannot be undone.</span>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
{loading ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading