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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ GOOGLE_CLIENT_SECRET =
NEXTAUTH_SECRET =
EMAIL_USER =
EMAIL_PASS =

# cloudinary
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
134 changes: 134 additions & 0 deletions app/api/cases/[id]/documents/[docId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/options";
import dbConnect from "@/lib/dbConnect";
import DocumentModel from "@/model/Document";
import { deleteFromCloudinary, isCloudinaryConfigured, getCloudinaryFileBuffer } from "@/lib/cloudinary";

// GET /api/cases/[id]/documents/[docId] - Download a document
export async function GET(
request: NextRequest,
{ params }: { params: { id: string; docId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?._id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await dbConnect();
const { id, docId } = await params;

// Find document and verify ownership
const document = await DocumentModel.findOne({
_id: docId,
caseId: id,
userId: session.user._id
});

if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
Comment on lines +20 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate ObjectId format for id and docId to avoid CastError 500s.

Return 400 early on invalid IDs.

-    const { id, docId } = params;
+    const { id, docId } = params;
+    if (!/^[0-9a-fA-F]{24}$/.test(id) || !/^[0-9a-fA-F]{24}$/.test(docId)) {
+      return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
+    }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/api/cases/[id]/documents/[docId]/route.ts around lines 20 to 31, the
handler directly uses id and docId in a Mongoose query which can throw a
CastError for invalid ObjectId strings; add early validation for both id and
docId using mongoose.Types.ObjectId.isValid (or equivalent) and if either is
invalid return NextResponse.json({ error: "Invalid id or docId" }, { status: 400
}) before querying the database so malformed IDs never reach Mongoose.


// Check if Cloudinary is configured
if (!isCloudinaryConfigured()) {
return NextResponse.json(
{ error: "Cloudinary is not configured. Please set up CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET environment variables." },
{ status: 500 }
);
}

// Update download count and last accessed
await DocumentModel.findByIdAndUpdate(docId, {
$inc: { downloadCount: 1 },
lastAccessed: new Date()
});

Comment on lines +41 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Count downloads only on successful fetch; harden Content-Disposition with ASCII fallback + RFC 5987.

  • Move downloadCount/lastAccessed update to after successful Cloudinary fetch.
  • Sanitize filename, add filename* for UTF‑8, and default Content-Type.
-    // Update download count and last accessed
-    await DocumentModel.findByIdAndUpdate(docId, {
-      $inc: { downloadCount: 1 },
-      lastAccessed: new Date()
-    });
-      // Return file with appropriate headers
-      // Properly encode filename for HTTP headers to handle Unicode characters
-      const encodedFilename = encodeURIComponent(document.originalName);
-      const contentDisposition = `attachment; filename*=UTF-8''${encodedFilename}`;
-      
-      return new NextResponse(fileBuffer, {
+      // Update metrics after successful fetch
+      await DocumentModel.findByIdAndUpdate(docId, {
+        $inc: { downloadCount: 1 },
+        lastAccessed: new Date()
+      });
+
+      // Return file with safe, standards-compliant headers
+      const safeName = (document.originalName || "download").replace(/[\r\n"]/g, "_");
+      const encoded = encodeURIComponent(safeName);
+      const contentDisposition = `attachment; filename="${safeName}"; filename*=UTF-8''${encoded}`;
+
+      return new NextResponse(fileBuffer, {
         headers: {
-          "Content-Type": document.mimeType,
+          "Content-Type": document.mimeType || "application/octet-stream",
           "Content-Disposition": contentDisposition,
           "Content-Length": fileBuffer.length.toString(),
           "Cache-Control": "no-cache",
         },
       });

Also applies to: 62-74

🤖 Prompt for AI Agents
In app/api/cases/[id]/documents/[docId]/route.ts around lines 41-46 (and
similarly 62-74), the code updates downloadCount and lastAccessed before
ensuring the Cloudinary fetch succeeded and sets Content-Disposition without a
sanitized ASCII fallback or RFC 5987 UTF-8 fallback and may not set a default
Content-Type; change the flow so the DocumentModel.findByIdAndUpdate(...) call
is executed only after a successful response/stream from Cloudinary, sanitize
the filename to remove/control unsafe characters for the ASCII filename
parameter, add a filename* parameter with RFC 5987 percent-encoded UTF-8 value,
set Content-Type to the response.contentType or a safe default like
application/octet-stream, and ensure the Content-Disposition header includes
both the sanitized filename and the filename* UTF-8 fallback.

// Get file buffer directly from Cloudinary using API
console.log("Fetching file from Cloudinary using API:", document.cloudinaryPublicId);

try {
// Determine resource type based on file type
// const resourceType = document.mimeType === 'application/pdf' ? 'raw' : 'image';
const resourceType = document.mimeType?.startsWith('image/') ? 'image' : 'raw';
const fileBuffer = await getCloudinaryFileBuffer(document.cloudinaryPublicId, resourceType);
console.log("Downloaded file:", {
fileName: document.originalName,
mimeType: document.mimeType,
fileSize: document.fileSize,
bufferSize: fileBuffer.length
});

// Return file with appropriate headers
// Properly encode filename for HTTP headers to handle Unicode characters
const encodedFilename = encodeURIComponent(document.originalName);
const contentDisposition = `attachment; filename*=UTF-8''${encodedFilename}`;

return new NextResponse(fileBuffer, {
headers: {
"Content-Type": document.mimeType,
"Content-Disposition": contentDisposition,
"Content-Length": fileBuffer.length.toString(),
"Cache-Control": "no-cache",
},
});
} catch (error) {
console.error("Failed to fetch file from Cloudinary:", error);
return NextResponse.json({
error: "Failed to fetch file from Cloudinary. Please check file permissions."
}, { status: 500 });
}
} catch (error) {
console.error("Error downloading document:", error);
return NextResponse.json(
{ error: "Failed to download document" },
{ status: 500 }
);
}
}

// DELETE /api/cases/[id]/documents/[docId] - Delete a document
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string; docId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?._id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await dbConnect();
const { id, docId } = await params;

// Find document and verify ownership
const document = await DocumentModel.findOne({
_id: docId,
caseId: id,
userId: session.user._id
});

if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}

// Delete file from Cloudinary
try {
await deleteFromCloudinary(document.cloudinaryPublicId);
} catch (error) {
console.error("Error deleting file from Cloudinary:", error);
// Continue with database deletion even if Cloudinary deletion fails
}
Comment on lines +115 to +121
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ensure Cloudinary deletion works for non-images by passing resource_type.

Raw assets (e.g., PDFs, DOCX) won’t delete with default image resource_type. Detect from mimeType and pass through.

-    try {
-      await deleteFromCloudinary(document.cloudinaryPublicId);
+    try {
+      const resourceType = document.mimeType?.startsWith('image/') ? 'image' : 'raw';
+      await deleteFromCloudinary(document.cloudinaryPublicId, resourceType);

Additionally update lib/cloudinary.ts:

-export async function deleteFromCloudinary(publicId: string): Promise<void> {
+export async function deleteFromCloudinary(publicId: string, resourceType: 'image' | 'raw' = 'image'): Promise<void> {
   return new Promise((resolve, reject) => {
-    cloudinary.uploader.destroy(publicId, (error, result) => {
+    cloudinary.uploader.destroy(publicId, { resource_type: resourceType }, (error, result) => {
       if (error) {
         reject(error);
       } else {
         resolve();
       }
     });
   });
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Delete file from Cloudinary
try {
await deleteFromCloudinary(document.cloudinaryPublicId);
} catch (error) {
console.error("Error deleting file from Cloudinary:", error);
// Continue with database deletion even if Cloudinary deletion fails
}
// File: app/api/cases/[id]/documents/[docId]/route.ts
// Delete file from Cloudinary
try {
const resourceType = document.mimeType?.startsWith('image/') ? 'image' : 'raw';
await deleteFromCloudinary(document.cloudinaryPublicId, resourceType);
} catch (error) {
console.error("Error deleting file from Cloudinary:", error);
// Continue with database deletion even if Cloudinary deletion fails
}
Suggested change
// Delete file from Cloudinary
try {
await deleteFromCloudinary(document.cloudinaryPublicId);
} catch (error) {
console.error("Error deleting file from Cloudinary:", error);
// Continue with database deletion even if Cloudinary deletion fails
}
// File: lib/cloudinary.ts
export async function deleteFromCloudinary(
publicId: string,
resourceType: 'image' | 'raw' = 'image'
): Promise<void> {
return new Promise((resolve, reject) => {
cloudinary.uploader.destroy(
publicId,
{ resource_type: resourceType },
(error, result) => {
if (error) {
reject(error);
} else {
resolve();
}
}
);
});
}
🤖 Prompt for AI Agents
In app/api/cases/[id]/documents/[docId]/route.ts around lines 115 to 121, the
Cloudinary deletion call always uses the default image resource_type so
non-image raw assets (PDF, DOCX, etc.) fail to delete; change the code to derive
resource_type from document.mimeType (e.g., if mimeType starts with "image/" use
"image", otherwise use "raw") and pass that resource_type into
deleteFromCloudinary; also update lib/cloudinary.ts to accept an optional
resource_type parameter and forward it to the Cloudinary destroy/delete API so
deletions for raw files succeed.


// Delete document record
await DocumentModel.findByIdAndDelete(docId);

return NextResponse.json({ message: "Document deleted successfully" });
} catch (error) {
console.error("Error deleting document:", error);
return NextResponse.json(
{ error: "Failed to delete document" },
{ status: 500 }
);
}
}
160 changes: 160 additions & 0 deletions app/api/cases/[id]/documents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/options";
import dbConnect from "@/lib/dbConnect";
import CaseModel from "@/model/Case";
import DocumentModel from "@/model/Document";
import { uploadToCloudinary, isCloudinaryConfigured } from "@/lib/cloudinary";

// GET /api/cases/[id]/documents - Get all documents for a case
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?._id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await dbConnect();
const { id } = await params;

// Verify case exists and belongs to user
const caseData = await CaseModel.findOne({
_id: id,
userId: session.user._id
});
Comment on lines +21 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Validate ObjectId format to avoid CastError 500s on invalid IDs.

Mirror the validation used in other routes and return 400 early.

-    const { id } = params;
+    const { id } = params;
+    if (!id.match(/^[0-9a-fA-F]{24}$/)) {
+      return NextResponse.json(
+        { error: "Invalid case ID format" },
+        { status: 400 }
+      );
+    }

Also applies to: 60-67

🤖 Prompt for AI Agents
In app/api/cases/[id]/documents/route.ts around lines 21-27 (and similarly
around 60-67), the route currently uses the raw id param in a Mongoose query
which will throw a CastError for malformed IDs; before querying, validate the id
with mongoose.Types.ObjectId.isValid (or the project's existing helper) and if
invalid return a 400 Bad Request response with an explanatory message, otherwise
proceed to call CaseModel.findOne using the validated id.


if (!caseData) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}

// Get documents for this case
const documents = await DocumentModel.find({
caseId: id,
userId: session.user._id
}).sort({ uploadDate: -1 });

return NextResponse.json({ documents });
} catch (error) {
console.error("Error fetching documents:", error);
return NextResponse.json(
{ error: "Failed to fetch documents" },
{ status: 500 }
);
}
}

// POST /api/cases/[id]/documents - Upload a new document
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?._id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await dbConnect();
const { id } = await params;

// Verify case exists and belongs to user
const caseData = await CaseModel.findOne({
_id: id,
userId: session.user._id
});

if (!caseData) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}

const formData = await request.formData();
const file = formData.get("file") as File;
const documentType = formData.get("documentType") as string;
const description = formData.get("description") as string;
const tags = formData.get("tags") as string;

if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}

// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: "File size exceeds 10MB limit" },
{ status: 400 }
);
}

// Validate file type
const allowedTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
"image/jpeg",
"image/png",
"image/gif"
];

if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "File type not allowed" },
{ status: 400 }
);
}

// Generate unique filename
const timestamp = Date.now();
const fileExtension = file.name.split('.').pop();
const fileName = `${id}_${timestamp}.${fileExtension}`;

// Check if Cloudinary is configured
if (!isCloudinaryConfigured()) {
return NextResponse.json(
{ error: "Cloudinary is not configured. Please set up CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET environment variables." },
{ status: 500 }
);
}

// Convert file to buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);

// Upload to Cloudinary
const cloudinaryResult = await uploadToCloudinary(
buffer,
fileName,
`legal-ease/cases/${id}/documents`
);

// Create document record
const document = new DocumentModel({
caseId: id,
fileName: fileName,
originalName: file.name,
fileSize: file.size,
mimeType: file.type,
cloudinaryUrl: cloudinaryResult.url,
cloudinaryPublicId: cloudinaryResult.public_id,
uploadedBy: session.user._id,
documentType: documentType || "other",
description: description || "",
tags: tags ? tags.split(',').map(tag => tag.trim()) : [],
userId: session.user._id
});

await document.save();

return NextResponse.json({ document }, { status: 201 });
} catch (error) {
console.error("Error uploading document:", error);
return NextResponse.json(
{ error: "Failed to upload document" },
{ status: 500 }
);
}
}
Loading