diff --git a/.env.example b/.env.example index 138419d..f4cda77 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,8 @@ GOOGLE_CLIENT_SECRET = NEXTAUTH_SECRET = EMAIL_USER = EMAIL_PASS = + +# cloudinary +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= diff --git a/app/api/cases/[id]/documents/[docId]/route.ts b/app/api/cases/[id]/documents/[docId]/route.ts new file mode 100644 index 0000000..2d0be2a --- /dev/null +++ b/app/api/cases/[id]/documents/[docId]/route.ts @@ -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 }); + } + + // 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() + }); + + // 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 + } + + // 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 } + ); + } +} diff --git a/app/api/cases/[id]/documents/route.ts b/app/api/cases/[id]/documents/route.ts new file mode 100644 index 0000000..b5ed989 --- /dev/null +++ b/app/api/cases/[id]/documents/route.ts @@ -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 + }); + + 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 } + ); + } +} diff --git a/app/api/cases/[id]/route.ts b/app/api/cases/[id]/route.ts new file mode 100644 index 0000000..7fa6183 --- /dev/null +++ b/app/api/cases/[id]/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/options"; +import dbConnect from "@/lib/dbConnect"; +import CaseModel from "@/model/Case"; +import TimelineEventModel from "@/model/TimelineEvent"; +import DocumentModel from "@/model/Document"; + +// GET /api/cases/[id] - Fetch a specific case by ID +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 caseId = params.id; + + // Validate ObjectId format + if (!caseId.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid case ID format" }, + { status: 400 } + ); + } + + const caseData = await CaseModel.findOne({ + _id: caseId, + userId: session.user._id + }).lean(); + + if (!caseData) { + return NextResponse.json( + { error: "Case not found" }, + { status: 404 } + ); + } + + return NextResponse.json(caseData); + + } catch (error) { + console.error("Error fetching case:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// PUT /api/cases/[id] - Update a specific case +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?._id) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const caseId = params.id; + const body = await request.json(); + + await dbConnect(); + + // Validate ObjectId format + if (!caseId.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid case ID format" }, + { status: 400 } + ); + } + + // Check if case exists and belongs to user + const existingCase = await CaseModel.findOne({ + _id: caseId, + userId: session.user._id + }); + + if (!existingCase) { + return NextResponse.json( + { error: "Case not found" }, + { status: 404 } + ); + } + + // If case number is being updated, check for duplicates within the same user + if (body.caseNumber && body.caseNumber !== existingCase.caseNumber) { + const duplicateCase = await CaseModel.findOne({ + caseNumber: body.caseNumber, + userId: session.user._id, + _id: { $ne: caseId } + }); + + if (duplicateCase) { + return NextResponse.json( + { error: "Case number already exists" }, + { status: 400 } + ); + } + } + + // Update the case + const updatedCase = await CaseModel.findByIdAndUpdate( + caseId, + { + ...body, + lastUpdated: new Date() + }, + { new: true, runValidators: true } + ); + + return NextResponse.json(updatedCase); + + } catch (error) { + console.error("Error updating case:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// DELETE /api/cases/[id] - Delete a specific case +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?._id) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const caseId = (await params).id; + + await dbConnect(); + + // Validate ObjectId format + if (!caseId.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid case ID format" }, + { status: 400 } + ); + } + + // Check if case exists and belongs to user + const existingCase = await CaseModel.findOne({ + _id: caseId, + userId: session.user._id + }); + + if (!existingCase) { + return NextResponse.json( + { error: "Case not found" }, + { status: 404 } + ); + } + + // Delete the case + // await CaseModel.findByIdAndDelete(caseId); + + // Delete the case and related data + await Promise.all([ + CaseModel.findOneAndDelete({ _id: caseId, userId: session.user._id }), + TimelineEventModel.deleteMany({ caseId, userId: session.user._id }), + DocumentModel.deleteMany({ caseId, userId: session.user._id }), + ]); + + return NextResponse.json( + { message: "Case deleted successfully" }, + { status: 200 } + ); + + } catch (error) { + console.error("Error deleting case:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/cases/[id]/timeline/[eventId]/route.ts b/app/api/cases/[id]/timeline/[eventId]/route.ts new file mode 100644 index 0000000..67654e8 --- /dev/null +++ b/app/api/cases/[id]/timeline/[eventId]/route.ts @@ -0,0 +1,153 @@ +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 TimelineEventModel from "@/model/TimelineEvent"; + +// DELETE /api/cases/[id]/timeline/[eventId] - Delete a timeline event +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string; eventId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?._id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + + // Validate ObjectId format + if (!params.id.match(/^[0-9a-fA-F]{24}$/) || !params.eventId.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid ID format" }, + { status: 400 } + ); + } + + // Verify case exists and belongs to user + const caseData = await CaseModel.findOne({ + _id: params.id, + userId: session.user._id + }); + + if (!caseData) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + + // Find and delete the timeline event + const timelineEvent = await TimelineEventModel.findOneAndDelete({ + _id: params.eventId, + caseId: params.id, + userId: session.user._id + }); + + if (!timelineEvent) { + return NextResponse.json({ error: "Timeline event not found" }, { status: 404 }); + } + + return NextResponse.json({ message: "Timeline event deleted successfully" }); + } catch (error) { + console.error("Error deleting timeline event:", error); + return NextResponse.json( + { error: "Failed to delete timeline event" }, + { status: 500 } + ); + } +} + +// PUT /api/cases/[id]/timeline/[eventId] - Update a timeline event +export async function PUT( + request: NextRequest, + { params }: { params: { id: string; eventId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?._id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + + // Validate ObjectId format + if (!params.id.match(/^[0-9a-fA-F]{24}$/) || !params.eventId.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid ID format" }, + { status: 400 } + ); + } + + // Verify case exists and belongs to user + const caseData = await CaseModel.findOne({ + _id: params.id, + userId: session.user._id + }); + + if (!caseData) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + + const body = await request.json(); + const { title, description, eventDate, eventType, status, metadata } = body; + + // Validate required fields + if (!title || !description || !eventDate || !eventType) { + return NextResponse.json( + { error: "Title, description, event date, and event type are required" }, + { status: 400 } + ); + } + + // Validate event type + const validEventTypes = ["filing", "hearing", "evidence", "document", "status_change", "custom"]; + if (!validEventTypes.includes(eventType)) { + return NextResponse.json( + { error: "Invalid event type" }, + { status: 400 } + ); + } + + // Validate status if provided + if (status) { + const validStatuses = ["completed", "scheduled", "pending"]; + if (!validStatuses.includes(status)) { + return NextResponse.json( + { error: "Invalid status" }, + { status: 400 } + ); + } + } + + // Update the timeline event + const updatedEvent = await TimelineEventModel.findOneAndUpdate( + { + _id: params.eventId, + caseId: params.id, + userId: session.user._id + }, + { + title, + description, + eventDate: new Date(eventDate), + eventType, + status: status || "completed", + metadata, + updatedAt: new Date() + }, + { new: true, runValidators: true } + ); + + if (!updatedEvent) { + return NextResponse.json({ error: "Timeline event not found" }, { status: 404 }); + } + + return NextResponse.json({ timelineEvent: updatedEvent }); + } catch (error) { + console.error("Error updating timeline event:", error); + return NextResponse.json( + { error: "Failed to update timeline event" }, + { status: 500 } + ); + } +} diff --git a/app/api/cases/[id]/timeline/route.ts b/app/api/cases/[id]/timeline/route.ts new file mode 100644 index 0000000..20edb55 --- /dev/null +++ b/app/api/cases/[id]/timeline/route.ts @@ -0,0 +1,151 @@ +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 TimelineEventModel from "@/model/TimelineEvent"; + +// GET /api/cases/[id]/timeline - Get timeline events 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; + + // Validate ObjectId format + if (!id.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid case ID format" }, + { status: 400 } + ); + } + + // 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 }); + } + + // Get timeline events for this case + const timelineEvents = await TimelineEventModel.find({ + caseId: id, + userId: session.user._id + }).sort({ eventDate: 1 }); // Sort from oldest to newest (ascending order) + + return NextResponse.json({ timelineEvents }); + } catch (error) { + console.error("Error fetching timeline events:", error); + return NextResponse.json( + { error: "Failed to fetch timeline events" }, + { status: 500 } + ); + } +} + +// POST /api/cases/[id]/timeline - Create a new timeline event +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; + + // Validate ObjectId format + if (!id.match(/^[0-9a-fA-F]{24}$/)) { + return NextResponse.json( + { error: "Invalid case ID format" }, + { status: 400 } + ); + } + + // 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 body = await request.json(); + const { title, description, eventDate, eventType, status, metadata } = body; + + // Validate required fields + if (!title || !description || !eventDate || !eventType) { + return NextResponse.json( + { error: "Title, description, event date, and event type are required" }, + { status: 400 } + ); + } + + // Validate eventDate + const parsedDate = new Date(eventDate); + if (Number.isNaN(parsedDate.getTime())) { + return NextResponse.json( + { error: "Invalid eventDate" }, + { status: 400 } + ); + } + + // Validate event type + const validEventTypes = ["filing", "hearing", "evidence", "document", "status_change", "custom"]; + if (!validEventTypes.includes(eventType)) { + return NextResponse.json( + { error: "Invalid event type" }, + { status: 400 } + ); + } + + // Validate status if provided + if (status) { + const validStatuses = ["completed", "scheduled", "pending"]; + if (!validStatuses.includes(status)) { + return NextResponse.json( + { error: "Invalid status" }, + { status: 400 } + ); + } + } + + // Create new timeline event + const timelineEvent = new TimelineEventModel({ + caseId: id, + title, + description, + eventDate: parsedDate, + eventType, + status: status || "completed", + metadata, + userId: session.user._id + }); + + await timelineEvent.save(); + + return NextResponse.json({ timelineEvent }, { status: 201 }); + } catch (error) { + console.error("Error creating timeline event:", error); + return NextResponse.json( + { error: "Failed to create timeline event" }, + { status: 500 } + ); + } +} + diff --git a/app/api/cases/route.ts b/app/api/cases/route.ts new file mode 100644 index 0000000..e7e429e --- /dev/null +++ b/app/api/cases/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/options"; +import dbConnect from "@/lib/dbConnect"; +import CaseModel from "@/model/Case"; +import TimelineEventModel from "@/model/TimelineEvent"; +import { generateInitialTimelineEvents } from "@/lib/timeline"; + +// GET /api/cases - Fetch all cases for the authenticated user +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?._id) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + await dbConnect(); + + // Get query parameters for filtering and sorting + const { searchParams } = new URL(request.url); + const status = searchParams.get("status"); + const stage = searchParams.get("stage"); + const search = searchParams.get("search"); + const sortBy = searchParams.get("sortBy") || "lastUpdated"; + const sortOrder = searchParams.get("sortOrder") || "desc"; + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + // Build filter object + const filter: any = { userId: session.user._id }; + + if (status && status !== "all") { + filter.status = status; + } + + if (stage && stage !== "all") { + filter.stage = stage; + } + + if (search) { + filter.$or = [ + { caseNumber: { $regex: search, $options: "i" } }, + { title: { $regex: search, $options: "i" } }, + { court: { $regex: search, $options: "i" } }, + { type: { $regex: search, $options: "i" } }, + { clientName: { $regex: search, $options: "i" } } + ]; + } + + // Build sort object + const sort: any = {}; + sort[sortBy] = sortOrder === "asc" ? 1 : -1; + + // Calculate skip for pagination + const skip = (page - 1) * limit; + + // Fetch cases with pagination + const cases = await CaseModel.find(filter) + .sort(sort) + .skip(skip) + .limit(limit) + .lean(); + + // Get total count for pagination + const totalCount = await CaseModel.countDocuments(filter); + + // Calculate pagination info + const totalPages = Math.ceil(totalCount / limit); + const hasNextPage = page < totalPages; + const hasPrevPage = page > 1; + + return NextResponse.json({ + cases, + pagination: { + currentPage: page, + totalPages, + totalCount, + hasNextPage, + hasPrevPage, + limit + } + }); + + } catch (error) { + console.error("Error fetching cases:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/cases - Create a new case +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?._id) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const body = await request.json(); + + // Validate required fields + const requiredFields = ["caseNumber", "title", "court", "type"]; + for (const field of requiredFields) { + if (!body[field]) { + return NextResponse.json( + { error: `${field} is required` }, + { status: 400 } + ); + } + } + + await dbConnect(); + + // Check if case number already exists + const existingCase = await CaseModel.findOne({ + caseNumber: body.caseNumber, + userId: session.user._id + }); + + if (existingCase) { + return NextResponse.json( + { error: "Case number already exists" }, + { status: 400 } + ); + } + + // Create new case + const newCase = new CaseModel({ + ...body, + userId: session.user._id, + lastUpdated: new Date() + }); + + const savedCase = await newCase.save(); + + // Generate initial timeline events + try { + const initialEvents = generateInitialTimelineEvents({ + _id: savedCase._id.toString(), + caseNumber: savedCase.caseNumber, + title: savedCase.title, + filingDate: savedCase.filingDate, + stage: savedCase.stage, + status: savedCase.status, + nextHearing: savedCase.nextHearing + }); + + // Create timeline events + const timelineEvents = initialEvents.map(event => ({ + ...event, + caseId: savedCase._id.toString(), + userId: session.user._id + })); + + await TimelineEventModel.insertMany(timelineEvents); + } catch (timelineError) { + console.error("Error creating initial timeline events:", timelineError); + // Don't fail the case creation if timeline events fail + } + + return NextResponse.json(savedCase, { status: 201 }); + + } catch (error) { + console.error("Error creating case:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 3fae3b8..34f70a1 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,116 +1,151 @@ "use client" -import { useState } from "react" +import { useState, useEffect, useRef } from "react" import { motion } from "framer-motion" -import { Calendar, Filter, Search, SortAsc, SortDesc } from "lucide-react" +import { Calendar, Filter, Search, SortAsc, SortDesc, Plus, Loader2, Upload, Trash2 } from "lucide-react" +import { useSession } from "next-auth/react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { CaseTimeline } from "@/components/case-timeline" - -type Case = { - id: string - caseNumber: string - court: string - type: string - stage: "Filed" | "Hearing" | "Evidence" | "Arguments" | "Judgment" | "Closed" - status: "Active" | "Pending" | "Delayed" | "Completed" - progress: number - lastUpdated: string - nextHearing?: string -} +import { DocumentList, DocumentListRef } from "@/components/document-list" +import { DocumentUpload } from "@/components/document-upload" +import { CaseForm } from "@/components/case-form" +import { fetchCases, CaseFilters, deleteCase } from "@/lib/cases" +import { Case } from "@/model/Case" export default function DashboardPage() { + const { data: session, status } = useSession() const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc") const [filterStatus, setFilterStatus] = useState("all") const [searchQuery, setSearchQuery] = useState("") const [selectedCase, setSelectedCase] = useState(null) + const [cases, setCases] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [isCaseFormOpen, setIsCaseFormOpen] = useState(false) + const [editingCase, setEditingCase] = useState(null) + const [isDocumentUploadOpen, setIsDocumentUploadOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const documentListRef = useRef(null) + const [pagination, setPagination] = useState({ + currentPage: 1, + totalPages: 1, + totalCount: 0, + hasNextPage: false, + hasPrevPage: false, + limit: 10 + }) + + // Fetch cases from API + const loadCases = async (filters: CaseFilters = {}) => { + if (status !== "authenticated") return + + try { + setLoading(true) + setError(null) + + const response = await fetchCases({ + status: filterStatus === "all" ? undefined : filterStatus, + search: searchQuery || undefined, + sortBy: "lastUpdated", + sortOrder, + page: pagination.currentPage, + limit: pagination.limit, + ...filters + }) + + setCases(response.cases) + setPagination(response.pagination) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load cases") + console.error("Error loading cases:", err) + } finally { + setLoading(false) + } + } + + // Load cases on component mount and when filters change + useEffect(() => { + if (status === "authenticated") { + loadCases() + } + }, [status, filterStatus, sortOrder, pagination.currentPage]) + + // Debounced search + useEffect(() => { + const timeoutId = setTimeout(() => { + if (status === "authenticated") { + setPagination(prev => ({ ...prev, currentPage: 1 })) + loadCases() + } + }, 500) + + return () => clearTimeout(timeoutId) + }, [searchQuery]) + + // Handle filter changes + const handleFilterChange = (newFilter: string) => { + setFilterStatus(newFilter) + setPagination(prev => ({ ...prev, currentPage: 1 })) + } + + const handleSortChange = (newSort: "asc" | "desc") => { + setSortOrder(newSort) + setPagination(prev => ({ ...prev, currentPage: 1 })) + } - // Mock data for cases - const cases: Case[] = [ - { - id: "1", - caseNumber: "CWP-1234/2023", - court: "Delhi High Court", - type: "Civil Writ Petition", - stage: "Hearing", - status: "Active", - progress: 40, - lastUpdated: "2023-11-15", - nextHearing: "2023-12-10", - }, - { - id: "2", - caseNumber: "CRL-5678/2023", - court: "District Court, Mumbai", - type: "Criminal Appeal", - stage: "Evidence", - status: "Delayed", - progress: 30, - lastUpdated: "2023-10-20", - nextHearing: "2023-12-15", - }, - { - id: "3", - caseNumber: "CS-9101/2022", - court: "Civil Court, Bangalore", - type: "Civil Suit", - stage: "Arguments", - status: "Active", - progress: 70, - lastUpdated: "2023-11-05", - nextHearing: "2023-11-25", - }, - { - id: "4", - caseNumber: "ARB-1122/2023", - court: "Arbitration Tribunal", - type: "Arbitration", - stage: "Filed", - status: "Pending", - progress: 10, - lastUpdated: "2023-11-10", - }, - { - id: "5", - caseNumber: "FAM-3344/2022", - court: "Family Court, Chennai", - type: "Divorce Petition", - stage: "Judgment", - status: "Active", - progress: 90, - lastUpdated: "2023-11-18", - }, - { - id: "6", - caseNumber: "TAX-5566/2021", - court: "Income Tax Appellate Tribunal", - type: "Tax Appeal", - stage: "Closed", - status: "Completed", - progress: 100, - lastUpdated: "2023-09-30", - }, - ] - - // Filter and sort cases - const filteredCases = cases - .filter( - (c) => - (filterStatus === "all" || c.status === filterStatus) && - (searchQuery === "" || - c.caseNumber.toLowerCase().includes(searchQuery.toLowerCase()) || - c.court.toLowerCase().includes(searchQuery.toLowerCase()) || - c.type.toLowerCase().includes(searchQuery.toLowerCase())), - ) - .sort((a, b) => { - const dateA = new Date(a.lastUpdated).getTime() - const dateB = new Date(b.lastUpdated).getTime() - return sortOrder === "asc" ? dateA - dateB : dateB - dateA - }) + // Handle pagination + const handlePageChange = (newPage: number) => { + setPagination(prev => ({ ...prev, currentPage: newPage })) + } + + // Handle case form + const handleOpenCaseForm = (caseToEdit?: Case) => { + setEditingCase(caseToEdit || null) + setIsCaseFormOpen(true) + } + + const handleCloseCaseForm = () => { + setIsCaseFormOpen(false) + setEditingCase(null) + } + + const handleCaseFormSuccess = () => { + loadCases() // Refresh the cases list + } + + // Handle case deletion + const handleDeleteCase = async (caseId: string) => { + try { + setIsDeleting(true) + await deleteCase(caseId) + + // Remove the case from the local state + setCases(prevCases => prevCases.filter(caseItem => caseItem._id !== caseId)) + + // If the deleted case was selected, clear the selection + if (selectedCase && selectedCase._id === caseId) { + setSelectedCase(null) + } + + // Update pagination if needed + setPagination(prev => ({ + ...prev, + totalCount: prev.totalCount - 1 + })) + + } catch (error) { + console.error("Error deleting case:", error) + setError("Failed to delete case. Please try again.") + } finally { + setIsDeleting(false) + } + } // Get status color const getStatusColor = (status: string) => { @@ -157,7 +192,13 @@ export default function DashboardPage() {

