Compare commits

..

15 commits

52 changed files with 36065 additions and 1057 deletions

1
.env
View file

@ -1,4 +1,5 @@
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2" DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
# values generated by Gemini # values generated by Gemini
# Generated for security # Generated for security

View file

@ -1,4 +1,5 @@
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2" DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
# values generated by Gemini # values generated by Gemini
# Generated for security # Generated for security

369
backup/db-backup-26-02-14 Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

Binary file not shown.

59
docs/Context-1.md Normal file
View file

@ -0,0 +1,59 @@
# WebCalibre: Application Context Document (v1.0)
## 1 Purpose & Vision
WebCalibre is a specialized Digital Asset Manager (DAM) designed to bridge the gap between cloud storage (OneDrive) and local metadata enrichment. It allows users to organize files while extracting and preserving deep technical metadata (EXIF for images, Document Info for PDFs) that standard file explorers ignore.
## 2 Core Technical Stack
- Framework: Next.js 15+ (App Router)
- Language: TypeScript
- Database: PostgreSQL via Prisma ORM
- Authentication: NextAuth.js (Auth.js)
- Styling: Material UI (MUI)
- Storage: Integrated with Microsoft OneDrive (via Microsoft Graph API)
- Processing:
- **Images:** sharp + exif-reader
- **PDFs: pdf-parse-new (v2 logic)
-
## 3 Application Architecture
### **A. Data Access Pattern (DAL)**
The project follows a strict Data Access Layer pattern located in src/data-access/.
- **Purpose:** All Prisma queries and database interactions are isolated here.
- **Benefit:** Components and Server Actions do not talk directly to the database; they call functions from file-nodes.ts or users.ts. This provides a single point of truth for data fetching and improves security by centralizing authorization checks.
**B. The Data Layer** (prisma/schema.prisma)
The app uses a **Recursive Tree Structure** for files and folders:
- **FileNode:** Represents both files and folders. Folders have a parentId pointing to another FileNode.
- **Metadata:** Stored as a JSONB field in the database, allowing for flexible, unstructured data from different file types.
**C. The Extraction Engine** (src/lib/metadata-extractor.ts)
A server-side utility that:
1. Identifies file type by extension.
2. Parses the file Buffer.
3. Images: Extracts camera make, model, GPS coordinates (DMS), aperture, and ISO.
4. PDFs: Extracts Author, Title, Subject, Page Count, and a Text Preview.
5. Sanitization: Normalizes raw binary buffers and complex objects into JSON-safe strings.
**D. The Transformation Layer** (src/lib/transformers.ts)
Converts raw, lowercase, or inconsistent metadata into a GUI-ready object. It handles unit conversions (e.g., Shutter Speed 0.02 → 1/50s) and coordinate mapping for Google Maps.
4. **Current Feature Roadmap & Status**
|Feature|Status|Description|
|-------|------|-----------|
|Dashboard|✅ Active|Tabular view of FileNodes. Supports "Magic Fill" and file listing.|
|Upload & Enrich|✅ FixedUploads files to OneDrive and creates sub-folders.|
|PDF Extraction|✅ Active|Uses pdf-parse-new for high-fidelity metadata.
|Library|⏳ Planned|A "Discovery" view (Gallery for images, Bookshelf for PDFs)||File Detail View|🚀 Next Up|A dedicated page for deep-diving into metadata (The "Double-Click" view).|
1. **Known Logic & UX Patterns**
- **Magic Fill:** A core "Wow" feature where the app reads the file binary before saving to suggest metadata properties.
- **Folder Logic:** Folders can be created at the "Root" (WebCalibre folder on OneDrive) or nested within existing directories.
- **Server Actions:** All database and storage mutations are handled via Next.js Server Actions for security and speed.
1. **Critical Fixes Applied**
- PDF Worker Crash: Resolved by using pdf-parse-new and disabling the web-worker in the Node.js environment.
- Folder Reset Bug: Fixed in upload-view.tsx by removing the logic that cleared the targetFolderId when toggling the "New Folder" input.

BIN
docs/Context-1.pdf Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

Binary file not shown.

1996
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"backfill": "tsx scripts/backfill-hashes.ts",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
@ -21,12 +22,20 @@
"@mui/material-nextjs": "^7.3.6", "@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.24.0", "@mui/x-data-grid": "^8.24.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "7.4.0",
"epub": "^1.3.0",
"epub2": "^3.0.2",
"exif-reader": "^2.0.3",
"next": "16.1.1", "next": "16.1.1",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"pdf-parse": "^2.4.5",
"pdf-parse-new": "^2.0.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"react-dropzone": "^15.0.0",
"server-only": "^0.0.1",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
@ -36,7 +45,8 @@
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"prisma": "^7.2.0", "prisma": "7.4.0",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }

View file

@ -1,12 +1,13 @@
// prisma.config.ts
import { config } from "dotenv"; import { config } from "dotenv";
config({ path: ".env.local" }); config({ path: ".env.local" });
import { defineConfig } from "prisma/config"; import { defineConfig, env } from "prisma/config";
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
datasource: { datasource: {
// This is now the ONLY place where the DB connection string is defined // This is where Prisma 7 looks for the connection string
url: process.env.DATABASE_URL, url: env("DATABASE_URL"),
}, },
}); });

View file

@ -1,20 +1,58 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateTable -- CreateTable
CREATE TABLE "User" ( CREATE TABLE "User" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"azureAdUserId" TEXT NOT NULL, "name" TEXT,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"displayName" TEXT, "role" "Role" NOT NULL DEFAULT 'USER',
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"azureAdUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") CONSTRAINT "User_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable -- CreateTable
CREATE TABLE "FileNode" ( CREATE TABLE "FileNode" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"size" BIGINT, "size" BIGINT,
"hash" TEXT,
"isFolder" BOOLEAN NOT NULL DEFAULT false, "isFolder" BOOLEAN NOT NULL DEFAULT false,
"oneDriveId" TEXT, "oneDriveId" TEXT,
"path" TEXT NOT NULL, "path" TEXT NOT NULL,
@ -29,11 +67,17 @@ CREATE TABLE "FileNode" (
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id") CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId"); CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId"); CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
@ -47,8 +91,15 @@ CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path"); CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,52 +0,0 @@
/*
Warnings:
- You are about to drop the column `displayName` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "displayName",
ADD COLUMN "emailVerified" TIMESTAMP(3),
ADD COLUMN "image" TEXT,
ADD COLUMN "name" TEXT,
ALTER COLUMN "azureAdUserId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[hash]` on the table `FileNode` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "FileNode_hash_key" ON "FileNode"("hash");

View file

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "FileNode_hash_key";

View file

@ -60,6 +60,7 @@ model FileNode {
id String @id id String @id
name String name String
size BigInt? // Preserved your BigInt size column size BigInt? // Preserved your BigInt size column
hash String?
isFolder Boolean @default(false) isFolder Boolean @default(false)
oneDriveId String? @unique oneDriveId String? @unique
path String path String

View file

@ -0,0 +1,59 @@
import 'dotenv/config';
import { prisma } from '../src/lib/prisma';
import * as nodeCrypto from 'crypto';
function generateFileHash(buffer: Buffer): string {
return nodeCrypto.createHash('sha256').update(buffer).digest('hex');
}
/**
* Replace this with your actual OneDrive download logic!
*/
async function getFromOneDrive(oneDriveId: string): Promise<Buffer> {
// For now, let's pretend we downloaded it to test the DB update
// DELETE THESE 2 LINES when you add your real OneDrive fetch code:
console.log(` ⬇️ Downloading ${oneDriveId}...`);
return Buffer.from(`mock-data-for-${oneDriveId}`);
}
async function backfill() {
console.log('🏁 Starting backfill with Prisma Adapter...');
try {
const files = await prisma.fileNode.findMany({
where: { isFolder: false
},
});
console.log(`📂 Found ${files.length} files to process.`);
for (const file of files) {
try {
process.stdout.write(`Processing: ${file.name}... `);
// 1. Get the file content
const buffer = await getFromOneDrive(file.oneDriveId!);
// 2. Generate the hash
const hash = generateFileHash(buffer);
// 3. Update the database
await prisma.fileNode.update({
where: { id: file.id },
data: { hash: hash }
});
console.log(`✅ Success! (Hash: ${hash.substring(0, 8)}...)`);
} catch (err) {
console.log(`❌ Failed: ${err instanceof Error ? err.message : err}`);
}
}
} catch (error) {
console.error('🚨 Script Error:', error);
} finally {
await prisma.$disconnect();
console.log('🏁 Finished.');
}
}
backfill();

View file

@ -1,66 +1,57 @@
// src/app/api/download/route.ts // src/app/api/download/route.ts
"use server"
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { auth } from "@/auth"; // Import the auth function from your central config import { auth } from "@/auth";
import { prisma } from '@/lib/prisma'; import { getFileNodeById } from "@/data-access/file-nodes";
import { getOneDriveContentStream } from "@/services/onedrive";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// 1. Check Authentication using the V5 auth() helper // 1. Authenticate the user session
const session = await auth(); const session = await auth();
if (!session?.user?.id) {
// In V5, tokens are usually handled in the session callback return new NextResponse("Unauthorized", { status: 401 });
if (!session || !session.accessToken) {
return new NextResponse("Unauthorized - No Access Token found", { status: 401 });
} }
// 2. Get parameters from URL // 2. Extract parameters from URL
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fileNodeId = searchParams.get('id'); const fileId = searchParams.get('id');
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment'; const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
if (!fileNodeId) { if (!fileId) {
return new NextResponse("File ID is required", { status: 400 }); return new NextResponse("File ID is required", { status: 400 });
} }
// 3. Find the file in your Postgres FileNode table // 3. DAL: Fetch file metadata from local database
const fileNode = await prisma.fileNode.findUnique({ const fileNode = await getFileNodeById(fileId);
where: { id: fileNodeId }
});
if (!fileNode || !fileNode.oneDriveId) { if (!fileNode || !fileNode.oneDriveId) {
return new NextResponse("File not found in database", { status: 404 }); return new NextResponse("File not found", { status: 404 });
} }
// 4. Fetch the file stream from Microsoft Graph // 4. SERVICE: Get the binary stream from Microsoft Graph
const graphResponse = await fetch( // The service layer automatically handles the 'getFreshAccessToken' logic
`https://graph.microsoft.com/v1.0/me/drive/items/${fileNode.oneDriveId}/content`, const graphResponse = await getOneDriveContentStream(session.user.id, fileNode.oneDriveId);
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
if (!graphResponse.ok) {
const errorText = await graphResponse.text();
console.error('MS Graph Error:', errorText);
return new NextResponse(`OneDrive Error: ${graphResponse.statusText}`, { status: graphResponse.status });
}
// 5. Stream the response directly to the client // 5. Stream the response directly to the client
return new NextResponse(graphResponse.body, { // We pass the graphResponse.body (ReadableStream) directly to NextResponse
status: 200, // src/app/api/download/route.ts
headers: { const metadata = fileNode.metadata as any;
'Content-Type': fileNode.mimeType || 'application/octet-stream', const contentType = metadata?.mimeType || 'application/octet-stream';
// Note: Using encodeURIComponent for filename to handle special characters
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
},
});
} catch (error) { return new NextResponse(graphResponse.body, {
console.error('Download error:', error); status: 200,
return new NextResponse("Internal Server Error", { status: 500 }); headers: {
'Content-Type': contentType,
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
},
});
} catch (error: any) {
console.error('Download Route Error:', error);
return new NextResponse(
JSON.stringify({ error: "Internal Server Error", message: error.message }),
{ status: 500 }
);
} }
} }

View file

