-
Notifications
You must be signed in to change notification settings - Fork 11
feat: add project deletion for rejected projects #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| // 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; | ||
|
|
||
|
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 }, | ||
| }); | ||
| }); | ||
|
|
||
| // 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 } | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>"{projectTitle}"</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> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.