From 06f1b4e5484ae0c930d3ab0f74339f606ed36108 Mon Sep 17 00:00:00 2001 From: Hammer Date: Tue, 27 Jan 2026 02:43:11 +0000 Subject: [PATCH] Initial API scaffold: Elysia + Bun + Drizzle + BetterAuth + LangChain --- .env.example | 17 +++ .gitignore | 34 +++++ Dockerfile | 18 +++ README.md | 128 ++++++++++++++++ bun.lock | 347 ++++++++++++++++++++++++++++++++++++++++++ drizzle.config.ts | 10 ++ package.json | 36 +++++ src/db/index.ts | 16 ++ src/db/schema.ts | 163 ++++++++++++++++++++ src/index.ts | 71 +++++++++ src/lib/auth.ts | 30 ++++ src/routes/clients.ts | 194 +++++++++++++++++++++++ src/routes/emails.ts | 269 ++++++++++++++++++++++++++++++++ src/routes/events.ts | 281 ++++++++++++++++++++++++++++++++++ src/services/ai.ts | 109 +++++++++++++ src/services/email.ts | 27 ++++ src/types/index.ts | 28 ++++ tsconfig.json | 29 ++++ 18 files changed, 1807 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bun.lock create mode 100644 drizzle.config.ts create mode 100644 package.json create mode 100644 src/db/index.ts create mode 100644 src/db/schema.ts create mode 100644 src/index.ts create mode 100644 src/lib/auth.ts create mode 100644 src/routes/clients.ts create mode 100644 src/routes/emails.ts create mode 100644 src/routes/events.ts create mode 100644 src/services/ai.ts create mode 100644 src/services/email.ts create mode 100644 src/types/index.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f16179 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Database +DATABASE_URL=postgresql://postgres:password@localhost:5432/networkapp + +# Server +PORT=3000 +APP_URL=http://localhost:3000 +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 + +# BetterAuth +BETTER_AUTH_SECRET=your-secret-key-here-min-32-chars + +# AI (LangChain) +ANTHROPIC_API_KEY=your-anthropic-api-key + +# Email (Resend) +RESEND_API_KEY=your-resend-api-key +DEFAULT_FROM_EMAIL=onboarding@resend.dev diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f8b9bb7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM oven/bun:1 AS base +WORKDIR /app + +# Install dependencies +FROM base AS install +COPY package.json bun.lockb ./ +RUN bun install --frozen-lockfile --production + +# Production image +FROM base AS release +COPY --from=install /app/node_modules ./node_modules +COPY . . + +ENV NODE_ENV=production +EXPOSE 3000 + +USER bun +CMD ["bun", "run", "src/index.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..322944c --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Network App API + +Backend API for The Network App — an AI-powered CRM for wealth management advisors. + +## Stack + +- **Runtime:** Bun +- **Framework:** Elysia +- **Database:** PostgreSQL + Drizzle ORM +- **Auth:** BetterAuth +- **AI:** LangChain.js (Anthropic Claude) +- **Email:** Resend + +## Quick Start + +### Prerequisites + +- [Bun](https://bun.sh/) v1.0+ +- PostgreSQL 15+ + +### Setup + +1. Install dependencies: + ```bash + bun install + ``` + +2. Copy environment file: + ```bash + cp .env.example .env + ``` + +3. Configure `.env` with your values (database, API keys, etc.) + +4. Push database schema: + ```bash + bun run db:push + ``` + +5. Start development server: + ```bash + bun run dev + ``` + +API runs at `http://localhost:3000` + +## API Endpoints + +### Auth (BetterAuth) +- `POST /api/auth/sign-up` — Register +- `POST /api/auth/sign-in/email` — Login +- `POST /api/auth/sign-out` — Logout +- `GET /api/auth/session` — Get current session + +### Clients +- `GET /api/clients` — List clients +- `GET /api/clients/:id` — Get client +- `POST /api/clients` — Create client +- `PUT /api/clients/:id` — Update client +- `DELETE /api/clients/:id` — Delete client +- `POST /api/clients/:id/contacted` — Mark as contacted + +### Emails +- `POST /api/emails/generate` — Generate AI email +- `POST /api/emails/generate-birthday` — Generate birthday message +- `GET /api/emails` — List emails +- `GET /api/emails/:id` — Get email +- `PUT /api/emails/:id` — Edit draft +- `POST /api/emails/:id/send` — Send email +- `DELETE /api/emails/:id` — Delete draft + +### Events +- `GET /api/events` — List events +- `GET /api/events/:id` — Get event +- `POST /api/events` — Create event +- `PUT /api/events/:id` — Update event +- `DELETE /api/events/:id` — Delete event +- `POST /api/events/sync/:clientId` — Sync client birthdays/anniversaries + +## Database + +### Generate migrations +```bash +bun run db:generate +``` + +### Apply migrations +```bash +bun run db:migrate +``` + +### Push schema (development) +```bash +bun run db:push +``` + +### Open Drizzle Studio +```bash +bun run db:studio +``` + +## Docker + +```bash +docker build -t network-app-api . +docker run -p 3000:3000 --env-file .env network-app-api +``` + +## Project Structure + +``` +src/ +├── index.ts # Entry point +├── routes/ +│ ├── clients.ts # Client CRUD +│ ├── emails.ts # AI email generation +│ └── events.ts # Event tracking +├── services/ +│ ├── ai.ts # LangChain integration +│ └── email.ts # Resend integration +├── db/ +│ ├── schema.ts # Drizzle schema +│ └── index.ts # DB connection +├── lib/ +│ └── auth.ts # BetterAuth config +└── types/ + └── index.ts # TypeScript types +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..65e5a71 --- /dev/null +++ b/bun.lock @@ -0,0 +1,347 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "network-app-api", + "dependencies": { + "@elysiajs/bearer": "^1.4.2", + "@elysiajs/cors": "^1.4.1", + "@langchain/anthropic": "^1.3.12", + "@langchain/core": "^1.1.17", + "better-auth": "^1.4.17", + "drizzle-orm": "^0.45.1", + "elysia": "^1.4.22", + "pg-boss": "^12.7.0", + "postgres": "^3.4.8", + "resend": "^6.8.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pg": "^8.16.0", + "drizzle-kit": "^0.31.8", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@better-auth/core": ["@better-auth/core@1.4.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.4.17", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.17" } }, "sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA=="], + + "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@elysiajs/bearer": ["@elysiajs/bearer@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.3" } }, "sha512-MK2aCFqnFMqMNSa1e/A6+Ow5uNl5LpKd8K4lCB2LIsyDrI6juxOUHAgqq+esgdSoh3urD1UIMqFC//TsqCQViA=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@langchain/anthropic": ["@langchain/anthropic@1.3.12", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "zod": "^3.25.76 || ^4" }, "peerDependencies": { "@langchain/core": "1.1.17" } }, "sha512-CeICvFLsMLau5RjGHqUsZKAdEWrBZYebVnufRRtGlkg7nq3BqZSK2zVAAcf37q7e5u3WyPzBTWAHwR2adfpMCA=="], + + "@langchain/core": ["@langchain/core@1.1.17", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.4.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "uuid": "^10.0.0", "zod": "^3.25.76 || ^4" } }, "sha512-g7/kcKbKEwNZSyyT7aT0utxn7wTOtKErqz0cL6VjrV4v/aOb9g+dKcfj17YkSm42YQmJp/rB2IXGc17vQPEBqA=="], + + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + + "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-auth": ["better-auth@1.4.17", "", { "dependencies": { "@better-auth/core": "1.4.17", "@better-auth/telemetry": "1.4.17", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew=="], + + "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "console-table-printer": ["console-table-printer@2.15.0", "", { "dependencies": { "simple-wcswidth": "^1.1.2" } }, "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], + + "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + + "elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="], + + "esbuild": ["esbuild@0.25.12", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="], + + "langsmith": ["langsmith@0.4.10", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-l9QP/a7RXBXdaoAnNx99X+TK8aul8Qe4us1oCybdMgDmYMLT5PAwlJactvSdTlT8NOeSoFThYa2N7ijznBNe9w=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + + "non-error": ["non-error@0.1.0", "", {}, "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], + + "pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="], + + "pg-boss": ["pg-boss@12.7.0", "", { "dependencies": { "cron-parser": "^5.5.0", "pg": "^8.17.2", "serialize-error": "^13.0.1" }, "bin": { "pg-boss": "dist/cli.js" } }, "sha512-sOr7wHVGILgMrciuDoyc3e1ejvC7Wx1ESJUdsIMwvYmNS5tT8EqJOZgzLnTl5O7sKMweyJsDRiGuG23JoHI/+g=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.10.1", "", {}, "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="], + + "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "resend": ["resend@6.8.0", "", { "dependencies": { "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-fDOXGqafQfQXl8nXe93wr93pus8tW7YPpowenE3SmG7dJJf0hH3xUWm3xqacnPvhqjCQTJH9xETg07rmUeSuqQ=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "serialize-error": ["serialize-error@13.0.1", "", { "dependencies": { "non-error": "^0.1.0", "type-fest": "^5.4.1" } }, "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-fest": ["type-fest@5.4.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + } +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..9233f37 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b066cdb --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "network-app-api", + "version": "0.1.0", + "module": "src/index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pg": "^8.16.0", + "drizzle-kit": "^0.31.8" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@elysiajs/bearer": "^1.4.2", + "@elysiajs/cors": "^1.4.1", + "@langchain/anthropic": "^1.3.12", + "@langchain/core": "^1.1.17", + "better-auth": "^1.4.17", + "drizzle-orm": "^0.45.1", + "elysia": "^1.4.22", + "pg-boss": "^12.7.0", + "postgres": "^3.4.8", + "resend": "^6.8.0", + "zod": "^4.3.6" + } +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..424117d --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +const connectionString = process.env.DATABASE_URL!; + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is required'); +} + +// For query purposes +const queryClient = postgres(connectionString); +export const db = drizzle(queryClient, { schema }); + +// For migrations (uses a different client) +export const migrationClient = postgres(connectionString, { max: 1 }); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..37f8a6f --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,163 @@ +import { pgTable, text, timestamp, uuid, boolean, jsonb, integer } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// Users table (managed by BetterAuth, but we define it for relations) +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + name: text('name').notNull(), + emailVerified: boolean('email_verified').default(false), + image: text('image'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// BetterAuth session table +export const sessions = pgTable('sessions', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// BetterAuth account table (for OAuth providers) +export const accounts = pgTable('accounts', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + idToken: text('id_token'), + password: text('password'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// BetterAuth verification table +export const verifications = pgTable('verifications', { + id: uuid('id').primaryKey().defaultRandom(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Clients table +export const clients = pgTable('clients', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + + // Basic info + firstName: text('first_name').notNull(), + lastName: text('last_name').notNull(), + email: text('email'), + phone: text('phone'), + + // Address + street: text('street'), + city: text('city'), + state: text('state'), + zip: text('zip'), + + // Professional + company: text('company'), + role: text('role'), + industry: text('industry'), + + // Personal + birthday: timestamp('birthday'), + anniversary: timestamp('anniversary'), + interests: jsonb('interests').$type().default([]), + family: jsonb('family').$type<{ spouse?: string; children?: string[] }>(), + notes: text('notes'), + + // Organization + tags: jsonb('tags').$type().default([]), + + // Tracking + lastContactedAt: timestamp('last_contacted_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Events table (birthdays, anniversaries, follow-ups) +export const events = pgTable('events', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(), + + type: text('type').notNull(), // 'birthday' | 'anniversary' | 'followup' | 'custom' + title: text('title').notNull(), + date: timestamp('date').notNull(), + recurring: boolean('recurring').default(false), + reminderDays: integer('reminder_days').default(7), + lastTriggered: timestamp('last_triggered'), + + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Communications table (emails, messages) +export const communications = pgTable('communications', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(), + + type: text('type').notNull(), // 'email' | 'birthday' | 'followup' + subject: text('subject'), + content: text('content').notNull(), + aiGenerated: boolean('ai_generated').default(false), + aiModel: text('ai_model'), // Which model was used + status: text('status').default('draft'), // 'draft' | 'approved' | 'sent' + sentAt: timestamp('sent_at'), + + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Relations +export const usersRelations = relations(users, ({ many }) => ({ + clients: many(clients), + events: many(events), + communications: many(communications), + sessions: many(sessions), + accounts: many(accounts), +})); + +export const clientsRelations = relations(clients, ({ one, many }) => ({ + user: one(users, { + fields: [clients.userId], + references: [users.id], + }), + events: many(events), + communications: many(communications), +})); + +export const eventsRelations = relations(events, ({ one }) => ({ + user: one(users, { + fields: [events.userId], + references: [users.id], + }), + client: one(clients, { + fields: [events.clientId], + references: [clients.id], + }), +})); + +export const communicationsRelations = relations(communications, ({ one }) => ({ + user: one(users, { + fields: [communications.userId], + references: [users.id], + }), + client: one(clients, { + fields: [communications.clientId], + references: [clients.id], + }), +})); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e111011 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,71 @@ +import { Elysia } from 'elysia'; +import { cors } from '@elysiajs/cors'; +import { auth } from './lib/auth'; +import { clientRoutes } from './routes/clients'; +import { emailRoutes } from './routes/emails'; +import { eventRoutes } from './routes/events'; +import type { User } from './lib/auth'; + +const app = new Elysia() + // CORS + .use(cors({ + origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'], + credentials: true, + })) + + // Health check + .get('/health', () => ({ status: 'ok', timestamp: new Date().toISOString() })) + + // BetterAuth routes (login, register, etc.) + .all('/api/auth/*', async ({ request }) => { + return auth.handler(request); + }) + + // Protected routes - require auth + .derive(async ({ request, set }): Promise<{ user: User }> => { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + set.status = 401; + throw new Error('Unauthorized'); + } + + return { user: session.user as User }; + }) + + // API routes (all require auth due to derive above) + .group('/api', app => app + .use(clientRoutes) + .use(emailRoutes) + .use(eventRoutes) + ) + + // Error handler + .onError(({ code, error, set }) => { + if (code === 'VALIDATION') { + set.status = 400; + return { error: 'Validation error', details: error.message }; + } + + if (error.message === 'Unauthorized') { + set.status = 401; + return { error: 'Unauthorized' }; + } + + if (error.message.includes('not found')) { + set.status = 404; + return { error: error.message }; + } + + console.error('Error:', error); + set.status = 500; + return { error: 'Internal server error' }; + }) + + .listen(process.env.PORT || 3000); + +console.log(`🚀 Network App API running at ${app.server?.hostname}:${app.server?.port}`); + +export type App = typeof app; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..5f1deb1 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,30 @@ +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { db } from '../db'; +import * as schema from '../db/schema'; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + schema: { + user: schema.users, + session: schema.sessions, + account: schema.accounts, + verification: schema.verifications, + }, + }), + emailAndPassword: { + enabled: true, + requireEmailVerification: false, // Enable later for production + }, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update session every day + }, + trustedOrigins: [ + process.env.APP_URL || 'http://localhost:3000', + ], +}); + +export type Session = typeof auth.$Infer.Session; +export type User = typeof auth.$Infer.Session.user; diff --git a/src/routes/clients.ts b/src/routes/clients.ts new file mode 100644 index 0000000..158d583 --- /dev/null +++ b/src/routes/clients.ts @@ -0,0 +1,194 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { clients } from '../db/schema'; +import { eq, and, ilike, or, sql } from 'drizzle-orm'; +import type { User } from '../lib/auth'; + +// Validation schemas +const clientSchema = t.Object({ + firstName: t.String({ minLength: 1 }), + lastName: t.String({ minLength: 1 }), + email: t.Optional(t.String({ format: 'email' })), + phone: t.Optional(t.String()), + street: t.Optional(t.String()), + city: t.Optional(t.String()), + state: t.Optional(t.String()), + zip: t.Optional(t.String()), + company: t.Optional(t.String()), + role: t.Optional(t.String()), + industry: t.Optional(t.String()), + birthday: t.Optional(t.String()), // ISO date string + anniversary: t.Optional(t.String()), + interests: t.Optional(t.Array(t.String())), + family: t.Optional(t.Object({ + spouse: t.Optional(t.String()), + children: t.Optional(t.Array(t.String())), + })), + notes: t.Optional(t.String()), + tags: t.Optional(t.Array(t.String())), +}); + +const updateClientSchema = t.Partial(clientSchema); + +export const clientRoutes = new Elysia({ prefix: '/clients' }) + // List clients with optional search + .get('/', async ({ query, user }: { query: { search?: string; tag?: string }; user: User }) => { + let baseQuery = db.select().from(clients).where(eq(clients.userId, user.id)); + + if (query.search) { + const searchTerm = `%${query.search}%`; + baseQuery = db.select().from(clients).where( + and( + eq(clients.userId, user.id), + or( + ilike(clients.firstName, searchTerm), + ilike(clients.lastName, searchTerm), + ilike(clients.company, searchTerm), + ilike(clients.email, searchTerm) + ) + ) + ); + } + + const results = await baseQuery.orderBy(clients.lastName, clients.firstName); + + // Filter by tag in-memory if needed (JSONB filtering) + if (query.tag) { + return results.filter(c => c.tags?.includes(query.tag!)); + } + + return results; + }, { + query: t.Object({ + search: t.Optional(t.String()), + tag: t.Optional(t.String()), + }), + }) + + // Get single client + .get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [client] = await db.select() + .from(clients) + .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))) + .limit(1); + + if (!client) { + throw new Error('Client not found'); + } + + return client; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }) + + // Create client + .post('/', async ({ body, user }: { body: typeof clientSchema.static; user: User }) => { + const [client] = await db.insert(clients) + .values({ + userId: user.id, + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + phone: body.phone, + street: body.street, + city: body.city, + state: body.state, + zip: body.zip, + company: body.company, + role: body.role, + industry: body.industry, + birthday: body.birthday ? new Date(body.birthday) : null, + anniversary: body.anniversary ? new Date(body.anniversary) : null, + interests: body.interests || [], + family: body.family, + notes: body.notes, + tags: body.tags || [], + }) + .returning(); + + return client; + }, { + body: clientSchema, + }) + + // Update client + .put('/:id', async ({ params, body, user }: { params: { id: string }; body: typeof updateClientSchema.static; user: User }) => { + // Build update object, only including provided fields + const updateData: Record = { + updatedAt: new Date(), + }; + + if (body.firstName !== undefined) updateData.firstName = body.firstName; + if (body.lastName !== undefined) updateData.lastName = body.lastName; + if (body.email !== undefined) updateData.email = body.email; + if (body.phone !== undefined) updateData.phone = body.phone; + if (body.street !== undefined) updateData.street = body.street; + if (body.city !== undefined) updateData.city = body.city; + if (body.state !== undefined) updateData.state = body.state; + if (body.zip !== undefined) updateData.zip = body.zip; + if (body.company !== undefined) updateData.company = body.company; + if (body.role !== undefined) updateData.role = body.role; + if (body.industry !== undefined) updateData.industry = body.industry; + if (body.birthday !== undefined) updateData.birthday = body.birthday ? new Date(body.birthday) : null; + if (body.anniversary !== undefined) updateData.anniversary = body.anniversary ? new Date(body.anniversary) : null; + if (body.interests !== undefined) updateData.interests = body.interests; + if (body.family !== undefined) updateData.family = body.family; + if (body.notes !== undefined) updateData.notes = body.notes; + if (body.tags !== undefined) updateData.tags = body.tags; + + const [client] = await db.update(clients) + .set(updateData) + .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))) + .returning(); + + if (!client) { + throw new Error('Client not found'); + } + + return client; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + body: updateClientSchema, + }) + + // Delete client + .delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [deleted] = await db.delete(clients) + .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))) + .returning({ id: clients.id }); + + if (!deleted) { + throw new Error('Client not found'); + } + + return { success: true, id: deleted.id }; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }) + + // Mark client as contacted + .post('/:id/contacted', async ({ params, user }: { params: { id: string }; user: User }) => { + const [client] = await db.update(clients) + .set({ + lastContactedAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))) + .returning(); + + if (!client) { + throw new Error('Client not found'); + } + + return client; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }); diff --git a/src/routes/emails.ts b/src/routes/emails.ts new file mode 100644 index 0000000..240675f --- /dev/null +++ b/src/routes/emails.ts @@ -0,0 +1,269 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { clients, communications } from '../db/schema'; +import { eq, and } from 'drizzle-orm'; +import { generateEmail, generateSubject, generateBirthdayMessage, type AIProvider } from '../services/ai'; +import { sendEmail } from '../services/email'; +import type { User } from '../lib/auth'; + +export const emailRoutes = new Elysia({ prefix: '/emails' }) + // Generate email for a client + .post('/generate', async ({ body, user }: { + body: { + clientId: string; + purpose: string; + provider?: AIProvider; + }; + user: User; + }) => { + // Get client + const [client] = await db.select() + .from(clients) + .where(and(eq(clients.id, body.clientId), eq(clients.userId, user.id))) + .limit(1); + + if (!client) { + throw new Error('Client not found'); + } + + // Generate email content + const content = await generateEmail({ + advisorName: user.name, + clientName: client.firstName, + interests: client.interests || [], + notes: client.notes || '', + purpose: body.purpose, + provider: body.provider, + }); + + // Generate subject + const subject = await generateSubject(body.purpose, client.firstName, body.provider); + + // Save as draft + const [communication] = await db.insert(communications) + .values({ + userId: user.id, + clientId: client.id, + type: 'email', + subject, + content, + aiGenerated: true, + aiModel: body.provider || 'anthropic', + status: 'draft', + }) + .returning(); + + return communication; + }, { + body: t.Object({ + clientId: t.String({ format: 'uuid' }), + purpose: t.String({ minLength: 1 }), + provider: t.Optional(t.Union([t.Literal('anthropic'), t.Literal('openai')])), + }), + }) + + // Generate birthday message + .post('/generate-birthday', async ({ body, user }: { + body: { + clientId: string; + provider?: AIProvider; + }; + user: User; + }) => { + // Get client + const [client] = await db.select() + .from(clients) + .where(and(eq(clients.id, body.clientId), eq(clients.userId, user.id))) + .limit(1); + + if (!client) { + throw new Error('Client not found'); + } + + // Calculate years as client + const yearsAsClient = Math.floor( + (Date.now() - new Date(client.createdAt).getTime()) / (365.25 * 24 * 60 * 60 * 1000) + ); + + // Generate message + const content = await generateBirthdayMessage({ + clientName: client.firstName, + yearsAsClient, + interests: client.interests || [], + provider: body.provider, + }); + + // Save as draft + const [communication] = await db.insert(communications) + .values({ + userId: user.id, + clientId: client.id, + type: 'birthday', + subject: `Happy Birthday, ${client.firstName}!`, + content, + aiGenerated: true, + aiModel: body.provider || 'anthropic', + status: 'draft', + }) + .returning(); + + return communication; + }, { + body: t.Object({ + clientId: t.String({ format: 'uuid' }), + provider: t.Optional(t.Union([t.Literal('anthropic'), t.Literal('openai')])), + }), + }) + + // List emails (drafts and sent) + .get('/', async ({ query, user }: { + query: { status?: string; clientId?: string }; + user: User; + }) => { + let conditions = [eq(communications.userId, user.id)]; + + if (query.status) { + conditions.push(eq(communications.status, query.status)); + } + + if (query.clientId) { + conditions.push(eq(communications.clientId, query.clientId)); + } + + const results = await db.select() + .from(communications) + .where(and(...conditions)) + .orderBy(communications.createdAt); + + return results; + }, { + query: t.Object({ + status: t.Optional(t.String()), + clientId: t.Optional(t.String({ format: 'uuid' })), + }), + }) + + // Get single email + .get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [email] = await db.select() + .from(communications) + .where(and(eq(communications.id, params.id), eq(communications.userId, user.id))) + .limit(1); + + if (!email) { + throw new Error('Email not found'); + } + + return email; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }) + + // Update email (edit draft) + .put('/:id', async ({ params, body, user }: { + params: { id: string }; + body: { subject?: string; content?: string }; + user: User; + }) => { + const updateData: Record = {}; + if (body.subject !== undefined) updateData.subject = body.subject; + if (body.content !== undefined) updateData.content = body.content; + + const [email] = await db.update(communications) + .set(updateData) + .where(and( + eq(communications.id, params.id), + eq(communications.userId, user.id), + eq(communications.status, 'draft') // Can only edit drafts + )) + .returning(); + + if (!email) { + throw new Error('Email not found or already sent'); + } + + return email; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + body: t.Object({ + subject: t.Optional(t.String()), + content: t.Optional(t.String()), + }), + }) + + // Send email + .post('/:id/send', async ({ params, user }: { params: { id: string }; user: User }) => { + // Get email + const [email] = await db.select({ + email: communications, + client: clients, + }) + .from(communications) + .innerJoin(clients, eq(communications.clientId, clients.id)) + .where(and( + eq(communications.id, params.id), + eq(communications.userId, user.id), + eq(communications.status, 'draft') + )) + .limit(1); + + if (!email) { + throw new Error('Email not found or already sent'); + } + + if (!email.client.email) { + throw new Error('Client has no email address'); + } + + // Send via Resend + await sendEmail({ + to: email.client.email, + subject: email.email.subject || 'Message from your advisor', + content: email.email.content, + }); + + // Update status + const [updated] = await db.update(communications) + .set({ + status: 'sent', + sentAt: new Date(), + }) + .where(eq(communications.id, params.id)) + .returning(); + + // Update client's last contacted + await db.update(clients) + .set({ lastContactedAt: new Date() }) + .where(eq(clients.id, email.client.id)); + + return updated; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }) + + // Delete draft + .delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [deleted] = await db.delete(communications) + .where(and( + eq(communications.id, params.id), + eq(communications.userId, user.id), + eq(communications.status, 'draft') // Can only delete drafts + )) + .returning({ id: communications.id }); + + if (!deleted) { + throw new Error('Email not found or already sent'); + } + + return { success: true, id: deleted.id }; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }); diff --git a/src/routes/events.ts b/src/routes/events.ts new file mode 100644 index 0000000..adb0a3d --- /dev/null +++ b/src/routes/events.ts @@ -0,0 +1,281 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { events, clients } from '../db/schema'; +import { eq, and, gte, lte, sql } from 'drizzle-orm'; +import type { User } from '../lib/auth'; + +export const eventRoutes = new Elysia({ prefix: '/events' }) + // List events with optional filters + .get('/', async ({ query, user }: { + query: { + clientId?: string; + type?: string; + upcoming?: string; // days ahead + }; + user: User; + }) => { + let conditions = [eq(events.userId, user.id)]; + + if (query.clientId) { + conditions.push(eq(events.clientId, query.clientId)); + } + + if (query.type) { + conditions.push(eq(events.type, query.type)); + } + + let results = await db.select({ + event: events, + client: { + id: clients.id, + firstName: clients.firstName, + lastName: clients.lastName, + }, + }) + .from(events) + .innerJoin(clients, eq(events.clientId, clients.id)) + .where(and(...conditions)) + .orderBy(events.date); + + // Filter upcoming events if requested + if (query.upcoming) { + const daysAhead = parseInt(query.upcoming) || 7; + const now = new Date(); + const future = new Date(); + future.setDate(future.getDate() + daysAhead); + + results = results.filter(r => { + const eventDate = new Date(r.event.date); + // For recurring events, check if the month/day falls within range + if (r.event.recurring) { + const thisYear = new Date( + now.getFullYear(), + eventDate.getMonth(), + eventDate.getDate() + ); + const nextYear = new Date( + now.getFullYear() + 1, + eventDate.getMonth(), + eventDate.getDate() + ); + return (thisYear >= now && thisYear <= future) || + (nextYear >= now && nextYear <= future); + } + return eventDate >= now && eventDate <= future; + }); + } + + return results; + }, { + query: t.Object({ + clientId: t.Optional(t.String({ format: 'uuid' })), + type: t.Optional(t.String()), + upcoming: t.Optional(t.String()), + }), + }) + + // Get single event + .get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [event] = await db.select({ + event: events, + client: { + id: clients.id, + firstName: clients.firstName, + lastName: clients.lastName, + }, + }) + .from(events) + .innerJoin(clients, eq(events.clientId, clients.id)) + .where(and(eq(events.id, params.id), eq(events.userId, user.id))) + .limit(1); + + if (!event) { + throw new Error('Event not found'); + } + + return event; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }) + + // Create event + .post('/', async ({ body, user }: { + body: { + clientId: string; + type: string; + title: string; + date: string; + recurring?: boolean; + reminderDays?: number; + }; + user: User; + }) => { + // Verify client belongs to user + const [client] = await db.select() + .from(clients) + .where(and(eq(clients.id, body.clientId), eq(clients.userId, user.id))) + .limit(1); + + if (!client) { + throw new Error('Client not found'); + } + + const [event] = await db.insert(events) + .values({ + userId: user.id, + clientId: body.clientId, + type: body.type, + title: body.title, + date: new Date(body.date), + recurring: body.recurring ?? false, + reminderDays: body.reminderDays ?? 7, + }) + .returning(); + + return event; + }, { + body: t.Object({ + clientId: t.String({ format: 'uuid' }), + type: t.String({ minLength: 1 }), + title: t.String({ minLength: 1 }), + date: t.String(), // ISO date + recurring: t.Optional(t.Boolean()), + reminderDays: t.Optional(t.Number({ minimum: 0 })), + }), + }) + + // Update event + .put('/:id', async ({ params, body, user }: { + params: { id: string }; + body: { + type?: string; + title?: string; + date?: string; + recurring?: boolean; + reminderDays?: number; + }; + user: User; + }) => { + const updateData: Record = {}; + + if (body.type !== undefined) updateData.type = body.type; + if (body.title !== undefined) updateData.title = body.title; + if (body.date !== undefined) updateData.date = new Date(body.date); + if (body.recurring !== undefined) updateData.recurring = body.recurring; + if (body.reminderDays !== undefined) updateData.reminderDays = body.reminderDays; + + const [event] = await db.update(events) + .set(updateData) + .where(and(eq(events.id, params.id), eq(events.userId, user.id))) + .returning(); + + if (!event) { + throw new Error('Event not found'); + } + + return event; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + body: t.Object({ + type: t.Optional(t.String()), + title: t.Optional(t.String()), + date: t.Optional(t.String()), + recurring: t.Optional(t.Boolean()), + reminderDays: t.Optional(t.Number()), + }), + }) + + // Delete event + .delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [deleted] = await db.delete(events) + .where(and(eq(events.id, params.id), eq(events.userId, user.id))) + .returning({ id: events.id }); + + if (!deleted) { + throw new Error('Event not found'); + } + + return { success: true, id: deleted.id }; + }, { + params: t.Object({ + id: t.String({ format: 'uuid' }), + }), + }) + + // Sync events from client birthdays/anniversaries + .post('/sync/:clientId', async ({ params, user }: { params: { clientId: string }; user: User }) => { + // Get client + const [client] = await db.select() + .from(clients) + .where(and(eq(clients.id, params.clientId), eq(clients.userId, user.id))) + .limit(1); + + if (!client) { + throw new Error('Client not found'); + } + + const created = []; + + // Create birthday event if client has birthday + if (client.birthday) { + // Check if birthday event already exists + const [existing] = await db.select() + .from(events) + .where(and( + eq(events.clientId, client.id), + eq(events.type, 'birthday') + )) + .limit(1); + + if (!existing) { + const [event] = await db.insert(events) + .values({ + userId: user.id, + clientId: client.id, + type: 'birthday', + title: `${client.firstName}'s Birthday`, + date: client.birthday, + recurring: true, + reminderDays: 7, + }) + .returning(); + created.push(event); + } + } + + // Create anniversary event if client has anniversary + if (client.anniversary) { + const [existing] = await db.select() + .from(events) + .where(and( + eq(events.clientId, client.id), + eq(events.type, 'anniversary') + )) + .limit(1); + + if (!existing) { + const [event] = await db.insert(events) + .values({ + userId: user.id, + clientId: client.id, + type: 'anniversary', + title: `${client.firstName}'s Anniversary`, + date: client.anniversary, + recurring: true, + reminderDays: 7, + }) + .returning(); + created.push(event); + } + } + + return { created }; + }, { + params: t.Object({ + clientId: t.String({ format: 'uuid' }), + }), + }); diff --git a/src/services/ai.ts b/src/services/ai.ts new file mode 100644 index 0000000..71ebd6c --- /dev/null +++ b/src/services/ai.ts @@ -0,0 +1,109 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { StringOutputParser } from '@langchain/core/output_parsers'; + +export type AIProvider = 'anthropic' | 'openai'; + +// Get model based on provider +function getModel(provider: AIProvider = 'anthropic') { + if (provider === 'anthropic') { + return new ChatAnthropic({ + modelName: 'claude-sonnet-4-20250514', + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + }); + } + + // Add OpenAI support later + throw new Error(`Provider ${provider} not yet supported`); +} + +// Email generation prompt +const emailPrompt = ChatPromptTemplate.fromMessages([ + ['system', `You are a professional wealth advisor writing to a valued client. +Maintain a warm but professional tone. Incorporate personal details naturally. +Keep emails concise (3-4 paragraphs max). +Do not include subject line - just the body.`], + ['human', `Advisor: {advisorName} +Client: {clientName} +Their interests: {interests} +Recent notes: {notes} +Purpose: {purpose} + +Generate a personalized email that feels genuine, not templated.`], +]); + +export interface GenerateEmailParams { + advisorName: string; + clientName: string; + interests: string[]; + notes: string; + purpose: string; + provider?: AIProvider; +} + +export async function generateEmail(params: GenerateEmailParams): Promise { + const model = getModel(params.provider); + const parser = new StringOutputParser(); + const chain = emailPrompt.pipe(model).pipe(parser); + + const response = await chain.invoke({ + advisorName: params.advisorName, + clientName: params.clientName, + interests: params.interests.join(', ') || 'not specified', + notes: params.notes || 'No recent notes', + purpose: params.purpose, + }); + + return response; +} + +// Birthday message generation +const birthdayPrompt = ChatPromptTemplate.fromMessages([ + ['system', `Generate a thoughtful birthday message from a wealth advisor to their client. +Should feel personal, not generic. Keep it brief (2-3 sentences) and sincere.`], + ['human', `Client: {clientName} +Years as client: {yearsAsClient} +Interests: {interests} + +Generate a warm birthday message.`], +]); + +export interface GenerateBirthdayMessageParams { + clientName: string; + yearsAsClient: number; + interests: string[]; + provider?: AIProvider; +} + +export async function generateBirthdayMessage(params: GenerateBirthdayMessageParams): Promise { + const model = getModel(params.provider); + const parser = new StringOutputParser(); + const chain = birthdayPrompt.pipe(model).pipe(parser); + + const response = await chain.invoke({ + clientName: params.clientName, + yearsAsClient: params.yearsAsClient.toString(), + interests: params.interests.join(', ') || 'not specified', + }); + + return response; +} + +// Email subject generation +const subjectPrompt = ChatPromptTemplate.fromMessages([ + ['system', `Generate a professional but warm email subject line for a wealth advisor's email. +Keep it short (under 50 characters). Do not use quotes.`], + ['human', `Purpose: {purpose} +Client name: {clientName} + +Generate just the subject line, nothing else.`], +]); + +export async function generateSubject(purpose: string, clientName: string, provider?: AIProvider): Promise { + const model = getModel(provider); + const parser = new StringOutputParser(); + const chain = subjectPrompt.pipe(model).pipe(parser); + + const response = await chain.invoke({ purpose, clientName }); + return response.trim(); +} diff --git a/src/services/email.ts b/src/services/email.ts new file mode 100644 index 0000000..664fedd --- /dev/null +++ b/src/services/email.ts @@ -0,0 +1,27 @@ +import { Resend } from 'resend'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export interface SendEmailParams { + to: string; + subject: string; + content: string; + from?: string; + replyTo?: string; +} + +export async function sendEmail(params: SendEmailParams) { + const { data, error } = await resend.emails.send({ + from: params.from || process.env.DEFAULT_FROM_EMAIL || 'onboarding@resend.dev', + to: params.to, + subject: params.subject, + text: params.content, + replyTo: params.replyTo, + }); + + if (error) { + throw new Error(`Failed to send email: ${error.message}`); + } + + return data; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..3c0ccff --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,28 @@ +import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; +import type { clients, events, communications, users } from '../db/schema'; + +// Database types +export type User = InferSelectModel; +export type NewUser = InferInsertModel; + +export type Client = InferSelectModel; +export type NewClient = InferInsertModel; + +export type Event = InferSelectModel; +export type NewEvent = InferInsertModel; + +export type Communication = InferSelectModel; +export type NewCommunication = InferInsertModel; + +// API types +export interface ApiResponse { + data?: T; + error?: string; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}