@ -1,138 +1,90 @@
// src/app/dashboard/actions.ts
'use server'; 'use server';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
getAllFileNodes,
getFileNodeById,
updateFileNode,
deleteFileNode
} from "@/data-access/file-nodes";
import {
getOneDriveItem,
deleteFromOneDrive,
uploadToOneDrive
} from "@/services/onedrive";
//import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
import { getFreshAccessToken } from "@/lib/auth-utils"; import { getFreshAccessToken } from "@/lib/auth-utils";
import { getOneDriveFileBuffer } from "@/services/onedrive"; // Import your working service
import { extractMetadata } from "@/lib/metadata-extractor";
/** /**
* 1. FETCH: Get all file nodes for the Dashboard * 1. FETCH: Get all file nodes
* Now simply calls the DAL. Error handling is left to the caller (the UI).
*/ */
export async function getFileNodes() { export async function getFileNodes() {
try { return await getAllFileNodes();
const nodes = await prisma.fileNode.findMany({
orderBy: {
updatedAt: 'desc',
},
});
return nodes;
} catch (error) {
console.error("Error fetching file nodes:", error);
return [];
}
} }
/** /**
* 2. DOWNLOAD: Generates the authenticated OneDrive URL * 2. DOWNLOAD: Generates the authenticated OneDrive URL
* Orchestrates the session check, DAL lookup, and Service call.
*/ */
export async function getDownloadUrlAction(id: string) { export async function getDownloadUrlAction(id: string) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
const file = await prisma.fileNode.findUnique({ where: { id } }); const file = await getFileNodeById(id);
if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID"); if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID");
const accessToken = await getFreshAccessToken(session.user.id); // Service handles token refresh and graph request internally
const res = await fetch( const data = await getOneDriveItem(session.user.id, file.oneDriveId);
`https://graph.microsoft.com/v1.0/me/drive/items/${file.oneDriveId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) throw new Error("Failed to contact OneDrive");
const data = await res.json();
const downloadUrl = data["@microsoft.graph.downloadUrl"]; const downloadUrl = data["@microsoft.graph.downloadUrl"];
if (!downloadUrl) throw new Error("OneDrive did not provide a download link"); if (!downloadUrl) throw new Error("OneDrive did not provide a download URL");
return downloadUrl;
return { downloadUrl };
} }
/** /**
* 3. DELETE: Remove from OneDrive (via ID) and Database * 3. DELETE: Removes from both Cloud and Database
* Folders are virtual (DB only), so cloud deletion is skipped if oneDriveId is null.
*/ */
export async function deleteFileAction(fileId: string) { export async function deleteFileNodeAction(id: string) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
const node = await prisma.fileNode.findUnique({
where: { id: fileId },
});
if (!node) {
revalidatePath("/dashboard");
return { success: true };
}
// @ts-ignore
const isAdmin = session.user.role === "ADMIN";
const isOwner = node.ownerId === session.user.id;
if (!isAdmin && !isOwner) {
throw new Error("Permission Denied.");
}
try { try {
const accessToken = await getFreshAccessToken(session.user.id); const file = await getFileNodeById(id);
if (!file) throw new Error("File record not found");
// Only attempt cloud deletion if it's a file/storage with a oneDriveId. // Phase 1: Cloud Deletion
// Virtual folders created in the DB have no oneDriveId and are skipped. if (file.oneDriveId) {
if (accessToken && node.oneDriveId) { await deleteFromOneDrive(session.user.id, file.oneDriveId);
const onedriveRes = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!onedriveRes.ok && onedriveRes.status !== 404) {
console.warn("OneDrive Deletion Warning: Cloud record might still exist.");
}
} }
} catch (cloudError) {
console.error("Cloud cleanup failed:", cloudError);
}
try { // Phase 2: Database Deletion
await prisma.fileNode.delete({ where: { id: fileId } }); await deleteFileNode(id);
revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true };
} catch (dbError) {
throw new Error("Failed to remove the record from the database.");
}
}
/**
* 4. MOVE: Assign file to folder or folder to another folder (Virtual Move)
*/
export async function moveNodeAction(nodeId: string, newParentId: string | null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
if (nodeId === newParentId) throw new Error("Cannot move to self.");
try {
await prisma.fileNode.update({
where: { id: nodeId },
data: { parentId: newParentId }
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
throw new Error("Move failed."); console.error("Delete Error:", error);
return { success: false, error: "Failed to delete file" };
} }
} }
/** /**
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content * 4. UPDATE: Modify record and optionally sync new content to OneDrive
*/ */
export async function updateFileFullAction(formData: FormData) { export async function updateFileNodeAction(id: string, formData: FormData) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
const id = formData.get("id") as string;
const name = formData.get("name") as string; const name = formData.get("name") as string;
const description = formData.get("description") as string; const description = formData.get("description") as string;
const parentIdRaw = formData.get("parentId") as string; const parentIdRaw = formData.get("parentId") as string;
@ -143,44 +95,62 @@ export async function updateFileFullAction(formData: FormData) {
let metadata = JSON.parse(metadataStr); let metadata = JSON.parse(metadataStr);
try { try {
const accessToken = await getFreshAccessToken(session.user.id); const node = await getFileNodeById(id);
const node = await prisma.fileNode.findUnique({ where: { id } });
// Update physical file content only if a new file is uploaded and we have a target oneDriveId // If a new file is uploaded, push it to OneDrive first
if (newFile && newFile.size > 0 && node?.oneDriveId) { if (newFile && newFile.size > 0 && node?.oneDriveId) {
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`; await uploadToOneDrive(session.user.id, newFile, node.oneDriveId);
const uploadRes = await fetch(onedrivePath, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": newFile.type
},
body: Buffer.from(await newFile.arrayBuffer()),
});
if (!uploadRes.ok) throw new Error("OneDrive content update failed");
metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN'; metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
metadata.mimeType = newFile.type; metadata.mimeType = newFile.type;
} }
await prisma.fileNode.update({ // Update the database via DAL
where: { id }, await updateFileNode(id, {
data: { name,
name, description,
description, parentId,
parentId, metadata,
metadata, size: newFile ? BigInt(newFile.size) : undefined,
size: newFile ? BigInt(newFile.size) : undefined,
updatedAt: new Date(),
}
}); });
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error: any) { } catch (error) {
console.error("Full Update Failure:", error); console.error("Update Error:", error);
throw new Error(error.message || "Failed to update record."); return { success: false, error: "Failed to update record" };
}
}
export async function getMetadataPreviewAction(fileId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const node = await getFileNodeById(fileId);
if (!node || !node.oneDriveId) throw new Error("No OneDrive ID found");
console.log(`📡 Attempting fetch via Service for: ${node.name}`);
const token = await getFreshAccessToken(session.user.id);
if (!token) throw new Error("Could not retrieve access token");
// Call your existing service
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
console.log(`📦 Buffer received: ${buffer.length} bytes`);
const extractedData = await extractMetadata(buffer, node.name);
console.log("✅ Extracted:", extractedData);
return { success: true, data: extractedData };
} catch (error: any) {
console.error("❌ Service Fetch Error:", error.message);
// If this still says ENOTFOUND, the code is fine, but the terminal is blocked.
return { success: false, error: error.message };
} }
} }

View file

@ -1,8 +1,7 @@
'use client'; 'use client';
// src/app/dashboard-view.tsx
// src/app/dashboard/dashboard-view.tsx
import { useState } from "react"; import { useState } from "react";
import { styled } from '@mui/material/styles';
import { import {
Button, Button,
CircularProgress, CircularProgress,
@ -13,7 +12,7 @@ import {
Stack, Stack,
TextField, TextField,
InputAdornment, InputAdornment,
Tooltip Tooltip,
} from "@mui/material"; } from "@mui/material";
import { import {
DataGrid, DataGrid,
@ -22,6 +21,7 @@ import {
QuickFilter, QuickFilter,
QuickFilterControl, QuickFilterControl,
QuickFilterClear, QuickFilterClear,
GridEventListener,
} from "@mui/x-data-grid"; } from "@mui/x-data-grid";
import SyncIcon from "@mui/icons-material/Sync"; import SyncIcon from "@mui/icons-material/Sync";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
@ -35,25 +35,42 @@ import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { syncOneDrive } from "./sync-actions"; import { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions"; import { deleteFileNodeAction } from "./actions";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
// --- 1. Styled Component for Search Placement ---
const StyledQuickFilter = styled(QuickFilter)({
marginLeft: 'auto', // Pushes the search box to the right side of the toolbar
});
// --- 2. Custom Toolbar Component ---
function CustomToolbar() { function CustomToolbar() {
return ( return (
<Toolbar sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <Toolbar >
<Box sx={{
display: 'flex',
width: '100%',
alignItems: 'center',
p: 2,
borderBottom: '1px solid',
borderColor: 'divider'
}}>
<Typography variant="h6" fontWeight="bold" color="primary"> <Typography variant="h6" fontWeight="bold" color="primary">
Library Library
</Typography> </Typography>
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
{/* The 'expanded' prop ensures the search input is always visible by default */}
<StyledQuickFilter expanded>
<QuickFilterControl <QuickFilterControl
render={({ ref, ...controlProps }, state) => ( render={({ ref, ...other }) => (
<TextField <TextField
{...controlProps} {...other}
sx={{ width: 300 }}
inputRef={ref} inputRef={ref}
variant="outlined" placeholder="Search library..."
size="small" size="small"
placeholder="Search files and metadata..."
sx={{ width: 350 }}
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
@ -61,23 +78,33 @@ function CustomToolbar() {
<SearchIcon fontSize="small" /> <SearchIcon fontSize="small" />
</InputAdornment> </InputAdornment>
), ),
endAdornment: state.value ? ( endAdornment: other.value ? (
<InputAdornment position="end"> <InputAdornment position="end">
<QuickFilterClear size="small"> <QuickFilterClear
edge="end"
size="small"
material={{ sx: { marginRight: -0.75 } }}
>
<CancelIcon fontSize="small" /> <CancelIcon fontSize="small" />
</QuickFilterClear> </QuickFilterClear>
</InputAdornment> </InputAdornment>
) : null, ) : null,
// Ensure other props are spread correctly
...other.slotProps?.input,
}, },
...other.slotProps,
}} }}
/> />
)} )}
/> />
</QuickFilter> </StyledQuickFilter>
</Box>
</Toolbar> </Toolbar>
); );
} }
// --- 3. Main Dashboard View ---
interface DashboardViewProps { interface DashboardViewProps {
initialFiles: any[]; initialFiles: any[];
user?: { user?: {
@ -91,6 +118,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter(); const router = useRouter();
const isAdmin = user?.role === "ADMIN"; const isAdmin = user?.role === "ADMIN";
const [lastSynced, setLastSynced] = useState<Date | null>(new Date()); // Defaults to 'Just now' on load
const getVirtualPath = (parentId: string | null): string => { const getVirtualPath = (parentId: string | null): string => {
if (!parentId) return "WebCalibre"; if (!parentId) return "WebCalibre";
@ -101,10 +129,11 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
}; };
const handleSync = async () => { const handleSync = async () => {
setLoading(true); setLoading(true);
try { try {
await syncOneDrive(); await syncOneDrive();
router.refresh(); setLastSynced(new Date()); // Update the time
router.refresh();
} catch (error) { } catch (error) {
console.error("Sync failed:", error); console.error("Sync failed:", error);
} finally { } finally {
@ -121,22 +150,19 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
const handleDelete = async (id: string, name: string) => { const handleDelete = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete "${name}"?`)) return; if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
try { try {
await deleteFileAction(id); await deleteFileNodeAction(id);
router.refresh(); router.refresh();
} catch (error: any) { } catch (error: any) {
alert(error.message || "Failed to delete file"); alert(error.message || "Failed to delete file");
} }
}; };
// --- NEW DOWNLOAD FUNCTIONS --- // --- NEW: Double Click Handler ---
const handleDownload = (id: string) => { const handleRowDoubleClick: GridEventListener<'rowDoubleClick'> = (params) => {
// Triggers local folder download via Content-Disposition: attachment // Only navigate if it's a file. If it's a folder, we could eventually navigate into it.
window.location.href = `/api/download?id=${id}&mode=attachment`; if (!params.row.isFolder) {
}; router.push(`/dashboard/files/${params.id}`);
}
const handleViewInTab = (id: string) => {
// Opens in a new tab via Content-Disposition: inline
window.open(`/api/download?id=${id}&mode=inline`, '_blank');
}; };
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -146,10 +172,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
flex: 1.5, flex: 1.5,
minWidth: 250, minWidth: 250,
renderCell: (params) => ( renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Tooltip title={params.row.isFolder ? "" : "Double-click to view deep metadata"} arrow>
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />} <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%', cursor: 'pointer' }}>
<Typography variant="body2">{params.value}</Typography> {params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
</Box> <Typography variant="body2">{params.value}</Typography>
</Box>
</Tooltip>
) )
}, },
{ {
@ -165,7 +193,7 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
width: 120, width: 120,
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"), valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
renderCell: (params) => ( renderCell: (params) => (
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}> <Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold' }}>
{params.value} {params.value}
</Typography> </Typography>
) )
@ -176,90 +204,87 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
width: 100, width: 100,
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB` renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
}, },
{
field: "metadata_search",
headerName: "Search Metadata",
width: 0,
valueGetter: (value, row) => row.metadata ? JSON.stringify(row.metadata) : ""
},
{ {
field: "actions", field: "actions",
headerName: "Actions", headerName: "Actions",
width: 180, // Increased width to accommodate new buttons width: 180,
align: 'right', align: 'right',
renderCell: (params) => { renderCell: (params) => {
const isOwner = params.row.ownerId === user?.id; const isOwner = params.row.ownerId === user?.id;
const isFolder = params.row.isFolder; const isFolder = params.row.isFolder;
return ( return (
<Stack direction="row" spacing={0.5} justifyContent="flex-end"> <Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" sx={{ height: '100%' }}>
{!isFolder && ( {!isFolder && (
<> <>
<Tooltip title="View in Tab"> <IconButton size="small" color="info" onClick={(e) => { e.stopPropagation(); window.open(`/api/download?id=${params.row.id}&mode=inline`, '_blank'); }}>
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}> <OpenInNewIcon fontSize="small" />
<OpenInNewIcon fontSize="small" /> </IconButton>
</IconButton> <IconButton size="small" color="success" onClick={(e) => { e.stopPropagation(); window.location.href = `/api/download?id=${params.row.id}&mode=attachment`; }}>
</Tooltip> <DownloadIcon fontSize="small" />
<Tooltip title="Download to Folder"> </IconButton>
<IconButton size="small" color="success" onClick={() => handleDownload(params.row.id)}>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</> </>
)} )}
{(isAdmin || isOwner) && ( {(isAdmin || isOwner) && (
<> <>
<Tooltip title="Edit Details"> <IconButton size="small" color="primary" onClick={(e) => { e.stopPropagation(); router.push(`/update/${params.row.id}`); }}>
<IconButton <EditIcon fontSize="small" />
size="small" </IconButton>
color="primary" <IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDelete(params.row.id, params.row.name); }}>
onClick={() => router.push(`/update/${params.row.id}`)} <DeleteIcon fontSize="small" />
> </IconButton>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
color="error"
onClick={() => handleDelete(params.row.id, params.row.name)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</> </>
)} )}
</Stack> </Stack>
); );
} }
},
{
field: "metadata_search",
headerName: "Metadata Search",
width: 0,
valueGetter: (value, row) => {
if (!row.metadata) return "";
return Object.entries(row.metadata)
.filter(([k]) => k !== 'type' && k !== 'mimeType')
.map(([k, v]) => `${k}:${v}`)
.join(" ");
}
} }
]; ];
return ( return (
<Box className="space-y-4"> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}> {lastSynced && (
Refresh List <Typography
variant="caption"
color="text.secondary"
sx={{ fontStyle: 'italic' }}
suppressHydrationWarning
>
Last synced: {lastSynced.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
)}
<Button
variant="outlined"
startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={handleRefresh}
>
Refresh
</Button> </Button>
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}> <Button
variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
Sync OneDrive Sync OneDrive
</Button> </Button>
</Box> </Box>
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}> <Box sx={{ height: 750, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
<DataGrid <DataGrid
rows={initialFiles} rows={initialFiles}
columns={columns} columns={columns}
slots={{ toolbar: CustomToolbar }} // --- THE FIX: showToolbar must be true, and toolbar slot must be assigned ---
showToolbar showToolbar
slots={{ toolbar: CustomToolbar }}
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={handleRowDoubleClick} // ADDED THIS HANDLER
initialState={{ initialState={{
columns: { columns: {
columnVisibilityModel: { columnVisibilityModel: {
@ -269,8 +294,9 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
}} }}
sx={{ sx={{
border: 'none', border: 'none',
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' }, '& .MuiDataGrid-row:hover': {
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' } cursor: 'pointer',
},
}} }}
/> />
</Box> </Box>

View file

@ -0,0 +1,132 @@
// src/app/dashboard/files/[id]/page.tsx
import { getFileNodeById } from "@/data-access/file-nodes";
import { mapMetadata } from "@/lib/transformers";
import { notFound } from "next/navigation";
import {
Box, Container, Typography, Paper, Grid, Divider, Stack, Button
} from "@mui/material";
import DescriptionIcon from '@mui/icons-material/Description';
import PhotoIcon from '@mui/icons-material/Photo';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import Link from "next/link";
export default async function FileDetailPage(props: {
params: Promise<{ id: string }>
}) {
// 1. Unwrapping params for Next.js 15/16+
const { id } = await props.params;
// 2. Fetch data via DAL
const file = await getFileNodeById(id);
// 3. Check schema-correct property 'isFolder'
if (!file || file.isFolder) {
notFound();
}
// 4. Transform data for the UI
const data = mapMetadata(file);
// 5. Derived extension logic
const extension = file.name.split('.').pop()?.toLowerCase();
const isPDF = extension === 'pdf';
// 6. Handle BigInt for Size
const sizeKB = file.size ? (Number(file.size) / 1024).toFixed(2) : "0";
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Navigation back to dashboard */}
<Link href="/dashboard" style={{ textDecoration: 'none' }}>
<Button
startIcon={<ArrowBackIcon />}
sx={{ mb: 3 }}
>
Back to Dashboard
</Button>
</Link>
<Paper elevation={3} sx={{ p: { xs: 3, md: 5 }, borderRadius: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
{isPDF ? (
<DescriptionIcon sx={{ color: '#f44336', fontSize: 40 }} />
) : (
<PhotoIcon sx={{ color: '#2196f3', fontSize: 40 }} />
)}
<Box>
<Typography variant="h4" fontWeight="900">{file.name}</Typography>
<Typography variant="body2" color="text.secondary">
ID: {file.id}
</Typography>
</Box>
</Stack>
<Grid container spacing={4}>
{/* Left Column: Core Info & Preview */}
<Grid item xs={12} md={7}>
<Typography variant="h6" gutterBottom fontWeight="bold">Summary</Typography>
<Divider sx={{ mb: 2 }} />
{data.textPreview && (
<Box sx={{ bgcolor: 'grey.50', p: 2, borderRadius: 2, mb: 3, border: '1px solid #ddd' }}>
<Typography variant="subtitle2" color="primary" gutterBottom sx={{ fontSize: '0.7rem', fontWeight: 'bold' }}>
DOCUMENT PREVIEW
</Typography>
<Typography variant="body2" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
"{data.textPreview}..."
</Typography>
</Box>
)}
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">FILE TYPE</Typography>
<Typography variant="body1" fontWeight="500">{data.type}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">SIZE</Typography>
<Typography variant="body1" fontWeight="500">{sizeKB} KB</Typography>
</Box>
{data.pageCount > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">PAGE COUNT</Typography>
<Typography variant="body1" fontWeight="500">{data.pageCount}</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">UPLOADED AT</Typography>
<Typography variant="body1" fontWeight="500">
{new Date(file.createdAt).toLocaleString()}
</Typography>
</Box>
</Stack>
</Grid>
{/* Right Column: Deep Metadata */}
<Grid item xs={12} md={5}>
<Typography variant="h6" gutterBottom fontWeight="bold">Extracted Attributes</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={1}>
{Object.entries(file.metadata as Record<string, any> || {}).map(([key, value]) => (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5, borderBottom: '1px solid #f0f0f0' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: '500' }}>
{key}
</Typography>
<Typography variant="body2" sx={{ textAlign: 'right', pl: 2 }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</Typography>
</Box>
))}
{/* FIXED: Properly closed logic for empty metadata */}
{Object.keys(file.metadata as object || {}).length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
No extra metadata extracted for this file.
</Typography>
)}
</Stack>
</Grid>
</Grid>
</Paper>
</Container>
);
}

View file

@ -1,62 +1,74 @@
// src/app/dashboard/sync-actions.ts
'use server'; 'use server';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getFreshAccessToken } from "@/lib/auth-utils"; import { upsertFileNode } from "@/data-access/file-nodes";
import { getWebCalibreChildren } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor"; // Import your utility
export async function syncOneDrive() { export async function syncOneDrive() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
try { try {
const accessToken = await getFreshAccessToken(session.user.id); const items = await getWebCalibreChildren(session.user.id);
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return { success: true, count: 0 };
const data = await response.json();
let syncedCount = 0; let syncedCount = 0;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
for (const item of data.value) { for (const item of items) {
const isFolder = !!item.folder; const isFolder = !!item.folder;
// Only skip if it's a folder AND it's a UUID (storage container) if (isFolder && uuidRegex.test(item.name)) continue;
// If a user named a file with a UUID, we still want it.
if (isFolder && uuidRegex.test(item.name)) { const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toLowerCase() || 'unknown');
continue;
let deepMetadata = {};
// --- NEW: Extraction Logic ---
// Only extract for specific types to save time/bandwidth
const supportedTypes = ['pdf', 'epub', 'jpg', 'jpeg', 'png', 'webp'];
if (!isFolder && supportedTypes.includes(extension)) {
const downloadUrl = item['@microsoft.graph.downloadUrl'];
if (downloadUrl) {
try {
// Fetch the file content as an ArrayBuffer
const response = await fetch(downloadUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Extract the "deep" metadata (Author, Title, etc.)
deepMetadata = await extractMetadata(buffer, item.name);
} catch (extractError) {
console.error(`Could not extract metadata for ${item.name}:`, extractError);
}
}
} }
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN'); // 2. Save to database with combined metadata
await upsertFileNode(item.id, {
await prisma.fileNode.upsert({ name: item.name,
where: { oneDriveId: item.id }, // Primary match size: BigInt(item.size || 0),
update: { isFolder: isFolder,
name: item.name, path: item.parentReference?.path + '/' + item.name,
size: BigInt(item.size || 0), ownerId: session.user.id,
isFolder: isFolder, metadata: {
path: item.parentReference?.path + '/' + item.name, type: extension.toUpperCase(),
updatedAt: new Date(), mimeType: item.file?.mimeType || null,
...deepMetadata // Merge the extracted Author, Title, etc.
}, },
create: {
id: crypto.randomUUID(),
oneDriveId: item.id,
name: item.name,
size: BigInt(item.size || 0),
isFolder: isFolder,
path: item.parentReference?.path + '/' + item.name,
ownerId: session.user.id,
metadata: { type: extension, mimeType: item.file?.mimeType || null },
}
}); });
syncedCount++; syncedCount++;
} }
revalidatePath('/dashboard'); revalidatePath('/dashboard');
return { success: true, count: syncedCount }; return { success: true, count: syncedCount };
} catch (error: any) { } catch (error: any) {
throw new Error(error.message); console.error("Sync Error:", error.message);
throw new Error("Failed to sync with OneDrive");
} }
} }

View file

@ -1,9 +1,9 @@
'use server'; 'use server';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createFileNode } from "@/data-access/file-nodes";
import { ensureOneDriveFolder, uploadLargeFile } from "@/services/onedrive";
export async function uploadFileAction(formData: FormData) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
@ -11,58 +11,32 @@ export async function uploadFileAction(formData: FormData) {
const file = formData.get("file") as File; const file = formData.get("file") as File;
const folderName = "WebCalibre"; const folderName = "WebCalibre";
const accessToken = await getFreshAccessToken(session.user.id);
// 1. Create/Check WebCalibre Folder try {
const folderPath = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`; // 1. Logic: Ensure destination exists
const folderCheck = await fetch(folderPath, { await ensureOneDriveFolder(session.user.id, folderName);
headers: { Authorization: `Bearer ${accessToken}` }
});
if (folderCheck.status === 404) { // 2. Logic: Perform the cloud upload
await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, { const driveItem = await uploadLargeFile(session.user.id, file, folderName);
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName, folder: {} })
});
}
// 2. Create Upload Session (Supports files > 4MB) // 3. Logic: Save the result to our DB
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${file.name}:/createUploadSession`; await createFileNode({
const sessionRes = await fetch(sessionUrl, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "rename" } })
});
const { uploadUrl } = await sessionRes.json();
// 3. Upload File Data
const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
const driveItem = await uploadRes.json();
// 4. Record in PostgreSQL
await prisma.fileNode.create({
data: {
oneDriveId: driveItem.id, oneDriveId: driveItem.id,
name: file.name, name: file.name,
size: BigInt(file.size), size: BigInt(file.size),
isFolder: false, isFolder: false,
path: `/${folderName}/${file.name}`, path: `/${folderName}/${file.name}`,
ownerId: session.user.id, ownerId: session.user.id,
metadata: { type: file.name.split('.').pop()?.toUpperCase() } metadata: {
} type: file.name.split('.').pop()?.toUpperCase(),
}); mimeType: file.type
}
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error: any) {
console.error("Upload Action Error:", error);
return { success: false, error: error.message || "Upload failed" };
}
} }

View file

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Typography, Box, LinearProgress } from "@mui/material"; import { Button, Typography, Box, LinearProgress } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { uploadFileToOneDrive } from "./upload-actions"; import { uploadFileAction } from "./upload-actions";
export default function UploadForm() { export default function UploadForm() {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -14,7 +14,7 @@ export default function UploadForm() {
setUploading(true); setUploading(true);
try { try {
await uploadFileToOneDrive(formData); await uploadFileAction(formData);
alert("Uploaded successfully!"); alert("Uploaded successfully!");
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View file

@ -1,15 +1,13 @@
'use server'; 'use server';
// src/app/settings/actions.ts
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getUserByEmail, getUserById, updateUserRole } from "@/data-access/users";
/** /**
* Toggles a user's role between 'ADMIN' and 'USER'. * Toggles a user's role between 'ADMIN' and 'USER' using the DAL pattern.
* * Security Logic:
* 1. Checks if the caller is the Bootstrap Admin (via .env).
* 2. Checks if the caller has the 'ADMIN' role in the database.
* 3. Prevents the Bootstrap Admin from being demoted to 'USER'.
*/ */
export async function toggleUserRoleAction(targetUserId: string) { export async function toggleUserRoleAction(targetUserId: string) {
const session = await auth(); const session = await auth();
@ -19,46 +17,34 @@ export async function toggleUserRoleAction(targetUserId: string) {
throw new Error("Unauthorized: No session found."); throw new Error("Unauthorized: No session found.");
} }
// 1. Authorization: Who is trying to change the role? // 1. Authorization: Verify caller's permissions via DAL
const isBootstrap = callerEmail === process.env.INITIAL_ADMIN_EMAIL; const isBootstrap = callerEmail === process.env.INITIAL_ADMIN_EMAIL;
const callerDbRecord = await getUserByEmail(callerEmail);
const callerDbRecord = await prisma.user.findUnique({
where: { email: callerEmail },
select: { role: true }
});
const isAdmin = isBootstrap || callerDbRecord?.role === "ADMIN"; const isAdmin = isBootstrap || callerDbRecord?.role === "ADMIN";
if (!isAdmin) { if (!isAdmin) {
throw new Error("Forbidden: You do not have permission to manage roles."); throw new Error("Forbidden: You do not have permission to manage roles.");
} }
// 2. Fetch the target user to be modified // 2. Fetch target user via DAL
const targetUser = await prisma.user.findUnique({ const targetUser = await getUserById(targetUserId);
where: { id: targetUserId },
select: { id: true, email: true, role: true }
});
if (!targetUser) { if (!targetUser) {
throw new Error("User not found."); throw new Error("User not found.");
} }
// 3. Protection: Prevent demoting the primary bootstrap admin // 3. Protection: Prevent demoting the primary bootstrap admin
// This ensures you don't accidentally lock yourself out of the settings page.
if (targetUser.email === process.env.INITIAL_ADMIN_EMAIL && targetUser.role === "ADMIN") { if (targetUser.email === process.env.INITIAL_ADMIN_EMAIL && targetUser.role === "ADMIN") {
throw new Error("Security Restriction: The primary Bootstrap Admin role cannot be removed."); throw new Error("Security Restriction: The primary Bootstrap Admin role cannot be removed.");
} }
// 4. Determine new role // 4. Logic: Determine new role
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN"; const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
// 5. Execute Update // 5. Execute Update via DAL
await prisma.user.update({ await updateUserRole(targetUserId, newRole);
where: { id: targetUserId },
data: { role: newRole }
});
// 6. Refresh the data on the Settings page // 6. UI Invalidation
revalidatePath("/settings"); revalidatePath("/settings");
return { return {

View file

@ -1,15 +1,23 @@
'use server'; 'use server';
//src/app/update/[id]/_actions.ts // src/app/update/[id]/_actions.ts
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
/**
* SERVER ACTION: Updates file metadata and organizational data.
*/
export async function updateFileAction(formData: FormData) { export async function updateFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// 1. Authorization Guard
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
// 2. Data Extraction from FormData
const id = formData.get("id") as string; const id = formData.get("id") as string;
const name = formData.get("name") as string; const name = formData.get("name") as string;
const description = formData.get("description") as string; const description = formData.get("description") as string;
@ -17,33 +25,44 @@ export async function updateFileAction(formData: FormData) {
const customMetadataRaw = formData.get("customMetadata") as string; const customMetadataRaw = formData.get("customMetadata") as string;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw; const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
try { try {
// 1. Get existing record to preserve system metadata (like mimeType) // 3. Parse the incoming metadata from the UI
const existing = await prisma.fileNode.findUnique({ where: { id } }); const newMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
const existingMetadata = (existing?.metadata as Record<string, any>) || {};
// 2. Update the record // 4. DAL: Fetch existing record to safely merge system fields
await prisma.fileNode.update({ const existing = await getFileNodeById(id);
where: { id }, if (!existing) throw new Error("File record not found");
data: { if (existing.ownerId !== session.user.id) throw new Error("Permission denied");
name,
description, const existingMetadata = (existing.metadata as Record<string, any>) || {};
parentId,
metadata: { // 5. Logic: Merge Strategy
...customMetadata, // User's new keys // We keep internal system fields like 'mimeType' but allow the
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE", // user-approved 'newMetadata' (including extracted GPS/Author) to take precedence.
mimeType: existingMetadata.mimeType // Preserve the original mimeType const updatedMetadata = {
} ...existingMetadata, // Keep everything we currently have
} ...newMetadata, // Overwrite with the fields the user just approved/edited
};
// 6. DAL: Perform the update via your file-nodes logic
await updateFileNode(id, {
name: name || existing.name,
description,
parentId,
metadata: updatedMetadata,
}); });
// 7. Cache Invalidation
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath(`/update/${id}`); revalidatePath(`/update/${id}`);
return { success: true }; return { success: true };
} catch (error: any) { } catch (error: any) {
console.error("Update error:", error); console.error("Update action error:", error);
return { success: false, message: error.message }; return {
success: false,
message: error.message || "An unexpected error occurred during update"
};
} }
} }

View file

@ -4,31 +4,53 @@ import { prisma } from "@/lib/prisma";
import { Container } from "@mui/material"; import { Container } from "@mui/material";
import UpdateView from "./update-view"; import UpdateView from "./update-view";
// Note: params is now handled as a Promise
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) { export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) redirect("/");
// 1. Await the params to get the actual ID // Security: Ensure the user is logged in
if (!session?.user?.id) {
redirect("/");
}
// 1. Await the params to get the actual ID from the URL
const { id } = await params; const { id } = await params;
// 2. Fetch the specific file using the awaited ID // 2. Fetch the specific file.
// We include ownerId in the where clause to prevent users from editing each other's files.
const fileNode = await prisma.fileNode.findUnique({ const fileNode = await prisma.fileNode.findUnique({
where: { id: id } where: {
id: id,
ownerId: session.user.id
}
}); });
if (!fileNode) notFound(); if (!fileNode) {
notFound();
}
// Fetch folders for the destination dropdown // 3. Fetch folders for the destination dropdown (if you decide to allow moving files)
const folders = await prisma.fileNode.findMany({ const folders = await prisma.fileNode.findMany({
where: { isFolder: true }, where: {
isFolder: true,
ownerId: session.user.id
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
}); });
// 4. Convert Decimal/BigInt fields to strings/numbers if necessary for client serialization
const serializedFileNode = {
...fileNode,
size: fileNode.size ? fileNode.size.toString() : "0" // BigInt cannot be passed directly to Client Components
// metadata is already a JSON object, so it passes through fine
};
return ( return (
<Container maxWidth="md" sx={{ py: 8 }}> <Container maxWidth="md" sx={{ py: 8 }}>
<UpdateView fileNode={fileNode} folders={folders} /> <UpdateView
fileNode={serializedFileNode}
folders={folders}
/>
</Container> </Container>
); );
} }

View file

@ -1,41 +1,96 @@
'use client'; 'use client';
// src/app/update/[id]/update-view.tsx
import { useState } from "react"; import { useState } from "react";
import { import {
Box, Button, Typography, Paper, Stack, Box, Button, Typography, Paper, Stack,
TextField, MenuItem, IconButton, Grid, Divider TextField, MenuItem, IconButton, Grid,
Checkbox, CircularProgress, Tooltip
} from "@mui/material"; } from "@mui/material";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FolderIcon from "@mui/icons-material/Folder"; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import MapIcon from '@mui/icons-material/Map';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { updateFileAction } from "./_actions"; import { updateFileAction } from "./_actions";
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
interface MetadataPair { interface MetadataPair {
key: string; key: string;
value: string; value: string;
selected: boolean;
isPending?: boolean;
} }
export default function UpdateView({ fileNode, folders }: any) { /** * Utility to turn nested objects into flat key-value pairs for the UI
*/
const flattenObject = (obj: any, prefix = ''): Record<string, string> => {
let results: Record<string, string> = {};
for (const key in obj) {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(results, flattenObject(value, newKey));
} else {
results[newKey] = String(value);
}
}
return results;
};
export default function UpdateView({ fileNode, folders: availablefolders }: { fileNode: any; folders: any[]; }) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isExtracting, setIsExtracting] = useState(false);
// 1. Initialize Basic Info
const [name, setName] = useState(fileNode.name); const [name, setName] = useState(fileNode.name);
const [description, setDescription] = useState(fileNode.description || ""); const [description, setDescription] = useState(fileNode.description || "");
const [parentId, setParentId] = useState(fileNode.parentId || ""); const [parentId, setParentId] = useState(fileNode.parentId || "");
// 2. Parse existing JSON metadata into Key/Value array for the UI const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
// We filter out 'type' and 'mimeType' as they are system-managed .filter(([key]) => !['type', 'mimeType', 'magicFilled', 'details'].includes(key))
const initialMetadata = Object.entries(fileNode.metadata || {}) .map(([key, value]) => ({
.filter(([key]) => !['type', 'mimeType'].includes(key)) key,
.map(([key, value]) => ({ key, value: String(value) })); value: String(value),
selected: true,
isPending: false
}));
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata); const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
const handleMagicEnhance = async () => {
setIsExtracting(true);
try {
const result = await getMetadataPreviewAction(fileNode.id);
if (result.success) {
// Flatten the nested 'details' and top level props
const flatData = flattenObject(result.data);
const extractedRows: MetadataPair[] = Object.entries(flatData)
.filter(([key]) => !['type', 'mimeType', 'title'].includes(key) && !key.includes('Binary Data'))
.map(([key, value]) => ({
key,
value: String(value),
selected: true,
isPending: true
}));
setCustomMetadata(prev => {
const existingKeys = new Set(prev.map(r => r.key));
const filteredNew = extractedRows.filter(r => !existingKeys.has(r.key));
return [...prev, ...filteredNew];
});
}
} catch (err) {
alert("Failed to extract metadata.");
} finally {
setIsExtracting(false);
}
};
const handleUpdate = async () => { const handleUpdate = async () => {
setLoading(true); setLoading(true);
const formData = new FormData(); const formData = new FormData();
@ -44,9 +99,10 @@ export default function UpdateView({ fileNode, folders }: any) {
formData.append("description", description); formData.append("description", description);
formData.append("parentId", parentId); formData.append("parentId", parentId);
// Convert array back to object for storage
const metadataObj = customMetadata.reduce((acc, curr) => { const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.key.trim()) acc[curr.key.trim()] = curr.value; if (curr.selected && curr.key.trim()) {
acc[curr.key.trim()] = curr.value;
}
return acc; return acc;
}, {} as Record<string, string>); }, {} as Record<string, string>);
@ -64,105 +120,88 @@ export default function UpdateView({ fileNode, folders }: any) {
return ( return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}> <Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>Back</Button>
Back
</Button>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom> <Typography variant="h4" fontWeight={900} color="primary" gutterBottom>Edit File Details</Typography>
Edit File Details
</Typography>
<Stack spacing={4} sx={{ mt: 2 }}> <Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
{/* Name Field */} <Stack direction="row" justifyContent="space-between" alignItems="center">
<TextField <Box>
label="File Name" <Typography variant="subtitle1" fontWeight="bold">Enrich Metadata</Typography>
fullWidth value={name} <Typography variant="caption" color="text.secondary">Extract GPS, Camera Specs, and Dimensions.</Typography>
onChange={(e) => setName(e.target.value)} </Box>
slotProps={{ inputLabel: { shrink: true } }} <Button
/> variant="contained" color="secondary"
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
onClick={handleMagicEnhance} disabled={isExtracting}
>
{isExtracting ? 'Extracting...' : 'Magic Fill'}
</Button>
</Stack>
</Box>
{/* Folder Select */} <Stack spacing={4}>
<TextField <TextField label="File Name" fullWidth value={name} onChange={(e) => setName(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
id="update-dest-select"
select fullWidth label="Destination Folder" <TextField select fullWidth label="Destination" value={parentId} onChange={(e) => setParentId(e.target.value)}>
value={parentId}
onChange={(e) => setParentId(e.target.value)}
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true }
}}
>
<MenuItem value=""><em>-- Root --</em></MenuItem> <MenuItem value=""><em>-- Root --</em></MenuItem>
{folders.map((f: any) => ( {availablefolders?.map((f: any) => (<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>))}
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField> </TextField>
{/* Custom Metadata Section */}
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Stack direction="row" justifyContent="space-between" mb={2}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" fontWeight="700"><AssignmentIcon /> Attributes</Typography>
<AssignmentIcon color="primary" /> Custom Attributes <Button startIcon={<AddCircleOutlineIcon />} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}>Add Field</Button>
</Typography> </Stack>
<Button
startIcon={<AddCircleOutlineIcon />}
size="small"
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
>
Add Field
</Button>
</Box>
<Stack spacing={2}> <Stack spacing={2}>
{customMetadata.map((row, index) => ( {customMetadata.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center"> <Box key={index}>
<Grid item xs={5}> <Grid container spacing={1} alignItems="center">
<TextField <Grid item xs={1}>
fullWidth size="small" placeholder="Key" <Checkbox checked={row.selected} onChange={(e) => {
value={row.key}
onChange={(e) => {
const updated = [...customMetadata]; const updated = [...customMetadata];
updated[index].key = e.target.value; updated[index].selected = e.target.checked;
setCustomMetadata(updated); setCustomMetadata(updated);
}} }} />
/> </Grid>
<Grid item xs={4}>
<TextField fullWidth size="small" value={row.key} disabled={row.isPending} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].key = e.target.value;
setCustomMetadata(updated);
}} />
</Grid>
<Grid item xs={6}>
<TextField fullWidth size="small" value={row.value} sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].value = e.target.value;
setCustomMetadata(updated);
}} />
</Grid>
<Grid item xs={1}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}><DeleteOutlineIcon /></IconButton>
</Grid>
</Grid> </Grid>
<Grid item xs={6}>
<TextField {row.key.toLowerCase().includes('latitude') && row.value && (
fullWidth size="small" placeholder="Value" <Box sx={{ ml: 7, mt: 0.5 }}>
value={row.value} <Button size="small" startIcon={<MapIcon />} target="_blank"
onChange={(e) => { href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}>
const updated = [...customMetadata]; View on Map
updated[index].value = e.target.value; </Button>
setCustomMetadata(updated); </Box>
}} )}
/> </Box>
</Grid>
<Grid item xs={1}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))} ))}
</Stack> </Stack>
</Box> </Box>
<TextField <TextField label="Description" multiline rows={3} fullWidth value={description} onChange={(e) => setDescription(e.target.value)} />
label="Description"
multiline rows={4} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Button <Button variant="contained" size="large" fullWidth startIcon={<SaveIcon />} onClick={handleUpdate} disabled={loading}>
variant="contained" size="large" fullWidth
startIcon={<SaveIcon />}
onClick={handleUpdate}
disabled={loading}
sx={{ py: 1.5, fontWeight: 'bold' }}
>
{loading ? "Saving..." : "Save Changes"} {loading ? "Saving..." : "Save Changes"}
</Button> </Button>
</Stack> </Stack>

