Compare commits
10 commits
5776146c50
...
0082802bac
| Author | SHA1 | Date | |
|---|---|---|---|
| 0082802bac | |||
| e634ffddf2 | |||
| 3bbe2ac108 | |||
| 46567538dc | |||
| c37f8f77c2 | |||
| 0aef5bca86 | |||
| 8a84a660b3 | |||
| 8a8420c4e3 | |||
| b08546294a | |||
| 7af75b6bb6 |
133 changed files with 14694 additions and 298 deletions
10
.cta.json
10
.cta.json
|
|
@ -16,11 +16,5 @@
|
|||
"routerOnly": false,
|
||||
"version": 1,
|
||||
"framework": "react",
|
||||
"chosenAddOns": [
|
||||
"eslint",
|
||||
"nitro",
|
||||
"shadcn",
|
||||
"drizzle",
|
||||
"table"
|
||||
]
|
||||
}
|
||||
"chosenAddOns": ["eslint", "nitro", "shadcn", "drizzle", "table"]
|
||||
}
|
||||
|
|
|
|||
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.output
|
||||
drizzle
|
||||
.env.local
|
||||
.env
|
||||
.playground
|
||||
docs
|
||||
22
.env.example
Normal file
22
.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# PostgreSQL
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=wahoo
|
||||
POSTGRES_PASSWORD=wahoo
|
||||
POSTGRES_DB=wahoo
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Encryption — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
MASTER_ENCRYPTION_KEY=
|
||||
|
||||
# Session — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
SESSION_SECRET=
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Optional: Caddy HTTPS
|
||||
DOMAIN=example.com
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -11,3 +11,6 @@ dist-ssr
|
|||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
.playground
|
||||
test-results
|
||||
uploads
|
||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"jsxSingleQuote": false
|
||||
}
|
||||
121
CLAUDE.md
Normal file
121
CLAUDE.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun --bun run dev # start dev server on :3000
|
||||
bun --bun run build # production build
|
||||
bun --bun run test # run vitest
|
||||
bun --bun run lint # eslint
|
||||
bun --bun run format # prettier + eslint --fix
|
||||
bun --bun run check # prettier check only
|
||||
|
||||
bun --bun run db:generate # generate drizzle migration
|
||||
bun --bun run db:migrate # run migrations
|
||||
bun --bun run db:push # push schema changes (no migration file)
|
||||
bun --bun run db:studio # open drizzle studio
|
||||
```
|
||||
|
||||
Run a single test file: `bun --bun run test src/path/to/file.test.ts`
|
||||
|
||||
Add shadcn components: `pnpm dlx shadcn@latest add <component>` (uses pnpm, not bun)
|
||||
|
||||
## Workplan
|
||||
|
||||
Milestone-by-milestone plan lives in `.playground/plan/`.
|
||||
|
||||
**Before starting any session:**
|
||||
|
||||
1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns.
|
||||
2. Open the relevant milestone file (e.g. `M1-foundations.md`).
|
||||
3. Work tasks top-to-bottom; check boxes as you complete them.
|
||||
4. After each task or at session end, update `STATUS.md`: progress, new blockers, decisions made.
|
||||
|
||||
**Status conventions in milestone files:**
|
||||
|
||||
- `⬜` ready to start
|
||||
- `🟡` concern / needs decision
|
||||
- `🔴` blocked
|
||||
- `✅` done (or check the checkbox)
|
||||
|
||||
**Handoff rule:** never mark a task done unless its acceptance criteria are met. Leave a note in `STATUS.md` if partially done.
|
||||
|
||||
## Documentation
|
||||
|
||||
Project docs live in `docs/`:
|
||||
|
||||
| File | Contents |
|
||||
| ---------------------- | ----------------------------------------------- |
|
||||
| `docs/architecture.md` | System overview, component map, key files |
|
||||
| `docs/api.md` | REST endpoint reference |
|
||||
| `docs/webhooks.md` | Event catalog, HMAC verification snippets |
|
||||
| `docs/security.md` | Encryption model, key rotation, audit log |
|
||||
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
|
||||
|
||||
Update docs when adding endpoints, changing the schema, or changing security behavior.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Stack:** TanStack Start (SSR) + TanStack Router (file-based) + Drizzle ORM + PostgreSQL + Tailwind CSS v4 + shadcn/ui + Nitro (server adapter) + Vite
|
||||
|
||||
**Path alias:** `#/*` → `./src/*` (e.g. `import { cn } from '#/lib/utils'`)
|
||||
|
||||
**Routing** — file-based under `src/routes/`. `routeTree.gen.ts` is auto-generated by the router plugin on dev/build — never edit it manually. Root layout and shell HTML live in `src/routes/__root.tsx` via `shellComponent`.
|
||||
|
||||
**Server functions** — use `createServerFn` from `@tanstack/react-start` for server-side logic callable from client components. Route loaders run server-side automatically.
|
||||
|
||||
**Database** — singleton `db` exported from `src/db/index.ts`, schema defined in `src/db/schema.ts`, migrations output to `./drizzle/`. DB credentials read from `DATABASE_URL` in `.env.local` (takes priority) or `.env`.
|
||||
|
||||
**UI components** — shadcn "new-york" style, zinc base color, CSS variables enabled. Components install to `#/components/ui`. Use `cn()` from `#/lib/utils` for className merging.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Domain actions
|
||||
|
||||
All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`).
|
||||
|
||||
| File | Domain |
|
||||
| ---------------------- | ----------------- |
|
||||
| `actions/apps.ts` | App CRUD |
|
||||
| `actions/api-keys.ts` | API key lifecycle |
|
||||
| `actions/sessions.ts` | Session lifecycle |
|
||||
| `actions/auth.ts` | User lookup, TOTP |
|
||||
| `actions/audit-log.ts` | Audit log queries |
|
||||
|
||||
**Scope pattern** — when a function needs different authorization checks (user-owned vs app-scoped), use a discriminated union scope parameter instead of separate functions:
|
||||
|
||||
```ts
|
||||
type Scope = { appId: string } | { userId: string };
|
||||
export async function findSession(
|
||||
sessionId: string,
|
||||
scope: Scope,
|
||||
): Promise<Session | null>;
|
||||
```
|
||||
|
||||
**Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
|
||||
|
||||
```ts
|
||||
export async function createSession(
|
||||
appId: string,
|
||||
via: "qr" | "pairing",
|
||||
ownerUserId?: string,
|
||||
);
|
||||
// admin: createSession(appId, via, sess.userId)
|
||||
// api key: createSession(appId, via)
|
||||
```
|
||||
|
||||
**Result type** — actions that can fail return a discriminated union, not thrown errors:
|
||||
|
||||
```ts
|
||||
return { ok: false, error: "App not found", status: 404 };
|
||||
return { ok: true, session };
|
||||
// route: if (!result.ok) return jsonError(result.error, result.status)
|
||||
```
|
||||
|
||||
**Audit context** — actions that write audit logs accept an optional `audit?: { ip?: string; userAgent?: string }` param. Routes extract these from request headers and pass them through.
|
||||
|
||||
### Route handlers
|
||||
|
||||
Routes handle: auth check → body parse → input validation → call action → map to HTTP response. No DB calls, no business logic inline.
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM oven/bun:1-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache python3 make g++ linux-headers
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN bun --bun run build
|
||||
|
||||
# ---- runtime ----
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.output .output
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Joshua Tanri
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
61
README.md
61
README.md
|
|
@ -1,4 +1,4 @@
|
|||
Welcome to your new TanStack Start app!
|
||||
Welcome to your new TanStack Start app!
|
||||
|
||||
# Getting Started
|
||||
|
||||
|
|
@ -40,7 +40,6 @@ If you prefer not to use Tailwind CSS:
|
|||
|
||||
## Linting & Formatting
|
||||
|
||||
|
||||
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
|
||||
|
||||
```bash
|
||||
|
|
@ -49,7 +48,6 @@ bun --bun run format
|
|||
bun --bun run check
|
||||
```
|
||||
|
||||
|
||||
## Deploy with Nitro
|
||||
|
||||
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
|
||||
|
|
@ -63,7 +61,6 @@ The build output is a self-contained Node server. To deploy, push the `dist/` di
|
|||
|
||||
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
|
||||
|
||||
|
||||
## Shadcn
|
||||
|
||||
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
||||
|
|
@ -72,8 +69,6 @@ Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
|||
pnpm dlx shadcn@latest add button
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Routing
|
||||
|
||||
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
||||
|
|
@ -111,14 +106,14 @@ In the File Based Routing setup the layout is located in `src/routes/__root.tsx`
|
|||
Here is an example layout that includes a header:
|
||||
|
||||
```tsx
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'My App' },
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: "My App" },
|
||||
],
|
||||
}),
|
||||
shellComponent: ({ children }) => (
|
||||
|
|
@ -138,7 +133,7 @@ export const Route = createRootRoute({
|
|||
</body>
|
||||
</html>
|
||||
),
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||
|
|
@ -148,23 +143,23 @@ More information on layouts can be found in the [Layouts documentation](https://
|
|||
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
||||
|
||||
```tsx
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
const getServerTime = createServerFn({
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
}).handler(async () => {
|
||||
return new Date().toISOString()
|
||||
})
|
||||
return new Date().toISOString();
|
||||
});
|
||||
|
||||
// Use in a component
|
||||
function MyComponent() {
|
||||
const [time, setTime] = useState('')
|
||||
|
||||
const [time, setTime] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getServerTime().then(setTime)
|
||||
}, [])
|
||||
|
||||
return <div>Server time: {time}</div>
|
||||
getServerTime().then(setTime);
|
||||
}, []);
|
||||
|
||||
return <div>Server time: {time}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -173,16 +168,16 @@ function MyComponent() {
|
|||
You can create API routes by using the `server` property in your route definitions:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json } from "@tanstack/react-start";
|
||||
|
||||
export const Route = createFileRoute('/api/hello')({
|
||||
export const Route = createFileRoute("/api/hello")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => json({ message: 'Hello, World!' }),
|
||||
GET: () => json({ message: "Hello, World!" }),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
## Data Fetching
|
||||
|
|
@ -192,25 +187,25 @@ There are multiple ways to fetch data in your application. You can use TanStack
|
|||
For example:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute('/people')({
|
||||
export const Route = createFileRoute("/people")({
|
||||
loader: async () => {
|
||||
const response = await fetch('https://swapi.dev/api/people')
|
||||
return response.json()
|
||||
const response = await fetch("https://swapi.dev/api/people");
|
||||
return response.json();
|
||||
},
|
||||
component: PeopleComponent,
|
||||
})
|
||||
});
|
||||
|
||||
function PeopleComponent() {
|
||||
const data = Route.useLoaderData()
|
||||
const data = Route.useLoaderData();
|
||||
return (
|
||||
<ul>
|
||||
{data.results.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@
|
|||
"hooks": "#/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- path: .env.local
|
||||
required: false
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
env_file:
|
||||
- path: .env.local
|
||||
required: false
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
91
docs/api.md
Normal file
91
docs/api.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# API Reference
|
||||
|
||||
Full interactive spec: `GET /openapi.json` → render at `GET /docs` (available after M6).
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints require:
|
||||
|
||||
```
|
||||
Authorization: Bearer wak_<env>_<32-char-random>
|
||||
```
|
||||
|
||||
Keys are per-App. All requests scoped to the authenticating App — no cross-app access.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Default: 60 req/s burst, 10 req/s sustained per API key. Configurable. Returns `429` with `Retry-After` header on exceed.
|
||||
|
||||
---
|
||||
|
||||
## Sessions
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | ------------------------------------ |
|
||||
| POST | `/v1/sessions` | Create session |
|
||||
| GET | `/v1/sessions` | List sessions |
|
||||
| GET | `/v1/sessions/:id` | Session detail + status |
|
||||
| DELETE | `/v1/sessions/:id` | Delete + wipe credentials |
|
||||
| POST | `/v1/sessions/:id/disconnect` | Graceful disconnect |
|
||||
| GET | `/v1/sessions/:id/qr` | Stream QR code (SSE) |
|
||||
| POST | `/v1/sessions/:id/pair` | Request pairing code `{phone}` |
|
||||
| GET | `/v1/sessions/:id/health` | Status + last_seen + reconnect count |
|
||||
|
||||
**Session states:** `PENDING_QR` → `CONNECTING` → `CONNECTED` → `DISCONNECTED` → `DEAD` / `LOGGED_OUT`
|
||||
|
||||
---
|
||||
|
||||
## Messages
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------------------- | ------------------------------------- |
|
||||
| POST | `/v1/sessions/:id/messages/text` | Send text `{to, text}` |
|
||||
| POST | `/v1/sessions/:id/messages/image` | Send image (multipart or `{to, url}`) |
|
||||
| POST | `/v1/sessions/:id/messages/video` | Send video |
|
||||
| POST | `/v1/sessions/:id/messages/document` | Send document `{to, url, filename}` |
|
||||
| POST | `/v1/sessions/:id/messages/audio` | Send audio `{to, url, voice_note?}` |
|
||||
| POST | `/v1/sessions/:id/messages/broadcast` | Fan-out to multiple JIDs |
|
||||
| POST | `/v1/sessions/:id/presence` | Set presence `{to_jid, type}` |
|
||||
| POST | `/v1/sessions/:id/messages/:msgId/read` | Mark read |
|
||||
| GET | `/v1/sessions/:id/messages` | List `?since=&limit=&direction=` |
|
||||
|
||||
---
|
||||
|
||||
## Contacts
|
||||
|
||||
| Method | Path | Description |
|
||||
| ---------------- | ------------------------------------------- | -------------------------------------- |
|
||||
| GET | `/v1/contacts` | List `?q=&tag=&group_id=` |
|
||||
| POST | `/v1/contacts` | Create |
|
||||
| GET | `/v1/contacts/:id` | Detail |
|
||||
| PATCH | `/v1/contacts/:id` | Update |
|
||||
| DELETE | `/v1/contacts/:id` | Delete |
|
||||
| POST | `/v1/contacts/import` | CSV import (multipart) `?dry_run=true` |
|
||||
| GET | `/v1/contacts/import/:jobId` | Import job status |
|
||||
| POST | `/v1/contacts/sync-from-whatsapp` | Trigger WhatsApp address book sync |
|
||||
| GET | `/v1/contact-groups` | List groups |
|
||||
| POST | `/v1/contact-groups` | Create group |
|
||||
| GET/PATCH/DELETE | `/v1/contact-groups/:id` | Group CRUD |
|
||||
| POST | `/v1/contact-groups/:id/members` | Bulk add contacts |
|
||||
| DELETE | `/v1/contact-groups/:id/members/:contactId` | Remove member |
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Description |
|
||||
| ---------------- | ----------------------------- | ----------------------------------- |
|
||||
| GET | `/v1/webhooks` | List endpoints |
|
||||
| POST | `/v1/webhooks` | Register `{url, events[], secret?}` |
|
||||
| GET/PATCH/DELETE | `/v1/webhooks/:id` | Endpoint CRUD |
|
||||
| GET | `/v1/webhooks/:id/deliveries` | Delivery log (last 100) |
|
||||
| POST | `/v1/webhooks/:id/test` | Fire test payload |
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------- | ---------------------- |
|
||||
| GET | `/health` | Liveness |
|
||||
| GET | `/ready` | Readiness (DB + Redis) |
|
||||
60
docs/architecture.md
Normal file
60
docs/architecture.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Architecture
|
||||
|
||||
**Stack:** Node.js (Fastify) API + TanStack Start dashboard + PostgreSQL + Redis (BullMQ) + Baileys
|
||||
|
||||
```
|
||||
Consumer App
|
||||
│ Authorization: Bearer wak_*
|
||||
▼
|
||||
Fastify REST API ──────────────────────────┐
|
||||
│ │
|
||||
├── Session Manager (Baileys) │
|
||||
│ ├── QR / pairing onboarding │
|
||||
│ ├── Encrypted creds in PG │
|
||||
│ └── Auto-reconnect │
|
||||
│ │
|
||||
├── BullMQ workers │
|
||||
│ ├── send-queue │
|
||||
│ └── webhook-delivery-queue │
|
||||
│ ▼
|
||||
└── PostgreSQL Redis 7+
|
||||
(Drizzle ORM) (BullMQ + cache)
|
||||
|
||||
TanStack Start Dashboard (Platform Operator only)
|
||||
├── /apps – tenant management
|
||||
├── /sessions – session lifecycle + QR UI
|
||||
├── /contacts – contact book + CSV import
|
||||
├── /webhooks – endpoint CRUD + delivery log
|
||||
├── /monitoring – charts (from PG aggregates + Redis)
|
||||
└── /audit-log – append-only admin actions
|
||||
|
||||
Media Storage (pluggable)
|
||||
├── local – STORAGE_LOCAL_PATH, served via Fastify static
|
||||
└── s3 – MinIO / R2 / AWS S3 via presigned URLs
|
||||
|
||||
Observability
|
||||
└── OpenTelemetry SDK → OTLP/HTTP → external collector
|
||||
```
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
Every data row (except `users` and `apps`) carries `app_id`. API key auth resolves `app_id` at request time. No cross-app reads.
|
||||
|
||||
## Encryption model
|
||||
|
||||
- **KEK** — `MASTER_ENCRYPTION_KEY` (env, base64 32 bytes). Never leaves host.
|
||||
- **DEK** — one per App, generated at creation, AES-256-GCM-wrapped by KEK, stored in `apps.dek_encrypted`.
|
||||
- **Encrypted columns** — session credentials, contact PII (name/phone/email/custom_fields), webhook secrets — all encrypted with App DEK.
|
||||
- **Blind index** — phone lookup uses `HMAC-SHA256(e164, index_key)` stored in `contacts.phone_blind_index`.
|
||||
|
||||
## Key files
|
||||
|
||||
| Path | Purpose |
|
||||
| ---------------------------- | -------------------------------------------- |
|
||||
| `src/db/schema.ts` | Drizzle schema (single source of truth) |
|
||||
| `src/lib/crypto.ts` | AES-256-GCM helpers, DEK wrap/unwrap |
|
||||
| `src/lib/blindIndex.ts` | HMAC blind index for phone lookups |
|
||||
| `src/lib/baileys/` | Baileys wrapper + session manager |
|
||||
| `src/lib/storage/` | Pluggable media storage drivers |
|
||||
| `src/lib/events/dispatch.ts` | Event → webhook fan-out |
|
||||
| `src/routes/` | TanStack Start file-based routes (dashboard) |
|
||||
93
docs/deployment.md
Normal file
93
docs/deployment.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker + Docker Compose v2
|
||||
- A server with ≥ 2 vCPU, ≥ 4 GB RAM
|
||||
- A domain name (for TLS)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd wahoo
|
||||
cp .env.example .env
|
||||
# Edit .env — minimum required vars below
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Required environment variables
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://wahoo:secret@postgres:5432/wahoo
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Generate: openssl rand -base64 32
|
||||
MASTER_ENCRYPTION_KEY=<base64 32 bytes>
|
||||
|
||||
# local or s3
|
||||
STORAGE_DRIVER=local
|
||||
STORAGE_LOCAL_PATH=/data/media
|
||||
|
||||
# For s3 driver:
|
||||
# STORAGE_S3_ENDPOINT=https://...
|
||||
# STORAGE_S3_BUCKET=wahoo-media
|
||||
# STORAGE_S3_ACCESS_KEY=...
|
||||
# STORAGE_S3_SECRET_KEY=...
|
||||
|
||||
DASHBOARD_BASE_URL=https://your-domain.example.com
|
||||
|
||||
# Optional
|
||||
MAX_SESSIONS_PER_APP=25
|
||||
WEBHOOK_LOG_RETAIN=100
|
||||
MEDIA_TTL_DAYS=30
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT= # leave empty to disable
|
||||
```
|
||||
|
||||
> **Warning:** `MASTER_ENCRYPTION_KEY` is the root of all encryption. If lost, all session credentials and PII are unrecoverable. Back it up to a secrets manager before first use.
|
||||
|
||||
## TLS / reverse proxy
|
||||
|
||||
TLS termination is expected at your reverse proxy. Caddy example:
|
||||
|
||||
```caddyfile
|
||||
your-domain.example.com {
|
||||
reverse_proxy api:3001
|
||||
}
|
||||
```
|
||||
|
||||
## Backup procedure
|
||||
|
||||
Back up all three:
|
||||
|
||||
1. **PostgreSQL dump** — `pg_dump $DATABASE_URL > backup.sql`
|
||||
2. **Media storage** — `rsync -av /data/media/ backup/media/` (or S3 snapshot)
|
||||
3. **Master key** — `MASTER_ENCRYPTION_KEY` from `.env` stored in a secrets manager
|
||||
|
||||
## Restore to new host
|
||||
|
||||
1. Provision new host, install Docker.
|
||||
2. Clone repo, copy `.env` with original `MASTER_ENCRYPTION_KEY`.
|
||||
3. Restore Postgres: `psql $DATABASE_URL < backup.sql`
|
||||
4. Restore media to `STORAGE_LOCAL_PATH` (or point to S3 bucket).
|
||||
5. `docker compose up -d` — all sessions resume automatically.
|
||||
|
||||
## Encryption key rotation
|
||||
|
||||
1. Ensure Postgres + media are backed up.
|
||||
2. Dashboard → App → "Rotate Encryption Key".
|
||||
3. Monitor rotation job progress in dashboard.
|
||||
4. After completion, old DEK is destroyed.
|
||||
|
||||
> Do not interrupt a rotation job in progress.
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
bun --bun run db:migrate # inside api container or locally
|
||||
```
|
||||
|
||||
Pin Baileys version in `package.json` — do not auto-upgrade. Check release notes for protocol changes before upgrading.
|
||||
64
docs/security.md
Normal file
64
docs/security.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Security Model
|
||||
|
||||
## Encryption at rest
|
||||
|
||||
All sensitive data encrypted with AES-256-GCM before writing to PostgreSQL.
|
||||
|
||||
**Key hierarchy:**
|
||||
|
||||
```
|
||||
MASTER_ENCRYPTION_KEY (KEK) ← env var, base64 32 bytes
|
||||
└── wraps App DEK (one per App)
|
||||
├── session credentials
|
||||
├── contact PII (name, phone, email, custom_fields)
|
||||
└── webhook signing secrets
|
||||
```
|
||||
|
||||
**Phone lookup:** `HMAC-SHA256(e164_phone, index_key)` stored as blind index. Enables exact-match search without decrypting the phone column.
|
||||
|
||||
## API authentication
|
||||
|
||||
- Header: `Authorization: Bearer wak_<env>_<32-char-random>`
|
||||
- Random portion stored as Argon2id hash — plaintext never persisted after creation.
|
||||
- Multiple active keys per App for zero-downtime rotation.
|
||||
- Rate limit: 60 req/s burst / 10 req/s sustained (configurable).
|
||||
|
||||
## Dashboard authentication
|
||||
|
||||
- Argon2id password hashing.
|
||||
- Optional TOTP 2FA.
|
||||
- Session cookies: HttpOnly, Secure, SameSite=Lax.
|
||||
|
||||
## Webhook payload integrity
|
||||
|
||||
Every delivery signed: `HMAC-SHA256(raw_body + "." + timestamp, endpoint_secret)`.
|
||||
Header: `X-Signature: sha256=<hex>`.
|
||||
|
||||
Verify (Node.js):
|
||||
|
||||
```js
|
||||
const crypto = require("crypto");
|
||||
function verify(secret, body, timestamp, sig) {
|
||||
const expected = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(body + "." + timestamp)
|
||||
.digest("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||
}
|
||||
```
|
||||
|
||||
## Key rotation
|
||||
|
||||
Admin action `POST /v1/admin/apps/:id/rotate-encryption-key` re-encrypts all app data with a new DEK in a background job. Old DEK destroyed after job completes. See `docs/deployment.md` for backup procedure — **back up master key and Postgres before rotating**.
|
||||
|
||||
## Network isolation
|
||||
|
||||
All inter-service traffic stays on Docker network. Only API + dashboard ports exposed externally. TLS termination at reverse proxy (Caddy / Traefik / nginx).
|
||||
|
||||
## Secrets never logged
|
||||
|
||||
Structured logger uses an explicit field allowlist — no API keys, DEKs, or signing secrets appear in logs (SEC-6).
|
||||
|
||||
## Audit log
|
||||
|
||||
Append-only `audit_log` table. Captured actions: login, App CRUD, ApiKey create/rotate/revoke, Session create/delete, webhook CRUD, encryption key rotation, contact bulk ops, config changes.
|
||||
158
docs/webhooks.md
Normal file
158
docs/webhooks.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Webhooks
|
||||
|
||||
## Registering an endpoint
|
||||
|
||||
```http
|
||||
POST /v1/webhooks
|
||||
Authorization: Bearer wak_prod_...
|
||||
|
||||
{
|
||||
"url": "https://your-app.example.com/hooks/whatsapp",
|
||||
"events": ["message.received", "session.status"],
|
||||
"secret": "optional-custom-secret"
|
||||
}
|
||||
```
|
||||
|
||||
If `secret` is omitted, one is generated. **Store it — shown once.**
|
||||
|
||||
---
|
||||
|
||||
## Event types
|
||||
|
||||
| Event | When fired |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| `session.status` | Any session state transition |
|
||||
| `session.deleted` | Session deleted via API |
|
||||
| `message.received` | Inbound message |
|
||||
| `message.sent` | Outbound message accepted by WhatsApp |
|
||||
| `message.delivered` | WhatsApp delivery receipt received |
|
||||
| `message.read` | Read receipt received |
|
||||
| `message.failed` | Send failed |
|
||||
| `presence.update` | Contact presence change |
|
||||
| `contact.created` | New contact added |
|
||||
| `contact.updated` | Contact modified |
|
||||
|
||||
---
|
||||
|
||||
## Payload structure
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.received",
|
||||
"event_id": "evt_01hw...",
|
||||
"app_id": "app_01hw...",
|
||||
"timestamp": "2026-05-14T10:00:00Z",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
`data` shape varies per event type — see `/docs` (OpenAPI) for full schemas.
|
||||
|
||||
---
|
||||
|
||||
## Standard headers
|
||||
|
||||
Every delivery includes:
|
||||
|
||||
```
|
||||
X-Event-Id: evt_01hw...
|
||||
X-Event-Type: message.received
|
||||
X-App-Id: app_01hw...
|
||||
X-Signature: sha256=<hex>
|
||||
X-Timestamp: 1715684400
|
||||
User-Agent: wagateway/1.0
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifying signatures
|
||||
|
||||
Signature covers: `raw_body + "." + X-Timestamp`.
|
||||
|
||||
**Node.js**
|
||||
|
||||
```js
|
||||
const crypto = require("crypto");
|
||||
|
||||
function verifyWebhook(secret, rawBody, timestamp, signature) {
|
||||
const expected = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(rawBody + "." + timestamp)
|
||||
.digest("hex");
|
||||
const received = signature.replace("sha256=", "");
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected, "hex"),
|
||||
Buffer.from(received, "hex"),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Python**
|
||||
|
||||
```python
|
||||
import hmac, hashlib
|
||||
|
||||
def verify_webhook(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
|
||||
expected = hmac.new(
|
||||
secret.encode(), raw_body + b'.' + timestamp.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
received = signature.replace('sha256=', '')
|
||||
return hmac.compare_digest(expected, received)
|
||||
```
|
||||
|
||||
**Go**
|
||||
|
||||
```go
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func VerifyWebhook(secret, body, timestamp, signature string) bool {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(body + "." + timestamp))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
received := strings.TrimPrefix(signature, "sha256=")
|
||||
return hmac.Equal([]byte(expected), []byte(received))
|
||||
}
|
||||
```
|
||||
|
||||
**PHP**
|
||||
|
||||
```php
|
||||
function verifyWebhook(string $secret, string $rawBody, string $timestamp, string $signature): bool {
|
||||
$expected = hash_hmac('sha256', $rawBody . '.' . $timestamp, $secret);
|
||||
$received = ltrim($signature, 'sha256=');
|
||||
return hash_equals($expected, $received);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Retry schedule
|
||||
|
||||
Failed deliveries retry with exponential backoff:
|
||||
|
||||
| Attempt | Delay after previous |
|
||||
| ------- | -------------------- |
|
||||
| 1 | immediate |
|
||||
| 2 | 30 seconds |
|
||||
| 3 | 2 minutes |
|
||||
| 4 | 10 minutes |
|
||||
| 5 | 1 hour |
|
||||
| 6 | 6 hours |
|
||||
| 7 | 24 hours |
|
||||
|
||||
After 50 consecutive failures the endpoint is marked **unhealthy** and delivery pauses. Re-enable from the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Replay / testing
|
||||
|
||||
```http
|
||||
POST /v1/webhooks/:id/test
|
||||
```
|
||||
|
||||
Fires a synthetic `message.received` payload to the endpoint and returns the HTTP response.
|
||||
|
|
@ -1,13 +1,26 @@
|
|||
import { config } from 'dotenv'
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
import { config } from "dotenv";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
config({ path: ['.env.local', '.env'] })
|
||||
config({ path: [".env.local", ".env"] });
|
||||
|
||||
const {
|
||||
POSTGRES_USER,
|
||||
POSTGRES_PASSWORD,
|
||||
POSTGRES_HOST,
|
||||
POSTGRES_PORT,
|
||||
POSTGRES_DB,
|
||||
} = process.env;
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
out: "./drizzle",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
host: POSTGRES_HOST ?? "localhost",
|
||||
port: Number(POSTGRES_PORT ?? 5432),
|
||||
user: POSTGRES_USER ?? "wahoo",
|
||||
password: POSTGRES_PASSWORD ?? "wahoo",
|
||||
database: POSTGRES_DB ?? "wahoo",
|
||||
ssl: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
|
|
|||
51
drizzle/0000_clean_wong.sql
Normal file
51
drizzle/0000_clean_wong.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
CREATE TYPE "public"."actor_type" AS ENUM('user', 'api_key');--> statement-breakpoint
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"app_id" uuid NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"key_prefix" text NOT NULL,
|
||||
"label" text DEFAULT '' NOT NULL,
|
||||
"revoked_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "apps" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"owner_user_id" uuid NOT NULL,
|
||||
"dek_encrypted" text NOT NULL,
|
||||
"max_sessions" text DEFAULT '10' NOT NULL,
|
||||
"deleted_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"actor_type" "actor_type" NOT NULL,
|
||||
"actor_id" uuid NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"resource_type" text NOT NULL,
|
||||
"resource_id" text,
|
||||
"ip" text,
|
||||
"user_agent" text,
|
||||
"diff_before" jsonb,
|
||||
"diff_after" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"totp_secret" text,
|
||||
"totp_enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "apps" ADD CONSTRAINT "apps_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "api_keys_app_id_idx" ON "api_keys" USING btree ("app_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_actor_idx" ON "audit_log" USING btree ("actor_type","actor_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_created_at_idx" ON "audit_log" USING btree ("created_at");
|
||||
27
drizzle/0001_outgoing_joseph.sql
Normal file
27
drizzle/0001_outgoing_joseph.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TYPE "public"."session_status" AS ENUM('PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD');--> statement-breakpoint
|
||||
CREATE TABLE "session_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" uuid NOT NULL,
|
||||
"key_type" text NOT NULL,
|
||||
"key_id" text NOT NULL,
|
||||
"value_encrypted" text NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "session_keys_unique" UNIQUE("session_id","key_type","key_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"app_id" uuid NOT NULL,
|
||||
"status" "session_status" DEFAULT 'PENDING_QR' NOT NULL,
|
||||
"phone_number" text,
|
||||
"display_name" text,
|
||||
"credentials_encrypted" text,
|
||||
"reconnect_count" integer DEFAULT 0 NOT NULL,
|
||||
"last_seen" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "session_keys" ADD CONSTRAINT "session_keys_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "sessions_app_id_idx" ON "sessions" USING btree ("app_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_status_idx" ON "sessions" USING btree ("status");
|
||||
31
drizzle/0002_orange_clint_barton.sql
Normal file
31
drizzle/0002_orange_clint_barton.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
CREATE TYPE "public"."message_direction" AS ENUM('in', 'out');--> statement-breakpoint
|
||||
CREATE TYPE "public"."message_status" AS ENUM('queued', 'sent', 'delivered', 'read', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."message_type" AS ENUM('text', 'image', 'video', 'document', 'audio');--> statement-breakpoint
|
||||
CREATE TABLE "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"app_id" uuid NOT NULL,
|
||||
"session_id" uuid NOT NULL,
|
||||
"whatsapp_msg_id" text NOT NULL,
|
||||
"direction" "message_direction" NOT NULL,
|
||||
"type" "message_type" NOT NULL,
|
||||
"to_jid" text,
|
||||
"from_jid" text,
|
||||
"body_encrypted" text,
|
||||
"media_url" text,
|
||||
"media_mime" text,
|
||||
"media_key" text,
|
||||
"media_size" bigint,
|
||||
"status" "message_status" DEFAULT 'queued' NOT NULL,
|
||||
"is_group" boolean DEFAULT false NOT NULL,
|
||||
"quoted_msg_id" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "auto_read" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "messages_session_id_idx" ON "messages" USING btree ("session_id");--> statement-breakpoint
|
||||
CREATE INDEX "messages_app_id_idx" ON "messages" USING btree ("app_id");--> statement-breakpoint
|
||||
CREATE INDEX "messages_created_at_idx" ON "messages" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "messages_whatsapp_msg_id_idx" ON "messages" USING btree ("whatsapp_msg_id");
|
||||
3
drizzle/0003_clammy_azazel.sql
Normal file
3
drizzle/0003_clammy_azazel.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "apps" ALTER COLUMN "max_sessions" SET DATA TYPE integer;--> statement-breakpoint
|
||||
ALTER TABLE "apps" ALTER COLUMN "max_sessions" SET DEFAULT 10;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "last_used_totp_token" text;
|
||||
369
drizzle/meta/0000_snapshot.json
Normal file
369
drizzle/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
{
|
||||
"id": "26e57c54-3075-4308-8de3-493327b47e7f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"api_keys_app_id_idx": {
|
||||
"name": "api_keys_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"api_keys_app_id_apps_id_fk": {
|
||||
"name": "api_keys_app_id_apps_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apps": {
|
||||
"name": "apps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_user_id": {
|
||||
"name": "owner_user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dek_encrypted": {
|
||||
"name": "dek_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_sessions": {
|
||||
"name": "max_sessions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'10'"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apps_owner_user_id_users_id_fk": {
|
||||
"name": "apps_owner_user_id_users_id_fk",
|
||||
"tableFrom": "apps",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["owner_user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "actor_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_before": {
|
||||
"name": "diff_before",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_after": {
|
||||
"name": "diff_after",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"audit_log_actor_idx": {
|
||||
"name": "audit_log_actor_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "actor_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "actor_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_action_idx": {
|
||||
"name": "audit_log_action_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "action",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_created_at_idx": {
|
||||
"name": "audit_log_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"totp_secret": {
|
||||
"name": "totp_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"totp_enabled": {
|
||||
"name": "totp_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.actor_type": {
|
||||
"name": "actor_type",
|
||||
"schema": "public",
|
||||
"values": ["user", "api_key"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
562
drizzle/meta/0001_snapshot.json
Normal file
562
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
{
|
||||
"id": "74058639-82f8-4a6d-88b8-8776d8badccc",
|
||||
"prevId": "26e57c54-3075-4308-8de3-493327b47e7f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"api_keys_app_id_idx": {
|
||||
"name": "api_keys_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"api_keys_app_id_apps_id_fk": {
|
||||
"name": "api_keys_app_id_apps_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apps": {
|
||||
"name": "apps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_user_id": {
|
||||
"name": "owner_user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dek_encrypted": {
|
||||
"name": "dek_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_sessions": {
|
||||
"name": "max_sessions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'10'"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apps_owner_user_id_users_id_fk": {
|
||||
"name": "apps_owner_user_id_users_id_fk",
|
||||
"tableFrom": "apps",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["owner_user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "actor_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_before": {
|
||||
"name": "diff_before",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_after": {
|
||||
"name": "diff_after",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"audit_log_actor_idx": {
|
||||
"name": "audit_log_actor_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "actor_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "actor_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_action_idx": {
|
||||
"name": "audit_log_action_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "action",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_created_at_idx": {
|
||||
"name": "audit_log_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session_keys": {
|
||||
"name": "session_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_type": {
|
||||
"name": "key_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value_encrypted": {
|
||||
"name": "value_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_keys_session_id_sessions_id_fk": {
|
||||
"name": "session_keys_session_id_sessions_id_fk",
|
||||
"tableFrom": "session_keys",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": ["session_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_keys_unique": {
|
||||
"name": "session_keys_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["session_id", "key_type", "key_id"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "session_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'PENDING_QR'"
|
||||
},
|
||||
"phone_number": {
|
||||
"name": "phone_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"credentials_encrypted": {
|
||||
"name": "credentials_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reconnect_count": {
|
||||
"name": "reconnect_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_app_id_idx": {
|
||||
"name": "sessions_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sessions_status_idx": {
|
||||
"name": "sessions_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_app_id_apps_id_fk": {
|
||||
"name": "sessions_app_id_apps_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"totp_secret": {
|
||||
"name": "totp_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"totp_enabled": {
|
||||
"name": "totp_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.actor_type": {
|
||||
"name": "actor_type",
|
||||
"schema": "public",
|
||||
"values": ["user", "api_key"]
|
||||
},
|
||||
"public.session_status": {
|
||||
"name": "session_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"PENDING_QR",
|
||||
"PENDING_PAIRING",
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"LOGGED_OUT",
|
||||
"DEAD"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
793
drizzle/meta/0002_snapshot.json
Normal file
793
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,793 @@
|
|||
{
|
||||
"id": "20e640d9-30d1-49e7-a0a0-64b86ebd08fc",
|
||||
"prevId": "74058639-82f8-4a6d-88b8-8776d8badccc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"api_keys_app_id_idx": {
|
||||
"name": "api_keys_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"api_keys_app_id_apps_id_fk": {
|
||||
"name": "api_keys_app_id_apps_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apps": {
|
||||
"name": "apps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_user_id": {
|
||||
"name": "owner_user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dek_encrypted": {
|
||||
"name": "dek_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_sessions": {
|
||||
"name": "max_sessions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'10'"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apps_owner_user_id_users_id_fk": {
|
||||
"name": "apps_owner_user_id_users_id_fk",
|
||||
"tableFrom": "apps",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["owner_user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "actor_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_before": {
|
||||
"name": "diff_before",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_after": {
|
||||
"name": "diff_after",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"audit_log_actor_idx": {
|
||||
"name": "audit_log_actor_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "actor_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "actor_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_action_idx": {
|
||||
"name": "audit_log_action_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "action",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_created_at_idx": {
|
||||
"name": "audit_log_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.messages": {
|
||||
"name": "messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"whatsapp_msg_id": {
|
||||
"name": "whatsapp_msg_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "message_direction",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "message_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"to_jid": {
|
||||
"name": "to_jid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"from_jid": {
|
||||
"name": "from_jid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"body_encrypted": {
|
||||
"name": "body_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_url": {
|
||||
"name": "media_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_mime": {
|
||||
"name": "media_mime",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_key": {
|
||||
"name": "media_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_size": {
|
||||
"name": "media_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "message_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"is_group": {
|
||||
"name": "is_group",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"quoted_msg_id": {
|
||||
"name": "quoted_msg_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"messages_session_id_idx": {
|
||||
"name": "messages_session_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "session_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_app_id_idx": {
|
||||
"name": "messages_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_created_at_idx": {
|
||||
"name": "messages_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_whatsapp_msg_id_idx": {
|
||||
"name": "messages_whatsapp_msg_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "whatsapp_msg_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"messages_app_id_apps_id_fk": {
|
||||
"name": "messages_app_id_apps_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_session_id_sessions_id_fk": {
|
||||
"name": "messages_session_id_sessions_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": ["session_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session_keys": {
|
||||
"name": "session_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_type": {
|
||||
"name": "key_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value_encrypted": {
|
||||
"name": "value_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_keys_session_id_sessions_id_fk": {
|
||||
"name": "session_keys_session_id_sessions_id_fk",
|
||||
"tableFrom": "session_keys",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": ["session_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_keys_unique": {
|
||||
"name": "session_keys_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["session_id", "key_type", "key_id"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "session_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'PENDING_QR'"
|
||||
},
|
||||
"phone_number": {
|
||||
"name": "phone_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"credentials_encrypted": {
|
||||
"name": "credentials_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reconnect_count": {
|
||||
"name": "reconnect_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"auto_read": {
|
||||
"name": "auto_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_app_id_idx": {
|
||||
"name": "sessions_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sessions_status_idx": {
|
||||
"name": "sessions_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_app_id_apps_id_fk": {
|
||||
"name": "sessions_app_id_apps_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"totp_secret": {
|
||||
"name": "totp_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"totp_enabled": {
|
||||
"name": "totp_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.actor_type": {
|
||||
"name": "actor_type",
|
||||
"schema": "public",
|
||||
"values": ["user", "api_key"]
|
||||
},
|
||||
"public.message_direction": {
|
||||
"name": "message_direction",
|
||||
"schema": "public",
|
||||
"values": ["in", "out"]
|
||||
},
|
||||
"public.message_status": {
|
||||
"name": "message_status",
|
||||
"schema": "public",
|
||||
"values": ["queued", "sent", "delivered", "read", "failed"]
|
||||
},
|
||||
"public.message_type": {
|
||||
"name": "message_type",
|
||||
"schema": "public",
|
||||
"values": ["text", "image", "video", "document", "audio"]
|
||||
},
|
||||
"public.session_status": {
|
||||
"name": "session_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"PENDING_QR",
|
||||
"PENDING_PAIRING",
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"LOGGED_OUT",
|
||||
"DEAD"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
847
drizzle/meta/0003_snapshot.json
Normal file
847
drizzle/meta/0003_snapshot.json
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
{
|
||||
"id": "9ada70d3-912d-49ea-ab7d-a01d95e09c44",
|
||||
"prevId": "20e640d9-30d1-49e7-a0a0-64b86ebd08fc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"api_keys_app_id_idx": {
|
||||
"name": "api_keys_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"api_keys_app_id_apps_id_fk": {
|
||||
"name": "api_keys_app_id_apps_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apps": {
|
||||
"name": "apps",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_user_id": {
|
||||
"name": "owner_user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dek_encrypted": {
|
||||
"name": "dek_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_sessions": {
|
||||
"name": "max_sessions",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apps_owner_user_id_users_id_fk": {
|
||||
"name": "apps_owner_user_id_users_id_fk",
|
||||
"tableFrom": "apps",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "actor_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_before": {
|
||||
"name": "diff_before",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"diff_after": {
|
||||
"name": "diff_after",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"audit_log_actor_idx": {
|
||||
"name": "audit_log_actor_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "actor_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "actor_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_action_idx": {
|
||||
"name": "audit_log_action_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "action",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"audit_log_created_at_idx": {
|
||||
"name": "audit_log_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.messages": {
|
||||
"name": "messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"whatsapp_msg_id": {
|
||||
"name": "whatsapp_msg_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "message_direction",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "message_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"to_jid": {
|
||||
"name": "to_jid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"from_jid": {
|
||||
"name": "from_jid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"body_encrypted": {
|
||||
"name": "body_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_url": {
|
||||
"name": "media_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_mime": {
|
||||
"name": "media_mime",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_key": {
|
||||
"name": "media_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"media_size": {
|
||||
"name": "media_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "message_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"is_group": {
|
||||
"name": "is_group",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"quoted_msg_id": {
|
||||
"name": "quoted_msg_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"messages_session_id_idx": {
|
||||
"name": "messages_session_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "session_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_app_id_idx": {
|
||||
"name": "messages_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_created_at_idx": {
|
||||
"name": "messages_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"messages_whatsapp_msg_id_idx": {
|
||||
"name": "messages_whatsapp_msg_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "whatsapp_msg_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"messages_app_id_apps_id_fk": {
|
||||
"name": "messages_app_id_apps_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_session_id_sessions_id_fk": {
|
||||
"name": "messages_session_id_sessions_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session_keys": {
|
||||
"name": "session_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_type": {
|
||||
"name": "key_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value_encrypted": {
|
||||
"name": "value_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_keys_session_id_sessions_id_fk": {
|
||||
"name": "session_keys_session_id_sessions_id_fk",
|
||||
"tableFrom": "session_keys",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_keys_unique": {
|
||||
"name": "session_keys_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"session_id",
|
||||
"key_type",
|
||||
"key_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"app_id": {
|
||||
"name": "app_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "session_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'PENDING_QR'"
|
||||
},
|
||||
"phone_number": {
|
||||
"name": "phone_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"credentials_encrypted": {
|
||||
"name": "credentials_encrypted",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reconnect_count": {
|
||||
"name": "reconnect_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"auto_read": {
|
||||
"name": "auto_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_app_id_idx": {
|
||||
"name": "sessions_app_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "app_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sessions_status_idx": {
|
||||
"name": "sessions_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_app_id_apps_id_fk": {
|
||||
"name": "sessions_app_id_apps_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"totp_secret": {
|
||||
"name": "totp_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"totp_enabled": {
|
||||
"name": "totp_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"last_used_totp_token": {
|
||||
"name": "last_used_totp_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.actor_type": {
|
||||
"name": "actor_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"user",
|
||||
"api_key"
|
||||
]
|
||||
},
|
||||
"public.message_direction": {
|
||||
"name": "message_direction",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"in",
|
||||
"out"
|
||||
]
|
||||
},
|
||||
"public.message_status": {
|
||||
"name": "message_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"queued",
|
||||
"sent",
|
||||
"delivered",
|
||||
"read",
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"public.message_type": {
|
||||
"name": "message_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"document",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
"public.session_status": {
|
||||
"name": "session_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"PENDING_QR",
|
||||
"PENDING_PAIRING",
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"LOGGED_OUT",
|
||||
"DEAD"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
34
drizzle/meta/_journal.json
Normal file
34
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1778765149216,
|
||||
"tag": "0000_clean_wong",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1778768446063,
|
||||
"tag": "0001_outgoing_joseph",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1778829335931,
|
||||
"tag": "0002_orange_clint_barton",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1778838712770,
|
||||
"tag": "0003_clammy_azazel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
// @ts-check
|
||||
|
||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||
import { tanstackConfig } from "@tanstack/eslint-config";
|
||||
|
||||
export default [
|
||||
...tanstackConfig,
|
||||
{
|
||||
rules: {
|
||||
'import/no-cycle': 'off',
|
||||
'import/order': 'off',
|
||||
'sort-imports': 'off',
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'pnpm/json-enforce-catalog': 'off',
|
||||
"import/no-cycle": "off",
|
||||
"import/order": "off",
|
||||
"sort-imports": "off",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"pnpm/json-enforce-catalog": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['eslint.config.js', 'prettier.config.js'],
|
||||
ignores: ["eslint.config.js", "prettier.config.js", ".output/**"],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
|
|
|||
23
package.json
23
package.json
|
|
@ -20,39 +20,58 @@
|
|||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1047.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1047.0",
|
||||
"@faker-js/faker": "^10.3.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/match-sorter-utils": "latest",
|
||||
"@tanstack/react-devtools": "latest",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-router": "latest",
|
||||
"@tanstack/react-router-devtools": "latest",
|
||||
"@tanstack/react-router-ssr-query": "latest",
|
||||
"@tanstack/react-start": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
||||
"argon2": "^0.44.0",
|
||||
"bullmq": "^5.76.8",
|
||||
"busboy": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mime": "^4.1.0",
|
||||
"nitro": "npm:nitro-nightly@latest",
|
||||
"otpauth": "^9.5.1",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"rate-limiter-flexible": "^11.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.3.6"
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/devtools-vite": "latest",
|
||||
"@tanstack/eslint-config": "latest",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^9.20.0",
|
||||
|
|
@ -69,4 +88,4 @@
|
|||
"lightningcss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
playwright.config.ts
Normal file
19
playwright.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
headless: true,
|
||||
channel: "chrome",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chrome",
|
||||
use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||
},
|
||||
],
|
||||
});
|
||||
21
scripts/seed-test-user.ts
Normal file
21
scripts/seed-test-user.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { users } from "../src/db/schema.ts";
|
||||
import argon2 from "argon2";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: [".env.local", ".env"] });
|
||||
|
||||
const db = drizzle(
|
||||
`postgresql://${process.env.POSTGRES_USER ?? "postgres"}:${process.env.POSTGRES_PASSWORD ?? "postgres"}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB ?? "wahoo"}`,
|
||||
{ schema: { users } },
|
||||
);
|
||||
|
||||
const email = "test@wahoo.local";
|
||||
const password = "TestPassword123!";
|
||||
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
|
||||
await db.insert(users).values({ email, passwordHash }).onConflictDoNothing();
|
||||
|
||||
console.log(`Seeded user: ${email} / ${password}`);
|
||||
process.exit(0);
|
||||
225
src/components/layout/sidebar.tsx
Normal file
225
src/components/layout/sidebar.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { Link, useLocation, useRouter } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Zap,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
ChevronLeft,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { appQuery } from "#/lib/query";
|
||||
import { cn } from "#/lib/utils";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const router = useRouter();
|
||||
|
||||
// Detect app context: /apps/<uuid>[/*]
|
||||
const appContextMatch = location.pathname.match(
|
||||
/^\/apps\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\/|$)/i,
|
||||
);
|
||||
const currentAppId = appContextMatch?.[1] ?? null;
|
||||
|
||||
const { data: currentApp } = useQuery({
|
||||
...appQuery(currentAppId ?? ""),
|
||||
enabled: !!currentAppId,
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
router.navigate({ to: "/login" });
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-[220px] h-screen flex flex-col bg-zinc-950 border-r border-zinc-900 flex-shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-zinc-900">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/30 flex-shrink-0">
|
||||
<MessageSquare
|
||||
size={14}
|
||||
className="text-white fill-white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-white font-semibold text-[15px] tracking-tight"
|
||||
style={{ fontFamily: "Fraunces, Georgia, serif" }}
|
||||
>
|
||||
wahoo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-3 overflow-y-auto">
|
||||
{currentAppId ? (
|
||||
<AppContextNav
|
||||
appId={currentAppId}
|
||||
appName={currentApp?.name}
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
) : (
|
||||
<GlobalNav pathname={location.pathname} />
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-2 pb-3 border-t border-zinc-900 pt-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium text-zinc-500 hover:text-white hover:bg-zinc-900 w-full transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<LogOut size={14} strokeWidth={1.75} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalNav({ pathname }: { pathname: string }) {
|
||||
const items = [
|
||||
{ to: "/apps" as const, label: "Apps", icon: LayoutGrid },
|
||||
{ to: "/audit-log" as const, label: "Audit Log", icon: ScrollText },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{items.map(({ to, label, icon: Icon }) => {
|
||||
const isActive =
|
||||
to === "/apps"
|
||||
? pathname === "/apps" || pathname === "/apps/"
|
||||
: pathname === to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full",
|
||||
isActive
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
strokeWidth={isActive ? 2 : 1.75}
|
||||
className={isActive ? "text-emerald-400" : ""}
|
||||
/>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContextNav({
|
||||
appId,
|
||||
appName,
|
||||
pathname,
|
||||
}: {
|
||||
appId: string;
|
||||
appName: string | undefined;
|
||||
pathname: string;
|
||||
}) {
|
||||
const keysPath = `/apps/${appId}/`;
|
||||
const sessionsPath = `/apps/${appId}/sessions`;
|
||||
|
||||
const isKeys =
|
||||
pathname === `/apps/${appId}` ||
|
||||
pathname === `/apps/${appId}/` ||
|
||||
(pathname.startsWith(`/apps/${appId}/`) &&
|
||||
!pathname.startsWith(sessionsPath));
|
||||
const isSessions = pathname.startsWith(sessionsPath);
|
||||
const isAuditLog = pathname === "/audit-log";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Back to Apps */}
|
||||
<Link
|
||||
to="/apps"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900 transition-colors duration-150 mb-1"
|
||||
>
|
||||
<ChevronLeft size={12} strokeWidth={2} />
|
||||
All apps
|
||||
</Link>
|
||||
|
||||
{/* App name header */}
|
||||
<div className="px-3 py-1.5 mb-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<LayoutGrid
|
||||
size={10}
|
||||
className="text-emerald-400"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-zinc-300 truncate">
|
||||
{appName ?? "…"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App-scoped items */}
|
||||
<Link
|
||||
to="/apps/$appId/"
|
||||
params={{ appId }}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
|
||||
isKeys
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||
)}
|
||||
>
|
||||
<Key
|
||||
size={13}
|
||||
strokeWidth={isKeys ? 2 : 1.75}
|
||||
className={isKeys ? "text-emerald-400" : ""}
|
||||
/>
|
||||
API Keys
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/apps/$appId/sessions/"
|
||||
params={{ appId }}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
|
||||
isSessions
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||
)}
|
||||
>
|
||||
<Zap
|
||||
size={13}
|
||||
strokeWidth={isSessions ? 2 : 1.75}
|
||||
className={isSessions ? "text-emerald-400" : ""}
|
||||
/>
|
||||
Sessions
|
||||
</Link>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-zinc-900 my-2 mx-1" />
|
||||
|
||||
{/* Global items still accessible */}
|
||||
<Link
|
||||
to="/audit-log"
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150",
|
||||
isAuditLog
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||
)}
|
||||
>
|
||||
<ScrollText
|
||||
size={13}
|
||||
strokeWidth={isAuditLog ? 2 : 1.75}
|
||||
className={isAuditLog ? "text-emerald-400" : ""}
|
||||
/>
|
||||
Audit Log
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/badge.tsx
Normal file
49
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
65
src/components/ui/button.tsx
Normal file
65
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
import { Button } from "#/components/ui/button.tsx";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
114
src/components/ui/table.tsx
Normal file
114
src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "#/lib/utils.ts";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
|
@ -1,5 +1,21 @@
|
|||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
|
||||
import * as schema from './schema.ts'
|
||||
import * as schema from "./schema.ts";
|
||||
|
||||
export const db = drizzle(process.env.DATABASE_URL!, { schema })
|
||||
function getDbUrl(): string {
|
||||
const {
|
||||
POSTGRES_USER,
|
||||
POSTGRES_PASSWORD,
|
||||
POSTGRES_HOST,
|
||||
POSTGRES_PORT,
|
||||
POSTGRES_DB,
|
||||
} = process.env;
|
||||
const user = POSTGRES_USER ?? "wahoo";
|
||||
const pass = POSTGRES_PASSWORD ?? "wahoo";
|
||||
const host = POSTGRES_HOST ?? "localhost";
|
||||
const port = POSTGRES_PORT ?? "5432";
|
||||
const db = POSTGRES_DB ?? "wahoo";
|
||||
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}`;
|
||||
}
|
||||
|
||||
export const db = drizzle(getDbUrl(), { schema });
|
||||
|
|
|
|||
216
src/db/schema.ts
216
src/db/schema.ts
|
|
@ -1,7 +1,211 @@
|
|||
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
bigint,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
export const todos = pgTable('todos', {
|
||||
id: serial().primaryKey(),
|
||||
title: text().notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
})
|
||||
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: boolean("totp_enabled").notNull().default(false),
|
||||
lastUsedTotpToken: text("last_used_totp_token"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const apps = pgTable("apps", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
ownerUserId: uuid("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
dekEncrypted: text("dek_encrypted").notNull(),
|
||||
maxSessions: integer("max_sessions").notNull().default(10),
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const apiKeys = pgTable(
|
||||
"api_keys",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
keyHash: text("key_hash").notNull(),
|
||||
keyPrefix: text("key_prefix").notNull(),
|
||||
label: text("label").notNull().default(""),
|
||||
revokedAt: timestamp("revoked_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index("api_keys_app_id_idx").on(t.appId)],
|
||||
);
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
apps: many(apps),
|
||||
}));
|
||||
|
||||
export const appsRelations = relations(apps, ({ one, many }) => ({
|
||||
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
|
||||
apiKeys: many(apiKeys),
|
||||
sessions: many(sessions),
|
||||
messages: many(messages),
|
||||
}));
|
||||
|
||||
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
|
||||
}));
|
||||
|
||||
export const sessionStatusEnum = pgEnum("session_status", [
|
||||
"PENDING_QR",
|
||||
"PENDING_PAIRING",
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"LOGGED_OUT",
|
||||
"DEAD",
|
||||
]);
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
status: sessionStatusEnum("status").notNull().default("PENDING_QR"),
|
||||
phoneNumber: text("phone_number"),
|
||||
displayName: text("display_name"),
|
||||
credentialsEncrypted: text("credentials_encrypted"),
|
||||
reconnectCount: integer("reconnect_count").notNull().default(0),
|
||||
autoRead: boolean("auto_read").notNull().default(false),
|
||||
lastSeen: timestamp("last_seen"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index("sessions_app_id_idx").on(t.appId),
|
||||
index("sessions_status_idx").on(t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const sessionKeys = pgTable(
|
||||
"session_keys",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sessionId: uuid("session_id")
|
||||
.notNull()
|
||||
.references(() => sessions.id, { onDelete: "cascade" }),
|
||||
keyType: text("key_type").notNull(),
|
||||
keyId: text("key_id").notNull(),
|
||||
valueEncrypted: text("value_encrypted").notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [unique("session_keys_unique").on(t.sessionId, t.keyType, t.keyId)],
|
||||
);
|
||||
|
||||
export const sessionsRelations = relations(sessions, ({ one, many }) => ({
|
||||
app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
|
||||
keys: many(sessionKeys),
|
||||
messages: many(messages),
|
||||
}));
|
||||
|
||||
export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({
|
||||
session: one(sessions, {
|
||||
fields: [sessionKeys.sessionId],
|
||||
references: [sessions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const messageTypeEnum = pgEnum("message_type", [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"document",
|
||||
"audio",
|
||||
]);
|
||||
|
||||
export const messageStatusEnum = pgEnum("message_status", [
|
||||
"queued",
|
||||
"sent",
|
||||
"delivered",
|
||||
"read",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
export const messageDirectionEnum = pgEnum("message_direction", ["in", "out"]);
|
||||
|
||||
export const messages = pgTable(
|
||||
"messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
sessionId: uuid("session_id")
|
||||
.notNull()
|
||||
.references(() => sessions.id, { onDelete: "cascade" }),
|
||||
whatsappMsgId: text("whatsapp_msg_id").notNull(),
|
||||
direction: messageDirectionEnum("direction").notNull(),
|
||||
type: messageTypeEnum("type").notNull(),
|
||||
toJid: text("to_jid"),
|
||||
fromJid: text("from_jid"),
|
||||
bodyEncrypted: text("body_encrypted"),
|
||||
mediaUrl: text("media_url"),
|
||||
mediaMime: text("media_mime"),
|
||||
mediaKey: text("media_key"),
|
||||
mediaSize: bigint("media_size", { mode: "number" }),
|
||||
status: messageStatusEnum("status").notNull().default("queued"),
|
||||
isGroup: boolean("is_group").notNull().default(false),
|
||||
quotedMsgId: text("quoted_msg_id"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index("messages_session_id_idx").on(t.sessionId),
|
||||
index("messages_app_id_idx").on(t.appId),
|
||||
index("messages_created_at_idx").on(t.createdAt),
|
||||
index("messages_whatsapp_msg_id_idx").on(t.whatsappMsgId),
|
||||
],
|
||||
);
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
app: one(apps, { fields: [messages.appId], references: [apps.id] }),
|
||||
session: one(sessions, {
|
||||
fields: [messages.sessionId],
|
||||
references: [sessions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const auditLog = pgTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
actorType: actorTypeEnum("actor_type").notNull(),
|
||||
actorId: uuid("actor_id").notNull(),
|
||||
action: text("action").notNull(),
|
||||
resourceType: text("resource_type").notNull(),
|
||||
resourceId: text("resource_id"),
|
||||
ip: text("ip"),
|
||||
userAgent: text("user_agent"),
|
||||
diffBefore: jsonb("diff_before"),
|
||||
diffAfter: jsonb("diff_after"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index("audit_log_actor_idx").on(t.actorType, t.actorId),
|
||||
index("audit_log_action_idx").on(t.action),
|
||||
index("audit_log_created_at_idx").on(t.createdAt),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
89
src/lib/actions/api-keys.ts
Normal file
89
src/lib/actions/api-keys.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apiKeys } from "#/db/schema";
|
||||
import { generateApiKey, hashApiKey } from "#/lib/api-keys";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
||||
export async function listAppApiKeys(appId: string) {
|
||||
const keys = await db.query.apiKeys.findMany({
|
||||
where: eq(apiKeys.appId, appId),
|
||||
columns: {
|
||||
id: true,
|
||||
keyPrefix: true,
|
||||
label: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return keys.map((k) => ({ ...k, display: `••••${k.keyPrefix}` }));
|
||||
}
|
||||
|
||||
export async function createAppApiKey(
|
||||
appId: string,
|
||||
userId: string,
|
||||
label?: string,
|
||||
env?: "live" | "test",
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
) {
|
||||
const { key, prefix } = generateApiKey(env ?? "live");
|
||||
const keyHash = await hashApiKey(key);
|
||||
|
||||
const [created] = await db
|
||||
.insert(apiKeys)
|
||||
.values({
|
||||
appId,
|
||||
keyHash,
|
||||
keyPrefix: prefix,
|
||||
label: label ?? "",
|
||||
})
|
||||
.returning({
|
||||
id: apiKeys.id,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
label: apiKeys.label,
|
||||
createdAt: apiKeys.createdAt,
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "api_key.create",
|
||||
resourceType: "api_key",
|
||||
resourceId: created.id,
|
||||
ip: audit?.ip,
|
||||
userAgent: audit?.userAgent,
|
||||
diffAfter: { label: created.label, appId },
|
||||
});
|
||||
|
||||
return { ...created, key };
|
||||
}
|
||||
|
||||
export async function revokeAppApiKey(
|
||||
keyId: string,
|
||||
appId: string,
|
||||
userId: string,
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
|
||||
const key = await db.query.apiKeys.findFirst({
|
||||
where: and(eq(apiKeys.id, keyId), eq(apiKeys.appId, appId)),
|
||||
});
|
||||
if (!key) return { ok: false, error: "Key not found", status: 404 };
|
||||
if (key.revokedAt)
|
||||
return { ok: false, error: "Key already revoked", status: 409 };
|
||||
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(apiKeys.id, keyId));
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "api_key.revoke",
|
||||
resourceType: "api_key",
|
||||
resourceId: keyId,
|
||||
ip: audit?.ip,
|
||||
userAgent: audit?.userAgent,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
132
src/lib/actions/apps.ts
Normal file
132
src/lib/actions/apps.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apps, apiKeys } from "#/db/schema";
|
||||
import { generateDek, wrapDek } from "#/lib/crypto";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
||||
type App = typeof apps.$inferSelect;
|
||||
|
||||
export async function listUserApps(userId: string) {
|
||||
return db.query.apps.findMany({
|
||||
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
||||
columns: { id: true, name: true, maxSessions: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function findUserApp(
|
||||
appId: string,
|
||||
userId: string,
|
||||
): Promise<App | null> {
|
||||
return (
|
||||
(await db.query.apps.findFirst({
|
||||
where: and(
|
||||
eq(apps.id, appId),
|
||||
eq(apps.ownerUserId, userId),
|
||||
isNull(apps.deletedAt),
|
||||
),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function createUserApp(
|
||||
userId: string,
|
||||
name: string,
|
||||
maxSessions?: number,
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
) {
|
||||
const dek = generateDek();
|
||||
const dekEncrypted = wrapDek(dek);
|
||||
|
||||
const [app] = await db
|
||||
.insert(apps)
|
||||
.values({
|
||||
name: name.trim(),
|
||||
ownerUserId: userId,
|
||||
dekEncrypted,
|
||||
maxSessions: maxSessions ?? 10,
|
||||
})
|
||||
.returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt });
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "app.create",
|
||||
resourceType: "app",
|
||||
resourceId: app.id,
|
||||
ip: audit?.ip,
|
||||
userAgent: audit?.userAgent,
|
||||
diffAfter: { name: app.name },
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function updateUserApp(
|
||||
appId: string,
|
||||
userId: string,
|
||||
updates: { name?: string; maxSessions?: number },
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
): Promise<typeof apps.$inferSelect | null> {
|
||||
const app = await findUserApp(appId, userId);
|
||||
if (!app) return null;
|
||||
|
||||
const dbUpdates: Partial<typeof apps.$inferInsert> = {};
|
||||
if (updates.name !== undefined) dbUpdates.name = updates.name.trim();
|
||||
if (updates.maxSessions !== undefined)
|
||||
dbUpdates.maxSessions = updates.maxSessions;
|
||||
|
||||
const [updated] = await db
|
||||
.update(apps)
|
||||
.set(dbUpdates)
|
||||
.where(eq(apps.id, appId))
|
||||
.returning({
|
||||
id: apps.id,
|
||||
name: apps.name,
|
||||
maxSessions: apps.maxSessions,
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "app.update",
|
||||
resourceType: "app",
|
||||
resourceId: appId,
|
||||
ip: audit?.ip,
|
||||
userAgent: audit?.userAgent,
|
||||
diffBefore: { name: app.name, maxSessions: app.maxSessions },
|
||||
diffAfter: dbUpdates,
|
||||
});
|
||||
|
||||
return updated as App;
|
||||
}
|
||||
|
||||
export async function deleteUserApp(
|
||||
appId: string,
|
||||
userId: string,
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
): Promise<boolean> {
|
||||
const app = await findUserApp(appId, userId);
|
||||
if (!app) return false;
|
||||
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, appId));
|
||||
await tx
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: now })
|
||||
.where(and(eq(apiKeys.appId, appId), isNull(apiKeys.revokedAt)));
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "app.delete",
|
||||
resourceType: "app",
|
||||
resourceId: appId,
|
||||
ip: audit?.ip,
|
||||
userAgent: audit?.userAgent,
|
||||
diffBefore: { name: app.name },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
44
src/lib/actions/audit-log.ts
Normal file
44
src/lib/actions/audit-log.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { auditLog } from "#/db/schema";
|
||||
|
||||
interface AuditLogFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
action?: string | null;
|
||||
resourceType?: string | null;
|
||||
since?: string | null;
|
||||
until?: string | null;
|
||||
}
|
||||
|
||||
export async function queryAuditLog(
|
||||
userId: string,
|
||||
filters: AuditLogFilters = {},
|
||||
) {
|
||||
const page = Math.max(1, filters.page ?? 1);
|
||||
const limit = Math.min(100, Math.max(1, filters.limit ?? 50));
|
||||
|
||||
const conditions = [eq(auditLog.actorId, userId)];
|
||||
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
|
||||
if (filters.resourceType)
|
||||
conditions.push(eq(auditLog.resourceType, filters.resourceType));
|
||||
if (filters.since) {
|
||||
const d = new Date(filters.since);
|
||||
if (isNaN(d.getTime())) return { data: [], page, limit };
|
||||
conditions.push(gte(auditLog.createdAt, d));
|
||||
}
|
||||
if (filters.until) {
|
||||
const d = new Date(filters.until);
|
||||
if (isNaN(d.getTime())) return { data: [], page, limit };
|
||||
conditions.push(lte(auditLog.createdAt, d));
|
||||
}
|
||||
|
||||
const rows = await db.query.auditLog.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(auditLog.createdAt)],
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
});
|
||||
|
||||
return { data: rows, page, limit };
|
||||
}
|
||||
90
src/lib/actions/auth.ts
Normal file
90
src/lib/actions/auth.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { users } from "#/db/schema";
|
||||
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
|
||||
import {
|
||||
decryptWithMasterKey,
|
||||
encryptWithMasterKey,
|
||||
} from "#/lib/crypto";
|
||||
|
||||
export async function findUserByEmail(email: string) {
|
||||
return (
|
||||
(await db.query.users.findFirst({
|
||||
where: eq(users.email, email.toLowerCase()),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function findUserById(userId: string) {
|
||||
return (
|
||||
(await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function enrollTotpSecret(
|
||||
userId: string,
|
||||
): Promise<
|
||||
| { ok: true; secret: string; uri: string }
|
||||
| { ok: false; error: string; status: number }
|
||||
> {
|
||||
const user = await findUserById(userId);
|
||||
if (!user) return { ok: false, error: "User not found", status: 404 };
|
||||
if (user.totpEnabled)
|
||||
return { ok: false, error: "TOTP already enabled", status: 409 };
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpSecret: encryptWithMasterKey(secret) })
|
||||
.where(eq(users.id, userId));
|
||||
return { ok: true, secret, uri: getTotpUri(user.email, secret) };
|
||||
}
|
||||
|
||||
export async function confirmTotpEnrollment(
|
||||
userId: string,
|
||||
token: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
|
||||
const user = await findUserById(userId);
|
||||
if (!user?.totpSecret)
|
||||
return { ok: false, error: "Enroll first", status: 400 };
|
||||
if (user.totpEnabled)
|
||||
return { ok: false, error: "TOTP already enabled", status: 409 };
|
||||
|
||||
const decryptedSecret = decryptWithMasterKey(user.totpSecret);
|
||||
if (!verifyTotp(decryptedSecret, token))
|
||||
return { ok: false, error: "Invalid token", status: 401 };
|
||||
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpEnabled: true, lastUsedTotpToken: tokenHash })
|
||||
.where(eq(users.id, userId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(
|
||||
user: typeof users.$inferSelect,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!user.totpSecret) return false;
|
||||
|
||||
const secret = decryptWithMasterKey(user.totpSecret);
|
||||
if (!verifyTotp(secret, token)) return false;
|
||||
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
if (user.lastUsedTotpToken === tokenHash) return false;
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastUsedTotpToken: tokenHash })
|
||||
.where(eq(users.id, user.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function disableTotp(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpEnabled: false, totpSecret: null })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
125
src/lib/actions/messages.ts
Normal file
125
src/lib/actions/messages.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { and, desc, eq, gt, lte } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { messages } from "#/db/schema";
|
||||
import { encryptColumn, decryptColumn } from "#/lib/crypto";
|
||||
import { getStorageDriver } from "#/lib/storage";
|
||||
|
||||
type Message = typeof messages.$inferSelect;
|
||||
type MessageInsert = typeof messages.$inferInsert;
|
||||
|
||||
const SIGNED_URL_TTL = Number(process.env.MEDIA_SIGNED_URL_TTL_SECONDS ?? 3600);
|
||||
const ENCRYPT_BODY = process.env.ENCRYPT_MESSAGE_BODY === "true";
|
||||
|
||||
export async function createMessage(
|
||||
data: Omit<MessageInsert, "id" | "createdAt" | "updatedAt">,
|
||||
dek: Buffer,
|
||||
): Promise<Message> {
|
||||
const bodyEncrypted =
|
||||
ENCRYPT_BODY && data.bodyEncrypted
|
||||
? encryptColumn(data.bodyEncrypted, dek)
|
||||
: data.bodyEncrypted;
|
||||
|
||||
const [msg] = await db
|
||||
.insert(messages)
|
||||
.values({ ...data, bodyEncrypted })
|
||||
.returning();
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function updateMessageStatus(
|
||||
whatsappMsgId: string,
|
||||
sessionId: string,
|
||||
status: Message["status"],
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(messages.whatsappMsgId, whatsappMsgId),
|
||||
eq(messages.sessionId, sessionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findMessageByWhatsappId(
|
||||
whatsappMsgId: string,
|
||||
sessionId: string,
|
||||
): Promise<Message | null> {
|
||||
return (
|
||||
(await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.whatsappMsgId, whatsappMsgId),
|
||||
eq(messages.sessionId, sessionId),
|
||||
),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMessages(
|
||||
sessionId: string,
|
||||
opts: {
|
||||
since?: Date;
|
||||
limit?: number;
|
||||
direction?: "in" | "out";
|
||||
},
|
||||
dek: Buffer,
|
||||
) {
|
||||
const limit = Math.min(opts.limit ?? 50, 200);
|
||||
const conditions = [eq(messages.sessionId, sessionId)];
|
||||
if (opts.since) conditions.push(gt(messages.createdAt, opts.since));
|
||||
if (opts.direction) conditions.push(eq(messages.direction, opts.direction));
|
||||
|
||||
const rows = await db.query.messages.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(messages.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
const driver = await getStorageDriver();
|
||||
return Promise.all(
|
||||
rows.map(async (msg) => {
|
||||
const body =
|
||||
ENCRYPT_BODY && msg.bodyEncrypted
|
||||
? decryptColumn(msg.bodyEncrypted, dek)
|
||||
: msg.bodyEncrypted;
|
||||
const mediaUrl = msg.mediaKey
|
||||
? await driver.signedUrl(msg.mediaKey, SIGNED_URL_TTL)
|
||||
: msg.mediaUrl;
|
||||
return { ...msg, bodyEncrypted: undefined, body, mediaUrl };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findMessage(
|
||||
id: string,
|
||||
sessionId: string,
|
||||
): Promise<Message | null> {
|
||||
return (
|
||||
(await db.query.messages.findFirst({
|
||||
where: and(eq(messages.id, id), eq(messages.sessionId, sessionId)),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteOldMedia(olderThanDays: number): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
||||
const driver = await getStorageDriver();
|
||||
|
||||
const old = await db.query.messages.findMany({
|
||||
where: and(lte(messages.createdAt, cutoff)),
|
||||
columns: { id: true, mediaKey: true },
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
for (const msg of old) {
|
||||
if (!msg.mediaKey) continue;
|
||||
await driver.delete(msg.mediaKey).catch(() => {});
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ mediaUrl: null, mediaKey: null, updatedAt: new Date() })
|
||||
.where(eq(messages.id, msg.id));
|
||||
deleted++;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
196
src/lib/actions/sessions.ts
Normal file
196
src/lib/actions/sessions.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apps, sessions, sessionKeys } from "#/db/schema";
|
||||
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
|
||||
|
||||
type Session = typeof sessions.$inferSelect;
|
||||
type SessionStatus = Session["status"];
|
||||
|
||||
type SessionScope = { appId: string } | { userId: string };
|
||||
|
||||
const ACTIVE_STATUSES = [
|
||||
"PENDING_QR",
|
||||
"PENDING_PAIRING",
|
||||
"CONNECTING",
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
] as const;
|
||||
|
||||
export async function findSession(
|
||||
sessionId: string,
|
||||
scope: SessionScope,
|
||||
): Promise<Session | null> {
|
||||
if ("appId" in scope) {
|
||||
return (
|
||||
(await db.query.sessions.findFirst({
|
||||
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const userApps = await db.query.apps.findMany({
|
||||
where: and(eq(apps.ownerUserId, scope.userId), isNull(apps.deletedAt)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (userApps.length === 0) return null;
|
||||
const appIds = userApps.map((a) => a.id);
|
||||
return (
|
||||
(await db.query.sessions.findFirst({
|
||||
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserSessions(userId: string) {
|
||||
const userApps = await db.query.apps.findMany({
|
||||
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
||||
columns: { id: true, name: true },
|
||||
});
|
||||
if (userApps.length === 0) return [];
|
||||
|
||||
const appIds = userApps.map((a) => a.id);
|
||||
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]));
|
||||
|
||||
const rows = await db.query.sessions.findMany({
|
||||
where: (s, { inArray: _inArray }) => _inArray(s.appId, appIds),
|
||||
columns: {
|
||||
id: true,
|
||||
appId: true,
|
||||
status: true,
|
||||
reconnectCount: true,
|
||||
lastSeen: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || "" }));
|
||||
}
|
||||
|
||||
export async function listAppSessions(appId: string) {
|
||||
return db.query.sessions.findMany({
|
||||
where: eq(sessions.appId, appId),
|
||||
columns: {
|
||||
id: true,
|
||||
status: true,
|
||||
lastSeen: true,
|
||||
createdAt: true,
|
||||
reconnectCount: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
appId: string,
|
||||
via: "qr" | "pairing",
|
||||
ownerUserId?: string,
|
||||
): Promise<
|
||||
| { ok: true; session: { id: string; status: string; createdAt: Date } }
|
||||
| { ok: false; error: string; status: number }
|
||||
> {
|
||||
const appWhere = ownerUserId
|
||||
? and(
|
||||
eq(apps.id, appId),
|
||||
eq(apps.ownerUserId, ownerUserId),
|
||||
isNull(apps.deletedAt),
|
||||
)
|
||||
: and(eq(apps.id, appId), isNull(apps.deletedAt));
|
||||
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: appWhere,
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!app) return { ok: false, error: "App not found", status: 404 };
|
||||
|
||||
const initialStatus: SessionStatus =
|
||||
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
|
||||
|
||||
type TxResult =
|
||||
| { limited: true; max: number }
|
||||
| { limited: false; session: { id: string; status: string; createdAt: Date } };
|
||||
|
||||
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
||||
// Lock app row to serialize concurrent createSession calls for the same app
|
||||
const lock = await tx.execute(
|
||||
sql`SELECT max_sessions FROM apps WHERE id = ${appId} FOR UPDATE`,
|
||||
);
|
||||
const maxSessions = Number(
|
||||
(lock.rows[0] as { max_sessions: unknown }).max_sessions,
|
||||
);
|
||||
|
||||
const cnt = await tx.execute(
|
||||
sql`SELECT COUNT(*)::int AS n FROM sessions WHERE app_id = ${appId} AND status = ANY(ARRAY['PENDING_QR','PENDING_PAIRING','CONNECTING','CONNECTED','DISCONNECTED']::session_status[])`,
|
||||
);
|
||||
const activeCount = Number((cnt.rows[0] as { n: unknown }).n);
|
||||
|
||||
if (activeCount >= maxSessions) return { limited: true, max: maxSessions };
|
||||
|
||||
const [session] = await tx
|
||||
.insert(sessions)
|
||||
.values({ appId, status: initialStatus })
|
||||
.returning({
|
||||
id: sessions.id,
|
||||
status: sessions.status,
|
||||
createdAt: sessions.createdAt,
|
||||
});
|
||||
|
||||
return { limited: false, session };
|
||||
});
|
||||
|
||||
if (txResult.limited) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Session limit reached (max ${txResult.max})`,
|
||||
status: 429,
|
||||
};
|
||||
}
|
||||
|
||||
if (via === "qr") startSession(txResult.session.id, appId).catch(console.error);
|
||||
|
||||
return { ok: true, session: txResult.session };
|
||||
}
|
||||
|
||||
export async function setSessionStatus(
|
||||
sessionId: string,
|
||||
status: SessionStatus,
|
||||
): Promise<void> {
|
||||
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
export async function disconnectSession(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
): Promise<void> {
|
||||
await stopSession(sessionId);
|
||||
await setSessionStatus(sessionId, "DISCONNECTED");
|
||||
emitWebhook(appId, "session.status", { sessionId, status: "DISCONNECTED" });
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
): Promise<void> {
|
||||
await stopSession(sessionId);
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
emitWebhook(appId, "session.deleted", { sessionId });
|
||||
}
|
||||
|
||||
export async function logoutAppSession(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
): Promise<void> {
|
||||
await stopSession(sessionId);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
|
||||
await tx
|
||||
.update(sessions)
|
||||
.set({
|
||||
status: "LOGGED_OUT",
|
||||
credentialsEncrypted: null,
|
||||
phoneNumber: null,
|
||||
displayName: null,
|
||||
})
|
||||
.where(eq(sessions.id, sessionId));
|
||||
});
|
||||
emitWebhook(appId, "session.deleted", { sessionId });
|
||||
}
|
||||
36
src/lib/api-key-auth.ts
Normal file
36
src/lib/api-key-auth.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apiKeys } from "#/db/schema";
|
||||
import { verifyApiKey } from "./api-keys";
|
||||
import { jsonError } from "./session-api";
|
||||
|
||||
export interface AuthedRequest {
|
||||
appId: string;
|
||||
keyId: string;
|
||||
}
|
||||
|
||||
export async function authenticateApiKey(
|
||||
request: Request,
|
||||
): Promise<AuthedRequest | Response> {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (!auth?.startsWith("Bearer "))
|
||||
return jsonError("Missing Authorization header", 401);
|
||||
|
||||
const key = auth.slice(7).trim();
|
||||
if (!key.startsWith("wak_")) return jsonError("Invalid API key format", 401);
|
||||
|
||||
const prefix = key.slice(-4);
|
||||
|
||||
const candidates = await db.query.apiKeys.findMany({
|
||||
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
|
||||
with: { app: { columns: { id: true, deletedAt: true } } },
|
||||
});
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.app.deletedAt) continue;
|
||||
const valid = await verifyApiKey(key, candidate.keyHash);
|
||||
if (valid) return { appId: candidate.appId, keyId: candidate.id };
|
||||
}
|
||||
|
||||
return jsonError("Invalid or revoked API key", 401);
|
||||
}
|
||||
29
src/lib/api-keys.ts
Normal file
29
src/lib/api-keys.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import argon2 from "argon2";
|
||||
|
||||
export type ApiKeyEnv = "live" | "test";
|
||||
|
||||
export function generateApiKey(env: ApiKeyEnv = "live"): {
|
||||
key: string;
|
||||
prefix: string;
|
||||
} {
|
||||
const rand = randomBytes(24).toString("base64url");
|
||||
const key = `wak_${env}_${rand}`;
|
||||
const prefix = key.slice(-4);
|
||||
return { key, prefix };
|
||||
}
|
||||
|
||||
export async function hashApiKey(key: string): Promise<string> {
|
||||
return argon2.hash(key, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
export async function verifyApiKey(
|
||||
key: string,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, key);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
28
src/lib/audit.ts
Normal file
28
src/lib/audit.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { db } from "#/db/index";
|
||||
import { auditLog } from "#/db/schema";
|
||||
|
||||
interface AuditEntry {
|
||||
actorType: "user" | "api_key";
|
||||
actorId: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
diffBefore?: unknown;
|
||||
diffAfter?: unknown;
|
||||
}
|
||||
|
||||
export async function writeAudit(entry: AuditEntry): Promise<void> {
|
||||
await db.insert(auditLog).values({
|
||||
actorType: entry.actorType,
|
||||
actorId: entry.actorId,
|
||||
action: entry.action,
|
||||
resourceType: entry.resourceType,
|
||||
resourceId: entry.resourceId,
|
||||
ip: entry.ip,
|
||||
userAgent: entry.userAgent,
|
||||
diffBefore: entry.diffBefore ?? null,
|
||||
diffAfter: entry.diffAfter ?? null,
|
||||
});
|
||||
}
|
||||
130
src/lib/baileys/auth-state.ts
Normal file
130
src/lib/baileys/auth-state.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
BufferJSON,
|
||||
initAuthCreds,
|
||||
makeCacheableSignalKeyStore,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import type {
|
||||
AuthenticationState,
|
||||
SignalDataSet,
|
||||
SignalDataTypeMap,
|
||||
SignalKeyStore,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { db } from "#/db/index";
|
||||
import { sessionKeys, sessions } from "#/db/schema";
|
||||
import { decryptColumn, encryptColumn } from "#/lib/crypto";
|
||||
|
||||
export async function usePostgresAuthState(
|
||||
sessionId: string,
|
||||
appDek: Buffer,
|
||||
): Promise<{ state: AuthenticationState; saveCreds: () => Promise<void> }> {
|
||||
const row = await db.query.sessions.findFirst({
|
||||
where: eq(sessions.id, sessionId),
|
||||
columns: { credentialsEncrypted: true },
|
||||
});
|
||||
|
||||
let creds = initAuthCreds();
|
||||
if (row?.credentialsEncrypted) {
|
||||
try {
|
||||
const json = decryptColumn(row.credentialsEncrypted, appDek);
|
||||
creds = JSON.parse(json, BufferJSON.reviver);
|
||||
} catch {
|
||||
creds = initAuthCreds();
|
||||
}
|
||||
}
|
||||
|
||||
const store: SignalKeyStore = {
|
||||
async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) {
|
||||
if (ids.length === 0) return {};
|
||||
const rows = await db.query.sessionKeys.findMany({
|
||||
where: and(
|
||||
eq(sessionKeys.sessionId, sessionId),
|
||||
eq(sessionKeys.keyType, type),
|
||||
inArray(sessionKeys.keyId, ids),
|
||||
),
|
||||
});
|
||||
const result: { [id: string]: SignalDataTypeMap[T] } = {};
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const json = decryptColumn(r.valueEncrypted, appDek);
|
||||
result[r.keyId] = JSON.parse(json, BufferJSON.reviver);
|
||||
} catch {
|
||||
// skip corrupt key
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async set(data: SignalDataSet) {
|
||||
type Row = {
|
||||
sessionId: string;
|
||||
keyType: string;
|
||||
keyId: string;
|
||||
valueEncrypted: string;
|
||||
};
|
||||
const toDelete = new Map<string, string[]>();
|
||||
const toUpsert: Row[] = [];
|
||||
|
||||
for (const [type, typeData] of Object.entries(data)) {
|
||||
for (const [id, value] of Object.entries(typeData)) {
|
||||
if (value == null) {
|
||||
if (!toDelete.has(type)) toDelete.set(type, []);
|
||||
toDelete.get(type)!.push(id);
|
||||
} else {
|
||||
const serialized = JSON.stringify(value, BufferJSON.replacer);
|
||||
toUpsert.push({
|
||||
sessionId,
|
||||
keyType: type,
|
||||
keyId: id,
|
||||
valueEncrypted: encryptColumn(serialized, appDek),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, ids] of toDelete) {
|
||||
await db.delete(sessionKeys).where(
|
||||
and(
|
||||
eq(sessionKeys.sessionId, sessionId),
|
||||
eq(sessionKeys.keyType, type),
|
||||
inArray(sessionKeys.keyId, ids),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (toUpsert.length > 0) {
|
||||
await db
|
||||
.insert(sessionKeys)
|
||||
.values(toUpsert)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
sessionKeys.sessionId,
|
||||
sessionKeys.keyType,
|
||||
sessionKeys.keyId,
|
||||
],
|
||||
set: {
|
||||
valueEncrypted: sql`EXCLUDED.value_encrypted`,
|
||||
updatedAt: sql`NOW()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async clear() {
|
||||
await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
|
||||
},
|
||||
};
|
||||
|
||||
const keys = makeCacheableSignalKeyStore(store);
|
||||
|
||||
async function saveCreds() {
|
||||
const json = JSON.stringify(creds, BufferJSON.replacer);
|
||||
const encrypted = encryptColumn(json, appDek);
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ credentialsEncrypted: encrypted })
|
||||
.where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
return { state: { creds, keys }, saveCreds };
|
||||
}
|
||||
12
src/lib/baileys/index.ts
Normal file
12
src/lib/baileys/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { usePostgresAuthState } from "./auth-state";
|
||||
export {
|
||||
startSession,
|
||||
stopSession,
|
||||
getQrCode,
|
||||
getSocket,
|
||||
isActive,
|
||||
initialize,
|
||||
getDecryptedField,
|
||||
getAppDekForSession,
|
||||
} from "./session-manager";
|
||||
export { emitWebhook } from "./webhook";
|
||||
493
src/lib/baileys/session-manager.ts
Normal file
493
src/lib/baileys/session-manager.ts
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
jidDecode,
|
||||
makeWASocket,
|
||||
normalizeMessageContent,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import type { WASocket } from "@whiskeysockets/baileys";
|
||||
import { db } from "#/db/index";
|
||||
import { apps, sessions } from "#/db/schema";
|
||||
import { decryptColumn, encryptColumn, unwrapDek } from "#/lib/crypto";
|
||||
import { createMessage, updateMessageStatus } from "#/lib/actions/messages";
|
||||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { usePostgresAuthState } from "./auth-state";
|
||||
import { emitWebhook } from "./webhook";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import mime from "mime";
|
||||
|
||||
type SessionStatus = (typeof sessions.$inferSelect)["status"];
|
||||
|
||||
interface SessionEntry {
|
||||
socket: WASocket;
|
||||
qrCode: string | null;
|
||||
appId: string;
|
||||
dek: Buffer;
|
||||
}
|
||||
|
||||
interface ReconnectEntry {
|
||||
start: Date;
|
||||
count: number;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
|
||||
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
|
||||
|
||||
// Store on global to survive Vite SSR module isolation / HMR reloads
|
||||
declare global {
|
||||
var __wahooActiveSessions: Map<string, SessionEntry> | undefined;
|
||||
var __wahooReconnecting: Map<string, ReconnectEntry> | undefined;
|
||||
var __wahooQrTimers: Map<string, ReturnType<typeof setTimeout>> | undefined;
|
||||
}
|
||||
|
||||
globalThis.__wahooActiveSessions ??= new Map<string, SessionEntry>();
|
||||
globalThis.__wahooReconnecting ??= new Map<string, ReconnectEntry>();
|
||||
globalThis.__wahooQrTimers ??= new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const active = globalThis.__wahooActiveSessions;
|
||||
const reconnecting = globalThis.__wahooReconnecting;
|
||||
const qrTimers = globalThis.__wahooQrTimers;
|
||||
|
||||
async function getAppDek(appId: string): Promise<Buffer> {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
columns: { dekEncrypted: true },
|
||||
});
|
||||
if (!app) throw new Error(`App ${appId} not found`);
|
||||
return unwrapDek(app.dekEncrypted);
|
||||
}
|
||||
|
||||
async function setStatus(
|
||||
sessionId: string,
|
||||
status: SessionStatus,
|
||||
): Promise<void> {
|
||||
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
async function scheduleReconnect(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
): Promise<void> {
|
||||
let rec = reconnecting.get(sessionId);
|
||||
|
||||
if (rec) {
|
||||
const elapsed = Date.now() - rec.start.getTime();
|
||||
if (elapsed > MAX_RECONNECT_WINDOW_MS) {
|
||||
reconnecting.delete(sessionId);
|
||||
await setStatus(sessionId, "DEAD");
|
||||
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
rec = { start: new Date(), count: 0, timer: null };
|
||||
reconnecting.set(sessionId, rec);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000);
|
||||
rec.count++;
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ reconnectCount: rec.count })
|
||||
.where(eq(sessions.id, sessionId));
|
||||
|
||||
rec.timer = setTimeout(() => {
|
||||
const r = reconnecting.get(sessionId);
|
||||
if (r) r.timer = null;
|
||||
startSession(sessionId, appId).catch(console.error);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
export async function startSession(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
): Promise<void> {
|
||||
if (active.has(sessionId)) return;
|
||||
|
||||
const dek = await getAppDek(appId);
|
||||
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek);
|
||||
|
||||
const sock = makeWASocket({
|
||||
auth: state,
|
||||
browser: Browsers.ubuntu("Chrome"),
|
||||
printQRInTerminal: false,
|
||||
syncFullHistory: false,
|
||||
});
|
||||
|
||||
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek };
|
||||
active.set(sessionId, entry);
|
||||
|
||||
sock.ev.on("creds.update", async (patch) => {
|
||||
Object.assign(state.creds, patch);
|
||||
await saveCreds().catch(console.error);
|
||||
});
|
||||
|
||||
sock.ev.on("messages.upsert", async ({ messages: msgs, type }) => {
|
||||
if (type !== "notify") return;
|
||||
const driver = await getStorageDriver();
|
||||
const SIGNED_URL_TTL = Number(
|
||||
process.env.MEDIA_SIGNED_URL_TTL_SECONDS ?? 3600,
|
||||
);
|
||||
|
||||
for (const msg of msgs) {
|
||||
const isInbound = !msg.key.fromMe;
|
||||
const jid = msg.key.remoteJid ?? "";
|
||||
const whatsappMsgId = msg.key.id ?? uuidv4();
|
||||
const content = normalizeMessageContent(msg.message ?? null);
|
||||
|
||||
let msgType: "text" | "image" | "video" | "document" | "audio" = "text";
|
||||
let body: string | undefined;
|
||||
let mediaUrl: string | undefined;
|
||||
let mediaKey: string | undefined;
|
||||
let mediaMime: string | undefined;
|
||||
let mediaSize: number | undefined;
|
||||
|
||||
if (content?.conversation || content?.extendedTextMessage) {
|
||||
msgType = "text";
|
||||
body = content.conversation ?? content.extendedTextMessage?.text ?? "";
|
||||
} else if (content?.imageMessage) {
|
||||
msgType = "image";
|
||||
body = content.imageMessage.caption ?? undefined;
|
||||
mediaMime = content.imageMessage.mimetype ?? "image/jpeg";
|
||||
} else if (content?.videoMessage) {
|
||||
msgType = "video";
|
||||
body = content.videoMessage.caption ?? undefined;
|
||||
mediaMime = content.videoMessage.mimetype ?? "video/mp4";
|
||||
} else if (content?.documentMessage) {
|
||||
msgType = "document";
|
||||
body = content.documentMessage.fileName ?? undefined;
|
||||
mediaMime =
|
||||
content.documentMessage.mimetype ?? "application/octet-stream";
|
||||
} else if (content?.audioMessage) {
|
||||
msgType = "audio";
|
||||
mediaMime = content.audioMessage.mimetype ?? "audio/ogg";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download inbound media and store
|
||||
if (isInbound && msgType !== "text") {
|
||||
try {
|
||||
const buffer = await downloadMediaMessage(msg, "buffer", {});
|
||||
if (buffer instanceof Buffer) {
|
||||
const ext = mediaMime
|
||||
? mime.getExtension(mediaMime)
|
||||
? `.${mime.getExtension(mediaMime)}`
|
||||
: ""
|
||||
: "";
|
||||
const storageKey = `media/${appId}/${sessionId}/${whatsappMsgId}${ext}`;
|
||||
await driver.upload(
|
||||
storageKey,
|
||||
buffer,
|
||||
mediaMime ?? "application/octet-stream",
|
||||
);
|
||||
mediaKey = storageKey;
|
||||
mediaUrl = await driver.signedUrl(storageKey, SIGNED_URL_TTL);
|
||||
mediaSize = buffer.length;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[session-manager] media download failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await createMessage(
|
||||
{
|
||||
appId,
|
||||
sessionId,
|
||||
whatsappMsgId,
|
||||
direction: isInbound ? "in" : "out",
|
||||
type: msgType,
|
||||
toJid: isInbound ? undefined : jid,
|
||||
fromJid: isInbound ? jid : undefined,
|
||||
bodyEncrypted: body,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
mediaKey: mediaKey ?? null,
|
||||
mediaMime: mediaMime ?? null,
|
||||
mediaSize: mediaSize ?? null,
|
||||
status: isInbound ? "delivered" : "sent",
|
||||
isGroup: jid.endsWith("@g.us"),
|
||||
},
|
||||
dek,
|
||||
);
|
||||
|
||||
// Auto-read
|
||||
const sessionRow = await db.query.sessions.findFirst({
|
||||
where: eq(sessions.id, sessionId),
|
||||
columns: { autoRead: true },
|
||||
});
|
||||
if (isInbound && sessionRow?.autoRead) {
|
||||
await sock.readMessages([msg.key]).catch(() => {});
|
||||
}
|
||||
|
||||
emitWebhook(appId, isInbound ? "message.received" : "message.sent", {
|
||||
messageId: saved.id,
|
||||
sessionId,
|
||||
whatsappMsgId,
|
||||
type: msgType,
|
||||
direction: saved.direction,
|
||||
from: saved.fromJid,
|
||||
to: saved.toJid,
|
||||
body,
|
||||
mediaUrl,
|
||||
mediaMime,
|
||||
isGroup: saved.isGroup,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[session-manager] message store failed", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on("message-receipt.update", async (updates) => {
|
||||
for (const update of updates) {
|
||||
const whatsappMsgId = update.key.id ?? "";
|
||||
const receipts = update.receipt;
|
||||
let newStatus: "delivered" | "read" | null = null;
|
||||
|
||||
if (receipts.readTimestamp) newStatus = "read";
|
||||
else if (receipts.receiptTimestamp) newStatus = "delivered";
|
||||
|
||||
if (!newStatus) continue;
|
||||
|
||||
await updateMessageStatus(whatsappMsgId, sessionId, newStatus).catch(
|
||||
console.error,
|
||||
);
|
||||
|
||||
const eventName =
|
||||
newStatus === "read" ? "message.read" : "message.delivered";
|
||||
emitWebhook(appId, eventName, {
|
||||
sessionId,
|
||||
whatsappMsgId,
|
||||
status: newStatus,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on("presence.update", ({ id: contactJid, presences }) => {
|
||||
for (const [participant, presence] of Object.entries(presences)) {
|
||||
const apiPresence = mapBaileysPresence(presence.lastKnownPresence);
|
||||
emitWebhook(appId, "presence.update", {
|
||||
sessionId,
|
||||
jid: contactJid,
|
||||
participant,
|
||||
presence: apiPresence,
|
||||
lastSeen: presence.lastSeen,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on("connection.update", async (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
entry.qrCode = qr;
|
||||
|
||||
// Reset the 5-min expiry timer on each new QR rotation
|
||||
const existing = qrTimers.get(sessionId);
|
||||
if (existing) clearTimeout(existing);
|
||||
qrTimers.set(
|
||||
sessionId,
|
||||
setTimeout(async () => {
|
||||
qrTimers.delete(sessionId);
|
||||
const e = active.get(sessionId);
|
||||
if (!e) return;
|
||||
// Only expire if still waiting for QR
|
||||
const row = await db.query.sessions.findFirst({
|
||||
where: eq(sessions.id, sessionId),
|
||||
columns: { status: true },
|
||||
});
|
||||
if (row?.status === "PENDING_QR") {
|
||||
active.delete(sessionId);
|
||||
try {
|
||||
e.socket.end(undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await setStatus(sessionId, "DEAD");
|
||||
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
|
||||
}
|
||||
}, QR_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
await setStatus(sessionId, "PENDING_QR");
|
||||
emitWebhook(appId, "session.status", { sessionId, status: "PENDING_QR" });
|
||||
}
|
||||
|
||||
if (connection === "connecting") {
|
||||
await setStatus(sessionId, "CONNECTING");
|
||||
}
|
||||
|
||||
if (connection === "open") {
|
||||
entry.qrCode = null;
|
||||
reconnecting.delete(sessionId);
|
||||
const qt = qrTimers.get(sessionId);
|
||||
if (qt) {
|
||||
clearTimeout(qt);
|
||||
qrTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
const meId = state.creds.me?.id;
|
||||
const mePhone = meId ? jidDecode(meId)?.user : undefined;
|
||||
const meName = state.creds.me?.name;
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
status: "CONNECTED",
|
||||
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
|
||||
displayName: meName ? encryptColumn(meName, dek) : undefined,
|
||||
lastSeen: new Date(),
|
||||
reconnectCount: 0,
|
||||
})
|
||||
.where(eq(sessions.id, sessionId));
|
||||
|
||||
emitWebhook(appId, "session.status", { sessionId, status: "CONNECTED" });
|
||||
}
|
||||
|
||||
if (connection === "close") {
|
||||
active.delete(sessionId);
|
||||
const qt = qrTimers.get(sessionId);
|
||||
if (qt) {
|
||||
clearTimeout(qt);
|
||||
qrTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
const code = (
|
||||
lastDisconnect?.error as
|
||||
| { output?: { statusCode?: number } }
|
||||
| undefined
|
||||
)?.output?.statusCode;
|
||||
|
||||
if (
|
||||
code === DisconnectReason.loggedOut ||
|
||||
code === DisconnectReason.forbidden ||
|
||||
code === DisconnectReason.connectionReplaced
|
||||
) {
|
||||
reconnecting.delete(sessionId);
|
||||
await setStatus(sessionId, "LOGGED_OUT");
|
||||
emitWebhook(appId, "session.status", {
|
||||
sessionId,
|
||||
status: "LOGGED_OUT",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Never authenticated (QR expired/exhausted) — don't reconnect
|
||||
if (!state.creds.me) {
|
||||
reconnecting.delete(sessionId);
|
||||
await setStatus(sessionId, "DEAD");
|
||||
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
|
||||
return;
|
||||
}
|
||||
|
||||
await setStatus(sessionId, "DISCONNECTED");
|
||||
emitWebhook(appId, "session.status", {
|
||||
sessionId,
|
||||
status: "DISCONNECTED",
|
||||
});
|
||||
await scheduleReconnect(sessionId, appId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopSession(sessionId: string): Promise<void> {
|
||||
const rec = reconnecting.get(sessionId);
|
||||
if (rec?.timer) clearTimeout(rec.timer);
|
||||
reconnecting.delete(sessionId);
|
||||
const qt = qrTimers.get(sessionId);
|
||||
if (qt) {
|
||||
clearTimeout(qt);
|
||||
qrTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
const entry = active.get(sessionId);
|
||||
if (entry) {
|
||||
try {
|
||||
entry.socket.end(undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
active.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function getQrCode(sessionId: string): string | null {
|
||||
return active.get(sessionId)?.qrCode ?? null;
|
||||
}
|
||||
|
||||
export function getSocket(sessionId: string): WASocket | null {
|
||||
return active.get(sessionId)?.socket ?? null;
|
||||
}
|
||||
|
||||
export function isActive(sessionId: string): boolean {
|
||||
return active.has(sessionId);
|
||||
}
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
const rows = await db.query.sessions.findMany({
|
||||
where: inArray(sessions.status, [
|
||||
"CONNECTED",
|
||||
"DISCONNECTED",
|
||||
"CONNECTING",
|
||||
]),
|
||||
columns: { id: true, appId: true },
|
||||
});
|
||||
|
||||
await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId)));
|
||||
}
|
||||
|
||||
function mapBaileysPresence(
|
||||
p: string,
|
||||
): "typing" | "online" | "offline" | "paused" | "recording" {
|
||||
switch (p) {
|
||||
case "composing":
|
||||
return "typing";
|
||||
case "available":
|
||||
return "online";
|
||||
case "unavailable":
|
||||
return "offline";
|
||||
case "recording":
|
||||
return "recording";
|
||||
case "paused":
|
||||
return "paused";
|
||||
default:
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppDekForSession(sessionId: string): Buffer | null {
|
||||
return active.get(sessionId)?.dek ?? null;
|
||||
}
|
||||
|
||||
export async function getDecryptedField(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
encryptedValue: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!encryptedValue) return null;
|
||||
|
||||
let dek = active.get(sessionId)?.dek ?? null;
|
||||
|
||||
if (!dek) {
|
||||
try {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
columns: { dekEncrypted: true },
|
||||
});
|
||||
if (!app) return null;
|
||||
dek = unwrapDek(app.dekEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return decryptColumn(encryptedValue, dek);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
8
src/lib/baileys/webhook.ts
Normal file
8
src/lib/baileys/webhook.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Webhook stub — replaced by full dispatcher in M5
|
||||
export function emitWebhook(
|
||||
appId: string,
|
||||
event: string,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
console.log("[webhook]", appId, event, JSON.stringify(payload));
|
||||
}
|
||||
81
src/lib/crypto.ts
Normal file
81
src/lib/crypto.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
|
||||
const ALG = "aes-256-gcm";
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
|
||||
function getMasterKey(): Buffer {
|
||||
const raw = process.env.MASTER_ENCRYPTION_KEY;
|
||||
if (!raw)
|
||||
throw new Error("MASTER_ENCRYPTION_KEY is not set — server cannot start");
|
||||
const buf = Buffer.from(raw, "base64");
|
||||
if (buf.byteLength !== 32)
|
||||
throw new Error(
|
||||
"MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)",
|
||||
);
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function generateDek(): Buffer {
|
||||
return randomBytes(32);
|
||||
}
|
||||
|
||||
export function wrapDek(dek: Buffer): string {
|
||||
const kek = getMasterKey();
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv(ALG, kek, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
export function unwrapDek(wrapped: string): Buffer {
|
||||
const kek = getMasterKey();
|
||||
const buf = Buffer.from(wrapped, "base64");
|
||||
const iv = buf.subarray(0, IV_BYTES);
|
||||
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
||||
const data = buf.subarray(IV_BYTES + TAG_BYTES);
|
||||
const decipher = createDecipheriv(ALG, kek, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
}
|
||||
|
||||
export function encryptColumn(plaintext: string, dek: Buffer): string {
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv(ALG, dek, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
export function decryptColumn(ciphertext: string, dek: Buffer): string {
|
||||
const buf = Buffer.from(ciphertext, "base64");
|
||||
const iv = buf.subarray(0, IV_BYTES);
|
||||
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
||||
const data = buf.subarray(IV_BYTES + TAG_BYTES);
|
||||
const decipher = createDecipheriv(ALG, dek, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function encryptWithMasterKey(plaintext: string): string {
|
||||
return encryptColumn(plaintext, getMasterKey());
|
||||
}
|
||||
|
||||
export function decryptWithMasterKey(ciphertext: string): string {
|
||||
return decryptColumn(ciphertext, getMasterKey());
|
||||
}
|
||||
|
||||
export function validateMasterKey(): void {
|
||||
getMasterKey();
|
||||
}
|
||||
23
src/lib/dek-helper.ts
Normal file
23
src/lib/dek-helper.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apps } from "#/db/schema";
|
||||
import { unwrapDek } from "#/lib/crypto";
|
||||
import { getAppDekForSession } from "#/lib/baileys";
|
||||
|
||||
export async function getSessionDek(
|
||||
sessionId: string,
|
||||
appId: string,
|
||||
): Promise<Buffer | null> {
|
||||
const inMemory = getAppDekForSession(sessionId);
|
||||
if (inMemory) return inMemory;
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
columns: { dekEncrypted: true },
|
||||
});
|
||||
if (!app) return null;
|
||||
try {
|
||||
return unwrapDek(app.dekEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
16
src/lib/password.ts
Normal file
16
src/lib/password.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import argon2 from "argon2";
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
69
src/lib/query.ts
Normal file
69
src/lib/query.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { QueryClient, queryOptions } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const res = await fetch(url, { credentials: "include", ...init });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const appsQuery = queryOptions({
|
||||
queryKey: ["apps"],
|
||||
queryFn: () => apiFetch("/api/v1/admin/apps"),
|
||||
});
|
||||
|
||||
export const appQuery = (appId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["apps", appId],
|
||||
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`),
|
||||
});
|
||||
|
||||
export const apiKeysQuery = (appId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["apps", appId, "api-keys"],
|
||||
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`),
|
||||
});
|
||||
|
||||
export const sessionsQuery = queryOptions({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: () => apiFetch("/api/v1/admin/sessions"),
|
||||
});
|
||||
|
||||
export const auditLogQuery = queryOptions({
|
||||
queryKey: ["audit-log"],
|
||||
queryFn: () => apiFetch("/api/v1/admin/audit-log?limit=50"),
|
||||
});
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
appId: string;
|
||||
appName: string;
|
||||
status: string;
|
||||
lastSeen: string | null;
|
||||
createdAt: string;
|
||||
reconnectCount: number;
|
||||
}
|
||||
|
||||
export const adminSessionDetailQuery = (sessionId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["sessions", sessionId, "admin-detail"],
|
||||
queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
export const appSessionsQuery = (appId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["apps", appId, "sessions"],
|
||||
queryFn: async (): Promise<SessionRow[]> => {
|
||||
const data: SessionRow[] = await apiFetch("/api/v1/admin/sessions");
|
||||
return data.filter((s) => s.appId === appId);
|
||||
},
|
||||
});
|
||||
92
src/lib/queue/broadcast-worker.ts
Normal file
92
src/lib/queue/broadcast-worker.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { Job, Processor } from "bullmq";
|
||||
import {
|
||||
createWorker,
|
||||
createQueue,
|
||||
QUEUE_BROADCAST,
|
||||
QUEUE_SEND,
|
||||
} from "./index";
|
||||
import { createMessage } from "#/lib/actions/messages";
|
||||
import { unwrapDek } from "#/lib/crypto";
|
||||
import { db } from "#/db/index";
|
||||
import { apps } from "#/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export interface BroadcastJob {
|
||||
sessionId: string;
|
||||
appId: string;
|
||||
recipients: string[];
|
||||
content: {
|
||||
type: "text" | "image" | "video" | "document" | "audio";
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
filename?: string;
|
||||
voiceNote?: boolean;
|
||||
};
|
||||
rateMs: number;
|
||||
}
|
||||
|
||||
async function processBroadcast(job: Job<BroadcastJob>): Promise<void> {
|
||||
const { sessionId, appId, recipients, content, rateMs } = job.data;
|
||||
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
columns: { dekEncrypted: true },
|
||||
});
|
||||
if (!app) return;
|
||||
const dek = unwrapDek(app.dekEncrypted);
|
||||
|
||||
const sendQueue = createQueue(QUEUE_SEND);
|
||||
|
||||
for (const jid of recipients) {
|
||||
const whatsappMsgId = uuidv4();
|
||||
const msg = await createMessage(
|
||||
{
|
||||
appId,
|
||||
sessionId,
|
||||
whatsappMsgId,
|
||||
direction: "out",
|
||||
type: content.type,
|
||||
toJid: jid,
|
||||
bodyEncrypted: content.text,
|
||||
status: "queued",
|
||||
isGroup: false,
|
||||
},
|
||||
dek,
|
||||
);
|
||||
|
||||
const waContent = buildWaContent(content, jid);
|
||||
await sendQueue.add("send", {
|
||||
messageId: msg.id,
|
||||
sessionId,
|
||||
appId,
|
||||
whatsappMsgId,
|
||||
waMessage: { to: jid, content: waContent },
|
||||
});
|
||||
|
||||
if (rateMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, rateMs));
|
||||
}
|
||||
}
|
||||
|
||||
await sendQueue.close();
|
||||
}
|
||||
|
||||
function buildWaContent(
|
||||
content: BroadcastJob["content"],
|
||||
_jid: string,
|
||||
): Record<string, unknown> {
|
||||
if (content.type === "text") return { text: content.text };
|
||||
return { url: content.mediaUrl, caption: content.caption };
|
||||
}
|
||||
|
||||
const _g = globalThis as { __wahooBroadcastWorker?: ReturnType<typeof createWorker> };
|
||||
|
||||
export function startBroadcastWorker(): void {
|
||||
if (_g.__wahooBroadcastWorker) return;
|
||||
_g.__wahooBroadcastWorker = createWorker(QUEUE_BROADCAST, processBroadcast as Processor);
|
||||
_g.__wahooBroadcastWorker.on("failed", (job, err) => {
|
||||
console.error("[broadcast-worker] job failed", job?.id, err.message);
|
||||
});
|
||||
}
|
||||
32
src/lib/queue/cleanup-worker.ts
Normal file
32
src/lib/queue/cleanup-worker.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Queue, Worker } from "bullmq";
|
||||
import { createQueue, createWorker, QUEUE_CLEANUP } from "./index";
|
||||
import { deleteOldMedia } from "#/lib/actions/messages";
|
||||
|
||||
const CLEANUP_REPEAT_CRON = "0 3 * * *"; // 3am daily
|
||||
|
||||
async function processCleanup(): Promise<void> {
|
||||
const ttlDays = Number(process.env.MEDIA_TTL_DAYS ?? 30);
|
||||
const deleted = await deleteOldMedia(ttlDays);
|
||||
console.log(
|
||||
`[cleanup-worker] deleted ${deleted} media records older than ${ttlDays} days`,
|
||||
);
|
||||
}
|
||||
|
||||
const _g = globalThis as { __wahooCleanupWorker?: Worker; __wahooCleanupQueue?: Queue };
|
||||
|
||||
export async function startCleanupWorker(): Promise<void> {
|
||||
if (_g.__wahooCleanupWorker) return;
|
||||
_g.__wahooCleanupQueue = createQueue(QUEUE_CLEANUP);
|
||||
await _g.__wahooCleanupQueue.add(
|
||||
"cleanup",
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: CLEANUP_REPEAT_CRON },
|
||||
jobId: "media-cleanup-repeatable",
|
||||
},
|
||||
);
|
||||
_g.__wahooCleanupWorker = createWorker(QUEUE_CLEANUP, processCleanup);
|
||||
_g.__wahooCleanupWorker.on("failed", (job, err) => {
|
||||
console.error("[cleanup-worker] job failed", job?.id, err.message);
|
||||
});
|
||||
}
|
||||
30
src/lib/queue/index.ts
Normal file
30
src/lib/queue/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Queue, Worker } from "bullmq";
|
||||
import type { ConnectionOptions } from "bullmq";
|
||||
|
||||
function getRedisConnection(): ConnectionOptions {
|
||||
const url = process.env.REDIS_URL ?? "redis://localhost:6379";
|
||||
const u = new URL(url);
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 6379,
|
||||
password: u.password || undefined,
|
||||
db: u.pathname ? Number(u.pathname.slice(1)) || 0 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const QUEUE_SEND = "msg-send";
|
||||
export const QUEUE_BROADCAST = "msg-broadcast";
|
||||
export const QUEUE_CLEANUP = "msg-cleanup";
|
||||
|
||||
export function createQueue(name: string): Queue {
|
||||
return new Queue(name, { connection: getRedisConnection() });
|
||||
}
|
||||
|
||||
export function createWorker(
|
||||
name: string,
|
||||
processor: ConstructorParameters<typeof Worker>[1],
|
||||
): Worker {
|
||||
return new Worker(name, processor, { connection: getRedisConnection() });
|
||||
}
|
||||
|
||||
export { getRedisConnection };
|
||||
72
src/lib/queue/send-worker.ts
Normal file
72
src/lib/queue/send-worker.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { Job, Processor } from "bullmq";
|
||||
import type { AnyMessageContent, WASocket } from "@whiskeysockets/baileys";
|
||||
import { createWorker, QUEUE_SEND } from "./index";
|
||||
import { getSocket } from "#/lib/baileys";
|
||||
import { updateMessageStatus } from "#/lib/actions/messages";
|
||||
import { emitWebhook } from "#/lib/baileys/webhook";
|
||||
|
||||
export interface SendJob {
|
||||
messageId: string;
|
||||
sessionId: string;
|
||||
appId: string;
|
||||
waMessage: { to: string; content: Record<string, unknown> };
|
||||
whatsappMsgId: string;
|
||||
}
|
||||
|
||||
// Resolve socket across Vite SSR / Nitro module boundary via globalThis
|
||||
function resolveSocket(sessionId: string): WASocket | null {
|
||||
const fromGlobal = (
|
||||
globalThis as {
|
||||
__wahooActiveSessions?: Map<string, { socket: WASocket }>;
|
||||
}
|
||||
).__wahooActiveSessions?.get(sessionId)?.socket;
|
||||
return fromGlobal ?? getSocket(sessionId);
|
||||
}
|
||||
|
||||
async function processSend(job: Job<SendJob>): Promise<void> {
|
||||
const { messageId, sessionId, appId, waMessage, whatsappMsgId } = job.data;
|
||||
|
||||
const mapSize = (globalThis as { __wahooActiveSessions?: Map<string, unknown> }).__wahooActiveSessions?.size ?? -1;
|
||||
const sock = resolveSocket(sessionId);
|
||||
console.log(`[send-worker] resolveSocket(${sessionId}) → ${sock ? "found" : "null"} | map size: ${mapSize}`);
|
||||
if (!sock) {
|
||||
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
||||
emitWebhook(appId, "message.failed", {
|
||||
messageId,
|
||||
sessionId,
|
||||
reason: "session_not_connected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sock.sendMessage(
|
||||
waMessage.to,
|
||||
waMessage.content as unknown as AnyMessageContent,
|
||||
{ messageId: whatsappMsgId },
|
||||
);
|
||||
await updateMessageStatus(whatsappMsgId, sessionId, "sent");
|
||||
emitWebhook(appId, "message.sent", { messageId, sessionId, whatsappMsgId });
|
||||
} catch (err) {
|
||||
const reason =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err !== null
|
||||
? JSON.stringify(err)
|
||||
: String(err);
|
||||
console.error("[send-worker] sendMessage error:", reason, err);
|
||||
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
|
||||
emitWebhook(appId, "message.failed", { messageId, sessionId, reason });
|
||||
throw err instanceof Error ? err : new Error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
const _g = globalThis as { __wahooSendWorker?: ReturnType<typeof createWorker> };
|
||||
|
||||
export function startSendWorker(): void {
|
||||
if (_g.__wahooSendWorker) return;
|
||||
_g.__wahooSendWorker = createWorker(QUEUE_SEND, processSend as Processor);
|
||||
_g.__wahooSendWorker.on("failed", (job, err) => {
|
||||
console.error("[send-worker] job failed", job?.id, err.message);
|
||||
});
|
||||
}
|
||||
61
src/lib/session-api.ts
Normal file
61
src/lib/session-api.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { sealData, unsealData } from "iron-session";
|
||||
import type { SessionData } from "./session";
|
||||
|
||||
const COOKIE_NAME = "wahoo_session";
|
||||
const TTL = 60 * 60 * 24 * 7;
|
||||
|
||||
function getPassword(): string {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s || s.length < 32)
|
||||
throw new Error("SESSION_SECRET must be at least 32 characters");
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function readSession(req: Request): Promise<SessionData | null> {
|
||||
const cookieHeader = req.headers.get("cookie") ?? "";
|
||||
const match = cookieHeader.match(
|
||||
new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`),
|
||||
);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return await unsealData<SessionData>(match[1], {
|
||||
password: getPassword(),
|
||||
ttl: TTL,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSessionCookie(data: SessionData): Promise<string> {
|
||||
const sealed = await sealData(data, { password: getPassword(), ttl: TTL });
|
||||
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||
return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie(): string {
|
||||
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
||||
}
|
||||
|
||||
export function json(
|
||||
data: unknown,
|
||||
status = 200,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...extraHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
export function jsonError(message: string, status: number): Response {
|
||||
return json({ error: message }, status);
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
req: Request,
|
||||
): Promise<SessionData | Response> {
|
||||
const session = await readSession(req);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
return session;
|
||||
}
|
||||
5
src/lib/session.ts
Normal file
5
src/lib/session.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface SessionData {
|
||||
userId: string;
|
||||
email: string;
|
||||
totpVerified: boolean;
|
||||
}
|
||||
24
src/lib/storage/index.ts
Normal file
24
src/lib/storage/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export interface StorageDriver {
|
||||
upload: (key: string, data: Buffer, mime: string) => Promise<void>;
|
||||
signedUrl: (key: string, ttlSeconds: number) => Promise<string>;
|
||||
delete: (key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type { LocalStorageDriver } from "./local";
|
||||
export type { S3StorageDriver } from "./s3";
|
||||
|
||||
let _driver: StorageDriver | null = null;
|
||||
|
||||
export async function getStorageDriver(): Promise<StorageDriver> {
|
||||
if (_driver) return _driver;
|
||||
|
||||
const type = process.env.STORAGE_DRIVER ?? "local";
|
||||
if (type === "s3") {
|
||||
const { S3StorageDriver } = await import("./s3");
|
||||
_driver = new S3StorageDriver();
|
||||
} else {
|
||||
const { LocalStorageDriver } = await import("./local");
|
||||
_driver = new LocalStorageDriver();
|
||||
}
|
||||
return _driver;
|
||||
}
|
||||
50
src/lib/storage/local.ts
Normal file
50
src/lib/storage/local.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createHmac } from "node:crypto";
|
||||
import { mkdir, writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { StorageDriver } from "./index";
|
||||
|
||||
function getBasePath(): string {
|
||||
return process.env.STORAGE_LOCAL_PATH ?? "./uploads";
|
||||
}
|
||||
|
||||
function getSigningSecret(): string {
|
||||
const s = process.env.STORAGE_SIGNING_SECRET ?? process.env.SESSION_SECRET;
|
||||
if (!s)
|
||||
throw new Error("STORAGE_SIGNING_SECRET or SESSION_SECRET must be set");
|
||||
return s;
|
||||
}
|
||||
|
||||
export class LocalStorageDriver implements StorageDriver {
|
||||
async upload(key: string, data: Buffer, _mime: string): Promise<void> {
|
||||
const fullPath = join(getBasePath(), key);
|
||||
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(fullPath, data);
|
||||
}
|
||||
|
||||
async signedUrl(key: string, ttlSeconds: number): Promise<string> {
|
||||
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||
const sig = createHmac("sha256", getSigningSecret())
|
||||
.update(`${key}:${expires}`)
|
||||
.digest("hex");
|
||||
const base = process.env.APP_URL ?? "http://localhost:3000";
|
||||
return `${base}/api/v1/media/?key=${encodeURIComponent(key)}&expires=${expires}&sig=${sig}`;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(getBasePath(), key));
|
||||
} catch {
|
||||
/* ignore missing */
|
||||
}
|
||||
}
|
||||
|
||||
verifySignedUrl(key: string, expires: number, sig: string): boolean {
|
||||
const expected = createHmac("sha256", getSigningSecret())
|
||||
.update(`${key}:${expires}`)
|
||||
.digest("hex");
|
||||
if (expected !== sig) return false;
|
||||
if (Math.floor(Date.now() / 1000) > expires) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
src/lib/storage/s3.ts
Normal file
55
src/lib/storage/s3.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand, GetObjectCommand
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import type { StorageDriver } from "./index";
|
||||
|
||||
function getClient(): S3Client {
|
||||
return new S3Client({
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getBucket(): string {
|
||||
const b = process.env.S3_BUCKET;
|
||||
if (!b) throw new Error("S3_BUCKET env var not set");
|
||||
return b;
|
||||
}
|
||||
|
||||
export class S3StorageDriver implements StorageDriver {
|
||||
async upload(key: string, data: Buffer, mime: string): Promise<void> {
|
||||
const client = getClient();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: mime,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async signedUrl(key: string, ttlSeconds: number): Promise<string> {
|
||||
const client = getClient();
|
||||
return getSignedUrl(
|
||||
client,
|
||||
new GetObjectCommand({ Bucket: getBucket(), Key: key }),
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const client = getClient();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: getBucket(), Key: key }),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/lib/totp.ts
Normal file
35
src/lib/totp.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import * as OTPAuth from "otpauth";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const ISSUER = "Wahoo";
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
const bytes = randomBytes(20);
|
||||
return Array.from(bytes)
|
||||
.map((b) => B32[b & 0x1f])
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getTotpUri(email: string, secret: string): string {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: ISSUER,
|
||||
label: email,
|
||||
secret,
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
return totp.toString();
|
||||
}
|
||||
|
||||
export function verifyTotp(secret: string, token: string): boolean {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: ISSUER,
|
||||
secret,
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
const delta = totp.validate({ token, window: 1 });
|
||||
return delta !== null;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,38 +9,624 @@
|
|||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuditLogRouteImport } from './routes/audit-log'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SessionsIndexRouteImport } from './routes/sessions/index'
|
||||
import { Route as AppsIndexRouteImport } from './routes/apps/index'
|
||||
import { Route as SessionsNewRouteImport } from './routes/sessions/new'
|
||||
import { Route as SessionsIdRouteImport } from './routes/sessions/$id'
|
||||
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
|
||||
import { Route as ApiReadyRouteImport } from './routes/api/ready'
|
||||
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
||||
import { Route as AppsAppIdIndexRouteImport } from './routes/apps/$appId/index'
|
||||
import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me'
|
||||
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
|
||||
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
|
||||
import { Route as AppsAppIdSessionsIndexRouteImport } from './routes/apps/$appId/sessions/index'
|
||||
import { Route as ApiV1SessionsIndexRouteImport } from './routes/api/v1/sessions/index'
|
||||
import { Route as ApiV1MediaIndexRouteImport } from './routes/api/v1/media/index'
|
||||
import { Route as AppsAppIdSessionsNewRouteImport } from './routes/apps/$appId/sessions/new'
|
||||
import { Route as AppsAppIdSessionsSessionIdRouteImport } from './routes/apps/$appId/sessions/$sessionId'
|
||||
import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
|
||||
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
|
||||
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
|
||||
import { Route as ApiV1AdminSessionsIndexRouteImport } from './routes/api/v1/admin/sessions/index'
|
||||
import { Route as ApiV1AdminAppsIndexRouteImport } from './routes/api/v1/admin/apps/index'
|
||||
import { Route as ApiV1SessionsIdQrRouteImport } from './routes/api/v1/sessions/$id/qr'
|
||||
import { Route as ApiV1SessionsIdPresenceRouteImport } from './routes/api/v1/sessions/$id/presence'
|
||||
import { Route as ApiV1SessionsIdPairRouteImport } from './routes/api/v1/sessions/$id/pair'
|
||||
import { Route as ApiV1SessionsIdHealthRouteImport } from './routes/api/v1/sessions/$id/health'
|
||||
import { Route as ApiV1SessionsIdDisconnectRouteImport } from './routes/api/v1/sessions/$id/disconnect'
|
||||
import { Route as ApiV1SessionsIdMessagesIndexRouteImport } from './routes/api/v1/sessions/$id/messages/index'
|
||||
import { Route as ApiV1AdminSessionsIdIndexRouteImport } from './routes/api/v1/admin/sessions/$id/index'
|
||||
import { Route as ApiV1AdminAppsIdIndexRouteImport } from './routes/api/v1/admin/apps/$id/index'
|
||||
import { Route as ApiV1SessionsIdMessagesVideoRouteImport } from './routes/api/v1/sessions/$id/messages/video'
|
||||
import { Route as ApiV1SessionsIdMessagesTextRouteImport } from './routes/api/v1/sessions/$id/messages/text'
|
||||
import { Route as ApiV1SessionsIdMessagesImageRouteImport } from './routes/api/v1/sessions/$id/messages/image'
|
||||
import { Route as ApiV1SessionsIdMessagesDocumentRouteImport } from './routes/api/v1/sessions/$id/messages/document'
|
||||
import { Route as ApiV1SessionsIdMessagesBroadcastRouteImport } from './routes/api/v1/sessions/$id/messages/broadcast'
|
||||
import { Route as ApiV1SessionsIdMessagesAudioRouteImport } from './routes/api/v1/sessions/$id/messages/audio'
|
||||
import { Route as ApiV1AdminSessionsIdQrRouteImport } from './routes/api/v1/admin/sessions/$id/qr'
|
||||
import { Route as ApiV1AdminSessionsIdPairRouteImport } from './routes/api/v1/admin/sessions/$id/pair'
|
||||
import { Route as ApiV1AdminSessionsIdDisconnectRouteImport } from './routes/api/v1/admin/sessions/$id/disconnect'
|
||||
import { Route as ApiV1AdminAppsIdApiKeysIndexRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/index'
|
||||
import { Route as ApiV1SessionsIdMessagesMsgIdReadRouteImport } from './routes/api/v1/sessions/$id/messages/$msgId/read'
|
||||
import { Route as ApiV1AdminAppsIdApiKeysKeyIdRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuditLogRoute = AuditLogRouteImport.update({
|
||||
id: '/audit-log',
|
||||
path: '/audit-log',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SessionsIndexRoute = SessionsIndexRouteImport.update({
|
||||
id: '/sessions/',
|
||||
path: '/sessions/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppsIndexRoute = AppsIndexRouteImport.update({
|
||||
id: '/apps/',
|
||||
path: '/apps/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SessionsNewRoute = SessionsNewRouteImport.update({
|
||||
id: '/sessions/new',
|
||||
path: '/sessions/new',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SessionsIdRoute = SessionsIdRouteImport.update({
|
||||
id: '/sessions/$id',
|
||||
path: '/sessions/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppsAppIdRoute = AppsAppIdRouteImport.update({
|
||||
id: '/apps/$appId',
|
||||
path: '/apps/$appId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiReadyRoute = ApiReadyRouteImport.update({
|
||||
id: '/api/ready',
|
||||
path: '/api/ready',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||
id: '/api/health',
|
||||
path: '/api/health',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppsAppIdIndexRoute = AppsAppIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppsAppIdRoute,
|
||||
} as any)
|
||||
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
|
||||
id: '/api/auth/me',
|
||||
path: '/api/auth/me',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthLogoutRoute = ApiAuthLogoutRouteImport.update({
|
||||
id: '/api/auth/logout',
|
||||
path: '/api/auth/logout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
|
||||
id: '/api/auth/login',
|
||||
path: '/api/auth/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppsAppIdSessionsIndexRoute = AppsAppIdSessionsIndexRouteImport.update({
|
||||
id: '/sessions/',
|
||||
path: '/sessions/',
|
||||
getParentRoute: () => AppsAppIdRoute,
|
||||
} as any)
|
||||
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
|
||||
id: '/api/v1/sessions/',
|
||||
path: '/api/v1/sessions/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1MediaIndexRoute = ApiV1MediaIndexRouteImport.update({
|
||||
id: '/api/v1/media/',
|
||||
path: '/api/v1/media/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppsAppIdSessionsNewRoute = AppsAppIdSessionsNewRouteImport.update({
|
||||
id: '/sessions/new',
|
||||
path: '/sessions/new',
|
||||
getParentRoute: () => AppsAppIdRoute,
|
||||
} as any)
|
||||
const AppsAppIdSessionsSessionIdRoute =
|
||||
AppsAppIdSessionsSessionIdRouteImport.update({
|
||||
id: '/sessions/$sessionId',
|
||||
path: '/sessions/$sessionId',
|
||||
getParentRoute: () => AppsAppIdRoute,
|
||||
} as any)
|
||||
const ApiV1AdminAuditLogRoute = ApiV1AdminAuditLogRouteImport.update({
|
||||
id: '/api/v1/admin/audit-log',
|
||||
path: '/api/v1/admin/audit-log',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthTotpEnrollRoute = ApiAuthTotpEnrollRouteImport.update({
|
||||
id: '/api/auth/totp/enroll',
|
||||
path: '/api/auth/totp/enroll',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdIndexRoute = ApiV1SessionsIdIndexRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/',
|
||||
path: '/api/v1/sessions/$id/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminSessionsIndexRoute = ApiV1AdminSessionsIndexRouteImport.update({
|
||||
id: '/api/v1/admin/sessions/',
|
||||
path: '/api/v1/admin/sessions/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminAppsIndexRoute = ApiV1AdminAppsIndexRouteImport.update({
|
||||
id: '/api/v1/admin/apps/',
|
||||
path: '/api/v1/admin/apps/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdQrRoute = ApiV1SessionsIdQrRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/qr',
|
||||
path: '/api/v1/sessions/$id/qr',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdPresenceRoute = ApiV1SessionsIdPresenceRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/presence',
|
||||
path: '/api/v1/sessions/$id/presence',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdPairRoute = ApiV1SessionsIdPairRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/pair',
|
||||
path: '/api/v1/sessions/$id/pair',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdHealthRoute = ApiV1SessionsIdHealthRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/health',
|
||||
path: '/api/v1/sessions/$id/health',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdDisconnectRoute =
|
||||
ApiV1SessionsIdDisconnectRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/disconnect',
|
||||
path: '/api/v1/sessions/$id/disconnect',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesIndexRoute =
|
||||
ApiV1SessionsIdMessagesIndexRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/',
|
||||
path: '/api/v1/sessions/$id/messages/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminSessionsIdIndexRoute =
|
||||
ApiV1AdminSessionsIdIndexRouteImport.update({
|
||||
id: '/api/v1/admin/sessions/$id/',
|
||||
path: '/api/v1/admin/sessions/$id/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
|
||||
id: '/api/v1/admin/apps/$id/',
|
||||
path: '/api/v1/admin/apps/$id/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesVideoRoute =
|
||||
ApiV1SessionsIdMessagesVideoRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/video',
|
||||
path: '/api/v1/sessions/$id/messages/video',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesTextRoute =
|
||||
ApiV1SessionsIdMessagesTextRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/text',
|
||||
path: '/api/v1/sessions/$id/messages/text',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesImageRoute =
|
||||
ApiV1SessionsIdMessagesImageRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/image',
|
||||
path: '/api/v1/sessions/$id/messages/image',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesDocumentRoute =
|
||||
ApiV1SessionsIdMessagesDocumentRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/document',
|
||||
path: '/api/v1/sessions/$id/messages/document',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesBroadcastRoute =
|
||||
ApiV1SessionsIdMessagesBroadcastRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/broadcast',
|
||||
path: '/api/v1/sessions/$id/messages/broadcast',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesAudioRoute =
|
||||
ApiV1SessionsIdMessagesAudioRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/audio',
|
||||
path: '/api/v1/sessions/$id/messages/audio',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminSessionsIdQrRoute = ApiV1AdminSessionsIdQrRouteImport.update({
|
||||
id: '/api/v1/admin/sessions/$id/qr',
|
||||
path: '/api/v1/admin/sessions/$id/qr',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminSessionsIdPairRoute =
|
||||
ApiV1AdminSessionsIdPairRouteImport.update({
|
||||
id: '/api/v1/admin/sessions/$id/pair',
|
||||
path: '/api/v1/admin/sessions/$id/pair',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminSessionsIdDisconnectRoute =
|
||||
ApiV1AdminSessionsIdDisconnectRouteImport.update({
|
||||
id: '/api/v1/admin/sessions/$id/disconnect',
|
||||
path: '/api/v1/admin/sessions/$id/disconnect',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminAppsIdApiKeysIndexRoute =
|
||||
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
|
||||
id: '/api/v1/admin/apps/$id/api-keys/',
|
||||
path: '/api/v1/admin/apps/$id/api-keys/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1SessionsIdMessagesMsgIdReadRoute =
|
||||
ApiV1SessionsIdMessagesMsgIdReadRouteImport.update({
|
||||
id: '/api/v1/sessions/$id/messages/$msgId/read',
|
||||
path: '/api/v1/sessions/$id/messages/$msgId/read',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminAppsIdApiKeysKeyIdRoute =
|
||||
ApiV1AdminAppsIdApiKeysKeyIdRouteImport.update({
|
||||
id: '/api/v1/admin/apps/$id/api-keys/$keyId',
|
||||
path: '/api/v1/admin/apps/$id/api-keys/$keyId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/audit-log': typeof AuditLogRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/ready': typeof ApiReadyRoute
|
||||
'/apps/$appId': typeof AppsAppIdRouteWithChildren
|
||||
'/sessions/$id': typeof SessionsIdRoute
|
||||
'/sessions/new': typeof SessionsNewRoute
|
||||
'/apps/': typeof AppsIndexRoute
|
||||
'/sessions/': typeof SessionsIndexRoute
|
||||
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||
'/api/auth/me': typeof ApiAuthMeRoute
|
||||
'/apps/$appId/': typeof AppsAppIdIndexRoute
|
||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
|
||||
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
|
||||
'/api/v1/media/': typeof ApiV1MediaIndexRoute
|
||||
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
|
||||
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
|
||||
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
||||
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
||||
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
||||
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
|
||||
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
|
||||
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
|
||||
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
|
||||
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
|
||||
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
|
||||
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
|
||||
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
|
||||
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
|
||||
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
|
||||
'/api/v1/sessions/$id/messages/document': typeof ApiV1SessionsIdMessagesDocumentRoute
|
||||
'/api/v1/sessions/$id/messages/image': typeof ApiV1SessionsIdMessagesImageRoute
|
||||
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
|
||||
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
|
||||
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
|
||||
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
|
||||
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/audit-log': typeof AuditLogRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/ready': typeof ApiReadyRoute
|
||||
'/sessions/$id': typeof SessionsIdRoute
|
||||
'/sessions/new': typeof SessionsNewRoute
|
||||
'/apps': typeof AppsIndexRoute
|
||||
'/sessions': typeof SessionsIndexRoute
|
||||
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||
'/api/auth/me': typeof ApiAuthMeRoute
|
||||
'/apps/$appId': typeof AppsAppIdIndexRoute
|
||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
|
||||
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
|
||||
'/api/v1/media': typeof ApiV1MediaIndexRoute
|
||||
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
|
||||
'/apps/$appId/sessions': typeof AppsAppIdSessionsIndexRoute
|
||||
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
||||
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
||||
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
||||
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
|
||||
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
|
||||
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
|
||||
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
|
||||
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
|
||||
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
|
||||
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
|
||||
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
|
||||
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
|
||||
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
|
||||
'/api/v1/sessions/$id/messages/document': typeof ApiV1SessionsIdMessagesDocumentRoute
|
||||
'/api/v1/sessions/$id/messages/image': typeof ApiV1SessionsIdMessagesImageRoute
|
||||
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
|
||||
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
|
||||
'/api/v1/admin/apps/$id': typeof ApiV1AdminAppsIdIndexRoute
|
||||
'/api/v1/admin/sessions/$id': typeof ApiV1AdminSessionsIdIndexRoute
|
||||
'/api/v1/sessions/$id/messages': typeof ApiV1SessionsIdMessagesIndexRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/audit-log': typeof AuditLogRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/ready': typeof ApiReadyRoute
|
||||
'/apps/$appId': typeof AppsAppIdRouteWithChildren
|
||||
'/sessions/$id': typeof SessionsIdRoute
|
||||
'/sessions/new': typeof SessionsNewRoute
|
||||
'/apps/': typeof AppsIndexRoute
|
||||
'/sessions/': typeof SessionsIndexRoute
|
||||
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||
'/api/auth/me': typeof ApiAuthMeRoute
|
||||
'/apps/$appId/': typeof AppsAppIdIndexRoute
|
||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
|
||||
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
|
||||
'/api/v1/media/': typeof ApiV1MediaIndexRoute
|
||||
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
|
||||
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
|
||||
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
||||
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
||||
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
||||
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
|
||||
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
|
||||
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
|
||||
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
|
||||
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
|
||||
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
|
||||
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
|
||||
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
|
||||
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
|
||||
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
|
||||
'/api/v1/sessions/$id/messages/document': typeof ApiV1SessionsIdMessagesDocumentRoute
|
||||
'/api/v1/sessions/$id/messages/image': typeof ApiV1SessionsIdMessagesImageRoute
|
||||
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
|
||||
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
|
||||
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
|
||||
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
|
||||
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/audit-log'
|
||||
| '/login'
|
||||
| '/api/health'
|
||||
| '/api/ready'
|
||||
| '/apps/$appId'
|
||||
| '/sessions/$id'
|
||||
| '/sessions/new'
|
||||
| '/apps/'
|
||||
| '/sessions/'
|
||||
| '/api/auth/login'
|
||||
| '/api/auth/logout'
|
||||
| '/api/auth/me'
|
||||
| '/apps/$appId/'
|
||||
| '/api/auth/totp/enroll'
|
||||
| '/api/v1/admin/audit-log'
|
||||
| '/apps/$appId/sessions/$sessionId'
|
||||
| '/apps/$appId/sessions/new'
|
||||
| '/api/v1/media/'
|
||||
| '/api/v1/sessions/'
|
||||
| '/apps/$appId/sessions/'
|
||||
| '/api/v1/sessions/$id/disconnect'
|
||||
| '/api/v1/sessions/$id/health'
|
||||
| '/api/v1/sessions/$id/pair'
|
||||
| '/api/v1/sessions/$id/presence'
|
||||
| '/api/v1/sessions/$id/qr'
|
||||
| '/api/v1/admin/apps/'
|
||||
| '/api/v1/admin/sessions/'
|
||||
| '/api/v1/sessions/$id/'
|
||||
| '/api/v1/admin/sessions/$id/disconnect'
|
||||
| '/api/v1/admin/sessions/$id/pair'
|
||||
| '/api/v1/admin/sessions/$id/qr'
|
||||
| '/api/v1/sessions/$id/messages/audio'
|
||||
| '/api/v1/sessions/$id/messages/broadcast'
|
||||
| '/api/v1/sessions/$id/messages/document'
|
||||
| '/api/v1/sessions/$id/messages/image'
|
||||
| '/api/v1/sessions/$id/messages/text'
|
||||
| '/api/v1/sessions/$id/messages/video'
|
||||
| '/api/v1/admin/apps/$id/'
|
||||
| '/api/v1/admin/sessions/$id/'
|
||||
| '/api/v1/sessions/$id/messages/'
|
||||
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
| '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
| '/api/v1/admin/apps/$id/api-keys/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/'
|
||||
id: '__root__' | '/'
|
||||
to:
|
||||
| '/'
|
||||
| '/audit-log'
|
||||
| '/login'
|
||||
| '/api/health'
|
||||
| '/api/ready'
|
||||
| '/sessions/$id'
|
||||
| '/sessions/new'
|
||||
| '/apps'
|
||||
| '/sessions'
|
||||
| '/api/auth/login'
|
||||
| '/api/auth/logout'
|
||||
| '/api/auth/me'
|
||||
| '/apps/$appId'
|
||||
| '/api/auth/totp/enroll'
|
||||
| '/api/v1/admin/audit-log'
|
||||
| '/apps/$appId/sessions/$sessionId'
|
||||
| '/apps/$appId/sessions/new'
|
||||
| '/api/v1/media'
|
||||
| '/api/v1/sessions'
|
||||
| '/apps/$appId/sessions'
|
||||
| '/api/v1/sessions/$id/disconnect'
|
||||
| '/api/v1/sessions/$id/health'
|
||||
| '/api/v1/sessions/$id/pair'
|
||||
| '/api/v1/sessions/$id/presence'
|
||||
| '/api/v1/sessions/$id/qr'
|
||||
| '/api/v1/admin/apps'
|
||||
| '/api/v1/admin/sessions'
|
||||
| '/api/v1/sessions/$id'
|
||||
| '/api/v1/admin/sessions/$id/disconnect'
|
||||
| '/api/v1/admin/sessions/$id/pair'
|
||||
| '/api/v1/admin/sessions/$id/qr'
|
||||
| '/api/v1/sessions/$id/messages/audio'
|
||||
| '/api/v1/sessions/$id/messages/broadcast'
|
||||
| '/api/v1/sessions/$id/messages/document'
|
||||
| '/api/v1/sessions/$id/messages/image'
|
||||
| '/api/v1/sessions/$id/messages/text'
|
||||
| '/api/v1/sessions/$id/messages/video'
|
||||
| '/api/v1/admin/apps/$id'
|
||||
| '/api/v1/admin/sessions/$id'
|
||||
| '/api/v1/sessions/$id/messages'
|
||||
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
| '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
| '/api/v1/admin/apps/$id/api-keys'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/audit-log'
|
||||
| '/login'
|
||||
| '/api/health'
|
||||
| '/api/ready'
|
||||
| '/apps/$appId'
|
||||
| '/sessions/$id'
|
||||
| '/sessions/new'
|
||||
| '/apps/'
|
||||
| '/sessions/'
|
||||
| '/api/auth/login'
|
||||
| '/api/auth/logout'
|
||||
| '/api/auth/me'
|
||||
| '/apps/$appId/'
|
||||
| '/api/auth/totp/enroll'
|
||||
| '/api/v1/admin/audit-log'
|
||||
| '/apps/$appId/sessions/$sessionId'
|
||||
| '/apps/$appId/sessions/new'
|
||||
| '/api/v1/media/'
|
||||
| '/api/v1/sessions/'
|
||||
| '/apps/$appId/sessions/'
|
||||
| '/api/v1/sessions/$id/disconnect'
|
||||
| '/api/v1/sessions/$id/health'
|
||||
| '/api/v1/sessions/$id/pair'
|
||||
| '/api/v1/sessions/$id/presence'
|
||||
| '/api/v1/sessions/$id/qr'
|
||||
| '/api/v1/admin/apps/'
|
||||
| '/api/v1/admin/sessions/'
|
||||
| '/api/v1/sessions/$id/'
|
||||
| '/api/v1/admin/sessions/$id/disconnect'
|
||||
| '/api/v1/admin/sessions/$id/pair'
|
||||
| '/api/v1/admin/sessions/$id/qr'
|
||||
| '/api/v1/sessions/$id/messages/audio'
|
||||
| '/api/v1/sessions/$id/messages/broadcast'
|
||||
| '/api/v1/sessions/$id/messages/document'
|
||||
| '/api/v1/sessions/$id/messages/image'
|
||||
| '/api/v1/sessions/$id/messages/text'
|
||||
| '/api/v1/sessions/$id/messages/video'
|
||||
| '/api/v1/admin/apps/$id/'
|
||||
| '/api/v1/admin/sessions/$id/'
|
||||
| '/api/v1/sessions/$id/messages/'
|
||||
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
| '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
| '/api/v1/admin/apps/$id/api-keys/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AuditLogRoute: typeof AuditLogRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
ApiHealthRoute: typeof ApiHealthRoute
|
||||
ApiReadyRoute: typeof ApiReadyRoute
|
||||
AppsAppIdRoute: typeof AppsAppIdRouteWithChildren
|
||||
SessionsIdRoute: typeof SessionsIdRoute
|
||||
SessionsNewRoute: typeof SessionsNewRoute
|
||||
AppsIndexRoute: typeof AppsIndexRoute
|
||||
SessionsIndexRoute: typeof SessionsIndexRoute
|
||||
ApiAuthLoginRoute: typeof ApiAuthLoginRoute
|
||||
ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute
|
||||
ApiAuthMeRoute: typeof ApiAuthMeRoute
|
||||
ApiAuthTotpEnrollRoute: typeof ApiAuthTotpEnrollRoute
|
||||
ApiV1AdminAuditLogRoute: typeof ApiV1AdminAuditLogRoute
|
||||
ApiV1MediaIndexRoute: typeof ApiV1MediaIndexRoute
|
||||
ApiV1SessionsIndexRoute: typeof ApiV1SessionsIndexRoute
|
||||
ApiV1SessionsIdDisconnectRoute: typeof ApiV1SessionsIdDisconnectRoute
|
||||
ApiV1SessionsIdHealthRoute: typeof ApiV1SessionsIdHealthRoute
|
||||
ApiV1SessionsIdPairRoute: typeof ApiV1SessionsIdPairRoute
|
||||
ApiV1SessionsIdPresenceRoute: typeof ApiV1SessionsIdPresenceRoute
|
||||
ApiV1SessionsIdQrRoute: typeof ApiV1SessionsIdQrRoute
|
||||
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
|
||||
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
|
||||
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
|
||||
ApiV1AdminSessionsIdDisconnectRoute: typeof ApiV1AdminSessionsIdDisconnectRoute
|
||||
ApiV1AdminSessionsIdPairRoute: typeof ApiV1AdminSessionsIdPairRoute
|
||||
ApiV1AdminSessionsIdQrRoute: typeof ApiV1AdminSessionsIdQrRoute
|
||||
ApiV1SessionsIdMessagesAudioRoute: typeof ApiV1SessionsIdMessagesAudioRoute
|
||||
ApiV1SessionsIdMessagesBroadcastRoute: typeof ApiV1SessionsIdMessagesBroadcastRoute
|
||||
ApiV1SessionsIdMessagesDocumentRoute: typeof ApiV1SessionsIdMessagesDocumentRoute
|
||||
ApiV1SessionsIdMessagesImageRoute: typeof ApiV1SessionsIdMessagesImageRoute
|
||||
ApiV1SessionsIdMessagesTextRoute: typeof ApiV1SessionsIdMessagesTextRoute
|
||||
ApiV1SessionsIdMessagesVideoRoute: typeof ApiV1SessionsIdMessagesVideoRoute
|
||||
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
|
||||
ApiV1AdminSessionsIdIndexRoute: typeof ApiV1AdminSessionsIdIndexRoute
|
||||
ApiV1SessionsIdMessagesIndexRoute: typeof ApiV1SessionsIdMessagesIndexRoute
|
||||
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||
ApiV1SessionsIdMessagesMsgIdReadRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/audit-log': {
|
||||
id: '/audit-log'
|
||||
path: '/audit-log'
|
||||
fullPath: '/audit-log'
|
||||
preLoaderRoute: typeof AuditLogRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
|
|
@ -48,11 +634,355 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/sessions/': {
|
||||
id: '/sessions/'
|
||||
path: '/sessions'
|
||||
fullPath: '/sessions/'
|
||||
preLoaderRoute: typeof SessionsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/apps/': {
|
||||
id: '/apps/'
|
||||
path: '/apps'
|
||||
fullPath: '/apps/'
|
||||
preLoaderRoute: typeof AppsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/sessions/new': {
|
||||
id: '/sessions/new'
|
||||
path: '/sessions/new'
|
||||
fullPath: '/sessions/new'
|
||||
preLoaderRoute: typeof SessionsNewRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/sessions/$id': {
|
||||
id: '/sessions/$id'
|
||||
path: '/sessions/$id'
|
||||
fullPath: '/sessions/$id'
|
||||
preLoaderRoute: typeof SessionsIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/apps/$appId': {
|
||||
id: '/apps/$appId'
|
||||
path: '/apps/$appId'
|
||||
fullPath: '/apps/$appId'
|
||||
preLoaderRoute: typeof AppsAppIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/ready': {
|
||||
id: '/api/ready'
|
||||
path: '/api/ready'
|
||||
fullPath: '/api/ready'
|
||||
preLoaderRoute: typeof ApiReadyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/health': {
|
||||
id: '/api/health'
|
||||
path: '/api/health'
|
||||
fullPath: '/api/health'
|
||||
preLoaderRoute: typeof ApiHealthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/apps/$appId/': {
|
||||
id: '/apps/$appId/'
|
||||
path: '/'
|
||||
fullPath: '/apps/$appId/'
|
||||
preLoaderRoute: typeof AppsAppIdIndexRouteImport
|
||||
parentRoute: typeof AppsAppIdRoute
|
||||
}
|
||||
'/api/auth/me': {
|
||||
id: '/api/auth/me'
|
||||
path: '/api/auth/me'
|
||||
fullPath: '/api/auth/me'
|
||||
preLoaderRoute: typeof ApiAuthMeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/logout': {
|
||||
id: '/api/auth/logout'
|
||||
path: '/api/auth/logout'
|
||||
fullPath: '/api/auth/logout'
|
||||
preLoaderRoute: typeof ApiAuthLogoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/login': {
|
||||
id: '/api/auth/login'
|
||||
path: '/api/auth/login'
|
||||
fullPath: '/api/auth/login'
|
||||
preLoaderRoute: typeof ApiAuthLoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/apps/$appId/sessions/': {
|
||||
id: '/apps/$appId/sessions/'
|
||||
path: '/sessions'
|
||||
fullPath: '/apps/$appId/sessions/'
|
||||
preLoaderRoute: typeof AppsAppIdSessionsIndexRouteImport
|
||||
parentRoute: typeof AppsAppIdRoute
|
||||
}
|
||||
'/api/v1/sessions/': {
|
||||
id: '/api/v1/sessions/'
|
||||
path: '/api/v1/sessions'
|
||||
fullPath: '/api/v1/sessions/'
|
||||
preLoaderRoute: typeof ApiV1SessionsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/media/': {
|
||||
id: '/api/v1/media/'
|
||||
path: '/api/v1/media'
|
||||
fullPath: '/api/v1/media/'
|
||||
preLoaderRoute: typeof ApiV1MediaIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/apps/$appId/sessions/new': {
|
||||
id: '/apps/$appId/sessions/new'
|
||||
path: '/sessions/new'
|
||||
fullPath: '/apps/$appId/sessions/new'
|
||||
preLoaderRoute: typeof AppsAppIdSessionsNewRouteImport
|
||||
parentRoute: typeof AppsAppIdRoute
|
||||
}
|
||||
'/apps/$appId/sessions/$sessionId': {
|
||||
id: '/apps/$appId/sessions/$sessionId'
|
||||
path: '/sessions/$sessionId'
|
||||
fullPath: '/apps/$appId/sessions/$sessionId'
|
||||
preLoaderRoute: typeof AppsAppIdSessionsSessionIdRouteImport
|
||||
parentRoute: typeof AppsAppIdRoute
|
||||
}
|
||||
'/api/v1/admin/audit-log': {
|
||||
id: '/api/v1/admin/audit-log'
|
||||
path: '/api/v1/admin/audit-log'
|
||||
fullPath: '/api/v1/admin/audit-log'
|
||||
preLoaderRoute: typeof ApiV1AdminAuditLogRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/totp/enroll': {
|
||||
id: '/api/auth/totp/enroll'
|
||||
path: '/api/auth/totp/enroll'
|
||||
fullPath: '/api/auth/totp/enroll'
|
||||
preLoaderRoute: typeof ApiAuthTotpEnrollRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/': {
|
||||
id: '/api/v1/sessions/$id/'
|
||||
path: '/api/v1/sessions/$id'
|
||||
fullPath: '/api/v1/sessions/$id/'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/sessions/': {
|
||||
id: '/api/v1/admin/sessions/'
|
||||
path: '/api/v1/admin/sessions'
|
||||
fullPath: '/api/v1/admin/sessions/'
|
||||
preLoaderRoute: typeof ApiV1AdminSessionsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/apps/': {
|
||||
id: '/api/v1/admin/apps/'
|
||||
path: '/api/v1/admin/apps'
|
||||
fullPath: '/api/v1/admin/apps/'
|
||||
preLoaderRoute: typeof ApiV1AdminAppsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/qr': {
|
||||
id: '/api/v1/sessions/$id/qr'
|
||||
path: '/api/v1/sessions/$id/qr'
|
||||
fullPath: '/api/v1/sessions/$id/qr'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdQrRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/presence': {
|
||||
id: '/api/v1/sessions/$id/presence'
|
||||
path: '/api/v1/sessions/$id/presence'
|
||||
fullPath: '/api/v1/sessions/$id/presence'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdPresenceRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/pair': {
|
||||
id: '/api/v1/sessions/$id/pair'
|
||||
path: '/api/v1/sessions/$id/pair'
|
||||
fullPath: '/api/v1/sessions/$id/pair'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdPairRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/health': {
|
||||
id: '/api/v1/sessions/$id/health'
|
||||
path: '/api/v1/sessions/$id/health'
|
||||
fullPath: '/api/v1/sessions/$id/health'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdHealthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/disconnect': {
|
||||
id: '/api/v1/sessions/$id/disconnect'
|
||||
path: '/api/v1/sessions/$id/disconnect'
|
||||
fullPath: '/api/v1/sessions/$id/disconnect'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdDisconnectRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/': {
|
||||
id: '/api/v1/sessions/$id/messages/'
|
||||
path: '/api/v1/sessions/$id/messages'
|
||||
fullPath: '/api/v1/sessions/$id/messages/'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/sessions/$id/': {
|
||||
id: '/api/v1/admin/sessions/$id/'
|
||||
path: '/api/v1/admin/sessions/$id'
|
||||
fullPath: '/api/v1/admin/sessions/$id/'
|
||||
preLoaderRoute: typeof ApiV1AdminSessionsIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/apps/$id/': {
|
||||
id: '/api/v1/admin/apps/$id/'
|
||||
path: '/api/v1/admin/apps/$id'
|
||||
fullPath: '/api/v1/admin/apps/$id/'
|
||||
preLoaderRoute: typeof ApiV1AdminAppsIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/video': {
|
||||
id: '/api/v1/sessions/$id/messages/video'
|
||||
path: '/api/v1/sessions/$id/messages/video'
|
||||
fullPath: '/api/v1/sessions/$id/messages/video'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesVideoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/text': {
|
||||
id: '/api/v1/sessions/$id/messages/text'
|
||||
path: '/api/v1/sessions/$id/messages/text'
|
||||
fullPath: '/api/v1/sessions/$id/messages/text'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesTextRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/image': {
|
||||
id: '/api/v1/sessions/$id/messages/image'
|
||||
path: '/api/v1/sessions/$id/messages/image'
|
||||
fullPath: '/api/v1/sessions/$id/messages/image'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesImageRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/document': {
|
||||
id: '/api/v1/sessions/$id/messages/document'
|
||||
path: '/api/v1/sessions/$id/messages/document'
|
||||
fullPath: '/api/v1/sessions/$id/messages/document'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesDocumentRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/broadcast': {
|
||||
id: '/api/v1/sessions/$id/messages/broadcast'
|
||||
path: '/api/v1/sessions/$id/messages/broadcast'
|
||||
fullPath: '/api/v1/sessions/$id/messages/broadcast'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesBroadcastRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/audio': {
|
||||
id: '/api/v1/sessions/$id/messages/audio'
|
||||
path: '/api/v1/sessions/$id/messages/audio'
|
||||
fullPath: '/api/v1/sessions/$id/messages/audio'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesAudioRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/sessions/$id/qr': {
|
||||
id: '/api/v1/admin/sessions/$id/qr'
|
||||
path: '/api/v1/admin/sessions/$id/qr'
|
||||
fullPath: '/api/v1/admin/sessions/$id/qr'
|
||||
preLoaderRoute: typeof ApiV1AdminSessionsIdQrRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/sessions/$id/pair': {
|
||||
id: '/api/v1/admin/sessions/$id/pair'
|
||||
path: '/api/v1/admin/sessions/$id/pair'
|
||||
fullPath: '/api/v1/admin/sessions/$id/pair'
|
||||
preLoaderRoute: typeof ApiV1AdminSessionsIdPairRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/sessions/$id/disconnect': {
|
||||
id: '/api/v1/admin/sessions/$id/disconnect'
|
||||
path: '/api/v1/admin/sessions/$id/disconnect'
|
||||
fullPath: '/api/v1/admin/sessions/$id/disconnect'
|
||||
preLoaderRoute: typeof ApiV1AdminSessionsIdDisconnectRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/apps/$id/api-keys/': {
|
||||
id: '/api/v1/admin/apps/$id/api-keys/'
|
||||
path: '/api/v1/admin/apps/$id/api-keys'
|
||||
fullPath: '/api/v1/admin/apps/$id/api-keys/'
|
||||
preLoaderRoute: typeof ApiV1AdminAppsIdApiKeysIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': {
|
||||
id: '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
path: '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
fullPath: '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
preLoaderRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/apps/$id/api-keys/$keyId': {
|
||||
id: '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
path: '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
fullPath: '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||
preLoaderRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AppsAppIdRouteChildren {
|
||||
AppsAppIdIndexRoute: typeof AppsAppIdIndexRoute
|
||||
AppsAppIdSessionsSessionIdRoute: typeof AppsAppIdSessionsSessionIdRoute
|
||||
AppsAppIdSessionsNewRoute: typeof AppsAppIdSessionsNewRoute
|
||||
AppsAppIdSessionsIndexRoute: typeof AppsAppIdSessionsIndexRoute
|
||||
}
|
||||
|
||||
const AppsAppIdRouteChildren: AppsAppIdRouteChildren = {
|
||||
AppsAppIdIndexRoute: AppsAppIdIndexRoute,
|
||||
AppsAppIdSessionsSessionIdRoute: AppsAppIdSessionsSessionIdRoute,
|
||||
AppsAppIdSessionsNewRoute: AppsAppIdSessionsNewRoute,
|
||||
AppsAppIdSessionsIndexRoute: AppsAppIdSessionsIndexRoute,
|
||||
}
|
||||
|
||||
const AppsAppIdRouteWithChildren = AppsAppIdRoute._addFileChildren(
|
||||
AppsAppIdRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AuditLogRoute: AuditLogRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
ApiReadyRoute: ApiReadyRoute,
|
||||
AppsAppIdRoute: AppsAppIdRouteWithChildren,
|
||||
SessionsIdRoute: SessionsIdRoute,
|
||||
SessionsNewRoute: SessionsNewRoute,
|
||||
AppsIndexRoute: AppsIndexRoute,
|
||||
SessionsIndexRoute: SessionsIndexRoute,
|
||||
ApiAuthLoginRoute: ApiAuthLoginRoute,
|
||||
ApiAuthLogoutRoute: ApiAuthLogoutRoute,
|
||||
ApiAuthMeRoute: ApiAuthMeRoute,
|
||||
ApiAuthTotpEnrollRoute: ApiAuthTotpEnrollRoute,
|
||||
ApiV1AdminAuditLogRoute: ApiV1AdminAuditLogRoute,
|
||||
ApiV1MediaIndexRoute: ApiV1MediaIndexRoute,
|
||||
ApiV1SessionsIndexRoute: ApiV1SessionsIndexRoute,
|
||||
ApiV1SessionsIdDisconnectRoute: ApiV1SessionsIdDisconnectRoute,
|
||||
ApiV1SessionsIdHealthRoute: ApiV1SessionsIdHealthRoute,
|
||||
ApiV1SessionsIdPairRoute: ApiV1SessionsIdPairRoute,
|
||||
ApiV1SessionsIdPresenceRoute: ApiV1SessionsIdPresenceRoute,
|
||||
ApiV1SessionsIdQrRoute: ApiV1SessionsIdQrRoute,
|
||||
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
|
||||
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
|
||||
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
|
||||
ApiV1AdminSessionsIdDisconnectRoute: ApiV1AdminSessionsIdDisconnectRoute,
|
||||
ApiV1AdminSessionsIdPairRoute: ApiV1AdminSessionsIdPairRoute,
|
||||
ApiV1AdminSessionsIdQrRoute: ApiV1AdminSessionsIdQrRoute,
|
||||
ApiV1SessionsIdMessagesAudioRoute: ApiV1SessionsIdMessagesAudioRoute,
|
||||
ApiV1SessionsIdMessagesBroadcastRoute: ApiV1SessionsIdMessagesBroadcastRoute,
|
||||
ApiV1SessionsIdMessagesDocumentRoute: ApiV1SessionsIdMessagesDocumentRoute,
|
||||
ApiV1SessionsIdMessagesImageRoute: ApiV1SessionsIdMessagesImageRoute,
|
||||
ApiV1SessionsIdMessagesTextRoute: ApiV1SessionsIdMessagesTextRoute,
|
||||
ApiV1SessionsIdMessagesVideoRoute: ApiV1SessionsIdMessagesVideoRoute,
|
||||
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
|
||||
ApiV1AdminSessionsIdIndexRoute: ApiV1AdminSessionsIdIndexRoute,
|
||||
ApiV1SessionsIdMessagesIndexRoute: ApiV1SessionsIdMessagesIndexRoute,
|
||||
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
|
||||
ApiV1SessionsIdMessagesMsgIdReadRoute: ApiV1SessionsIdMessagesMsgIdReadRoute,
|
||||
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
});
|
||||
|
||||
return router
|
||||
return router;
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,48 @@
|
|||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
useLocation,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "#/lib/query";
|
||||
import { Sidebar } from "#/components/layout/sidebar";
|
||||
|
||||
import appCss from '../styles.css?url'
|
||||
import appCss from "../styles.css?url";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'TanStack Start Starter',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: "Wahoo — WhatsApp API Gateway" },
|
||||
],
|
||||
links: [{ rel: "stylesheet", href: appCss }],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
const location = useLocation();
|
||||
const isLogin = location.pathname === "/login";
|
||||
|
||||
if (isLogin) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
@ -35,20 +51,20 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<TanStackDevtools
|
||||
config={{ position: "bottom-right" }}
|
||||
plugins={[
|
||||
{
|
||||
name: "Tanstack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
</QueryClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
84
src/routes/api/auth/login.ts
Normal file
84
src/routes/api/auth/login.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { verifyPassword } from "#/lib/password";
|
||||
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
import { findUserByEmail, verifyTotpToken } from "#/lib/actions/auth";
|
||||
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_LIMIT = 10;
|
||||
|
||||
function checkLoginRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
if (entry.count >= LOGIN_LIMIT) return false;
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/login")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
// x-forwarded-for is trusted as-is; deploy behind a trusted proxy or accept that IPs may be spoofed
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
if (!checkLoginRateLimit(ip)) {
|
||||
return jsonError("Too many login attempts", 429);
|
||||
}
|
||||
|
||||
let body: { email: string; password: string; totpToken?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON", 400);
|
||||
}
|
||||
|
||||
const { email, password, totpToken } = body;
|
||||
if (!email || !password)
|
||||
return jsonError("email and password required", 400);
|
||||
|
||||
const user = await findUserByEmail(email);
|
||||
if (!user) return jsonError("Invalid credentials", 401);
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) return jsonError("Invalid credentials", 401);
|
||||
|
||||
if (user.totpEnabled) {
|
||||
if (!totpToken) return json({ requireTotp: true }, 200);
|
||||
const totpOk = await verifyTotpToken(user, totpToken);
|
||||
if (!totpOk) return jsonError("Invalid TOTP token", 401);
|
||||
}
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "login",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
ip: ip === "unknown" ? undefined : ip,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
|
||||
const cookie = await createSessionCookie({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
totpVerified: user.totpEnabled,
|
||||
});
|
||||
|
||||
return json(
|
||||
{ ok: true, user: { id: user.id, email: user.email } },
|
||||
200,
|
||||
{ "set-cookie": cookie },
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
11
src/routes/api/auth/logout.ts
Normal file
11
src/routes/api/auth/logout.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { clearSessionCookie, json } from "#/lib/session-api";
|
||||
|
||||
export const Route = createFileRoute("/api/auth/logout")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: () =>
|
||||
json({ ok: true }, 200, { "set-cookie": clearSessionCookie() }),
|
||||
},
|
||||
},
|
||||
});
|
||||
14
src/routes/api/auth/me.ts
Normal file
14
src/routes/api/auth/me.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
|
||||
export const Route = createFileRoute("/api/auth/me")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
return json({ user: { id: session.userId, email: session.email } });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
47
src/routes/api/auth/totp/enroll.ts
Normal file
47
src/routes/api/auth/totp/enroll.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import {
|
||||
enrollTotpSecret,
|
||||
confirmTotpEnrollment,
|
||||
disableTotp,
|
||||
} from "#/lib/actions/auth";
|
||||
|
||||
export const Route = createFileRoute("/api/auth/totp/enroll")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const result = await enrollTotpSecret(session.userId);
|
||||
if (!result.ok) return jsonError(result.error, result.status);
|
||||
return json({ secret: result.secret, uri: result.uri });
|
||||
},
|
||||
|
||||
PUT: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
let body: { token: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON", 400);
|
||||
}
|
||||
if (!body.token) return jsonError("token required", 400);
|
||||
|
||||
const result = await confirmTotpEnrollment(session.userId, body.token);
|
||||
if (!result.ok) return jsonError(result.error, result.status);
|
||||
return json({ ok: true });
|
||||
},
|
||||
|
||||
DELETE: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
await disableTotp(session.userId);
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
13
src/routes/api/health.ts
Normal file
13
src/routes/api/health.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/api/health")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () =>
|
||||
new Response(JSON.stringify({ status: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
38
src/routes/api/ready.ts
Normal file
38
src/routes/api/ready.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { db } from "#/db/index";
|
||||
import { sql } from "drizzle-orm";
|
||||
import Redis from "ioredis";
|
||||
|
||||
export const Route = createFileRoute("/api/ready")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const checks: Record<string, boolean> = { db: false, redis: false };
|
||||
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
checks.db = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const redis = new Redis(
|
||||
process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||
{ lazyConnect: true, connectTimeout: 2000 },
|
||||
);
|
||||
await redis.ping();
|
||||
await redis.quit();
|
||||
checks.redis = true;
|
||||
} catch {}
|
||||
|
||||
const ok = Object.values(checks).every(Boolean);
|
||||
return new Response(
|
||||
JSON.stringify({ status: ok ? "ok" : "degraded", checks }),
|
||||
{
|
||||
status: ok ? 200 : 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
31
src/routes/api/v1/admin/apps/$id/api-keys/$keyId.ts
Normal file
31
src/routes/api/v1/admin/apps/$id/api-keys/$keyId.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { findUserApp } from "#/lib/actions/apps";
|
||||
import { revokeAppApiKey } from "#/lib/actions/api-keys";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/$keyId")({
|
||||
server: {
|
||||
handlers: {
|
||||
DELETE: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const app = await findUserApp(params.id, session.userId);
|
||||
if (!app) return jsonError("Not found", 404);
|
||||
|
||||
const result = await revokeAppApiKey(
|
||||
params.keyId,
|
||||
params.id,
|
||||
session.userId,
|
||||
{
|
||||
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
);
|
||||
if (!result.ok) return jsonError(result.error, result.status);
|
||||
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
46
src/routes/api/v1/admin/apps/$id/api-keys/index.ts
Normal file
46
src/routes/api/v1/admin/apps/$id/api-keys/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { findUserApp } from "#/lib/actions/apps";
|
||||
import { listAppApiKeys, createAppApiKey } from "#/lib/actions/api-keys";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const app = await findUserApp(params.id, session.userId);
|
||||
if (!app) return jsonError("Not found", 404);
|
||||
|
||||
return json(await listAppApiKeys(params.id));
|
||||
},
|
||||
|
||||
POST: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const app = await findUserApp(params.id, session.userId);
|
||||
if (!app) return jsonError("Not found", 404);
|
||||
|
||||
let body: { label?: string; env?: "live" | "test" } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {}
|
||||
|
||||
const created = await createAppApiKey(
|
||||
params.id,
|
||||
session.userId,
|
||||
body.label,
|
||||
body.env,
|
||||
{
|
||||
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return json(created, 201);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
68
src/routes/api/v1/admin/apps/$id/index.ts
Normal file
68
src/routes/api/v1/admin/apps/$id/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { findUserApp, updateUserApp, deleteUserApp } from "#/lib/actions/apps";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/apps/$id/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const app = await findUserApp(params.id, session.userId);
|
||||
if (!app) return jsonError("Not found", 404);
|
||||
return json({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
maxSessions: app.maxSessions,
|
||||
createdAt: app.createdAt,
|
||||
});
|
||||
},
|
||||
|
||||
PATCH: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
let body: { name?: string; maxSessions?: number };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON", 400);
|
||||
}
|
||||
|
||||
const updates: { name?: string; maxSessions?: number } = {};
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.maxSessions !== undefined)
|
||||
updates.maxSessions = body.maxSessions;
|
||||
if (Object.keys(updates).length === 0)
|
||||
return jsonError("Nothing to update", 400);
|
||||
|
||||
const updated = await updateUserApp(
|
||||
params.id,
|
||||
session.userId,
|
||||
updates,
|
||||
{
|
||||
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
);
|
||||
if (!updated) return jsonError("Not found", 404);
|
||||
|
||||
return json(updated);
|
||||
},
|
||||
|
||||
DELETE: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const deleted = await deleteUserApp(params.id, session.userId, {
|
||||
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
if (!deleted) return jsonError("Not found", 404);
|
||||
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
42
src/routes/api/v1/admin/apps/index.ts
Normal file
42
src/routes/api/v1/admin/apps/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { listUserApps, createUserApp } from "#/lib/actions/apps";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/apps/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const list = await listUserApps(session.userId);
|
||||
return json(list);
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
let body: { name?: string; maxSessions?: number };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON", 400);
|
||||
}
|
||||
if (!body.name?.trim()) return jsonError("name required", 400);
|
||||
|
||||
const app = await createUserApp(
|
||||
session.userId,
|
||||
body.name,
|
||||
body.maxSessions,
|
||||
{
|
||||
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return json(app, 201);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
26
src/routes/api/v1/admin/audit-log.ts
Normal file
26
src/routes/api/v1/admin/audit-log.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { queryAuditLog } from "#/lib/actions/audit-log";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/audit-log")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const result = await queryAuditLog(session.userId, {
|
||||
page: Number(url.searchParams.get("page") ?? 1),
|
||||
limit: Number(url.searchParams.get("limit") ?? 50),
|
||||
action: url.searchParams.get("action"),
|
||||
resourceType: url.searchParams.get("resourceType"),
|
||||
since: url.searchParams.get("since"),
|
||||
until: url.searchParams.get("until"),
|
||||
});
|
||||
|
||||
return json(result);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
24
src/routes/api/v1/admin/sessions/$id/disconnect.ts
Normal file
24
src/routes/api/v1/admin/sessions/$id/disconnect.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { findSession, disconnectSession } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/sessions/$id/disconnect")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const sess = await readSession(request);
|
||||
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const row = await findSession(params.id, { userId: sess.userId });
|
||||
if (!row) return jsonError("Not found", 404);
|
||||
|
||||
if (["DISCONNECTED", "LOGGED_OUT", "DEAD"].includes(row.status)) {
|
||||
return json({ ok: true, status: row.status });
|
||||
}
|
||||
|
||||
await disconnectSession(params.id, row.appId);
|
||||
return json({ ok: true, status: "DISCONNECTED" });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
45
src/routes/api/v1/admin/sessions/$id/index.ts
Normal file
45
src/routes/api/v1/admin/sessions/$id/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { getDecryptedField } from "#/lib/baileys";
|
||||
import { findSession, deleteSession } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/sessions/$id/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const sess = await readSession(request);
|
||||
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const row = await findSession(params.id, { userId: sess.userId });
|
||||
if (!row) return jsonError("Not found", 404);
|
||||
|
||||
const [phoneNumber, displayName] = await Promise.all([
|
||||
getDecryptedField(row.id, row.appId, row.phoneNumber),
|
||||
getDecryptedField(row.id, row.appId, row.displayName),
|
||||
]);
|
||||
|
||||
return json({
|
||||
id: row.id,
|
||||
appId: row.appId,
|
||||
status: row.status,
|
||||
phoneNumber,
|
||||
displayName,
|
||||
reconnectCount: row.reconnectCount,
|
||||
lastSeen: row.lastSeen,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
},
|
||||
|
||||
DELETE: async ({ request, params }) => {
|
||||
const sess = await readSession(request);
|
||||
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const row = await findSession(params.id, { userId: sess.userId });
|
||||
if (!row) return jsonError("Not found", 404);
|
||||
|
||||
await deleteSession(params.id, row.appId);
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
54
src/routes/api/v1/admin/sessions/$id/pair.ts
Normal file
54
src/routes/api/v1/admin/sessions/$id/pair.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { startSession, getSocket } from "#/lib/baileys";
|
||||
import { findSession, setSessionStatus } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/sessions/$id/pair")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const sess = await readSession(request);
|
||||
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const row = await findSession(params.id, { userId: sess.userId });
|
||||
if (!row) return jsonError("Not found", 404);
|
||||
if (row.status === "CONNECTED")
|
||||
return jsonError("Already connected", 409);
|
||||
if (row.status === "DEAD" || row.status === "LOGGED_OUT") {
|
||||
return jsonError("Session is no longer active", 410);
|
||||
}
|
||||
|
||||
let body: { phone?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON", 400);
|
||||
}
|
||||
if (!body.phone) return jsonError("phone required", 400);
|
||||
|
||||
const phoneDigits = body.phone.replace(/\D/g, "");
|
||||
if (phoneDigits.length < 7)
|
||||
return jsonError("Invalid phone number", 400);
|
||||
|
||||
await setSessionStatus(params.id, "PENDING_PAIRING");
|
||||
|
||||
if (!getSocket(params.id)) {
|
||||
await startSession(params.id, row.appId);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
const sock = getSocket(params.id);
|
||||
if (!sock)
|
||||
return jsonError("Session not ready — retry in a moment", 503);
|
||||
|
||||
try {
|
||||
const pairingCode = await sock.requestPairingCode(phoneDigits);
|
||||
return json({ pairingCode });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return jsonError(`Pairing code request failed: ${msg}`, 502);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
31
src/routes/api/v1/admin/sessions/$id/qr.ts
Normal file
31
src/routes/api/v1/admin/sessions/$id/qr.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import QRCode from "qrcode";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { getQrCode } from "#/lib/baileys";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/sessions/$id/qr")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const sess = await readSession(request);
|
||||
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const row = await findSession(params.id, { userId: sess.userId });
|
||||
if (!row) return jsonError("Not found", 404);
|
||||
|
||||
if (row.status === "CONNECTED")
|
||||
return json({ error: "Already connected" }, 409);
|
||||
if (row.status === "DEAD" || row.status === "LOGGED_OUT") {
|
||||
return json({ error: "Session is no longer active" }, 410);
|
||||
}
|
||||
|
||||
const qrRaw = getQrCode(params.id);
|
||||
if (!qrRaw) return json({ waiting: true }, 202);
|
||||
|
||||
const qrPng = await QRCode.toDataURL(qrRaw);
|
||||
return json({ qr: qrPng, qrRaw });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
48
src/routes/api/v1/admin/sessions/index.ts
Normal file
48
src/routes/api/v1/admin/sessions/index.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { listUserSessions, createSession } from "#/lib/actions/sessions";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const rows = await listUserSessions(session.userId);
|
||||
return json(rows);
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
const sess = await readSession(request);
|
||||
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
let body: { appId?: string; connect_via?: "qr" | "pairing" } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON", 400);
|
||||
}
|
||||
if (!body.appId) return jsonError("appId required", 400);
|
||||
|
||||
const via = body.connect_via === "pairing" ? "pairing" : "qr";
|
||||
const result = await createSession(body.appId, via, sess.userId);
|
||||
|
||||
if (!result.ok) return jsonError(result.error, result.status);
|
||||
|
||||
writeAudit({
|
||||
actorType: "user",
|
||||
actorId: sess.userId,
|
||||
action: "session.create",
|
||||
resourceType: "session",
|
||||
resourceId: result.session.id,
|
||||
ip: request.headers.get("x-forwarded-for")?.split(",")[0].trim(),
|
||||
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||
}).catch(console.error);
|
||||
|
||||
return json(result.session, 201);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
52
src/routes/api/v1/media/index.ts
Normal file
52
src/routes/api/v1/media/index.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { jsonError } from "#/lib/session-api";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, extname } from "node:path";
|
||||
import { LocalStorageDriver } from "#/lib/storage/local";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/media/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (process.env.STORAGE_DRIVER === "s3") {
|
||||
return jsonError("Media not served locally in S3 mode", 404);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const expires = Number(url.searchParams.get("expires"));
|
||||
const sig = url.searchParams.get("sig") ?? "";
|
||||
const key = url.searchParams.get("key") ?? "";
|
||||
|
||||
const driver = new LocalStorageDriver();
|
||||
if (!driver.verifySignedUrl(key, expires, sig)) {
|
||||
return jsonError("Invalid or expired media URL", 403);
|
||||
}
|
||||
|
||||
const basePath = process.env.STORAGE_LOCAL_PATH ?? "./uploads";
|
||||
try {
|
||||
const data = await readFile(join(basePath, key));
|
||||
const ext = extname(key).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
".mp4": "video/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".pdf": "application/pdf",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
};
|
||||
const contentType = mimeMap[ext] ?? "application/octet-stream";
|
||||
return new Response(data, {
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
} catch {
|
||||
return jsonError("Media not found", 404);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
29
src/routes/api/v1/sessions/$id/disconnect.ts
Normal file
29
src/routes/api/v1/sessions/$id/disconnect.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession, disconnectSession } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/disconnect")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
|
||||
if (
|
||||
session.status === "DISCONNECTED" ||
|
||||
session.status === "LOGGED_OUT" ||
|
||||
session.status === "DEAD"
|
||||
) {
|
||||
return json({ ok: true, status: session.status });
|
||||
}
|
||||
|
||||
await disconnectSession(params.id, auth.appId);
|
||||
return json({ ok: true, status: "DISCONNECTED" });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
27
src/routes/api/v1/sessions/$id/health.ts
Normal file
27
src/routes/api/v1/sessions/$id/health.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { isActive } from "#/lib/baileys";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/health")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
|
||||
return json({
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
lastSeen: session.lastSeen,
|
||||
reconnectCount: session.reconnectCount,
|
||||
socketAlive: isActive(session.id),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
45
src/routes/api/v1/sessions/$id/index.ts
Normal file
45
src/routes/api/v1/sessions/$id/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { getDecryptedField } from "#/lib/baileys";
|
||||
import { findSession, logoutAppSession } from "#/lib/actions/sessions";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
|
||||
const [phoneNumber, displayName] = await Promise.all([
|
||||
getDecryptedField(session.id, auth.appId, session.phoneNumber),
|
||||
getDecryptedField(session.id, auth.appId, session.displayName),
|
||||
]);
|
||||
|
||||
return json({
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
phoneNumber,
|
||||
displayName,
|
||||
reconnectCount: session.reconnectCount,
|
||||
lastSeen: session.lastSeen,
|
||||
createdAt: session.createdAt,
|
||||
});
|
||||
},
|
||||
|
||||
DELETE: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
|
||||
await logoutAppSession(params.id, auth.appId);
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
43
src/routes/api/v1/sessions/$id/messages/$msgId/read.ts
Normal file
43
src/routes/api/v1/sessions/$id/messages/$msgId/read.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
import { findMessage, updateMessageStatus } from "#/lib/actions/messages";
|
||||
import { getSocket } from "#/lib/baileys";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/api/v1/sessions/$id/messages/$msgId/read",
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
if (session.status !== "CONNECTED")
|
||||
return jsonError("Session not connected", 409);
|
||||
|
||||
const msg = await findMessage(params.msgId, session.id);
|
||||
if (!msg) return jsonError("Message not found", 404);
|
||||
|
||||
const sock = getSocket(session.id);
|
||||
if (sock && msg.fromJid) {
|
||||
await sock
|
||||
.readMessages([
|
||||
{
|
||||
id: msg.whatsappMsgId,
|
||||
remoteJid: msg.fromJid,
|
||||
fromMe: false,
|
||||
},
|
||||
])
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
await updateMessageStatus(msg.whatsappMsgId, session.id, "read");
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
134
src/routes/api/v1/sessions/$id/messages/audio.ts
Normal file
134
src/routes/api/v1/sessions/$id/messages/audio.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
import { createMessage } from "#/lib/actions/messages";
|
||||
import { getSessionDek } from "#/lib/dek-helper";
|
||||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "audio/ogg",
|
||||
name: info.filename || name,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/audio")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
if (session.status !== "CONNECTED")
|
||||
return jsonError("Session not connected", 409);
|
||||
|
||||
const dek = await getSessionDek(session.id, auth.appId);
|
||||
if (!dek) return jsonError("Session DEK unavailable", 503);
|
||||
|
||||
const ct = request.headers.get("content-type") ?? "";
|
||||
let to: string,
|
||||
voiceNote = false,
|
||||
mediaUrl: string | undefined;
|
||||
let uploadedKey: string | undefined;
|
||||
let mime = "audio/ogg; codecs=opus";
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
to = fields.to;
|
||||
voiceNote = fields.voice_note === "true";
|
||||
const rawMime = file.mime || "audio/ogg";
|
||||
// OGG without codec hint → assume Opus (WhatsApp requires it)
|
||||
mime = rawMime === "audio/ogg" ? "audio/ogg; codecs=opus" : rawMime;
|
||||
const ext = mime.split("/")[1]?.split(";")[0]?.trim() ?? "ogg";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.to || !body?.url)
|
||||
return jsonError("Missing required fields: to, url", 400);
|
||||
to = body.to;
|
||||
voiceNote = body.voice_note === true;
|
||||
mediaUrl = body.url;
|
||||
}
|
||||
|
||||
if (!to) return jsonError("Missing field: to", 400);
|
||||
|
||||
const whatsappMsgId = uuidv4();
|
||||
const msg = await createMessage(
|
||||
{
|
||||
appId: auth.appId,
|
||||
sessionId: session.id,
|
||||
whatsappMsgId,
|
||||
direction: "out",
|
||||
type: "audio",
|
||||
toJid: to,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
mediaKey: uploadedKey ?? null,
|
||||
mediaMime: mime,
|
||||
status: "queued",
|
||||
isGroup: to.endsWith("@g.us"),
|
||||
},
|
||||
dek,
|
||||
);
|
||||
|
||||
const queue = createQueue(QUEUE_SEND);
|
||||
await queue.add("send", {
|
||||
messageId: msg.id,
|
||||
sessionId: session.id,
|
||||
appId: auth.appId,
|
||||
whatsappMsgId,
|
||||
waMessage: {
|
||||
to,
|
||||
content: {
|
||||
audio: { url: mediaUrl },
|
||||
ptt: voiceNote,
|
||||
mimetype: mime,
|
||||
},
|
||||
},
|
||||
});
|
||||
await queue.close();
|
||||
|
||||
return json({ id: msg.id, whatsappMsgId, status: "queued" }, 202);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
62
src/routes/api/v1/sessions/$id/messages/broadcast.ts
Normal file
62
src/routes/api/v1/sessions/$id/messages/broadcast.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
import { createQueue, QUEUE_BROADCAST } from "#/lib/queue";
|
||||
|
||||
const DEFAULT_RATE_MS = 1000;
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/broadcast")(
|
||||
{
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
if (session.status !== "CONNECTED")
|
||||
return jsonError("Session not connected", 409);
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (
|
||||
!body?.recipients ||
|
||||
!Array.isArray(body.recipients) ||
|
||||
body.recipients.length === 0
|
||||
) {
|
||||
return jsonError(
|
||||
"Missing required field: recipients (non-empty array)",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!body.type || (!body.text && !body.url)) {
|
||||
return jsonError("Missing message content (text or url)", 400);
|
||||
}
|
||||
|
||||
const queue = createQueue(QUEUE_BROADCAST);
|
||||
const job = await queue.add("broadcast", {
|
||||
sessionId: session.id,
|
||||
appId: auth.appId,
|
||||
recipients: body.recipients,
|
||||
content: {
|
||||
type: body.type,
|
||||
text: body.text,
|
||||
mediaUrl: body.url,
|
||||
caption: body.caption,
|
||||
filename: body.filename,
|
||||
voiceNote: body.voice_note,
|
||||
},
|
||||
rateMs: body.rate_ms ?? DEFAULT_RATE_MS,
|
||||
});
|
||||
await queue.close();
|
||||
|
||||
return json(
|
||||
{ jobId: job.id, recipientCount: body.recipients.length },
|
||||
202,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
133
src/routes/api/v1/sessions/$id/messages/document.ts
Normal file
133
src/routes/api/v1/sessions/$id/messages/document.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
import { createMessage } from "#/lib/actions/messages";
|
||||
import { getSessionDek } from "#/lib/dek-helper";
|
||||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (_name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "application/octet-stream",
|
||||
name: info.filename || "file",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/document")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
if (session.status !== "CONNECTED")
|
||||
return jsonError("Session not connected", 409);
|
||||
|
||||
const dek = await getSessionDek(session.id, auth.appId);
|
||||
if (!dek) return jsonError("Session DEK unavailable", 503);
|
||||
|
||||
const ct = request.headers.get("content-type") ?? "";
|
||||
let to: string,
|
||||
filename: string | undefined,
|
||||
mediaUrl: string | undefined;
|
||||
let uploadedKey: string | undefined;
|
||||
let mime = "application/octet-stream";
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
to = fields.to;
|
||||
filename = fields.filename ?? file.name;
|
||||
mime = file.mime.length > 0 ? file.mime : mime;
|
||||
const ext = filename.split(".").pop() ?? "bin";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.to || !body?.url)
|
||||
return jsonError("Missing required fields: to, url", 400);
|
||||
to = body.to;
|
||||
filename = body.filename;
|
||||
mediaUrl = body.url;
|
||||
}
|
||||
|
||||
if (!to) return jsonError("Missing field: to", 400);
|
||||
|
||||
const whatsappMsgId = uuidv4();
|
||||
const msg = await createMessage(
|
||||
{
|
||||
appId: auth.appId,
|
||||
sessionId: session.id,
|
||||
whatsappMsgId,
|
||||
direction: "out",
|
||||
type: "document",
|
||||
toJid: to,
|
||||
bodyEncrypted: filename,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
mediaKey: uploadedKey ?? null,
|
||||
mediaMime: mime,
|
||||
status: "queued",
|
||||
isGroup: to.endsWith("@g.us"),
|
||||
},
|
||||
dek,
|
||||
);
|
||||
|
||||
const queue = createQueue(QUEUE_SEND);
|
||||
await queue.add("send", {
|
||||
messageId: msg.id,
|
||||
sessionId: session.id,
|
||||
appId: auth.appId,
|
||||
whatsappMsgId,
|
||||
waMessage: {
|
||||
to,
|
||||
content: {
|
||||
document: { url: mediaUrl },
|
||||
mimetype: mime,
|
||||
fileName: filename,
|
||||
},
|
||||
},
|
||||
});
|
||||
await queue.close();
|
||||
|
||||
return json({ id: msg.id, whatsappMsgId, status: "queued" }, 202);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
125
src/routes/api/v1/sessions/$id/messages/image.ts
Normal file
125
src/routes/api/v1/sessions/$id/messages/image.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
import { createMessage } from "#/lib/actions/messages";
|
||||
import { getSessionDek } from "#/lib/dek-helper";
|
||||
import { getStorageDriver } from "#/lib/storage";
|
||||
import { createQueue, QUEUE_SEND } from "#/lib/queue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Busboy from "busboy";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function parseMultipart(
|
||||
contentType: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const fields: Record<string, string> = {};
|
||||
let file: { buffer: Buffer; mime: string; name: string } | null = null;
|
||||
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
|
||||
bb.on("file", (_name, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () => {
|
||||
file = {
|
||||
buffer: Buffer.concat(chunks),
|
||||
mime: info.mimeType || "image/jpeg",
|
||||
name: info.filename || "image.jpg",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("finish", () => resolve({ fields, file }));
|
||||
bb.on("error", reject);
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/image")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
if (session.status !== "CONNECTED")
|
||||
return jsonError("Session not connected", 409);
|
||||
|
||||
const dek = await getSessionDek(session.id, auth.appId);
|
||||
if (!dek) return jsonError("Session DEK unavailable", 503);
|
||||
|
||||
const ct = request.headers.get("content-type") ?? "";
|
||||
let to: string,
|
||||
caption: string | undefined,
|
||||
mediaUrl: string | undefined;
|
||||
let uploadedKey: string | undefined;
|
||||
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
if (!request.body) return jsonError("Missing body", 400);
|
||||
const { fields, file } = await parseMultipart(ct, request.body);
|
||||
if (!fields.to) return jsonError("Missing field: to", 400);
|
||||
if (!file) return jsonError("Missing file field", 400);
|
||||
to = fields.to;
|
||||
caption = fields.caption;
|
||||
const mime = file.mime.length > 0 ? file.mime : "image/jpeg";
|
||||
const ext = mime.split("/")[1] ?? "jpg";
|
||||
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
|
||||
const driver = await getStorageDriver();
|
||||
await driver.upload(key, file.buffer, mime);
|
||||
uploadedKey = key;
|
||||
mediaUrl = await driver.signedUrl(key, 3600);
|
||||
} else {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.to || !body?.url)
|
||||
return jsonError("Missing required fields: to, url", 400);
|
||||
to = body.to;
|
||||
caption = body.caption;
|
||||
mediaUrl = body.url;
|
||||
}
|
||||
|
||||
if (!to) return jsonError("Missing field: to", 400);
|
||||
|
||||
const whatsappMsgId = uuidv4();
|
||||
const msg = await createMessage(
|
||||
{
|
||||
appId: auth.appId,
|
||||
sessionId: session.id,
|
||||
whatsappMsgId,
|
||||
direction: "out",
|
||||
type: "image",
|
||||
toJid: to,
|
||||
bodyEncrypted: caption,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
mediaKey: uploadedKey ?? null,
|
||||
mediaMime: "image/jpeg",
|
||||
status: "queued",
|
||||
isGroup: to.endsWith("@g.us"),
|
||||
},
|
||||
dek,
|
||||
);
|
||||
|
||||
const queue = createQueue(QUEUE_SEND);
|
||||
await queue.add("send", {
|
||||
messageId: msg.id,
|
||||
sessionId: session.id,
|
||||
appId: auth.appId,
|
||||
whatsappMsgId,
|
||||
waMessage: { to, content: { image: { url: mediaUrl }, caption } },
|
||||
});
|
||||
await queue.close();
|
||||
|
||||
return json({ id: msg.id, whatsappMsgId, status: "queued" }, 202);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
43
src/routes/api/v1/sessions/$id/messages/index.ts
Normal file
43
src/routes/api/v1/sessions/$id/messages/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||
import { json, jsonError } from "#/lib/session-api";
|
||||
import { findSession } from "#/lib/actions/sessions";
|
||||
import { listMessages } from "#/lib/actions/messages";
|
||||
import { getSessionDek } from "#/lib/dek-helper";
|
||||
|
||||
export const Route = createFileRoute("/api/v1/sessions/$id/messages/")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const auth = await authenticateApiKey(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const session = await findSession(params.id, { appId: auth.appId });
|
||||
if (!session) return jsonError("Not found", 404);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const sinceParam = url.searchParams.get("since");
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
const directionParam = url.searchParams.get("direction") as
|
||||
| "in"
|
||||
| "out"
|
||||
| null;
|
||||
|
||||
const dek = await getSessionDek(session.id, auth.appId);
|
||||
if (!dek) return jsonError("Session DEK unavailable", 503);
|
||||
|
||||
const msgs = await listMessages(
|
||||
session.id,
|
||||
{
|
||||
since: sinceParam ? new Date(sinceParam) : undefined,
|
||||
limit: limitParam ? Number(limitParam) : undefined,
|
||||
direction: directionParam ?? undefined,
|
||||
},
|
||||
dek,
|
||||
);
|
||||
|
||||
return json({ messages: msgs });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue