first MVP
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
115
DEVELOPMENT.md
Normal file
115
DEVELOPMENT.md
Normal file
@@ -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":"<idA>","personBId":"<idB>","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 ✔
|
||||||
944
package-lock.json
generated
944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -6,12 +6,25 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "6.19.0",
|
||||||
|
"@tanstack/react-query": "5.90.9",
|
||||||
|
"cytoscape": "3.33.1",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"prisma": "6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"zod": "4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -21,6 +34,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
prisma.config.ts
Normal file
13
prisma.config.ts
Normal file
@@ -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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
43
prisma/migrations/20251114190833_init/migration.sql
Normal file
43
prisma/migrations/20251114190833_init/migration.sql
Normal file
@@ -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;
|
||||||
@@ -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"))
|
||||||
|
);
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
51
prisma/schema.prisma
Normal file
51
prisma/schema.prisma
Normal file
@@ -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).
|
||||||
|
}
|
||||||
138
prisma/seed.ts
Normal file
138
prisma/seed.ts
Normal file
@@ -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<string>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
96
src/app/api/connections/[id]/route.ts
Normal file
96
src/app/api/connections/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/app/api/connections/route.ts
Normal file
111
src/app/api/connections/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/graph/route.ts
Normal file
34
src/app/api/graph/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/app/api/people/[id]/route.ts
Normal file
109
src/app/api/people/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/people/route.ts
Normal file
73
src/app/api/people/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/connections/new/page.tsx
Normal file
19
src/app/connections/new/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black p-4">
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Add Connection</h1>
|
||||||
|
<div className="rounded border border-zinc-200 bg-white p-4">
|
||||||
|
<ConnectionForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Providers from "./providers";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Boat",
|
||||||
description: "Generated by create next app",
|
description: "Boat — global graph of people and connections",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -27,7 +28,7 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
107
src/app/page.tsx
107
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() {
|
export default function Home() {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery<GraphResponseDTO>({
|
||||||
|
queryKey: ["graph"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/graph");
|
||||||
|
if (!res.ok) throw new Error("Failed to load graph");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="min-h-screen bg-zinc-50 dark:bg-black p-4">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="mx-auto max-w-6xl">
|
||||||
<Image
|
<div className="mb-4 flex items-center gap-2">
|
||||||
className="dark:invert"
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
src="/next.svg"
|
Boat — Global Graph
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<div className="ml-auto flex gap-2">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
<Link
|
||||||
<a
|
href="/people/new"
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50"
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
>
|
||||||
Templates
|
Add Person
|
||||||
</a>{" "}
|
</Link>
|
||||||
or the{" "}
|
<Link
|
||||||
<a
|
href="/connections/new"
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50"
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
>
|
||||||
Learning
|
Add Connection
|
||||||
</a>{" "}
|
</Link>
|
||||||
center.
|
<button
|
||||||
</p>
|
onClick={() => refetch()}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1 text-sm hover:bg-zinc-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
{isLoading && (
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<div className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
Loading graph…
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
>
|
{error && (
|
||||||
<Image
|
<div className="text-sm text-red-600">
|
||||||
className="dark:invert"
|
Failed to load graph
|
||||||
src="/vercel.svg"
|
</div>
|
||||||
alt="Vercel logomark"
|
)}
|
||||||
width={16}
|
{data && <Graph data={data} height={600} />}
|
||||||
height={16}
|
</div>
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/app/people/new/page.tsx
Normal file
19
src/app/people/new/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black p-4">
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Add Person</h1>
|
||||||
|
<div className="rounded border border-zinc-200 bg-white p-4">
|
||||||
|
<PersonForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/providers.tsx
Normal file
9
src/app/providers.tsx
Normal file
@@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
205
src/components/ConnectionForm.tsx
Normal file
205
src/components/ConnectionForm.tsx
Normal file
@@ -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<Selected>(null);
|
||||||
|
const [personB, setPersonB] = useState<Selected>(null);
|
||||||
|
|
||||||
|
const [introducerPick, setIntroducerPick] = useState<Selected>(null);
|
||||||
|
const [introducedBy, setIntroducedBy] = useState<Selected[]>([]);
|
||||||
|
|
||||||
|
const [eventLabelsText, setEventLabelsText] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(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 (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<PeopleSelect
|
||||||
|
label="From (Person A)"
|
||||||
|
value={personA}
|
||||||
|
onChange={setPersonA}
|
||||||
|
placeholder="Search name…"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<PeopleSelect
|
||||||
|
label="To (Person B)"
|
||||||
|
value={personB}
|
||||||
|
onChange={setPersonB}
|
||||||
|
placeholder="Search name…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-zinc-200 p-3">
|
||||||
|
<div className="mb-2 text-sm font-medium text-zinc-700">Introduced-by chain (ordered, optional)</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_auto]">
|
||||||
|
<PeopleSelect
|
||||||
|
label="Add introducer"
|
||||||
|
value={introducerPick}
|
||||||
|
onChange={setIntroducerPick}
|
||||||
|
placeholder="Search introducer…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canAddIntroducer}
|
||||||
|
onClick={addIntroducer}
|
||||||
|
className="h-9 self-end rounded border border-zinc-300 px-3 text-sm hover:bg-zinc-50 disabled:opacity-50"
|
||||||
|
aria-disabled={!canAddIntroducer}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{introducedBy.length > 0 ? (
|
||||||
|
<ol className="mt-2 space-y-1 text-sm">
|
||||||
|
{introducedBy.map((p, idx) => (
|
||||||
|
<li key={p?.id ?? idx} className="flex items-center justify-between rounded border border-zinc-200 px-2 py-1">
|
||||||
|
<span>
|
||||||
|
<span className="mr-2 rounded bg-zinc-100 px-1 text-xs text-zinc-600">{idx + 1}</span>
|
||||||
|
{p?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => p?.id && removeIntroducer(p.id)}
|
||||||
|
className="rounded px-2 py-1 text-xs text-zinc-600 hover:bg-zinc-100"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">No introducers added</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Event labels (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eventLabelsText}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="h-[42px] w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="Short note (optional)"
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">{success}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !personA || !personB || personA.id === personB.id}
|
||||||
|
className="rounded bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="rounded border border-zinc-300 px-4 py-2 text-sm hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/components/Graph.tsx
Normal file
260
src/components/Graph.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import cytoscape, { Core } from "cytoscape";
|
||||||
|
|
||||||
|
type GraphNodeDTO = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sectors: string[];
|
||||||
|
company: string | null;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphEdgeDTO = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
introducedByCount: number;
|
||||||
|
hasProvenance: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphData = {
|
||||||
|
nodes: GraphNodeDTO[];
|
||||||
|
edges: GraphEdgeDTO[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: GraphData;
|
||||||
|
height?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Graph({ data, height = 600 }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const cyRef = useRef<Core | null>(null);
|
||||||
|
|
||||||
|
const [selectedNode, setSelectedNode] = useState<GraphNodeDTO | null>(null);
|
||||||
|
const [selectedEdge, setSelectedEdge] = useState<GraphEdgeDTO | null>(null);
|
||||||
|
|
||||||
|
const elements = useMemo(() => {
|
||||||
|
const nodes = data.nodes.map((n) => ({
|
||||||
|
data: {
|
||||||
|
id: n.id,
|
||||||
|
label: n.label,
|
||||||
|
company: n.company,
|
||||||
|
role: n.role,
|
||||||
|
sectors: n.sectors,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const edges = data.edges.map((e) => ({
|
||||||
|
data: {
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
introducedByCount: e.introducedByCount,
|
||||||
|
hasProvenance: e.hasProvenance,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { nodes, edges };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// Destroy previous instance if any
|
||||||
|
if (cyRef.current) {
|
||||||
|
cyRef.current.destroy();
|
||||||
|
cyRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cy = cytoscape({
|
||||||
|
container: containerRef.current,
|
||||||
|
elements: {
|
||||||
|
nodes: elements.nodes,
|
||||||
|
edges: elements.edges,
|
||||||
|
},
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
selector: "node",
|
||||||
|
style: {
|
||||||
|
"background-color": "#0ea5e9", // sky-500
|
||||||
|
"border-width": 2,
|
||||||
|
"border-color": "#0369a1", // sky-700
|
||||||
|
label: "data(label)",
|
||||||
|
"font-size": 10,
|
||||||
|
"text-valign": "center",
|
||||||
|
"text-halign": "center",
|
||||||
|
color: "#111827", // gray-900
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": "90px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "edge",
|
||||||
|
style: {
|
||||||
|
width: 2,
|
||||||
|
"line-color": "#94a3b8", // slate-400
|
||||||
|
opacity: 0.9,
|
||||||
|
"curve-style": "bezier",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "edge[hasProvenance]",
|
||||||
|
style: {
|
||||||
|
"line-color": "#16a34a", // green-600
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: ":selected",
|
||||||
|
style: {
|
||||||
|
"border-width": 3,
|
||||||
|
"border-color": "#111827",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: {
|
||||||
|
name: "cose",
|
||||||
|
animate: true,
|
||||||
|
padding: 30,
|
||||||
|
} as any,
|
||||||
|
wheelSensitivity: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit initially
|
||||||
|
cy.one("render", () => {
|
||||||
|
cy.fit(undefined, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const onNodeSelect = (evt: any) => {
|
||||||
|
const d = evt.target.data();
|
||||||
|
setSelectedEdge(null);
|
||||||
|
setSelectedNode({
|
||||||
|
id: d.id,
|
||||||
|
label: d.label,
|
||||||
|
company: d.company ?? null,
|
||||||
|
role: d.role ?? null,
|
||||||
|
sectors: (d.sectors as string[]) ?? [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdgeSelect = (evt: any) => {
|
||||||
|
const d = evt.target.data();
|
||||||
|
setSelectedNode(null);
|
||||||
|
setSelectedEdge({
|
||||||
|
id: d.id,
|
||||||
|
source: d.source,
|
||||||
|
target: d.target,
|
||||||
|
introducedByCount: Number(d.introducedByCount ?? 0),
|
||||||
|
hasProvenance: Boolean(d.hasProvenance),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
cy.on("tap", "node", onNodeSelect);
|
||||||
|
cy.on("tap", "edge", onEdgeSelect);
|
||||||
|
cy.on("tap", (evt) => {
|
||||||
|
if (evt.target === cy) {
|
||||||
|
setSelectedNode(null);
|
||||||
|
setSelectedEdge(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cyRef.current = cy;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cy.off("tap", "node", onNodeSelect);
|
||||||
|
cy.off("tap", "edge", onEdgeSelect);
|
||||||
|
if (cyRef.current) {
|
||||||
|
cyRef.current.destroy();
|
||||||
|
cyRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [elements]);
|
||||||
|
|
||||||
|
const refit = () => {
|
||||||
|
cyRef.current?.fit(undefined, 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const relayout = () => {
|
||||||
|
if (!cyRef.current) return;
|
||||||
|
const layout = cyRef.current.elements().layout({ name: "cose", animate: true, padding: 30 } as any);
|
||||||
|
layout.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 rounded border border-zinc-200 bg-white text-zinc-900 dark:text-zinc-900">
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-200 p-2">
|
||||||
|
<span className="font-medium">Global Graph</span>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={refit}
|
||||||
|
className="rounded border border-zinc-300 px-2 py-1 text-sm hover:bg-zinc-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={relayout}
|
||||||
|
className="rounded border border-zinc-300 px-2 py-1 text-sm hover:bg-zinc-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Layout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ height, width: "100%" }}
|
||||||
|
className="cytoscape-container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-80 shrink-0 rounded border border-zinc-200 bg-white p-3 text-zinc-900 dark:text-zinc-900">
|
||||||
|
<div className="mb-2 text-sm font-semibold">Details</div>
|
||||||
|
{!selectedNode && !selectedEdge && (
|
||||||
|
<div className="text-sm text-zinc-500">Tap a node or edge to see details.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedNode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500">Person</div>
|
||||||
|
<div className="font-medium">{selectedNode.label}</div>
|
||||||
|
</div>
|
||||||
|
{selectedNode.company || selectedNode.role ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
{(selectedNode.role ?? "")} {selectedNode.company ? `@ ${selectedNode.company}` : ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedNode.sectors?.length ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-xs text-zinc-500">Sectors:</span>{" "}
|
||||||
|
{selectedNode.sectors.join(", ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEdge && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500">Connection</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{selectedEdge.source} — {selectedEdge.target}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-xs text-zinc-500">Provenance length:</span>{" "}
|
||||||
|
{selectedEdge.introducedByCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-xs text-zinc-500">Has provenance:</span>{" "}
|
||||||
|
{selectedEdge.hasProvenance ? "Yes" : "No"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/PeopleSelect.tsx
Normal file
134
src/components/PeopleSelect.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type PersonLite = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
company: string | null;
|
||||||
|
role: string | null;
|
||||||
|
sectors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Selected = { id: string; name: string } | null;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value: Selected;
|
||||||
|
onChange: (v: Selected) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useDebouncedValue(value: string, delay = 250) {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setV(value), delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, delay]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeopleSelect({
|
||||||
|
label = "Select person",
|
||||||
|
placeholder = "Type a name…",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
autoFocus,
|
||||||
|
}: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const debounced = useDebouncedValue(query, 250);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const { data, isFetching, error } = useQuery<PersonLite[]>({
|
||||||
|
queryKey: ["people", debounced],
|
||||||
|
queryFn: async () => {
|
||||||
|
const u = new URL("/api/people", window.location.origin);
|
||||||
|
if (debounced.trim()) u.searchParams.set("q", debounced.trim());
|
||||||
|
u.searchParams.set("limit", "50");
|
||||||
|
const res = await fetch(u.toString());
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch people");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = useMemo(() => data ?? [], [data]);
|
||||||
|
|
||||||
|
const select = (p: PersonLite) => {
|
||||||
|
onChange({ id: p.id, name: p.name });
|
||||||
|
setOpen(false);
|
||||||
|
setQuery(p.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
onChange(null);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && !query) setQuery(value.name);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label ? (
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600">{label}</label>
|
||||||
|
) : null}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
className="absolute right-1 top-1 rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100"
|
||||||
|
aria-label="Clear selection"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded border border-zinc-300 bg-white text-zinc-900 dark:text-zinc-900 shadow">
|
||||||
|
{isFetching ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-zinc-500">Loading…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-red-600">Error loading people</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-zinc-500">No results</div>
|
||||||
|
) : (
|
||||||
|
results.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(p)}
|
||||||
|
className="flex w-full items-start gap-2 px-3 py-2 text-left text-sm hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{p.name}</span>
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{p.role ? `${p.role}` : ""}
|
||||||
|
{p.company ? `${p.role ? " @ " : ""}${p.company}` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/components/PersonForm.tsx
Normal file
181
src/components/PersonForm.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type PersonFormData = {
|
||||||
|
name: string;
|
||||||
|
company?: string;
|
||||||
|
role?: string;
|
||||||
|
email?: string;
|
||||||
|
location?: string;
|
||||||
|
sectors?: string[];
|
||||||
|
interests?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitCsv(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState<PersonFormData>({
|
||||||
|
name: "",
|
||||||
|
company: "",
|
||||||
|
role: "",
|
||||||
|
email: "",
|
||||||
|
location: "",
|
||||||
|
sectors: [],
|
||||||
|
interests: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sectorsText, setSectorsText] = useState("");
|
||||||
|
const [interestsText, setInterestsText] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
const payload: PersonFormData = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
company: form.company?.trim() || undefined,
|
||||||
|
role: form.role?.trim() || undefined,
|
||||||
|
email: form.email?.trim() || undefined,
|
||||||
|
location: form.location?.trim() || undefined,
|
||||||
|
sectors: splitCsv(sectorsText),
|
||||||
|
interests: splitCsv(interestsText),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/people", {
|
||||||
|
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 create person");
|
||||||
|
}
|
||||||
|
setSuccess("Person created");
|
||||||
|
// Small delay for UX, then go home
|
||||||
|
setTimeout(() => router.push("/"), 600);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || "Failed to create person");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Name *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Company</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.company}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, company: e.target.value }))}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="Org Inc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Role</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value }))}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="Founder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Location</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.location}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="City or Country"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Sectors (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sectorsText}
|
||||||
|
onChange={(e) => setSectorsText(e.target.value)}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="agriculture, construction, pharma"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-zinc-700">Interests (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={interestsText}
|
||||||
|
onChange={(e) => setInterestsText(e.target.value)}
|
||||||
|
className="w-full rounded border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-400"
|
||||||
|
placeholder="funding, team, knowledge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">{success}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || form.name.trim().length === 0}
|
||||||
|
className="rounded bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="rounded border border-zinc-300 px-4 py-2 text-sm hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/lib/db.ts
Normal file
16
src/lib/db.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalThis.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma
|
||||||
|
|
||||||
|
export default prisma
|
||||||
72
src/lib/validators.ts
Normal file
72
src/lib/validators.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Person schemas
|
||||||
|
export const personCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name is required"),
|
||||||
|
company: z.string().trim().optional(),
|
||||||
|
role: z.string().trim().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
location: z.string().trim().optional(),
|
||||||
|
sectors: z.array(z.string().trim()).default([]).optional(),
|
||||||
|
interests: z.array(z.string().trim()).default([]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const personUpdateSchema = personCreateSchema.partial();
|
||||||
|
|
||||||
|
// Query schemas
|
||||||
|
export const peopleListQuerySchema = z.object({
|
||||||
|
q: z.string().trim().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection schemas
|
||||||
|
export const connectionCreateSchema = z.object({
|
||||||
|
personAId: z.string().uuid(),
|
||||||
|
personBId: z.string().uuid(),
|
||||||
|
introducedByChain: z.array(z.string().uuid()).default([]).optional(),
|
||||||
|
eventLabels: z.array(z.string().trim()).default([]).optional(),
|
||||||
|
notes: z.string().trim().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const connectionUpdateSchema = z.object({
|
||||||
|
introducedByChain: z.array(z.string().uuid()).optional(),
|
||||||
|
eventLabels: z.array(z.string().trim()).optional(),
|
||||||
|
notes: z.string().trim().max(1000).nullable().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => Object.keys(data).length > 0,
|
||||||
|
{ message: "No fields to update" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Graph DTO schemas (responses)
|
||||||
|
export const graphNodeSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
label: z.string(),
|
||||||
|
sectors: z.array(z.string()),
|
||||||
|
company: z.string().nullable(),
|
||||||
|
role: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const graphEdgeSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
source: z.string().uuid(),
|
||||||
|
target: z.string().uuid(),
|
||||||
|
introducedByCount: z.number().int().nonnegative(),
|
||||||
|
hasProvenance: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const graphResponseSchema = z.object({
|
||||||
|
nodes: z.array(graphNodeSchema),
|
||||||
|
edges: z.array(graphEdgeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type PersonCreate = z.infer<typeof personCreateSchema>;
|
||||||
|
export type PersonUpdate = z.infer<typeof personUpdateSchema>;
|
||||||
|
export type PeopleListQuery = z.infer<typeof peopleListQuerySchema>;
|
||||||
|
|
||||||
|
export type ConnectionCreate = z.infer<typeof connectionCreateSchema>;
|
||||||
|
export type ConnectionUpdate = z.infer<typeof connectionUpdateSchema>;
|
||||||
|
|
||||||
|
export type GraphNodeDTO = z.infer<typeof graphNodeSchema>;
|
||||||
|
export type GraphEdgeDTO = z.infer<typeof graphEdgeSchema>;
|
||||||
|
export type GraphResponseDTO = z.infer<typeof graphResponseSchema>;
|
||||||
Reference in New Issue
Block a user