View file

@ -1,55 +1,52 @@
'use server'; 'use server';
//src/app/upload/_actions.ts)
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createFileNode, getAllFolders, upsertFileNodeByHash } from "@/data-access/file-nodes";
import { prisma } from "@/lib/prisma";
import { createOneDriveFolder, uploadToFolderId, ensureOneDriveFolder } from "@/services/onedrive";
/** /**
* 1. CREATE FOLDER: Virtual Only * FIXED: Changed findUnique to findFirst to avoid runtime database crashes
* Logic: User-created organizational folders exist ONLY in the database.
* No call to OneDrive is made here.
*/ */
export async function checkDuplicateAction(hash: string) {
const existing = await prisma.fileNode.findFirst({
where: { hash },
select: { name: true, parentId: true }
});
return existing;
}
export async function createFolderAction(name: string, parentId?: string | null) { export async function createFolderAction(name: string, parentId?: string | null) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
try { try {
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
const newNode = await createFileNode({
const newNode = await prisma.fileNode.create({ id: internalId,
data: { oneDriveId: null,
id: internalId, name,
oneDriveId: null, // Virtual folders do not have a cloud ID isFolder: true,
name: name, path: `virtual:/${name}`,
isFolder: true, ownerId: session.user.id,
path: `virtual:/${name}`, parentId: parentId || null,
ownerId: session.user.id, metadata: { type: "FOLDER" }
parentId: parentId || null,
metadata: { type: "FOLDER" }
}
}); });
revalidatePath("/upload");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true, node: newNode }; return { success: true, node: newNode };
} catch (error: any) { } catch (error: any) {
console.error("Folder creation error:", error);
throw new Error(error.message || "Failed to create virtual folder"); throw new Error(error.message || "Failed to create virtual folder");
} }
} }
/**
* 2. UPLOAD FILE: Physical Container
* Logic: Creates a physical folder (UUID) on OneDrive to hold the file.
* This ensures every file has a unique storage space in the cloud.
*/
export async function uploadFileAction(formData: FormData) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
const file = formData.get("file") as File; const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const description = formData.get("description") as string || ""; const description = formData.get("description") as string || "";
const parentIdRaw = formData.get("parentId") as string | null; const parentIdRaw = formData.get("parentId") as string | null;
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw; const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
@ -59,62 +56,22 @@ export async function uploadFileAction(formData: FormData) {
if (!file) throw new Error("No file selected"); if (!file) throw new Error("No file selected");
const accessToken = await getFreshAccessToken(session.user.id);
const rootFolder = "WebCalibre"; const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID(); // This UUID will be the OneDrive folder name const internalId = crypto.randomUUID();
// 1. Create the Physical Storage Folder on OneDrive try {
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, { await ensureOneDriveFolder(session.user.id, rootFolder);
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
name: internalId,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
});
if (!createSubFolderRes.ok) { const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const errorData = await createSubFolderRes.json(); const subFolderData = await subFolderRes.json();
throw new Error(errorData.error?.message || "Storage directory creation failed");
}
const subFolderData = await createSubFolderRes.json();
// 2. Create Upload Session inside the new Physical Folder const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
});
const { uploadUrl } = await sessionRes.json(); await createFileNode({
const buffer = Buffer.from(await file.arrayBuffer());
// 3. PUT the file binary
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
const uploadedFileData = await uploadRes.json();
const oneDriveId = uploadedFileData.id;
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
// 4. Create record in Database
// Link it to the VIRTUAL folder via parentId
await prisma.fileNode.create({
data: {
id: internalId, id: internalId,
oneDriveId: oneDriveId, oneDriveId: uploadedFileData.id,
name: file.name, name: file.name,
hash: hash,
description: description, description: description,
size: BigInt(file.size), size: BigInt(file.size),
isFolder: false, isFolder: false,
@ -123,13 +80,86 @@ export async function uploadFileAction(formData: FormData) {
parentId: parentId, parentId: parentId,
metadata: { metadata: {
...customMetadata, ...customMetadata,
type: extension, type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
mimeType: file.type mimeType: file.type
} }
} });
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload"); revalidatePath("/upload");
return { success: true }; return { success: true };
} catch (error: any) {
console.error("Upload refactor error:", error);
return { success: false, error: error.message };
}
}
export async function getFoldersAction() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
try {
const folders = await getAllFolders();
return folders;
} catch (error) {
console.error("Error in getFoldersAction:", error);
return [];
}
}
export async function executeBulkItemAction(formData: FormData) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized: You must be logged in to perform bulk actions.");
}
const file = formData.get("file") as File;
const hash = formData.get("hash") as string;
const targetFolderIdRaw = formData.get("targetFolderId") as string | null;
const targetFolderId = (targetFolderIdRaw === "" || targetFolderIdRaw === "root")
? null
: targetFolderIdRaw;
if (!file || !hash) {
throw new Error("Missing required file or hash data for bulk operation.");
}
try {
const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
// A. Direct upload to OneDrive
await ensureOneDriveFolder(session.user.id, rootFolder);
const subFolderRes = await createOneDriveFolder(session.user.id, rootFolder, internalId);
const subFolderData = await subFolderRes.json();
const uploadedFileData = await uploadToFolderId(session.user.id, file, subFolderData.id);
// B. Write or update PostgreSQL records via the safe wrapper
const result = await upsertFileNodeByHash({
name: file.name,
hash: hash,
oneDriveId: uploadedFileData.id,
parentId: targetFolderId,
size: BigInt(file.size),
ownerId: session.user.id,
});
revalidatePath("/dashboard");
return {
success: true,
id: result.node.id,
mode: result.mode
};
} catch (error: any) {
console.error("Bulk Item Execution Failure:", error);
return {
success: false,
error: error.message || "An unexpected error occurred during upload."
};
}
} }

View file

@ -0,0 +1,243 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box, Button, Typography, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, Chip,
Stack, MenuItem, Select, IconButton, Tooltip, LinearProgress
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
import { calculateFileHash } from '@/lib/hashing-client';
import { checkDuplicateAction, getFoldersAction, executeBulkItemAction } from '../_actions';
interface UploadQueueItem {
id: string;
file: File;
path: string;
hash: string | null;
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
targetFolderId: string;
}
export default function BulkUploadPage() {
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [dbFolders, setDbFolders] = useState<{id: string, name: string}[]>([]);
useEffect(() => {
async function loadFolders() {
try {
const folders = await getFoldersAction();
setDbFolders(folders);
} catch (err) {
console.error("Failed to load folders:", err);
}
}
loadFolders();
}, []);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newItems = acceptedFiles.map(file => ({
id: crypto.randomUUID(),
file,
path: (file as any).path || file.name,
hash: null,
status: 'queued' as const,
targetFolderId: ""
}));
setQueue(prev => [...prev, ...newItems]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
setQueue(curr => curr.map(item => item.id === id ? { ...item, ...updates } : item));
};
const removeItem = (id: string) => {
setQueue(prev => prev.filter(item => item.id !== id));
};
const copyFirstRowDestination = () => {
if (queue.length < 2) return;
const firstFolderId = queue[0].targetFolderId;
setQueue(current => current.map(item => ({ ...item, targetFolderId: firstFolderId })));
};
useEffect(() => {
if (!isProcessing) return;
const runAnalysis = async () => {
const nextIndex = queue.findIndex(item => item.status === 'queued');
if (nextIndex === -1) { setIsProcessing(false); return; }
const item = queue[nextIndex];
try {
updateItem(item.id, { status: 'hashing' });
const hash = await calculateFileHash(item.file);
updateItem(item.id, { status: 'checking', hash });
const existing = await checkDuplicateAction(hash);
updateItem(item.id, {
status: existing ? 'duplicate' : 'ready',
targetFolderId: existing?.parentId || item.targetFolderId
});
} catch (err) {
updateItem(item.id, { status: 'error' });
}
};
runAnalysis();
}, [queue, isProcessing]);
const handleExecute = async () => {
setIsExecuting(true);
const itemsToProcess = queue.filter(i => i.status === 'ready' || i.status === 'duplicate');
const BATCH_SIZE = 3;
for (let i = 0; i < itemsToProcess.length; i += BATCH_SIZE) {
const batch = itemsToProcess.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async (item) => {
updateItem(item.id, { status: 'uploading' });
try {
const formData = new FormData();
formData.append("file", item.file);
formData.append("hash", item.hash || "");
formData.append("targetFolderId", item.targetFolderId);
const result = await executeBulkItemAction(formData);
if (result.success) {
updateItem(item.id, { status: 'success' });
} else {
console.error(`Execution failed for ${item.path}:`, result.error);
updateItem(item.id, { status: 'error' });
}
} catch (error) {
console.error(`Network error for ${item.path}:`, error);
updateItem(item.id, { status: 'error' });
}
}));
}
setIsExecuting(false);
};
return (
<Box sx={{ p: 4, maxWidth: 1400, mx: 'auto' }}>
<Typography variant="h4" fontWeight={900} gutterBottom color="primary">
Bulk Upload & Recovery
</Typography>
{isExecuting && <LinearProgress sx={{ mb: 2 }} />}
<Paper
{...getRootProps()}
sx={{
p: 4, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'grey.50'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 40, color: 'primary.main' }} />
<Typography>Drag Folders or Files Here</Typography>
</Paper>
{queue.length > 0 && (
<>
<TableContainer component={Paper} sx={{ mb: 3, maxHeight: 600 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 50 }} />
<TableCell><strong>File Path</strong></TableCell>
<TableCell><strong>Status</strong></TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<strong>Destination</strong>
<IconButton size="small" onClick={copyFirstRowDestination} color="primary" disabled={isExecuting}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Box>
</TableCell>
<TableCell align="right"><strong>Size</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell>
<IconButton
size="small"
color="error"
onClick={() => removeItem(item.id)}
disabled={isExecuting || item.status === 'uploading'}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
<TableCell sx={{ fontSize: '0.75rem', maxWidth: 300, overflow: 'hidden' }}>
{item.path}
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'success' ? 'success' :
item.status === 'duplicate' ? 'warning' :
item.status === 'error' ? 'error' : 'default'
}
/>
</TableCell>
<TableCell>
<Select
value={item.targetFolderId}
onChange={(e) => updateItem(item.id, { targetFolderId: e.target.value })}
size="small" fullWidth displayEmpty
disabled={item.status === 'success' || isExecuting}
sx={{ fontSize: '0.8rem' }}
>
<MenuItem value="">Root Directory</MenuItem>
{dbFolders.map(f => <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>)}
</Select>
</TableCell>
<TableCell align="right">
{(item.file.size / 1024 / 1024).toFixed(2)}MB
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={() => setQueue([])} disabled={isExecuting}>Clear Queue</Button>
<Button
variant="contained"
startIcon={<PlayArrowIcon />}
onClick={() => setIsProcessing(true)}
disabled={isProcessing || isExecuting}
>
Analyze
</Button>
<Button
variant="contained"
color="success"
startIcon={<CloudUploadIcon />}
onClick={handleExecute}
disabled={isProcessing || isExecuting || !queue.some(i => i.status === 'ready' || i.status === 'duplicate')}
>
Execute Upload/Restore
</Button>
</Stack>
</>
)}
</Box>
);
}

View file

@ -1,25 +1,181 @@
import { auth } from "@/auth"; // src/app/upload/bulk/page.tsx
import { redirect } from "next/navigation";
import UploadView from "./upload-view";
import { Container } from "@mui/material";
import { prisma } from "@/lib/prisma";
export default async function UploadPage() { "use client";
const session = await auth();
if (!session) redirect("/"); import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box, Button, Typography, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, LinearProgress, Chip,
Alert, Stack
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
// Fetch only folders so the user can select a destination // --- OUR UTILITIES ---
const folders = await prisma.fileNode.findMany({ import { calculateFileHash } from '@/lib/hashing-client';
where: { isFolder: true }, import { checkDuplicateAction } from '@/app/upload/_actions';
orderBy: { name: 'asc' },
select: { id: true, name: true, parentId: true } interface UploadQueueItem {
}); id: string;
file: File;
path: string;
hash: string | null;
status: 'queued' | 'hashing' | 'checking' | 'ready' | 'uploading' | 'success' | 'duplicate' | 'error';
error?: string;
}
export default function BulkUploadPage() {
const [queue, setQueue] = useState<UploadQueueItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// 1. Handle File & Folder Drops
const onDrop = useCallback((acceptedFiles: File[]) => {
const newItems = acceptedFiles.map(file => ({
id: crypto.randomUUID(),
file,
path: (file as any).path || file.name, // Captures subfolder structure
hash: null,
status: 'queued' as const,
}));
setQueue(prev => [...prev, ...newItems]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
// 2. The "Processing Engine"
// This effect runs whenever the queue changes or isProcessing toggles
useEffect(() => {
if (!isProcessing) return;
const runQueue = async () => {
// Find the next file that hasn't been hashed/checked yet
const nextIndex = queue.findIndex(item => item.status === 'queued');
if (nextIndex === -1) {
setIsProcessing(false);
return;
}
const item = queue[nextIndex];
try {
// Step A: Hashing
updateItem(item.id, { status: 'hashing' });
const hash = await calculateFileHash(item.file);
// Step B: Duplicate Check
updateItem(item.id, { status: 'checking', hash });
const existing = await checkDuplicateAction(hash);
// Step C: Mark Results
updateItem(item.id, {
status: existing ? 'duplicate' : 'ready'
});
} catch (err) {
updateItem(item.id, { status: 'error', error: 'Process failed' });
}
};
runQueue();
}, [queue, isProcessing]);
const updateItem = (id: string, updates: Partial<UploadQueueItem>) => {
setQueue(current => current.map(item => item.id === id ? { ...item, ...updates } : item));
};
const duplicateCount = queue.filter(i => i.status === 'duplicate').length;
return ( return (
<Container maxWidth="md" sx={{ py: 8 }}> <Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
{/* Pass folders to the view */} <Typography variant="h4" fontWeight={800} color="primary" gutterBottom>
<UploadView user={session.user} folders={folders} /> Bulk Uploads & Restore
</Container> </Typography>
<Paper
{...getRootProps()}
sx={{
p: 6, mb: 4, textAlign: 'center', cursor: 'pointer',
border: '2px dashed', borderColor: isDragActive ? 'primary.main' : 'divider',
bgcolor: isDragActive ? 'primary.50' : 'background.paper',
transition: 'all 0.2s'
}}
>
<input {...getInputProps()} {...({ webkitdirectory: "", directory: "" } as any)} />
<FolderIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6">Drop Folders or Files Here</Typography>
<Typography variant="body2" color="text.secondary">
Perfect for camera imports or full system restores
</Typography>
</Paper>
{duplicateCount > 0 && (
<Alert severity="warning" sx={{ mb: 3 }}>
{duplicateCount} duplicate(s) found. These files already exist in your library.
</Alert>
)}
{queue.length > 0 && (
<TableContainer component={Paper} sx={{ maxHeight: 500, borderRadius: 2 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Location / Path</TableCell>
<TableCell>Size</TableCell>
<TableCell>Status</TableCell>
<TableCell>SHA-256 Hash</TableCell>
</TableRow>
</TableHead>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id} hover>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.path}
</TableCell>
<TableCell>
{(item.file.size / 1024 / 1024).toFixed(2)} MB
</TableCell>
<TableCell>
<Chip
label={item.status.toUpperCase()}
size="small"
color={
item.status === 'duplicate' ? 'warning' :
item.status === 'ready' ? 'info' :
item.status === 'success' ? 'success' : 'default'
}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{item.hash ? `${item.hash.substring(0, 16)}...` : '---'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Stack direction="row" spacing={2} sx={{ mt: 4 }}>
<Button
variant="contained"
size="large"
startIcon={isProcessing ? <LinearProgress sx={{ width: 20 }} /> : <CloudUploadIcon />}
disabled={isProcessing || queue.length === 0}
onClick={() => setIsProcessing(true)}
>
{isProcessing ? 'Analyzing...' : `Analyze ${queue.length} Files`}
</Button>
<Button
variant="outlined"
color="inherit"
disabled={isProcessing}
onClick={() => setQueue([])}
>
Clear All
</Button>
</Stack>
</Box>
); );
} }

View file

@ -1,251 +1,345 @@
'use client'; 'use client';
// src/app/upload/upload-view.tsx
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { import {
Box, Button, Typography, Paper, Stack, Box, Button, Typography, Paper, Stack,
TextField, MenuItem, IconButton, Divider, TextField, IconButton, Divider,
Grid, CircularProgress Grid,
CircularProgress, Checkbox, MenuItem,
Collapse,
Dialog, DialogTitle, DialogContent,
DialogContentText, DialogActions
} from "@mui/material"; } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import FolderIcon from "@mui/icons-material/Folder";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import AssignmentIcon from '@mui/icons-material/Assignment';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { useRouter } from "next/navigation"; import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { uploadFileAction, createFolderAction } from "./_actions"; import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import AssignmentIcon from '@mui/icons-material/Assignment';
import ClearIcon from '@mui/icons-material/Clear';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
interface MetadataPair { import { useRouter } from "next/navigation";
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
import { calculateFileHash } from "@/lib/hashing-client";
import { uploadFileAction, createFolderAction, checkDuplicateAction } from "./_actions";
interface MetadataRow {
key: string; key: string;
value: string; value: string;
isPending?: boolean;
selected?: boolean;
} }
export default function UploadView({ user, folders = [] }: any) { export default function UploadView({ folders }: { user: any; folders: any[] }) {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null); // Form State
const [description, setDescription] = useState(""); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [parentId, setParentId] = useState(""); const [targetFolderId, setTargetFolderId] = useState<string>("");
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle'); const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
// Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [rows, setRows] = useState<MetadataRow[]>([]);
const handleCreateFolder = async () => { // UI Status State
if (!newFolderName.trim()) return; const [isExtracting, setIsExtracting] = useState(false);
setIsCreatingFolder(true); const [saveStatus, setSaveStatus] = useState<'idle' | 'hashing' | 'saving'>('idle');
// Duplicate Dialog State
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [duplicateInfo, setDuplicateInfo] = useState<{ name: string; hash: string } | null>(null);
const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
// --- 1. MAGIC EXTRACTION ---
const handleMagicEnhance = async () => {
if (!selectedFile) return;
setIsExtracting(true);
try { try {
// FIX: Pass the current parentId to the action so it nests correctly const result = await getMetadataPreviewAction(selectedFile.name);
const result = await createFolderAction(newFolderName, parentId);
if (result.success) { if (result.success) {
setNewFolderName(""); const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
setShowFolderInput(false); key: k,
router.refresh(); value: typeof v === 'object' ? JSON.stringify(v) : String(v),
isPending: true,
selected: true
}));
setRows(prev => {
const existingKeys = new Set(prev.map(r => r.key));
const newUniqueRows = extractedRows.filter(r => !existingKeys.has(r.key));
return [...prev, ...newUniqueRows];
});
} }
} catch (err: any) { } catch (err) {
alert(err.message || "Failed to create folder"); console.error("Extraction failed:", err);
} finally { } finally {
setIsCreatingFolder(false); setIsExtracting(false);
} }
}; };
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]); const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const removeMetadataRow = (index: number) => { if (file) setSelectedFile(file);
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
}; };
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => { // --- 2. UPLOAD EXECUTION ---
const updated = [...customMetadata]; const executeUpload = async (preCalculatedHash?: string) => {
updated[index][field] = val; setSaveStatus('saving');
setCustomMetadata(updated);
};
const handleUpload = async () => {
if (!file) return;
setStatus('uploading');
const formData = new FormData();
formData.append("file", file);
formData.append("description", description);
formData.append("parentId", parentId);
const metadataObj = customMetadata.reduce((acc, curr) => {
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
formData.append("customMetadata", JSON.stringify(metadataObj));
try { try {
const result = await uploadFileAction(formData); let currentParentId = targetFolderId;
if (result.success) {
setStatus('success'); // STEP A: Handle New Folder Creation
setFile(null); if (newFolderName.trim()) {
setDescription(""); const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
setCustomMetadata([]); if (folderResult.success) {
router.push("/dashboard"); currentParentId = folderResult.node.id;
router.refresh(); } else {
throw new Error(folderResult.error || "Failed to create folder");
}
} }
} catch (err) {
alert("Upload failed."); // STEP B: Handle File Upload
setStatus('idle'); if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("hash", preCalculatedHash || "");
formData.append("parentId", currentParentId || "root");
const metadataObject = rows
.filter(r => r.selected && r.key.trim() !== "")
.reduce((acc, curr) => {
acc[curr.key.trim()] = curr.value;
return acc;
}, {} as Record<string, string>);
formData.append("customMetadata", JSON.stringify(metadataObject));
const uploadResult = await uploadFileAction(formData);
if (!uploadResult.success) throw new Error(uploadResult.error || "Upload failed");
}
router.push("/dashboard");
router.refresh();
} catch (err: any) {
console.error("Save failed:", err);
alert(err.message || "An error occurred while saving.");
setSaveStatus('idle');
}
};
// --- 3. SAVE HANDLER (With Hash Intercept) ---
const handleSave = async () => {
if (!canSubmit) return;
if (selectedFile) {
setSaveStatus('hashing');
// Calculate local SHA-256
const fileHash = await calculateFileHash(selectedFile);
// Check database via Server Action
const duplicate = await checkDuplicateAction(fileHash);
if (duplicate) {
setDuplicateInfo({ name: duplicate.name, hash: fileHash });
setDuplicateDialogOpen(true);
return; // Dialog takes over from here
}
await executeUpload(fileHash);
} else {
await executeUpload(); // Folder only
} }
}; };
return ( return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}> <>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center"> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto', mt: 4 }} elevation={3}>
Add to Library <Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
</Typography> Upload & Enrich
</Typography>
<Stack spacing={4} sx={{ mt: 4 }}> <Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
{/* 1. Destination */} {/* FOLDER SELECTION */}
<Box> <Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Stack direction="row" spacing={1}>
<FolderIcon color="primary" /> 1. Destination <TextField
</Typography> select fullWidth label="Parent Destination"
value={targetFolderId}
onChange={(e) => setTargetFolderId(e.target.value)}
helperText="Choose where your file will live"
>
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders?.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<Button
variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
sx={{ height: 56, minWidth: 56 }}
>
<CreateNewFolderIcon />
</Button>
</Stack>
<Stack direction="row" spacing={1}> <Collapse in={showNewFolderInput}>
<TextField <Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
id="project-destination-select" <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
select NEW SUB-FOLDER NAME
fullWidth </Typography>
label="Target Project / Folder"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
size="small"
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true },
}}
>
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
{folders.map((f: any) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))}
</TextField>
<IconButton
color="primary"
onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
>
<CreateNewFolderIcon />
</IconButton>
</Stack>
{showFolderInput && (
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{parentId ? `Create inside current selection` : `Create at Root`}
</Typography>
<Stack direction="row" spacing={1}>
<TextField <TextField
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)" fullWidth size="small" placeholder="e.g. Finance 2026"
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()}
/> />
<Button
variant="contained"
onClick={handleCreateFolder}
disabled={isCreatingFolder || !newFolderName}
>
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
</Button>
</Stack>
</Box>
)}
</Box>
{/* 2. Upload Area */}
<Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CloudUploadIcon color="primary" /> 2. Upload File
</Typography>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<Button
variant="outlined"
fullWidth
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }}
onClick={() => fileInputRef.current?.click()}
>
{file ? (
<Box>
<Typography color="success.main" fontWeight="bold"> {file.name}</Typography>
<Typography variant="caption" color="text.secondary">
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
</Typography>
</Box> </Box>
) : ( </Collapse>
"Click to Select File"
)}
</Button>
</Box>
{/* 3. Custom Attributes */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> 3. Custom Attributes
</Typography>
<Button startIcon={<AddCircleOutlineIcon />} size="small" onClick={addMetadataRow}>
Add Field
</Button>
</Box> </Box>
{/* FILE SELECTION */}
<Box>
<input
type="file" id="file-upload-input" style={{ display: 'none' }}
onChange={handleFileChange} ref={fileInputRef}
/>
{!selectedFile ? (
<Button
variant="outlined" fullWidth startIcon={<CloudUploadIcon />}
onClick={() => fileInputRef.current?.click()}
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2, borderRadius: 2 }}
>
Select File to Upload
</Button>
) : (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloudUploadIcon color="primary" />
<Typography variant="body1" fontWeight="600">{selectedFile.name}</Typography>
</Stack>
<IconButton onClick={() => setSelectedFile(null)} color="error" size="small">
<ClearIcon />
</IconButton>
</Paper>
)}
</Box>
</Stack>
<Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT */}
<Box sx={{ mb: 4, p: 2.5, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight="bold" color="primary.main">Magic Extract</Typography>
<Typography variant="caption" color="text.secondary">
Auto-pull metadata from file content.
</Typography>
</Box>
<Button
variant="contained" onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
sx={{ borderRadius: 20, px: 3 }}
>
{isExtracting ? "Extracting..." : "Run"}
</Button>
</Stack>
</Box>
{/* METADATA PREVIEW */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" fontWeight="700" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Metadata Fields
</Typography>
<Stack spacing={2}> <Stack spacing={2}>
{customMetadata.map((row, index) => ( {rows.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center"> <Grid container spacing={1} key={index} alignItems="center">
<Grid item xs={5}> <Grid item xs={1}>
<TextField <Checkbox checked={row.selected} size="small" onChange={(e) => {
fullWidth size="small" placeholder="Key (e.g. Project-ID)" const updated = [...rows];
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)} updated[index].selected = e.target.checked;
/> setRows(updated);
}} />
</Grid> </Grid>
<Grid item xs={6}> <Grid item xs={5}>
<TextField <TextField fullWidth size="small" label="Key" value={row.key} onChange={(e) => {
fullWidth size="small" placeholder="Value" const updated = [...rows];
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)} updated[index].key = e.target.value;
/> setRows(updated);
}} />
</Grid>
<Grid item xs={5}>
<TextField fullWidth size="small" label="Value" value={row.value} onChange={(e) => {
const updated = [...rows];
updated[index].value = e.target.value;
setRows(updated);
}} />
</Grid> </Grid>
<Grid item xs={1}> <Grid item xs={1}>
<IconButton color="error" onClick={() => removeMetadataRow(index)}> <IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
<DeleteOutlineIcon /> <DeleteOutlineIcon />
</IconButton> </IconButton>
</Grid> </Grid>
</Grid> </Grid>
))} ))}
<Button variant="text" startIcon={<AddCircleOutlineIcon />} onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}>
<TextField Add Manual Field
label="General Description" </Button>
multiline rows={2} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Stack> </Stack>
</Box> </Box>
{/* FINAL BUTTON */}
<Button <Button
variant="contained" size="large" fullWidth variant="contained" size="large" fullWidth onClick={handleSave}
disabled={!file || status === 'uploading'} disabled={!canSubmit || saveStatus !== 'idle'}
onClick={handleUpload} sx={{ py: 2, fontWeight: 'bold', borderRadius: 2, boxShadow: 4 }}
sx={{ py: 2, fontWeight: 'bold' }}
> >
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'} {saveStatus === 'hashing' ? <CircularProgress size={24} color="inherit" /> :
saveStatus === 'saving' ? "Uploading to OneDrive..." :
"Complete Upload & Save"}
</Button> </Button>
</Stack> </Paper>
</Paper>
{/* --- DUPLICATE ALERT DIALOG --- */}
<Dialog
open={duplicateDialogOpen}
onClose={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
PaperProps={{ sx: { borderRadius: 3, p: 1, maxWidth: 450 } }}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'warning.dark', fontWeight: 'bold' }}>
<WarningAmberIcon fontSize="large" /> Duplicate Content
</DialogTitle>
<DialogContent>
{/* FIX: Added component="div" here.
This prevents the "<div> cannot be a descendant of <p>" error
*/}
<DialogContentText component="div">
The file you selected has exactly the same content as a file already in your library:
<Box sx={{ mt: 2, p: 2, bgcolor: 'warning.50', border: '1px solid', borderColor: 'warning.light', borderRadius: 2, color: 'text.primary', fontWeight: '600' }}>
{duplicateInfo?.name}
</Box>
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
Would you like to skip this upload or create a second copy?
</Typography>
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button
onClick={() => { setDuplicateDialogOpen(false); setSaveStatus('idle'); }}
variant="outlined" color="inherit" fullWidth
>
Cancel
</Button>
<Button
onClick={() => { setDuplicateDialogOpen(false); executeUpload(duplicateInfo?.hash); }}
variant="contained" color="warning" fullWidth
>
Upload Anyway
</Button>
</DialogActions>
</Dialog>
</>
); );
} }

View file

@ -1,3 +1,4 @@
// src/auth.config.ts
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import type { NextAuthConfig } from "next-auth"; import type { NextAuthConfig } from "next-auth";
@ -9,20 +10,10 @@ export default {
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER, issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
authorization: { authorization: {
params: { params: {
scope: "openid profile offline_access email Files.ReadWrite Files.Read", // offline_access is vital for getting the refresh_token
prompt: "consent", // Forces Microsoft to show the permission screen scope: "openid profile email offline_access Files.ReadWrite Files.Read",
access_type: "offline",
}, },
}, },
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
azureAdUserId: profile.oid ?? profile.sub,
};
},
}), }),
], ],
} satisfies NextAuthConfig; } satisfies NextAuthConfig;

View file

@ -7,22 +7,18 @@ import authConfig from "./auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" }, session: { strategy: "jwt" },
...authConfig, ...authConfig, // This now spreads the default export from auth.config.ts
callbacks: { callbacks: {
async jwt({ token, account, user }) { async jwt({ token, account, user }) {
// 1. Handle OAuth tokens (from first sign-in)
// This captures the tokens directly from the Microsoft Azure response
if (account) { if (account) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.refreshToken = account.refresh_token; token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at; token.expiresAt = account.expires_at;
} }
// 2. Attach User ID and Role to the token
// This runs when the user first logs in
if (user) { if (user) {
token.sub = user.id; token.sub = user.id;
// @ts-ignore - 'role' is a custom field in your Postgres User table // @ts-ignore
token.role = user.role; token.role = user.role;
} }
@ -30,16 +26,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}, },
async session({ session, token }) { async session({ session, token }) {
// 3. Pass values from the JWT Token into the Client-facing Session
// This makes the tokens and IDs available to your API routes and Components
if (session?.user) { if (session?.user) {
session.user.id = token.sub as string; session.user.id = token.sub as string;
// @ts-ignore
// @ts-ignore - Attaching the role for UI permissions
session.user.role = token.role as string; session.user.role = token.role as string;
// IMPORTANT: We must attach the accessToken here so the
// /api/download route can use it to fetch from MS Graph
session.accessToken = token.accessToken as string; session.accessToken = token.accessToken as string;
} }
return session; return session;
@ -50,7 +40,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async linkAccount({ account, user }) { async linkAccount({ account, user }) {
console.log("🔗 Account linked successfully for user:", user.id); console.log("🔗 Account linked successfully for user:", user.id);
if (!account.refresh_token) { if (!account.refresh_token) {
console.warn("⚠️ WARNING: No refresh_token received in linkAccount event!"); console.warn("⚠️ WARNING: No refresh_token received!");
} }
} }
} }

View file

@ -0,0 +1,18 @@
'use client';
import { signOut } from "next-auth/react";
import { Button } from "@mui/material";
import LogoutIcon from '@mui/icons-material/Logout';
export default function LogoutButton() {
return (
<Button
variant="outlined"
color="error"
startIcon={<LogoutIcon />}
onClick={() => signOut({ callbackUrl: "/" })}
>
Sign Out
</Button>
);
}

View file

@ -9,6 +9,7 @@ import {
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'; import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudSyncIcon from '@mui/icons-material/CloudSync'; // Great icon for Bulk/Restore
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import Link from 'next/link'; import Link from 'next/link';
@ -44,6 +45,7 @@ export default function Navbar({ user }: NavbarProps) {
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' }, { text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' }, { text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' }, { text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
{ text: 'Bulk Upload / Restore', icon: <CloudSyncIcon />, href: '/upload/bulk' },
]; ];
// Only push Settings if the user has Admin rights // Only push Settings if the user has Admin rights

View file

@ -1,12 +1,19 @@
'use client'; 'use client';
import React from "react";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
// ... other imports like ThemeProvider import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/lib/theme'; // Import your custom theme here
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<SessionProvider> <SessionProvider>
<AppRouterCacheProvider> <AppRouterCacheProvider options={{ enableCssLayer: true }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
{/* CssBaseline resets browser styles to match MUI and your theme */}
<CssBaseline />
{children} {children}
</ThemeProvider> </ThemeProvider>
</AppRouterCacheProvider> </AppRouterCacheProvider>

View file

@ -0,0 +1,156 @@
// src/data-access/file-nodes.ts
import "server-only";
import { getOneDriveFileBuffer } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { prisma } from "@/lib/prisma";
export async function getAllFileNodes() {
return await prisma.fileNode.findMany({
orderBy: {
updatedAt: 'desc',
},
});
}
export async function getFileNodeById(id: string) {
return await prisma.fileNode.findUnique({
where: { id },
});
}
export async function updateFileNode(id: string, data: any) {
return await prisma.fileNode.update({
where: { id },
data: {
...data,
updatedAt: new Date(),
},
});
}
export async function deleteFileNode(id: string) {
return await prisma.fileNode.delete({
where: { id },
});
}
export async function createFileNode(data: {
id?: string;
oneDriveId: string | null;
name: string;
hash?: string | null;
description?: string;
isFolder: boolean;
path: string;
ownerId: string;
parentId?: string | null;
size?: bigint;
metadata: any;
}) {
return await prisma.fileNode.create({
data: {
...data,
id: data.id ?? crypto.randomUUID(),
}
});
}
export async function upsertFileNode(oneDriveId: string, data: {
name: string;
size: bigint;
isFolder: boolean;
path: string;
ownerId: string;
metadata: any;
hash?: string | null;
}) {
return await prisma.fileNode.upsert({
where: { oneDriveId },
update: {
name: data.name,
size: data.size,
isFolder: data.isFolder,
path: data.path,
hash: data.hash,
updatedAt: new Date(),
},
create: {
id: crypto.randomUUID(),
oneDriveId: oneDriveId,
name: data.name,
size: data.size,
isFolder: data.isFolder,
path: data.path,
ownerId: data.ownerId,
metadata: data.metadata,
hash: data.hash,
}
});
}
export async function getAllFolders() {
return await prisma.fileNode.findMany({
where: {
isFolder: true
},
select: {
id: true,
name: true
},
orderBy: {
name: 'asc'
}
});
}
/**
* UPSERT BY HASH: FIXED FOR SYSTEM STABILITY
*/
export async function upsertFileNodeByHash(data: {
name: string;
hash: string;
oneDriveId: string;
parentId?: string | null;
size: bigint;
ownerId: string;
}) {
// Use findFirst instead of findUnique to protect against schema constraints
const existing = await prisma.fileNode.findFirst({
where: { hash: data.hash }
});
if (existing) {
// 🛡️ DISASTER RECOVERY MODE (RESTORE)
const updatedNode = await prisma.fileNode.update({
where: { id: existing.id },
data: {
oneDriveId: data.oneDriveId,
parentId: data.parentId || existing.parentId,
updatedAt: new Date(),
}
});
return { node: updatedNode, mode: 'restored' as const };
}
// ✨ NEW UPLOAD MODE
const fileExtension = data.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
const newNode = await prisma.fileNode.create({
data: {
id: crypto.randomUUID(),
oneDriveId: data.oneDriveId,
name: data.name,
hash: data.hash,
size: data.size,
isFolder: false,
ownerId: data.ownerId,
parentId: data.parentId || null,
path: `/WebCalibre/Bulk/${data.name}`,
metadata: {
type: fileExtension,
mimeType: "application/octet-stream"
},
}
});
return { node: newNode, mode: 'created' as const };
}

42
src/data-access/users.ts Normal file
View file

@ -0,0 +1,42 @@
import "server-only";
import { prisma } from "@/lib/prisma";
/**
* FETCH: Get user by Email
* Used for authorization checks in actions.
*/
export async function getUserByEmail(email: string) {
return await prisma.user.findUnique({
where: { email },
select: { id: true, email: true, role: true }
});
}
/**
* FETCH: Get user by ID
*/
export async function getUserById(id: string) {
return await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, role: true }
});
}
/**
* UPDATE: Update user role
*/
export async function updateUserRole(id: string, role: "ADMIN" | "USER") {
return await prisma.user.update({
where: { id },
data: { role }
});
}
/**
* FETCH: List all users (for the settings table)
*/
export async function getAllUsers() {
return await prisma.user.findMany({
orderBy: { email: 'asc' }
});
}

View file

@ -1,3 +1,4 @@
// src/lib/auth-utils.ts
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function getFreshAccessToken(userId: string) { export async function getFreshAccessToken(userId: string) {
@ -11,7 +12,6 @@ export async function getFreshAccessToken(userId: string) {
} }
// 2. Check if the token is expired (with a 1-minute buffer) // 2. Check if the token is expired (with a 1-minute buffer)
// account.expires_at is usually in seconds, so we multiply by 1000
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000; const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
if (!isExpired && account.access_token) { if (!isExpired && account.access_token) {
@ -30,12 +30,17 @@ export async function getFreshAccessToken(userId: string) {
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!, client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: account.refresh_token, refresh_token: account.refresh_token,
// CRITICAL: Re-declare scopes to ensure the new access_token has OneDrive permissions
scope: "openid profile offline_access Files.ReadWrite Files.Read",
}), }),
}); });
const tokens = await response.json(); const tokens = await response.json();
if (!response.ok) throw tokens; if (!response.ok) {
console.error("❌ Microsoft Token Refresh Response Error:", tokens);
throw tokens;
}
// 4. Update the Account table with the new tokens // 4. Update the Account table with the new tokens
await prisma.account.update({ await prisma.account.update({
@ -43,6 +48,7 @@ export async function getFreshAccessToken(userId: string) {
data: { data: {
access_token: tokens.access_token, access_token: tokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in), expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
// Microsoft sometimes rotates the refresh_token; save it if they provide a new one
refresh_token: tokens.refresh_token ?? account.refresh_token, refresh_token: tokens.refresh_token ?? account.refresh_token,
}, },
}); });
@ -50,6 +56,7 @@ export async function getFreshAccessToken(userId: string) {
return tokens.access_token; return tokens.access_token;
} catch (error) { } catch (error) {
console.error("❌ Failed to refresh Microsoft token:", error); console.error("❌ Failed to refresh Microsoft token:", error);
// Returning a specific error string helps Auth.js or your components handle re-auth
throw new Error("RefreshAccessTokenError"); throw new Error("RefreshAccessTokenError");
} }
} }

View file

@ -0,0 +1,9 @@
// src/lib/hashing-client.ts
// src/lib/hashing-client.ts
export async function calculateFileHash(file: File): Promise<string> {
const arrayBuffer = await file.arrayBuffer();
// Native browser API (SubtleCrypto)
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

9
src/lib/hashing.ts Normal file
View file

@ -0,0 +1,9 @@
// src/lib/hashing.ts
import crypto from 'crypto';
/**
* Generates an MD5 hash from a file buffer.
*/
export function generateFileHash(buffer: Buffer): string {
return crypto.createHash('md5').update(buffer).digest('hex');
}

View file

@ -0,0 +1,83 @@
// src/lib/metadata-extractor.ts
// 1. Update your import to use the new named export
//import * as PDFLib from 'pdf-parse';
//import { PDFParse } from 'pdf-parse';
//import {pdf} from 'pdf-parse';
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp';
import exifReader from 'exif-reader';
/**
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER:
* Converts Buffers to strings, standardizes keys, and handles GPS conversion.
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
// Specifically handle GPS Latitude/Longitude Arrays
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
if (extension === 'pdf') {
// Create parser instance
const parser = new PdfParse.SmartPDFParser({
oversaturationFactor: 2.0,
enableFastPath: true
});
console.log(`--- PDF Debug Start: ${filename} ---`);
const result = await parser.parse(buffer);
console.log(`Parsed ${result.numpages} pages using ${result._meta.method}`);
console.log(`Parsed ${result.info} info using ${result._meta.method}`);
console.log(`Parsed ${result.info} info using ${result._meta.method}`);
console.log(JSON.stringify(result.info, null, 2));
//const { text, numpages, info } = await pdf(buffer);
//const parser = new PDFParse(buffer);
// `text` → full document text
// `numpages` → page count
// `info` → metadata (author, creation date, etc.)
// console.log(`Pages: ${numpages}`);
// console.log(`Author: ${info.Author}`);
// console.log(text.slice(0, 200)); // preview first 200 chars
return {
type: 'PDF',
title: filename
};
}
}

View file

@ -0,0 +1,145 @@
// src/lib/metadata-extractor.ts
// 1. Update your import to use the new named export
import * as PDFLib from 'pdf-parse';
import sharp from 'sharp';
import exifReader from 'exif-reader';
/**
* Converts EXIF DMS (Degrees, Minutes, Seconds) array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER:
* Converts Buffers to strings, standardizes keys, and handles GPS conversion.
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
// Specifically handle GPS Latitude/Longitude Arrays
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
if (extension === 'pdf') {
console.log(`--- PDF Debug Start: ${filename} ---`);
// Initialize using the data property for our Buffer
const parser = new PDFLib.PDFParse({
data: buffer,
// disableWorker: true, // Crucial for Next.js
// verbosity: 0
});
try {
const result = await parser.getInfo({ parsePageInfo: true });
// LOGGING TO TERMINAL
console.log(`✅ Total pages: ${result.total}`);
console.log(`✅ Title: ${result.info?.Title}`);
console.log(`✅ Author: ${result.info?.Author}`);
console.log(`✅ Creator: ${result.info?.Creator}`);
// Date info
const dates = result.getDateNode();
console.log(`✅ Creation Date: ${dates.CreationDate}`);
console.log('--- PDF Debug End ---');
}
// return {
// type: 'PDF',
// title: filename,
// pageCount: result.total,
// details: result.info, // This sends the raw info object back
// };
// } catch (err: any) {
// console.error("❌ PDF Parsing Error inside debug block:", err.message);
// return { type: 'PDF', error: err.message };
// } finally {
// await parser.destroy();
// }
// --- 1. PDF EXTRACTION (v2 Class-based) ---
// if (extension === 'pdf') {
// // Access PDFParse from the namespace
// const parser = new PDFLib.PDFParse({
// data: buffer,
// disableWorker: true, // Prevents the .mjs worker error
// verbosity: 0
// });
// try {
// const infoResult = await parser.getInfo({ parsePageInfo: true });
// const textResult = await parser.getText();
// return {
// type: 'PDF',
// title: filename,
// details: sanitizeMetadata(infoResult.info || {}),
// pageCount: infoResult.total || 0,
// textPreview: textResult.text ? textResult.text.substring(0, 200).replace(/\s+/g, ' ') : ""
// };
// } finally {
// // Free memory
// await parser.destroy();
// }
// }
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let details = {};
if (metadata.exif) {
try {
const rawExif = exifReader(metadata.exif);
details = sanitizeMetadata(rawExif);
} catch (e) {
console.warn("EXIF Parse failed");
}
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: `${metadata.width}x${metadata.height}`,
title: filename,
details: details
};
}
return { type: 'FILE', title: filename };
} catch (error) {
console.error(`Extraction failed: ${filename}`, error);
return { type: 'FILE', title: filename, error: "Extraction failed" };
}
}

View file

@ -0,0 +1,117 @@
// src/lib/metadata-extractor.ts
// src/lib/metadata-extractor.ts
import * as PdfParse from 'pdf-parse-new';
import sharp from 'sharp';
import exifReader from 'exif-reader';
import EPub from 'epub2'; // New Import
/**
* Converts EXIF DMS array to Decimal Degrees.
*/
function convertDMSToDD(dms: any, ref: string): string {
if (!Array.isArray(dms) || dms.length < 3) return String(dms);
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') dd = dd * -1;
return dd.toFixed(6);
}
/**
* RECURSIVE SANITIZER
*/
function sanitizeMetadata(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj.toISOString();
if (Buffer.isBuffer(obj)) return `[Binary Data: ${obj.length} bytes]`;
if (Array.isArray(obj)) return obj.map(sanitizeMetadata);
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
const cleanKey = key.charAt(0).toLowerCase() + key.slice(1);
if (cleanKey === 'gPSLatitude' && obj['gPSLatitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLatitudeRef']);
continue;
}
if (cleanKey === 'gPSLongitude' && obj['gPSLongitudeRef']) {
sanitized[cleanKey] = convertDMSToDD(value, obj['gPSLongitudeRef']);
continue;
}
sanitized[cleanKey] = sanitizeMetadata(value);
}
return sanitized;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<any> {
const extension = filename.split('.').pop()?.toLowerCase();
try {
// --- 1. PDF EXTRACTION ---
if (extension === 'pdf') {
const parser = new PdfParse.SmartPDFParser({ oversaturationFactor: 2.0, enableFastPath: true });
const result = await parser.parse(buffer);
return {
type: 'PDF',
title: filename,
pageCount: result.numpages || 0,
details: sanitizeMetadata(result.info || {}),
textPreview: result.text ? result.text.substring(0, 200).replace(/\s+/g, ' ') : ""
};
}
// --- 2. IMAGE EXTRACTION ---
if (['jpg', 'jpeg', 'png', 'webp','heic'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let details = {};
if (metadata.exif) {
try {
const rawExif = exifReader(metadata.exif);
details = sanitizeMetadata(rawExif);
} catch (e) { console.warn("EXIF Parse failed"); }
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: `${metadata.width}x${metadata.height}`,
title: filename,
details: details
};
}
// --- 3. EPUB EXTRACTION (New Section) ---
if (extension === 'epub') {
return new Promise((resolve, reject) => {
// We initialize the EPub instance with a null image path and use the buffer
const epub = new EPub(buffer);
epub.on('error', (err) => {
console.error("EPUB Parser Error:", err);
resolve({ type: 'EPUB', title: filename, error: "Failed to parse EPUB" });
});
epub.on('end', () => {
// Standardizing the metadata for your UI
resolve({
type: 'EPUB',
title: epub.metadata.title || filename,
author: epub.metadata.creator || 'Unknown',
details: sanitizeMetadata(epub.metadata),
// Use the number of chapters/manifest items as a rough "page" guide
pageCount: epub.spine.contents.length || 0,
textPreview: epub.metadata.description
? epub.metadata.description.substring(0, 200).replace(/<[^>]*>?/gm, '')
: ""
});
});
epub.parse();
});
}
return { type: 'FILE', title: filename };
} catch (error: any) {
console.error(`❌ Extraction failed for ${filename}:`, error.message);
return { type: 'FILE', title: filename, error: error.message };
}
}

View file

@ -1,7 +1,6 @@
import { createTheme } from '@mui/material/styles'; import { createTheme } from '@mui/material/styles';
import { Roboto } from 'next/font/google'; import { Roboto } from 'next/font/google';
// Load the font optimized for Next.js
const roboto = Roboto({ const roboto = Roboto({
weight: ['300', '400', '500', '700'], weight: ['300', '400', '500', '700'],
subsets: ['latin'], subsets: ['latin'],
@ -9,39 +8,36 @@ const roboto = Roboto({
}); });
const theme = createTheme({ const theme = createTheme({
// 1. Color Palette (Clean & Professional for a Library App)
palette: { palette: {
mode: 'light', mode: 'light',
primary: { primary: {
main: '#1976d2', // Professional Blue main: '#1976d2',
}, },
secondary: { secondary: {
main: '#9c27b0', // Purple for accents main: '#9c27b0',
}, },
background: { background: {
default: '#f4f6f8', // Light grey for the app background default: '#f4f6f8',
paper: '#ffffff', paper: '#ffffff',
}, },
}, },
// 2. Typography
typography: { typography: {
fontFamily: roboto.style.fontFamily, fontFamily: roboto.style.fontFamily,
h6: { h6: {
fontWeight: 600, fontWeight: 600,
}, },
}, },
// 3. Component Defaults
components: { components: {
// Keep your button styles
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
root: { root: {
textTransform: 'none', // Prevents all-caps buttons textTransform: 'none',
borderRadius: 8, borderRadius: 8,
}, },
}, },
}, },
// Keep your paper styles
MuiPaper: { MuiPaper: {
defaultProps: { defaultProps: {
elevation: 2, elevation: 2,
@ -52,6 +48,12 @@ const theme = createTheme({
}, },
}, },
}, },
// ADD THIS: Ensures the theme is compatible with MUI v7's Grid logic
MuiStack: {
defaultProps: {
useFlexGap: true,
},
},
}, },
}); });

111
src/lib/transformers.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* Helper: Converts [Degrees, Minutes, Seconds] to Decimal Degrees
*/
const convertDMSToDD = (dms: number[] | undefined, ref: string | undefined): number | null => {
if (!dms || dms.length < 3) return null;
const [degrees, minutes, seconds] = dms;
let dd = degrees + (minutes / 60) + (seconds / 3600);
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return parseFloat(dd.toFixed(6));
};
/**
* MAPPER: Transforms the raw database JSON into a structured GUI object.
* Designed to handle the lowercase keys generated by the sanitizer.
*/
export const mapImageMetadata = (metadata: any) => {
if (!metadata || !metadata.details) {
return { title: metadata?.title || "Unknown File" };
}
const { details, dimensions, title } = metadata;
const { image = {}, photo = {}, gpsInfo = {} } = details;
return {
fileName: title,
device: `${image.make || ''} ${image.model || ''}`.trim() || 'Unknown Device',
// Dates from EXIF are strings after sanitization
timestamp: photo.dateTimeOriginal ? new Date(photo.dateTimeOriginal) : null,
resolution: {
width: photo.pixelXDimension || dimensions?.split('x')[0],
height: photo.pixelYDimension || dimensions?.split('x')[1],
},
settings: {
aperture: photo.fNumber ? `f/${photo.fNumber}` : 'N/A',
shutterSpeed: photo.exposureTime
? (photo.exposureTime < 1
? `1/${Math.round(1 / photo.exposureTime)}s`
: `${photo.exposureTime}s`)
: 'N/A',
iso: photo.iSOSpeedRatings || 'N/A',
focalLength: photo.focalLength ? `${photo.focalLength}mm` : 'N/A',
},
location: {
latitude: convertDMSToDD(gpsInfo.gpsLatitude, gpsInfo.gpsLatitudeRef),
longitude: convertDMSToDD(gpsInfo.gpsLongitude, gpsInfo.gpsLongitudeRef),
altitude: gpsInfo.gpsAltitude ? Math.round(gpsInfo.gpsAltitude) : null,
mapUrl: (gpsInfo.gpsLatitude && gpsInfo.gpsLongitude)
? `https://www.google.com/maps?q=${convertDMSToDD(gpsInfo.gpsLatitude, gpsInfo.gpsLatitudeRef)},${convertDMSToDD(gpsInfo.gpsLongitude, gpsInfo.gpsLongitudeRef)}`
: null
}
};
};
// src/lib/transformers.ts
/**
* Transforms the raw database FileNode into a structured object for the UI.
*/
export const mapMetadata = (node: any) => {
if (!node) return { title: "Unknown File" };
const meta = node.metadata || {};
const fileName = node.name;
const extension = fileName.split('.').pop()?.toLowerCase();
// --- PDF Logic ---
if (extension === 'pdf') {
return {
fileName,
type: 'PDF Document',
title: meta.title || fileName,
author: meta.author || 'Unknown Author',
subject: meta.subject || 'N/A',
pageCount: meta.pageCount || 0,
keywords: meta.keywords || 'None',
textPreview: node.description || meta.textPreview || '', // description field often stores the preview
creator: meta.creator || 'N/A',
producer: meta.producer || 'N/A'
};
}
// --- Image Logic (Sharp/Exif) ---
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
// Note: your extractor lowercase keys via the sanitizer
return {
fileName,
type: 'Image',
device: `${meta.image?.make || ''} ${meta.image?.model || ''}`.trim() || 'Unknown Device',
timestamp: meta.photo?.dateTimeOriginal ? new Date(meta.photo.dateTimeOriginal) : null,
settings: {
aperture: meta.photo?.fNumber ? `f/${meta.photo.fNumber}` : 'N/A',
iso: meta.photo?.iSOSpeedRatings || 'N/A',
focalLength: meta.photo?.focalLength ? `${meta.photo.focalLength}mm` : 'N/A',
}
};
}
// --- Default Fallback ---
return {
fileName,
type: extension?.toUpperCase() || 'File',
details: meta
};
};

View file

@ -0,0 +1,25 @@
// src/services/metadata-service.ts
import { getOneDriveFileBuffer } from "./onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
export async function enrichFileMetadata(fileId: string, token: string) {
const node = await getFileNodeById(fileId);
if (!node || !node.oneDriveId) throw new Error("Node not found");
// Fetch file from OneDrive
const buffer = await getOneDriveFileBuffer(node.oneDriveId, token);
// Extract deep metadata (PDF, JPEG, etc.)
const deepMetadata = await extractMetadata(buffer, node.name);
// Merge with existing metadata
const updatedMetadata = {
...(node.metadata as object),
...deepMetadata,
magicFilled: true
};
// Update DB
return await updateFileNode(fileId, { metadata: updatedMetadata });
}

224
src/services/onedrive.ts Normal file
View file

@ -0,0 +1,224 @@
// src/services/onedrive.ts
import "server-only";
import { getFreshAccessToken } from "@/lib/auth-utils";
import { auth } from "@/auth";
/**
* PRIVATE HELPER: graphRequest
* This internal function handles the heavy lifting of fetching tokens
* and making the actual HTTP call to Microsoft.
*/
async function graphRequest(userId: string, endpoint: string, options: RequestInit = {}) {
// 1. Automatically handle token refresh logic
const token = await getFreshAccessToken(userId);
const baseUrl = "https://graph.microsoft.com/v1.0";
const res = await fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
// 2. Centralized Error Handling for OneDrive
if (!res.ok) {
const errorData = await res.text();
console.error(`OneDrive API Error [${endpoint}]:`, errorData);
throw new Error(`OneDrive API failed: ${res.statusText}`);
}
return res;
}
/**
* SERVICE: Download File Content
* Returns the raw binary stream from OneDrive.
*/
export async function getOneDriveContentStream(userId: string, oneDriveId: string) {
return await graphRequest(userId, `/me/drive/items/${oneDriveId}/content`);
}
/**
* SERVICE: Get File Metadata
* Used to get the @microsoft.graph.downloadUrl or driveItem properties.
*/
export async function getOneDriveItem(userId: string, oneDriveId: string) {
const res = await graphRequest(userId, `/me/drive/items/${oneDriveId}`);
return res.json();
}
/**
* SERVICE: Upload File
* Handles the PUT request to OneDrive for new or updated files.
*/
export async function uploadToOneDrive(userId: string, file: File, oneDriveId?: string) {
// If oneDriveId exists, we update. Otherwise, we'd use a path (needs expansion for new files).
const endpoint = oneDriveId
? `/me/drive/items/${oneDriveId}/content`
: `/me/drive/root:/${file.name}:/content`;
return await graphRequest(userId, endpoint, {
method: "PUT",
headers: { "Content-Type": file.type },
body: Buffer.from(await file.arrayBuffer()),
});
}
/**
* SERVICE: Delete from Cloud
*/
export async function deleteFromOneDrive(userId: string, oneDriveId: string) {
return await graphRequest(userId, `/me/drive/items/${oneDriveId}`, {
method: "DELETE",
});
}
/**
* SERVICE: List Children of the WebCalibre folder
*/
export async function getWebCalibreChildren(userId: string) {
const res = await graphRequest(userId, "/me/drive/root:/WebCalibre:/children");
const data = await res.json();
return data.value; // Returns the array of driveItems
}
/**
* SERVICE: Ensure a specific folder exists in OneDrive
* Returns the folder ID
*/
export async function ensureOneDriveFolder(userId: string, folderName: string) {
try {
const res = await graphRequest(userId, `/me/drive/root:/${folderName}`);
const data = await res.json();
return data.id;
} catch (error) {
// If 404, create it
const createRes = await graphRequest(userId, `/me/drive/root/children`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName, folder: {} })
});
const data = await createRes.json();
return data.id;
}
}
/**
* SERVICE: Upload Large File via Session
* This replaces the basic PUT for better reliability
*/
export async function uploadLargeFile(userId: string, file: File, folderName: string) {
// 1. Create Upload Session
const sessionRes = await graphRequest(userId, `/me/drive/root:/${folderName}/${file.name}:/createUploadSession`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "rename" } })
});
const { uploadUrl } = await sessionRes.json();
// 2. Upload the data to the provided URL (No Authorization header needed for the uploadUrl itself)
const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
if (!uploadRes.ok) throw new Error("Upload session failed");
return await uploadRes.json(); // Returns the DriveItem
}
/**
* SERVICE: Create a folder by name inside a parent path
*/
export async function createOneDriveFolder(userId: string, parentPath: string, folderName: string) {
return await graphRequest(userId, `/me/drive/root:/${parentPath}:/children`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: folderName,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
});
}
/**
* SERVICE: Upload to a specific folder ID (using session)
*/
export async function uploadToFolderId(userId: string, file: File, folderId: string) {
const sessionRes = await graphRequest(userId, `/me/drive/items/${folderId}:/${encodeURIComponent(file.name)}:/createUploadSession`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
});
const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer());
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer
});
if (!uploadRes.ok) throw new Error("Upload failed");
return await uploadRes.json();
}
/**
* Retrieves the access token from the active NextAuth session.
* This is required to authorize requests to the Microsoft Graph API.
*/
async function getAccessToken(): Promise<string> {
const session = await auth();
// We cast to 'any' because the default Session type often
// needs custom augmentation to show the accessToken.
const token = (session as any)?.accessToken;
if (!token) {
// This will help you debug if the session is missing the token
console.error("OneDrive Service Error: No access token found in session.");
throw new Error("Authentication required: No access token available.");
}
return token;
}
/**
* Fetches raw file content from OneDrive.
* Parameterized token allows this to be used in different contexts (User actions, Webhooks, etc.)
*/
export async function getOneDriveFileBuffer(oneDriveId: string, token: string): Promise<Buffer> {
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${oneDriveId}/content`,
{
method: 'GET', // Explicit is better
headers: {
'Authorization': `Bearer ${token}`,
'Accept': '*/*'
},
// CRITICAL: Next.js tends to cache fetch calls.
// We do NOT want to cache large binary buffers in memory/disk.
cache: 'no-store',
}
);
if (!response.ok) {
const errorBody = await response.text().catch(() => "No error body");
console.error(`OneDrive Download Error (${response.status}):`, errorBody);
throw new Error(`OneDrive download failed: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}