Track and manage your legal cases

- + {/* Filters and Search */} @@ -174,7 +215,7 @@ export default function DashboardPage() {
- @@ -194,7 +235,7 @@ export default function DashboardPage() { ) : ( )} - handleSortChange(value as "asc" | "desc")}> @@ -206,12 +247,36 @@ export default function DashboardPage() {
+ {/* Loading State */} + {loading && ( +
+ + Loading cases... +
+ )} + + {/* Error State */} + {error && ( +
+

Error: {error}

+ +
+ )} + {/* Case Cards */} -
- {filteredCases.length > 0 ? ( - filteredCases.map((caseItem, index) => ( + {!loading && !error && ( +
+ {cases.length > 0 ? ( + cases.map((caseItem, index) => ( - Next hearing: {caseItem.nextHearing} + Next hearing: {new Date(caseItem.nextHearing).toLocaleDateString()}
)} -
Last updated: {caseItem.lastUpdated}
+
+ Last updated: {new Date(caseItem.lastUpdated).toLocaleDateString()} +
)) - ) : ( -
-

No cases found matching your filters.

-
- )} - + ) : ( +
+

No cases found. Create your first case to get started!

+
+ )} + + )} + + {/* Pagination */} + {!loading && !error && pagination.totalPages > 1 && ( +
+ + + Page {pagination.currentPage} of {pagination.totalPages} + + +
+ )} {/* Case Timeline View */} {selectedCase && ( @@ -276,9 +369,41 @@ export default function DashboardPage() {
Case Timeline: {selectedCase.caseNumber} - +
+ + + + + + + Are you sure? + + This action cannot be undone. This will permanently delete the case + "{selectedCase.caseNumber}" and all associated data including timeline events and documents. + + + + Cancel + handleDeleteCase(selectedCase._id)} + className="bg-red-600 hover:bg-red-700" + > + Delete Case + + + + + +
@@ -295,38 +420,122 @@ export default function DashboardPage() {
-
-
-

Case Number

-

{selectedCase.caseNumber}

-
-
-

Court

-

{selectedCase.court}

-
-
-

Case Type

-

{selectedCase.type}

-
-
-

Status

-

{selectedCase.status}

-
-
-

Current Stage

-

{selectedCase.stage}

-
-
-

Last Updated

-

{selectedCase.lastUpdated}

+ {/* All Case Information in Simple Grid */} +
+

Case Information

+
+
+

Case Number

+

{selectedCase.caseNumber}

+
+
+

Court

+

{selectedCase.court}

+
+
+

Case Type

+

{selectedCase.type}

+
+
+

Status

+ + {selectedCase.status} + +
+
+

Current Stage

+ + {selectedCase.stage} + +
+
+

Progress

+
+
+
+
+ {selectedCase.progress}% +
+
+ {selectedCase.clientName && ( +
+

Client Name

+

{selectedCase.clientName}

+
+ )} + {selectedCase.opposingParty && ( +
+

Opposing Party

+

{selectedCase.opposingParty}

+
+ )} + {selectedCase.caseValue && ( +
+

Case Value

+

₹{selectedCase.caseValue.toLocaleString()}

+
+ )} +
+

Filing Date

+

{new Date(selectedCase.filingDate).toLocaleDateString()}

+
+ {selectedCase.nextHearing && ( +
+

Next Hearing

+

{new Date(selectedCase.nextHearing).toLocaleDateString()}

+
+ )} +
+

Last Updated

+

{new Date(selectedCase.lastUpdated).toLocaleDateString()}

+
+ + {/* Description & Notes */} + {(selectedCase.description || selectedCase.notes) && ( +
+

Additional Information

+
+ {selectedCase.description && ( +
+

Description

+

{selectedCase.description}

+
+ )} + {selectedCase.notes && ( +
+

Notes

+

{selectedCase.notes}

+
+ )} +
+
+ )}
-
-

No documents available for this case.

+
+
+

Case Documents

+ +
+
@@ -334,6 +543,29 @@ export default function DashboardPage() { )} + + {/* Case Form Modal */} + + + {/* Document Upload Modal */} + {selectedCase && ( + setIsDocumentUploadOpen(false)} + onSuccess={async () => { + // Refresh documents list after successful upload + if (documentListRef.current) { + await documentListRef.current.refreshDocuments() + } + }} + caseId={selectedCase._id} + /> + )}
) diff --git a/app/globals.css b/app/globals.css index f2ff4e0..b2e35c5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -87,3 +87,36 @@ body { @apply bg-background text-foreground; } } + +@layer components { + /* Enhanced shadcn date picker styling */ + .rdp { + --rdp-cell-size: 36px; + --rdp-accent-color: #0d9488; + --rdp-background-color: #f8fafc; + --rdp-accent-color-dark: #0f766e; + --rdp-background-color-dark: #1e293b; + --rdp-outline: 2px solid var(--rdp-accent-color); + --rdp-outline-selected: 2px solid var(--rdp-accent-color); + margin: 0; + } + + .rdp-day_selected { + background-color: var(--rdp-accent-color) !important; + color: white !important; + } + + .rdp-day_selected:hover { + background-color: var(--rdp-accent-color-dark) !important; + } + + .rdp-day_today { + background-color: #f1f5f9 !important; + color: #0f172a !important; + font-weight: 600; + } + + .rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled) { + background-color: #f1f5f9 !important; + } +} \ No newline at end of file diff --git a/components/case-form.tsx b/components/case-form.tsx new file mode 100644 index 0000000..78f8886 --- /dev/null +++ b/components/case-form.tsx @@ -0,0 +1,557 @@ +"use client" + +import { useState } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Calendar, X, Loader2, Plus } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Calendar as CalendarComponent } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { format } from "date-fns" +import { createCase } from "@/lib/cases" +import { Case } from "@/model/Case" + +const caseFormSchema = z.object({ + caseNumber: z.string().min(1, "Case number is required"), + title: z.string().min(1, "Case title is required"), + court: z.string().min(1, "Court name is required"), + type: z.string().min(1, "Case type is required"), + stage: z.enum(["Filed", "Hearing", "Evidence", "Arguments", "Judgment", "Closed"]), + status: z.enum(["Active", "Pending", "Delayed", "Completed"]), + progress: z.number().min(0).max(100), + description: z.string().optional(), + clientName: z.string().optional(), + opposingParty: z.string().optional(), + caseValue: z.number().min(0).default(0), + filingDate: z.date(), + nextHearing: z.date().optional(), + notes: z.string().optional(), +}) + +type CaseFormValues = z.infer + +interface CaseFormProps { + isOpen: boolean + onClose: () => void + onSuccess: () => void + editCase?: Case | null +} + +export function CaseForm({ isOpen, onClose, onSuccess, editCase }: CaseFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const [filingDateOpen, setFilingDateOpen] = useState(false) + const [nextHearingOpen, setNextHearingOpen] = useState(false) + + const form = useForm({ + resolver: zodResolver(caseFormSchema), + defaultValues: { + caseNumber: editCase?.caseNumber || "", + title: editCase?.title || "", + court: editCase?.court || "", + type: editCase?.type || "", + stage: editCase?.stage || "Filed", + status: editCase?.status || "Active", + progress: editCase?.progress || 0, + description: editCase?.description || "", + clientName: editCase?.clientName || "", + opposingParty: editCase?.opposingParty || "", + caseValue: editCase?.caseValue || 0, + filingDate: editCase?.filingDate ? new Date(editCase.filingDate) : new Date(), + nextHearing: editCase?.nextHearing ? new Date(editCase.nextHearing) : undefined, + notes: editCase?.notes || "", + }, + }) + + const onSubmit = async (values: CaseFormValues) => { + try { + setIsSubmitting(true) + setError(null) + + // Prepare data for API - convert 0 caseValue to undefined + const apiData = { + ...values, + caseValue: values.caseValue === 0 ? undefined : values.caseValue + } + + if (editCase) { + // Update existing case + const { updateCase } = await import("@/lib/cases") + await updateCase(editCase._id, apiData) + } else { + // Create new case + await createCase(apiData) + } + + onSuccess() + onClose() + form.reset() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save case") + } finally { + setIsSubmitting(false) + } + } + + const handleClose = () => { + form.reset() + setError(null) + onClose() + } + + return ( + + + +
+
+ +
+
+ + {editCase ? "Edit Case" : "Add New Case"} + +

+ {editCase ? "Update case information and details" : "Create a new legal case entry"} +

+
+
+
+ +
+ + {/* Basic Information */} +
+
+
+

Basic Information

+
+
+ +
+ ( + + Case Number * + + + + + + )} + /> + + ( + + Case Title * + + + + + + )} + /> + + ( + + Court * + + + + + + )} + /> + + ( + + Case Type * + + + + + + )} + /> +
+ + ( + + Description + +