Working Version-Where we are up to 8/1/2026
This commit is contained in:
parent
390620a749
commit
59444e9c7c
29 changed files with 2556 additions and 105 deletions
21
.env
21
.env
|
|
@ -1,6 +1,17 @@
|
||||||
DATABASE_URL="postgresql://stephen:Web2025$$@192.168.1.210:5432/webcalibre2"
|
|
||||||
|
|
||||||
# Credentials for your Microsoft App (NextAuth)
|
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
||||||
AZURE_AD_CLIENT_ID="549931a-f491-436b-b7bc-d37d8ca3c17e"
|
# values generated by Gemini
|
||||||
AZURE_AD_CLIENT_SECRET="6a242be1-c711-4cc3-a132-03c1f57993ed"
|
# Generated for security
|
||||||
AZURE_AD_TENANT_ID="1c06ce7c-7884-4796-8652-d4c32d75a5d0"
|
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
||||||
|
|
||||||
|
# Use 'common' for multi-tenant (Work + Personal) support
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID="common"
|
||||||
|
|
||||||
|
# The Application (client) ID
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
|
|
||||||
|
# The Client Secret VALUE (ensure no leading '6' or spaces)
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
||||||
|
|
||||||
|
# Required to tell Auth.js to trust your localhost/proxy URL
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
6
.env-bak
Normal file
6
.env-bak
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
DATABASE_URL="postgresql://stephen:Web2025$$@192.168.1.210:5432/webcalibre2"
|
||||||
|
|
||||||
|
# Credentials for your Microsoft App (NextAuth)
|
||||||
|
AZURE_AD_CLIENT_ID="549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
|
AZURE_AD_CLIENT_SECRET="6a242be1-c711-4cc3-a132-03c1f57993ed"
|
||||||
|
AZURE_AD_TENANT_ID="1c06ce7c-7884-4796-8652-d4c32d75a5d0"
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
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
|
||||||
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,3 +39,5 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
|
||||||
111
docs/notes.md
111
docs/notes.md
|
|
@ -4,6 +4,7 @@
|
||||||
- [1.3. Overview of user consent and how to manage it in Microsoft Entra | Microsoft](#13-overview-of-user-consent-and-how-to-manage-it-in-microsoft-entra--microsoft)
|
- [1.3. Overview of user consent and how to manage it in Microsoft Entra | Microsoft](#13-overview-of-user-consent-and-how-to-manage-it-in-microsoft-entra--microsoft)
|
||||||
- [1.4. Add Tailwind CSS to an Existing Next js Project](#14-add-tailwind-css-to-an-existing-next-js-project)
|
- [1.4. Add Tailwind CSS to an Existing Next js Project](#14-add-tailwind-css-to-an-existing-next-js-project)
|
||||||
- [1.5. How to style Material UI component with Tailwindcss in React project](#15-how-to-style-material-ui-component-with-tailwindcss-in-react-project)
|
- [1.5. How to style Material UI component with Tailwindcss in React project](#15-how-to-style-material-ui-component-with-tailwindcss-in-react-project)
|
||||||
|
- [1.6. How to Use Prisma 7 in Next.js](#16-how-to-use-prisma-7-in-nextjs)
|
||||||
- [2. Project Structure](#2-project-structure)
|
- [2. Project Structure](#2-project-structure)
|
||||||
- [3. App Registrations](#3-app-registrations)
|
- [3. App Registrations](#3-app-registrations)
|
||||||
- [3.1. Permissions Needed](#31-permissions-needed)
|
- [3.1. Permissions Needed](#31-permissions-needed)
|
||||||
|
|
@ -14,6 +15,15 @@
|
||||||
- [4.4. Test nextjs](#44-test-nextjs)
|
- [4.4. Test nextjs](#44-test-nextjs)
|
||||||
- [4.5. Check Authentication](#45-check-authentication)
|
- [4.5. Check Authentication](#45-check-authentication)
|
||||||
- [4.6. Install NextAuth.js v5](#46-install-nextauthjs-v5)
|
- [4.6. Install NextAuth.js v5](#46-install-nextauthjs-v5)
|
||||||
|
- [5. .env files](#5-env-files)
|
||||||
|
- [5.1. .env.local](#51-envlocal)
|
||||||
|
- [5.2. .env](#52-env)
|
||||||
|
- [6. Prisma Install](#6-prisma-install)
|
||||||
|
- [6.1. The CLI is for development tasks like migrations](#61-the-cli-is-for-development-tasks-like-migrations)
|
||||||
|
- [6.2. Create the prisma folder](#62-create-the-prisma-folder)
|
||||||
|
- [7. Run this whenever you change your schema.prisma file](#7-run-this-whenever-you-change-your-schemaprisma-file)
|
||||||
|
- [7.1. Pro-Tip: Update your package.json](#71-pro-tip-update-your-packagejson)
|
||||||
|
- [8. Where we are up to 8/1/2026](#8-where-we-are-up-to-812026)
|
||||||
|
|
||||||
# 1. Reference
|
# 1. Reference
|
||||||
|
|
||||||
|
|
@ -37,6 +47,11 @@
|
||||||
|
|
||||||
[How to style Material UI component with Tailwindcss in React project](https://www.youtube.com/watch?v=QQIfuMlA6TI)
|
[How to style Material UI component with Tailwindcss in React project](https://www.youtube.com/watch?v=QQIfuMlA6TI)
|
||||||
|
|
||||||
|
|
||||||
|
## 1.6. How to Use Prisma 7 in Next.js
|
||||||
|
|
||||||
|
[How to Use Prisma 7 in Next.js](https://www.youtube.com/watch?v=Ndhx_rNkoUw)
|
||||||
|
|
||||||
# 2. Project Structure
|
# 2. Project Structure
|
||||||
|
|
||||||
The following is the desire structure
|
The following is the desire structure
|
||||||
|
|
@ -230,3 +245,99 @@ created .env.local
|
||||||
```text
|
```text
|
||||||
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# 5. .env files
|
||||||
|
|
||||||
|
## 5.1. .env.local
|
||||||
|
```text
|
||||||
|
# values generated by Gemini
|
||||||
|
# Generated for security
|
||||||
|
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
||||||
|
|
||||||
|
# Use 'common' for multi-tenant (Work + Personal) support
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID="common"
|
||||||
|
|
||||||
|
# The Application (client) ID
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
|
|
||||||
|
# The Client Secret VALUE (ensure no leading '6' or spaces)
|
||||||
|
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
||||||
|
|
||||||
|
# Required to tell Auth.js to trust your localhost/proxy URL
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2. .env
|
||||||
|
```
|
||||||
|
DATABASE_URL="postgresql://stephen:Web2025$$@192.168.1.210:5432/webcalibre2"
|
||||||
|
|
||||||
|
# Credentials for your Microsoft App (NextAuth)
|
||||||
|
AZURE_AD_CLIENT_ID="549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
|
AZURE_AD_CLIENT_SECRET="6a242be1-c711-4cc3-a132-03c1f57993ed"
|
||||||
|
AZURE_AD_TENANT_ID="1c06ce7c-7884-4796-8652-d4c32d75a5d0"
|
||||||
|
```
|
||||||
|
|
||||||
|
# 6. Prisma Install
|
||||||
|
|
||||||
|
To get Prisma fully installed and ready to talk to your PostgreSQL database, you need to install the CLI (for migrations) and the Client (for your code to query the database).
|
||||||
|
|
||||||
|
Run these three commands in your terminal:
|
||||||
|
|
||||||
|
1. Install the Prisma packages
|
||||||
|
Bash
|
||||||
|
|
||||||
|
## 6.1. The CLI is for development tasks like migrations
|
||||||
|
npm install prisma --save-dev
|
||||||
|
|
||||||
|
##The Client is used by your Next.js code to talk to the DB
|
||||||
|
```zsh
|
||||||
|
npm install @prisma/client
|
||||||
|
```
|
||||||
|
2. Install the Dotenv helper
|
||||||
|
Since you are keeping your DATABASE_URL in .env.local (and your password has special characters), you need this to bridge the gap between Next.js and the Prisma CLI:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
|
||||||
|
npm install dotenv-cli --save-dev
|
||||||
|
```
|
||||||
|
3. Initialize and Generate
|
||||||
|
Once the packages are installed, you need to initialize the Prisma folder (if you haven't already) and generate the types for your FileNode model:
|
||||||
|
|
||||||
|
Bash
|
||||||
|
|
||||||
|
## 6.2. Create the prisma folder
|
||||||
|
```zsh
|
||||||
|
npx prisma init
|
||||||
|
```
|
||||||
|
# 7. Run this whenever you change your schema.prisma file
|
||||||
|
npx dotenv -e .env.local -- npx prisma generate
|
||||||
|
4. Apply your Schema to PostgreSQL
|
||||||
|
Finally, run this to actually create the tables in your webcalibre2 database:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
npx dotenv -e .env.local -- npx prisma migrate dev --name init_database
|
||||||
|
|
||||||
|
## 7.1. Pro-Tip: Update your package.json
|
||||||
|
To avoid typing that long dotenv command every time, open your package.json and add this to the "scripts" section:
|
||||||
|
|
||||||
|
JSON
|
||||||
|
|
||||||
|
"scripts": {
|
||||||
|
"db:migrate": "dotenv -e .env.local -- prisma migrate dev",
|
||||||
|
"db:studio": "dotenv -e .env.local -- prisma studio"
|
||||||
|
}
|
||||||
|
Now, you can just run npm run db:migrate whenever you update your schema!
|
||||||
|
|
||||||
|
# 8. Where we are up to 8/1/2026
|
||||||
|
|
||||||
|
We can authenticate with MS Azure, upload a file and store it on my personal OneDrive.
|
||||||
|
I can see a few problems,
|
||||||
|
|
||||||
|
1. if the file gets stored with its original filename, if I store the same file again it tells that it fail, but in reality it did not fail the it got renamed with 1.pdf added this is not added data base.
|
||||||
|
|
||||||
|
2. We may be better to create a folder and put the file in the folder, to me the logical name would id of the file, but that is automatically create and assigned when the data is stored as a UUID.
|
||||||
|
|
||||||
|
3. We would have to create the UUID ourselves, so that we can create a folder with that specific UUID and store the file in the folder.
|
||||||
|
|
||||||
|
4. The Upload file Page needs to be able to create folder as well select a parent , add a description of either the file
|
||||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
1343
package-lock.json
generated
1343
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -6,26 +6,37 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@emotion/cache": "^11.14.0",
|
"@emotion/cache": "^11.14.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.6",
|
"@mui/icons-material": "^7.3.6",
|
||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/material-nextjs": "^7.3.6",
|
"@mui/material-nextjs": "^7.3.6",
|
||||||
|
"@mui/x-data-grid": "^8.23.0",
|
||||||
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
|
"@prisma/client": "^7.2.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
|
"prisma": "^7.2.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
prisma.config.ts
Normal file
12
prisma.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { config } from "dotenv";
|
||||||
|
config({ path: ".env.local" });
|
||||||
|
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
// This is now the ONLY place where the DB connection string is defined
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"azureAdUserId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FileNode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"size" BIGINT,
|
||||||
|
"isFolder" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"oneDriveId" TEXT,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"orderIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"description" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FileNode_parentId_idx" ON "FileNode"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `displayName` on the `User` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" DROP COLUMN "displayName",
|
||||||
|
ADD COLUMN "emailVerified" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "image" TEXT,
|
||||||
|
ADD COLUMN "name" TEXT,
|
||||||
|
ALTER COLUMN "azureAdUserId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerAccountId" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionToken" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
76
prisma/schema.prisma
Normal file
76
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String? // Renamed from displayName for NextAuth compatibility
|
||||||
|
email String @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
image String?
|
||||||
|
azureAdUserId String? @unique
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
nodes FileNode[]
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model FileNode {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
size BigInt?
|
||||||
|
isFolder Boolean @default(false)
|
||||||
|
oneDriveId String? @unique
|
||||||
|
path String
|
||||||
|
orderIndex Int @default(0)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
description String?
|
||||||
|
|
||||||
|
ownerId String
|
||||||
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
|
||||||
|
parentId String?
|
||||||
|
parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id])
|
||||||
|
children FileNode[] @relation("TreeHierarchy")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ownerId, path])
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([orderIndex])
|
||||||
|
}
|
||||||
25
src/app/dashboard/actions.ts
Normal file
25
src/app/dashboard/actions.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
'use server';
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
export async function getFileNodes() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return []; // Return empty if not logged in
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await prisma.fileNode.findMany({
|
||||||
|
where: {
|
||||||
|
ownerId: session.user.id, // Only get THIS user's files
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes.map(node => ({
|
||||||
|
...node,
|
||||||
|
size: node.size ? Number(node.size) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
75
src/app/dashboard/dashboard-view.tsx
Normal file
75
src/app/dashboard/dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, CircularProgress } from "@mui/material";
|
||||||
|
import SyncIcon from "@mui/icons-material/Sync";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { syncOneDrive } from "./sync-actions";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface DashboardViewProps {
|
||||||
|
initialFiles: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardView({ initialFiles }: DashboardViewProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await syncOneDrive();
|
||||||
|
// This tells Next.js to re-run the Server Component (page.tsx)
|
||||||
|
// and fetch the fresh data from Postgres
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Sync failed:", error);
|
||||||
|
alert("Failed to sync OneDrive");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{ field: "name", headerName: "File Name", width: 300 },
|
||||||
|
{
|
||||||
|
field: "metadata",
|
||||||
|
headerName: "Type",
|
||||||
|
width: 120,
|
||||||
|
valueGetter: (params) => params?.type || "Unknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "size",
|
||||||
|
headerName: "Size (MB)",
|
||||||
|
width: 120,
|
||||||
|
valueGetter: (value) => value ? (Number(value) / 1024 / 1024).toFixed(2) : "0"
|
||||||
|
},
|
||||||
|
{ field: "updatedAt", headerName: "Last Synced", width: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Syncing..." : "Sync OneDrive"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 600, width: "100%" }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={initialFiles}
|
||||||
|
columns={columns}
|
||||||
|
pageSizeOptions={[10, 25, 50]}
|
||||||
|
initialState={{
|
||||||
|
pagination: { paginationModel: { pageSize: 10 } },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,25 @@
|
||||||
import React from 'react'
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getFileNodes } from "./actions";
|
||||||
|
import DashboardView from "./dashboard-view"
|
||||||
|
|
||||||
function Dashboard() {
|
export default async function DashboardPage() {
|
||||||
return (
|
const session = await auth();
|
||||||
<div>Dashboard</div>
|
|
||||||
)
|
// Guard: If not logged in, go back to home
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dashboard
|
// Fetch initial files from PostgreSQL
|
||||||
|
const initialFiles = await getFileNodes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">My OneDrive Library</h1>
|
||||||
|
|
||||||
|
{/* Pass the data to the interactive Client Component */}
|
||||||
|
<DashboardView initialFiles={initialFiles} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/dashboard/sync-actions.ts
Normal file
52
src/app/dashboard/sync-actions.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function syncOneDrive() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
// 1. Get the OAuth token from your PostgreSQL 'Account' table
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account?.access_token) throw new Error("Microsoft account not linked properly");
|
||||||
|
|
||||||
|
// 2. Fetch the files from Microsoft Graph
|
||||||
|
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root/children", {
|
||||||
|
headers: { Authorization: `Bearer ${account.access_token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 3. The "Sync Loop": Map OneDrive items to your Postgres FileNode table
|
||||||
|
for (const item of data.value) {
|
||||||
|
const extension = item.name.split('.').pop()?.toUpperCase() || (item.folder ? 'FOLDER' : 'UNKNOWN');
|
||||||
|
|
||||||
|
await prisma.fileNode.upsert({
|
||||||
|
where: { oneDriveId: item.id },
|
||||||
|
update: {
|
||||||
|
name: item.name,
|
||||||
|
size: item.size,
|
||||||
|
metadata: { type: extension }, // Store the "Type" in your JSONB field
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
oneDriveId: item.id,
|
||||||
|
name: item.name,
|
||||||
|
size: item.size,
|
||||||
|
isFolder: !!item.folder,
|
||||||
|
path: item.parentReference.path + '/' + item.name,
|
||||||
|
ownerId: session.user.id,
|
||||||
|
metadata: { type: extension },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the dashboard UI to show new data
|
||||||
|
revalidatePath('/dashboard');
|
||||||
|
return { success: true, count: data.value.length };
|
||||||
|
}
|
||||||
68
src/app/dashboard/upload-actions.ts
Normal file
68
src/app/dashboard/upload-actions.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function uploadFileAction(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
const folderName = "WebCalibre";
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
|
|
||||||
|
// 1. Create/Check WebCalibre Folder
|
||||||
|
const folderPath = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
|
||||||
|
const folderCheck = await fetch(folderPath, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folderCheck.status === 404) {
|
||||||
|
await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: folderName, folder: {} })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Upload Session (Supports files > 4MB)
|
||||||
|
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${file.name}:/createUploadSession`;
|
||||||
|
const sessionRes = await fetch(sessionUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "rename" } })
|
||||||
|
});
|
||||||
|
|
||||||
|
const { uploadUrl } = await sessionRes.json();
|
||||||
|
|
||||||
|
// 3. Upload File Data
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Length": `${file.size}`,
|
||||||
|
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
|
||||||
|
},
|
||||||
|
body: buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
const driveItem = await uploadRes.json();
|
||||||
|
|
||||||
|
// 4. Record in PostgreSQL
|
||||||
|
await prisma.fileNode.create({
|
||||||
|
data: {
|
||||||
|
oneDriveId: driveItem.id,
|
||||||
|
name: file.name,
|
||||||
|
size: BigInt(file.size),
|
||||||
|
isFolder: false,
|
||||||
|
path: `/${folderName}/${file.name}`,
|
||||||
|
ownerId: session.user.id,
|
||||||
|
metadata: { type: file.name.split('.').pop()?.toUpperCase() }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
65
src/app/dashboard/upload.tsx
Normal file
65
src/app/dashboard/upload.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Typography, Box, LinearProgress } from "@mui/material";
|
||||||
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
|
import { uploadFileToOneDrive } from "./upload-actions";
|
||||||
|
|
||||||
|
export default function UploadForm() {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
async function handleAction(formData: FormData) {
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
if (!file || file.size === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await uploadFileToOneDrive(formData);
|
||||||
|
alert("Uploaded successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Upload failed.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="p-4 border-2 border-dashed border-gray-300 rounded-lg text-center">
|
||||||
|
<form action={handleAction}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
// Optional: Trigger auto-submit or show filename
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="file-upload">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="span"
|
||||||
|
startIcon={<CloudUploadIcon />}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
Select Book / File
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<Box sx={{ width: '100%', mt: 2 }}>
|
||||||
|
<Typography variant="caption">Uploading to OneDrive & Database...</Typography>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box mt={2}>
|
||||||
|
<Button type="submit" variant="contained" disabled={uploading}>
|
||||||
|
Confirm Upload
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/app/dashboard/upload/page.tsx
Normal file
69
src/app/dashboard/upload/page.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Button, Typography, Paper, LinearProgress, Container } from "@mui/material";
|
||||||
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
|
import { uploadFileAction } from "../upload-actions";
|
||||||
|
|
||||||
|
export default function UploadPage() {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadFileAction(formData);
|
||||||
|
alert("Book added to WebCalibre!");
|
||||||
|
setFile(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert("Upload failed. Check console.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm" sx={{ mt: 8 }}>
|
||||||
|
<Paper elevation={3} sx={{ p: 4, textAlign: 'center', borderRadius: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom fontWeight="bold">
|
||||||
|
Upload to Library
|
||||||
|
</Typography>
|
||||||
|
<Typography color="textSecondary" mb={4}>
|
||||||
|
Files will be saved in your OneDrive "WebCalibre" folder.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ border: '2px dashed #ccc', p: 4, mb: 3, borderRadius: 2 }}>
|
||||||
|
<input
|
||||||
|
accept=".pdf,.epub,.mobi,.txt"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="file-input">
|
||||||
|
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
|
||||||
|
{file ? file.name : "Choose Book File"}
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
disabled={!file || loading}
|
||||||
|
onClick={handleUpload}
|
||||||
|
>
|
||||||
|
{loading ? "Uploading..." : "Start Upload"}
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/app/upload/_actions.ts
Normal file
112
src/app/upload/_actions.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function uploadFileAction(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
if (!file) throw new Error("No file selected");
|
||||||
|
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
|
const folderName = "WebCalibre";
|
||||||
|
|
||||||
|
// --- 1. CHECK/CREATE THE WEBCALIBRE FOLDER ---
|
||||||
|
// We check if the folder exists at the root of the user's OneDrive
|
||||||
|
const folderCheckUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
|
||||||
|
const folderCheck = await fetch(folderCheckUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folderCheck.status === 404) {
|
||||||
|
console.log(`📂 Folder '${folderName}' not found. Creating it...`);
|
||||||
|
const createFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: folderName,
|
||||||
|
folder: {}, // Empty object tells Graph to create a folder
|
||||||
|
"@microsoft.graph.conflictBehavior": "fail"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createFolderRes.ok) {
|
||||||
|
const errorData = await createFolderRes.json();
|
||||||
|
console.error("❌ Folder Creation Error:", errorData);
|
||||||
|
throw new Error("Could not create WebCalibre folder on OneDrive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. CREATE UPLOAD SESSION ---
|
||||||
|
// encodeURIComponent is vital for filenames with spaces or special characters
|
||||||
|
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${encodeURIComponent(file.name)}:/createUploadSession`;
|
||||||
|
|
||||||
|
const sessionRes = await fetch(sessionUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
item: {
|
||||||
|
"@microsoft.graph.conflictBehavior": "rename", // If file exists, name it "Book 1.pdf"
|
||||||
|
name: file.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionData = await sessionRes.json();
|
||||||
|
if (!sessionRes.ok) {
|
||||||
|
console.error("❌ Session Error:", sessionData);
|
||||||
|
throw new Error(sessionData.error?.message || "OneDrive session failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl } = sessionData;
|
||||||
|
|
||||||
|
// --- 3. UPLOAD THE DATA BYTES ---
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Length": `${file.size}`,
|
||||||
|
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
|
||||||
|
},
|
||||||
|
body: buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
const uploadError = await uploadRes.json();
|
||||||
|
console.error("❌ Upload Error:", uploadError);
|
||||||
|
throw new Error("Chunk upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveItem = await uploadRes.json();
|
||||||
|
|
||||||
|
// --- 4. RECORD IN POSTGRESQL (PRISMA) ---
|
||||||
|
await prisma.fileNode.create({
|
||||||
|
data: {
|
||||||
|
oneDriveId: driveItem.id,
|
||||||
|
name: file.name,
|
||||||
|
size: BigInt(file.size),
|
||||||
|
isFolder: false,
|
||||||
|
path: `/${folderName}/${file.name}`,
|
||||||
|
ownerId: session.user.id,
|
||||||
|
metadata: {
|
||||||
|
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
|
||||||
|
mimeType: file.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revalidate ensures the dashboard list updates immediately
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
17
src/app/upload/page.tsx
Normal file
17
src/app/upload/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import UploadView from "./upload-view";
|
||||||
|
import { Container } from "@mui/material";
|
||||||
|
|
||||||
|
export default async function UploadPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// Guard: Must be logged in to upload
|
||||||
|
if (!session) redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
|
<UploadView user={session.user} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/app/upload/upload-view.tsx
Normal file
122
src/app/upload/upload-view.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Button, Typography, Paper, LinearProgress, Stack } from "@mui/material";
|
||||||
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
|
import { uploadFileAction } from "./_actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to convert raw bytes into a human-readable string.
|
||||||
|
* This helps the user understand exactly how large their e-book is.
|
||||||
|
*/
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
// Returns something like "1.45 MB" or "850 KB"
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UploadView({ user }: { user: any }) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Limit
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('uploading');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadFileAction(formData);
|
||||||
|
setStatus('success');
|
||||||
|
setFile(null);
|
||||||
|
} catch (err) {
|
||||||
|
// If the server action fails, it usually prints details in the terminal
|
||||||
|
alert("Upload failed. Ensure the 'WebCalibre' folder can be created and OneDrive has space.");
|
||||||
|
setStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }} elevation={3}>
|
||||||
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
||||||
|
Upload to WebCalibre
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" mb={4}>
|
||||||
|
Adding books as <strong>{user.name}</strong>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={3} alignItems="center">
|
||||||
|
{/* Dropzone/Selection Area */}
|
||||||
|
<Box sx={{ width: '100%', p: 5, border: '2px dashed #ccc', borderRadius: 2, bgcolor: '#f9f9f9' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="book-upload"
|
||||||
|
hidden
|
||||||
|
// Only allow common book formats
|
||||||
|
accept=".pdf,.epub,.mobi,.azw3,.txt"
|
||||||
|
onChange={(e) => {
|
||||||
|
setFile(e.target.files?.[0] || null);
|
||||||
|
setStatus('idle');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="book-upload">
|
||||||
|
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
|
||||||
|
{file ? "Select Different File" : "Choose File (PDF, EPUB, MOBI)"}
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* New: Enhanced File Info Display */}
|
||||||
|
{file && (
|
||||||
|
<Box mt={3}>
|
||||||
|
<Typography variant="subtitle2" color="primary.main" fontWeight="bold">
|
||||||
|
Selected: {file.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
File Size: {formatFileSize(file.size)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
{status === 'uploading' && (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<Typography variant="caption" display="block" gutterBottom sx={{ color: 'primary.main', fontWeight: 600 }}>
|
||||||
|
Connecting to OneDrive & Uploading...
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
disabled={!file || status === 'uploading'}
|
||||||
|
onClick={handleUpload}
|
||||||
|
sx={{ py: 1.5, fontSize: '1.1rem', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{status === 'uploading' ? 'Please Wait...' : 'Confirm Upload'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Success Feedback */}
|
||||||
|
{status === 'success' && (
|
||||||
|
<Typography color="success.main" fontWeight="bold" sx={{ mt: 2 }}>
|
||||||
|
✅ Successfully uploaded to your library!
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/auth.config.ts
Normal file
28
src/auth.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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: {
|
||||||
|
scope: "openid profile offline_access email Files.ReadWrite Files.Read",
|
||||||
|
prompt: "consent", // Forces Microsoft to show the permission screen
|
||||||
|
access_type: "offline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
image: null,
|
||||||
|
azureAdUserId: profile.oid ?? profile.sub,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
52
src/auth.ts
52
src/auth.ts
|
|
@ -1,21 +1,39 @@
|
||||||
import NextAuth from "next-auth"
|
import NextAuth from "next-auth";
|
||||||
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import authConfig from "./auth.config";
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
providers: [
|
adapter: PrismaAdapter(prisma),
|
||||||
MicrosoftEntraID({
|
session: { strategy: "jwt" },
|
||||||
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
|
...authConfig,
|
||||||
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
|
callbacks: {
|
||||||
// Pass tenantId as 'common' to support multi-tenant + personal accounts
|
async jwt({ token, account, user }) {
|
||||||
tenantId: process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID,
|
// On the first sign in, 'account' contains the refresh_token
|
||||||
authorization: {
|
if (account) {
|
||||||
params: {
|
token.accessToken = account.access_token;
|
||||||
// Explicitly ask for these scopes for OneDrive access later
|
token.refreshToken = account.refresh_token;
|
||||||
scope: "openid profile email offline_access User.Read Files.ReadWrite",
|
token.expiresAt = account.expires_at;
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
token.sub = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session?.user && token.sub) {
|
||||||
|
session.user.id = token.sub;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
// Adding events can help debug if the account is actually linking
|
||||||
],
|
events: {
|
||||||
// Required for Next.js 15/16 and Nginx production environments
|
async linkAccount({ account, user }) {
|
||||||
trustHost: true,
|
console.log("🔗 Account linked successfully for user:", user.id);
|
||||||
})
|
if (!account.refresh_token) {
|
||||||
|
console.warn("⚠️ WARNING: No refresh_token received in linkAccount event!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AppBar, Toolbar, Typography, Button, Box, Container, Avatar, IconButton } from '@mui/material';
|
import {
|
||||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
AppBar, Toolbar, Typography, Button, Box, Container,
|
||||||
|
Avatar, IconButton, Drawer, List, ListItem,
|
||||||
|
ListItemButton, ListItemIcon, ListItemText, Divider
|
||||||
|
} from '@mui/material';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn, signOut } from "next-auth/react";
|
import { signIn, signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
|
@ -16,14 +22,34 @@ interface NavbarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ user }: NavbarProps) {
|
export default function Navbar({ user }: NavbarProps) {
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const isLoggedIn = !!user;
|
const isLoggedIn = !!user;
|
||||||
|
|
||||||
|
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
||||||
|
if (event.type === 'keydown' && ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDrawerOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
||||||
|
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
||||||
|
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
|
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Toolbar disableGutters>
|
<Toolbar disableGutters>
|
||||||
{/* Menu Icon for the future AppDrawer */}
|
<IconButton
|
||||||
<IconButton edge="start" color="inherit" aria-label="menu" sx={{ mr: 2 }}>
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="menu"
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
onClick={toggleDrawer(true)}
|
||||||
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
|
@ -39,43 +65,19 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
{/* User Info: Name and Small Email */}
|
|
||||||
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
|
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
|
||||||
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
|
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>{user.name}</Typography>
|
||||||
{user.name}
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{user.email}</Typography>
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
|
||||||
{user.email}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}>
|
||||||
{/* Profile Picture */}
|
|
||||||
<Avatar
|
|
||||||
src={user.image || ""}
|
|
||||||
sx={{ width: 38, height: 38, border: '1px solid #eee' }}
|
|
||||||
>
|
|
||||||
{user.name?.charAt(0)}
|
{user.name?.charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<Button variant="outlined" color="inherit" size="small" onClick={() => signOut({ callbackUrl: '/' })} sx={{ textTransform: 'none' }}>
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
// We are still calling the function, just giving it a destination!
|
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
|
||||||
sx={{ borderRadius: '8px', textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Login button triggers the Microsoft flow directly
|
<Button variant="contained" disableElevation onClick={() => signIn("microsoft-entra-id", { callbackUrl: "/dashboard" })} sx={{ textTransform: 'none', px: 3 }}>
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disableElevation
|
|
||||||
onClick={() => signIn("microsoft-entra-id", { callbackUrl: "/dashboard" })}
|
|
||||||
sx={{ borderRadius: '8px', textTransform: 'none', px: 3 }}
|
|
||||||
>
|
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -83,5 +85,27 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</Container>
|
</Container>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
|
{/* The Drawer (Sidebar) */}
|
||||||
|
<Drawer anchor="left" open={isDrawerOpen} onClose={toggleDrawer(false)}>
|
||||||
|
<Box sx={{ width: 250 }} role="presentation" onClick={toggleDrawer(false)} onKeyDown={toggleDrawer(false)}>
|
||||||
|
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 2, bgcolor: 'primary.main', color: 'white' }}>
|
||||||
|
<LibraryBooksIcon />
|
||||||
|
<Typography variant="h6" fontWeight={700}>Navigation</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<ListItem key={item.text} disablePadding>
|
||||||
|
<ListItemButton component={Link} href={item.href}>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.text} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
55
src/lib/auth-utils.ts
Normal file
55
src/lib/auth-utils.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/lib/prisma.ts
Normal file
37
src/lib/prisma.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
// 1. Setup the connection pool using your .env.local variable
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
// 2. Define the singleton logic
|
||||||
|
const prismaClientSingleton = () => {
|
||||||
|
const client = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
// ✅ HEALTH CHECK: Only runs once per client initialization
|
||||||
|
client.$connect()
|
||||||
|
.then(() => console.log("✅ Prisma 7 connected to PostgreSQL successfully"))
|
||||||
|
.catch((err) => console.error("❌ Prisma connection error:", err));
|
||||||
|
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClientSingleton | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Export the client (reusing existing one if it exists)
|
||||||
|
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
|
// BigInt fix for JSON serialization (needed for file sizes)
|
||||||
|
if (typeof BigInt !== 'undefined') {
|
||||||
|
(BigInt.prototype as any).toJSON = function () {
|
||||||
|
return Number(this);
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue