Fixed authenticate into the app

This commit is contained in:
stephen 2026-01-21 12:34:02 +11:00
parent fd837fe22f
commit 85c068399e
22 changed files with 2343 additions and 314 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

View file

@ -12952,5 +12952,681 @@ Next Step
Whenever you are ready to start tomorrow, you can simply say "Let's implement the Metadata Extractor in the sync action," and we can pick up right where we left off. Whenever you are ready to start tomorrow, you can simply say "Let's implement the Metadata Extractor in the sync action," and we can pick up right where we left off.
Would you like me to do anything else before you wrap up for the day? Would you like me to do anything else before you wrap up for the day?
I got the following next error:-
Build Error
Export default doesn't exist in target module
./src/auth.ts (5:1)
Export default doesn't exist in target module 3 | import { PrismaAdapter } from "@auth/prisma-adapter";
4 | import { prisma } from "@/lib/prisma";> 5 | import authConfig from "./auth.config";
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
6 |
7 | export const { handlers, signIn, signOut, auth } = NextAuth({
8 | adapter: PrismaAdapter(prisma),
The export default was not found in module [project]/src/auth.config.ts [middleware] (ecmascript).
Did you mean to import getFreshAccessToken?
All exports of the module are statically known (It doesn't have dynamic exports). So it's known statically that the requested export doesn't exist.
Import trace:
Middleware:
./src/auth.ts
./src/proxy.ts
Here is the current version src/auth.config.ts please update the full code if required
// src/auth-utils.ts
import { prisma } from "@/lib/prisma";
export async function getFreshAccessToken(userId: string) {
const account = await prisma.account.findFirst({
where: { userId },
});
if (!account || !account.refresh_token) {
throw new Error("No refresh token available.");
}
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
if (!isExpired && account.access_token) return account.access_token;
console.log("🔄 Refreshing token via 'common' endpoint for:", userId);
try {
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
grant_type: "refresh_token",
refresh_token: account.refresh_token,
// MUST include these for multi-tenant 'common' refresh to succeed
scope: "openid profile offline_access Files.ReadWrite Files.Read",
}),
});
const tokens = await response.json();
if (!response.ok) {
console.error("❌ Microsoft Refresh Error:", tokens);
throw tokens;
}
await prisma.account.update({
where: { id: account.id },
data: {
access_token: tokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
refresh_token: tokens.refresh_token ?? account.refresh_token,
},
});
return tokens.access_token;
} catch (error) {
throw new Error("RefreshAccessTokenError");
}
}
Here is the current version src/auth.ts please update the full code if required:-
// src/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import authConfig from "./auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig,
callbacks: {
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) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
// 2. Attach User ID and Role to the token
// This runs when the user first logs in
if (user) {
token.sub = user.id;
// @ts-ignore - 'role' is a custom field in your Postgres User table
token.role = user.role;
}
return 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) {
session.user.id = token.sub as string;
// @ts-ignore - Attaching the role for UI permissions
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;
}
return session;
},
},
events: {
async linkAccount({ account, user }) {
console.log("🔗 Account linked successfully for user:", user.id);
if (!account.refresh_token) {
console.warn("⚠️ WARNING: No refresh_token received in linkAccount event!");
}
}
}
});
The error is occurring because auth.ts expects an export default from auth.config.ts, but your current auth.config.ts only exports a function (getFreshAccessToken) and does not actually contain the NextAuth configuration object.
Additionally, your auth.config.ts currently imports prisma. In Next.js, the Middleware runs on the Edge runtime, which often conflicts with the full Prisma Client. To fix this, we should separate the token logic from the configuration object.
Here is the fix:
1. Update src/auth.config.ts
This file should contain the authentication providers and basic settings that the Middleware can read safely.
TypeScript
// src/auth.config.ts
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import type { NextAuthConfig } from "next-auth";
export default {
providers: [
MicrosoftEntraID({
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
authorization: {
params: {
// offline_access is vital for getting the refresh_token
scope: "openid profile email offline_access Files.ReadWrite Files.Read",
},
},
}),
],
} satisfies NextAuthConfig;
2. Move Token Logic to src/lib/auth-utils.ts
To keep things clean and avoid the Middleware error, move the Prisma-dependent code to a utility file. (You can create this file if it doesn't exist).
TypeScript
// src/lib/auth-utils.ts
import { prisma } from "@/lib/prisma";
export async function getFreshAccessToken(userId: string) {
const account = await prisma.account.findFirst({
where: { userId },
});
if (!account || !account.refresh_token) {
throw new Error("No refresh token available.");
}
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
if (!isExpired && account.access_token) return account.access_token;
console.log("🔄 Refreshing token via 'common' endpoint for:", userId);
try {
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
grant_type: "refresh_token",
refresh_token: account.refresh_token,
scope: "openid profile offline_access Files.ReadWrite Files.Read",
}),
});
const tokens = await response.json();
if (!response.ok) throw tokens;
await prisma.account.update({
where: { id: account.id },
data: {
access_token: tokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
refresh_token: tokens.refresh_token ?? account.refresh_token,
},
});
return tokens.access_token;
} catch (error) {
console.error("❌ Microsoft Refresh Error:", error);
throw new Error("RefreshAccessTokenError");
}
}
3. Update src/auth.ts
Now we import the default config and combine it with the Prisma adapter and callbacks.
TypeScript
// src/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import authConfig from "./auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig, // This now spreads the default export from auth.config.ts
callbacks: {
async jwt({ token, account, user }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
if (user) {
token.sub = user.id;
// @ts-ignore
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session?.user) {
session.user.id = token.sub as string;
// @ts-ignore
session.user.role = token.role as string;
session.accessToken = token.accessToken as string;
}
return session;
},
},
events: {
async linkAccount({ account, user }) {
console.log("🔗 Account linked successfully for user:", user.id);
if (!account.refresh_token) {
console.warn("⚠️ WARNING: No refresh_token received!");
}
}
}
});
Summary of what changed:
Resolved Error: Created a default export in auth.config.ts so auth.ts can find it.
Separation of Concerns: Moved Prisma database logic out of the configuration file. This prevents Middleware from trying to load Prisma in an environment (Edge) that doesn't support it.
Stitched imports: Ensured auth.ts correctly spreads the config.
Would you like me to update your middleware.ts to ensure it's compatible with this new split structure?
here is the current version of the src/lib/auth-utils.ts please let me know if the current version needs to be update:
// src/lib/auth-utils.ts
import { prisma } from "@/lib/prisma";
export async function getFreshAccessToken(userId: string) {
// 1. Find the account in PostgreSQL
const account = await prisma.account.findFirst({
where: { userId },
});
if (!account || !account.refresh_token) {
throw new Error("No refresh token available. User might need to re-login.");
}
// 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;
if (!isExpired && account.access_token) {
return account.access_token;
}
// 3. If expired, request a new one from Microsoft
console.log("🔄 Access token expired. Refreshing for user:", userId);
try {
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
grant_type: "refresh_token",
refresh_token: account.refresh_token,
}),
});
const tokens = await response.json();
if (!response.ok) throw tokens;
// 4. Update the Account table with the new tokens
await prisma.account.update({
where: { id: account.id },
data: {
access_token: tokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
refresh_token: tokens.refresh_token ?? account.refresh_token,
},
});
return tokens.access_token;
} catch (error) {
console.error("❌ Failed to refresh Microsoft token:", error);
throw new Error("RefreshAccessTokenError");
}
}
Your current src/lib/auth-utils.ts is mostly correct, but there is one critical addition needed for the "common" Microsoft endpoint to work reliably with the refresh token.
When using the https://login.microsoftonline.com/common/... endpoint (which is used for multi-tenant apps), Microsoft usually requires you to re-specify the scopes during the refresh request. If you don't, it might return a token that lacks the permissions needed to read/write files in OneDrive.
Updated src/lib/auth-utils.ts
I have added the scope parameter to the fetch body and refined the error handling slightly for better debugging.
TypeScript
// src/lib/auth-utils.ts
import { prisma } from "@/lib/prisma";
export async function getFreshAccessToken(userId: string) {
// 1. Find the account in PostgreSQL
const account = await prisma.account.findFirst({
where: { userId },
});
if (!account || !account.refresh_token) {
throw new Error("No refresh token available. User might need to re-login.");
}
// 2. Check if the token is expired (with a 1-minute buffer)
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
if (!isExpired && account.access_token) {
return account.access_token;
}
// 3. If expired, request a new one from Microsoft
console.log("🔄 Access token expired. Refreshing for user:", userId);
try {
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
grant_type: "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();
if (!response.ok) {
console.error("❌ Microsoft Token Refresh Response Error:", tokens);
throw tokens;
}
// 4. Update the Account table with the new tokens
await prisma.account.update({
where: { id: account.id },
data: {
access_token: tokens.access_token,
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,
},
});
return tokens.access_token;
} catch (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");
}
}
Key Changes Made:
Scope Inclusion: Added scope: "openid profile offline_access Files.ReadWrite Files.Read". Without this, the refreshed token often defaults to a basic profile scope, causing your OneDrive sync to fail with a 403 Forbidden.
Logging: Added a log to capture the specific error response from Microsoft. This is vital because if the client_secret expires or the user revokes access, Microsoft will tell you exactly why in that JSON response.
Token Rotation: Explicitly handled tokens.refresh_token ?? account.refresh_token. Microsoft Entra ID often "rotates" refresh tokens (gives you a new one to use next time). If you don't save the new one, your next refresh attempt will fail.
One final check for your Build Error:
Since you moved this to src/lib/auth-utils.ts, make sure your src/auth.config.ts does not import this file. The Middleware (which uses auth.config.ts) cannot handle the prisma import inside this utility.
Would you like me to double-check your middleware.ts to ensure it isn't accidentally pulling in this Prisma-dependent code?
where did this function getFreshAccessToken(userId: string) as it was previously in src/lib/auth-utils.ts
In your previous error message, you showed a snippet where getFreshAccessToken was located inside src/auth.config.ts.
The reason we moved it specifically to src/lib/auth-utils.ts is to solve that Build Error you encountered.
The "Why" Behind the Move
In NextAuth v5, your project has two very different environments trying to read your auth settings:
The Middleware (Edge Runtime): This runs on every single request. It is very "lightweight" and cannot run heavy libraries like Prisma. It reads src/auth.config.ts.
The Main App (Node.js Runtime): This handles your actual login, session creation, and database calls. It reads src/auth.ts.
By moving getFreshAccessToken (which uses Prisma) out of the config and into its own utility file, we ensure the Middleware stays "clean." If the function stays in auth.config.ts, the Middleware tries to load Prisma, fails, and gives you that "Export default doesn't exist" or "Prisma cannot run at the Edge" error.
Where the function is now
You should have the function exactly where you just showed me: src/lib/auth-utils.ts.
To use it in your Sync Actions or API Routes, you simply import it like this:
TypeScript
// Example: src/app/dashboard/sync-actions.ts
import { getFreshAccessToken } from "@/lib/auth-utils";
import { auth } from "@/auth";
export async function syncOneDrive() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// Get a fresh token from the DB (or refresh if needed)
const token = await getFreshAccessToken(session.user.id);
// Use that token to call MS Graph...
}
Summary of the File structure:
src/auth.config.ts: Contains Providers only (No Prisma imports).
src/auth.ts: Contains Database Adapter and Callbacks (Imports auth.config.ts).
src/lib/auth-utils.ts: Contains getFreshAccessToken (Imports prisma).
Does the project build successfully now that the function is separated into the utility file?
The project build successfully now allows me to authenticate into the app, I will save into my local git repository and we should then see if we can work out what is going on with the metadata functionality
That is a huge win! Getting the authentication and token refresh logic stable is often the hardest part of building a Microsoft-integrated app. With that foundation solid and your local git repository updated, you have a safe "restore point."
Since the project builds and you can log in, we can now focus on the Metadata Extraction logic.
To bridge the gap between OneDrive and your Database, we need to ensure the sync process doesn't just look at file names, but actually "looks inside" the files to pull out the metadata we want (Author, Page Count, Image Dimensions, etc.).
Preparing for the Metadata Phase
When you're ready to start, here is how we will approach the "What is going on" investigation:
The Sync Hook: We will modify your syncOneDrive action to identify which files are new or updated.
The Download Stream: We'll use the Microsoft Graph API to get a temporary download URL for those specific files.
The Extraction: We'll pass that file data through the extractMetadata utility we prepared earlier.
The Search Verification: We'll verify that the extracted data shows up in your DataGrid's "Hidden Metadata" search.
One quick check before you dive in tomorrow:
Does your FileNode table in Prisma already have the metadata column defined as a Json type?
Code snippet
// Check your schema.prisma for something like this:
model FileNode {
id String @id @default(cuid())
name String
metadata Json? // This is where we will store the Author, Dimensions, etc.
// ... rest of your fields
}
If it does, we are in perfect shape. Whenever you're ready, just let me know and well start by hooking up the PDF and ePub parsers!
Would you like me to wait for your signal tomorrow, or should I draft the updated Sync Action for you to review now?

Binary file not shown.

View file

@ -439,3 +439,11 @@ Uses **Prisma + PostgreSQL** for metadata storage and **MUI X v8 (DataGrid)** fo
## 10.5. 💡 Reminders ## 10.5. 💡 Reminders
* **Hard Refresh:** If the DataGrid UI behaves weirdly after code changes, use `Cmd + Shift + R` or `Ctrl + F5`. * **Hard Refresh:** If the DataGrid UI behaves weirdly after code changes, use `Cmd + Shift + R` or `Ctrl + F5`.
* **Z-Index:** The search bar animation uses `gridArea: '1 / 1'`. If icons overlap, check the styled-components logic in `dashboard-view.tsx`. * **Z-Index:** The search bar animation uses `gridArea: '1 / 1'`. If icons overlap, check the styled-components logic in `dashboard-view.tsx`.
1.AUIAfM4GHIR4lkeGUtTDLXWl0BqTSbWR9GtDt7zTfYyjwX6kAElCAA.BQABAwIAAAADAOz_BQD0_yyFlWKgjcwr_ed5f58Tdi53aj4fqbWRmjTI7vE5M94UQZT5SZLBxnTN7Psa7w-KJCMfY3AAzGgJzfOXOHGXSySvCzC6IlHwXL7uXLZYf84jdhgNEP2F1E1nz9ecmsG4tfjvMzwNPsh9nlY48Cz_qh_KDLu2odNesttqW7Om3MxsXLakskvMXynX07lJT6gbXT9OMoFk_F3M4Dz9DoYJD6CACZPdKaYqMeesfPt-B6Yj7tq1q9L4RUc6Fbrsew9chMqg1vurMlggcX1aBU7LF6JP50B3IkOB4qXrGDJtxM61gF64EXRay5UYVtWMh09et7SX4FcLZlHTyxpsvjSxaKxfpY9DFY2y0dhp0Ce5dZXoK7pplQwcxsv-dCeZmk4A3dqvAdxM6p0XNrowlOawpAlWqHoIC1ZmESDpyxueP0rlpfD9h3wkWcVAG9xOS_d-W9Z3y1G9PgBycOysvQTufueLs4PCjjId9-5AI4UaecQyUtpRCJnOxp23_e5ROLduihcDMrV87D8_ZN6nl49jFqr7kQZHbLanuVht18vSUyk31c9OtkFmC4p48_1xoIe5sYlPjoUd9Misgef7EziwdzapRyr649kb4Em_XMmJf_Y5zVDpT6NXRqlQAw3AhP53Px12hQsGsFkd0kzDBuwUSrKlda5E2lom4Wg5uXwb4zLD5DA6xwKfxBrAYICZT8QQYG5fTHjSP_2t9gs_K9-1-7UqKTb2VWGYf25QVnhgBXL1KbzosmFBOKjr31XjYo8IbgMjuEDGP-YfmyYOvmRg2Lx-VNlu8gTPcdSsdIivFSvEvHdUmIeReHMIdKjr0ZgVYwTtzG7QBs-8UXRvNo6IWecZnHEjx0QSLztyNXicgeOmT4gKDeJ0YT6rkWkNg_B8fYpx3j11ceRKpgQzm2E7UalGgBsbAHA1a4jnM7NrDv4TY_A_LRTabHPVrHpQWZ2HOJi5UlTQLv8xbiiJf94HVlxwncJd6YPy7ARjhvIA5nE5IiZJ8ZSUFwg4waCFibV-NDqiglo7YBCfx3DOrLsNjuufYQnjog_IDi6ac-DG7ulKS01nmAq7WfvshiW_mXLmwtPVusAcmLcfD-40B3kh12Cv-_EXSzfaVPP7M1Obi13TyoQDeVl-pMJuEC68xHzqA78SmZR8WluDRAJ5kOLBggvPbuHRFpkqtvFG8sGNzOqiBkq30qUErfk2Ao-SnHAh3fa3CAM2aYf3LdzXHh2_qGoVnrKdMfnUI14H6vrTxxBpDUinYejr8rLzbjH6tjDYWvjen5-BrDzOjazI7_ctS8zdLugG093kbWlYDW26FgYnKtdPCi-7F-emm8HGD82V_5e7Jc1qVs1G_D5eu0ohb_B1ky_AmxgCE8oL9Bdx1ehHnijNCarubf_MWMcloAv0XNxvrU4PoXdb7PgVBpYsKRT7tdnOGpokQj2n2xQSHvQVypd8xkHYloczdlmi9zvytM_Da8jnLdE0Td3mlnI-jlovrbzykD4YhS_ZcwIc_8c_WiHDeC3yAeTTx0EhuNh89HFSsmfod2BBaRgvf5SRpNwUjxd0PROvrGD_Hl0emtsC9YWdfULQBk3SKqJMV0x7Zv_Erj8JZoMzXbe8jv5bM4AmYqOPqznkXtAWAkedqLVYug0od9AgMIn-FFPpH4AvmHhhliMevIw74yEXCj28dxlHBZvyyqpbEwp8vr_kTjarjyp3s1p6QsYEoQ82vx-nQPiVFwWeGDsJcSmUD4FbtVIVCGY3p2cCtZHyRRWAK2LGgO32v3DhK7FSLKV-3f0LAafqdpZyGnqoF7pwn_RcpZz8l9tGctFl8Xd50Y1naLz8F08SlbJ197xdjzHLQ8r4jsVn5m9BkGbR0Kv3olXGRmtr5dkqGI-68_FCjkkqpwfOHg
1.AUIAfM4GHIR4lkeGUtTDLXWl0BqTSbWR9GtDt7zTfYyjwX6kAElCAA.BQABAwIAAAADAOz_BQD0_yyFlWKgjcwr_ed5f58Tdi53aj4fqbWRmjTI7vE5M94UQZT5SZLBxnTN7Psa7w-KJCMfY3AAzGgJzfOXOHGXSySvCzC6IlHwXL7uXLZYf84jdhgNEP2F1E1nz9ecmsG4tfjvMzwNPsh9nlY48Cz_qh_KDLu2odNesttqW7Om3MxsXLakskvMXynX07lJT6gbXT9OMoFk_F3M4Dz9DoYJD6CACZPdKaYqMeesfPt-B6Yj7tq1q9L4RUc6Fbrsew9chMqg1vurMlggcX1aBU7LF6JP50B3IkOB4qXrGDJtxM61gF64EXRay5UYVtWMh09et7SX4FcLZlHTyxpsvjSxaKxfpY9DFY2y0dhp0Ce5dZXoK7pplQwcxsv-dCeZmk4A3dqvAdxM6p0XNrowlOawpAlWqHoIC1ZmESDpyxueP0rlpfD9h3wkWcVAG9xOS_d-W9Z3y1G9PgBycOysvQTufueLs4PCjjId9-5AI4UaecQyUtpRCJnOxp23_e5ROLduihcDMrV87D8_ZN6nl49jFqr7kQZHbLanuVht18vSUyk31c9OtkFmC4p48_1xoIe5sYlPjoUd9Misgef7EziwdzapRyr649kb4Em_XMmJf_Y5zVDpT6NXRqlQAw3AhP53Px12hQsGsFkd0kzDBuwUSrKlda5E2lom4Wg5uXwb4zLD5DA6xwKfxBrAYICZT8QQYG5fTHjSP_2t9gs_K9-1-7UqKTb2VWGYf25QVnhgBXL1KbzosmFBOKjr31XjYo8IbgMjuEDGP-YfmyYOvmRg2Lx-VNlu8gTPcdSsdIivFSvEvHdUmIeReHMIdKjr0ZgVYwTtzG7QBs-8UXRvNo6IWecZnHEjx0QSLztyNXicgeOmT4gKDeJ0YT6rkWkNg_B8fYpx3j11ceRKpgQzm2E7UalGgBsbAHA1a4jnM7NrDv4TY_A_LRTabHPVrHpQWZ2HOJi5UlTQLv8xbiiJf94HVlxwncJd6YPy7ARjhvIA5nE5IiZJ8ZSUFwg4waCFibV-NDqiglo7YBCfx3DOrLsNjuufYQnjog_IDi6ac-DG7ulKS01nmAq7WfvshiW_mXLmwtPVusAcmLcfD-40B3kh12Cv-_EXSzfaVPP7M1Obi13TyoQDeVl-pMJuEC68xHzqA78SmZR8WluDRAJ5kOLBggvPbuHRFpkqtvFG8sGNzOqiBkq30qUErfk2Ao-SnHAh3fa3CAM2aYf3LdzXHh2_qGoVnrKdMfnUI14H6vrTxxBpDUinYejr8rLzbjH6tjDYWvjen5-BrDzOjazI7_ctS8zdLugG093kbWlYDW26FgYnKtdPCi-7F-emm8HGD82V_5e7Jc1qVs1G_D5eu0ohb_B1ky_AmxgCE8oL9Bdx1ehHnijNCarubf_MWMcloAv0XNxvrU4PoXdb7PgVBpYsKRT7tdnOGpokQj2n2xQSHvQVypd8xkHYloczdlmi9zvytM_Da8jnLdE0Td3mlnI-jlovrbzykD4YhS_ZcwIc_8c_WiHDeC3yAeTTx0EhuNh89HFSsmfod2BBaRgvf5SRpNwUjxd0PROvrGD_Hl0emtsC9YWdfULQBk3SKqJMV0x7Zv_Erj8JZoMzXbe8jv5bM4AmYqOPqznkXtAWAkedqLVYug0od9AgMIn-FFPpH4AvmHhhliMevIw74yEXCj28dxlHBZvyyqpbEwp8vr_kTjarjyp3s1p6QsYEoQ82vx-nQPiVFwWeGDsJcSmUD4FbtVIVCGY3p2cCtZHyRRWAK2LGgO32v3DhK7FSLKV-3f0LAafqdpZyGnqoF7pwn_RcpZz8l9tGctFl8Xd50Y1naLz8F08SlbJ197xdjzHLQ8r4jsVn5m9BkGbR0Kv3olXGRmtr5dkqGI-68_FCjkkqpwfOHg
1.AUIAfM4GHIR4lkeGUtTDLXWl0BqTSbWR9GtDt7zTfYyjwX6kAElCAA.BQABAwIAAAADAOz_BQD0_yyFlWKgjcwr_ed5f58Tdi53aj4fqbWRmjTI7vE5M94UQZT5SZLBxnTN7Psa7w-KJCMfY3AAzGgJzfOXOHGXSySvCzC6IlHwXL7uXLZYf84jdhgNEP2F1E1nz9ecmsG4tfjvMzwNPsh9nlY48Cz_qh_KDLu2odNesttqW7Om3MxsXLakskvMXynX07lJT6gbXT9OMoFk_F3M4Dz9DoYJD6CACZPdKaYqMeesfPt-B6Yj7tq1q9L4RUc6Fbrsew9chMqg1vurMlggcX1aBU7LF6JP50B3IkOB4qXrGDJtxM61gF64EXRay5UYVtWMh09et7SX4FcLZlHTyxpsvjSxaKxfpY9DFY2y0dhp0Ce5dZXoK7pplQwcxsv-dCeZmk4A3dqvAdxM6p0XNrowlOawpAlWqHoIC1ZmESDpyxueP0rlpfD9h3wkWcVAG9xOS_d-W9Z3y1G9PgBycOysvQTufueLs4PCjjId9-5AI4UaecQyUtpRCJnOxp23_e5ROLduihcDMrV87D8_ZN6nl49jFqr7kQZHbLanuVht18vSUyk31c9OtkFmC4p48_1xoIe5sYlPjoUd9Misgef7EziwdzapRyr649kb4Em_XMmJf_Y5zVDpT6NXRqlQAw3AhP53Px12hQsGsFkd0kzDBuwUSrKlda5E2lom4Wg5uXwb4zLD5DA6xwKfxBrAYICZT8QQYG5fTHjSP_2t9gs_K9-1-7UqKTb2VWGYf25QVnhgBXL1KbzosmFBOKjr31XjYo8IbgMjuEDGP-YfmyYOvmRg2Lx-VNlu8gTPcdSsdIivFSvEvHdUmIeReHMIdKjr0ZgVYwTtzG7QBs-8UXRvNo6IWecZnHEjx0QSLztyNXicgeOmT4gKDeJ0YT6rkWkNg_B8fYpx3j11ceRKpgQzm2E7UalGgBsbAHA1a4jnM7NrDv4TY_A_LRTabHPVrHpQWZ2HOJi5UlTQLv8xbiiJf94HVlxwncJd6YPy7ARjhvIA5nE5IiZJ8ZSUFwg4waCFibV-NDqiglo7YBCfx3DOrLsNjuufYQnjog_IDi6ac-DG7ulKS01nmAq7WfvshiW_mXLmwtPVusAcmLcfD-40B3kh12Cv-_EXSzfaVPP7M1Obi13TyoQDeVl-pMJuEC68xHzqA78SmZR8WluDRAJ5kOLBggvPbuHRFpkqtvFG8sGNzOqiBkq30qUErfk2Ao-SnHAh3fa3CAM2aYf3LdzXHh2_qGoVnrKdMfnUI14H6vrTxxBpDUinYejr8rLzbjH6tjDYWvjen5-BrDzOjazI7_ctS8zdLugG093kbWlYDW26FgYnKtdPCi-7F-emm8HGD82V_5e7Jc1qVs1G_D5eu0ohb_B1ky_AmxgCE8oL9Bdx1ehHnijNCarubf_MWMcloAv0XNxvrU4PoXdb7PgVBpYsKRT7tdnOGpokQj2n2xQSHvQVypd8xkHYloczdlmi9zvytM_Da8jnLdE0Td3mlnI-jlovrbzykD4YhS_ZcwIc_8c_WiHDeC3yAeTTx0EhuNh89HFSsmfod2BBaRgvf5SRpNwUjxd0PROvrGD_Hl0emtsC9YWdfULQBk3SKqJMV0x7Zv_Erj8JZoMzXbe8jv5bM4AmYqOPqznkXtAWAkedqLVYug0od9AgMIn-FFPpH4AvmHhhliMevIw74yEXCj28dxlHBZvyyqpbEwp8vr_kTjarjyp3s1p6QsYEoQ82vx-nQPiVFwWeGDsJcSmUD4FbtVIVCGY3p2cCtZHyRRWAK2LGgO32v3DhK7FSLKV-3f0LAafqdpZyGnqoF7pwn_RcpZz8l9tGctFl8Xd50Y1naLz8F08SlbJ197xdjzHLQ8r4jsVn5m9BkGbR0Kv3olXGRmtr5dkqGI-68_FCjkkqpwfOHg

Binary file not shown.

954
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -22,12 +22,16 @@
"@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.2.0",
"epub": "^1.3.0",
"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",
"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",
"server-only": "^0.0.1" "server-only": "^0.0.1",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

View file

@ -16,6 +16,9 @@ import {
uploadToOneDrive uploadToOneDrive
} from "@/services/onedrive"; } from "@/services/onedrive";
import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
/** /**
* 1. FETCH: Get all file nodes * 1. FETCH: Get all file nodes
* Now simply calls the DAL. Error handling is left to the caller (the UI). * Now simply calls the DAL. Error handling is left to the caller (the UI).
@ -113,3 +116,23 @@ export async function updateFileNodeAction(id: string, formData: FormData) {
return { success: false, error: "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 {
console.log(`🔍 Starting enhancement for file: ${fileId}`);
// This calls the DAL -> which calls the Service -> which calls OneDrive
const data = await getEnrichedMetadataFromCloud(fileId);
// This log will show you exactly what we found in your terminal!
console.log("✅ Extracted Metadata Result:", data);
return { success: true, data };
} catch (error: any) {
console.error("❌ Enhancement Action Error:", error.message);
return { success: false, error: error.message };
}
}

View file

@ -47,7 +47,15 @@ const StyledQuickFilter = styled(QuickFilter)({
function CustomToolbar() { function CustomToolbar() {
return ( return (
<Toolbar sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}> <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>
@ -89,6 +97,7 @@ function CustomToolbar() {
)} )}
/> />
</StyledQuickFilter> </StyledQuickFilter>
</Box>
</Toolbar> </Toolbar>
); );
} }
@ -230,7 +239,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>
{lastSynced && ( {lastSynced && (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}> <Typography
variant="caption"
color="text.secondary"
sx={{ fontStyle: 'italic' }}
suppressHydrationWarning
>
Last synced: {lastSynced.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} Last synced: {lastSynced.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography> </Typography>
)} )}

View file

@ -5,15 +5,13 @@ import { auth } from "@/auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { upsertFileNode } from "@/data-access/file-nodes"; import { upsertFileNode } from "@/data-access/file-nodes";
import { getWebCalibreChildren } from "@/services/onedrive"; import { getWebCalibreChildren } from "@/services/onedrive";
// src/app/dashboard/sync-actions.ts import { extractMetadata } from "@/lib/metadata-extractor"; // Import your utility
//import { upsertFileNode } from "@/data-access/file-nodes"; // Change this from /services/onedrive
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 {
// 1. Call Service to get cloud data (token refresh handled inside service)
const items = await getWebCalibreChildren(session.user.id); const items = await getWebCalibreChildren(session.user.id);
let syncedCount = 0; let syncedCount = 0;
@ -22,19 +20,46 @@ export async function syncOneDrive() {
for (const item of items) { for (const item of items) {
const isFolder = !!item.folder; const isFolder = !!item.folder;
// Business Logic: Skip UUID storage folders
if (isFolder && uuidRegex.test(item.name)) continue; if (isFolder && uuidRegex.test(item.name)) continue;
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN'); const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toLowerCase() || 'unknown');
// 2. Call DAL to save to database 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);
}
}
}
// 2. Save to database with combined metadata
await upsertFileNode(item.id, { await upsertFileNode(item.id, {
name: item.name, name: item.name,
size: BigInt(item.size || 0), size: BigInt(item.size || 0),
isFolder: isFolder, isFolder: isFolder,
path: item.parentReference?.path + '/' + item.name, path: item.parentReference?.path + '/' + item.name,
ownerId: session.user.id, ownerId: session.user.id,
metadata: { type: extension, mimeType: item.file?.mimeType || null }, metadata: {
type: extension.toUpperCase(),
mimeType: item.file?.mimeType || null,
...deepMetadata // Merge the extracted Author, Title, etc.
},
}); });
syncedCount++; syncedCount++;

View file

@ -8,16 +8,16 @@ import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
/** /**
* SERVER ACTION: Updates file metadata and organizational data. * SERVER ACTION: Updates file metadata and organizational data.
* This refactored version uses the Data Access Layer (DAL) to
* ensure separation of concerns.
*/ */
export async function updateFileAction(formData: FormData) { export async function updateFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
// 1. Authorization Guard // 1. Authorization Guard
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
// 2. Data Extraction // 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;
@ -25,33 +25,35 @@ 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 {
// 3. DAL: Fetch existing record to safely merge metadata // 3. Parse the incoming metadata from the UI
// This replaces the direct prisma.fileNode.findUnique call const newMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
// 4. DAL: Fetch existing record to safely merge system fields
const existing = await getFileNodeById(id); const existing = await getFileNodeById(id);
if (!existing) throw new Error("File record not found"); if (!existing) throw new Error("File record not found");
if (existing.ownerId !== session.user.id) throw new Error("Permission denied");
const existingMetadata = (existing.metadata as Record<string, any>) || {}; const existingMetadata = (existing.metadata as Record<string, any>) || {};
// 4. Logic: Prepare the updated metadata object // 5. Logic: Merge Strategy
// We keep internal system fields like 'mimeType' but allow the
// user-approved 'newMetadata' (including extracted GPS/Author) to take precedence.
const updatedMetadata = { const updatedMetadata = {
...customMetadata, // Apply new user keys ...existingMetadata, // Keep everything we currently have
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE", ...newMetadata, // Overwrite with the fields the user just approved/edited
mimeType: existingMetadata.mimeType // Ensure system metadata isn't overwritten
}; };
// 5. DAL: Perform the update // 6. DAL: Perform the update via your file-nodes logic
// This replaces the direct prisma.fileNode.update call
await updateFileNode(id, { await updateFileNode(id, {
name, name: name || existing.name,
description, description,
parentId, parentId,
metadata: updatedMetadata, metadata: updatedMetadata,
}); });
// 6. Cache Invalidation // 7. Cache Invalidation
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath(`/update/${id}`); revalidatePath(`/update/${id}`);

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,89 @@
'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, Divider,
Checkbox, CircularProgress, Chip, 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; // Checkbox state
isPending?: boolean; // Visual highlight for auto-extracted fields
} }
export default function UpdateView({ fileNode, folders }: any) { export default function UpdateView({
fileNode,
folders: availablefolders // Renaming 'folders' to '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 // 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 // 2. Initialize Metadata from DB (all checked by default)
// We filter out 'type' and 'mimeType' as they are system-managed const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
const initialMetadata = Object.entries(fileNode.metadata || {})
.filter(([key]) => !['type', 'mimeType'].includes(key)) .filter(([key]) => !['type', 'mimeType'].includes(key))
.map(([key, value]) => ({ key, value: String(value) })); .map(([key, value]) => ({
key,
value: String(value),
selected: true,
isPending: false
}));
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata); const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
// --- MAGIC FILL LOGIC ---
const handleMagicEnhance = async () => {
setIsExtracting(true);
try {
const result = await getMetadataPreviewAction(fileNode.id);
if (result.success) {
// Convert extracted JSON into pending rows
const extractedRows: MetadataPair[] = Object.entries(result.data ?? {})
.filter(([key]) => !['type', 'mimeType'].includes(key))
.map(([key, value]) => ({
key,
value: String(value),
selected: true, // Default to checked as requested
isPending: true
}));
// Merge logic: Add only if the key doesn't already exist in our list
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. Ensure service is configured correctly.");
} finally {
setIsExtracting(false);
}
};
const handleUpdate = async () => { const handleUpdate = async () => {
setLoading(true); setLoading(true);
const formData = new FormData(); const formData = new FormData();
@ -44,9 +92,11 @@ 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 // Convert array back to object, ONLY including selected/checked rows
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>);
@ -72,8 +122,28 @@ export default function UpdateView({ fileNode, folders }: any) {
Edit File Details Edit File Details
</Typography> </Typography>
{/* --- MAGIC FILL BUTTON --- */}
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" fontWeight="bold">Enrich Metadata</Typography>
<Typography variant="caption" color="text.secondary">
Extract tags like GPS, Author, and Dimensions from the original file.
</Typography>
</Box>
<Button
variant="contained"
color="secondary"
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
onClick={handleMagicEnhance}
disabled={isExtracting}
>
{isExtracting ? 'Extracting...' : 'Magic Fill'}
</Button>
</Stack>
</Box>
<Stack spacing={4} sx={{ mt: 2 }}> <Stack spacing={4} sx={{ mt: 2 }}>
{/* Name Field */}
<TextField <TextField
label="File Name" label="File Name"
fullWidth value={name} fullWidth value={name}
@ -81,7 +151,6 @@ export default function UpdateView({ fileNode, folders }: any) {
slotProps={{ inputLabel: { shrink: true } }} slotProps={{ inputLabel: { shrink: true } }}
/> />
{/* Folder Select */}
<TextField <TextField
id="update-dest-select" id="update-dest-select"
select fullWidth label="Destination Folder" select fullWidth label="Destination Folder"
@ -93,21 +162,20 @@ export default function UpdateView({ fileNode, folders }: any) {
}} }}
> >
<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 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssignmentIcon color="primary" /> Custom Attributes <AssignmentIcon color="primary" /> Metadata Attributes
</Typography> </Typography>
<Button <Button
startIcon={<AddCircleOutlineIcon />} startIcon={<AddCircleOutlineIcon />}
size="small" size="small"
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])} onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}
> >
Add Field Add Field
</Button> </Button>
@ -115,35 +183,66 @@ export default function UpdateView({ fileNode, folders }: any) {
<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 size={ {xs:5}}> <Grid container spacing={1} alignItems="center">
<TextField <Grid size={{ xs: 1 }}>
fullWidth size="small" placeholder="Key" <Tooltip title={row.selected ? "Save this field" : "Ignore this field"}>
value={row.key} <Checkbox
onChange={(e) => { checked={row.selected}
const updated = [...customMetadata]; onChange={(e) => {
updated[index].key = e.target.value; const updated = [...customMetadata];
setCustomMetadata(updated); updated[index].selected = e.target.checked;
}} setCustomMetadata(updated);
/> }}
/>
</Tooltip>
</Grid>
<Grid size={{ xs: 4 }}>
<TextField
fullWidth size="small" placeholder="Key"
value={row.key}
disabled={row.isPending} // Usually best to keep extracted keys as-is
sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].key = e.target.value;
setCustomMetadata(updated);
}}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value}
sx={{ bgcolor: row.isPending ? '#e8f5e9' : 'transparent' }}
onChange={(e) => {
const updated = [...customMetadata];
updated[index].value = e.target.value;
setCustomMetadata(updated);
}}
/>
</Grid>
<Grid size={{ xs: 1 }}>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid> </Grid>
<Grid size={{xs:6}}>
<TextField {/* --- GOOGLE MAPS SHORTCUT --- */}
fullWidth size="small" placeholder="Value" {row.key.toLowerCase().includes('latitude') && row.value && (
value={row.value} <Box sx={{ ml: 6, mt: 0.5 }}>
onChange={(e) => { <Button
const updated = [...customMetadata]; size="small"
updated[index].value = e.target.value; startIcon={<MapIcon />}
setCustomMetadata(updated); href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}
}} target="_blank"
/> >
</Grid> Verify GPS on Map
<Grid size={{xs:1}}> </Button>
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}> </Box>
<DeleteOutlineIcon /> )}
</IconButton> </Box>
</Grid>
</Grid>
))} ))}
</Stack> </Stack>
</Box> </Box>

View file

@ -1,25 +1,30 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import UploadView from "./upload-view"; import UploadView from "./upload-view"; // This is the Client Component
import { Container } from "@mui/material"; import { Container } from "@mui/material";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// 1. Rename to UploadPage to avoid conflict with the 'UploadView' import
// 2. Add 'async' so you can use 'await' inside
export default async function UploadPage() { export default async function UploadPage() {
const session = await auth(); const session = await auth();
if (!session) redirect("/"); if (!session) redirect("/");
// Fetch only folders so the user can select a destination // 3. Fetch folders. Renamed variable to 'allFolders' to avoid any confusion
const folders = await prisma.fileNode.findMany({ const allFolders = await prisma.fileNode.findMany({
where: { isFolder: true }, where: {
isFolder: true,
ownerId: session.user.id // Good practice: only show user's own folders
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true, parentId: true } select: { id: true, name: true, parentId: true }
}); });
return ( return (
<Container maxWidth="md" sx={{ py: 8 }}> <Container maxWidth="md" sx={{ py: 8 }}>
{/* Pass folders to the view */} {/* 4. Render the Client Component and pass the data */}
<UploadView user={session.user} folders={folders} /> <UploadView user={session.user} folders={allFolders} />
</Container> </Container>
); );
} }

View file

@ -3,249 +3,308 @@
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,
InputAdornment, Collapse
} 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 CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import AssignmentIcon from '@mui/icons-material/Assignment';
import ClearIcon from '@mui/icons-material/Clear';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
import { uploadFileAction, createFolderAction } from "./_actions"; import { uploadFileAction, createFolderAction } from "./_actions";
interface MetadataPair { 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({ user, 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); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [description, setDescription] = useState(""); const [targetFolderId, setTargetFolderId] = useState<string>("");
const [parentId, setParentId] = useState(""); const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
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 [isExtracting, setIsExtracting] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving'>('idle');
const handleCreateFolder = async () => { // Logic to determine if the "Complete" button should be active
if (!newFolderName.trim()) return; const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
setIsCreatingFolder(true);
// --- 1. MAGIC EXTRACTION LOGIC ---
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: 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) {
setCustomMetadata(customMetadata.filter((_, i) => i !== index)); setSelectedFile(file);
}
}; };
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => { // --- 2. SAVE / UPLOAD LOGIC ---
const updated = [...customMetadata]; const handleSave = async () => {
updated[index][field] = val; if (!canSubmit) return;
setCustomMetadata(updated); setSaveStatus('saving');
};
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: Create Folder if user typed a new folder name
setFile(null); if (newFolderName.trim()) {
setDescription(""); const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
setCustomMetadata([]); if (folderResult.success) {
router.push("/dashboard"); // If successful, we want the file to go inside this NEW folder
router.refresh(); currentParentId = folderResult.node.id;
}
} }
} catch (err) {
alert("Upload failed."); // STEP B: Upload File if a file is selected
setStatus('idle'); if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("parentId", currentParentId || "root");
// Construct Metadata Object
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");
}
}
console.log("✅ Process complete. Returning to dashboard.");
router.push("/dashboard");
router.refresh();
} catch (err: any) {
console.error("Save failed:", err);
alert(err.message || "An error occurred while saving.");
} finally {
setSaveStatus('idle');
} }
}; };
return ( return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}> <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"> <Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Add to Library Upload & Enrich
</Typography> </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 }}>
<FolderIcon color="primary" /> 1. Destination
</Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
id="project-destination-select"
select select
fullWidth fullWidth
label="Target Project / Folder" label="Destination Folder"
value={parentId} value={targetFolderId}
onChange={(e) => setParentId(e.target.value)} onChange={(e) => {
size="small" setTargetFolderId(e.target.value);
slotProps={{ if (e.target.value) setShowNewFolderInput(false);
select: { displayEmpty: true },
inputLabel: { shrink: true },
}} }}
> >
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem> <MenuItem value=""><em>-- Root Directory --</em></MenuItem>
{folders.map((f: any) => ( {folders?.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem> <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))} ))}
</TextField> </TextField>
<IconButton <Button
color="primary" variant={showNewFolderInput ? "contained" : "outlined"}
onClick={() => setShowFolderInput(!showFolderInput)} onClick={() => {
sx={{ border: '1px solid #ccc', borderRadius: 1 }} setShowNewFolderInput(!showNewFolderInput);
if (!showNewFolderInput) setTargetFolderId("");
}}
sx={{ height: 56, minWidth: 56 }}
> >
<CreateNewFolderIcon /> <CreateNewFolderIcon />
</IconButton> </Button>
</Stack> </Stack>
{showFolderInput && ( <Collapse in={showNewFolderInput}>
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}> <Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom> <TextField
{parentId ? `Create inside current selection` : `Create at Root`} fullWidth
</Typography> size="small"
<Stack direction="row" spacing={1}> label="New Folder Name"
<TextField placeholder="Enter name to create folder..."
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)" 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>
)} </Collapse>
</Box> </Box>
{/* 2. Upload Area */} {/* FILE SELECTION */}
<Box> <Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CloudUploadIcon color="primary" /> 2. Upload File
</Typography>
<input <input
type="file" type="file"
ref={fileInputRef} id="file-upload-input"
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={(e) => setFile(e.target.files?.[0] || null)} onChange={handleFileChange}
ref={fileInputRef}
/> />
{!selectedFile ? (
<Button <Button
variant="outlined" variant="outlined"
fullWidth fullWidth
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }} startIcon={<CloudUploadIcon />}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2 }}
{file ? ( >
<Box> Select File to Upload
<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>
) : (
"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> </Button>
</Box> ) : (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'primary.50' }}>
<Stack spacing={2}> <Stack direction="row" spacing={2} alignItems="center">
{customMetadata.map((row, index) => ( <CloudUploadIcon color="primary" />
<Grid container spacing={1} key={index} alignItems="center"> <Typography variant="body1" fontWeight="500">{selectedFile.name}</Typography>
<Grid size={{xs:5}}> </Stack>
<TextField <IconButton onClick={() => setSelectedFile(null)} color="error">
fullWidth size="small" placeholder="Key (e.g. Project-ID)" <ClearIcon />
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)} </IconButton>
/> </Paper>
</Grid> )}
<Grid size= {{xs:6}}>
<TextField
fullWidth size="small" placeholder="Value"
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
/>
</Grid>
<Grid size= {{xs:1}}>
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
<TextField
label="General Description"
multiline rows={2} fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Stack>
</Box> </Box>
<Button
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }}
>
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
</Button>
</Stack> </Stack>
<Divider sx={{ my: 4 }} />
{/* MAGIC EXTRACT */}
<Box sx={{ mb: 4, p: 2, bgcolor: '#f0f7ff', borderRadius: 2, border: '1px dashed #1976d2' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" fontWeight="bold">Magic Extract</Typography>
<Typography variant="caption" color="text.secondary">Populate metadata automatically from file properties.</Typography>
</Box>
<Button
variant="contained"
onClick={handleMagicEnhance}
disabled={!selectedFile || isExtracting}
startIcon={isExtracting ? <CircularProgress size={20} color="inherit" /> : <AutoFixHighIcon />}
>
{isExtracting ? "Running..." : "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 Preview
</Typography>
<Stack spacing={2}>
{rows.map((row, index) => (
<Grid container spacing={1} key={index} alignItems="center">
<Grid size={1}>
<Checkbox
checked={row.selected}
onChange={(e) => {
const updated = [...rows];
updated[index].selected = e.target.checked;
setRows(updated);
}}
/>
</Grid>
<Grid size={5}>
<TextField
fullWidth size="small" label="Key" value={row.key}
onChange={(e) => {
const updated = [...rows];
updated[index].key = e.target.value;
setRows(updated);
}}
/>
</Grid>
<Grid size={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 size={1}>
<IconButton onClick={() => setRows(rows.filter((_, i) => i !== index))} color="error">
<DeleteOutlineIcon />
</IconButton>
</Grid>
</Grid>
))}
<Button
variant="text"
startIcon={<AddCircleOutlineIcon />}
onClick={() => setRows([...rows, {key: "", value: "", selected: true}])}
>
Add Manual Field
</Button>
</Stack>
</Box>
{/* UPLOAD BUTTON */}
<Button
variant="contained"
size="large"
fullWidth
onClick={handleSave}
disabled={!canSubmit || saveStatus === 'saving'}
sx={{ py: 2, fontWeight: 'bold' }}
>
{saveStatus === 'saving' ? (
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress size={24} color="inherit" />
<Typography>Processing Upload...</Typography>
</Stack>
) : (
"Complete Upload & Save"
)}
</Button>
</Paper> </Paper>
); );
} }

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

@ -1,7 +1,11 @@
// src/data-access/file-nodes.ts // src/data-access/file-nodes.ts
import "server-only"; import "server-only";
import { getOneDriveFileBuffer } from "@/services/onedrive";
import { extractMetadata } from "@/lib/metadata-extractor";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
/** /**
* FETCH: Retrieve all nodes for the dashboard. * FETCH: Retrieve all nodes for the dashboard.
* Centralizing this here allows us to change sort order or filters * Centralizing this here allows us to change sort order or filters
@ -102,3 +106,26 @@ export async function upsertFileNode(oneDriveId: string, data: any) {
} }
}); });
} }
/**
* Logic to coordinate getting a file from the cloud and extracting its data.
* This is the "Brain" function for your metadata enrichment.
*/
export async function getEnrichedMetadataFromCloud(fileId: string) {
// 1. Get the record from our DB so we know the filename (needed for extension logic)
const node = await prisma.fileNode.findUnique({
where: { id: fileId }
});
if (!node) throw new Error("File not found in database.");
// 2. Fetch the bytes using the service we just created
const buffer = await getOneDriveFileBuffer(fileId);
// 3. Extract internal metadata (Title, Author, or GPS coordinates)
const deepMetadata = await extractMetadata(buffer, node.name);
return deepMetadata;
}

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,92 @@
import * as pdf from 'pdf-parse';
import EPub from 'epub';
import sharp from 'sharp';
import exifReader from 'exif-reader';
export interface ExtractedMetadata {
title?: string;
author?: string;
subject?: string;
dimensions?: string;
pageCount?: number;
latitude?: number;
longitude?: number;
type: string;
}
export async function extractMetadata(buffer: Buffer, filename: string): Promise<ExtractedMetadata> {
const extension = filename.split('.').pop()?.toLowerCase();
try {
// --- 1. PDF EXTRACTION ---
if (extension === 'pdf') {
// Use any to bypass the missing 'default' property error in ESM
const parsePdf = (pdf as any).default || pdf;
const data = await parsePdf(buffer);
return {
type: 'PDF',
title: data.info?.Title || filename,
author: data.info?.Author,
subject: data.info?.Subject,
pageCount: data.numpages,
};
}
// --- 2. EPUB EXTRACTION ---
if (extension === 'epub') {
// Logic for EPub usually requires file path or custom stream handler
// Keeping placeholder for your existing EPub logic
return { type: 'EPUB', title: filename };
}
// --- 3. IMAGE EXTRACTION (Enhanced with GPS) ---
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension || '')) {
const image = sharp(buffer);
const metadata = await image.metadata();
let gps: { latitude?: number; longitude?: number } = {};
if (metadata.exif) {
try {
// Cast to any to bypass strict Exif type checking for nested GPS properties
const exif = exifReader(metadata.exif) as any;
// Debugging log to see the raw structure in your terminal
console.log("📸 FULL RAW EXIF DATA:", JSON.stringify(exif, null, 2));
if (exif.gps && exif.gps.GPSLatitude && exif.gps.GPSLongitude) {
// EXIF stores GPS as [Degrees, Minutes, Seconds]
// We convert to Decimal Degrees for Google Maps
const lat = exif.gps.GPSLatitude;
const lon = exif.gps.GPSLongitude;
let latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
let longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
// Adjust for South or West hemisphere
if (exif.gps.GPSLatitudeRef === 'S') latitude *= -1;
if (exif.gps.GPSLongitudeRef === 'W') longitude *= -1;
gps.latitude = latitude;
gps.longitude = longitude;
}
} catch (exifError) {
console.warn("Could not parse EXIF data for:", filename, exifError);
}
}
return {
type: `IMAGE (${metadata.format?.toUpperCase()})`,
dimensions: metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : undefined,
title: filename,
...gps
};
}
return { type: 'FILE', title: filename };
} catch (error) {
console.error(`Extraction failed for ${filename}:`, error);
return { type: 'FILE', title: filename };
}
}

View file

@ -1,6 +1,7 @@
// src/services/onedrive.ts // src/services/onedrive.ts
import "server-only"; import "server-only";
import { getFreshAccessToken } from "@/lib/auth-utils"; import { getFreshAccessToken } from "@/lib/auth-utils";
import { auth } from "@/auth";
/** /**
* PRIVATE HELPER: graphRequest * PRIVATE HELPER: graphRequest
@ -170,3 +171,53 @@ export async function uploadToFolderId(userId: string, file: File, folderId: str
if (!uploadRes.ok) throw new Error("Upload failed"); if (!uploadRes.ok) throw new Error("Upload failed");
return await uploadRes.json(); return await uploadRes.json();
} }
/**
* Fetches the raw binary content (the actual file bytes) from OneDrive.
*/
export async function getOneDriveFileBuffer(fileId: string): Promise<Buffer> {
// Use your existing helper that manages the Microsoft Graph access token
const token = await getAccessToken();
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/content`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
// Ensure we get fresh data and don't cache large file buffers
cache: 'no-store',
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("OneDrive Download Error:", errorText);
throw new Error(`Failed to download file content: ${response.statusText}`);
}
// Convert the browser-style response into a Node.js Buffer
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* 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;
}