Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
50 changes: 48 additions & 2 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,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<any>(null);
const [deleteDialog, setDeleteDialog] = useState(false);
const [currentProject, setCurrentProject] = useState<PendingProject | ApprovedProject | RejectedProject | null>(null);
const [currentTab, setCurrentTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
const [lastFetchedTime, setLastFetchedTime] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
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 @@ -402,7 +437,7 @@ export default function ProjectManagement() {
<ApproveDialog
open={approveDialog}
onOpenChange={setApproveDialog}
projectID={currentProject.id}
projectID={String(currentProject.id)}
onApproved={refreshPending}
/>
)}
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>
);
}
226 changes: 226 additions & 0 deletions app/api/admin/projects/[projectId]/delete/route.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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
}
Comment thread
Sithumli marked this conversation as resolved.
}

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 }
);
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
// 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 }
);
}
}
Loading