ソースを参照

feat: persist uploads in PostgreSQL via Prisma ORM

Replace filesystem storage with a PostgreSQL-backed solution using Prisma.
- Add Prisma schema, client, and migrations
- Update POST /api/upload to save files as BYTEA in the database
- Extend .gitignore for generated Prisma client
- Add docker-compose.yml for local Postgres and DATABASE_SETUP.md guide
- Update uploadForm success message to show file size
vtugulan 6 ヶ月 前
コミット
87d3d5b65d

+ 2 - 0
.gitignore

@@ -39,3 +39,5 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+
+/app/generated/prisma

+ 77 - 0
DATABASE_SETUP.md

@@ -0,0 +1,77 @@
+# PostgreSQL Blob Storage Setup
+
+This project has been updated to store uploaded files as blobs in PostgreSQL using Prisma ORM.
+
+## Database Schema
+
+The `File` model in `prisma/schema.prisma`:
+- `id`: Unique identifier (CUID)
+- `filename`: Original filename
+- `mimetype`: File MIME type
+- `size`: File size in bytes
+- `data`: File content as binary data (PostgreSQL ByteA type)
+- `createdAt`: Timestamp when file was uploaded
+- `updatedAt`: Last update timestamp
+
+## Setup Instructions
+
+### 1. Start PostgreSQL Database
+```bash
+# Using Docker Compose
+docker-compose up -d postgres
+
+# Or use your own PostgreSQL instance
+# Update DATABASE_URL in .env accordingly
+```
+
+### 2. Database Migration
+```bash
+# Run Prisma migrations
+npx prisma migrate dev --name init
+
+# Generate Prisma Client
+npx prisma generate
+```
+
+### 3. Start Development Server
+```bash
+npm run dev
+```
+
+## API Endpoints
+
+### Upload File
+- **POST** `/api/upload`
+- **Content-Type**: `multipart/form-data`
+- **Body**: Form data with `file` field
+- **Response**: File metadata (id, filename, mimetype, size, createdAt)
+
+### List Files
+- **GET** `/api/files`
+- **Response**: Array of file metadata (excluding binary data)
+
+### Download File
+- **GET** `/api/files/[id]`
+- **Response**: File content with appropriate headers
+- **Headers**: Content-Type, Content-Disposition, Content-Length
+
+## Testing
+
+1. Open `http://localhost:3000` to test with the main application
+2. Use `test-upload.html` for simple testing
+3. Use Prisma Studio for database inspection:
+   ```bash
+   npx prisma studio
+   ```
+
+## Environment Variables
+
+- `DATABASE_URL`: PostgreSQL connection string
+- `ROOT_PATH`: Project root path (for legacy file system operations)
+
+## Docker Setup
+
+The project includes Docker Compose configuration for easy PostgreSQL setup:
+- PostgreSQL 15 with persistent storage
+- Automatic database creation
+- Port mapping: localhost:5432

+ 39 - 0
app/api/files/[id]/route.ts

@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { PrismaClient } from "@prisma/client";
+
+const prisma = new PrismaClient();
+
+export const GET = async (
+  req: NextRequest,
+  { params }: { params: { id: string } }
+) => {
+  try {
+    const file = await prisma.file.findUnique({
+      where: { id: params.id },
+    });
+
+    if (!file) {
+      return NextResponse.json(
+        { error: "File not found" },
+        { status: 404 }
+      );
+    }
+
+    // Return file as downloadable response
+    return new NextResponse(file.data, {
+      headers: {
+        "Content-Type": file.mimetype,
+        "Content-Disposition": `attachment; filename="${file.filename}"`,
+        "Content-Length": file.size.toString(),
+      },
+    });
+  } catch (error) {
+    console.error("Error retrieving file:", error);
+    return NextResponse.json(
+      { error: "Failed to retrieve file" },
+      { status: 500 }
+    );
+  } finally {
+    await prisma.$disconnect();
+  }
+};

+ 35 - 0
app/api/files/route.ts

@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { PrismaClient } from "@prisma/client";
+
+const prisma = new PrismaClient();
+
+export const GET = async (req: NextRequest) => {
+  try {
+    const files = await prisma.file.findMany({
+      select: {
+        id: true,
+        filename: true,
+        mimetype: true,
+        size: true,
+        createdAt: true,
+        updatedAt: true,
+      },
+      orderBy: {
+        createdAt: 'desc',
+      },
+    });
+
+    return NextResponse.json({
+      success: true,
+      files,
+    });
+  } catch (error) {
+    console.error("Error listing files:", error);
+    return NextResponse.json(
+      { success: false, error: "Failed to list files" },
+      { status: 500 }
+    );
+  } finally {
+    await prisma.$disconnect();
+  }
+};

+ 41 - 22
app/api/upload/route.ts

@@ -1,32 +1,51 @@
 import { NextRequest, NextResponse } from "next/server";
-import path from "path";
-import fs from "fs";
+import { PrismaClient } from "@prisma/client";
 
-const UPLOAD_DIR = path.resolve(process.env.ROOT_PATH ?? "", "public/uploads");
+const prisma = new PrismaClient();
 
 export const POST = async (req: NextRequest) => {
-  const formData = await req.formData();
-  const body = Object.fromEntries(formData);
-  const file = (body.file as Blob) || null;
+  try {
+    const formData = await req.formData();
+    const body = Object.fromEntries(formData);
+    const file = (body.file as File) || null;
 
-  if (file) {
-    const buffer = Buffer.from(await file.arrayBuffer());
-    if (!fs.existsSync(UPLOAD_DIR)) {
-      fs.mkdirSync(UPLOAD_DIR);
+    if (!file) {
+      return NextResponse.json({
+        success: false,
+        error: "No file provided",
+      }, { status: 400 });
     }
 
-    fs.writeFileSync(
-      path.resolve(UPLOAD_DIR, (body.file as File).name),
-      buffer
-    );
-  } else {
+    // Convert file to buffer
+    const buffer = Buffer.from(await file.arrayBuffer());
+    
+    // Store file in database
+    const savedFile = await prisma.file.create({
+      data: {
+        filename: file.name,
+        mimetype: file.type,
+        size: file.size,
+        data: buffer,
+      },
+    });
+
     return NextResponse.json({
-      success: false,
+      success: true,
+      file: {
+        id: savedFile.id,
+        filename: savedFile.filename,
+        mimetype: savedFile.mimetype,
+        size: savedFile.size,
+        createdAt: savedFile.createdAt,
+      },
     });
+  } catch (error) {
+    console.error("Upload error:", error);
+    return NextResponse.json({
+      success: false,
+      error: "Failed to upload file",
+    }, { status: 500 });
+  } finally {
+    await prisma.$disconnect();
   }
-
-  return NextResponse.json({
-    success: true,
-    name: (body.file as File).name,
-  });
-};
+};

+ 3 - 3
app/components/uploadForm.tsx

@@ -19,12 +19,12 @@ export const UploadForm = () => {
 
           const result = await response.json();
           if (result.success) {
-            alert("Upload ok : " + result.name);
+            alert(`Upload successful: ${result.file.filename} (${result.file.size} bytes)`);
           } else {
-            alert("Upload failed");
+            alert(`Upload failed: ${result.error || 'Unknown error'}`);
           }
         }
       }}
     />
   );
-};
+};

+ 31 - 0
docker-compose.yml

@@ -0,0 +1,31 @@
+version: '3.8'
+
+services:
+  postgres:
+    image: postgres:15-alpine
+    restart: always
+    environment:
+      POSTGRES_USER: postgres
+      POSTGRES_PASSWORD: postgres
+      POSTGRES_DB: vtorio
+    ports:
+      - "5432:5432"
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+
+  app:
+    build: .
+    restart: always
+    ports:
+      - "3000:3000"
+    environment:
+      DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/vtorio?schema=public"
+      ROOT_PATH: "/app"
+    depends_on:
+      - postgres
+    volumes:
+      - ./:/app
+      - /app/node_modules
+
+volumes:
+  postgres_data:

+ 260 - 1
package-lock.json

@@ -8,7 +8,10 @@
       "name": "vtorio",
       "version": "0.1.0",
       "dependencies": {
+        "@prisma/client": "^6.11.1",
         "next": "15.1.6",
+        "pg": "^8.16.3",
+        "prisma": "^6.11.1",
         "react": "^19.0.0",
         "react-dom": "^19.0.0"
       },
@@ -876,6 +879,91 @@
         "node": ">=14"
       }
     },
+    "node_modules/@prisma/client": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.1.tgz",
+      "integrity": "sha512-5CLFh8QP6KxRm83pJ84jaVCeSVPQr8k0L2SEtOJHwdkS57/VQDcI/wQpGmdyOZi+D9gdNabdo8tj1Uk+w+upsQ==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "peerDependencies": {
+        "prisma": "*",
+        "typescript": ">=5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "prisma": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@prisma/config": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz",
+      "integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jiti": "2.4.2"
+      }
+    },
+    "node_modules/@prisma/config/node_modules/jiti": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
+      "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/@prisma/debug": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz",
+      "integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@prisma/engines": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz",
+      "integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "6.11.1",
+        "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
+        "@prisma/fetch-engine": "6.11.1",
+        "@prisma/get-platform": "6.11.1"
+      }
+    },
+    "node_modules/@prisma/engines-version": {
+      "version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
+      "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz",
+      "integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@prisma/fetch-engine": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz",
+      "integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "6.11.1",
+        "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
+        "@prisma/get-platform": "6.11.1"
+      }
+    },
+    "node_modules/@prisma/get-platform": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz",
+      "integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "6.11.1"
+      }
+    },
     "node_modules/@rtsao/scc": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -4328,6 +4416,95 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/pg": {
+      "version": "8.16.3",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
+      "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.9.1",
+        "pg-pool": "^3.10.1",
+        "pg-protocol": "^1.10.3",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.2.7"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
+      "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
+      "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
+      "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.10.3",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+      "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4527,6 +4704,45 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
+      "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/prelude-ls": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4537,6 +4753,31 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/prisma": {
+      "version": "6.11.1",
+      "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.1.tgz",
+      "integrity": "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/config": "6.11.1",
+        "@prisma/engines": "6.11.1"
+      },
+      "bin": {
+        "prisma": "build/index.js"
+      },
+      "engines": {
+        "node": ">=18.18"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/prop-types": {
       "version": "15.8.1",
       "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5045,6 +5286,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
     "node_modules/stable-hash": {
       "version": "0.0.4",
       "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz",
@@ -5620,7 +5870,7 @@
       "version": "5.7.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
       "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
-      "dev": true,
+      "devOptional": true,
       "license": "Apache-2.0",
       "bin": {
         "tsc": "bin/tsc",
@@ -5882,6 +6132,15 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
     "node_modules/yaml": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",

+ 10 - 7
package.json

@@ -5,23 +5,26 @@
   "scripts": {
     "dev": "next dev --turbopack",
     "build": "next build",
-    "start": "node .next/standalone/server.js",
+    "start": "next start",
     "lint": "next lint"
   },
   "dependencies": {
+    "@prisma/client": "^6.11.1",
+    "next": "15.1.6",
+    "pg": "^8.16.3",
+    "prisma": "^6.11.1",
     "react": "^19.0.0",
-    "react-dom": "^19.0.0",
-    "next": "15.1.6"
+    "react-dom": "^19.0.0"
   },
   "devDependencies": {
-    "typescript": "^5",
+    "@eslint/eslintrc": "^3",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",
-    "postcss": "^8",
-    "tailwindcss": "^3.4.1",
     "eslint": "^9",
     "eslint-config-next": "15.1.6",
-    "@eslint/eslintrc": "^3"
+    "postcss": "^8",
+    "tailwindcss": "^3.4.1",
+    "typescript": "^5"
   }
 }

+ 12 - 0
prisma/migrations/20250714040412_init/migration.sql

@@ -0,0 +1,12 @@
+-- CreateTable
+CREATE TABLE "File" (
+    "id" TEXT NOT NULL,
+    "filename" TEXT NOT NULL,
+    "mimetype" TEXT NOT NULL,
+    "size" INTEGER NOT NULL,
+    "data" BYTEA NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "File_pkey" PRIMARY KEY ("id")
+);

+ 3 - 0
prisma/migrations/migration_lock.toml

@@ -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"

+ 21 - 0
prisma/schema.prisma

@@ -0,0 +1,21 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+  provider = "prisma-client-js"
+}
+
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+}
+
+model File {
+  id        String   @id @default(cuid())
+  filename  String
+  mimetype  String
+  size      Int
+  data      Bytes    // PostgreSQL ByteA type for blob storage
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+}

+ 49 - 0
test-upload.html

@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>File Upload Test</title>
+</head>
+<body>
+    <h1>File Upload Test</h1>
+    <input type="file" id="fileInput" />
+    <button onclick="uploadFile()">Upload</button>
+    <button onclick="listFiles()">List Files</button>
+    <div id="result"></div>
+
+    <script>
+        async function uploadFile() {
+            const fileInput = document.getElementById('fileInput');
+            const file = fileInput.files[0];
+            if (!file) {
+                alert('Please select a file');
+                return;
+            }
+
+            const formData = new FormData();
+            formData.append('file', file);
+
+            try {
+                const response = await fetch('/api/upload', {
+                    method: 'POST',
+                    body: formData,
+                });
+
+                const result = await response.json();
+                document.getElementById('result').innerHTML = JSON.stringify(result, null, 2);
+            } catch (error) {
+                document.getElementById('result').innerHTML = 'Error: ' + error.message;
+            }
+        }
+
+        async function listFiles() {
+            try {
+                const response = await fetch('/api/files');
+                const result = await response.json();
+                document.getElementById('result').innerHTML = JSON.stringify(result, null, 2);
+            } catch (error) {
+                document.getElementById('result').innerHTML = 'Error: ' + error.message;
+            }
+        }
+    </script>
+</body>
+</html>