Fixed authenticate into the app
This commit is contained in:
parent
fd837fe22f
commit
85c068399e
22 changed files with 2343 additions and 314 deletions
1
.env
1
.env
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
|
||||
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
||||
# values generated by Gemini
|
||||
# Generated for security
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
|
||||
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
||||
# values generated by Gemini
|
||||
# Generated for security
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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 we’ll 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.
|
|
@ -439,3 +439,11 @@ Uses **Prisma + PostgreSQL** for metadata storage and **MUI X v8 (DataGrid)** fo
|
|||
## 10.5. 💡 Reminders
|
||||
* **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`.
|
||||
|
||||
|
||||
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
|
||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
954
package-lock.json
generated
954
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,12 +22,16 @@
|
|||
"@mui/x-data-grid": "^8.24.0",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"epub": "^1.3.0",
|
||||
"exif-reader": "^2.0.3",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"server-only": "^0.0.1"
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import {
|
|||
uploadToOneDrive
|
||||
} from "@/services/onedrive";
|
||||
|
||||
import { getEnrichedMetadataFromCloud } from "@/data-access/file-nodes";
|
||||
|
||||
|
||||
/**
|
||||
* 1. FETCH: Get all file nodes
|
||||
* 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" };
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,15 @@ const StyledQuickFilter = styled(QuickFilter)({
|
|||
|
||||
function CustomToolbar() {
|
||||
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">
|
||||
Library
|
||||
</Typography>
|
||||
|
|
@ -89,6 +97,7 @@ function CustomToolbar() {
|
|||
)}
|
||||
/>
|
||||
</StyledQuickFilter>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
|
@ -230,7 +239,12 @@ export default function DashboardView({ initialFiles, user }: DashboardViewProps
|
|||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>
|
||||
{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' })}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ import { auth } from "@/auth";
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { upsertFileNode } from "@/data-access/file-nodes";
|
||||
import { getWebCalibreChildren } from "@/services/onedrive";
|
||||
// src/app/dashboard/sync-actions.ts
|
||||
//import { upsertFileNode } from "@/data-access/file-nodes"; // Change this from /services/onedrive
|
||||
import { extractMetadata } from "@/lib/metadata-extractor"; // Import your utility
|
||||
|
||||
export async function syncOneDrive() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||
|
||||
try {
|
||||
// 1. Call Service to get cloud data (token refresh handled inside service)
|
||||
const items = await getWebCalibreChildren(session.user.id);
|
||||
|
||||
let syncedCount = 0;
|
||||
|
|
@ -22,19 +20,46 @@ export async function syncOneDrive() {
|
|||
for (const item of items) {
|
||||
const isFolder = !!item.folder;
|
||||
|
||||
// Business Logic: Skip UUID storage folders
|
||||
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, {
|
||||
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 },
|
||||
metadata: {
|
||||
type: extension.toUpperCase(),
|
||||
mimeType: item.file?.mimeType || null,
|
||||
...deepMetadata // Merge the extracted Author, Title, etc.
|
||||
},
|
||||
});
|
||||
|
||||
syncedCount++;
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ import { getFileNodeById, updateFileNode } from "@/data-access/file-nodes";
|
|||
|
||||
/**
|
||||
* 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) {
|
||||
const session = await auth();
|
||||
|
||||
// 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 name = formData.get("name") 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 parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
||||
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
||||
|
||||
try {
|
||||
// 3. DAL: Fetch existing record to safely merge metadata
|
||||
// This replaces the direct prisma.fileNode.findUnique call
|
||||
// 3. Parse the incoming metadata from the UI
|
||||
const newMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
||||
|
||||
// 4. DAL: Fetch existing record to safely merge system fields
|
||||
const existing = await getFileNodeById(id);
|
||||
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>) || {};
|
||||
|
||||
// 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 = {
|
||||
...customMetadata, // Apply new user keys
|
||||
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE",
|
||||
mimeType: existingMetadata.mimeType // Ensure system metadata isn't overwritten
|
||||
...existingMetadata, // Keep everything we currently have
|
||||
...newMetadata, // Overwrite with the fields the user just approved/edited
|
||||
};
|
||||
|
||||
// 5. DAL: Perform the update
|
||||
// This replaces the direct prisma.fileNode.update call
|
||||
// 6. DAL: Perform the update via your file-nodes logic
|
||||
await updateFileNode(id, {
|
||||
name,
|
||||
name: name || existing.name,
|
||||
description,
|
||||
parentId,
|
||||
metadata: updatedMetadata,
|
||||
});
|
||||
|
||||
// 6. Cache Invalidation
|
||||
// 7. Cache Invalidation
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath(`/update/${id}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,31 +4,53 @@ import { prisma } from "@/lib/prisma";
|
|||
import { Container } from "@mui/material";
|
||||
import UpdateView from "./update-view";
|
||||
|
||||
// Note: params is now handled as a Promise
|
||||
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
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;
|
||||
|
||||
// 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({
|
||||
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({
|
||||
where: { isFolder: true },
|
||||
where: {
|
||||
isFolder: true,
|
||||
ownerId: session.user.id
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
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 (
|
||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||
<UpdateView fileNode={fileNode} folders={folders} />
|
||||
<UpdateView
|
||||
fileNode={serializedFileNode}
|
||||
folders={folders}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +1,89 @@
|
|||
'use client';
|
||||
|
||||
// src/app/update/[id]/update-view.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box, Button, Typography, Paper, Stack,
|
||||
TextField, MenuItem, IconButton, Grid, Divider
|
||||
TextField, MenuItem, IconButton, Grid, Divider,
|
||||
Checkbox, CircularProgress, Chip, Tooltip
|
||||
} from "@mui/material";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
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 { updateFileAction } from "./_actions";
|
||||
import { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
||||
|
||||
interface MetadataPair {
|
||||
key: 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 [loading, setLoading] = useState(false);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 1. Initialize Basic Info
|
||||
const [name, setName] = useState(fileNode.name);
|
||||
const [description, setDescription] = useState(fileNode.description || "");
|
||||
const [parentId, setParentId] = useState(fileNode.parentId || "");
|
||||
|
||||
// 2. Parse existing JSON metadata into Key/Value array for the UI
|
||||
// We filter out 'type' and 'mimeType' as they are system-managed
|
||||
const initialMetadata = Object.entries(fileNode.metadata || {})
|
||||
// 2. Initialize Metadata from DB (all checked by default)
|
||||
const initialMetadata: MetadataPair[] = Object.entries(fileNode.metadata || {})
|
||||
.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);
|
||||
|
||||
// --- 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 () => {
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
|
|
@ -44,9 +92,11 @@ export default function UpdateView({ fileNode, folders }: any) {
|
|||
formData.append("description", description);
|
||||
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) => {
|
||||
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
||||
if (curr.selected && curr.key.trim()) {
|
||||
acc[curr.key.trim()] = curr.value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
|
|
@ -72,8 +122,28 @@ export default function UpdateView({ fileNode, folders }: any) {
|
|||
Edit File Details
|
||||
</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 }}>
|
||||
{/* Name Field */}
|
||||
<TextField
|
||||
label="File Name"
|
||||
fullWidth value={name}
|
||||
|
|
@ -81,7 +151,6 @@ export default function UpdateView({ fileNode, folders }: any) {
|
|||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
|
||||
{/* Folder Select */}
|
||||
<TextField
|
||||
id="update-dest-select"
|
||||
select fullWidth label="Destination Folder"
|
||||
|
|
@ -93,21 +162,20 @@ export default function UpdateView({ fileNode, folders }: any) {
|
|||
}}
|
||||
>
|
||||
<MenuItem value=""><em>-- Root --</em></MenuItem>
|
||||
{folders.map((f: any) => (
|
||||
{availablefolders?.map((f: any) => (
|
||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Custom Metadata Section */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AssignmentIcon color="primary" /> Custom Attributes
|
||||
<AssignmentIcon color="primary" /> Metadata Attributes
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
size="small"
|
||||
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
|
||||
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "", selected: true }])}
|
||||
>
|
||||
Add Field
|
||||
</Button>
|
||||
|
|
@ -115,35 +183,66 @@ export default function UpdateView({ fileNode, folders }: any) {
|
|||
|
||||
<Stack spacing={2}>
|
||||
{customMetadata.map((row, index) => (
|
||||
<Grid container spacing={1} key={index} alignItems="center">
|
||||
<Grid size={ {xs:5}}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder="Key"
|
||||
value={row.key}
|
||||
onChange={(e) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index].key = e.target.value;
|
||||
setCustomMetadata(updated);
|
||||
}}
|
||||
/>
|
||||
<Box key={index}>
|
||||
<Grid container spacing={1} alignItems="center">
|
||||
<Grid size={{ xs: 1 }}>
|
||||
<Tooltip title={row.selected ? "Save this field" : "Ignore this field"}>
|
||||
<Checkbox
|
||||
checked={row.selected}
|
||||
onChange={(e) => {
|
||||
const updated = [...customMetadata];
|
||||
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 size={{xs:6}}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder="Value"
|
||||
value={row.value}
|
||||
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>
|
||||
|
||||
{/* --- GOOGLE MAPS SHORTCUT --- */}
|
||||
{row.key.toLowerCase().includes('latitude') && row.value && (
|
||||
<Box sx={{ ml: 6, mt: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<MapIcon />}
|
||||
href={`https://www.google.com/maps?q=${row.value},${customMetadata.find(m => m.key.toLowerCase().includes('longitude'))?.value}`}
|
||||
target="_blank"
|
||||
>
|
||||
Verify GPS on Map
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,30 @@
|
|||
import { auth } from "@/auth";
|
||||
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 { 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() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) redirect("/");
|
||||
|
||||
// Fetch only folders so the user can select a destination
|
||||
const folders = await prisma.fileNode.findMany({
|
||||
where: { isFolder: true },
|
||||
// 3. Fetch folders. Renamed variable to 'allFolders' to avoid any confusion
|
||||
const allFolders = await prisma.fileNode.findMany({
|
||||
where: {
|
||||
isFolder: true,
|
||||
ownerId: session.user.id // Good practice: only show user's own folders
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, parentId: true }
|
||||
});
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||
{/* Pass folders to the view */}
|
||||
<UploadView user={session.user} folders={folders} />
|
||||
{/* 4. Render the Client Component and pass the data */}
|
||||
<UploadView user={session.user} folders={allFolders} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,249 +3,308 @@
|
|||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Box, Button, Typography, Paper, Stack,
|
||||
TextField, MenuItem, IconButton, Divider,
|
||||
Grid, CircularProgress
|
||||
TextField, IconButton, Divider,
|
||||
Grid,
|
||||
CircularProgress, Checkbox, MenuItem,
|
||||
InputAdornment, Collapse
|
||||
} from "@mui/material";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
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 { getMetadataPreviewAction } from "@/app/dashboard/actions";
|
||||
import { uploadFileAction, createFolderAction } from "./_actions";
|
||||
|
||||
interface MetadataPair {
|
||||
interface MetadataRow {
|
||||
key: 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
||||
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
|
||||
|
||||
// Folder Creation State
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [targetFolderId, setTargetFolderId] = useState<string>("");
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
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 () => {
|
||||
if (!newFolderName.trim()) return;
|
||||
setIsCreatingFolder(true);
|
||||
// Logic to determine if the "Complete" button should be active
|
||||
const canSubmit = selectedFile !== null || newFolderName.trim().length > 0;
|
||||
|
||||
// --- 1. MAGIC EXTRACTION LOGIC ---
|
||||
const handleMagicEnhance = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setIsExtracting(true);
|
||||
try {
|
||||
// FIX: Pass the current parentId to the action so it nests correctly
|
||||
const result = await createFolderAction(newFolderName, parentId);
|
||||
const result = await getMetadataPreviewAction(selectedFile.name);
|
||||
|
||||
if (result.success) {
|
||||
setNewFolderName("");
|
||||
setShowFolderInput(false);
|
||||
router.refresh();
|
||||
const extractedRows: MetadataRow[] = Object.entries(result.data ?? {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
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) {
|
||||
alert(err.message || "Failed to create folder");
|
||||
} catch (err) {
|
||||
console.error("Extraction failed:", err);
|
||||
} finally {
|
||||
setIsCreatingFolder(false);
|
||||
setIsExtracting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
|
||||
|
||||
const removeMetadataRow = (index: number) => {
|
||||
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
|
||||
const updated = [...customMetadata];
|
||||
updated[index][field] = val;
|
||||
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));
|
||||
// --- 2. SAVE / UPLOAD LOGIC ---
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaveStatus('saving');
|
||||
|
||||
try {
|
||||
const result = await uploadFileAction(formData);
|
||||
if (result.success) {
|
||||
setStatus('success');
|
||||
setFile(null);
|
||||
setDescription("");
|
||||
setCustomMetadata([]);
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
let currentParentId = targetFolderId;
|
||||
|
||||
// STEP A: Create Folder if user typed a new folder name
|
||||
if (newFolderName.trim()) {
|
||||
const folderResult = await createFolderAction(newFolderName, targetFolderId || null);
|
||||
if (folderResult.success) {
|
||||
// If successful, we want the file to go inside this NEW folder
|
||||
currentParentId = folderResult.node.id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Upload failed.");
|
||||
setStatus('idle');
|
||||
|
||||
// STEP B: Upload File if a file is selected
|
||||
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 (
|
||||
<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">
|
||||
Add to Library
|
||||
Upload & Enrich
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={4} sx={{ mt: 4 }}>
|
||||
{/* 1. Destination */}
|
||||
<Stack spacing={3} sx={{ mt: 4, mb: 4 }}>
|
||||
{/* FOLDER SELECTION */}
|
||||
<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}>
|
||||
<TextField
|
||||
id="project-destination-select"
|
||||
select
|
||||
fullWidth
|
||||
label="Target Project / Folder"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
size="small"
|
||||
slotProps={{
|
||||
select: { displayEmpty: true },
|
||||
inputLabel: { shrink: true },
|
||||
label="Destination Folder"
|
||||
value={targetFolderId}
|
||||
onChange={(e) => {
|
||||
setTargetFolderId(e.target.value);
|
||||
if (e.target.value) setShowNewFolderInput(false);
|
||||
}}
|
||||
>
|
||||
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
|
||||
{folders.map((f: any) => (
|
||||
<MenuItem value=""><em>-- Root Directory --</em></MenuItem>
|
||||
{folders?.map((f) => (
|
||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setShowFolderInput(!showFolderInput)}
|
||||
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
|
||||
<Button
|
||||
variant={showNewFolderInput ? "contained" : "outlined"}
|
||||
onClick={() => {
|
||||
setShowNewFolderInput(!showNewFolderInput);
|
||||
if (!showNewFolderInput) setTargetFolderId("");
|
||||
}}
|
||||
sx={{ height: 56, minWidth: 56 }}
|
||||
>
|
||||
<CreateNewFolderIcon />
|
||||
</IconButton>
|
||||
</Button>
|
||||
</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
|
||||
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
|
||||
value={newFolderName}
|
||||
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>
|
||||
<Collapse in={showNewFolderInput}>
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="New Folder Name"
|
||||
placeholder="Enter name to create folder..."
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* 2. Upload Area */}
|
||||
{/* FILE SELECTION */}
|
||||
<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}
|
||||
id="file-upload-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
"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
|
||||
{!selectedFile ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<CloudUploadIcon />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
sx={{ py: 4, borderStyle: 'dashed', borderWidth: 2 }}
|
||||
>
|
||||
Select File to Upload
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{customMetadata.map((row, index) => (
|
||||
<Grid container spacing={1} key={index} alignItems="center">
|
||||
<Grid size={{xs:5}}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
|
||||
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<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="500">{selectedFile.name}</Typography>
|
||||
</Stack>
|
||||
<IconButton onClick={() => setSelectedFile(null)} color="error">
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// src/auth.config.ts
|
||||
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
|
|
@ -9,20 +10,10 @@ export default {
|
|||
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile offline_access email Files.ReadWrite Files.Read",
|
||||
prompt: "consent", // Forces Microsoft to show the permission screen
|
||||
access_type: "offline",
|
||||
// offline_access is vital for getting the refresh_token
|
||||
scope: "openid profile email offline_access Files.ReadWrite Files.Read",
|
||||
},
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
azureAdUserId: profile.oid ?? profile.sub,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
} satisfies NextAuthConfig;
|
||||
18
src/auth.ts
18
src/auth.ts
|
|
@ -7,22 +7,18 @@ import authConfig from "./auth.config";
|
|||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
session: { strategy: "jwt" },
|
||||
...authConfig,
|
||||
...authConfig, // This now spreads the default export from auth.config.ts
|
||||
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
|
||||
// @ts-ignore
|
||||
token.role = user.role;
|
||||
}
|
||||
|
||||
|
|
@ -30,16 +26,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||
},
|
||||
|
||||
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
|
||||
// @ts-ignore
|
||||
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;
|
||||
|
|
@ -50,7 +40,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||
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!");
|
||||
console.warn("⚠️ WARNING: No refresh_token received!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
// 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";
|
||||
|
||||
|
||||
/**
|
||||
* FETCH: Retrieve all nodes for the dashboard.
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// src/lib/auth-utils.ts
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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)
|
||||
// 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) {
|
||||
|
|
@ -30,12 +30,17 @@ export async function getFreshAccessToken(userId: string) {
|
|||
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) throw tokens;
|
||||
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({
|
||||
|
|
@ -43,6 +48,7 @@ export async function getFreshAccessToken(userId: string) {
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -50,6 +56,7 @@ export async function getFreshAccessToken(userId: string) {
|
|||
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");
|
||||
}
|
||||
}
|
||||
92
src/lib/metadata-extractor.ts
Normal file
92
src/lib/metadata-extractor.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// src/services/onedrive.ts
|
||||
import "server-only";
|
||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
/**
|
||||
* 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");
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue