diff --git a/.gitignore b/.gitignore index 5ef6a52..f390d12 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..5886b83 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,115 @@ +Boat — Local Development Runbook + +This runbook explains how to run the Boat MVP locally, seed demo data, and troubleshoot common issues. + +Project layout highlights +- [app.page()](src/app/page.tsx:1) — Graph landing page that fetches /api/graph and renders the Cytoscape graph +- [components/Graph.tsx](src/components/Graph.tsx:1) — Cytoscape wrapper with zoom/pan, fit, and selection details +- [components/PersonForm.tsx](src/components/PersonForm.tsx:1) — Create person form +- [components/PeopleSelect.tsx](src/components/PeopleSelect.tsx:1) — Typeahead selector against /api/people +- [components/ConnectionForm.tsx](src/components/ConnectionForm.tsx:1) — Create connection form with ordered introducer chain +- [app.people.new.page()](src/app/people/new/page.tsx:1) — Page for adding a person +- [app.connections.new.page()](src/app/connections/new/page.tsx:1) — Page for adding a connection +- [api.people.route()](src/app/api/people/route.ts:1), [api.people.id.route()](src/app/api/people/[id]/route.ts:1) — People API +- [api.connections.route()](src/app/api/connections/route.ts:1), [api.connections.id.route()](src/app/api/connections/[id]/route.ts:1) — Connections API +- [api.graph.route()](src/app/api/graph/route.ts:1) — Graph snapshot API +- [lib.db()](src/lib/db.ts:1) — Prisma client singleton +- [lib.validators()](src/lib/validators.ts:1) — Zod schemas +- [prisma.schema()](prisma/schema.prisma:1) — Data model +- [prisma.migration.sql](prisma/migrations/20251114191515_connection_pair_constraints/migration.sql:1) — Undirected uniqueness + self-edge constraints +- [prisma.seed()](prisma/seed.ts:1) — Seed script for 20 demo people and ~30 connections + +Prerequisites +- Docker (to run local Postgres quickly) +- Node 20+ and npm + +1) Start Postgres (Docker) +If you haven’t yet started the Docker Postgres container, run: +- docker volume create boat-pgdata +- docker run -d --name boat-postgres -p 5432:5432 -e POSTGRES_DB=boat -e POSTGRES_USER=boat -e POSTGRES_PASSWORD=boat -v boat-pgdata:/var/lib/postgresql/data postgres:16 + +Verify it’s running: +- docker ps | grep boat-postgres + +2) Environment variables +This repo already includes [.env](.env:1) pointing to the Docker Postgres: +DATABASE_URL="postgresql://boat:boat@localhost:5432/boat?schema=public" + +3) Install deps and generate Prisma Client +From the app directory: +- npm install +- npx prisma generate + +4) Migrate the database +- npx prisma migrate dev --name init +- npx prisma migrate dev (if new migrations were added) + +Note: Constraints for undirected uniqueness and self-edges are included in [prisma.migration.sql](prisma/migrations/20251114191515_connection_pair_constraints/migration.sql:1). + +5) Seed demo data (20 people) +- npm run db:seed + +This executes [prisma.seed()](prisma/seed.ts:1) and inserts people and connections. + +6) Run the Next.js dev server +Important: run from the app directory, not workspace root. + +- npm run dev + +Access: +- http://localhost:3000 (or the alternate port if 3000 is busy) + +7) Using the app +- Landing page shows the global graph +- Toolbar buttons: + - Add Person → [app.people.new.page()](src/app/people/new/page.tsx:1) + - Add Connection → [app.connections.new.page()](src/app/connections/new/page.tsx:1) + - Reload — refetches data for the graph +- Click nodes or edges to show details in the side panel + +8) API quick tests +- List people: + - curl "http://localhost:3000/api/people?limit=10" +- Create person: + - curl -X POST "http://localhost:3000/api/people" -H "Content-Type: application/json" -d '{"name":"Jane Demo","sectors":["agriculture"]}' +- Create/update connection (undirected): + - curl -X POST "http://localhost:3000/api/connections" -H "Content-Type: application/json" -d '{"personAId":"","personBId":"","introducedByChain":[],"eventLabels":["event:demo"]}' + +Troubleshooting + +A) “npm enoent Could not read package.json at /home/maxime/boat/package.json” +- You ran npm in the workspace root. Use the app directory: + - cd boat-web + - npm run dev + +B) “Unable to acquire lock … .next/dev/lock” +- Another Next dev instance is running or a stale lock exists. + - Kill dev: pkill -f "next dev" (Unix) + - Remove lock: rm -f .next/dev/lock + - Then: npm run dev + +C) “Failed to load external module @prisma/client … cannot find module '.prisma/client/default'” +- Prisma client must be generated after schema changes or misconfigured generator. + - Ensure generator in [prisma.schema()](prisma/schema.prisma:7) is: + generator client { provider = "prisma-client-js" } + - Regenerate: npx prisma generate + - If still failing, remove stale output and regenerate: + - rm -rf node_modules/.prisma + - npx prisma generate + +D) Port 3000 already in use +- Run on a different port: + - npm run dev -- -p 3001 + +Tech notes +- The undirected edge uniqueness is enforced via functional unique index on LEAST/GREATEST and a no-self-edge CHECK in [prisma.migration.sql](prisma/migrations/20251114191515_connection_pair_constraints/migration.sql:1). +- Deleting a person cascades to connections (MVP behavior). +- Sectors, interests, and eventLabels are free-text arrays (TEXT[]). +- Introduced-by chain is an ordered list of person IDs (existing people only). +- UI intentionally minimal and open as per MVP brief. + +Acceptance checklist mapping +- Create person: [api.people.route()](src/app/api/people/route.ts:1) + [PersonForm.tsx](src/components/PersonForm.tsx:1) ✔ +- Create connection: [api.connections.route()](src/app/api/connections/route.ts:1) + [ConnectionForm.tsx](src/components/ConnectionForm.tsx:1) ✔ +- Global graph view: [api.graph.route()](src/app/api/graph/route.ts:1) + [Graph.tsx](src/components/Graph.tsx:1) ✔ +- Persistence: Postgres via Prisma ✔ diff --git a/package-lock.json b/package-lock.json index eec4346..0972b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,14 @@ "name": "boat-web", "version": "0.1.0", "dependencies": { + "@prisma/client": "6.19.0", + "@tanstack/react-query": "5.90.9", + "cytoscape": "3.33.1", "next": "16.0.3", + "prisma": "6.19.0", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "zod": "4.1.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -20,6 +25,7 @@ "eslint": "^9", "eslint-config-next": "16.0.3", "tailwindcss": "^4", + "tsx": "^4", "typescript": "^5" } }, @@ -310,6 +316,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -1227,6 +1675,85 @@ "node": ">=12.4.0" } }, + "node_modules/@prisma/client": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", + "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", + "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.19.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", + "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", + "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", + "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/fetch-engine": "6.19.0", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", + "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", + "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", + "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1234,6 +1761,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1514,6 +2047,32 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.9.tgz", + "integrity": "sha512-UFOCQzi6pRGeVTVlPNwNdnAvT35zugcIydqjvFUzG62dvz2iVjElmNp/hJkUoM5eqbUPfSU/GJIr/wbvD8bTUw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.9.tgz", + "integrity": "sha512-Zke2AaXiaSfnG8jqPZR52m8SsclKT2d9//AgE/QIzyNvbpj/Q2ln+FsZjb1j69bJZUouBvX2tg9PHirkTm8arw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2511,6 +3070,34 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2608,6 +3195,30 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2641,6 +3252,21 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2670,6 +3296,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2756,6 +3391,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2792,6 +3436,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2815,6 +3471,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2830,6 +3498,16 @@ "node": ">= 0.4" } }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.252", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.252.tgz", @@ -2844,6 +3522,15 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -3035,6 +3722,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3250,6 +3979,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3483,6 +4213,34 @@ "node": ">=0.10.0" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3624,6 +4382,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3755,6 +4528,23 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4439,7 +5229,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -5074,6 +5863,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5081,6 +5876,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5204,6 +6018,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5312,6 +6132,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5331,6 +6163,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5380,6 +6223,32 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", + "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/config": "6.19.0", + "@prisma/engines": "6.19.0" + }, + "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", @@ -5402,6 +6271,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5423,6 +6308,16 @@ ], "license": "MIT" }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -5453,6 +6348,19 @@ "dev": true, "license": "MIT" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6086,6 +6994,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6193,6 +7110,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6288,7 +7225,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -6564,7 +7501,6 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", - "dev": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index b9ed79f..c41a221 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,25 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "db:seed": "prisma db seed" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@prisma/client": "6.19.0", + "@tanstack/react-query": "5.90.9", + "cytoscape": "3.33.1", "next": "16.0.3", + "prisma": "6.19.0", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "zod": "4.1.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -21,6 +34,7 @@ "eslint": "^9", "eslint-config-next": "16.0.3", "tailwindcss": "^4", + "tsx": "^4", "typescript": "^5" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..2592d12 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,13 @@ +import "dotenv/config"; +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + engine: "classic", + datasource: { + url: env("DATABASE_URL"), + }, +}); diff --git a/prisma/migrations/20251114190833_init/migration.sql b/prisma/migrations/20251114190833_init/migration.sql new file mode 100644 index 0000000..0320d71 --- /dev/null +++ b/prisma/migrations/20251114190833_init/migration.sql @@ -0,0 +1,43 @@ +-- CreateTable +CREATE TABLE "Person" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "company" TEXT, + "role" TEXT, + "email" TEXT, + "location" TEXT, + "sectors" TEXT[] DEFAULT ARRAY[]::TEXT[], + "interests" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Person_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Connection" ( + "id" TEXT NOT NULL, + "personAId" TEXT NOT NULL, + "personBId" TEXT NOT NULL, + "introducedByChain" TEXT[] DEFAULT ARRAY[]::TEXT[], + "eventLabels" TEXT[] DEFAULT ARRAY[]::TEXT[], + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Connection_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Person_name_idx" ON "Person"("name"); + +-- CreateIndex +CREATE INDEX "Connection_personAId_idx" ON "Connection"("personAId"); + +-- CreateIndex +CREATE INDEX "Connection_personBId_idx" ON "Connection"("personBId"); + +-- AddForeignKey +ALTER TABLE "Connection" ADD CONSTRAINT "Connection_personAId_fkey" FOREIGN KEY ("personAId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Connection" ADD CONSTRAINT "Connection_personBId_fkey" FOREIGN KEY ("personBId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251114191515_connection_pair_constraints/migration.sql b/prisma/migrations/20251114191515_connection_pair_constraints/migration.sql new file mode 100644 index 0000000..74c3ba5 --- /dev/null +++ b/prisma/migrations/20251114191515_connection_pair_constraints/migration.sql @@ -0,0 +1,14 @@ +-- Enforce undirected connection uniqueness and prevent self-edges + +-- Prevent self-edge (A == B) +ALTER TABLE "Connection" + ADD CONSTRAINT "Connection_no_self_edge" + CHECK ("personAId" <> "personBId"); + +-- Unique undirected pair using functional index on LEAST/GREATEST +-- Ensures only one edge exists for a given unordered pair {A,B} +CREATE UNIQUE INDEX IF NOT EXISTS "Connection_undirected_pair_unique" +ON "Connection" ( + (LEAST("personAId","personBId")), + (GREATEST("personAId","personBId")) +); \ No newline at end of file diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..b5534e4 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,51 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Person { + id String @id @default(uuid()) + name String + company String? + role String? + email String? + location String? + sectors String[] @default([]) + interests String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations (undirected connections modeled as two directed FKs) + connectionsA Connection[] @relation("ConnectionsA") + connectionsB Connection[] @relation("ConnectionsB") + + @@index([name]) +} + +model Connection { + id String @id @default(uuid()) + personAId String + personBId String + personA Person @relation("ConnectionsA", fields: [personAId], references: [id], onDelete: Cascade) + personB Person @relation("ConnectionsB", fields: [personBId], references: [id], onDelete: Cascade) + introducedByChain String[] @default([]) + eventLabels String[] @default([]) + notes String? + createdAt DateTime @default(now()) + + @@index([personAId]) + @@index([personBId]) + // Uniqueness of undirected pair (A,B) and self-edge prevention enforced via SQL migration with + // a functional unique index on (LEAST(personAId, personBId), GREATEST(personAId, personBId)) + // and a CHECK (personAId <> personBId). +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..d685802 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,138 @@ +// prisma/seed.ts +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +type SeedPerson = { + name: string; + company?: string; + role?: string; + email?: string; + location?: string; + sectors?: string[]; + interests?: string[]; +}; + +const people: SeedPerson[] = [ + { name: "Alex Carter", company: "GreenFields", role: "Founder", email: "alex@greenfields.io", location: "Brussels", sectors: ["agriculture", "sustainability"], interests: ["funding", "network"] }, + { name: "John Miller", company: "BuildRight", role: "PM", email: "john@buildright.eu", location: "Amsterdam", sectors: ["construction"], interests: ["knowledge", "network"] }, + { name: "Mark Li", company: "BioPharmX", role: "Analyst", email: "mark@biopharmx.com", location: "Zurich", sectors: ["pharma"], interests: ["team", "network"] }, + { name: "Sara Gomez", company: "AeroNext", role: "VP BizDev", email: "sara@aeronext.ai", location: "Madrid", sectors: ["aerospace", "ai"], interests: ["funding"] }, + { name: "Priya Nair", company: "AgriSense", role: "CTO", email: "priya@agrisense.io", location: "Bangalore", sectors: ["agriculture", "iot"], interests: ["network", "knowledge"] }, + { name: "Luca Moretti", company: "Constructa", role: "Engineer", email: "luca@constructa.it", location: "Milan", sectors: ["construction"], interests: ["team"] }, + { name: "Emily Chen", company: "FinScope", role: "Investor", email: "emily@finscope.vc", location: "London", sectors: ["finance"], interests: ["where to invest"] }, + { name: "David Kim", company: "HealthBridge", role: "Founder", email: "david@healthbridge.io", location: "Seoul", sectors: ["healthcare", "pharma"], interests: ["funding", "network"] }, + { name: "Nina Petrov", company: "EcoLogix", role: "Consultant", email: "nina@ecologix.de", location: "Berlin", sectors: ["sustainability"], interests: ["knowledge"] }, + { name: "Omar Hassan", company: "BuildHub", role: "Architect", email: "omar@buildhub.me", location: "Dubai", sectors: ["construction"], interests: ["network"] }, + { name: "Isabella Rossi", company: "AgriCo", role: "Ops Lead", email: "isabella@agrico.it", location: "Rome", sectors: ["agriculture"], interests: ["team"] }, + { name: "Tom Williams", company: "MedNova", role: "Scientist", email: "tom@mednova.uk", location: "Oxford", sectors: ["pharma", "biotech"], interests: ["knowledge", "team"] }, + { name: "Chen Wei", company: "SkyLink", role: "Systems Eng", email: "chen@skylink.cn", location: "Shanghai", sectors: ["aerospace"], interests: ["network"] }, + { name: "Ana Silva", company: "GreenWave", role: "Analyst", email: "ana@greenwave.pt", location: "Lisbon", sectors: ["sustainability", "energy"], interests: ["where to invest"] }, + { name: "Michael Brown", company: "FinBridge", role: "Partner", email: "michael@finbridge.vc", location: "New York", sectors: ["finance"], interests: ["where to invest", "network"] }, + { name: "Yuki Tanaka", company: "BioCore", role: "Researcher", email: "yuki@biocore.jp", location: "Tokyo", sectors: ["biotech"], interests: ["knowledge", "team"] }, + { name: "Fatima Zahra", company: "AgriRoot", role: "Founder", email: "fatima@agriroot.ma", location: "Casablanca", sectors: ["agriculture"], interests: ["funding", "network"] }, + { name: "Peter Novak", company: "BuildSmart", role: "Engineer", email: "peter@buildsmart.cz", location: "Prague", sectors: ["construction", "iot"], interests: ["knowledge"] }, + { name: "Sofia Anders", company: "NordPharm", role: "PM", email: "sofia@nordpharm.se", location: "Stockholm", sectors: ["pharma"], interests: ["network"] }, + { name: "Rafael Diaz", company: "AeroLab", role: "Founder", email: "rafael@aerolab.mx", location: "Mexico City", sectors: ["aerospace"], interests: ["funding", "team"] }, +]; + +function pairKey(a: string, b: string) { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +async function main() { + console.log("Seeding database…"); + + // Reset (dev only) + await prisma.connection.deleteMany({}); + await prisma.person.deleteMany({}); + + // Create people + const created = await Promise.all( + people.map((p) => + prisma.person.create({ + data: { + name: p.name, + company: p.company ?? null, + role: p.role ?? null, + email: p.email ?? null, + location: p.location ?? null, + sectors: p.sectors ?? [], + interests: p.interests ?? [], + }, + select: { id: true, name: true }, + }) + ) + ); + + const ids = created.map((c) => c.id); + const idByName = new Map(created.map((c) => [c.name, c.id])); + + // Create a set of sample undirected connections (about 28-32) + const targetEdges = Math.min(32, Math.floor((ids.length * (ids.length - 1)) / 6)); + const used = new Set(); + const rnd = (n: number) => Math.floor(Math.random() * n); + + const edges: { + a: string; + b: string; + introducedByChain: string[]; + eventLabels: string[]; + notes?: string | null; + }[] = []; + + let guard = 0; + while (edges.length < targetEdges && guard < 5000) { + guard++; + const i = rnd(ids.length); + let j = rnd(ids.length); + if (j === i) continue; + const a = ids[i]; + const b = ids[j]; + const key = pairKey(a, b); + if (used.has(key)) continue; + used.add(key); + + // 50% add a single introducer different from a and b + let introducedByChain: string[] = []; + if (Math.random() < 0.5 && ids.length > 2) { + let k = rnd(ids.length); + let guard2 = 0; + while ((k === i || k === j) && guard2 < 100) { + k = rnd(ids.length); + guard2++; + } + if (k !== i && k !== j) { + introducedByChain = [ids[k]]; + } + } + + const eventLabels = Math.random() < 0.4 ? ["event:demo"] : []; + edges.push({ a, b, introducedByChain, eventLabels, notes: null }); + } + + for (const e of edges) { + await prisma.connection.create({ + data: { + personAId: e.a, + personBId: e.b, + introducedByChain: e.introducedByChain, + eventLabels: e.eventLabels, + notes: e.notes ?? null, + }, + }); + } + + console.log(`Inserted ${created.length} people and ${edges.length} connections.`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); \ No newline at end of file diff --git a/src/app/api/connections/[id]/route.ts b/src/app/api/connections/[id]/route.ts new file mode 100644 index 0000000..43e8f41 --- /dev/null +++ b/src/app/api/connections/[id]/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/db"; +import { connectionUpdateSchema } from "@/lib/validators"; + +// GET /api/connections/[id] +export async function GET( + _req: Request, + context: { params: { id: string } } +) { + try { + const { id } = context.params; + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const connection = await prisma.connection.findUnique({ + where: { id }, + }); + + if (!connection) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + return NextResponse.json(connection, { status: 200 }); + } catch (err) { + console.error("GET /api/connections/[id] error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// PATCH /api/connections/[id] +// Only metadata is updatable in MVP (introducedByChain, eventLabels, notes) +export async function PATCH( + req: Request, + context: { params: { id: string } } +) { + try { + const { id } = context.params; + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const json = await req.json().catch(() => ({})); + const parsed = connectionUpdateSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { introducedByChain, eventLabels, notes } = parsed.data; + + const updated = await prisma.connection.update({ + where: { id }, + data: { + introducedByChain: introducedByChain === undefined ? undefined : introducedByChain, + eventLabels: eventLabels === undefined ? undefined : eventLabels, + notes: notes === undefined ? undefined : notes, + }, + }); + + return NextResponse.json(updated, { status: 200 }); + } catch (err: any) { + if (err?.code === "P2025") { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + console.error("PATCH /api/connections/[id] error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// DELETE /api/connections/[id] +export async function DELETE( + _req: Request, + context: { params: { id: string } } +) { + try { + const { id } = context.params; + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + await prisma.connection.delete({ + where: { id }, + }); + + return new NextResponse(null, { status: 204 }); + } catch (err: any) { + if (err?.code === "P2025") { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + console.error("DELETE /api/connections/[id] error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/connections/route.ts b/src/app/api/connections/route.ts new file mode 100644 index 0000000..66d05c3 --- /dev/null +++ b/src/app/api/connections/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/db"; +import { connectionCreateSchema } from "@/lib/validators"; + +// GET /api/connections +// Optional query: personId (filters connections that include this person) +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const personId = searchParams.get("personId") ?? undefined; + + const where = personId + ? { + OR: [{ personAId: personId }, { personBId: personId }], + } + : undefined; + + const connections = await prisma.connection.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(connections, { status: 200 }); + } catch (err) { + console.error("GET /api/connections error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// POST /api/connections +// Body: { personAId, personBId, introducedByChain?, eventLabels?, notes? } +// Behavior: undirected. If pair exists (in any order), update metadata; else create new. +export async function POST(req: Request) { + try { + const json = await req.json().catch(() => ({})); + const parsed = connectionCreateSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { + personAId, + personBId, + introducedByChain = [], + eventLabels = [], + notes, + } = parsed.data; + + if (personAId === personBId) { + return NextResponse.json( + { error: "personAId and personBId must be different" }, + { status: 400 } + ); + } + + // Verify both persons exist + const [a, b] = await Promise.all([ + prisma.person.findUnique({ where: { id: personAId } }), + prisma.person.findUnique({ where: { id: personBId } }), + ]); + if (!a || !b) { + return NextResponse.json({ error: "Person not found" }, { status: 404 }); + } + + // Find existing connection regardless of order + const existing = await prisma.connection.findFirst({ + where: { + OR: [ + { personAId, personBId }, + { personAId: personBId, personBId: personAId }, + ], + }, + }); + + if (existing) { + const updated = await prisma.connection.update({ + where: { id: existing.id }, + data: { + introducedByChain, + eventLabels, + notes: notes ?? null, + }, + }); + return NextResponse.json(updated, { status: 200 }); + } + + const created = await prisma.connection.create({ + data: { + personAId, + personBId, + introducedByChain, + eventLabels, + notes: notes ?? null, + }, + }); + return NextResponse.json(created, { status: 201 }); + } catch (err: any) { + // If we later add a functional unique index, handle conflicts here (e.g., P2002) + if (err?.code === "P2002") { + return NextResponse.json( + { error: "Connection already exists" }, + { status: 409 } + ); + } + console.error("POST /api/connections error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/graph/route.ts b/src/app/api/graph/route.ts new file mode 100644 index 0000000..4dcaa62 --- /dev/null +++ b/src/app/api/graph/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/db"; + +// GET /api/graph +// Returns DTO: { nodes: [{id,label,sectors,company,role}], edges: [{id,source,target,introducedByCount,hasProvenance}] } +export async function GET() { + try { + const [people, connections] = await Promise.all([ + prisma.person.findMany(), + prisma.connection.findMany(), + ]); + + const nodes = people.map((p: any) => ({ + id: p.id, + label: p.name, + sectors: p.sectors, + company: p.company, + role: p.role, + })); + + const edges = connections.map((c: any) => ({ + id: c.id, + source: c.personAId, + target: c.personBId, + introducedByCount: c.introducedByChain.length, + hasProvenance: c.introducedByChain.length > 0, + })); + + return NextResponse.json({ nodes, edges }, { status: 200 }); + } catch (err) { + console.error("GET /api/graph error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/people/[id]/route.ts b/src/app/api/people/[id]/route.ts new file mode 100644 index 0000000..391fca4 --- /dev/null +++ b/src/app/api/people/[id]/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/db"; +import { personUpdateSchema } from "@/lib/validators"; + +// GET /api/people/[id] +export async function GET( + _req: Request, + context: { params: { id: string } } +) { + try { + const { id } = context.params; + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const person = await prisma.person.findUnique({ + where: { id }, + }); + + if (!person) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + return NextResponse.json(person, { status: 200 }); + } catch (err) { + console.error("GET /api/people/[id] error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// PATCH /api/people/[id] +export async function PATCH( + req: Request, + context: { params: { id: string } } +) { + try { + const { id } = context.params; + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const body = await req.json().catch(() => ({})); + const parsed = personUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { + name, + company, + role, + email, + location, + sectors, + interests, + } = parsed.data; + + const updated = await prisma.person.update({ + where: { id }, + data: { + name: name ?? undefined, + company: company === undefined ? undefined : company ?? null, + role: role === undefined ? undefined : role ?? null, + email: email === undefined ? undefined : email ?? null, + location: location === undefined ? undefined : location ?? null, + sectors: sectors === undefined ? undefined : sectors, + interests: interests === undefined ? undefined : interests, + }, + }); + + return NextResponse.json(updated, { status: 200 }); + } catch (err: any) { + if (err?.code === "P2025") { + // Prisma: record not found + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + console.error("PATCH /api/people/[id] error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// DELETE /api/people/[id] +// Cascading delete: connections referencing this person are removed via ON DELETE CASCADE +export async function DELETE( + _req: Request, + context: { params: { id: string } } +) { + try { + const { id } = context.params; + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + await prisma.person.delete({ + where: { id }, + }); + + return new NextResponse(null, { status: 204 }); + } catch (err: any) { + if (err?.code === "P2025") { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + console.error("DELETE /api/people/[id] error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/people/route.ts b/src/app/api/people/route.ts new file mode 100644 index 0000000..9ed4954 --- /dev/null +++ b/src/app/api/people/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/db"; +import { peopleListQuerySchema, personCreateSchema } from "@/lib/validators"; + +// GET /api/people +// Query params: q?: string (search by name), limit?: number (1..100, default 50) +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const parsed = peopleListQuerySchema.safeParse({ + q: searchParams.get("q") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + }); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query parameters", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + const { q, limit = 50 } = parsed.data; + + const people = await prisma.person.findMany({ + where: q + ? { + name: { + contains: q, + mode: "insensitive", + }, + } + : undefined, + orderBy: { name: "asc" }, + take: limit, + }); + + return NextResponse.json(people, { status: 200 }); + } catch (err) { + console.error("GET /api/people error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// POST /api/people +// Body: PersonCreate +export async function POST(req: Request) { + try { + const json = await req.json().catch(() => ({})); + const parsed = personCreateSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + const { name, company, role, email, location, sectors = [], interests = [] } = parsed.data; + + const created = await prisma.person.create({ + data: { + name, + company: company ?? null, + role: role ?? null, + email: email ?? null, + location: location ?? null, + sectors, + interests, + }, + }); + + return NextResponse.json(created, { status: 201 }); + } catch (err) { + console.error("POST /api/people error", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/connections/new/page.tsx b/src/app/connections/new/page.tsx new file mode 100644 index 0000000..c25facd --- /dev/null +++ b/src/app/connections/new/page.tsx @@ -0,0 +1,19 @@ +import ConnectionForm from "@/components/ConnectionForm"; + +export const metadata = { + title: "Add Connection - Boat", + description: "Create a new connection in the global graph", +}; + +export default function NewConnectionPage() { + return ( +
+
+

Add Connection

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..d1d66f1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Providers from "./providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Boat", + description: "Boat — global graph of people and connections", }; export default function RootLayout({ @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..3f576dc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,62 @@ -import Image from "next/image"; +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import Graph from "@/components/Graph"; +import type { GraphResponseDTO } from "@/lib/validators"; export default function Home() { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["graph"], + queryFn: async () => { + const res = await fetch("/api/graph"); + if (!res.ok) throw new Error("Failed to load graph"); + return res.json(); + }, + }); + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. +
+
+
+

+ Boat — Global Graph

-

- Looking for a starting point or more instructions? Head over to{" "} - + - Templates - {" "} - or the{" "} - + - Learning - {" "} - center. -

+ Add Connection + + +
- -

+ + {isLoading && ( +
+ Loading graph… +
+ )} + {error && ( +
+ Failed to load graph +
+ )} + {data && } +
); } diff --git a/src/app/people/new/page.tsx b/src/app/people/new/page.tsx new file mode 100644 index 0000000..e8fa279 --- /dev/null +++ b/src/app/people/new/page.tsx @@ -0,0 +1,19 @@ +import PersonForm from "@/components/PersonForm"; + +export const metadata = { + title: "Add Person - Boat", + description: "Create a new person in the global graph", +}; + +export default function NewPersonPage() { + return ( +
+
+

Add Person

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..6d6c4bc --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode, useState } from "react"; + +export default function Providers({ children }: { children: ReactNode }) { + const [client] = useState(() => new QueryClient()); + return {children}; +} \ No newline at end of file diff --git a/src/components/ConnectionForm.tsx b/src/components/ConnectionForm.tsx new file mode 100644 index 0000000..de57c26 --- /dev/null +++ b/src/components/ConnectionForm.tsx @@ -0,0 +1,205 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import PeopleSelect from "@/components/PeopleSelect"; + +type Selected = { id: string; name: string } | null; + +function splitCsv(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export default function ConnectionForm() { + const router = useRouter(); + + const [personA, setPersonA] = useState(null); + const [personB, setPersonB] = useState(null); + + const [introducerPick, setIntroducerPick] = useState(null); + const [introducedBy, setIntroducedBy] = useState([]); + + const [eventLabelsText, setEventLabelsText] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const canAddIntroducer = useMemo(() => { + if (!introducerPick) return false; + if (personA && introducerPick.id === personA.id) return false; + if (personB && introducerPick.id === personB.id) return false; + if (introducedBy.find((p) => p && p.id === introducerPick.id)) return false; + return true; + }, [introducerPick, introducedBy, personA, personB]); + + function addIntroducer() { + if (!introducerPick) return; + if (!canAddIntroducer) return; + setIntroducedBy((list) => [...list, introducerPick]); + setIntroducerPick(null); + } + + function removeIntroducer(id: string) { + setIntroducedBy((list) => list.filter((p) => p?.id !== id)); + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + setSuccess(null); + + if (!personA || !personB) { + setError("Select both Person A and Person B"); + setSubmitting(false); + return; + } + if (personA.id === personB.id) { + setError("Person A and Person B must be different"); + setSubmitting(false); + return; + } + + const payload = { + personAId: personA.id, + personBId: personB.id, + introducedByChain: introducedBy.filter(Boolean).map((p) => (p as Selected)!.id), + eventLabels: splitCsv(eventLabelsText), + notes: notes.trim() || undefined, + }; + + try { + const res = await fetch("/api/connections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j?.error || "Failed to save connection"); + } + setSuccess("Connection saved"); + // Small delay for UX then go home + setTimeout(() => router.push("/"), 600); + } catch (err: any) { + setError(err?.message || "Failed to save connection"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ + +
+ +
+
Introduced-by chain (ordered, optional)
+
+ + +
+ + {introducedBy.length > 0 ? ( +
    + {introducedBy.map((p, idx) => ( +
  1. + + {idx + 1} + {p?.name} + + +
  2. + ))} +
+ ) : ( +
No introducers added
+ )} +
+ +
+
+ + setEventLabelsText(e.target.value)} + className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400" + placeholder="event:demo, meetup, conf2025" + /> +
+
+ +