Skip to content
Closed
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
40 changes: 40 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);
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,17 @@ export default function ProjectManagement() {
setDetailsDialog(true);
};

const handleDelete = (project: RejectedProject) => {
setCurrentProject(project);
setDeleteDialog(true);
};

const handleConfirmDelete = async () => {
if (currentProject?.id) {
await deleteProject(currentProject.id);
Comment thread
Sithumli marked this conversation as resolved.
Outdated
}
};

const { toggleFeature, isLoading: isFeatureToggling } = useToggleProjectFeature({
onSuccess: () => refreshApproved()
});
Expand Down Expand Up @@ -310,6 +337,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 +464,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>
);
}
191 changes: 191 additions & 0 deletions app/api/admin/projects/[projectId]/delete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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) {
return pathParts[1]; // The blob name is the second part
Comment thread
Sithumli marked this conversation as resolved.
Outdated
}
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> {
const account = process.env.NEXT_PUBLIC_AZURE_STORAGE_ACCOUNT_NAME;
const container = process.env.NEXT_PUBLIC_AZURE_STORAGE_CONTAINER_NAME;
const sas = 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
}
}

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

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: typeof prisma) => {
const contentId = project.projectContent?.content_id;

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

// Delete blobs in the background (non-blocking)
Promise.all(blobsToDelete.map(deleteBlob)).catch((error) => {
console.error("Error deleting blobs:", error);
});

Comment thread
Sithumli marked this conversation as resolved.
Outdated
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 }
);
}
}
59 changes: 59 additions & 0 deletions components/dialogs/DeleteProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// © 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) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
Comment thread
Sithumli marked this conversation as resolved.
Outdated
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Project?</DialogTitle>
<DialogDescription>
{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>
Comment thread
Sithumli marked this conversation as resolved.
Outdated
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
{loading ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading