Compare commits

...

7 commits

Author SHA1 Message Date
92145d36b1
feat: webhook groups 2026-05-16 17:27:23 +07:00
f4632e77d5
feat: test server for webhook 2026-05-16 16:26:19 +07:00
f8b10288be
refactor: remove admin paths from oapi 2026-05-16 15:50:45 +07:00
79520fe28f
refactor: code review improvements 2026-05-16 15:46:12 +07:00
b40333a2cd
feat: openapi 2026-05-16 15:19:45 +07:00
abf5029579
refactor: ui improvements 2026-05-16 14:59:43 +07:00
e45dd79e8a
feat: webhhoks 2026-05-16 14:20:49 +07:00
99 changed files with 17008 additions and 1123 deletions

260
README.md
View file

@ -1,222 +1,82 @@
Welcome to your new TanStack Start app!
# Wahoo
# Getting Started
**Self-hosted WhatsApp gateway** — send and receive WhatsApp messages through a clean REST API, with multi-app support, end-to-end encryption, webhooks, and a built-in admin dashboard.
To run this application:
> **Not affiliated with WhatsApp or Meta. Unofficial API. Use at your own risk.**
> See [docs/baileys-risk.md](docs/baileys-risk.md) for full risk disclosure.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![TanStack Start](https://img.shields.io/badge/TanStack-Start-red.svg)](https://tanstack.com/start)
---
## Quickstart
See [docs/quickstart.md](docs/quickstart.md) for the full guide. Short version:
```bash
bun install
bun --bun run dev
git clone https://github.com/yourorg/wahoo.git && cd wahoo
cp .env.example .env # fill in MASTER_ENCRYPTION_KEY and SESSION_SECRET
docker compose up -d
# open http://localhost:3000
```
# Building For Production
---
To build this application for production:
## Features
```bash
bun --bun run build
```
- **Multi-app** — create isolated apps, each with its own encryption key and API keys
- **Multi-session** — run multiple WhatsApp numbers per app
- **Encrypted at rest** — contacts and session credentials encrypted with AES-256-GCM, per-app data encryption keys wrapped by a master key
- **Encryption key rotation** — rotate the app DEK in the background without downtime
- **REST API** — send text, image, video, document, and audio messages; broadcast to multiple recipients
- **Webhooks** — receive real-time events (message received/sent/delivered/read, session status, presence) with HMAC-SHA256 signatures
- **Contact management** — import via CSV, sync from WhatsApp, tag and group contacts, custom field schemas
- **Audit log** — tamper-evident log of all admin and API actions
- **TOTP 2FA** — protect admin accounts with time-based one-time passwords
- **OpenAPI docs** — interactive Swagger UI at `/api/docs`
- **Admin dashboard** — full web UI for managing apps, sessions, webhooks, contacts, and monitoring
## Testing
---
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
## Architecture
```bash
bun --bun run test
```
| Layer | Technology |
| ----------------- | ---------------------------------------------- |
| Frontend + SSR | TanStack Start (React, Vite) |
| Routing | TanStack Router (file-based) |
| API | TanStack Start server handlers (Nitro adapter) |
| Database | PostgreSQL via Drizzle ORM |
| Job queue | BullMQ (Redis) |
| WhatsApp protocol | Baileys (unofficial, reverse-engineered) |
| Encryption | AES-256-GCM, per-app DEK + master KEK |
| Auth | Iron Session (cookie) + API key (Bearer) |
## Styling
---
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
## Documentation
### Removing Tailwind CSS
| File | Contents |
| -------------------------------------------- | ----------------------------------------- |
| [docs/quickstart.md](docs/quickstart.md) | Step-by-step getting started guide |
| [docs/api.md](docs/api.md) | REST endpoint reference |
| [docs/webhooks.md](docs/webhooks.md) | Event catalog, HMAC verification |
| [docs/security.md](docs/security.md) | Encryption model, key rotation, audit log |
| [docs/deployment.md](docs/deployment.md) | Docker Compose deploy, backup, restore |
| [docs/baileys-risk.md](docs/baileys-risk.md) | Baileys risks, ToS, number warming guide |
If you prefer not to use Tailwind CSS:
Interactive API reference available at `http://localhost:3000/api/docs` when running.
1. Remove the demo pages in `src/routes/demo/`
2. Replace the Tailwind import in `src/styles.css` with your own styles
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
4. Uninstall the packages: `bun install @tailwindcss/vite tailwindcss -D`
---
## Linting & Formatting
## Baileys risk notice
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:
Wahoo uses [Baileys](https://github.com/WhiskeySockets/Baileys), an unofficial WhatsApp Web client built through reverse engineering. This violates WhatsApp's Terms of Service (§2c). Your number may be temporarily or permanently banned. Read [docs/baileys-risk.md](docs/baileys-risk.md) before deploying in production.
```bash
bun --bun run lint
bun --bun run format
bun --bun run check
```
---
## Deploy with Nitro
## License
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
Wahoo is released under the [MIT License](LICENSE).
```bash
npm run build
node dist/server/index.mjs
```
The build output is a self-contained Node server. To deploy, push the `dist/` directory to your host (Render, Fly.io, your own VPS, etc.) and run the server command above.
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/).
```bash
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`.
### Adding A Route
To add a new route to your application just add a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
Here is an example layout that includes a header:
```tsx
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" },
],
}),
shellComponent: ({ children }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
{children}
<Scripts />
</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).
## Server Functions
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";
const getServerTime = createServerFn({
method: "GET",
}).handler(async () => {
return new Date().toISOString();
});
// Use in a component
function MyComponent() {
const [time, setTime] = useState("");
useEffect(() => {
getServerTime().then(setTime);
}, []);
return <div>Server time: {time}</div>;
}
```
## API Routes
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";
export const Route = createFileRoute("/api/hello")({
server: {
handlers: {
GET: () => json({ message: "Hello, World!" }),
},
},
});
```
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/people")({
loader: async () => {
const response = await fetch("https://swapi.dev/api/people");
return response.json();
},
component: PeopleComponent,
});
function PeopleComponent() {
const data = Route.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
);
}
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
Baileys is separately licensed under the [Apache 2.0 License](https://github.com/WhiskeySockets/Baileys/blob/master/LICENSE). Wahoo does not bundle Baileys source code; it lists it as an npm dependency.

234
bun.lock
View file

@ -8,6 +8,10 @@
"@aws-sdk/client-s3": "^3.1047.0",
"@aws-sdk/s3-request-presigner": "^3.1047.0",
"@faker-js/faker": "^10.3.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/sdk-node": "^0.218.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/match-sorter-utils": "latest",
"@tanstack/react-devtools": "latest",
@ -320,6 +324,10 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@hapi/boom": ["@hapi/boom@9.1.4", "", { "dependencies": { "@hapi/hoek": "9.x.x" } }, "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw=="],
"@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="],
@ -396,6 +404,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
@ -426,6 +436,160 @@
"@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="],
"@opentelemetry/auto-instrumentations-node": ["@opentelemetry/auto-instrumentations-node@0.76.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/instrumentation-amqplib": "^0.65.0", "@opentelemetry/instrumentation-aws-lambda": "^0.70.0", "@opentelemetry/instrumentation-aws-sdk": "^0.73.0", "@opentelemetry/instrumentation-bunyan": "^0.63.0", "@opentelemetry/instrumentation-cassandra-driver": "^0.63.0", "@opentelemetry/instrumentation-connect": "^0.61.0", "@opentelemetry/instrumentation-cucumber": "^0.34.0", "@opentelemetry/instrumentation-dataloader": "^0.35.0", "@opentelemetry/instrumentation-dns": "^0.61.0", "@opentelemetry/instrumentation-express": "^0.66.0", "@opentelemetry/instrumentation-fs": "^0.37.0", "@opentelemetry/instrumentation-generic-pool": "^0.61.0", "@opentelemetry/instrumentation-graphql": "^0.66.0", "@opentelemetry/instrumentation-grpc": "^0.218.0", "@opentelemetry/instrumentation-hapi": "^0.64.0", "@opentelemetry/instrumentation-http": "^0.218.0", "@opentelemetry/instrumentation-ioredis": "^0.66.0", "@opentelemetry/instrumentation-kafkajs": "^0.27.0", "@opentelemetry/instrumentation-knex": "^0.62.0", "@opentelemetry/instrumentation-koa": "^0.66.0", "@opentelemetry/instrumentation-lru-memoizer": "^0.62.0", "@opentelemetry/instrumentation-memcached": "^0.61.0", "@opentelemetry/instrumentation-mongodb": "^0.71.0", "@opentelemetry/instrumentation-mongoose": "^0.64.0", "@opentelemetry/instrumentation-mysql": "^0.64.0", "@opentelemetry/instrumentation-mysql2": "^0.64.0", "@opentelemetry/instrumentation-nestjs-core": "^0.64.0", "@opentelemetry/instrumentation-net": "^0.62.0", "@opentelemetry/instrumentation-openai": "^0.16.0", "@opentelemetry/instrumentation-oracledb": "^0.43.0", "@opentelemetry/instrumentation-pg": "^0.70.0", "@opentelemetry/instrumentation-pino": "^0.64.0", "@opentelemetry/instrumentation-redis": "^0.66.0", "@opentelemetry/instrumentation-restify": "^0.63.0", "@opentelemetry/instrumentation-router": "^0.62.0", "@opentelemetry/instrumentation-runtime-node": "^0.31.0", "@opentelemetry/instrumentation-socket.io": "^0.65.0", "@opentelemetry/instrumentation-tedious": "^0.37.0", "@opentelemetry/instrumentation-undici": "^0.28.0", "@opentelemetry/instrumentation-winston": "^0.62.0", "@opentelemetry/resource-detector-alibaba-cloud": "^0.33.8", "@opentelemetry/resource-detector-aws": "^2.18.0", "@opentelemetry/resource-detector-azure": "^0.26.0", "@opentelemetry/resource-detector-container": "^0.8.9", "@opentelemetry/resource-detector-gcp": "^0.53.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-node": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.4.1", "@opentelemetry/core": "^2.0.0" } }, "sha512-44KWgqsMuqfV4UhOcwwnDeK8CpB5LT1MmpZj6sKXFXu2q6rjKo622pWgOgn5Ntp5Qal9q1uBX2VS8mvTpsMeyw=="],
"@opentelemetry/configuration": ["@opentelemetry/configuration@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-W8wIz7H2R1pufR5jfjb3gU2XkMpm2x/7b1RJcsuzvd70Il/rWWE+g5/Od7hQKrxRTSrTrOWlru101PWXz5I1EQ=="],
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.7.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ=="],
"@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="],
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.218.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/sdk-logs": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g=="],
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/sdk-logs": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw=="],
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg=="],
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.218.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-metrics": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-YapQ9vNMX0NSZF6LK5pWAFfjpJleV2O9uYWfYGeb/5F1Kb9rPGK8tZDMJFa/sOksgdFuflDvYuA0B4qjDB4fjQ=="],
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-metrics": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ=="],
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-metrics": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ubLddKjWULhla9YZRCj/rTBeppjJYE4e9w0icx5mTu3eFhWjQzbV75NYjXuIlEG+NJsBl6d+sTFw5Qu+oej4oQ=="],
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RT5oEyu1kddZJ1vt7/BUo5wV+P7hpNAESsR3dUd3+8deHuX7gWNoCOZn+SfDT+hJHlIJ5h/AxiCLXIrutswDJg=="],
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.218.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-3fXxVQEj9TNAFaCi79JeFKfeLd0sDtInaR3gaZDVlzNSPHtz8PZuCV34JKWjD4XXzT20IdMe8IpX6mRVNDA4Tw=="],
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw=="],
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw=="],
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg=="],
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg=="],
"@opentelemetry/instrumentation-amqplib": ["@opentelemetry/instrumentation-amqplib@0.65.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fF7fNHA59n3y23ROfst2EbSxmP+L3E+snZO6aMU4w4xD84mfejAivspIAsqa9arX5HZlBK6dslHz5dWGNp5D0A=="],
"@opentelemetry/instrumentation-aws-lambda": ["@opentelemetry/instrumentation-aws-lambda@0.70.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/aws-lambda": "^8.10.155" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HT74cQxi/iiVEz5dRdNdfGCFzPFbkxSiwHfFPHDwkRcr1JKQqI6hm8qeXEvEiJ+36xIU1KkQMDfeThJ1ifnUiA=="],
"@opentelemetry/instrumentation-aws-sdk": ["@opentelemetry/instrumentation-aws-sdk@0.73.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.34.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-0INPkHbR6o4J3psE+ncwWaE7qtDpb2p+i+qfV82cfwYLCXavYCGosBZ/S4pOErDVJYIyQVIsNAHhaUgaL313SQ=="],
"@opentelemetry/instrumentation-bunyan": ["@opentelemetry/instrumentation-bunyan@0.63.0", "", { "dependencies": { "@opentelemetry/api-logs": "^0.218.0", "@opentelemetry/instrumentation": "^0.218.0", "@types/bunyan": "1.8.11" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-z0xPSZ62d3I7sG2sUTyQ5/ES1RdESP2eOETiMLY9gPSp+HZwbsAyj7T/2sdZKYD+O2ajRHZEil+DBoUolf1ocQ=="],
"@opentelemetry/instrumentation-cassandra-driver": ["@opentelemetry/instrumentation-cassandra-driver@0.63.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jnVTOr3h/46UDalEwJ4ITux8UWwHmnsOik5WFs3JB/UrUj8Wad5eI+KpOEBuOUeOfPB9sce11qgVw3WXU2r+hg=="],
"@opentelemetry/instrumentation-connect": ["@opentelemetry/instrumentation-connect@0.61.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.38" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZTQ0W3Lb7GJsOd+72cG8FJQKA5DqYfELJGLmChrJIezRSLfJIfofwKEGLX5rMtFJmwckpichQkBZWjid5dvnVQ=="],
"@opentelemetry/instrumentation-cucumber": ["@opentelemetry/instrumentation-cucumber@0.34.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-VK63Cm8osAdsSZpULPk+qnNktQUJzmnIOv2wuh79fV41WuTM38uOFC3s978/24pDkSljhN4EYCbPRLrAhXfKSA=="],
"@opentelemetry/instrumentation-dataloader": ["@opentelemetry/instrumentation-dataloader@0.35.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6x6UPP0tLzrdj15PIEN3qgp/WCcESCavHJfkIKoyLmy4UjGLF1KgEUMyD74xhbKGo426uvMbhvCgZC0ye8nO/A=="],
"@opentelemetry/instrumentation-dns": ["@opentelemetry/instrumentation-dns@0.61.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5D8xFaw9GXq9ZIOAvG7NPDivFfZWFAekLGFn1B7ppyhuAYBVHGybFpx4Q9BV1Uup3yzCdiD78KhyH7c3dKOYSw=="],
"@opentelemetry/instrumentation-express": ["@opentelemetry/instrumentation-express@0.66.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-G1xTh5M5shklMgIyUXWDjU2BakulKtcISaM4U5TyanvO7R4xbB3iC7YQ8QKegLXaOs81Ku8RlcIcbYRrz/82wQ=="],
"@opentelemetry/instrumentation-fs": ["@opentelemetry/instrumentation-fs@0.37.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5mxhFuwAK0FFvisUdvuywaZ9ySMZ15HfbN6IpLn0gwRh9s1/QBcpLznQ/A15cZs1QFtBJ+JXIHdwY7WOD0c4Eg=="],
"@opentelemetry/instrumentation-generic-pool": ["@opentelemetry/instrumentation-generic-pool@0.61.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-tvp5PWnGRPHY/kz9Kg1IRLBL0qUAxMSNG623f+ZGEsvnCVEjr3tFyw1JGQzM+B3eZKkO+Dp/LYrtOSfb69D5lA=="],
"@opentelemetry/instrumentation-graphql": ["@opentelemetry/instrumentation-graphql@0.66.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-D4PN1tStj6rnOdofnt2xINJjtT1k2ockzaODrn76VEBZeqJ3QsEvKFfunB0EFAohO4xswVp14VAVmKNnGzA1Dw=="],
"@opentelemetry/instrumentation-grpc": ["@opentelemetry/instrumentation-grpc@0.218.0", "", { "dependencies": { "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kcDCNrC7IWNXEKQriGrwuh5jjbMFU5exOQzU9ufEY9UkACNcgYIdOd7XpX3IqZ3UPSnZyZtlwgfsbC5SNlEDbA=="],
"@opentelemetry/instrumentation-hapi": ["@opentelemetry/instrumentation-hapi@0.64.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-PCHgCICCDz7p9BgCU9gQz2smbqu4V4P8QtWJ7DLjL3bmzSdrgy6EGvecDg1YuhjBsoN08SR+y36hgdHkqCgrzQ=="],
"@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-x9djaqdzpT8WAboep1H9nCAQ1E+MMsm08TNfA02TqM3bNNddZeiim+E3KMWVQFaX6JpUy7V0nm/wfN/K2Em+Zw=="],
"@opentelemetry/instrumentation-ioredis": ["@opentelemetry/instrumentation-ioredis@0.66.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/redis-common": "^0.38.3", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-UfTAcaBKCzLUZ9opvfOLV4bH46XiNFqUsKykfPCIefDIxJ1iUYtMOucNaiZ+/kjQdPy5i6Ef5tk2IAjxol4X1w=="],
"@opentelemetry/instrumentation-kafkajs": ["@opentelemetry/instrumentation-kafkajs@0.27.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.30.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kl/C2AU4KZGHlMZD12nMFXcMjxSHvu5Q0UPSQ6IJeBfCadYuWgW+sWIa2JZVK/A0qRYm2cncekJyeBHQDyfUUg=="],
"@opentelemetry/instrumentation-knex": ["@opentelemetry/instrumentation-knex@0.62.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XgfhCAWwSqA0YnwaEKdpvQMavc90D3R65frhLCO9JNl867EulNps9tm6pjGIg+GiYuewn00gEzW4HQ5btgYxGQ=="],
"@opentelemetry/instrumentation-koa": ["@opentelemetry/instrumentation-koa@0.66.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.36.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-04x/z21WTMEfy3lUSr4aTj8WsTN3OZF901hJ+ciOwdwf7AK8UJTpZCXw6KQ3G4Vag56q1HoMihCONeWZLeld1g=="],
"@opentelemetry/instrumentation-lru-memoizer": ["@opentelemetry/instrumentation-lru-memoizer@0.62.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-AlGKIdk6ZT7WmIozfUb2LjOcI3AhQrvAXKX0zi1cVcnw2QlRbVYyV5GTa2Th9ebuczVfWPaoPrmZw61zCp/czw=="],
"@opentelemetry/instrumentation-memcached": ["@opentelemetry/instrumentation-memcached@0.61.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@types/memcached": "^2.2.6" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-qiCR9Wovf5AHzn6g+LXhvwMmv2I6zhHz2I2tEHZMmBuD8c18bkJzGFxHoSBlxdApRT+SW13r9472dDMm4BRjgQ=="],
"@opentelemetry/instrumentation-mongodb": ["@opentelemetry/instrumentation-mongodb@0.71.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6rwfVjAUY69CKkyGqzL+F5X7Nzw0+Ke9pOxk9xUPJpy8vracZxuQYF7rWu02sV1xOgi4u52449SuVhD+zaSiIA=="],
"@opentelemetry/instrumentation-mongoose": ["@opentelemetry/instrumentation-mongoose@0.64.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-iCIqeUaERN8Uc5Rrtg4zvQ6d7z5JQ5iUmbnr/JHYPxAidDowmRc8/wDMJeMKRfLPTj336Zu0ec7rH/ak/4N9vw=="],
"@opentelemetry/instrumentation-mysql": ["@opentelemetry/instrumentation-mysql@0.64.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@types/mysql": "2.15.27" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-W1w76AJkP7i0uzzAe7nsCMWq4+EMSA550f1lAmxDPdQC5FnreNbRIm/tod2OS9gVrYvRrQXNkFmZJKGo4kzCnw=="],
"@opentelemetry/instrumentation-mysql2": ["@opentelemetry/instrumentation-mysql2@0.64.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@opentelemetry/sql-common": "^0.41.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-yTu0mYh/qJPSE86VmNLQww5uugDyvCS2KJIPfPtIk2ufoEUoHPsV6Iynnvmz588Moq04aBLxfTa/EtE4A2ykWA=="],
"@opentelemetry/instrumentation-nestjs-core": ["@opentelemetry/instrumentation-nestjs-core@0.64.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.30.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-PW1ArxryMwF8/IXq1nzlQs7tmr/fWd1tf71AHevZT3Fm0hW7jRX9JEfYgIAcKDvmbqcJEr5K1224NEimrRPbuQ=="],
"@opentelemetry/instrumentation-net": ["@opentelemetry/instrumentation-net@0.62.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Gt2kzpACpmIad+q3LQqe8UNHuoVvdLuFpB6SN/A6xLPKNllb+ksPUYQhj1kXdZOpcFZNGKDXHyN+TUCVCk1TRw=="],
"@opentelemetry/instrumentation-openai": ["@opentelemetry/instrumentation-openai@0.16.0", "", { "dependencies": { "@opentelemetry/api-logs": "^0.218.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.36.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-I0KKybyqqFOxSBgYKQNdR/EF3LvzSaAUT7Y75xkjbgscY+V8UWDpUbY68POLhUC3SKMlGvZmrTSxcQ+Y0vRhNw=="],
"@opentelemetry/instrumentation-oracledb": ["@opentelemetry/instrumentation-oracledb@0.43.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@types/oracledb": "6.5.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7Z4kOOdnrHX4S5gCeWhnnpWQwEd7weRjDhJA1nSrwTYtAcVWNjk5wsMKHBCTDCN0uJtA9T6PouZ+AKRYiS1Rrg=="],
"@opentelemetry/instrumentation-pg": ["@opentelemetry/instrumentation-pg@0.70.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/sql-common": "^0.41.2", "@types/pg": "8.15.6", "@types/pg-pool": "2.0.7" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-g8WXwwOUXfjiEmATwjB/33QKE2AkIpNe4KIuJJh4djtXgCL0Wne+AzAfjuDIAspGvO1txQp8ibKsLd3SBmcvJA=="],
"@opentelemetry/instrumentation-pino": ["@opentelemetry/instrumentation-pino@0.64.0", "", { "dependencies": { "@opentelemetry/api-logs": "^0.218.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+vDL7tZMZjkp8BpYMx/cL2/HWGsNUqKcRmAIIEaQu/6F44oM6xGDMCSqMKHdKCsH1+WW52EYdHbWkVGTF0KVsQ=="],
"@opentelemetry/instrumentation-redis": ["@opentelemetry/instrumentation-redis@0.66.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/redis-common": "^0.38.3", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-bVShkag6vP2VQO0cpA8CHjOohWbKNYLyjiwGkOnSAwou1TPc6pf9DssFUxwqN2XF1J4oqP0LVSvN9kZUzMecfA=="],
"@opentelemetry/instrumentation-restify": ["@opentelemetry/instrumentation-restify@0.63.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Z73YxZpt0Y56uRu2pRWOjO5wXHvZqF46K4czoKRTGlUifzzFmUZxyOeAAECACuMRSLZmZ394WJin0MDgU9iW9w=="],
"@opentelemetry/instrumentation-router": ["@opentelemetry/instrumentation-router@0.62.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-0w8ok7GbXtYvX7TtLp72qQJKNyI7lD72Fy2NsNKIcQAv6TqGox5javFyXrIrCAtZHCONePxeAwAYj1Qd9si9OQ=="],
"@opentelemetry/instrumentation-runtime-node": ["@opentelemetry/instrumentation-runtime-node@0.31.0", "", { "dependencies": { "@opentelemetry/api-logs": "^0.218.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HkLsuEfUDahFiL/xFtEqJDMp7sp8ynOtA045bJi9nAH8CrPvljPW5SgJQb2mQqEYJQopbWYZ2lPqQEfj7bYgJg=="],
"@opentelemetry/instrumentation-socket.io": ["@opentelemetry/instrumentation-socket.io@0.65.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-dNvIbD40h0z69stQ9cIeAWRyy5WyQM1a1XnFthekc/oi/ipX4E6oYJBM4X2xKBxjZMTjdV5VshLoNeYMSBsnjw=="],
"@opentelemetry/instrumentation-tedious": ["@opentelemetry/instrumentation-tedious@0.37.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@types/tedious": "^4.0.14" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-cGLF46UsgeI1334atJxLO36yQlV7WXKg35Mp+e2NXo2vOTfIZTVqoKOzExVOTOwT4AQjfGVEDxyq5wXybUYXIA=="],
"@opentelemetry/instrumentation-undici": ["@opentelemetry/instrumentation-undici@0.28.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.24.0" }, "peerDependencies": { "@opentelemetry/api": "^1.7.0" } }, "sha512-7nh4Gw7PhYtQm82FIJtWUhx6iZQJj0bdkKe2RQb3XNIyxu0o9rM1J5Xt083SsG2tCbQZpX9/mlDxhTrK1Z/lVQ=="],
"@opentelemetry/instrumentation-winston": ["@opentelemetry/instrumentation-winston@0.62.0", "", { "dependencies": { "@opentelemetry/api-logs": "^0.218.0", "@opentelemetry/instrumentation": "^0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pr1U9ZV4RRy23qMVrRzebfxwDWjp44xA7sC0PAdeW9v4HDcfOr0ejdTJmIsBGvhkNHPBajfieaIF9b6/9wjErA=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="],
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.218.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A=="],
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="],
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A=="],
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q=="],
"@opentelemetry/redis-common": ["@opentelemetry/redis-common@0.38.3", "", {}, "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw=="],
"@opentelemetry/resource-detector-alibaba-cloud": ["@opentelemetry/resource-detector-alibaba-cloud@0.33.8", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/resources": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-RnSB/uxkElny0/WBFEtIG2HRG0cpSNTRdE+YSB7Poa+uljK+ddCacEZYz/PMgZh+cs586XstJQxdyjz0jtcAug=="],
"@opentelemetry/resource-detector-aws": ["@opentelemetry/resource-detector-aws@2.18.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-wyMM4UoRuHvI2KjqnTzvyW8Yv7MKRGA+I78Xti6gTEw7hBhqXU1SRo+f9KrsQfeeiOn+TkDuvxavuaAQbD3i6g=="],
"@opentelemetry/resource-detector-azure": ["@opentelemetry/resource-detector-azure@0.26.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-7KxF7mlwI2nKja/iEdwPqOaS0QAJbhT9ye4DeYZnXdOS/4phfonk5nSmyGDBYhBL7J30MPL91oZNuGYRKXZAXA=="],
"@opentelemetry/resource-detector-container": ["@opentelemetry/resource-detector-container@0.8.9", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/resources": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-Xd2C4HjW9hl75iqZT7tQNy2yRBUqNucq2O9+e0FJRNkbiItInYVMzc0S0KDXcx/vZBwNmlrKS3R0uLCU9ULsGA=="],
"@opentelemetry/resource-detector-gcp": ["@opentelemetry/resource-detector-gcp@0.53.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/resources": "^2.0.0", "gcp-metadata": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-RCV31v23ZwZfYR3LPkuORHTHIOvfm3hZBT7hAzSO0+oAIrG/Dm0ld5tV4lYNO05GjI7sHQdRcbSqzEYAvQcQuw=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="],
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/configuration": "0.218.0", "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-logs-otlp-grpc": "0.218.0", "@opentelemetry/exporter-logs-otlp-http": "0.218.0", "@opentelemetry/exporter-logs-otlp-proto": "0.218.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.218.0", "@opentelemetry/exporter-prometheus": "0.218.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.218.0", "@opentelemetry/exporter-trace-otlp-http": "0.218.0", "@opentelemetry/exporter-trace-otlp-proto": "0.218.0", "@opentelemetry/exporter-zipkin": "2.7.1", "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/propagator-b3": "2.7.1", "@opentelemetry/propagator-jaeger": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-tPMjHrLV5gsfNdYqoRHjeGbCAZBXXD9c1Qo/2ut7VwnUABDNh76xNxrT0SEhkIIJuCN45bbN1vZnYL1gY0IkOg=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="],
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.7.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.120.0", "", { "os": "android", "cpu": "arm" }, "sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.120.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg=="],
@ -796,28 +960,44 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/aws-lambda": ["@types/aws-lambda@8.10.161", "", {}, "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ=="],
"@types/bunyan": ["@types/bunyan@1.8.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="],
"@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/memcached": ["@types/memcached@2.2.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg=="],
"@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="],
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@types/oracledb": ["@types/oracledb@6.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ=="],
"@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
@ -898,6 +1078,8 @@
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@ -934,6 +1116,8 @@
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@ -970,6 +1154,8 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
@ -1018,6 +1204,8 @@
"curve25519-js": ["curve25519-js@0.0.4", "", {}, "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
@ -1114,6 +1302,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@ -1126,6 +1316,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@ -1140,8 +1332,16 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@ -1158,6 +1358,8 @@
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="],
@ -1190,6 +1392,8 @@
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-in-the-middle": ["import-in-the-middle@3.0.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
@ -1224,6 +1428,8 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@ -1266,6 +1472,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
@ -1292,6 +1500,8 @@
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.1", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA=="],
@ -1314,6 +1524,10 @@
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
@ -1460,6 +1674,8 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -1620,6 +1836,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@ -1660,6 +1878,8 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
@ -1680,6 +1900,10 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@ -1792,6 +2016,12 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@grpc/proto-loader/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"@grpc/proto-loader/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@ -1858,12 +2088,16 @@
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@grpc/proto-loader/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"@grpc/proto-loader/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
}
}

View file

@ -71,6 +71,36 @@ Default: 60 req/s burst, 10 req/s sustained per API key. Configurable. Returns `
---
## WhatsApp Groups
Groups are tracked automatically when the session joins a group or receives messages from one. Records sync on connect and update in real-time via Baileys events.
| Method | Path | Description |
| ------ | -------------------------------------- | ---------------------------------------- |
| GET | `/v1/whatsapp-groups` | List tracked groups. `session_id` required. |
| GET | `/v1/whatsapp-groups/:jid` | Single group by JID. `session_id` required. |
`:jid` must be URL-encoded (e.g. `120363429229698693%40g.us`).
**Group object:**
```json
{
"id": "uuid",
"appId": "uuid",
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"subject": "Dev Team",
"description": "Internal dev chat",
"participantCount": 12,
"createdAt": "2026-05-16T10:00:00Z",
"updatedAt": "2026-05-16T10:00:00Z"
}
```
`subject` and `description` may be `null` if metadata has not been received yet. `participantCount` is updated incrementally on `group.participants_added` / `group.participants_removed` and replaced wholesale on `group.joined` / history sync.
---
## Webhooks
| Method | Path | Description |

71
docs/baileys-risk.md Normal file
View file

@ -0,0 +1,71 @@
# Baileys Risk Notice
## What is Baileys?
[Baileys](https://github.com/WhiskeySockets/Baileys) is an open-source, community-maintained Node.js library that implements the WhatsApp Web protocol through reverse engineering. Wahoo uses Baileys as its WhatsApp connectivity layer.
## Unofficial status
Baileys is **not** an official WhatsApp or Meta product. It is not endorsed, supported, or sanctioned by WhatsApp Inc. or Meta Platforms, Inc. The library was built by analyzing the WhatsApp Web client's network traffic and is subject to break without warning whenever WhatsApp updates its protocol.
## Terms of Service
Using Baileys (and by extension Wahoo) to connect to WhatsApp violates WhatsApp's Terms of Service, specifically **Section 2(c)**, which prohibits:
> "reverse engineering, altering, modifying, creating derivative works from, decompiling, or extracting code from our Services"
and
> "using automated or programmatic methods to send or receive messages"
By deploying Wahoo you accept this risk. The Wahoo project and its contributors provide this software as-is and are not liable for any account suspension, number ban, or legal consequences that result from its use.
## Risk of number bans
WhatsApp actively detects and bans numbers it believes are operating in an automated or bulk-messaging capacity. Bans can be:
- **Temporary** — number blocked for hours to days
- **Permanent** — number permanently disabled from WhatsApp
Risk factors that increase ban likelihood:
- Sending high message volumes before the number is "warmed up"
- Sending unsolicited or bulk promotional messages
- Using a freshly registered number
- Receiving a high ratio of "block" or "report spam" actions from recipients
- Rapid session reconnects or unusual connection patterns
## Number warming guide
New numbers should be introduced to automation gradually:
| Week | Max messages/day | Notes |
| ---- | ---------------- | ----------------------------- |
| 1 | 50 | Personal-style messages only |
| 2 | 100 | Continue conversational style |
| 3 | 200 | Can begin light business use |
| 4+ | 500+ | Ramp up based on engagement |
General principles:
- **Never** send to people who have not opted in
- **Never** send the same message text to many recipients simultaneously (vary content)
- Keep message quality high — useful, relevant, and personalised content
- Monitor your delivery rates and stop if you see unusual failures
- Keep session connection stable — avoid frequent disconnect/reconnect cycles
- Use a number that has an active personal WhatsApp history before automating it
## Monitoring number health
Use the `/api/v1/sessions/{id}/health` endpoint to check session status regularly. If a session enters `DEAD` or `LOGGED_OUT` status unexpectedly, your number may have been flagged.
## Disclaimer
The Wahoo project and its contributors:
- Are not affiliated with WhatsApp Inc. or Meta Platforms, Inc.
- Provide no warranty, expressed or implied
- Accept no liability for account bans, service disruption, legal action, or any other consequence arising from use of this software
- Recommend evaluating the [official WhatsApp Business API](https://www.whatsapp.com/business/api) for production business use cases
Use this software at your own risk.

126
docs/quickstart.md Normal file
View file

@ -0,0 +1,126 @@
# Quickstart
Get Wahoo running locally in under 5 minutes.
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- A WhatsApp account (personal or business) for testing
## 1. Clone the repository
```bash
git clone https://github.com/yourorg/wahoo.git
cd wahoo
```
## 2. Configure environment
```bash
cp .env.example .env
```
Open `.env` and fill in the two required secrets:
```bash
# Generate a 32-byte master encryption key (base64)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Generate a session secret (at least 32 characters)
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
Set the values in `.env`:
```env
MASTER_ENCRYPTION_KEY=<output from first command>
SESSION_SECRET=<output from second command>
```
## 3. Start all services
```bash
docker compose up -d
```
This starts PostgreSQL, Redis, and the Wahoo app. Wait 10–15 seconds for the database migrations to complete.
## 4. Log in
Open [http://localhost:3000](http://localhost:3000) in your browser.
Use the default admin credentials set in your `.env` (check `ADMIN_EMAIL` / `ADMIN_PASSWORD`). On first run these are created automatically from those env vars.
## 5. Create an app
1. Click **Apps** in the sidebar
2. Click **New app**, give it a name, click **Create**
3. Open the app — you'll land on the **API Keys** tab
## 6. Generate an API key
1. Click **New API key**, optionally label it
2. Copy the key (`wak_live_...`) — it's shown only once
## 7. Create a WhatsApp session
Using your new API key:
```bash
export BASE_URL=http://localhost:3000
export API_KEY=wak_live_...
# Create a session (QR mode)
curl -s -X POST "$BASE_URL/api/v1/sessions/" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"via":"qr"}' | jq .
```
Note the `id` field — that's your `SESSION_ID`.
## 8. Scan the QR code
```bash
SESSION_ID=<id from previous step>
curl -s "$BASE_URL/api/v1/sessions/$SESSION_ID/qr" \
-H "Authorization: Bearer $API_KEY" | jq .qr
```
Open WhatsApp on your phone → Linked Devices → Link a Device → scan the QR.
Wait a few seconds, then check the session status:
```bash
curl -s "$BASE_URL/api/v1/sessions/$SESSION_ID/" \
-H "Authorization: Bearer $API_KEY" | jq .status
# → "CONNECTED"
```
## 9. Send your first message
```bash
curl -s -X POST "$BASE_URL/api/v1/sessions/$SESSION_ID/messages/text" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"1234567890@s.whatsapp.net","text":"Hello from Wahoo!"}' | jq .
```
Replace `1234567890` with the recipient's full phone number (country code, no `+`).
## 10. Explore the API (Swagger UI)
Open [http://localhost:3000/api/docs](http://localhost:3000/api/docs) in your browser.
The full interactive API reference is served there — try endpoints directly, view request/response schemas, and authenticate using your `wak_live_...` key via the **Authorize** button.
The raw OpenAPI spec (OAS 3.0 JSON) is at [http://localhost:3000/api/openapi/json](http://localhost:3000/api/openapi/json).
## Next steps
- Read [docs/api.md](./api.md) for the full REST reference
- Set up webhooks — see [docs/webhooks.md](./webhooks.md)
- Review security practices — see [docs/security.md](./security.md)
- Deploy to production — see [docs/deployment.md](./deployment.md)
- Understand the risks — see [docs/baileys-risk.md](./baileys-risk.md)

View file

@ -19,18 +19,71 @@ 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 |
| 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 |
| `group.joined` | Session was added to a WhatsApp group |
| `group.left` | Session was removed or kicked from a group |
| `group.updated` | Group subject or description changed |
| `group.participants_added` | Other participants added to a group |
| `group.participants_removed`| Other participants removed from a group |
---
## Group event payloads
All group payloads appear in the `data` field of the standard envelope (see [Payload structure](#payload-structure) below).
**`group.joined`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"subject": "Dev Team",
"description": "Internal dev chat",
"participantCount": 12
}
```
**`group.left`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"participants": ["6281234567890@s.whatsapp.net"]
}
```
`participants` contains the JID(s) that triggered the removal (may include the session's own JID when kicked).
**`group.updated`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"subject": "New Name",
"description": null
}
```
Only fields reported by WhatsApp are present. `null` means the field was explicitly cleared.
**`group.participants_added`** / **`group.participants_removed`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"participants": ["491234567890@s.whatsapp.net", "441234567890@s.whatsapp.net"]
}
```
These fire for other participants — not for the session account itself (that triggers `group.joined` / `group.left`).
---
@ -156,3 +209,34 @@ POST /v1/webhooks/:id/test
```
Fires a synthetic `message.received` payload to the endpoint and returns the HTTP response.
---
## Local test server
A local receiver is included at `scripts/webhook-test-server.ts`. It prompts for your signing secret, listens on port `7432`, verifies every delivery signature, and pretty-prints each request to stdout.
```bash
bun --bun run webhook:test-server
```
Output example:
```
── #1 ──────────────────────────────────────
POST /api/testing 2026-05-16T10:00:00.000Z
app-id app_01hw...
event-type message.received
event-id evt_01hw...
timestamp 1715684400000
signature VALID sha256=abc123...
body:
{
"event": "message.received",
...
}
```
Register `http://localhost:7432/<path>` as your endpoint URL in the dashboard while developing. Valid signatures print green; invalid print red and return `401`.

View file

@ -0,0 +1,34 @@
CREATE TYPE "public"."webhook_delivery_status" AS ENUM('pending', 'success', 'failed');--> statement-breakpoint
CREATE TABLE "webhook_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"endpoint_id" uuid NOT NULL,
"app_id" uuid NOT NULL,
"event_type" text NOT NULL,
"event_id" text NOT NULL,
"payload" jsonb NOT NULL,
"status" "webhook_delivery_status" DEFAULT 'pending' NOT NULL,
"http_status" integer,
"attempt" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp,
"delivered_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhook_endpoints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"url" text NOT NULL,
"secret_encrypted" text NOT NULL,
"events" text[] DEFAULT '{}' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"unhealthy_since" timestamp,
"consecutive_failures" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_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 "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_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 "webhook_deliveries_endpoint_created_idx" ON "webhook_deliveries" USING btree ("endpoint_id","created_at");--> statement-breakpoint
CREATE INDEX "webhook_deliveries_app_id_idx" ON "webhook_deliveries" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "webhook_endpoints_app_id_idx" ON "webhook_endpoints" USING btree ("app_id");

View file

@ -0,0 +1,4 @@
ALTER TABLE "apps" ADD COLUMN "rotation_status" text;--> statement-breakpoint
ALTER TABLE "apps" ADD COLUMN "rotation_job_id" text;--> statement-breakpoint
ALTER TABLE "apps" ADD COLUMN "rotation_started_at" timestamp;--> statement-breakpoint
ALTER TABLE "apps" ADD COLUMN "rotation_completed_at" timestamp;

View file

@ -0,0 +1,2 @@
CREATE TYPE "public"."rotation_status" AS ENUM('pending', 'running', 'done', 'failed');--> statement-breakpoint
ALTER TABLE "apps" ALTER COLUMN "rotation_status" SET DATA TYPE "public"."rotation_status" USING "rotation_status"::"public"."rotation_status";

View file

@ -0,0 +1,17 @@
CREATE TABLE "whatsapp_groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"session_id" uuid NOT NULL,
"jid" text NOT NULL,
"subject" text,
"description" text,
"participant_count" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "whatsapp_groups_session_jid_unique" UNIQUE("session_id","jid")
);
--> statement-breakpoint
ALTER TABLE "whatsapp_groups" ADD CONSTRAINT "whatsapp_groups_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 "whatsapp_groups" ADD CONSTRAINT "whatsapp_groups_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "whatsapp_groups_app_id_idx" ON "whatsapp_groups" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "whatsapp_groups_session_id_idx" ON "whatsapp_groups" USING btree ("session_id");

View file

@ -76,12 +76,8 @@
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -148,12 +144,8 @@
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": [
"owner_user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["owner_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -486,12 +478,8 @@
"name": "messages_app_id_apps_id_fk",
"tableFrom": "messages",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -499,12 +487,8 @@
"name": "messages_session_id_sessions_id_fk",
"tableFrom": "messages",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -564,12 +548,8 @@
"name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -579,11 +559,7 @@
"session_keys_unique": {
"name": "session_keys_unique",
"nullsNotDistinct": false,
"columns": [
"session_id",
"key_type",
"key_id"
]
"columns": ["session_id", "key_type", "key_id"]
}
},
"policies": {},
@ -698,12 +674,8 @@
"name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -771,9 +743,7 @@
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -785,40 +755,22 @@
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": [
"user",
"api_key"
]
"values": ["user", "api_key"]
},
"public.message_direction": {
"name": "message_direction",
"schema": "public",
"values": [
"in",
"out"
]
"values": ["in", "out"]
},
"public.message_status": {
"name": "message_status",
"schema": "public",
"values": [
"queued",
"sent",
"delivered",
"read",
"failed"
]
"values": ["queued", "sent", "delivered", "read", "failed"]
},
"public.message_type": {
"name": "message_type",
"schema": "public",
"values": [
"text",
"image",
"video",
"document",
"audio"
]
"values": ["text", "image", "video", "document", "audio"]
},
"public.session_status": {
"name": "session_status",
@ -844,4 +796,4 @@
"schemas": {},
"tables": {}
}
}
}

View file

@ -76,12 +76,8 @@
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -148,12 +144,8 @@
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": [
"owner_user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["owner_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -321,12 +313,8 @@
"name": "contact_group_members_contact_id_contacts_id_fk",
"tableFrom": "contact_group_members",
"tableTo": "contacts",
"columnsFrom": [
"contact_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["contact_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@ -334,12 +322,8 @@
"name": "contact_group_members_group_id_contact_groups_id_fk",
"tableFrom": "contact_group_members",
"tableTo": "contact_groups",
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -347,10 +331,7 @@
"compositePrimaryKeys": {
"contact_group_members_contact_id_group_id_pk": {
"name": "contact_group_members_contact_id_group_id_pk",
"columns": [
"contact_id",
"group_id"
]
"columns": ["contact_id", "group_id"]
}
},
"uniqueConstraints": {},
@ -417,12 +398,8 @@
"name": "contact_groups_app_id_apps_id_fk",
"tableFrom": "contact_groups",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -485,12 +462,8 @@
"name": "contact_tags_app_id_apps_id_fk",
"tableFrom": "contact_tags",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -498,12 +471,8 @@
"name": "contact_tags_contact_id_contacts_id_fk",
"tableFrom": "contact_tags",
"tableTo": "contacts",
"columnsFrom": [
"contact_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["contact_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -513,10 +482,7 @@
"contact_tags_unique": {
"name": "contact_tags_unique",
"nullsNotDistinct": false,
"columns": [
"contact_id",
"tag"
]
"columns": ["contact_id", "tag"]
}
},
"policies": {},
@ -648,12 +614,8 @@
"name": "contacts_app_id_apps_id_fk",
"tableFrom": "contacts",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -663,10 +625,7 @@
"contacts_app_phone_unique": {
"name": "contacts_app_phone_unique",
"nullsNotDistinct": false,
"columns": [
"app_id",
"phone_blind_index"
]
"columns": ["app_id", "phone_blind_index"]
}
},
"policies": {},
@ -747,12 +706,8 @@
"name": "custom_field_schemas_app_id_apps_id_fk",
"tableFrom": "custom_field_schemas",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -762,10 +717,7 @@
"custom_field_schemas_app_key_unique": {
"name": "custom_field_schemas_app_key_unique",
"nullsNotDistinct": false,
"columns": [
"app_id",
"key"
]
"columns": ["app_id", "key"]
}
},
"policies": {},
@ -960,12 +912,8 @@
"name": "messages_app_id_apps_id_fk",
"tableFrom": "messages",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
@ -973,12 +921,8 @@
"name": "messages_session_id_sessions_id_fk",
"tableFrom": "messages",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -1038,12 +982,8 @@
"name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -1053,11 +993,7 @@
"session_keys_unique": {
"name": "session_keys_unique",
"nullsNotDistinct": false,
"columns": [
"session_id",
"key_type",
"key_id"
]
"columns": ["session_id", "key_type", "key_id"]
}
},
"policies": {},
@ -1172,12 +1108,8 @@
"name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@ -1245,9 +1177,7 @@
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -1259,59 +1189,32 @@
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": [
"user",
"api_key"
]
"values": ["user", "api_key"]
},
"public.contact_source": {
"name": "contact_source",
"schema": "public",
"values": [
"csv",
"whatsapp_sync",
"api"
]
"values": ["csv", "whatsapp_sync", "api"]
},
"public.custom_field_type": {
"name": "custom_field_type",
"schema": "public",
"values": [
"text",
"number",
"bool",
"date"
]
"values": ["text", "number", "bool", "date"]
},
"public.message_direction": {
"name": "message_direction",
"schema": "public",
"values": [
"in",
"out"
]
"values": ["in", "out"]
},
"public.message_status": {
"name": "message_status",
"schema": "public",
"values": [
"queued",
"sent",
"delivered",
"read",
"failed"
]
"values": ["queued", "sent", "delivered", "read", "failed"]
},
"public.message_type": {
"name": "message_type",
"schema": "public",
"values": [
"text",
"image",
"video",
"document",
"audio"
]
"values": ["text", "image", "video", "document", "audio"]
},
"public.session_status": {
"name": "session_status",
@ -1337,4 +1240,4 @@
"schemas": {},
"tables": {}
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,34 @@
"when": 1778872231629,
"tag": "0004_striped_ben_grimm",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1778876071537,
"tag": "0005_lush_moondragon",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1778918643395,
"tag": "0006_tricky_korg",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1778920492721,
"tag": "0007_silky_sentinel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1778923691302,
"tag": "0008_tricky_miek",
"breakpoints": true
}
]
}

View file

@ -15,6 +15,11 @@ export default [
},
},
{
ignores: ["eslint.config.js", "prettier.config.js", ".output/**"],
ignores: [
"eslint.config.js",
"prettier.config.js",
".output/**",
"load-test/**",
],
},
];

79
load-test/k6.js Normal file
View file

@ -0,0 +1,79 @@
/**
* k6 load test — Wahoo Send Text API
*
* Usage:
* k6 run \
* -e BASE_URL=http://localhost:3000 \
* -e API_KEY=wak_live_xxx \
* -e SESSION_ID=<uuid> \
* load-test/k6.js
*
* Install k6: https://k6.io/docs/get-started/installation/
*
* The test phone number (+15550000001@s.whatsapp.net) is a dummy recipient.
* Replace with a real JID for meaningful delivery testing.
*/
import http from "k6/http";
import { check, sleep } from "k6";
import { Rate } from "k6/metrics";
const errorRate = new Rate("errors");
const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";
const API_KEY = __ENV.API_KEY || "";
const SESSION_ID = __ENV.SESSION_ID || "";
export const options = {
scenarios: {
ramp_up: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "1m", target: 10 },
{ duration: "10m", target: 10 },
{ duration: "1m", target: 0 },
],
},
},
thresholds: {
http_req_duration: ["p(95)<250"],
errors: ["rate<0.01"],
},
};
export function setup() {
console.log("=== Wahoo k6 load test ===");
console.log(`BASE_URL: ${BASE_URL}`);
console.log(`SESSION_ID: ${SESSION_ID}`);
console.log(`API_KEY: ${API_KEY.slice(0, 12)}...`);
console.log("Scenarios: ramp 0→10 VUs over 1m, hold 10m, ramp down 1m");
console.log("Thresholds: p95 < 250ms, error rate < 1%");
}
export default function () {
const url = `${BASE_URL}/api/v1/sessions/${SESSION_ID}/messages/text`;
const payload = JSON.stringify({
to: "15550000001@s.whatsapp.net",
text: `Load test message from VU ${__VU} iter ${__ITER} at ${Date.now()}`,
});
const params = {
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
};
const res = http.post(url, payload, params);
const ok = check(res, {
"status is 202": (r) => r.status === 202,
"response time < 500ms": (r) => r.timings.duration < 500,
});
errorRate.add(!ok);
sleep(0.1);
}

View file

@ -17,18 +17,20 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"webhook:test-server": "bun run scripts/webhook-test-server.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1047.0",
"@aws-sdk/s3-request-presigner": "^3.1047.0",
"@faker-js/faker": "^10.3.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/sdk-node": "^0.218.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",
@ -60,9 +62,12 @@
"uuid": "^14.0.0"
},
"devDependencies": {
"@faker-js/faker": "^10.3.0",
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/eslint-config": "latest",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bun
import { createHmac, timingSafeEqual } from "node:crypto";
import * as readline from "node:readline";
const PORT = 7432;
const G = "\x1b[32m";
const R = "\x1b[31m";
const Y = "\x1b[33m";
const D = "\x1b[2m";
const B = "\x1b[1m";
const Z = "\x1b[0m";
let reqCount = 0;
function verify(body: string, timestamp: string, sig: string, secret: string): boolean {
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${body}.${timestamp}`)
.digest("hex");
try {
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch {
return false;
}
}
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
const secret = await prompt("Signing secret: ");
if (!secret) {
console.error("No secret provided.");
process.exit(1);
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const n = ++reqCount;
const url = new URL(req.url);
const ts = new Date().toISOString();
const sig = req.headers.get("x-signature") ?? "";
const timestamp = req.headers.get("x-timestamp") ?? "";
const eventId = req.headers.get("x-event-id") ?? "";
const eventType = req.headers.get("x-event-type") ?? "";
const appId = req.headers.get("x-app-id") ?? "";
const ct = req.headers.get("content-type") ?? "";
const ua = req.headers.get("user-agent") ?? "";
const body = req.method !== "GET" && req.method !== "HEAD"
? await req.text()
: "";
if (req.method !== "POST") {
console.log(
`\n${D}── #${n} ──────────────────────────────────────${Z}` +
`\n${Y}${B}${req.method}${Z} ${url.pathname} ${D}${ts}${Z}` +
`\n${D} → 405 Method Not Allowed${Z}`
);
return new Response("Method Not Allowed", { status: 405 });
}
const valid = !!(sig && timestamp && verify(body, timestamp, sig, secret));
const sigLabel = valid ? `${G}VALID${Z}` : `${R}INVALID${Z}`;
let bodyPreview = body;
try { bodyPreview = JSON.stringify(JSON.parse(body), null, 2); } catch { /* leave as-is */ }
if (bodyPreview.length > 600) bodyPreview = bodyPreview.slice(0, 600) + "\n…";
const indented = bodyPreview.split("\n").map((l) => " " + l).join("\n");
console.log(
`\n${D}── #${n} ──────────────────────────────────────${Z}` +
`\n${valid ? G : R}${B}POST${Z} ${url.pathname} ${D}${ts}${Z}` +
`\n` +
`\n ${D}app-id ${Z}${appId || D + "(none)" + Z}` +
`\n ${D}event-type ${Z}${eventType || D + "(none)" + Z}` +
`\n ${D}event-id ${Z}${eventId || D + "(none)" + Z}` +
`\n ${D}timestamp ${Z}${timestamp || D + "(none)" + Z}` +
`\n ${D}user-agent ${Z}${ua || D + "(none)" + Z}` +
`\n ${D}content-t ${Z}${ct || D + "(none)" + Z}` +
`\n ${D}signature ${Z}${sigLabel} ${D}${sig || "(none)"}${Z}` +
`\n` +
`\n${D} body:${Z}` +
`\n${indented}` +
`\n`
);
if (!valid) {
return new Response(JSON.stringify({ error: "invalid signature" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
console.log(`\n${B}Webhook test server${Z} listening on ${G}http://localhost:${server.port}${Z}`);
console.log(`${D}Waiting for deliveries…${Z}\n`);

View file

@ -0,0 +1,425 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Plus, Trash2, Check, X } from "lucide-react";
import { customFieldSchemasQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { Badge } from "#/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "#/components/ui/dialog";
interface FieldSchema {
id: string;
key: string;
label: string;
fieldType: "text" | "number" | "bool" | "date";
required: boolean;
createdAt: string;
}
const TYPE_LABELS: Record<string, string> = {
text: "Text",
number: "Number",
bool: "Boolean",
date: "Date",
};
interface Props {
appId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FieldsDialog({ appId, open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { data: schemasData, isPending } = useQuery<FieldSchema[]>(
customFieldSchemasQuery(appId),
);
const schemas = schemasData ?? [];
// create
const [showCreate, setShowCreate] = useState(false);
const [newKey, setNewKey] = useState("");
const [newLabel, setNewLabel] = useState("");
const [newType, setNewType] = useState<"text" | "number" | "bool" | "date">(
"text",
);
const [newRequired, setNewRequired] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
// inline edit
const [editId, setEditId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState("");
const [editRequired, setEditRequired] = useState(false);
const [saving, setSaving] = useState(false);
// delete
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
function openEdit(s: FieldSchema) {
setEditId(s.id);
setEditLabel(s.label);
setEditRequired(s.required);
setDeleteId(null);
}
function cancelEdit() {
setEditId(null);
}
async function createSchema(e: React.FormEvent) {
e.preventDefault();
setCreateError("");
setCreating(true);
try {
const res = await fetch(
`/api/v1/admin/custom-field-schemas?app_id=${encodeURIComponent(appId)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
key: newKey.trim(),
label: newLabel.trim(),
field_type: newType,
required: newRequired,
}),
},
);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error ?? "Failed");
}
setShowCreate(false);
setNewKey("");
setNewLabel("");
setNewType("text");
setNewRequired(false);
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed");
} finally {
setCreating(false);
}
}
async function saveEdit() {
if (!editId) return;
setSaving(true);
try {
await fetch(`/api/v1/admin/custom-field-schemas/${editId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
app_id: appId,
label: editLabel,
required: editRequired,
}),
});
setEditId(null);
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
} finally {
setSaving(false);
}
}
async function deleteSchema() {
if (!deleteId) return;
setDeleting(true);
try {
await fetch(
`/api/v1/admin/custom-field-schemas/${deleteId}?app_id=${encodeURIComponent(appId)}`,
{ method: "DELETE", credentials: "include" },
);
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
} finally {
setDeleting(false);
setDeleteId(null);
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) {
setShowCreate(false);
setEditId(null);
setDeleteId(null);
}
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="text-[15px]">
Custom field schemas
</DialogTitle>
<DialogDescription className="text-[13px]">
Define custom fields stored on contacts in this app.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
{Boolean(isPending) && (
<p className="text-[13px] text-gray-400 py-2">Loading…</p>
)}
{!(isPending as boolean) && schemas.length === 0 && !showCreate && (
<p className="text-[13px] text-gray-400 py-2">
No custom fields defined.
</p>
)}
{schemas.map((s) => (
<div
key={s.id}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* row */}
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<code className="text-[11px] font-mono text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded shrink-0">
{s.key}
</code>
<span className="text-[13px] font-medium text-gray-900 truncate">
{s.label}
</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{TYPE_LABELS[s.fieldType]}
</Badge>
{s.required && (
<Badge variant="secondary" className="text-[10px] shrink-0">
Required
</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{editId !== s.id && deleteId !== s.id && (
<>
<button
onClick={() => openEdit(s)}
className="text-[12px] text-gray-500 hover:text-gray-900 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
>
Edit
</button>
<button
onClick={() => {
setDeleteId(s.id);
setEditId(null);
}}
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={13} strokeWidth={1.75} />
</button>
</>
)}
</div>
</div>
{/* inline edit */}
{editId === s.id && (
<div className="border-t border-gray-100 bg-gray-50 px-3 py-3 space-y-2.5">
<div className="space-y-1">
<Label className="text-[12px] text-gray-600">Label</Label>
<Input
autoFocus
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
className="h-8 text-[13px]"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editRequired}
onChange={(e) => setEditRequired(e.target.checked)}
className="h-3.5 w-3.5"
/>
<span className="text-[12px] text-gray-700">
Required field
</span>
</label>
<div className="flex gap-2">
<Button
size="sm"
onClick={saveEdit}
disabled={saving}
className="h-7 px-3 text-[12px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
<Check size={11} strokeWidth={2.5} className="mr-1" />
{saving ? "Saving…" : "Save"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEdit}
className="h-7 px-3 text-[12px]"
>
<X size={11} strokeWidth={2} className="mr-1" />
Cancel
</Button>
</div>
</div>
)}
{/* inline delete confirm */}
{deleteId === s.id && (
<div className="border-t border-red-100 bg-red-50 px-3 py-2.5 flex items-center justify-between">
<p className="text-[12px] text-red-700">
Delete <strong>{s.label}</strong>? Existing data is not
removed.
</p>
<div className="flex gap-2 shrink-0 ml-3">
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteId(null)}
className="h-7 px-3 text-[12px]"
>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
disabled={deleting}
onClick={deleteSchema}
className="h-7 px-3 text-[12px]"
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
)}
</div>
))}
{/* inline create form */}
{showCreate && (
<div className="border border-gray-200 rounded-lg px-3 py-3 space-y-2.5 bg-gray-50">
<p className="text-[12px] font-semibold text-gray-700">
New field
</p>
<form onSubmit={createSchema} className="space-y-2.5">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[12px] text-gray-600">Key</Label>
<Input
autoFocus
placeholder="e.g. tier"
value={newKey}
onChange={(e) =>
setNewKey(
e.target.value.replace(/\s+/g, "_").toLowerCase(),
)
}
className="h-8 text-[13px]"
required
/>
<p className="text-[11px] text-gray-400">
Immutable after creation
</p>
</div>
<div className="space-y-1">
<Label className="text-[12px] text-gray-600">Label</Label>
<Input
placeholder="e.g. Customer Tier"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="h-8 text-[13px]"
required
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-[12px] text-gray-600">Type</Label>
<select
value={newType}
onChange={(e) =>
setNewType(
e.target.value as "text" | "number" | "bool" | "date",
)
}
className="w-full h-8 text-[13px] border border-input rounded-md px-3 bg-background"
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="bool">Boolean</option>
<option value="date">Date</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newRequired}
onChange={(e) => setNewRequired(e.target.checked)}
className="h-3.5 w-3.5"
/>
<span className="text-[12px] text-gray-700">
Required field
</span>
</label>
{createError && (
<p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5">
{createError}
</p>
)}
<div className="flex gap-2">
<Button
type="submit"
size="sm"
disabled={creating}
className="h-7 px-3 text-[12px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
{creating ? "Creating…" : "Create"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setShowCreate(false);
setCreateError("");
}}
className="h-7 px-3 text-[12px]"
>
Cancel
</Button>
</div>
</form>
</div>
)}
</div>
{!showCreate && (
<button
onClick={() => {
setShowCreate(true);
setEditId(null);
setDeleteId(null);
}}
className="flex items-center gap-1.5 text-[13px] text-gray-500 hover:text-gray-900 transition-colors mt-1"
>
<Plus size={13} strokeWidth={2} />
New field
</button>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -9,6 +9,9 @@ import {
ChevronLeft,
Key,
Users,
Users2,
Webhook,
Activity,
} from "lucide-react";
import { appQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
@ -82,6 +85,7 @@ export function Sidebar() {
function GlobalNav({ pathname }: { pathname: string }) {
const items = [
{ to: "/apps" as const, label: "Apps", icon: LayoutGrid },
{ to: "/monitoring" as const, label: "Monitoring", icon: Activity },
{ to: "/audit-log" as const, label: "Audit Log", icon: ScrollText },
];
@ -91,7 +95,7 @@ function GlobalNav({ pathname }: { pathname: string }) {
const isActive =
to === "/apps"
? pathname === "/apps" || pathname === "/apps/"
: pathname === to;
: pathname.startsWith(to);
return (
<Link
@ -128,12 +132,18 @@ function AppContextNav({
}) {
const sessionsPath = `/apps/${appId}/sessions`;
const contactsPath = `/apps/${appId}/contacts`;
const webhooksPath = `/apps/${appId}/webhooks`;
const groupsPath = `/apps/${appId}/groups`;
const isContacts = pathname.startsWith(contactsPath);
const isSessions = pathname.startsWith(sessionsPath);
const isWebhooks = pathname.startsWith(webhooksPath);
const isGroups = pathname.startsWith(groupsPath);
const isKeys =
!isContacts &&
!isSessions &&
!isWebhooks &&
!isGroups &&
(pathname === `/apps/${appId}` ||
pathname === `/apps/${appId}/` ||
pathname.startsWith(`/apps/${appId}/`));
@ -221,6 +231,42 @@ function AppContextNav({
Contacts
</Link>
<Link
to="/apps/$appId/webhooks/"
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",
isWebhooks
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<Webhook
size={13}
strokeWidth={isWebhooks ? 2 : 1.75}
className={isWebhooks ? "text-emerald-400" : ""}
/>
Webhooks
</Link>
<Link
to="/apps/$appId/groups/"
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",
isGroups
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<Users2
size={13}
strokeWidth={isGroups ? 2 : 1.75}
className={isGroups ? "text-emerald-400" : ""}
/>
Groups
</Link>
{/* Separator */}
<div className="border-t border-zinc-900 my-2 mx-1" />

View file

@ -16,6 +16,13 @@ import { relations } from "drizzle-orm";
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
export const rotationStatusEnum = pgEnum("rotation_status", [
"pending",
"running",
"done",
"failed",
]);
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
@ -34,6 +41,10 @@ export const apps = pgTable("apps", {
.references(() => users.id),
dekEncrypted: text("dek_encrypted").notNull(),
maxSessions: integer("max_sessions").notNull().default(10),
rotationStatus: rotationStatusEnum("rotation_status"),
rotationJobId: text("rotation_job_id"),
rotationStartedAt: timestamp("rotation_started_at"),
rotationCompletedAt: timestamp("rotation_completed_at"),
deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
@ -66,6 +77,7 @@ export const appsRelations = relations(apps, ({ one, many }) => ({
contacts: many(contacts),
contactGroups: many(contactGroups),
customFieldSchemas: many(customFieldSchemas),
webhookEndpoints: many(webhookEndpoints),
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
@ -342,6 +354,117 @@ export const customFieldSchemasRelations = relations(
}),
);
// ─── WhatsApp Groups ─────────────────────────────────────────────────────────
export const whatsappGroups = pgTable(
"whatsapp_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
sessionId: uuid("session_id")
.notNull()
.references(() => sessions.id, { onDelete: "cascade" }),
jid: text("jid").notNull(),
subject: text("subject"),
description: text("description"),
participantCount: integer("participant_count"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => [
unique("whatsapp_groups_session_jid_unique").on(t.sessionId, t.jid),
index("whatsapp_groups_app_id_idx").on(t.appId),
index("whatsapp_groups_session_id_idx").on(t.sessionId),
],
);
export const whatsappGroupsRelations = relations(whatsappGroups, ({ one }) => ({
app: one(apps, { fields: [whatsappGroups.appId], references: [apps.id] }),
session: one(sessions, {
fields: [whatsappGroups.sessionId],
references: [sessions.id],
}),
}));
// ─── Webhooks ────────────────────────────────────────────────────────────────
export const webhookDeliveryStatusEnum = pgEnum("webhook_delivery_status", [
"pending",
"success",
"failed",
]);
export const webhookEndpoints = pgTable(
"webhook_endpoints",
{
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
url: text("url").notNull(),
secretEncrypted: text("secret_encrypted").notNull(),
events: text("events").array().notNull().default([]),
enabled: boolean("enabled").notNull().default(true),
unhealthySince: timestamp("unhealthy_since"),
consecutiveFailures: integer("consecutive_failures").notNull().default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [index("webhook_endpoints_app_id_idx").on(t.appId)],
);
export const webhookDeliveries = pgTable(
"webhook_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
endpointId: uuid("endpoint_id")
.notNull()
.references(() => webhookEndpoints.id, { onDelete: "cascade" }),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
eventType: text("event_type").notNull(),
eventId: text("event_id").notNull(),
payload: jsonb("payload").notNull(),
status: webhookDeliveryStatusEnum("status").notNull().default("pending"),
httpStatus: integer("http_status"),
attempt: integer("attempt").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at"),
deliveredAt: timestamp("delivered_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [
index("webhook_deliveries_endpoint_created_idx").on(
t.endpointId,
t.createdAt,
),
index("webhook_deliveries_app_id_idx").on(t.appId),
],
);
export const webhookEndpointsRelations = relations(
webhookEndpoints,
({ one, many }) => ({
app: one(apps, { fields: [webhookEndpoints.appId], references: [apps.id] }),
deliveries: many(webhookDeliveries),
}),
);
export const webhookDeliveriesRelations = relations(
webhookDeliveries,
({ one }) => ({
endpoint: one(webhookEndpoints, {
fields: [webhookDeliveries.endpointId],
references: [webhookEndpoints.id],
}),
app: one(apps, {
fields: [webhookDeliveries.appId],
references: [apps.id],
}),
}),
);
// ─── Audit Log ────────────────────────────────────────────────────────────────
export const auditLog = pgTable(

View file

@ -1,8 +1,9 @@
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, isNull, notInArray, or } 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";
import { createQueue, QUEUE_KEY_ROTATION } from "#/lib/queue/index";
type App = typeof apps.$inferSelect;
@ -100,6 +101,83 @@ export async function updateUserApp(
return updated as App;
}
export async function startKeyRotation(
appId: string,
userId: string,
audit?: { ip?: string; userAgent?: string },
): Promise<
{ ok: true; jobId: string } | { ok: false; error: string; status: number }
> {
const app = await findUserApp(appId, userId);
if (!app) return { ok: false, error: "Not found", status: 404 };
// Atomic: only proceed if not already pending/running
const updated = await db
.update(apps)
.set({
rotationStatus: "pending",
rotationJobId: null,
rotationStartedAt: new Date(),
rotationCompletedAt: null,
})
.where(
and(
eq(apps.id, appId),
or(
isNull(apps.rotationStatus),
notInArray(apps.rotationStatus, ["pending", "running"]),
),
),
)
.returning({ id: apps.id });
if (updated.length === 0) {
return { ok: false, error: "Rotation already in progress", status: 409 };
}
const newDek = generateDek();
const newDekEncrypted = wrapDek(newDek);
const queue = createQueue(QUEUE_KEY_ROTATION);
const job = await queue.add("rotate", { appId, newDekEncrypted });
await queue.close();
await db
.update(apps)
.set({ rotationJobId: String(job.id) })
.where(eq(apps.id, appId));
await writeAudit({
actorType: "user",
actorId: userId,
action: "app.rotation.start",
resourceType: "app",
resourceId: appId,
ip: audit?.ip,
userAgent: audit?.userAgent,
});
return { ok: true, jobId: String(job.id) };
}
export async function getKeyRotationStatus(
appId: string,
userId: string,
): Promise<{
status: string | null;
startedAt: Date | null;
completedAt: Date | null;
} | null> {
const app = await findUserApp(appId, userId);
if (!app) return null;
return {
status: app.rotationStatus ?? null,
startedAt: app.rotationStartedAt ?? null,
completedAt: app.rotationCompletedAt ?? null,
};
}
export async function deleteUserApp(
appId: string,
userId: string,

View file

@ -3,10 +3,7 @@ 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";
import { decryptWithMasterKey, encryptWithMasterKey } from "#/lib/crypto";
export async function findUserByEmail(email: string) {
return (

View file

@ -4,7 +4,9 @@ import { contactGroups, contactGroupMembers, contacts } from "#/db/schema";
type ContactGroup = typeof contactGroups.$inferSelect;
export async function listContactGroups(appId: string): Promise<ContactGroup[]> {
export async function listContactGroups(
appId: string,
): Promise<ContactGroup[]> {
return db.query.contactGroups.findMany({
where: eq(contactGroups.appId, appId),
orderBy: (g, { asc }) => [asc(g.name)],
@ -29,10 +31,7 @@ export async function findContactGroup(
): Promise<ContactGroup | null> {
return (
(await db.query.contactGroups.findFirst({
where: and(
eq(contactGroups.id, groupId),
eq(contactGroups.appId, appId),
),
where: and(eq(contactGroups.id, groupId), eq(contactGroups.appId, appId)),
})) ?? null
);
}
@ -41,7 +40,10 @@ export async function updateContactGroup(
groupId: string,
appId: string,
updates: { name?: string; description?: string | null },
): Promise<{ ok: true; group: ContactGroup } | { ok: false; error: string; status: number }> {
): Promise<
| { ok: true; group: ContactGroup }
| { ok: false; error: string; status: number }
> {
const existing = await findContactGroup(groupId, appId);
if (!existing) return { ok: false, error: "Group not found", status: 404 };
@ -73,7 +75,9 @@ export async function addGroupMembers(
groupId: string,
appId: string,
contactIds: string[],
): Promise<{ ok: true; added: number } | { ok: false; error: string; status: number }> {
): Promise<
{ ok: true; added: number } | { ok: false; error: string; status: number }
> {
const existing = await findContactGroup(groupId, appId);
if (!existing) return { ok: false, error: "Group not found", status: 404 };
@ -81,9 +85,7 @@ export async function addGroupMembers(
// Verify all contactIds belong to appId
const validContacts = await db.query.contacts.findMany({
where: and(
eq(contacts.appId, appId),
),
where: and(eq(contacts.appId, appId)),
columns: { id: true },
});
const validIds = new Set(validContacts.map((c) => c.id));

View file

@ -1,18 +1,15 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "#/db/index";
import {
apps,
contacts,
contactTags,
contactGroupMembers,
} from "#/db/schema";
import { unwrapDek } from "#/lib/crypto";
import { contacts, contactTags, contactGroupMembers } from "#/db/schema";
import {
encryptContact,
decryptContact,
phoneBlindIndex,
normalizePhone,
} from "#/lib/contacts-crypto";
import { getAppDek } from "#/lib/dek-helper";
import { dispatchEvent } from "#/lib/events/dispatch";
import { listCustomFieldSchemas } from "#/lib/actions/custom-field-schemas";
export type Contact = typeof contacts.$inferSelect;
@ -31,15 +28,53 @@ export interface DecryptedContact {
updatedAt: Date;
}
export 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 validateCustomFields(
appId: string,
customFields: Record<string, unknown>,
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const schemas = await listCustomFieldSchemas(appId);
if (schemas.length === 0) return { ok: true };
for (const schema of schemas) {
const val = customFields[schema.key];
if (schema.required && (val === undefined || val === null || val === "")) {
return {
ok: false,
error: `Custom field '${schema.key}' is required`,
status: 400,
};
}
if (val !== undefined && val !== null) {
if (
schema.fieldType === "number" &&
typeof val !== "number" &&
isNaN(Number(val))
) {
return {
ok: false,
error: `Custom field '${schema.key}' must be a number`,
status: 400,
};
}
if (
schema.fieldType === "bool" &&
typeof val !== "boolean" &&
val !== "true" &&
val !== "false"
) {
return {
ok: false,
error: `Custom field '${schema.key}' must be a boolean`,
status: 400,
};
}
}
}
return { ok: true };
}
export { getAppDek };
export async function createContact(
appId: string,
data: {
@ -52,11 +87,19 @@ export async function createContact(
pushName?: string;
},
dek?: Buffer,
): Promise<{ ok: true; contact: Contact } | { ok: false; error: string; status: number }> {
): Promise<
| { ok: true; contact: Contact; created: boolean }
| { ok: false; error: string; status: number }
> {
if (!data.phone || !data.phone.trim()) {
return { ok: false, error: "phone is required", status: 400 };
}
if (data.customFields) {
const validation = await validateCustomFields(appId, data.customFields);
if (!validation.ok) return validation;
}
const resolvedDek = dek ?? (await getAppDek(appId));
const encrypted = encryptContact(data, resolvedDek);
@ -81,12 +124,8 @@ export async function createContact(
set: {
phoneEnc: encrypted.phoneEnc,
// Keep existing values when incoming data is null (don't wipe user-set fields)
nameEnc: encrypted.nameEnc
? encrypted.nameEnc
: sql`name_enc`,
emailEnc: encrypted.emailEnc
? encrypted.emailEnc
: sql`email_enc`,
nameEnc: encrypted.nameEnc ? encrypted.nameEnc : sql`name_enc`,
emailEnc: encrypted.emailEnc ? encrypted.emailEnc : sql`email_enc`,
customFieldsEnc: encrypted.customFieldsEnc
? encrypted.customFieldsEnc
: sql`custom_fields_enc`,
@ -97,7 +136,13 @@ export async function createContact(
})
.returning();
return { ok: true, contact };
const created = contact.createdAt.getTime() === contact.updatedAt.getTime();
dispatchEvent(appId, created ? "contact.created" : "contact.updated", {
contactId: contact.id,
source: contact.source,
}).catch(() => {});
return { ok: true, contact, created };
}
async function getTagsForContacts(
@ -143,6 +188,26 @@ export async function findContact(
};
}
function decryptRow(row: Contact, dek: Buffer): DecryptedContact {
let decrypted: ReturnType<typeof decryptContact>;
try {
decrypted = decryptContact(row, dek);
} catch {
decrypted = { phone: "", name: null, email: null, customFields: null };
}
return {
id: row.id,
appId: row.appId,
...decrypted,
jid: row.jid,
pushName: row.pushName,
source: row.source,
tags: [] as string[],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function listContacts(
appId: string,
opts: {
@ -155,116 +220,109 @@ export async function listContacts(
): Promise<{ contacts: DecryptedContact[]; total: number }> {
const page = Math.max(1, opts.page ?? 1);
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
const offset = (page - 1) * limit;
// Build base query with optional tag/group joins
let rows: Contact[];
const dek = await getAppDek(appId);
// Phone search: use blind index — O(1) DB lookup
if (opts.q && /^[+\d]/.test(opts.q)) {
const blind = phoneBlindIndex(opts.q, dek);
const rows = await db.query.contacts.findMany({
where: and(
eq(contacts.appId, appId),
eq(contacts.phoneBlindIndex, blind),
),
});
const decryptedRows = rows.map((r) => decryptRow(r, dek));
const paginated = decryptedRows.slice(offset, offset + limit);
const tagsMap = await getTagsForContacts(paginated.map((r) => r.id));
for (const r of paginated) r.tags = tagsMap.get(r.id) ?? [];
return { contacts: paginated, total: decryptedRows.length };
}
// Name/email search: encrypted, full-scan unavoidable
if (opts.q) {
const qLower = opts.q.toLowerCase();
const rows = await db.query.contacts.findMany({
where: eq(contacts.appId, appId),
});
const decryptedRows: DecryptedContact[] = [];
for (const row of rows) {
const d = decryptRow(row, dek);
if (
d.phone.toLowerCase().includes(qLower) ||
(d.name?.toLowerCase().includes(qLower) ?? false) ||
(d.email?.toLowerCase().includes(qLower) ?? false)
) {
decryptedRows.push(d);
}
}
const paginated = decryptedRows.slice(offset, offset + limit);
const tagsMap = await getTagsForContacts(paginated.map((r) => r.id));
for (const r of paginated) r.tags = tagsMap.get(r.id) ?? [];
return { contacts: paginated, total: decryptedRows.length };
}
// Tag filter
if (opts.tag) {
const taggedContactIds = (
const taggedIds = (
await db.query.contactTags.findMany({
where: and(
eq(contactTags.appId, appId),
eq(contactTags.tag, opts.tag),
),
where: and(eq(contactTags.appId, appId), eq(contactTags.tag, opts.tag)),
columns: { contactId: true },
})
).map((r) => r.contactId);
if (taggedIds.length === 0) return { contacts: [], total: 0 };
if (taggedContactIds.length === 0) return { contacts: [], total: 0 };
rows = await db.query.contacts.findMany({
where: and(
eq(contacts.appId, appId),
inArray(contacts.id, taggedContactIds),
),
const rows = await db.query.contacts.findMany({
where: and(eq(contacts.appId, appId), inArray(contacts.id, taggedIds)),
limit,
offset,
orderBy: (c, { desc }) => [desc(c.createdAt)],
});
} else if (opts.groupId) {
const decryptedRows = rows.map((r) => decryptRow(r, dek));
const tagsMap = await getTagsForContacts(decryptedRows.map((r) => r.id));
for (const r of decryptedRows) r.tags = tagsMap.get(r.id) ?? [];
return { contacts: decryptedRows, total: taggedIds.length };
}
// Group filter
if (opts.groupId) {
const memberIds = (
await db.query.contactGroupMembers.findMany({
where: eq(contactGroupMembers.groupId, opts.groupId),
columns: { contactId: true },
})
).map((r) => r.contactId);
if (memberIds.length === 0) return { contacts: [], total: 0 };
rows = await db.query.contacts.findMany({
where: and(
eq(contacts.appId, appId),
inArray(contacts.id, memberIds),
),
});
} else {
rows = await db.query.contacts.findMany({
where: eq(contacts.appId, appId),
const rows = await db.query.contacts.findMany({
where: and(eq(contacts.appId, appId), inArray(contacts.id, memberIds)),
limit,
offset,
orderBy: (c, { desc }) => [desc(c.createdAt)],
});
const decryptedRows = rows.map((r) => decryptRow(r, dek));
const tagsMap = await getTagsForContacts(decryptedRows.map((r) => r.id));
for (const r of decryptedRows) r.tags = tagsMap.get(r.id) ?? [];
return { contacts: decryptedRows, total: memberIds.length };
}
const dek = await getAppDek(appId);
// No filter: push pagination to DB
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(contacts)
.where(eq(contacts.appId, appId));
// Decrypt all
let decryptedRows: DecryptedContact[] = rows.map((row) => {
let decrypted: ReturnType<typeof decryptContact>;
try {
decrypted = decryptContact(row, dek);
} catch {
decrypted = { phone: "", name: null, email: null, customFields: null };
}
return {
id: row.id,
appId: row.appId,
...decrypted,
jid: row.jid,
pushName: row.pushName,
source: row.source,
tags: [] as string[],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
const rows = await db.query.contacts.findMany({
where: eq(contacts.appId, appId),
limit,
offset,
orderBy: (c, { desc }) => [desc(c.createdAt)],
});
// Client-side filter by q
if (opts.q) {
const qLower = opts.q.toLowerCase();
const isPhone = /^[+\d]/.test(opts.q);
if (isPhone) {
try {
const blind = phoneBlindIndex(opts.q, dek);
decryptedRows = decryptedRows.filter(
(r) => {
const row = rows.find((raw) => raw.id === r.id);
return row?.phoneBlindIndex === blind;
},
);
} catch {
decryptedRows = decryptedRows.filter(
(r) =>
r.phone.toLowerCase().includes(qLower) ||
(r.name?.toLowerCase().includes(qLower) ?? false),
);
}
} else {
decryptedRows = decryptedRows.filter(
(r) =>
r.phone.toLowerCase().includes(qLower) ||
(r.name?.toLowerCase().includes(qLower) ?? false) ||
(r.email?.toLowerCase().includes(qLower) ?? false),
);
}
}
const total = decryptedRows.length;
// Paginate
const paginated = decryptedRows.slice((page - 1) * limit, page * limit);
// Load tags for paginated rows
const tagsMap = await getTagsForContacts(paginated.map((r) => r.id));
for (const r of paginated) {
r.tags = tagsMap.get(r.id) ?? [];
}
return { contacts: paginated, total };
const decryptedRows = rows.map((r) => decryptRow(r, dek));
const tagsMap = await getTagsForContacts(decryptedRows.map((r) => r.id));
for (const r of decryptedRows) r.tags = tagsMap.get(r.id) ?? [];
return { contacts: decryptedRows, total: count };
}
export async function updateContact(
@ -279,12 +337,19 @@ export async function updateContact(
pushName?: string | null;
},
dek?: Buffer,
): Promise<{ ok: true; contact: Contact } | { ok: false; error: string; status: number }> {
): Promise<
{ ok: true; contact: Contact } | { ok: false; error: string; status: number }
> {
const existing = await db.query.contacts.findFirst({
where: and(eq(contacts.id, contactId), eq(contacts.appId, appId)),
});
if (!existing) return { ok: false, error: "Contact not found", status: 404 };
if (data.customFields) {
const validation = await validateCustomFields(appId, data.customFields);
if (!validation.ok) return validation;
}
const resolvedDek = dek ?? (await getAppDek(appId));
const updates: Partial<Contact> = { updatedAt: new Date() };
@ -295,7 +360,10 @@ export async function updateContact(
}
if (data.name !== undefined) {
updates.nameEnc =
data.name !== null ? encryptContact({ phone: "", name: data.name }, resolvedDek).nameEnc ?? null : null;
data.name !== null
? (encryptContact({ phone: "", name: data.name }, resolvedDek)
.nameEnc ?? null)
: null;
}
if (data.email !== undefined) {
const { encryptColumn } = await import("#/lib/crypto");
@ -318,6 +386,8 @@ export async function updateContact(
.where(eq(contacts.id, contactId))
.returning();
dispatchEvent(appId, "contact.updated", { contactId }).catch(() => {});
return { ok: true, contact: updated };
}
@ -335,19 +405,28 @@ export async function deleteContact(
return { ok: true };
}
const DEBUG_CONTACTS = process.env.DEBUG?.includes("baileys");
export async function upsertContactFromWhatsApp(
appId: string,
jid: string,
pushName?: string,
dek?: Buffer,
): Promise<{ ok: true; contact: Contact } | { ok: false; error: string; status: number }> {
): Promise<
| { ok: true; contact: Contact; created: boolean }
| { ok: false; error: string; status: number }
> {
const phone = jid.split("@")[0];
if (!phone || !/^\d+$/.test(phone)) {
console.log(`[contacts] upsertFromWA skip non-user jid="${jid}"`);
if (DEBUG_CONTACTS)
console.log(`[contacts] upsertFromWA skip non-user jid="${jid}"`);
return { ok: false, error: `Non-user JID: ${jid}`, status: 400 };
}
console.log(`[contacts] upsertFromWA jid="${jid}" pushName="${pushName ?? ""}"`);
if (DEBUG_CONTACTS)
console.log(
`[contacts] upsertFromWA jid="${jid}" pushName="${pushName ?? ""}"`,
);
const result = await createContact(
appId,
{
@ -359,10 +438,12 @@ export async function upsertContactFromWhatsApp(
},
dek,
);
if (result.ok) {
console.log(`[contacts] saved id=${result.contact.id} phone=${phone}`);
} else {
console.log(`[contacts] save failed: ${result.error}`);
if (DEBUG_CONTACTS) {
if (result.ok) {
console.log(`[contacts] saved id=${result.contact.id} phone=${phone}`);
} else {
console.log(`[contacts] save failed: ${result.error}`);
}
}
return result;
}

View file

@ -25,8 +25,10 @@ export async function createCustomFieldSchema(
| { ok: true; schema: CustomFieldSchema }
| { ok: false; error: string; status: number }
> {
if (!data.key || !data.key.trim()) return { ok: false, error: "key is required", status: 400 };
if (!data.label || !data.label.trim()) return { ok: false, error: "label is required", status: 400 };
if (!data.key || !data.key.trim())
return { ok: false, error: "key is required", status: 400 };
if (!data.label || !data.label.trim())
return { ok: false, error: "label is required", status: 400 };
const [schema] = await db
.insert(customFieldSchemas)
@ -63,7 +65,10 @@ export async function updateCustomFieldSchema(
| { ok: false; error: string; status: number }
> {
const existing = await db.query.customFieldSchemas.findFirst({
where: and(eq(customFieldSchemas.id, id), eq(customFieldSchemas.appId, appId)),
where: and(
eq(customFieldSchemas.id, id),
eq(customFieldSchemas.appId, appId),
),
});
if (!existing) return { ok: false, error: "Schema not found", status: 404 };
@ -86,7 +91,10 @@ export async function deleteCustomFieldSchema(
appId: string,
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const existing = await db.query.customFieldSchemas.findFirst({
where: and(eq(customFieldSchemas.id, id), eq(customFieldSchemas.appId, appId)),
where: and(
eq(customFieldSchemas.id, id),
eq(customFieldSchemas.appId, appId),
),
columns: { id: true },
});
if (!existing) return { ok: false, error: "Schema not found", status: 404 };

View file

@ -107,7 +107,10 @@ export async function createSession(
type TxResult =
| { limited: true; max: number }
| { limited: false; session: { id: string; status: string; createdAt: Date } };
| {
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
@ -145,7 +148,8 @@ export async function createSession(
};
}
if (via === "qr") startSession(txResult.session.id, appId).catch(console.error);
if (via === "qr")
startSession(txResult.session.id, appId).catch(console.error);
return { ok: true, session: txResult.session };
}

303
src/lib/actions/webhooks.ts Normal file
View file

@ -0,0 +1,303 @@
import { and, desc, eq, notInArray, sql } from "drizzle-orm";
import { db } from "#/db/index";
import { webhookEndpoints, webhookDeliveries } from "#/db/schema";
import { encryptColumn, decryptColumn } from "#/lib/crypto";
import { getAppDek } from "#/lib/dek-helper";
import { randomBytes } from "node:crypto";
type WebhookEndpoint = typeof webhookEndpoints.$inferSelect;
type WebhookDelivery = typeof webhookDeliveries.$inferSelect;
const WEBHOOK_LOG_RETAIN = Number(process.env.WEBHOOK_LOG_RETAIN ?? 100);
const BLOCKED_HOSTNAMES =
/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+)$/i;
function validateWebhookUrl(
raw: string,
): { ok: true } | { ok: false; error: string } {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return { ok: false, error: "Invalid URL" };
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return { ok: false, error: "URL must use http or https" };
}
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
return { ok: false, error: "URL must use https in production" };
}
return { ok: true };
}
function generateSecret(): string {
return randomBytes(32).toString("hex");
}
export interface WebhookEndpointPublic {
id: string;
appId: string;
url: string;
events: string[];
enabled: boolean;
unhealthySince: string | null;
consecutiveFailures: number;
createdAt: string;
}
function toPublic(row: WebhookEndpoint): WebhookEndpointPublic {
return {
id: row.id,
appId: row.appId,
url: row.url,
events: row.events,
enabled: row.enabled,
unhealthySince: row.unhealthySince?.toISOString() ?? null,
consecutiveFailures: row.consecutiveFailures,
createdAt: row.createdAt.toISOString(),
};
}
export async function listWebhookEndpoints(
appId: string,
): Promise<WebhookEndpointPublic[]> {
const rows = await db.query.webhookEndpoints.findMany({
where: eq(webhookEndpoints.appId, appId),
orderBy: (e, { asc }) => [asc(e.createdAt)],
});
return rows.map(toPublic);
}
export async function createWebhookEndpoint(
appId: string,
url: string,
events: string[],
secretInput?: string,
): Promise<
| { ok: true; endpoint: WebhookEndpointPublic; secret: string }
| { ok: false; error: string; status: number }
> {
const urlCheck = validateWebhookUrl(url);
if (!urlCheck.ok) return { ok: false, error: urlCheck.error, status: 400 };
const dek = await getAppDek(appId);
const secret = secretInput ?? generateSecret();
const secretEncrypted = encryptColumn(secret, dek);
const [row] = await db
.insert(webhookEndpoints)
.values({ appId, url, events, secretEncrypted })
.returning();
return { ok: true, endpoint: toPublic(row), secret };
}
export async function findWebhookEndpoint(
endpointId: string,
appId: string,
): Promise<WebhookEndpoint | null> {
return (
(await db.query.webhookEndpoints.findFirst({
where: and(
eq(webhookEndpoints.id, endpointId),
eq(webhookEndpoints.appId, appId),
),
})) ?? null
);
}
export async function updateWebhookEndpoint(
endpointId: string,
appId: string,
updates: {
url?: string;
events?: string[];
enabled?: boolean;
secret?: string;
},
): Promise<
| { ok: true; endpoint: WebhookEndpointPublic }
| { ok: false; error: string; status: number }
> {
const existing = await findWebhookEndpoint(endpointId, appId);
if (!existing) return { ok: false, error: "Endpoint not found", status: 404 };
const set: Partial<typeof webhookEndpoints.$inferInsert> = {};
if (updates.url !== undefined) {
const urlCheck = validateWebhookUrl(updates.url);
if (!urlCheck.ok) return { ok: false, error: urlCheck.error, status: 400 };
set.url = updates.url;
}
if (updates.events !== undefined) set.events = updates.events;
if (updates.enabled !== undefined) set.enabled = updates.enabled;
if (updates.secret !== undefined) {
const dek = await getAppDek(appId);
set.secretEncrypted = encryptColumn(updates.secret, dek);
}
const [updated] = await db
.update(webhookEndpoints)
.set(set)
.where(eq(webhookEndpoints.id, endpointId))
.returning();
return { ok: true, endpoint: toPublic(updated) };
}
export async function deleteWebhookEndpoint(
endpointId: string,
appId: string,
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const existing = await findWebhookEndpoint(endpointId, appId);
if (!existing) return { ok: false, error: "Endpoint not found", status: 404 };
await db.delete(webhookEndpoints).where(eq(webhookEndpoints.id, endpointId));
return { ok: true };
}
export async function getEndpointSecret(
endpointId: string,
appId: string,
): Promise<string | null> {
const row = await findWebhookEndpoint(endpointId, appId);
if (!row) return null;
const dek = await getAppDek(appId);
return decryptColumn(row.secretEncrypted, dek);
}
export async function listActiveEndpointsForEvent(
appId: string,
eventType: string,
): Promise<WebhookEndpoint[]> {
const rows = await db.query.webhookEndpoints.findMany({
where: and(
eq(webhookEndpoints.appId, appId),
eq(webhookEndpoints.enabled, true),
),
});
return rows.filter((e) => {
const events = e.events;
return (
events.length === 0 || events.includes(eventType) || events.includes("*")
);
});
}
export async function createDelivery(
endpointId: string,
appId: string,
eventType: string,
eventId: string,
payload: Record<string, unknown>,
): Promise<WebhookDelivery> {
const [row] = await db
.insert(webhookDeliveries)
.values({
endpointId,
appId,
eventType,
eventId,
payload,
status: "pending",
})
.returning();
return row;
}
export async function updateDeliverySuccess(
deliveryId: string,
httpStatus: number,
): Promise<void> {
await db
.update(webhookDeliveries)
.set({
status: "success",
httpStatus,
deliveredAt: new Date(),
})
.where(eq(webhookDeliveries.id, deliveryId));
const delivery = await db.query.webhookDeliveries.findFirst({
where: eq(webhookDeliveries.id, deliveryId),
columns: { endpointId: true },
});
if (delivery) {
await db
.update(webhookEndpoints)
.set({ consecutiveFailures: 0, unhealthySince: null })
.where(eq(webhookEndpoints.id, delivery.endpointId));
}
}
export async function updateDeliveryFailed(
deliveryId: string,
endpointId: string,
httpStatus: number | null,
attempt: number,
nextAttemptAt: Date | null,
): Promise<void> {
await db
.update(webhookDeliveries)
.set({
status: nextAttemptAt ? "pending" : "failed",
httpStatus: httpStatus ?? undefined,
attempt,
nextAttemptAt,
})
.where(eq(webhookDeliveries.id, deliveryId));
const [endpoint] = await db
.update(webhookEndpoints)
.set({
consecutiveFailures: sql`${webhookEndpoints.consecutiveFailures} + 1`,
})
.where(eq(webhookEndpoints.id, endpointId))
.returning({
id: webhookEndpoints.id,
consecutiveFailures: webhookEndpoints.consecutiveFailures,
unhealthySince: webhookEndpoints.unhealthySince,
});
if (endpoint.consecutiveFailures >= 50 && !endpoint.unhealthySince) {
await db
.update(webhookEndpoints)
.set({ unhealthySince: new Date(), enabled: false })
.where(eq(webhookEndpoints.id, endpoint.id));
}
}
export async function listDeliveries(
endpointId: string,
appId: string,
limit = 100,
): Promise<WebhookDelivery[]> {
return db.query.webhookDeliveries.findMany({
where: and(
eq(webhookDeliveries.endpointId, endpointId),
eq(webhookDeliveries.appId, appId),
),
orderBy: [desc(webhookDeliveries.createdAt)],
limit: Math.min(limit, 200),
});
}
export async function pruneDeliveries(endpointId: string): Promise<void> {
const keep = await db.query.webhookDeliveries.findMany({
where: eq(webhookDeliveries.endpointId, endpointId),
orderBy: [desc(webhookDeliveries.createdAt)],
limit: WEBHOOK_LOG_RETAIN,
columns: { id: true },
});
if (keep.length < WEBHOOK_LOG_RETAIN) return;
const keepIds = keep.map((r) => r.id);
await db
.delete(webhookDeliveries)
.where(
and(
eq(webhookDeliveries.endpointId, endpointId),
notInArray(webhookDeliveries.id, keepIds),
),
);
}

View file

@ -0,0 +1,90 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "#/db/index";
import { whatsappGroups } from "#/db/schema";
type WhatsappGroup = typeof whatsappGroups.$inferSelect;
export async function upsertWhatsappGroup(data: {
appId: string;
sessionId: string;
jid: string;
subject?: string | null;
description?: string | null;
participantCount?: number | null;
}): Promise<WhatsappGroup> {
const set: Partial<typeof whatsappGroups.$inferInsert> = {
updatedAt: new Date(),
};
if (data.subject !== undefined) set.subject = data.subject;
if (data.description !== undefined) set.description = data.description;
if (data.participantCount !== undefined) set.participantCount = data.participantCount;
const [row] = await db
.insert(whatsappGroups)
.values({
appId: data.appId,
sessionId: data.sessionId,
jid: data.jid,
subject: data.subject ?? null,
description: data.description ?? null,
participantCount: data.participantCount ?? null,
})
.onConflictDoUpdate({
target: [whatsappGroups.sessionId, whatsappGroups.jid],
set,
})
.returning();
return row;
}
export async function adjustParticipantCount(
sessionId: string,
jid: string,
delta: number,
): Promise<void> {
await db
.update(whatsappGroups)
.set({
participantCount: sql`GREATEST(0, COALESCE(${whatsappGroups.participantCount}, 0) + ${delta})`,
updatedAt: new Date(),
})
.where(
and(
eq(whatsappGroups.sessionId, sessionId),
eq(whatsappGroups.jid, jid),
),
);
}
export async function listWhatsappGroups(
appId: string,
sessionId?: string,
): Promise<WhatsappGroup[]> {
const conditions = [eq(whatsappGroups.appId, appId)];
if (sessionId) conditions.push(eq(whatsappGroups.sessionId, sessionId));
return db
.select()
.from(whatsappGroups)
.where(and(...conditions))
.orderBy(whatsappGroups.updatedAt);
}
export async function getWhatsappGroup(
jid: string,
sessionId: string,
appId: string,
): Promise<WhatsappGroup | null> {
const [row] = await db
.select()
.from(whatsappGroups)
.where(
and(
eq(whatsappGroups.jid, jid),
eq(whatsappGroups.sessionId, sessionId),
eq(whatsappGroups.appId, appId),
),
)
.limit(1);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return row ?? null;
}

View file

@ -83,13 +83,15 @@ export async function usePostgresAuthState(
}
for (const [type, ids] of toDelete) {
await db.delete(sessionKeys).where(
and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids),
),
);
await db
.delete(sessionKeys)
.where(
and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids),
),
);
}
if (toUpsert.length > 0) {

View file

@ -12,6 +12,10 @@ 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 {
upsertWhatsappGroup,
adjustParticipantCount,
} from "#/lib/actions/whatsapp-groups";
import { getStorageDriver } from "#/lib/storage";
import { usePostgresAuthState } from "./auth-state";
import { emitWebhook } from "./webhook";
@ -35,6 +39,7 @@ interface ReconnectEntry {
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
const DEBUG_BAILEYS = process.env.DEBUG?.includes("baileys");
// Store on global to survive Vite SSR module isolation / HMR reloads
declare global {
@ -123,14 +128,17 @@ export async function startSession(
// contacts.upsert fires BEFORE connection "open" — register here, not in connection.update
// In Baileys 7.x contact.id may be @lid format; use contact.phoneNumber for the real JID
sock.ev.on("contacts.upsert", async (contactList) => {
console.log(`[contacts.upsert] >>> FIRED count=${contactList.length}`);
if (contactList[0]) {
// Log raw shape of first contact so we see all available fields
console.log(`[contacts.upsert] sample[0] raw=`, JSON.stringify(contactList[0]));
if (DEBUG_BAILEYS) {
console.log(`[contacts.upsert] >>> FIRED count=${contactList.length}`);
if (contactList[0]) {
console.log(
`[contacts.upsert] sample[0] raw=`,
JSON.stringify(contactList[0]),
);
}
}
const { upsertContactFromWhatsApp } = await import(
"#/lib/actions/contacts"
);
const { upsertContactFromWhatsApp } =
await import("#/lib/actions/contacts");
for (const contact of contactList) {
const jid =
(contact as { phoneNumber?: string }).phoneNumber ??
@ -144,9 +152,14 @@ export async function startSession(
});
sock.ev.on("contacts.update", (updates) => {
console.log(`[contacts.update] >>> FIRED count=${updates.length}`);
if (updates[0]) {
console.log(`[contacts.update] sample[0] raw=`, JSON.stringify(updates[0]));
if (DEBUG_BAILEYS) {
console.log(`[contacts.update] >>> FIRED count=${updates.length}`);
if (updates[0]) {
console.log(
`[contacts.update] sample[0] raw=`,
JSON.stringify(updates[0]),
);
}
}
});
@ -170,9 +183,11 @@ export async function startSession(
// Upsert contact on every message — reliable fallback for when contacts.upsert misses
if (jid.endsWith("@s.whatsapp.net")) {
const pushName =
(msg as { pushName?: string }).pushName ?? undefined;
console.log(`[contacts] msg-fallback upsert jid="${jid}" pushName="${pushName ?? ""}"`);
const pushName = (msg as { pushName?: string }).pushName ?? undefined;
if (DEBUG_BAILEYS)
console.log(
`[contacts] msg-fallback upsert jid="${jid}" pushName="${pushName ?? ""}"`,
);
import("#/lib/actions/contacts")
.then(({ upsertContactFromWhatsApp }) =>
upsertContactFromWhatsApp(appId, jid, pushName, dek),
@ -322,6 +337,107 @@ export async function startSession(
}
});
sock.ev.on("groups.upsert", async (groups) => {
for (const group of groups) {
await upsertWhatsappGroup({
appId,
sessionId,
jid: group.id,
subject: group.subject,
description: group.desc,
participantCount: group.participants.length,
}).catch(console.error);
emitWebhook(appId, "group.joined", {
sessionId,
jid: group.id,
subject: group.subject,
description: group.desc ?? null,
participantCount: group.participants.length,
});
}
});
sock.ev.on("groups.update", async (updates) => {
for (const update of updates) {
if (!update.id) continue;
await upsertWhatsappGroup({
appId,
sessionId,
jid: update.id,
subject: update.subject,
description: update.desc,
}).catch(console.error);
emitWebhook(appId, "group.updated", {
sessionId,
jid: update.id,
subject: update.subject ?? null,
description: update.desc ?? null,
});
}
});
sock.ev.on("group-participants.update", async ({ id, participants, action }) => {
const ownJid = state.creds.me?.id;
const jidUser = (j: string) => j.split(":")[0].split("@")[0];
const ownRemoved = action === "remove" && ownJid &&
participants.some((p) => p === ownJid || jidUser(p) === jidUser(ownJid));
const ownAdded = action === "add" && ownJid &&
participants.some((p) => p === ownJid || jidUser(p) === jidUser(ownJid));
if (ownAdded) {
try {
const meta = await sock.groupMetadata(id);
await upsertWhatsappGroup({
appId,
sessionId,
jid: id,
subject: meta.subject,
description: meta.desc,
participantCount: meta.participants.length,
});
emitWebhook(appId, "group.joined", {
sessionId,
jid: id,
subject: meta.subject,
description: meta.desc,
participantCount: meta.participants.length,
});
} catch {
await adjustParticipantCount(sessionId, id, participants.length).catch(console.error);
emitWebhook(appId, "group.joined", { sessionId, jid: id });
}
} else if (ownRemoved) {
await adjustParticipantCount(sessionId, id, -participants.length).catch(console.error);
emitWebhook(appId, "group.left", { sessionId, jid: id, participants });
} else if (action === "add") {
await adjustParticipantCount(sessionId, id, participants.length).catch(console.error);
emitWebhook(appId, "group.participants_added", { sessionId, jid: id, participants });
} else if (action === "remove") {
await adjustParticipantCount(sessionId, id, -participants.length).catch(console.error);
emitWebhook(appId, "group.participants_removed", { sessionId, jid: id, participants });
}
});
// History sync on reconnect — captures all current groups
sock.ev.on("messaging-history.set", async ({ chats }) => {
const groupChats = chats.filter((c) => c.id?.endsWith("@g.us"));
for (const chat of groupChats) {
try {
const meta = await sock.groupMetadata(chat.id);
await upsertWhatsappGroup({
appId,
sessionId,
jid: chat.id,
subject: meta.subject,
description: meta.desc,
participantCount: meta.participants.length,
});
} catch {
await upsertWhatsappGroup({ appId, sessionId, jid: chat.id }).catch(console.error);
}
}
});
sock.ev.on("connection.update", async (update) => {
const { connection, lastDisconnect, qr } = update;

View file

@ -1,8 +1,11 @@
// Webhook stub — replaced by full dispatcher in M5
import { dispatchEvent } from "#/lib/events/dispatch";
export function emitWebhook(
appId: string,
event: string,
payload: Record<string, unknown>,
): void {
console.log("[webhook]", appId, event, JSON.stringify(payload));
dispatchEvent(appId, event, payload).catch((err) =>
console.error("[webhook] dispatch failed", event, err),
);
}

View file

@ -7,9 +7,7 @@ export function normalizePhone(phone: string): string {
}
export function phoneBlindIndex(phone: string, dek: Buffer): string {
return createHmac("sha256", dek)
.update(normalizePhone(phone))
.digest("hex");
return createHmac("sha256", dek).update(normalizePhone(phone)).digest("hex");
}
export function encryptContact(

View file

@ -7,16 +7,27 @@ import { upsertContactFromWhatsApp, getAppDek } from "#/lib/actions/contacts";
export async function manualSyncContactsFromWhatsApp(
sessionId: string,
appId: string,
): Promise<{ ok: true; fromMessages: number } | { ok: false; error: string; status: number }> {
): Promise<
| { ok: true; fromMessages: number }
| { ok: false; error: string; status: number }
> {
console.log(`[sync] manualSync called sessionId=${sessionId} appId=${appId}`);
// --- Strategy 1: resyncAppState (works if WA app state has contact patches) ---
const socket = getSocket(sessionId);
if (socket) {
console.log(`[sync] socket found, calling resyncAppState (isInitialSync=true)...`);
console.log(
`[sync] socket found, calling resyncAppState (isInitialSync=true)...`,
);
try {
await socket.resyncAppState(
["critical_block", "critical_unblock_low", "regular_high", "regular_low", "regular"],
[
"critical_block",
"critical_unblock_low",
"regular_high",
"regular_low",
"regular",
],
true,
);
console.log(`[sync] resyncAppState completed`);
@ -54,7 +65,12 @@ export async function manualSyncContactsFromWhatsApp(
console.log(`[sync] found ${jids.size} unique JIDs in message history`);
let saved = 0;
for (const jid of jids) {
const result = await upsertContactFromWhatsApp(appId, jid, undefined, dek).catch(() => null);
const result = await upsertContactFromWhatsApp(
appId,
jid,
undefined,
dek,
).catch(() => null);
if (result?.ok) saved++;
}

View file

@ -4,6 +4,15 @@ import { apps } from "#/db/schema";
import { unwrapDek } from "#/lib/crypto";
import { getAppDekForSession } from "#/lib/baileys";
export 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);
}
export async function getSessionDek(
sessionId: string,
appId: string,

View file

@ -0,0 +1,55 @@
import { v4 as uuidv4 } from "uuid";
import { createQueue, QUEUE_WEBHOOK } from "#/lib/queue";
import {
listActiveEndpointsForEvent,
createDelivery,
} from "#/lib/actions/webhooks";
import type { WebhookDeliveryJob } from "#/lib/queue/webhook-worker";
export async function dispatchEvent(
appId: string,
eventType: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
const endpoints = await listActiveEndpointsForEvent(appId, eventType);
if (endpoints.length === 0) return;
const eventId = uuidv4();
const enrichedPayload = {
...payload,
event: eventType,
event_id: eventId,
app_id: appId,
timestamp: new Date().toISOString(),
};
const queue = createQueue(QUEUE_WEBHOOK);
try {
for (const endpoint of endpoints) {
const delivery = await createDelivery(
endpoint.id,
appId,
eventType,
eventId,
enrichedPayload,
);
const job: WebhookDeliveryJob = {
deliveryId: delivery.id,
endpointId: endpoint.id,
appId,
url: endpoint.url,
eventType,
eventId,
payload: enrichedPayload,
attempt: 0,
};
await queue.add("deliver", job);
}
} finally {
await queue.close();
}
} catch (err) {
console.error("[dispatch] failed to dispatch event", eventType, err);
}
}

1271
src/lib/openapi.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -106,7 +106,20 @@ export const customFieldSchemasQuery = (appId: string) =>
export const contactGroupsAdminQuery = (appId: string) =>
queryOptions({
queryKey: ["contact-groups", appId],
queryFn: () => apiFetch(`/api/v1/contact-groups?app_id=${appId}`),
queryFn: () =>
apiFetch(
`/api/v1/admin/contact-groups?app_id=${encodeURIComponent(appId)}`,
),
});
export const contactGroupMembersQuery = (appId: string, groupId: string) =>
queryOptions({
queryKey: ["contact-groups", appId, groupId, "members"],
queryFn: () =>
apiFetch(
`/api/v1/admin/contacts?app_id=${encodeURIComponent(appId)}&group_id=${encodeURIComponent(groupId)}&limit=200`,
),
staleTime: 10_000,
});
export const appSessionsQuery = (appId: string) =>
@ -117,3 +130,38 @@ export const appSessionsQuery = (appId: string) =>
return data.filter((s) => s.appId === appId);
},
});
export const webhooksQuery = (appId: string) =>
queryOptions({
queryKey: ["webhooks", appId],
queryFn: () =>
apiFetch(`/api/v1/admin/webhooks?app_id=${encodeURIComponent(appId)}`),
staleTime: 15_000,
});
export const webhookDeliveriesQuery = (appId: string, webhookId: string) =>
queryOptions({
queryKey: ["webhooks", appId, webhookId, "deliveries"],
queryFn: () =>
apiFetch(
`/api/v1/admin/webhooks/${webhookId}/deliveries?app_id=${encodeURIComponent(appId)}&limit=100`,
),
staleTime: 10_000,
});
export const whatsappGroupsQuery = (appId: string, sessionId?: string) => {
const params = new URLSearchParams({ app_id: appId });
if (sessionId) params.set("session_id", sessionId);
return queryOptions({
queryKey: ["whatsapp-groups", appId, sessionId],
queryFn: () =>
apiFetch(`/api/v1/admin/whatsapp-groups?${params.toString()}`),
staleTime: 15_000,
});
};
export const monitoringStatsQuery = queryOptions({
queryKey: ["monitoring", "stats"],
queryFn: () => apiFetch("/api/v1/admin/monitoring/stats"),
staleTime: 30_000,
});

View file

@ -81,11 +81,16 @@ function buildWaContent(
return { url: content.mediaUrl, caption: content.caption };
}
const _g = globalThis as { __wahooBroadcastWorker?: ReturnType<typeof createWorker> };
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 = createWorker(
QUEUE_BROADCAST,
processBroadcast as Processor,
);
_g.__wahooBroadcastWorker.on("failed", (job, err) => {
console.error("[broadcast-worker] job failed", job?.id, err.message);
});

View file

@ -12,7 +12,10 @@ async function processCleanup(): Promise<void> {
);
}
const _g = globalThis as { __wahooCleanupWorker?: Worker; __wahooCleanupQueue?: Queue };
const _g = globalThis as {
__wahooCleanupWorker?: Worker;
__wahooCleanupQueue?: Queue;
};
export async function startCleanupWorker(): Promise<void> {
if (_g.__wahooCleanupWorker) return;

View file

@ -78,11 +78,7 @@ async function processImport(job: Job<ImportJob>): Promise<ImportResult> {
);
if (result.ok) {
// Determine if created or updated based on timestamps
const c = result.contact;
const isNew =
Math.abs(c.createdAt.getTime() - c.updatedAt.getTime()) < 1000;
if (isNew) {
if (result.created) {
created++;
} else {
updated++;

View file

@ -16,6 +16,8 @@ export const QUEUE_SEND = "msg-send";
export const QUEUE_BROADCAST = "msg-broadcast";
export const QUEUE_CLEANUP = "msg-cleanup";
export const QUEUE_CONTACT_IMPORT = "contact-import";
export const QUEUE_WEBHOOK = "webhook-delivery";
export const QUEUE_KEY_ROTATION = "key-rotation";
export function createQueue(name: string): Queue {
return new Queue(name, { connection: getRedisConnection() });

View file

@ -0,0 +1,153 @@
import type { Worker } from "bullmq";
import { eq } from "drizzle-orm";
import { createWorker, QUEUE_KEY_ROTATION } from "./index";
import { db } from "#/db/index";
import {
apps,
contacts,
sessions,
messages,
webhookEndpoints,
} from "#/db/schema";
import { unwrapDek, encryptColumn, decryptColumn } from "#/lib/crypto";
import { phoneBlindIndex } from "#/lib/contacts-crypto";
import { writeAudit } from "#/lib/audit";
export interface KeyRotationJob {
appId: string;
newDekEncrypted: string;
}
const _g = globalThis as { __wahooRotationWorker?: Worker };
export function startRotationWorker(): Worker {
if (_g.__wahooRotationWorker) return _g.__wahooRotationWorker;
const worker = createWorker(QUEUE_KEY_ROTATION, async (job) => {
const { appId, newDekEncrypted } = job.data as KeyRotationJob;
await db
.update(apps)
.set({ rotationStatus: "running" })
.where(eq(apps.id, appId));
try {
const appRow = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true, ownerUserId: true },
});
if (!appRow) throw new Error(`App ${appId} not found`);
const oldDek = unwrapDek(appRow.dekEncrypted);
const newDek = unwrapDek(newDekEncrypted);
const contactRows = await db.query.contacts.findMany({
where: eq(contacts.appId, appId),
columns: {
id: true,
phoneEnc: true,
nameEnc: true,
emailEnc: true,
customFieldsEnc: true,
},
});
for (const row of contactRows) {
const phone = decryptColumn(row.phoneEnc, oldDek);
const updates: Record<string, string | null> = {
phoneEnc: encryptColumn(phone, newDek),
phoneBlindIndex: phoneBlindIndex(phone, newDek),
};
if (row.nameEnc) {
updates.nameEnc = encryptColumn(
decryptColumn(row.nameEnc, oldDek),
newDek,
);
}
if (row.emailEnc) {
updates.emailEnc = encryptColumn(
decryptColumn(row.emailEnc, oldDek),
newDek,
);
}
if (row.customFieldsEnc) {
updates.customFieldsEnc = encryptColumn(
decryptColumn(row.customFieldsEnc, oldDek),
newDek,
);
}
await db.update(contacts).set(updates).where(eq(contacts.id, row.id));
}
const sessionRows = await db.query.sessions.findMany({
where: eq(sessions.appId, appId),
columns: { id: true, credentialsEncrypted: true },
});
for (const row of sessionRows) {
if (!row.credentialsEncrypted) continue;
const plain = decryptColumn(row.credentialsEncrypted, oldDek);
await db
.update(sessions)
.set({ credentialsEncrypted: encryptColumn(plain, newDek) })
.where(eq(sessions.id, row.id));
}
if (process.env.ENCRYPT_MESSAGE_BODY === "true") {
const messageRows = await db.query.messages.findMany({
where: eq(messages.appId, appId),
columns: { id: true, bodyEncrypted: true },
});
for (const row of messageRows) {
if (!row.bodyEncrypted) continue;
const plain = decryptColumn(row.bodyEncrypted, oldDek);
await db
.update(messages)
.set({ bodyEncrypted: encryptColumn(plain, newDek) })
.where(eq(messages.id, row.id));
}
}
const webhookRows = await db.query.webhookEndpoints.findMany({
where: eq(webhookEndpoints.appId, appId),
columns: { id: true, secretEncrypted: true },
});
for (const row of webhookRows) {
const plain = decryptColumn(row.secretEncrypted, oldDek);
await db
.update(webhookEndpoints)
.set({ secretEncrypted: encryptColumn(plain, newDek) })
.where(eq(webhookEndpoints.id, row.id));
}
await db
.update(apps)
.set({
dekEncrypted: newDekEncrypted,
rotationStatus: "done",
rotationCompletedAt: new Date(),
})
.where(eq(apps.id, appId));
await writeAudit({
actorType: "user",
actorId: appRow.ownerUserId,
action: "app.rotation.complete",
resourceType: "app",
resourceId: appId,
});
} catch (err) {
console.error("[rotation-worker] rotation failed for app", appId, err);
await db
.update(apps)
.set({ rotationStatus: "failed" })
.where(eq(apps.id, appId));
throw err;
}
});
_g.__wahooRotationWorker = worker;
return worker;
}

View file

@ -26,9 +26,13 @@ function resolveSocket(sessionId: string): WASocket | null {
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 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}`);
console.log(
`[send-worker] resolveSocket(${sessionId}) → ${sock ? "found" : "null"} | map size: ${mapSize}`,
);
if (!sock) {
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
emitWebhook(appId, "message.failed", {
@ -61,7 +65,9 @@ async function processSend(job: Job<SendJob>): Promise<void> {
}
}
const _g = globalThis as { __wahooSendWorker?: ReturnType<typeof createWorker> };
const _g = globalThis as {
__wahooSendWorker?: ReturnType<typeof createWorker>;
};
export function startSendWorker(): void {
if (_g.__wahooSendWorker) return;

View file

@ -0,0 +1,121 @@
import { createHmac } from "node:crypto";
import { createQueue, createWorker, QUEUE_WEBHOOK } from "./index";
import type { Worker } from "bullmq";
import {
updateDeliverySuccess,
updateDeliveryFailed,
pruneDeliveries,
getEndpointSecret,
} from "#/lib/actions/webhooks";
// Retry delays in ms: 0s, 30s, 2m, 10m, 1h, 6h, 24h
const RETRY_DELAYS = [
0, 30_000, 120_000, 600_000, 3_600_000, 21_600_000, 86_400_000,
];
export interface WebhookDeliveryJob {
deliveryId: string;
endpointId: string;
appId: string;
url: string;
eventType: string;
eventId: string;
payload: Record<string, unknown>;
attempt: number;
}
function sign(body: string, timestamp: string, secret: string): string {
const mac = createHmac("sha256", secret)
.update(`${body}.${timestamp}`)
.digest("hex");
return `sha256=${mac}`;
}
export function startWebhookWorker(): Worker {
const _g = globalThis as { __wahooWebhookWorker?: Worker };
if (_g.__wahooWebhookWorker) return _g.__wahooWebhookWorker;
const worker = createWorker(QUEUE_WEBHOOK, async (job) => {
const data = job.data as WebhookDeliveryJob;
const {
deliveryId,
endpointId,
appId,
url,
eventType,
eventId,
payload,
attempt,
} = data;
const secret = await getEndpointSecret(endpointId, appId);
if (!secret) {
await updateDeliveryFailed(deliveryId, endpointId, null, attempt, null);
return;
}
const body = JSON.stringify(payload);
const timestamp = String(Date.now());
const signature = sign(body, timestamp, secret);
let httpStatus: number | null = null;
let success = false;
try {
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "wagateway/1.0",
"x-event-id": eventId,
"x-event-type": eventType,
"x-app-id": appId,
"x-signature": signature,
"x-timestamp": timestamp,
},
body,
signal: AbortSignal.timeout(15_000),
});
httpStatus = res.status;
success = res.ok;
} catch {
httpStatus = null;
}
if (success) {
await updateDeliverySuccess(deliveryId, httpStatus!);
await pruneDeliveries(endpointId);
return;
}
const nextAttemptIndex = attempt + 1;
const nextDelay = (RETRY_DELAYS as (number | undefined)[])[
nextAttemptIndex
];
const nextAttemptAt =
nextDelay !== undefined ? new Date(Date.now() + nextDelay) : null;
await updateDeliveryFailed(
deliveryId,
endpointId,
httpStatus,
attempt + 1,
nextAttemptAt,
);
if (nextAttemptAt) {
const queue = createQueue(QUEUE_WEBHOOK);
await queue.add(
"deliver",
{ ...data, attempt: attempt + 1 },
{ delay: nextDelay },
);
await queue.close();
}
await pruneDeliveries(endpointId);
});
_g.__wahooWebhookWorker = worker;
return worker;
}

View file

@ -1,7 +1,8 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand, GetObjectCommand
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { StorageDriver } from "./index";

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { RateLimiterRedis } from "rate-limiter-flexible";
import Redis from "ioredis";
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";
import { getRedisConnection } from "#/lib/queue";
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;
}
const loginLimiter = new RateLimiterRedis({
storeClient: new Redis(getRedisConnection()),
keyPrefix: "login_rl",
points: 10,
duration: 15 * 60,
blockDuration: 15 * 60,
});
export const Route = createFileRoute("/api/auth/login")({
server: {
@ -30,7 +25,9 @@ export const Route = createFileRoute("/api/auth/login")({
request.headers.get("x-real-ip") ??
"unknown";
if (!checkLoginRateLimit(ip)) {
try {
await loginLimiter.consume(ip);
} catch {
return jsonError("Too many login attempts", 429);
}

37
src/routes/api/docs.ts Normal file
View file

@ -0,0 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Wahoo API Docs</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" >
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"> </script>
<script>
SwaggerUIBundle({
url: "/api/openapi/json",
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout",
deepLinking: true,
tryItOutEnabled: true,
})
</script>
</body>
</html>`;
export const Route = createFileRoute("/api/docs")({
server: {
handlers: {
GET: () =>
new Response(html, {
status: 200,
headers: { "content-type": "text/html" },
}),
},
},
});

View file

@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { spec } from "#/lib/openapi";
export const Route = createFileRoute("/api/openapi/json")({
server: {
handlers: {
GET: () =>
new Response(JSON.stringify(spec), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
},
});

View file

@ -0,0 +1,33 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { startKeyRotation, getKeyRotationStatus } from "#/lib/actions/apps";
export const Route = createFileRoute(
"/api/v1/admin/apps/$id/rotate-encryption-key/",
)({
server: {
handlers: {
POST: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const result = await startKeyRotation(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(result, 202);
},
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const status = await getKeyRotationStatus(params.id, session.userId);
if (!status) return jsonError("Not found", 404);
return json(status);
},
},
},
});

View file

@ -4,9 +4,7 @@ import { findUserApp } from "#/lib/actions/apps";
import { listAppSessions } from "#/lib/actions/sessions";
import { manualSyncContactsFromWhatsApp } from "#/lib/contacts-sync";
export const Route = createFileRoute(
"/api/v1/admin/apps/$id/sync-contacts",
)({
export const Route = createFileRoute("/api/v1/admin/apps/$id/sync-contacts")({
server: {
handlers: {
POST: async ({ request, params }) => {

View file

@ -0,0 +1,54 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import {
findContactGroup,
updateContactGroup,
deleteContactGroup,
} from "#/lib/actions/contact-groups";
export const Route = createFileRoute("/api/v1/admin/contact-groups/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const group = await findContactGroup(params.id, appId);
if (!group) return jsonError("Group not found", 404);
return json(group);
},
PATCH: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const body = await request.json().catch(() => ({}));
const appId = body.app_id as string | undefined;
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const result = await updateContactGroup(params.id, appId, {
name: body.name,
description: body.description,
});
if (!result.ok) return jsonError(result.error, result.status);
return json(result.group);
},
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const result = await deleteContactGroup(params.id, appId);
if (!result.ok) return jsonError(result.error, result.status);
return new Response(null, { status: 204 });
},
},
},
});

View file

@ -0,0 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import {
findContactGroup,
removeGroupMember,
} from "#/lib/actions/contact-groups";
export const Route = createFileRoute(
"/api/v1/admin/contact-groups/$id/members/$contactId",
)({
server: {
handlers: {
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const group = await findContactGroup(params.id, appId);
if (!group) return jsonError("Group not found", 404);
await removeGroupMember(params.id, appId, params.contactId);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import {
findContactGroup,
addGroupMembers,
} from "#/lib/actions/contact-groups";
export const Route = createFileRoute(
"/api/v1/admin/contact-groups/$id/members/",
)({
server: {
handlers: {
POST: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const body = await request.json().catch(() => ({}));
const appId = body.app_id as string | undefined;
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const group = await findContactGroup(params.id, appId);
if (!group) return jsonError("Group not found", 404);
if (!Array.isArray(body.contact_ids) || body.contact_ids.length === 0) {
return jsonError("contact_ids array is required", 400);
}
const result = await addGroupMembers(
params.id,
appId,
body.contact_ids,
);
if (!result.ok) return jsonError(result.error, result.status);
return json({ added: result.added }, 201);
},
},
},
});

View file

@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import {
listContactGroups,
createContactGroup,
} from "#/lib/actions/contact-groups";
export const Route = createFileRoute("/api/v1/admin/contact-groups/")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
return json(await listContactGroups(appId));
},
POST: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const body = await request.json().catch(() => ({}));
const appId = body.app_id as string | undefined;
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
if (!body.name?.trim()) return jsonError("name is required", 400);
const group = await createContactGroup(
appId,
body.name,
body.description ?? undefined,
);
return json(group, 201);
},
},
},
});

View file

@ -6,57 +6,55 @@ import {
deleteCustomFieldSchema,
} from "#/lib/actions/custom-field-schemas";
export const Route = createFileRoute("/api/v1/admin/custom-field-schemas/$id")(
{
server: {
handlers: {
PATCH: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
export const Route = createFileRoute("/api/v1/admin/custom-field-schemas/$id")({
server: {
handlers: {
PATCH: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
let body: {
label?: string;
field_type?: "text" | "number" | "bool" | "date";
required?: boolean;
};
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
let body: {
label?: string;
field_type?: "text" | "number" | "bool" | "date";
required?: boolean;
};
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
const result = await updateCustomFieldSchema(params.id, appId, {
label: body.label,
fieldType: body.field_type,
required: body.required,
});
if (!result.ok) return jsonError(result.error, result.status);
return json(result.schema);
},
const result = await updateCustomFieldSchema(params.id, appId, {
label: body.label,
fieldType: body.field_type,
required: body.required,
});
if (!result.ok) return jsonError(result.error, result.status);
return json(result.schema);
},
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const result = await deleteCustomFieldSchema(params.id, appId);
if (!result.ok) return jsonError(result.error, result.status);
return new Response(null, { status: 204 });
},
const result = await deleteCustomFieldSchema(params.id, appId);
if (!result.ok) return jsonError(result.error, result.status);
return new Response(null, { status: 204 });
},
},
},
);
});

View file

@ -0,0 +1,94 @@
import { createFileRoute } from "@tanstack/react-router";
import { sql } from "drizzle-orm";
import { json, jsonError, readSession } from "#/lib/session-api";
import { db } from "#/db/index";
import { getRedisConnection } from "#/lib/queue";
import Redis from "ioredis";
export const Route = createFileRoute("/api/v1/admin/monitoring/stats")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const now = new Date();
const h24ago = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const h7dago = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const msgHourly = await db.execute(sql`
SELECT
date_trunc('hour', created_at) AS hour,
direction,
COUNT(*)::int AS count
FROM messages
WHERE created_at >= ${h24ago}
GROUP BY 1, 2
ORDER BY 1 ASC
`);
const msgDaily = await db.execute(sql`
SELECT
date_trunc('day', created_at) AS day,
direction,
COUNT(*)::int AS count
FROM messages
WHERE created_at >= ${h7dago}
GROUP BY 1, 2
ORDER BY 1 ASC
`);
const sessionCounts = await db.execute(sql`
SELECT status, COUNT(*)::int AS count
FROM sessions
GROUP BY status
`);
const webhookStats = await db.execute(sql`
SELECT
status,
COUNT(*)::int AS count,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (delivered_at - created_at)) * 1000) AS p50_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (delivered_at - created_at)) * 1000) AS p95_ms
FROM webhook_deliveries
WHERE created_at >= ${h24ago}
GROUP BY status
`);
const reconnectCounts = await db.execute(sql`
SELECT id, reconnect_count
FROM sessions
WHERE reconnect_count > 0
ORDER BY reconnect_count DESC
LIMIT 20
`);
let sendQueueDepth = 0;
let webhookQueueDepth = 0;
try {
const conn = getRedisConnection();
const redis = new Redis(conn as Parameters<typeof Redis>[0]);
sendQueueDepth = await redis.llen("bull:msg-send:wait");
webhookQueueDepth = await redis.llen("bull:webhook-delivery:wait");
await redis.quit();
} catch {
// Redis unavailable — return 0
}
return json({
messages: {
hourly: msgHourly.rows as unknown[],
daily: msgDaily.rows as unknown[],
},
sessions: sessionCounts.rows as unknown[],
webhooks: webhookStats.rows as unknown[],
reconnects: reconnectCounts.rows as unknown[],
queues: {
send: sendQueueDepth,
webhook: webhookQueueDepth,
},
});
},
},
},
});

View file

@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import { findWebhookEndpoint, listDeliveries } from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/admin/webhooks/$id/deliveries")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const endpoint = await findWebhookEndpoint(params.id, appId);
if (!endpoint) return jsonError("Not found", 404);
const limit = Math.min(
Number(url.searchParams.get("limit") ?? 100),
200,
);
const deliveries = await listDeliveries(params.id, appId, limit);
return json(
deliveries.map((d) => ({
id: d.id,
eventType: d.eventType,
eventId: d.eventId,
status: d.status,
httpStatus: d.httpStatus,
attempt: d.attempt,
nextAttemptAt: d.nextAttemptAt?.toISOString() ?? null,
deliveredAt: d.deliveredAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
})),
);
},
},
},
});

View file

@ -0,0 +1,65 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import {
findWebhookEndpoint,
updateWebhookEndpoint,
deleteWebhookEndpoint,
} from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/admin/webhooks/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const endpoint = await findWebhookEndpoint(params.id, appId);
if (!endpoint) return jsonError("Not found", 404);
return json({
id: endpoint.id,
appId: endpoint.appId,
url: endpoint.url,
events: endpoint.events,
enabled: endpoint.enabled,
unhealthySince: endpoint.unhealthySince?.toISOString() ?? null,
consecutiveFailures: endpoint.consecutiveFailures,
createdAt: endpoint.createdAt.toISOString(),
});
},
PATCH: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const body = await request.json().catch(() => ({}));
const appId = body.app_id as string | undefined;
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const result = await updateWebhookEndpoint(params.id, appId, {
url: body.url,
events: Array.isArray(body.events) ? body.events : undefined,
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
secret: body.secret ?? undefined,
});
if (!result.ok) return jsonError(result.error, result.status);
return json(result.endpoint);
},
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const result = await deleteWebhookEndpoint(params.id, appId);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,62 @@
import { createFileRoute } from "@tanstack/react-router";
import { createHmac } from "node:crypto";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import { findWebhookEndpoint, getEndpointSecret } from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/admin/webhooks/$id/test")({
server: {
handlers: {
POST: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const body = await request.json().catch(() => ({}));
const appId = body.app_id as string | undefined;
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
const endpoint = await findWebhookEndpoint(params.id, appId);
if (!endpoint) return jsonError("Not found", 404);
const secret = await getEndpointSecret(params.id, appId);
if (!secret) return jsonError("Secret unavailable", 503);
const payload = JSON.stringify({
event: "test",
event_id: crypto.randomUUID(),
app_id: appId,
timestamp: new Date().toISOString(),
message: "This is a test delivery from wagateway",
});
const timestamp = String(Date.now());
const signature = `sha256=${createHmac("sha256", secret).update(`${payload}.${timestamp}`).digest("hex")}`;
let httpStatus: number | null = null;
let ok = false;
let error: string | undefined;
try {
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "wagateway/1.0",
"x-event-id": crypto.randomUUID(),
"x-event-type": "test",
"x-app-id": appId,
"x-signature": signature,
"x-timestamp": timestamp,
},
body: payload,
signal: AbortSignal.timeout(10_000),
});
httpStatus = res.status;
ok = res.ok;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
return json({ ok, httpStatus, error: error ?? null });
},
},
},
});

View file

@ -0,0 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import {
listWebhookEndpoints,
createWebhookEndpoint,
} from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/admin/webhooks/")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
if (!appId) return jsonError("app_id is required", 400);
const app = await findUserApp(appId, session.userId);
if (!app) return jsonError("App not found", 404);
return json(await listWebhookEndpoints(appId));
},
POST: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const body = await request.json().catch(() => null);
if (!body?.app_id) return jsonError("app_id is required", 400);
const app = await findUserApp(body.app_id, session.userId);
if (!app) return jsonError("App not found", 404);
if (!body.url) return jsonError("url is required", 400);
if (!Array.isArray(body.events))
return jsonError("events must be an array", 400);
const result = await createWebhookEndpoint(
body.app_id,
body.url,
body.events,
body.secret ?? undefined,
);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ...result.endpoint, secret: result.secret }, 201);
},
},
},
});

View file

@ -0,0 +1,28 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { getWhatsappGroup } from "#/lib/actions/whatsapp-groups";
export const Route = createFileRoute("/api/v1/admin/whatsapp-groups/$jid/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
const sessionId = url.searchParams.get("session_id");
if (!appId) return jsonError("app_id required", 400);
if (!sessionId) return jsonError("session_id required", 400);
const group = await getWhatsappGroup(
decodeURIComponent(params.jid),
sessionId,
appId,
);
if (!group) return jsonError("Group not found", 404);
return json(group);
},
},
},
});

View file

@ -0,0 +1,33 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { listWhatsappGroups } from "#/lib/actions/whatsapp-groups";
import { db } from "#/db/index";
import { apps } from "#/db/schema";
import { eq } from "drizzle-orm";
export const Route = createFileRoute("/api/v1/admin/whatsapp-groups/")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
const sessionId = url.searchParams.get("session_id") ?? undefined;
if (!appId) return jsonError("app_id required", 400);
const appRows = await db
.select({ id: apps.id })
.from(apps)
.where(eq(apps.id, appId))
.limit(1);
if (!appRows.length) return jsonError("App not found", 404);
const groups = await listWhatsappGroups(appId, sessionId);
return json(groups);
},
},
},
});

View file

@ -1,7 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findContact, updateContact, deleteContact } from "#/lib/actions/contacts";
import {
findContact,
updateContact,
deleteContact,
} from "#/lib/actions/contacts";
export const Route = createFileRoute("/api/v1/contacts/$id/")({
server: {

View file

@ -19,18 +19,28 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
}
// Parse multipart
const MAX_CSV_BYTES = 10 * 1024 * 1024; // 10 MB
let csvContent = "";
let columnMapRaw = "";
let dryRunRaw = "false";
let csvTooLarge = false;
await new Promise<void>((resolve, reject) => {
const bb = Busboy({ headers: { "content-type": contentType } });
const bb = Busboy({
headers: { "content-type": contentType },
limits: { fileSize: MAX_CSV_BYTES },
});
bb.on("file", (_field, stream) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("limit", () => {
csvTooLarge = true;
stream.resume();
});
stream.on("end", () => {
csvContent = Buffer.concat(chunks).toString("utf8");
if (!csvTooLarge)
csvContent = Buffer.concat(chunks).toString("utf8");
});
stream.on("error", reject);
});
@ -52,6 +62,11 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
.catch(reject);
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (csvTooLarge) {
return jsonError("CSV file exceeds 10 MB limit", 413);
}
if (!csvContent.trim()) {
return jsonError("CSV file is required", 400);
}
@ -75,8 +90,12 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
});
const preview = parsed.data.slice(0, 10).map((row) => ({
phone: row[columnMap.phone] ?? "",
name: columnMap.name ? (row[columnMap.name] ?? undefined) : undefined,
email: columnMap.email ? (row[columnMap.email] ?? undefined) : undefined,
name: columnMap.name
? (row[columnMap.name] ?? undefined)
: undefined,
email: columnMap.email
? (row[columnMap.email] ?? undefined)
: undefined,
}));
return json({ dry_run: true, preview, total: parsed.data.length });
}

View file

@ -13,7 +13,10 @@ 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 }> {
): 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> = {};
@ -38,7 +41,9 @@ function parseMultipart(
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
const nodeStream = Readable.fromWeb(
body as Parameters<typeof Readable.fromWeb>[0],
);
nodeStream.pipe(bb);
});
}

View file

@ -30,6 +30,13 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/broadcast")(
400,
);
}
const MAX_RECIPIENTS = 1000;
if (body.recipients.length > MAX_RECIPIENTS) {
return jsonError(
`recipients must not exceed ${MAX_RECIPIENTS}`,
400,
);
}
if (!body.type || (!body.text && !body.url)) {
return jsonError("Missing message content (text or url)", 400);
}

View file

@ -13,7 +13,10 @@ 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 }> {
): 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> = {};
@ -38,7 +41,9 @@ function parseMultipart(
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
const nodeStream = Readable.fromWeb(
body as Parameters<typeof Readable.fromWeb>[0],
);
nodeStream.pipe(bb);
});
}

View file

@ -13,7 +13,10 @@ 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 }> {
): 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> = {};
@ -38,7 +41,9 @@ function parseMultipart(
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
const nodeStream = Readable.fromWeb(
body as Parameters<typeof Readable.fromWeb>[0],
);
nodeStream.pipe(bb);
});
}

View file

@ -13,7 +13,10 @@ 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 }> {
): 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> = {};
@ -38,7 +41,9 @@ function parseMultipart(
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
const nodeStream = Readable.fromWeb(
body as Parameters<typeof Readable.fromWeb>[0],
);
nodeStream.pipe(bb);
});
}
@ -114,7 +119,14 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/video")({
sessionId: session.id,
appId: auth.appId,
whatsappMsgId,
waMessage: { to, content: { video: { url: mediaUrl }, caption, mimetype: "video/mp4" } },
waMessage: {
to,
content: {
video: { url: mediaUrl },
caption,
mimetype: "video/mp4",
},
},
});
await queue.close();

View file

@ -0,0 +1,39 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findWebhookEndpoint, listDeliveries } from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/webhooks/$id/deliveries")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const endpoint = await findWebhookEndpoint(params.id, auth.appId);
if (!endpoint) return jsonError("Not found", 404);
const url = new URL(request.url);
const limit = Math.min(
Number(url.searchParams.get("limit") ?? 100),
200,
);
const deliveries = await listDeliveries(params.id, auth.appId, limit);
return json(
deliveries.map((d) => ({
id: d.id,
eventType: d.eventType,
eventId: d.eventId,
status: d.status,
httpStatus: d.httpStatus,
attempt: d.attempt,
nextAttemptAt: d.nextAttemptAt?.toISOString() ?? null,
deliveredAt: d.deliveredAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
})),
);
},
},
},
});

View file

@ -0,0 +1,51 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import {
findWebhookEndpoint,
updateWebhookEndpoint,
deleteWebhookEndpoint,
} from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/webhooks/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const endpoint = await findWebhookEndpoint(params.id, auth.appId);
if (!endpoint) return jsonError("Not found", 404);
return json({
id: endpoint.id,
appId: endpoint.appId,
url: endpoint.url,
events: endpoint.events,
enabled: endpoint.enabled,
unhealthySince: endpoint.unhealthySince?.toISOString() ?? null,
consecutiveFailures: endpoint.consecutiveFailures,
createdAt: endpoint.createdAt.toISOString(),
});
},
PATCH: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const body = await request.json().catch(() => ({}));
const result = await updateWebhookEndpoint(params.id, auth.appId, {
url: body.url,
events: Array.isArray(body.events) ? body.events : undefined,
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
secret: body.secret ?? undefined,
});
if (!result.ok) return jsonError(result.error, result.status);
return json(result.endpoint);
},
DELETE: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const result = await deleteWebhookEndpoint(params.id, auth.appId);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,59 @@
import { createFileRoute } from "@tanstack/react-router";
import { createHmac } from "node:crypto";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findWebhookEndpoint, getEndpointSecret } from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/webhooks/$id/test")({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const endpoint = await findWebhookEndpoint(params.id, auth.appId);
if (!endpoint) return jsonError("Not found", 404);
const secret = await getEndpointSecret(params.id, auth.appId);
if (!secret) return jsonError("Secret unavailable", 503);
const payload = JSON.stringify({
event: "test",
event_id: crypto.randomUUID(),
app_id: auth.appId,
timestamp: new Date().toISOString(),
message: "This is a test delivery from wagateway",
});
const timestamp = String(Date.now());
const signature = `sha256=${createHmac("sha256", secret).update(`${payload}.${timestamp}`).digest("hex")}`;
let httpStatus: number | null = null;
let ok = false;
let error: string | undefined;
try {
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "wagateway/1.0",
"x-event-id": crypto.randomUUID(),
"x-event-type": "test",
"x-app-id": auth.appId,
"x-signature": signature,
"x-timestamp": timestamp,
},
body: payload,
signal: AbortSignal.timeout(10_000),
});
httpStatus = res.status;
ok = res.ok;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
return json({ ok, httpStatus, error: error ?? null });
},
},
},
});

View file

@ -0,0 +1,39 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import {
listWebhookEndpoints,
createWebhookEndpoint,
} from "#/lib/actions/webhooks";
export const Route = createFileRoute("/api/v1/webhooks/")({
server: {
handlers: {
GET: async ({ request }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const endpoints = await listWebhookEndpoints(auth.appId);
return json(endpoints);
},
POST: async ({ request }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const body = await request.json().catch(() => null);
if (!body?.url) return jsonError("Missing required field: url", 400);
if (!Array.isArray(body.events))
return jsonError("events must be an array", 400);
const result = await createWebhookEndpoint(
auth.appId,
body.url,
body.events,
body.secret ?? undefined,
);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ...result.endpoint, secret: result.secret }, 201);
},
},
},
});

View 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 { getWhatsappGroup } from "#/lib/actions/whatsapp-groups";
export const Route = createFileRoute("/api/v1/whatsapp-groups/$jid/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const url = new URL(request.url);
const sessionId = url.searchParams.get("session_id");
if (!sessionId) return jsonError("session_id required", 400);
const group = await getWhatsappGroup(
decodeURIComponent(params.jid),
sessionId,
auth.appId,
);
if (!group) return jsonError("Group not found", 404);
return json(group);
},
},
},
});

View file

@ -0,0 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { listWhatsappGroups } from "#/lib/actions/whatsapp-groups";
export const Route = createFileRoute("/api/v1/whatsapp-groups/")({
server: {
handlers: {
GET: async ({ request }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const url = new URL(request.url);
const sessionId = url.searchParams.get("session_id") ?? undefined;
if (!sessionId) return jsonError("session_id required", 400);
const groups = await listWhatsappGroups(auth.appId, sessionId);
return json(groups);
},
},
},
});

View file

@ -1,15 +1,7 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
ArrowLeft,
Pencil,
Trash2,
Check,
X,
Plus,
Tag,
} from "lucide-react";
import { ArrowLeft, Pencil, Trash2, Check, X, Plus, Tag } from "lucide-react";
import { contactQuery, customFieldSchemasQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
@ -79,7 +71,10 @@ function InlineField({
const [saving, setSaving] = useState(false);
async function save() {
if (draft === value) { setEditing(false); return; }
if (draft === value) {
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(draft);
@ -97,20 +92,36 @@ function InlineField({
if (editing) {
return (
<div>
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1">{label}</p>
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
{label}
</p>
<div className="flex items-center gap-1.5">
<Input
autoFocus
type={type}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") cancel(); }}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") cancel();
}}
className="h-8 text-sm w-56"
/>
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-600 hover:bg-emerald-50" onClick={save} disabled={saving}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-emerald-600 hover:bg-emerald-50"
onClick={save}
disabled={saving}
>
<Check size={13} strokeWidth={2.5} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:bg-gray-100" onClick={cancel}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:bg-gray-100"
onClick={cancel}
>
<X size={13} strokeWidth={2.5} />
</Button>
</div>
@ -120,15 +131,29 @@ function InlineField({
return (
<div>
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1">{label}</p>
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
{label}
</p>
<button
onClick={() => { setDraft(value); setEditing(true); }}
onClick={() => {
setDraft(value);
setEditing(true);
}}
className="group flex items-center gap-1.5 text-left"
>
<span className={cn("text-sm", value ? "text-gray-900" : "text-gray-400 italic")}>
<span
className={cn(
"text-sm",
value ? "text-gray-900" : "text-gray-400 italic",
)}
>
{value || placeholder || "—"}
</span>
<Pencil size={11} className="text-gray-300 group-hover:text-gray-500 transition-colors" strokeWidth={2} />
<Pencil
size={11}
className="text-gray-300 group-hover:text-gray-500 transition-colors"
strokeWidth={2}
/>
</button>
</div>
);
@ -153,7 +178,9 @@ function ContactDetailPage() {
const [addingSchema, setAddingSchema] = useState(false);
const [newSchemaKey, setNewSchemaKey] = useState("");
const [newSchemaLabel, setNewSchemaLabel] = useState("");
const [newSchemaType, setNewSchemaType] = useState<"text" | "number" | "bool" | "date">("text");
const [newSchemaType, setNewSchemaType] = useState<
"text" | "number" | "bool" | "date"
>("text");
async function patch(body: Record<string, unknown>) {
const res = await fetch(
@ -166,7 +193,9 @@ function ContactDetailPage() {
},
);
if (!res.ok) throw new Error(await res.text());
await queryClient.invalidateQueries({ queryKey: ["contacts", appId, contactId] });
await queryClient.invalidateQueries({
queryKey: ["contacts", appId, contactId],
});
queryClient.invalidateQueries({ queryKey: ["contacts", appId] });
}
@ -237,7 +266,9 @@ function ContactDetailPage() {
setNewSchemaLabel("");
setNewSchemaType("text");
setAddingSchema(false);
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
}
if (!contact) {
@ -257,7 +288,11 @@ function ContactDetailPage() {
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/apps/$appId/contacts/" params={{ appId }}>
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:text-gray-700">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:text-gray-700"
>
<ArrowLeft size={14} strokeWidth={2} />
</Button>
</Link>
@ -265,7 +300,12 @@ function ContactDetailPage() {
<span className="text-[14px] font-semibold text-gray-900">
{contact.name ?? contact.pushName ?? contact.phone}
</span>
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded border", sourceStyle)}>
<span
className={cn(
"text-[10px] font-medium px-1.5 py-0.5 rounded border",
sourceStyle,
)}
>
{sourceLabel}
</span>
</div>
@ -281,7 +321,6 @@ function ContactDetailPage() {
</div>
<div className="px-8 py-6 max-w-2xl space-y-8 animate-fade-up">
{/* Core fields */}
<section>
<h2 className="text-[12px] font-semibold text-gray-400 uppercase tracking-wide mb-4">
@ -321,7 +360,9 @@ function ContactDetailPage() {
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
JID
</p>
<code className="text-[12px] font-mono text-gray-500">{contact.jid}</code>
<code className="text-[12px] font-mono text-gray-500">
{contact.jid}
</code>
</div>
)}
</div>
@ -335,7 +376,11 @@ function ContactDetailPage() {
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex flex-wrap gap-1.5 items-center">
{contact.tags.map((t) => (
<Badge key={t} variant="secondary" className="text-[12px] px-2 py-0.5 gap-1 group">
<Badge
key={t}
variant="secondary"
className="text-[12px] px-2 py-0.5 gap-1 group"
>
<Tag size={10} strokeWidth={2} />
{t}
<button
@ -355,15 +400,31 @@ function ContactDetailPage() {
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addTag();
if (e.key === "Escape") { setAddingTag(false); setNewTag(""); }
if (e.key === "Escape") {
setAddingTag(false);
setNewTag("");
}
}}
placeholder="tag name"
className="h-7 text-[12px] w-28"
/>
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-600 hover:bg-emerald-50" onClick={addTag}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-emerald-600 hover:bg-emerald-50"
onClick={addTag}
>
<Check size={12} strokeWidth={2.5} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:bg-gray-100" onClick={() => { setAddingTag(false); setNewTag(""); }}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:bg-gray-100"
onClick={() => {
setAddingTag(false);
setNewTag("");
}}
>
<X size={12} strokeWidth={2.5} />
</Button>
</div>
@ -401,7 +462,9 @@ function ContactDetailPage() {
{schemas.length === 0 && !addingSchema ? (
<div className="bg-white border border-gray-200 border-dashed rounded-xl p-5 text-center">
<p className="text-[13px] text-gray-400">No custom fields defined.</p>
<p className="text-[13px] text-gray-400">
No custom fields defined.
</p>
<Button
variant="outline"
size="sm"
@ -416,7 +479,8 @@ function ContactDetailPage() {
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-5">
{schemas.map((schema) => {
const raw = contact.customFields?.[schema.key];
const valStr = raw !== undefined && raw !== null ? String(raw) : "";
const valStr =
raw !== undefined && raw !== null ? String(raw) : "";
return (
<InlineField
@ -424,11 +488,19 @@ function ContactDetailPage() {
label={`${schema.label}${schema.required ? " *" : ""}`}
value={valStr}
placeholder={`Add ${schema.label.toLowerCase()}…`}
type={schema.fieldType === "number" ? "number" : schema.fieldType === "date" ? "date" : "text"}
type={
schema.fieldType === "number"
? "number"
: schema.fieldType === "date"
? "date"
: "text"
}
onSave={async (v) => {
let coerced: unknown = v || null;
if (v && schema.fieldType === "number") coerced = Number(v);
if (v && schema.fieldType === "bool") coerced = v === "true";
if (v && schema.fieldType === "number")
coerced = Number(v);
if (v && schema.fieldType === "bool")
coerced = v === "true";
await saveCustomField(schema.key, coerced);
}}
/>
@ -445,7 +517,11 @@ function ContactDetailPage() {
autoFocus
placeholder="key (e.g. tier)"
value={newSchemaKey}
onChange={(e) => setNewSchemaKey(e.target.value.replace(/\s+/g, "_").toLowerCase())}
onChange={(e) =>
setNewSchemaKey(
e.target.value.replace(/\s+/g, "_").toLowerCase(),
)
}
className="h-8 text-sm w-32"
/>
<Input
@ -456,7 +532,11 @@ function ContactDetailPage() {
/>
<select
value={newSchemaType}
onChange={(e) => setNewSchemaType(e.target.value as "text" | "number" | "bool" | "date")}
onChange={(e) =>
setNewSchemaType(
e.target.value as "text" | "number" | "bool" | "date",
)
}
className="h-8 text-sm border border-gray-200 rounded-md px-2 bg-white text-gray-700"
>
<option value="text">text</option>
@ -464,10 +544,20 @@ function ContactDetailPage() {
<option value="bool">bool</option>
<option value="date">date</option>
</select>
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-600 hover:bg-emerald-50" onClick={addSchema}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-emerald-600 hover:bg-emerald-50"
onClick={addSchema}
>
<Check size={13} strokeWidth={2.5} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:bg-gray-100" onClick={() => setAddingSchema(false)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:bg-gray-100"
onClick={() => setAddingSchema(false)}
>
<X size={13} strokeWidth={2.5} />
</Button>
</div>
@ -487,19 +577,35 @@ function ContactDetailPage() {
</div>
{/* Delete dialog */}
<Dialog open={deleteOpen} onOpenChange={(o) => !o && setDeleteOpen(false)}>
<Dialog
open={deleteOpen}
onOpenChange={(o) => !o && setDeleteOpen(false)}
>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px] text-red-600">Delete contact?</DialogTitle>
<DialogTitle className="text-[15px] text-red-600">
Delete contact?
</DialogTitle>
<DialogDescription className="text-[13px]">
Permanently deletes this contact and all associated data.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDeleteOpen(false)} className="h-8 px-3 text-[13px]">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(false)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={deleteContact} disabled={deleting} className="h-8 px-3 text-[13px]">
<Button
variant="destructive"
size="sm"
onClick={deleteContact}
disabled={deleting}
className="h-8 px-3 text-[13px]"
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>

View file

@ -0,0 +1,533 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
ArrowLeft,
Users,
Plus,
Trash2,
ChevronDown,
UserMinus,
UserPlus,
} from "lucide-react";
import {
contactGroupsAdminQuery,
contactGroupMembersQuery,
contactsQuery,
} from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "#/components/ui/dialog";
import { cn } from "#/lib/utils";
export const Route = createFileRoute("/apps/$appId/contacts/groups/")({
component: GroupsPage,
});
interface Group {
id: string;
appId: string;
name: string;
description: string | null;
createdAt: string;
}
interface ContactRow {
id: string;
phone: string;
name: string | null;
pushName: string | null;
}
function GroupRow({
appId,
group,
onDelete,
}: {
appId: string;
group: Group;
onDelete: () => void;
}) {
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [searchQ, setSearchQ] = useState("");
const [adding, setAdding] = useState(false);
const { data: membersData } = useQuery<{ contacts: ContactRow[] }>(
contactGroupMembersQuery(appId, group.id),
);
const members = membersData?.contacts ?? [];
const { data: allContactsData } = useQuery<{ contacts: ContactRow[] }>(
contactsQuery(appId, { q: searchQ || undefined, limit: 50 }),
);
const allContacts = allContactsData?.contacts ?? [];
const memberIds = new Set(members.map((m) => m.id));
const addableContacts = allContacts.filter((c) => !memberIds.has(c.id));
async function removeMember(contactId: string) {
await fetch(
`/api/v1/admin/contact-groups/${group.id}/members/${contactId}?app_id=${encodeURIComponent(appId)}`,
{ method: "DELETE", credentials: "include" },
);
queryClient.invalidateQueries({
queryKey: ["contact-groups", appId, group.id, "members"],
});
}
async function addMember(contactId: string) {
setAdding(true);
try {
await fetch(`/api/v1/admin/contact-groups/${group.id}/members`, {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ app_id: appId, contact_ids: [contactId] }),
});
queryClient.invalidateQueries({
queryKey: ["contact-groups", appId, group.id, "members"],
});
} finally {
setAdding(false);
}
}
const initials = group.name.slice(0, 2).toUpperCase();
return (
<div
className={cn(
"bg-white rounded-xl border border-gray-200 overflow-hidden transition-shadow duration-200",
expanded && "shadow-sm",
)}
>
{/* Header row */}
<div className="flex items-center gap-3 px-4 py-3.5">
{/* Avatar */}
<div className="w-9 h-9 rounded-lg bg-zinc-900 flex items-center justify-center shrink-0">
<span className="text-[11px] font-bold text-white tracking-wide">
{initials}
</span>
</div>
{/* Expand trigger */}
<button
className="flex-1 flex items-center gap-2 text-left min-w-0"
onClick={() => setExpanded((v) => !v)}
>
<div className="min-w-0">
<p className="text-[14px] font-semibold text-gray-900 truncate leading-snug">
{group.name}
</p>
{group.description && (
<p className="text-[12px] text-gray-400 truncate mt-0.5">
{group.description}
</p>
)}
</div>
</button>
{/* Member pill */}
<span className="text-[11px] font-medium text-gray-500 bg-gray-100 rounded-full px-2.5 py-1 shrink-0 tabular-nums">
{members.length} {members.length === 1 ? "member" : "members"}
</span>
{/* Chevron */}
<button
onClick={() => setExpanded((v) => !v)}
className="text-gray-400 hover:text-gray-700 transition-colors shrink-0"
aria-label={expanded ? "Collapse" : "Expand"}
>
<ChevronDown
size={16}
strokeWidth={2}
className={cn(
"transition-transform duration-200",
expanded && "rotate-180",
)}
/>
</button>
{/* Delete */}
<Button
variant="ghost"
size="icon-sm"
className="text-gray-300 hover:text-red-500 hover:bg-red-50 shrink-0"
onClick={onDelete}
>
<Trash2 />
</Button>
</div>
{/* Expanded panel */}
{expanded && (
<div className="border-t border-gray-100 bg-gray-50/60">
{members.length === 0 ? (
<div className="flex flex-col items-center py-8 gap-3">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center">
<Users size={16} className="text-gray-400" strokeWidth={1.5} />
</div>
<p className="text-[13px] text-gray-400">
No contacts in this group yet
</p>
<Button
size="sm"
onClick={() => setAddOpen(true)}
className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<UserPlus size={13} strokeWidth={2} />
Add contacts
</Button>
</div>
) : (
<div className="px-4 py-3">
<div className="divide-y divide-gray-100">
{members.map((m) => {
const displayName = m.name ?? m.pushName ?? m.phone;
const initial = displayName.charAt(0).toUpperCase();
return (
<div
key={m.id}
className="flex items-center gap-3 py-2.5 group"
>
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
<span className="text-[10px] font-semibold text-gray-600">
{initial}
</span>
</div>
<div className="flex-1 min-w-0 flex items-baseline gap-2">
<span className="text-[13px] font-medium text-gray-900 truncate">
{displayName}
</span>
{m.name && (
<span className="text-[11px] text-gray-400 font-mono shrink-0">
{m.phone}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all"
onClick={() => removeMember(m.id)}
>
<UserMinus />
</Button>
</div>
);
})}
</div>
<div className="pt-3 pb-1">
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="h-8 px-3 text-[13px] gap-1.5 border-dashed text-gray-500"
>
<UserPlus size={13} strokeWidth={2} />
Add contacts
</Button>
</div>
</div>
)}
</div>
)}
{/* Add contacts dialog */}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add to {group.name}</DialogTitle>
<DialogDescription>
Search and select contacts to add to this group.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Search contacts…"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
className="mb-2"
/>
<div className="max-h-64 overflow-y-auto space-y-1">
{addableContacts.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{searchQ ? "No results" : "All contacts already in group"}
</p>
) : (
addableContacts.map((c) => (
<button
key={c.id}
disabled={adding}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left transition-colors disabled:opacity-50"
onClick={() => addMember(c.id)}
>
<div className="w-7 h-7 rounded-full bg-zinc-900 flex items-center justify-center shrink-0">
<span className="text-[10px] font-bold text-white uppercase">
{(c.name ?? c.pushName ?? c.phone).charAt(0)}
</span>
</div>
<div>
<p className="text-[13px] font-medium text-gray-900">
{c.name ?? c.pushName ?? c.phone}
</p>
<p className="text-[11px] text-gray-400 font-mono">
{c.phone}
</p>
</div>
</button>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
function GroupsPage() {
const { appId } = Route.useParams();
const queryClient = useQueryClient();
const { data: groupsData, isPending } = useQuery<Group[]>(
contactGroupsAdminQuery(appId),
);
const groups = groupsData ?? [];
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
async function createGroup(e: React.FormEvent) {
e.preventDefault();
setCreateError("");
setCreating(true);
try {
const res = await fetch("/api/v1/admin/contact-groups", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
app_id: appId,
name: newName,
description: newDesc || undefined,
}),
});
if (!res.ok) throw new Error(await res.text());
setNewOpen(false);
setNewName("");
setNewDesc("");
queryClient.invalidateQueries({ queryKey: ["contact-groups", appId] });
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed");
} finally {
setCreating(false);
}
}
async function deleteGroup() {
if (!deleteId) return;
setDeleting(true);
try {
await fetch(
`/api/v1/admin/contact-groups/${deleteId}?app_id=${encodeURIComponent(appId)}`,
{ method: "DELETE", credentials: "include" },
);
queryClient.invalidateQueries({ queryKey: ["contact-groups", appId] });
} finally {
setDeleting(false);
setDeleteId(null);
}
}
return (
<div className="min-h-full">
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/apps/$appId/contacts/"
params={{ appId }}
className="flex items-center gap-1 text-[13px] text-gray-500 hover:text-gray-900 transition-colors"
>
<ArrowLeft size={14} strokeWidth={1.75} />
Contacts
</Link>
<span className="text-gray-300">/</span>
<div className="flex items-center gap-2">
<Users size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Groups</h1>
{groups.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{groups.length}
</span>
)}
</div>
</div>
<Button
size="sm"
onClick={() => setNewOpen(true)}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New group
</Button>
</div>
<div className="p-8">
{(isPending as boolean) && (
<div className="flex items-center justify-center py-16">
<div className="w-5 h-5 border-2 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
</div>
)}
{!(isPending as boolean) && groups.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<div className="w-14 h-14 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center">
<Users size={22} className="text-gray-400" strokeWidth={1.5} />
</div>
<div className="text-center">
<p className="text-[14px] font-semibold text-gray-900 mb-1">
No groups yet
</p>
<p className="text-[13px] text-gray-500">
Create a group to segment your contacts
</p>
</div>
<Button
size="sm"
onClick={() => setNewOpen(true)}
className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New group
</Button>
</div>
)}
<div className="space-y-3">
{groups.map((g) => (
<GroupRow
key={g.id}
appId={appId}
group={g}
onDelete={() => setDeleteId(g.id)}
/>
))}
</div>
</div>
{/* Create dialog */}
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-[15px]">New group</DialogTitle>
<DialogDescription className="text-[13px]">
Create a contact group to segment your audience.
</DialogDescription>
</DialogHeader>
<form onSubmit={createGroup} className="space-y-3 pt-1">
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">
Name <span className="text-red-500">*</span>
</Label>
<Input
autoFocus
placeholder="e.g. VIP Customers"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-9 text-sm"
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">
Description{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</Label>
<Input
placeholder="What this group represents"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="h-9 text-sm"
/>
</div>
{createError && (
<p className="text-[13px] text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{createError}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setNewOpen(false)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={creating || !newName.trim()}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
{creating ? "Creating…" : "Create"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog
open={!!deleteId}
onOpenChange={(o) => {
if (!o) setDeleteId(null);
}}
>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px] text-red-600">
Delete group?
</DialogTitle>
<DialogDescription className="text-[13px]">
Removes the group. Contacts are not deleted.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteId(null)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
disabled={deleting}
onClick={deleteGroup}
className="h-8 px-3 text-[13px]"
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ import {
RefreshCw,
Trash2,
Upload,
Layers,
Settings,
} from "lucide-react";
import { contactsQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
@ -22,6 +24,7 @@ import {
DialogDescription,
} from "#/components/ui/dialog";
import { cn } from "#/lib/utils";
import { FieldsDialog } from "#/components/fields-dialog";
export const Route = createFileRoute("/apps/$appId/contacts/")({
component: ContactsPage,
@ -72,6 +75,7 @@ function ContactsPage() {
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [fieldsOpen, setFieldsOpen] = useState(false);
const { data, refetch, isPending } = useQuery<{
contacts: DecryptedContact[];
@ -175,8 +179,31 @@ function ContactsPage() {
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-40"
title="Sync contacts from WhatsApp"
>
<RefreshCw size={14} strokeWidth={1.75} className={syncing ? "animate-spin" : ""} />
<RefreshCw
size={14}
strokeWidth={1.75}
className={syncing ? "animate-spin" : ""}
/>
</button>
<Link to="/apps/$appId/contacts/groups/" params={{ appId }}>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-[13px] gap-1.5"
>
<Layers size={12} strokeWidth={2} />
Groups
</Button>
</Link>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-[13px] gap-1.5"
onClick={() => setFieldsOpen(true)}
>
<Settings size={12} strokeWidth={2} />
Fields
</Button>
<Link to="/apps/$appId/contacts/import" params={{ appId }}>
<Button
variant="outline"
@ -508,10 +535,7 @@ function ContactsPage() {
</Dialog>
{/* Delete Confirm Dialog */}
<Dialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px] text-red-600">
@ -543,6 +567,12 @@ function ContactsPage() {
</div>
</DialogContent>
</Dialog>
<FieldsDialog
appId={appId}
open={fieldsOpen}
onOpenChange={setFieldsOpen}
/>
</div>
);
}

View file

@ -0,0 +1,362 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ArrowLeft, Plus, Trash2, Settings } from "lucide-react";
import { customFieldSchemasQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { Badge } from "#/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "#/components/ui/dialog";
export const Route = createFileRoute("/apps/$appId/contacts/settings/")({
component: ContactSettingsPage,
});
interface FieldSchema {
id: string;
key: string;
label: string;
fieldType: "text" | "number" | "bool" | "date";
required: boolean;
createdAt: string;
}
const TYPE_LABELS: Record<string, string> = {
text: "Text",
number: "Number",
bool: "Boolean",
date: "Date",
};
function ContactSettingsPage() {
const { appId } = Route.useParams();
const queryClient = useQueryClient();
const { data: schemasData, isPending } = useQuery<FieldSchema[]>(
customFieldSchemasQuery(appId),
);
const schemas = schemasData ?? [];
const [newOpen, setNewOpen] = useState(false);
const [newKey, setNewKey] = useState("");
const [newLabel, setNewLabel] = useState("");
const [newType, setNewType] = useState<"text" | "number" | "bool" | "date">(
"text",
);
const [newRequired, setNewRequired] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState("");
const [editRequired, setEditRequired] = useState(false);
const [saving, setSaving] = useState(false);
function openEdit(schema: FieldSchema) {
setEditId(schema.id);
setEditLabel(schema.label);
setEditRequired(schema.required);
}
async function createSchema(e: React.FormEvent) {
e.preventDefault();
setCreateError("");
setCreating(true);
try {
const res = await fetch(
`/api/v1/admin/custom-field-schemas?app_id=${encodeURIComponent(appId)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
key: newKey.trim(),
label: newLabel.trim(),
field_type: newType,
required: newRequired,
}),
},
);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error ?? "Failed");
}
setNewOpen(false);
setNewKey("");
setNewLabel("");
setNewType("text");
setNewRequired(false);
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed");
} finally {
setCreating(false);
}
}
async function saveEdit() {
if (!editId) return;
setSaving(true);
try {
await fetch(`/api/v1/admin/custom-field-schemas/${editId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
app_id: appId,
label: editLabel,
required: editRequired,
}),
});
setEditId(null);
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
} finally {
setSaving(false);
}
}
async function deleteSchema() {
if (!deleteId) return;
setDeleting(true);
try {
await fetch(
`/api/v1/admin/custom-field-schemas/${deleteId}?app_id=${encodeURIComponent(appId)}`,
{ method: "DELETE", credentials: "include" },
);
queryClient.invalidateQueries({
queryKey: ["custom-field-schemas", appId],
});
} finally {
setDeleting(false);
setDeleteId(null);
}
}
return (
<div className="p-6 max-w-3xl">
<div className="flex items-center gap-3 mb-6">
<Link to="/apps/$appId/contacts/" params={{ appId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1" /> Contacts
</Button>
</Link>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Settings className="h-5 w-5" /> Custom field schemas
</h1>
<div className="ml-auto">
<Button size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New field
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Define custom fields that can be stored on any contact in this app.
Fields are validated when creating or updating contacts.
</p>
{(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{!(isPending as boolean) && schemas.length === 0 && (
<p className="text-sm text-muted-foreground">
No custom fields defined.
</p>
)}
<div className="space-y-2">
{schemas.map((s) => (
<div
key={s.id}
className="border rounded-lg p-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<code className="text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded shrink-0">
{s.key}
</code>
<span className="text-sm font-medium truncate">{s.label}</span>
<Badge variant="outline" className="text-xs shrink-0">
{TYPE_LABELS[s.fieldType]}
</Badge>
{s.required && (
<Badge variant="secondary" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" onClick={() => openEdit(s)}>
Edit
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-red-500"
onClick={() => setDeleteId(s.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
{/* Create dialog */}
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New custom field</DialogTitle>
<DialogDescription>
Define a new field available on all contacts in this app.
</DialogDescription>
</DialogHeader>
<form onSubmit={createSchema} className="space-y-3">
<div className="space-y-1">
<Label>Key (identifier)</Label>
<Input
placeholder="e.g. tier"
value={newKey}
onChange={(e) =>
setNewKey(e.target.value.replace(/\s+/g, "_").toLowerCase())
}
required
/>
<p className="text-xs text-muted-foreground">
Lowercase, underscores only. Cannot be changed after creation.
</p>
</div>
<div className="space-y-1">
<Label>Label (display name)</Label>
<Input
placeholder="e.g. Customer Tier"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label>Type</Label>
<select
value={newType}
onChange={(e) =>
setNewType(
e.target.value as "text" | "number" | "bool" | "date",
)
}
className="w-full h-9 text-sm border border-input rounded-md px-3 bg-background"
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="bool">Boolean</option>
<option value="date">Date</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newRequired}
onChange={(e) => setNewRequired(e.target.checked)}
className="h-4 w-4"
/>
<span className="text-sm">Required field</span>
</label>
{createError && (
<p className="text-sm text-red-500">{createError}</p>
)}
<Button type="submit" disabled={creating}>
{creating ? "Creating…" : "Create"}
</Button>
</form>
</DialogContent>
</Dialog>
{/* Edit dialog */}
<Dialog
open={!!editId}
onOpenChange={(o) => {
if (!o) setEditId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit field</DialogTitle>
<DialogDescription>
You can change the label and required flag. Key and type are
immutable.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label>Label</Label>
<Input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editRequired}
onChange={(e) => setEditRequired(e.target.checked)}
className="h-4 w-4"
/>
<span className="text-sm">Required field</span>
</label>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setEditId(null)}>
Cancel
</Button>
<Button onClick={saveEdit} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog
open={!!deleteId}
onOpenChange={(o) => {
if (!o) setDeleteId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete field schema?</DialogTitle>
<DialogDescription>
Removes the field definition. Existing contact data for this key
is not deleted but will no longer be validated.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={deleteSchema}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,206 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Users2, ChevronDown } from "lucide-react";
import { whatsappGroupsQuery, appSessionsQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
export const Route = createFileRoute("/apps/$appId/groups/")({
component: GroupsPage,
});
interface WhatsappGroup {
id: string;
appId: string;
sessionId: string;
jid: string;
subject: string | null;
description: string | null;
participantCount: number | null;
createdAt: string;
updatedAt: string;
}
interface SessionRow {
id: string;
appId: string;
appName: string;
status: string;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
if (h < 24) return `${h}h ago`;
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function splitJid(jid: string) {
const at = jid.indexOf("@");
if (at === -1) return { num: jid, domain: "" };
return { num: jid.slice(0, at), domain: "@" + jid.slice(at + 1) };
}
function SkeletonRow() {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 animate-pulse">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="h-4 w-48 rounded bg-gray-100" />
<div className="h-3 w-64 rounded bg-gray-100" />
</div>
<div className="h-5 w-12 rounded-full bg-gray-100" />
</div>
</div>
);
}
function GroupsPage() {
const { appId } = Route.useParams();
const [sessionFilter, setSessionFilter] = useState<string>("");
const { data: sessions = [] } = useQuery<SessionRow[]>(appSessionsQuery(appId));
const { data: groups = [], isPending } = useQuery<WhatsappGroup[]>(
whatsappGroupsQuery(appId, sessionFilter || undefined),
);
const sessionMap = Object.fromEntries(sessions.map((s) => [s.id, s]));
return (
<div className="min-h-full bg-gray-50/50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users2 size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Groups</h1>
{!(isPending as boolean) && groups.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{groups.length}
</span>
)}
</div>
{/* Session filter */}
{sessions.length > 1 && (
<div className="relative">
<select
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
className="appearance-none text-[12px] text-gray-600 bg-white border border-gray-200 rounded-lg pl-3 pr-7 py-1.5 focus:outline-none focus:ring-1 focus:ring-zinc-300 cursor-pointer"
>
<option value="">All sessions</option>
{sessions.map((s) => (
<option key={s.id} value={s.id}>
{s.id.slice(0, 8)}… ({s.status})
</option>
))}
</select>
<ChevronDown size={11} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
</div>
)}
</div>
<div className="px-8 py-8">
{/* Loading */}
{(isPending as boolean) && (
<div className="space-y-2.5">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
)}
{/* Empty state */}
{!(isPending as boolean) && groups.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 text-gray-300">
<Users2 size={28} strokeWidth={1.25} />
</div>
<h2 className="text-[15px] font-semibold text-gray-800 mb-1">No groups tracked yet</h2>
<p className="text-[13px] text-gray-400 max-w-xs leading-relaxed">
Groups appear here automatically when messages are received from WhatsApp group chats.
</p>
</div>
)}
{/* Group cards */}
<div className="space-y-2.5">
{groups.map((g) => {
const { num, domain } = splitJid(g.jid);
const session = sessionMap[g.sessionId];
return (
<div
key={g.id}
className="rounded-xl border border-gray-100 bg-white overflow-hidden hover:shadow-sm transition-shadow"
>
<div className="flex">
<div className="w-0.5 shrink-0 bg-violet-300" />
<div className="flex-1 px-4 py-3.5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Subject + JID */}
<div className="flex items-center gap-2 mb-1">
{g.subject ? (
<span className="text-[13px] font-semibold text-gray-900 truncate">
{g.subject}
</span>
) : (
<span className="text-[13px] text-gray-400 italic">Unnamed group</span>
)}
</div>
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-[11px] text-gray-400">
{num}
<span className="text-gray-300">{domain}</span>
</span>
</div>
{/* Description */}
{g.description && (
<p className="text-[12px] text-gray-500 mb-2 line-clamp-2 leading-relaxed">
{g.description}
</p>
)}
{/* Meta row */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
{g.participantCount !== null && (
<span className="flex items-center gap-1">
<Users2 size={10} />
{g.participantCount} participant{g.participantCount !== 1 ? "s" : ""}
</span>
)}
{session && (
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono border",
session.status === "CONNECTED"
? "bg-emerald-50 border-emerald-100 text-emerald-600"
: "bg-gray-50 border-gray-100 text-gray-500",
)}
>
{g.sessionId.slice(0, 8)}…
</span>
)}
<span title={new Date(g.updatedAt).toLocaleString()}>
updated {timeAgo(g.updatedAt)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,16 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Key, Plus, Copy, Check, Trash2, AlertTriangle } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import {
Key,
Plus,
Copy,
Check,
Trash2,
AlertTriangle,
Shield,
RefreshCw,
} from "lucide-react";
import { appQuery, apiKeysQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
@ -47,6 +56,60 @@ function AppKeysPage() {
const [generating, setGenerating] = useState(false);
const [deleting, setDeleting] = useState(false);
const [rotateOpen, setRotateOpen] = useState(false);
const [rotating, setRotating] = useState(false);
const [rotateError, setRotateError] = useState("");
const [rotationStatus, setRotationStatus] = useState<{
status: string | null;
startedAt: string | null;
completedAt: string | null;
} | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
async function fetchRotationStatus() {
const res = await fetch(
`/api/v1/admin/apps/${appId}/rotate-encryption-key`,
{ credentials: "include" },
);
if (res.ok) {
const data = await res.json();
setRotationStatus(data);
if (data.status === "done" || data.status === "failed") {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
}
}
async function startRotation() {
setRotateError("");
setRotating(true);
try {
const res = await fetch(
`/api/v1/admin/apps/${appId}/rotate-encryption-key`,
{ method: "POST", credentials: "include" },
);
const data = await res.json();
if (!res.ok) {
setRotateError(data.error ?? "Failed to start rotation");
return;
}
setRotateOpen(false);
await fetchRotationStatus();
pollRef.current = setInterval(fetchRotationStatus, 3000);
} finally {
setRotating(false);
}
}
async function generateKey(e: React.FormEvent) {
e.preventDefault();
setError("");
@ -253,6 +316,66 @@ function AppKeysPage() {
<Separator className="my-2" />
{/* Security */}
<section>
<h2 className="text-[13px] font-semibold text-gray-900 mb-3 flex items-center gap-1.5">
<Shield size={13} strokeWidth={2} />
Security
</h2>
<div className="bg-white border border-gray-200 rounded-xl p-4 flex items-start justify-between gap-4">
<div className="flex-1">
<p className="text-[13px] font-medium text-gray-900">
Rotate encryption key
</p>
<p className="text-[12px] text-gray-500 mt-0.5">
Generate a new data encryption key and re-encrypt all contact
and session data. This may take a moment.
</p>
{rotationStatus && (
<div className="mt-2">
{rotationStatus.status === "pending" ||
rotationStatus.status === "running" ? (
<div className="flex items-center gap-2 text-[12px] text-blue-600">
<RefreshCw size={11} className="animate-spin" />
Rotation in progress…
</div>
) : rotationStatus.status === "done" ? (
<p className="text-[12px] text-emerald-600">
Rotation completed
{rotationStatus.completedAt
? ` at ${new Date(rotationStatus.completedAt).toLocaleString()}`
: ""}
.
</p>
) : rotationStatus.status === "failed" ? (
<p className="text-[12px] text-red-600">
Rotation failed. Please try again.
</p>
) : null}
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setRotateOpen(true);
setRotateError("");
}}
disabled={
rotationStatus?.status === "pending" ||
rotationStatus?.status === "running"
}
className="h-8 px-3 text-[13px] flex-shrink-0"
>
<RefreshCw size={12} strokeWidth={2} className="mr-1.5" />
Rotate key
</Button>
</div>
</section>
<Separator className="my-2" />
{/* Danger zone */}
<section>
<h2 className="text-[13px] font-semibold text-red-500 mb-3 flex items-center gap-1.5">
@ -347,6 +470,53 @@ function AppKeysPage() {
</DialogContent>
</Dialog>
{/* Rotate Key Dialog */}
<Dialog
open={rotateOpen}
onOpenChange={(o) => {
setRotateOpen(o);
if (!o) setRotateError("");
}}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-[15px]">
Rotate encryption key?
</DialogTitle>
<DialogDescription className="text-[13px]">
This generates a new data encryption key and re-encrypts all
contacts and session credentials. The app remains operational.
This operation runs in the background and may take a few minutes
for large datasets.
</DialogDescription>
</DialogHeader>
{rotateError && (
<p className="text-[13px] text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{rotateError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRotateOpen(false)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
size="sm"
onClick={startRotation}
disabled={rotating}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
{rotating ? "Starting…" : "Rotate key"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete App Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[380px]">

View file

@ -0,0 +1,224 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, XCircle, Clock, ScrollText } from "lucide-react";
import { webhookDeliveriesQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
export const Route = createFileRoute(
"/apps/$appId/webhooks/$webhookId/deliveries",
)({
component: DeliveriesPage,
});
interface Delivery {
id: string;
eventType: string;
eventId: string;
status: "pending" | "success" | "failed";
httpStatus: number | null;
attempt: number;
nextAttemptAt: string | null;
deliveredAt: string | null;
createdAt: string;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
if (m < 60) return `${m}m ago`;
if (h < 24) return `${h}h ago`;
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function timeUntil(iso: string): string {
const diff = new Date(iso).getTime() - Date.now();
if (diff <= 0) return "soon";
const s = Math.floor(diff / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (s < 60) return `${s}s`;
if (m < 60) return `${m}m`;
return `${h}h ${m % 60}m`;
}
const STATUS_CONFIG = {
success: {
icon: <CheckCircle2 size={12} />,
bar: "bg-emerald-400",
pill: "bg-emerald-50 border-emerald-100 text-emerald-700",
label: "success",
},
failed: {
icon: <XCircle size={12} />,
bar: "bg-red-400",
pill: "bg-red-50 border-red-100 text-red-700",
label: "failed",
},
pending: {
icon: <Clock size={12} />,
bar: "bg-amber-400",
pill: "bg-amber-50 border-amber-100 text-amber-700",
label: "pending",
},
};
function SkeletonRow() {
return (
<div className="rounded-xl border border-gray-100 bg-white overflow-hidden animate-pulse">
<div className="flex">
<div className="w-0.5 bg-gray-200 shrink-0" />
<div className="flex-1 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="h-5 w-16 rounded-full bg-gray-100" />
<div className="h-5 w-28 rounded-md bg-gray-100" />
<div className="h-4 w-14 rounded bg-gray-100" />
</div>
<div className="h-4 w-20 rounded bg-gray-100" />
</div>
<div className="mt-2 flex gap-3">
<div className="h-3 w-24 rounded bg-gray-100" />
<div className="h-3 w-16 rounded bg-gray-100" />
</div>
</div>
</div>
</div>
);
}
function DeliveriesPage() {
const { appId, webhookId } = Route.useParams();
const { data: deliveriesData, isPending } = useQuery<Delivery[]>(
webhookDeliveriesQuery(appId, webhookId),
);
const deliveries = deliveriesData ?? [];
return (
<div className="min-h-full bg-gray-50/50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/apps/$appId/webhooks/"
params={{ appId }}
className="flex items-center gap-1.5 text-[12px] text-gray-400 hover:text-gray-700 transition-colors"
>
<ArrowLeft size={13} strokeWidth={2} />
<span>Webhooks</span>
</Link>
<span className="text-gray-200 text-sm">/</span>
<div className="flex items-center gap-2">
<ScrollText size={14} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Delivery Log</h1>
</div>
{!(isPending as boolean) && deliveries.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{deliveries.length}
</span>
)}
</div>
</div>
<div className="px-8 py-8">
{/* Loading */}
{(isPending as boolean) && (
<div className="space-y-2.5">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
)}
{/* Empty state */}
{!(isPending as boolean) && deliveries.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 text-gray-300">
<ScrollText size={28} strokeWidth={1.25} />
</div>
<h2 className="text-[15px] font-semibold text-gray-800 mb-1">No deliveries yet</h2>
<p className="text-[13px] text-gray-400 max-w-xs leading-relaxed">
Deliveries appear here once events are triggered for this endpoint.
</p>
</div>
)}
{/* Delivery rows */}
<div className="space-y-2.5">
{deliveries.map((d) => {
const cfg = STATUS_CONFIG[d.status];
return (
<div
key={d.id}
className="group rounded-xl border border-gray-100 bg-white overflow-hidden hover:shadow-sm transition-shadow"
>
<div className="flex">
<div className={cn("w-0.5 shrink-0", cfg.bar)} />
<div className="flex-1 px-4 py-3.5">
<div className="flex items-center justify-between gap-4">
{/* Left: status + event + http */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span
className={cn(
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
cfg.pill,
)}
>
{cfg.icon}
{cfg.label}
</span>
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded-md border border-gray-100 bg-gray-50 text-gray-600">
{d.eventType}
</span>
{d.httpStatus && (
<span className="font-mono text-[11px] text-gray-400">
HTTP {d.httpStatus}
</span>
)}
<span className="text-[11px] text-gray-300">
·
</span>
<span className="text-[11px] text-gray-400">
attempt {d.attempt}
</span>
</div>
{/* Right: created time */}
<span
className="text-[11px] text-gray-400 shrink-0 tabular-nums"
title={new Date(d.createdAt).toLocaleString()}
>
{timeAgo(d.createdAt)}
</span>
</div>
{/* Meta row */}
{(d.deliveredAt || (d.nextAttemptAt && d.status === "pending")) && (
<div className="mt-1.5 flex items-center gap-3 text-[11px] text-gray-400">
{d.deliveredAt && (
<span title={new Date(d.deliveredAt).toLocaleString()}>
delivered {timeAgo(d.deliveredAt)}
</span>
)}
{d.nextAttemptAt && d.status === "pending" && (
<span className="text-amber-500">
retry in {timeUntil(d.nextAttemptAt)}
</span>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,543 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
Webhook,
Plus,
Trash2,
AlertCircle,
CheckCircle2,
XCircle,
TestTube,
ScrollText,
Zap,
} from "lucide-react";
import { webhooksQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "#/components/ui/dialog";
import { cn } from "#/lib/utils";
export const Route = createFileRoute("/apps/$appId/webhooks/")({
component: WebhooksPage,
});
interface WebhookEndpoint {
id: string;
appId: string;
url: string;
events: string[];
enabled: boolean;
unhealthySince: string | null;
consecutiveFailures: number;
createdAt: string;
secret?: string;
}
type TestResult = {
ok: boolean;
httpStatus: number | null;
error: string | null;
};
const ALL_EVENTS = [
"session.status",
"session.deleted",
"message.received",
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"presence.update",
"contact.created",
"contact.updated",
"group.joined",
"group.left",
"group.updated",
"group.participants_added",
"group.participants_removed",
];
function formatDate(iso: string) {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(mins / 60);
const days = Math.floor(hrs / 24);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
if (hrs < 24) return `${hrs}h ago`;
if (days < 30) return `${days}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function splitUrl(raw: string) {
try {
const u = new URL(raw);
return { proto: u.protocol + "//", rest: u.host + u.pathname + u.search };
} catch {
return { proto: "", rest: raw };
}
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 animate-pulse">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-gray-200" />
<div className="h-4 w-72 rounded bg-gray-100" />
</div>
<div className="flex gap-1.5">
<div className="h-5 w-20 rounded-full bg-gray-100" />
<div className="h-5 w-24 rounded-full bg-gray-100" />
<div className="h-5 w-16 rounded-full bg-gray-100" />
</div>
</div>
<div className="flex gap-2">
<div className="h-8 w-16 rounded-lg bg-gray-100" />
<div className="h-8 w-10 rounded-lg bg-gray-100" />
<div className="h-8 w-8 rounded-lg bg-gray-100" />
</div>
</div>
</div>
);
}
function WebhooksPage() {
const { appId } = Route.useParams();
const queryClient = useQueryClient();
const { data: webhooksData, isPending } = useQuery<WebhookEndpoint[]>(
webhooksQuery(appId),
);
const webhooks = webhooksData ?? [];
const [newOpen, setNewOpen] = useState(false);
const [newUrl, setNewUrl] = useState("");
const [newEvents, setNewEvents] = useState<string[]>([]);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Partial<Record<string, TestResult>>>({});
function toggleEvent(ev: string) {
setNewEvents((prev) =>
prev.includes(ev) ? prev.filter((e) => e !== ev) : [...prev, ev],
);
}
async function createWebhook(e: React.FormEvent) {
e.preventDefault();
setCreateError("");
setCreating(true);
try {
const res = await fetch("/api/v1/admin/webhooks", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ app_id: appId, url: newUrl, events: newEvents }),
});
if (!res.ok) {
const body = await res.text();
try {
const j = JSON.parse(body);
throw new Error(j.error ?? j.message ?? body);
} catch (parseErr) {
if (parseErr instanceof SyntaxError) throw new Error(body);
throw parseErr;
}
}
const data = await res.json();
setCreatedSecret(data.secret);
queryClient.invalidateQueries({ queryKey: ["webhooks", appId] });
setNewUrl("");
setNewEvents([]);
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed");
} finally {
setCreating(false);
}
}
async function deleteWebhook() {
if (!deleteId) return;
setDeleting(true);
try {
await fetch(
`/api/v1/admin/webhooks/${deleteId}?app_id=${encodeURIComponent(appId)}`,
{ method: "DELETE", credentials: "include" },
);
queryClient.invalidateQueries({ queryKey: ["webhooks", appId] });
} finally {
setDeleting(false);
setDeleteId(null);
}
}
async function testWebhook(id: string) {
setTestingId(id);
setTestResults((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
try {
const res = await fetch(`/api/v1/admin/webhooks/${id}/test`, {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ app_id: appId }),
});
const result: TestResult = await res.json();
setTestResults((prev) => ({ ...prev, [id]: result }));
} catch {
setTestResults((prev) => ({
...prev,
[id]: { ok: false, httpStatus: null, error: "Request failed" },
}));
} finally {
setTestingId(null);
}
}
const isPrivateUrl = (url: string) => {
try {
const h = new URL(url).hostname;
return /^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+)$/i.test(h);
} catch { return false; }
};
return (
<div className="min-h-full bg-gray-50/50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Webhook size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Webhooks</h1>
{webhooks.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{webhooks.length}
</span>
)}
</div>
<Button
size="sm"
onClick={() => { setNewOpen(true); setCreatedSecret(null); }}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} /> Add endpoint
</Button>
</div>
<div className="px-8 py-8">
{/* Loading skeletons */}
{(isPending as boolean) && (
<div className="space-y-3">
<SkeletonCard />
<SkeletonCard />
</div>
)}
{/* Empty state */}
{!(isPending as boolean) && webhooks.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-5 flex items-center justify-center w-16 h-16 rounded-2xl bg-gray-100 text-gray-300">
<Webhook size={32} strokeWidth={1.25} />
</div>
<h2 className="text-[15px] font-semibold text-gray-800 mb-1">No endpoints yet</h2>
<p className="text-[13px] text-gray-400 mb-6 max-w-xs leading-relaxed">
Add a webhook endpoint to start receiving real-time event notifications via signed HTTP POST.
</p>
<Button
size="sm"
onClick={() => { setNewOpen(true); setCreatedSecret(null); }}
className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} /> Add endpoint
</Button>
</div>
)}
{/* Endpoint cards */}
<div className="space-y-3">
{webhooks.map((wh) => {
const { proto, rest } = splitUrl(wh.url);
const healthy = wh.enabled && !wh.unhealthySince;
const result = testResults[wh.id];
return (
<div
key={wh.id}
className={cn(
"group rounded-xl border bg-white overflow-hidden transition-shadow hover:shadow-sm",
healthy ? "border-gray-100" : "border-red-100",
)}
>
{/* Left accent bar */}
<div className="flex">
<div
className={cn(
"w-0.5 shrink-0 rounded-l-xl",
healthy ? "bg-emerald-400" : "bg-red-400",
)}
/>
<div className="flex-1 p-5">
{/* Top row */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Status + URL */}
<div className="flex items-center gap-2 mb-2.5">
<span className="relative flex h-2 w-2 shrink-0">
{healthy ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</>
) : (
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
)}
</span>
<span className="font-mono text-[13px] text-gray-800 truncate">
<span className="text-gray-400">{proto}</span>
{rest}
</span>
</div>
{/* Events */}
<div className="flex flex-wrap gap-1 mb-2">
{(wh.events.length === 0
? [{ label: "all events", all: true }]
: wh.events.map((ev) => ({ label: ev, all: false }))
).map(({ label, all }) => (
<span
key={label}
className={cn(
"inline-flex items-center font-mono text-[10px] px-1.5 py-0.5 rounded-md border",
all
? "text-violet-600 bg-violet-50 border-violet-100"
: "text-gray-500 bg-gray-50 border-gray-100",
)}
>
{label}
</span>
))}
</div>
{/* Meta row */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
<span>Added {formatDate(wh.createdAt)}</span>
{wh.consecutiveFailures > 0 && (
<span className="flex items-center gap-1 text-red-500">
<AlertCircle size={10} />
{wh.consecutiveFailures} failure{wh.consecutiveFailures !== 1 ? "s" : ""}
</span>
)}
{!wh.enabled && (
<span className="flex items-center gap-1 text-gray-400">
<XCircle size={10} />
Disabled
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0 pt-0.5">
<Button
variant="outline"
size="sm"
disabled={testingId === wh.id}
onClick={() => testWebhook(wh.id)}
className="h-8 px-3 text-[12px] gap-1.5 border-gray-200 text-gray-700 hover:bg-gray-50"
>
<Zap size={11} className={testingId === wh.id ? "animate-pulse" : ""} />
{testingId === wh.id ? "Testing…" : "Test"}
</Button>
<Link
to="/apps/$appId/webhooks/$webhookId/deliveries"
params={{ appId, webhookId: wh.id }}
>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-[12px] gap-1.5 text-gray-500 hover:text-gray-800 hover:bg-gray-50"
>
<ScrollText size={11} />
Log
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(wh.id)}
className="h-8 w-8 p-0 text-gray-300 hover:text-red-500 hover:bg-red-50"
>
<Trash2 size={13} />
</Button>
</div>
</div>
{/* Inline test result */}
{result && (
<div
className={cn(
"mt-3 flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] border",
result.ok
? "bg-emerald-50 border-emerald-100 text-emerald-700"
: "bg-red-50 border-red-100 text-red-700",
)}
>
{result.ok ? (
<CheckCircle2 size={13} className="shrink-0" />
) : (
<XCircle size={13} className="shrink-0" />
)}
<span>
{result.ok
? `Delivered — HTTP ${result.httpStatus}`
: `Failed — ${result.error ?? `HTTP ${result.httpStatus}`}`}
</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Create dialog */}
<Dialog
open={newOpen}
onOpenChange={(o) => { setNewOpen(o); if (!o) setCreatedSecret(null); }}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add webhook endpoint</DialogTitle>
<DialogDescription>
Events will be delivered via signed HTTP POST.
</DialogDescription>
</DialogHeader>
{createdSecret ? (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm text-emerald-700">
<CheckCircle2 size={15} className="mt-0.5 shrink-0" />
<span>Endpoint created. Copy your signing secret — it won't be shown again.</span>
</div>
<code className="block bg-muted rounded-lg p-3 text-xs break-all select-all font-mono border">
{createdSecret}
</code>
<Button
onClick={() => { setNewOpen(false); setCreatedSecret(null); }}
className="bg-zinc-900 hover:bg-zinc-800"
>
Done
</Button>
</div>
) : (
<form onSubmit={createWebhook} className="space-y-4">
<div className="space-y-1.5">
<Label>URL</Label>
<Input
placeholder="https://example.com/webhook"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
className="font-mono text-sm"
required
/>
{newUrl.startsWith("http://") && !newUrl.startsWith("https://") && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-700">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>HTTP endpoints receive webhook payloads unencrypted. Use HTTPS in production.</span>
</div>
)}
{isPrivateUrl(newUrl) && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-700">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>URL targets a private or local address. Deliveries will only work if the server can reach this host.</span>
</div>
)}
</div>
<div className="space-y-1.5">
<Label>Events <span className="text-gray-400 font-normal">(empty = all)</span></Label>
<div className="flex flex-wrap gap-1.5 mt-1">
{ALL_EVENTS.map((ev) => (
<button
key={ev}
type="button"
onClick={() => toggleEvent(ev)}
className={cn(
"font-mono text-[11px] px-2 py-1 rounded-md border transition-colors",
newEvents.includes(ev)
? "bg-zinc-900 text-white border-zinc-900"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
)}
>
{ev}
</button>
))}
</div>
</div>
{createError && (
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{createError}</span>
</div>
)}
<Button
type="submit"
disabled={creating}
className="bg-zinc-900 hover:bg-zinc-800 w-full"
>
{creating ? "Creating…" : "Create endpoint"}
</Button>
</form>
)}
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog
open={!!deleteId}
onOpenChange={(o) => { if (!o) setDeleteId(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete endpoint</DialogTitle>
<DialogDescription>
This removes the endpoint and all delivery history.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={deleteWebhook}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

556
src/routes/monitoring.tsx Normal file
View file

@ -0,0 +1,556 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
MessageSquare,
Webhook,
RefreshCw,
Server,
ArrowDown,
ArrowUp,
Layers,
AlertTriangle,
RotateCcw,
} from "lucide-react";
import { monitoringStatsQuery } from "#/lib/query";
import { getSessionFn } from "#/server/auth";
import { cn } from "#/lib/utils";
export const Route = createFileRoute("/monitoring")({
beforeLoad: async () => {
const session = await getSessionFn();
if (!session?.userId) throw redirect({ to: "/login" });
},
component: MonitoringPage,
});
interface HourlyRow {
hour: string;
direction: "in" | "out";
count: number;
}
interface SessionCount {
status: string;
count: number;
}
interface WebhookStat {
status: string;
count: number;
p50_ms: number | null;
p95_ms: number | null;
}
interface MonitoringStats {
messages: { hourly: HourlyRow[]; daily: unknown[] };
sessions: SessionCount[];
webhooks: WebhookStat[];
reconnects: { id: string; reconnect_count: number }[];
queues: { send: number; webhook: number };
}
const SESSION_STATUS_STYLE: Record<
string,
{ badge: string; dot: string; label: string } | undefined
> = {
CONNECTED: {
badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
dot: "bg-emerald-500",
label: "Connected",
},
DISCONNECTED: {
badge: "bg-amber-50 text-amber-700 border-amber-200",
dot: "bg-amber-500",
label: "Disconnected",
},
DEAD: {
badge: "bg-red-50 text-red-700 border-red-200",
dot: "bg-red-500",
label: "Dead",
},
LOGGED_OUT: {
badge: "bg-gray-50 text-gray-600 border-gray-200",
dot: "bg-gray-400",
label: "Logged out",
},
PENDING_QR: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
label: "Pending QR",
},
PENDING_PAIRING: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
label: "Pending Pairing",
},
CONNECTING: {
badge: "bg-sky-50 text-sky-700 border-sky-200",
dot: "bg-sky-500",
label: "Connecting",
},
};
function KpiCard({
label,
value,
icon: Icon,
accent,
sub,
}: {
label: string;
value: string | number;
icon: React.ElementType;
accent: string;
sub?: string;
}) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
{label}
</span>
<div className={cn("w-7 h-7 rounded-lg flex items-center justify-center", accent)}>
<Icon size={13} strokeWidth={2} />
</div>
</div>
<div>
<p className="text-2xl font-bold text-gray-900 leading-none">{value}</p>
{sub && (
<p className="text-[11px] text-gray-400 mt-1">{sub}</p>
)}
</div>
</div>
);
}
function QueueCard({
label,
depth,
}: {
label: string;
depth: number;
}) {
const isHealthy = depth === 0;
return (
<div className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
{label}
</span>
<div
className={cn(
"flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
isHealthy
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-amber-50 text-amber-700 border-amber-200",
)}
>
<div
className={cn(
"w-1.5 h-1.5 rounded-full",
isHealthy ? "bg-emerald-500" : "bg-amber-500",
)}
/>
{isHealthy ? "Healthy" : "Backlog"}
</div>
</div>
<p className="text-2xl font-bold text-gray-900 leading-none">
{depth}
</p>
<p className="text-[11px] text-gray-400 mt-1">pending jobs</p>
</div>
);
}
function MessageChart({ rows }: { rows: HourlyRow[] }) {
if (rows.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center">
<MessageSquare size={24} className="text-gray-200 mb-2" />
<p className="text-[12px] text-gray-400">No message data in the last 24h</p>
</div>
);
}
const hourMap: Record<string, { in: number; out: number }> = {};
for (const row of rows) {
const h = new Date(row.hour).getHours();
const key = String(h);
if (!Object.hasOwn(hourMap, key)) hourMap[key] = { in: 0, out: 0 };
if (row.direction === "in") hourMap[key].in += row.count;
else hourMap[key].out += row.count;
}
const hours = Object.keys(hourMap).sort((a, b) => Number(a) - Number(b));
const maxVal = Math.max(
1,
...hours.flatMap((h) => [hourMap[h].in, hourMap[h].out]),
);
return (
<div className="space-y-3">
{hours.map((h) => {
const d = hourMap[h];
const inPct = (d.in / maxVal) * 100;
const outPct = (d.out / maxVal) * 100;
return (
<div key={h} className="flex items-start gap-3">
{/* Hour label — vertically centered across both bars */}
<span className="text-[11px] font-mono text-gray-400 w-10 text-right shrink-0 mt-1.5">
{String(h).padStart(2, "0")}:00
</span>
{/* Bar group */}
<div className="flex-1 space-y-1">
{/* Inbound */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-blue-400 shrink-0 w-3 leading-none">↓</span>
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-400 rounded-full transition-all duration-500"
style={{ width: `${inPct}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-blue-500 tabular-nums w-6 text-right shrink-0">
{d.in}
</span>
</div>
{/* Outbound */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 shrink-0 w-3 leading-none">↑</span>
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-400 rounded-full transition-all duration-500"
style={{ width: `${outPct}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-emerald-500 tabular-nums w-6 text-right shrink-0">
{d.out}
</span>
</div>
</div>
</div>
);
})}
</div>
);
}
function MonitoringPage() {
const {
data: stats,
isPending,
refetch,
isFetching,
} = useQuery<MonitoringStats>(monitoringStatsQuery);
const hourlyRows = stats?.messages.hourly ?? [];
const totalIn24h = hourlyRows
.filter((r) => r.direction === "in")
.reduce((s, r) => s + r.count, 0);
const totalOut24h = hourlyRows
.filter((r) => r.direction === "out")
.reduce((s, r) => s + r.count, 0);
const webhookSuccess = stats?.webhooks.find((w) => w.status === "success");
const webhookFailed = stats?.webhooks.find((w) => w.status === "failed");
const webhookTotal = (webhookSuccess?.count ?? 0) + (webhookFailed?.count ?? 0);
const webhookSuccessRate =
webhookTotal > 0
? Math.round(((webhookSuccess?.count ?? 0) / webhookTotal) * 100)
: null;
const connectedSessions =
stats?.sessions.find((s) => s.status === "CONNECTED")?.count ?? 0;
const totalSessions =
stats?.sessions.reduce((s, r) => s + r.count, 0) ?? 0;
return (
<div className="min-h-full">
{/* Page header — matches sessions/audit-log */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity size={16} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Monitoring</h1>
{!(isPending as boolean) && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
last 24h
</span>
)}
</div>
<button
onClick={() => refetch()}
className={cn(
"p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors",
isFetching && "animate-spin",
)}
title="Refresh"
disabled={isFetching}
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
</div>
{/* Content */}
<div className="px-8 py-6 max-w-5xl animate-fade-up space-y-4">
{(isPending as boolean) && (
<div className="flex items-center gap-2 text-[13px] text-gray-400">
<RefreshCw size={13} className="animate-spin" />
Loading stats…
</div>
)}
{/* KPI cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<KpiCard
label="Received (24h)"
value={totalIn24h}
icon={ArrowDown}
accent="bg-blue-50 text-blue-500"
/>
<KpiCard
label="Sent (24h)"
value={totalOut24h}
icon={ArrowUp}
accent="bg-emerald-50 text-emerald-500"
/>
<KpiCard
label="Connected sessions"
value={connectedSessions}
icon={Server}
accent="bg-violet-50 text-violet-500"
sub={totalSessions > 0 ? `${totalSessions} total` : undefined}
/>
<KpiCard
label="Webhook success"
value={webhookSuccessRate !== null ? `${webhookSuccessRate}%` : "—"}
icon={Webhook}
accent={
webhookSuccessRate === null
? "bg-gray-100 text-gray-400"
: webhookSuccessRate >= 90
? "bg-emerald-50 text-emerald-500"
: "bg-amber-50 text-amber-500"
}
sub={webhookTotal > 0 ? `${webhookTotal} deliveries` : undefined}
/>
</div>
{/* Queue depths */}
<div className="grid grid-cols-2 gap-3">
<QueueCard label="Send queue" depth={stats?.queues.send ?? 0} />
<QueueCard label="Webhook queue" depth={stats?.queues.webhook ?? 0} />
</div>
{/* Messages chart + Session breakdown side by side */}
<div className="grid grid-cols-[1fr_280px] gap-3">
{/* Messages per hour */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare size={13} className="text-gray-400" strokeWidth={1.75} />
<span className="text-[13px] font-semibold text-gray-900">
Messages per hour
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-gray-400">
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-blue-400" />
Inbound
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
Outbound
</span>
</div>
</div>
<div className="px-4 py-4">
<MessageChart rows={hourlyRows} />
</div>
</div>
{/* Session status */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<Server size={13} className="text-gray-400" strokeWidth={1.75} />
<span className="text-[13px] font-semibold text-gray-900">
Session status
</span>
</div>
<div className="px-4 py-4">
{!stats?.sessions.length ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server size={20} className="text-gray-200 mb-2" />
<p className="text-[12px] text-gray-400">No sessions</p>
</div>
) : (
<div className="space-y-2">
{stats.sessions.map((s) => {
const style =
SESSION_STATUS_STYLE[s.status] ||
SESSION_STATUS_STYLE.DISCONNECTED;
const pct = totalSessions > 0 ? (s.count / totalSessions) * 100 : 0;
return (
<div key={s.status} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div
className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0",
style.dot,
)}
/>
<span className="text-[12px] text-gray-600">
{style.label}
</span>
</div>
<span className="text-[12px] font-semibold text-gray-900 tabular-nums">
{s.count}
</span>
</div>
<div className="h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
s.status === "CONNECTED"
? "bg-emerald-400"
: s.status === "DEAD"
? "bg-red-400"
: s.status === "DISCONNECTED"
? "bg-amber-400"
: "bg-gray-300",
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
{/* Webhook deliveries */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<Webhook size={13} className="text-gray-400" strokeWidth={1.75} />
<span className="text-[13px] font-semibold text-gray-900">
Webhook deliveries
</span>
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5 ml-1">
last 24h
</span>
</div>
{!stats?.webhooks.length ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<Webhook size={24} className="text-gray-200 mb-2" />
<p className="text-[12px] text-gray-400">No deliveries in the last 24h</p>
</div>
) : (
<div>
{/* Table header */}
<div className="grid grid-cols-[1fr_100px_120px_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Status
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
Count
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
p50 latency
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
p95 latency
</span>
</div>
<div className="divide-y divide-gray-100">
{stats.webhooks.map((w) => (
<div
key={w.status}
className="grid grid-cols-[1fr_100px_120px_120px] gap-4 px-4 py-3 items-center"
>
<div className="flex items-center gap-2">
<div
className={cn(
"w-1.5 h-1.5 rounded-full",
w.status === "success" ? "bg-emerald-500" : "bg-red-400",
)}
/>
<span
className={cn(
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border capitalize",
w.status === "success"
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-red-50 text-red-700 border-red-200",
)}
>
{w.status}
</span>
</div>
<span className="text-[12px] font-semibold text-gray-900 text-right tabular-nums">
{w.count}
</span>
<span className="text-[12px] text-gray-500 text-right font-mono tabular-nums">
{w.p50_ms != null ? `${Math.round(w.p50_ms)}ms` : "—"}
</span>
<span className="text-[12px] text-gray-500 text-right font-mono tabular-nums">
{w.p95_ms != null ? `${Math.round(w.p95_ms)}ms` : "—"}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Reconnecting sessions */}
{(stats?.reconnects.length ?? 0) > 0 && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<RotateCcw size={13} className="text-amber-400" strokeWidth={1.75} />
<span className="text-[13px] font-semibold text-gray-900">
Top reconnecting sessions
</span>
<div className="flex items-center gap-1 text-[11px] font-medium text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5 ml-1">
<AlertTriangle size={10} />
{stats.reconnects.length} sessions
</div>
</div>
{/* Header */}
<div className="grid grid-cols-[1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Session ID
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
Reconnects
</span>
</div>
<div className="divide-y divide-gray-100">
{stats.reconnects.map((r) => (
<div
key={r.id}
className="grid grid-cols-[1fr_120px] gap-4 px-4 py-2.5 items-center"
>
<code className="text-[11px] font-mono text-gray-500 truncate">
{r.id}
</code>
<div className="flex items-center justify-end gap-1.5">
<span className="text-[12px] font-semibold text-amber-600 tabular-nums">
{r.reconnect_count}
</span>
<Layers size={11} className="text-amber-400" />
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,26 @@
// OpenTelemetry init — runs only when OTEL_EXPORTER_OTLP_ENDPOINT is set
export default async function () {
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (!endpoint) return;
const { NodeSDK } = await import("@opentelemetry/sdk-node");
const { getNodeAutoInstrumentations } =
await import("@opentelemetry/auto-instrumentations-node");
const { OTLPTraceExporter } =
await import("@opentelemetry/exporter-trace-otlp-http");
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
],
});
sdk.start();
console.log("[otel] tracing started →", endpoint);
process.once("SIGTERM", () => sdk.shutdown().catch(console.error));
process.once("SIGINT", () => sdk.shutdown().catch(console.error));
}

View file

@ -3,6 +3,9 @@ import { startSendWorker } from "#/lib/queue/send-worker";
import { startBroadcastWorker } from "#/lib/queue/broadcast-worker";
import { startCleanupWorker } from "#/lib/queue/cleanup-worker";
import { startImportWorker } from "#/lib/queue/import-worker";
import { startWebhookWorker } from "#/lib/queue/webhook-worker";
import { startRotationWorker } from "#/lib/queue/rotation-worker";
import { validateMasterKey } from "#/lib/crypto";
import type { Worker } from "bullmq";
const _g = globalThis as {
@ -10,10 +13,19 @@ const _g = globalThis as {
__wahooBroadcastWorker?: Worker;
__wahooCleanupWorker?: Worker;
__wahooImportWorker?: Worker;
__wahooWebhookWorker?: Worker;
__wahooRotationWorker?: Worker;
__wahooShutdownRegistered?: boolean;
};
export default async function () {
try {
validateMasterKey();
} catch (err) {
console.error("[startup] FATAL:", err);
process.exit(1);
}
try {
await initialize();
console.log("[session-startup] sessions initialized");
@ -26,6 +38,8 @@ export default async function () {
startBroadcastWorker();
await startCleanupWorker();
startImportWorker();
startWebhookWorker();
startRotationWorker();
console.log("[session-startup] queue workers started");
} catch (err) {
console.error("[session-startup] failed to start queue workers:", err);
@ -40,6 +54,8 @@ export default async function () {
_g.__wahooBroadcastWorker?.close(),
_g.__wahooCleanupWorker?.close(),
_g.__wahooImportWorker?.close(),
_g.__wahooWebhookWorker?.close(),
_g.__wahooRotationWorker?.close(),
]);
process.exit(0);
};

View file

@ -0,0 +1,344 @@
/**
* Critical system unit tests — pure logic, no DB/network/Redis required.
*
* Coverage:
* - API key generation: format, prefix, env prefix, uniqueness
* - Webhook URL validation: invalid URL, bad protocol, SSRF private IP block,
* production HTTPS enforcement, private allowed in non-prod
* - Key rotation guard: concurrent rotation prevention conditions
* - API key revocation idempotency: 409 on double-revoke
* - Broadcast recipient cap (1000)
* - File upload size limit (10 MB)
*/
import { describe, it, expect } from "vitest";
import { randomBytes } from "node:crypto";
// ─── API key generation ───────────────────────────────────────────────────────
function generateApiKey(env: "live" | "test" = "live"): { key: string; prefix: string } {
const rand = randomBytes(24).toString("base64url");
const key = `wak_${env}_${rand}`;
const prefix = key.slice(-4);
return { key, prefix };
}
describe("API key generation", () => {
it("live key starts with wak_live_", () => {
const { key } = generateApiKey("live");
expect(key).toMatch(/^wak_live_/);
});
it("test key starts with wak_test_", () => {
const { key } = generateApiKey("test");
expect(key).toMatch(/^wak_test_/);
});
it("prefix is last 4 characters of key", () => {
const { key, prefix } = generateApiKey("live");
expect(prefix).toBe(key.slice(-4));
expect(prefix).toHaveLength(4);
});
it("random portion is base64url safe (no +/=)", () => {
for (let i = 0; i < 50; i++) {
const { key } = generateApiKey("live");
const rand = key.replace(/^wak_live_/, "");
expect(rand).toMatch(/^[A-Za-z0-9_-]+$/);
}
});
it("each call produces unique key", () => {
const keys = new Set(Array.from({ length: 200 }, () => generateApiKey("live").key));
expect(keys.size).toBe(200);
});
it("default env is live", () => {
const { key } = generateApiKey();
expect(key).toMatch(/^wak_live_/);
});
it("key total length is consistent", () => {
// wak_live_ (9) + base64url(24 bytes) = 9 + 32 = 41
const { key } = generateApiKey("live");
expect(key.length).toBeGreaterThan(20);
});
});
// ─── Webhook URL validation ───────────────────────────────────────────────────
const BLOCKED_HOSTNAMES =
/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+)$/i;
function validateWebhookUrl(
raw: string,
env: "production" | "development" | "test" = "test",
): { ok: true } | { ok: false; error: string } {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return { ok: false, error: "Invalid URL" };
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return { ok: false, error: "URL must use http or https" };
}
if (env === "production" && parsed.protocol !== "https:") {
return { ok: false, error: "URL must use https in production" };
}
return { ok: true };
}
function isPrivateHostname(hostname: string): boolean {
return BLOCKED_HOSTNAMES.test(hostname);
}
describe("webhook URL validation — basic", () => {
it("valid https URL passes", () => {
expect(validateWebhookUrl("https://example.com/webhook")).toEqual({ ok: true });
});
it("valid http URL passes in non-prod", () => {
expect(validateWebhookUrl("http://example.com/webhook", "development")).toEqual({ ok: true });
});
it("invalid URL rejected", () => {
const r = validateWebhookUrl("not-a-url");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string }).error).toMatch(/invalid url/i);
});
it("ftp:// protocol rejected", () => {
const r = validateWebhookUrl("ftp://example.com/hook");
expect(r.ok).toBe(false);
});
it("ws:// protocol rejected", () => {
const r = validateWebhookUrl("ws://example.com/hook");
expect(r.ok).toBe(false);
});
it("empty string rejected", () => {
expect(validateWebhookUrl("").ok).toBe(false);
});
});
describe("webhook URL validation — production HTTPS enforcement", () => {
it("http rejected in production", () => {
const r = validateWebhookUrl("http://example.com/webhook", "production");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string }).error).toMatch(/https/i);
});
it("https accepted in production", () => {
expect(validateWebhookUrl("https://example.com/webhook", "production")).toEqual({ ok: true });
});
it("http accepted in development", () => {
expect(validateWebhookUrl("http://example.com/webhook", "development")).toEqual({ ok: true });
});
});
describe("SSRF — private hostname detection", () => {
const blocked = [
"localhost",
"LOCALHOST",
"127.0.0.1",
"127.1.2.3",
"0.0.0.0",
"::1",
"10.0.0.1",
"10.255.255.255",
"172.16.0.1",
"172.20.5.5",
"172.31.255.255",
"192.168.0.1",
"192.168.1.100",
"169.254.0.1",
"169.254.169.254", // AWS metadata
];
const allowed = [
"example.com",
"api.example.com",
"172.15.0.1", // just outside 172.16-31 range
"172.32.0.1", // just outside 172.16-31 range
"192.169.0.1", // not 192.168
"11.0.0.1", // not 10.x
"8.8.8.8",
];
for (const host of blocked) {
it(`blocks ${host}`, () => {
expect(isPrivateHostname(host)).toBe(true);
});
}
for (const host of allowed) {
it(`allows ${host}`, () => {
expect(isPrivateHostname(host)).toBe(false);
});
}
});
// ─── Key rotation guard ───────────────────────────────────────────────────────
type RotationStatus = "pending" | "running" | "done" | "failed" | null;
function canStartRotation(currentStatus: RotationStatus): boolean {
return currentStatus !== "pending" && currentStatus !== "running";
}
describe("key rotation concurrency guard", () => {
it("null status → can start", () => {
expect(canStartRotation(null)).toBe(true);
});
it("done status → can start", () => {
expect(canStartRotation("done")).toBe(true);
});
it("failed status → can start (retry allowed)", () => {
expect(canStartRotation("failed")).toBe(true);
});
it("pending status → cannot start (already queued)", () => {
expect(canStartRotation("pending")).toBe(false);
});
it("running status → cannot start (in progress)", () => {
expect(canStartRotation("running")).toBe(false);
});
});
// ─── API key revocation idempotency ──────────────────────────────────────────
interface FakeApiKey {
id: string;
appId: string;
revokedAt: Date | null;
}
function revokeApiKey(
key: FakeApiKey | null,
requestedAppId: string,
): { ok: true } | { ok: false; error: string; status: number } {
if (!key || key.appId !== requestedAppId) {
return { ok: false, error: "Key not found", status: 404 };
}
if (key.revokedAt) {
return { ok: false, error: "Key already revoked", status: 409 };
}
key.revokedAt = new Date();
return { ok: true };
}
describe("API key revocation idempotency", () => {
function makeKey(appId = "app1"): FakeApiKey {
return { id: "key1", appId, revokedAt: null };
}
it("first revocation succeeds", () => {
const key = makeKey();
expect(revokeApiKey(key, "app1")).toEqual({ ok: true });
});
it("second revocation → 409", () => {
const key = makeKey();
revokeApiKey(key, "app1");
const r = revokeApiKey(key, "app1");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string; status: number }).status).toBe(409);
});
it("key not found → 404", () => {
const r = revokeApiKey(null, "app1");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string; status: number }).status).toBe(404);
});
it("wrong appId → 404 (cross-app isolation)", () => {
const key = makeKey("app1");
const r = revokeApiKey(key, "app2");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string; status: number }).status).toBe(404);
});
it("revocation sets revokedAt timestamp", () => {
const key = makeKey();
const before = Date.now();
revokeApiKey(key, "app1");
expect(key.revokedAt).toBeInstanceOf(Date);
expect(key.revokedAt!.getTime()).toBeGreaterThanOrEqual(before);
});
});
// ─── Broadcast recipient cap ──────────────────────────────────────────────────
const BROADCAST_MAX = 1000;
function validateBroadcastRecipients(
recipients: string[],
): { ok: true } | { ok: false; error: string } {
if (recipients.length === 0) return { ok: false, error: "No recipients" };
if (recipients.length > BROADCAST_MAX) {
return { ok: false, error: `Recipient count ${recipients.length} exceeds maximum of ${BROADCAST_MAX}` };
}
return { ok: true };
}
describe("broadcast recipient cap", () => {
it("1 recipient → ok", () => {
expect(validateBroadcastRecipients(["a"])).toEqual({ ok: true });
});
it("1000 recipients → ok (at limit)", () => {
const r = Array.from({ length: 1000 }, (_, i) => `${i}@s.whatsapp.net`);
expect(validateBroadcastRecipients(r)).toEqual({ ok: true });
});
it("1001 recipients → rejected", () => {
const r = Array.from({ length: 1001 }, (_, i) => `${i}@s.whatsapp.net`);
const result = validateBroadcastRecipients(r);
expect(result.ok).toBe(false);
expect((result as { ok: false; error: string }).error).toMatch(/1001/);
});
it("empty list → rejected", () => {
expect(validateBroadcastRecipients([])).toEqual({ ok: false, error: "No recipients" });
});
});
// ─── File upload size limit ───────────────────────────────────────────────────
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
function validateUploadSize(bytes: number): { ok: true } | { ok: false; error: string } {
if (bytes > MAX_UPLOAD_BYTES) {
return { ok: false, error: `File too large: ${bytes} bytes (max ${MAX_UPLOAD_BYTES})` };
}
return { ok: true };
}
describe("contact import file size limit (10 MB)", () => {
it("exactly 10 MB → ok", () => {
expect(validateUploadSize(MAX_UPLOAD_BYTES)).toEqual({ ok: true });
});
it("10 MB + 1 byte → rejected", () => {
expect(validateUploadSize(MAX_UPLOAD_BYTES + 1).ok).toBe(false);
});
it("0 bytes → ok (let content validation handle empty)", () => {
expect(validateUploadSize(0)).toEqual({ ok: true });
});
it("5 MB → ok", () => {
expect(validateUploadSize(5 * 1024 * 1024)).toEqual({ ok: true });
});
it("error message includes actual and max size", () => {
const r = validateUploadSize(MAX_UPLOAD_BYTES + 1) as { ok: false; error: string };
expect(r.error).toMatch(/10485761/);
expect(r.error).toMatch(/10485760/);
});
});

View file

@ -40,7 +40,9 @@ async function createAppAndKey(
await page.getByText(appName).click();
await page.getByRole("button", { name: "Generate key" }).click();
await page.getByRole("button", { name: "Generate" }).click();
await expect(page.locator("text=Copy this key").first()).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=Copy this key").first()).toBeVisible({
timeout: 10000,
});
const apiKey = await page.locator("code").first().innerText();
const appId = page.url().split("/apps/")[1].replace(/\/$/, "");
return { appId, apiKey };
@ -102,13 +104,24 @@ test.describe.serial("M3 Messaging API", () => {
const nonExistentId = "00000000-0000-0000-0000-000000000000";
const endpoints = [
{ method: "POST", path: `/api/v1/sessions/${nonExistentId}/messages/text`, body: { to: "1@s.whatsapp.net", text: "hi" } },
{
method: "POST",
path: `/api/v1/sessions/${nonExistentId}/messages/text`,
body: { to: "1@s.whatsapp.net", text: "hi" },
},
{ method: "GET", path: `/api/v1/sessions/${nonExistentId}/messages` },
{ method: "POST", path: `/api/v1/sessions/${nonExistentId}/presence`, body: { type: "typing" } },
{
method: "POST",
path: `/api/v1/sessions/${nonExistentId}/presence`,
body: { type: "typing" },
},
];
for (const ep of endpoints) {
const result = await api(page, ep.method, ep.path, { apiKey, body: ep.body });
const result = await api(page, ep.method, ep.path, {
apiKey,
body: ep.body,
});
expect(result.status, `${ep.method} ${ep.path} should be 404`).toBe(404);
}
});
@ -122,27 +135,56 @@ test.describe.serial("M3 Messaging API", () => {
const { appId, apiKey } = await createAppAndKey(page);
// Create a QR session (status = PENDING_QR)
const createResult = await api(
page,
"POST",
`/api/v1/sessions`,
{ apiKey, body: { via: "qr" } },
);
const createResult = await api(page, "POST", `/api/v1/sessions`, {
apiKey,
body: { via: "qr" },
});
expect(createResult.status).toBe(201);
const sessionId = createResult.body as { id: string };
const endpoints = [
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/messages/text`, body: { to: "1@s.whatsapp.net", text: "hi" } },
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/messages/image`, body: { to: "1@s.whatsapp.net", url: "http://example.com/img.jpg" } },
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/messages/video`, body: { to: "1@s.whatsapp.net", url: "http://example.com/vid.mp4" } },
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/messages/document`, body: { to: "1@s.whatsapp.net", url: "http://example.com/doc.pdf" } },
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/messages/audio`, body: { to: "1@s.whatsapp.net", url: "http://example.com/audio.ogg" } },
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/messages/broadcast`, body: { recipients: ["1@s.whatsapp.net"], type: "text", text: "hi" } },
{ method: "POST", path: `/api/v1/sessions/${sessionId.id}/presence`, body: { type: "typing" } },
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/messages/text`,
body: { to: "1@s.whatsapp.net", text: "hi" },
},
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/messages/image`,
body: { to: "1@s.whatsapp.net", url: "http://example.com/img.jpg" },
},
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/messages/video`,
body: { to: "1@s.whatsapp.net", url: "http://example.com/vid.mp4" },
},
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/messages/document`,
body: { to: "1@s.whatsapp.net", url: "http://example.com/doc.pdf" },
},
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/messages/audio`,
body: { to: "1@s.whatsapp.net", url: "http://example.com/audio.ogg" },
},
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/messages/broadcast`,
body: { recipients: ["1@s.whatsapp.net"], type: "text", text: "hi" },
},
{
method: "POST",
path: `/api/v1/sessions/${sessionId.id}/presence`,
body: { type: "typing" },
},
];
for (const ep of endpoints) {
const result = await api(page, ep.method, ep.path, { apiKey, body: ep.body });
const result = await api(page, ep.method, ep.path, {
apiKey,
body: ep.body,
});
expect(result.status, `${ep.method} ${ep.path} should be 409`).toBe(409);
}
});

View file

@ -34,7 +34,6 @@ function verify(
}
describe("LocalStorageDriver signed URL", () => {
const SECRET = "test-signing-secret-32-bytes-long!";
it("valid signature verifies", () => {
@ -48,7 +47,9 @@ describe("LocalStorageDriver signed URL", () => {
const key = "media/app1/sess1/abc.jpg";
const expires = Math.floor(Date.now() / 1000) + 3600;
const sig = sign(key, expires, SECRET);
expect(verify("media/app1/sess1/xyz.jpg", expires, sig, SECRET, Date.now())).toBe(false);
expect(
verify("media/app1/sess1/xyz.jpg", expires, sig, SECRET, Date.now()),
).toBe(false);
});
it("tampered sig fails", () => {
@ -71,12 +72,18 @@ describe("LocalStorageDriver signed URL", () => {
describe("presence type mapping", () => {
function mapBaileys(p: string): string {
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";
case "composing":
return "typing";
case "available":
return "online";
case "unavailable":
return "offline";
case "recording":
return "recording";
case "paused":
return "paused";
default:
return "offline";
}
}
@ -91,18 +98,29 @@ describe("presence type mapping", () => {
return m[p] ?? "unavailable";
}
it("Baileys → API: composing → typing", () => expect(mapBaileys("composing")).toBe("typing"));
it("Baileys → API: available → online", () => expect(mapBaileys("available")).toBe("online"));
it("Baileys → API: unavailable → offline", () => expect(mapBaileys("unavailable")).toBe("offline"));
it("Baileys → API: recording → recording", () => expect(mapBaileys("recording")).toBe("recording"));
it("Baileys → API: paused → paused", () => expect(mapBaileys("paused")).toBe("paused"));
it("Baileys → API: unknown → offline", () => expect(mapBaileys("unknown_type")).toBe("offline"));
it("Baileys → API: composing → typing", () =>
expect(mapBaileys("composing")).toBe("typing"));
it("Baileys → API: available → online", () =>
expect(mapBaileys("available")).toBe("online"));
it("Baileys → API: unavailable → offline", () =>
expect(mapBaileys("unavailable")).toBe("offline"));
it("Baileys → API: recording → recording", () =>
expect(mapBaileys("recording")).toBe("recording"));
it("Baileys → API: paused → paused", () =>
expect(mapBaileys("paused")).toBe("paused"));
it("Baileys → API: unknown → offline", () =>
expect(mapBaileys("unknown_type")).toBe("offline"));
it("API → Baileys: typing → composing", () => expect(mapApi("typing")).toBe("composing"));
it("API → Baileys: online → available", () => expect(mapApi("online")).toBe("available"));
it("API → Baileys: offline → unavailable", () => expect(mapApi("offline")).toBe("unavailable"));
it("API → Baileys: recording → recording", () => expect(mapApi("recording")).toBe("recording"));
it("API → Baileys: paused → paused", () => expect(mapApi("paused")).toBe("paused"));
it("API → Baileys: typing → composing", () =>
expect(mapApi("typing")).toBe("composing"));
it("API → Baileys: online → available", () =>
expect(mapApi("online")).toBe("available"));
it("API → Baileys: offline → unavailable", () =>
expect(mapApi("offline")).toBe("unavailable"));
it("API → Baileys: recording → recording", () =>
expect(mapApi("recording")).toBe("recording"));
it("API → Baileys: paused → paused", () =>
expect(mapApi("paused")).toBe("paused"));
it("round-trip: API → Baileys → API preserves meaning", () => {
const types = ["typing", "online", "offline", "recording", "paused"];
@ -124,12 +142,18 @@ describe("message type detection from Baileys content", () => {
return "unknown";
}
it("conversation → text", () => expect(detectType({ conversation: "hi" })).toBe("text"));
it("extendedTextMessage → text", () => expect(detectType({ extendedTextMessage: { text: "hi" } })).toBe("text"));
it("imageMessage → image", () => expect(detectType({ imageMessage: {} })).toBe("image"));
it("videoMessage → video", () => expect(detectType({ videoMessage: {} })).toBe("video"));
it("documentMessage → document", () => expect(detectType({ documentMessage: {} })).toBe("document"));
it("audioMessage → audio", () => expect(detectType({ audioMessage: {} })).toBe("audio"));
it("conversation → text", () =>
expect(detectType({ conversation: "hi" })).toBe("text"));
it("extendedTextMessage → text", () =>
expect(detectType({ extendedTextMessage: { text: "hi" } })).toBe("text"));
it("imageMessage → image", () =>
expect(detectType({ imageMessage: {} })).toBe("image"));
it("videoMessage → video", () =>
expect(detectType({ videoMessage: {} })).toBe("video"));
it("documentMessage → document", () =>
expect(detectType({ documentMessage: {} })).toBe("document"));
it("audioMessage → audio", () =>
expect(detectType({ audioMessage: {} })).toBe("audio"));
it("empty content → unknown", () => expect(detectType({})).toBe("unknown"));
});
@ -194,7 +218,9 @@ describe("group JID detection", () => {
return jid.endsWith("@g.us");
}
it("group JID detected", () => expect(isGroup("120363000000000@g.us")).toBe(true));
it("individual JID not group", () => expect(isGroup("628123456789@s.whatsapp.net")).toBe(false));
it("group JID detected", () =>
expect(isGroup("120363000000000@g.us")).toBe(true));
it("individual JID not group", () =>
expect(isGroup("628123456789@s.whatsapp.net")).toBe(false));
it("empty string not group", () => expect(isGroup("")).toBe(false));
});

View file

@ -25,13 +25,19 @@ function normalizePhone(phone: string): string {
}
describe("phone normalization", () => {
it("adds + prefix if missing", () => expect(normalizePhone("628123456789")).toBe("+628123456789"));
it("preserves existing + prefix", () => expect(normalizePhone("+628123456789")).toBe("+628123456789"));
it("strips dashes and spaces", () => expect(normalizePhone("+62-812-3456-789")).toBe("+628123456789"));
it("strips parentheses", () => expect(normalizePhone("+1 (415) 555-0100")).toBe("+14155550100"));
it("strips dots", () => expect(normalizePhone("+1.415.555.0100")).toBe("+14155550100"));
it("adds + prefix if missing", () =>
expect(normalizePhone("628123456789")).toBe("+628123456789"));
it("preserves existing + prefix", () =>
expect(normalizePhone("+628123456789")).toBe("+628123456789"));
it("strips dashes and spaces", () =>
expect(normalizePhone("+62-812-3456-789")).toBe("+628123456789"));
it("strips parentheses", () =>
expect(normalizePhone("+1 (415) 555-0100")).toBe("+14155550100"));
it("strips dots", () =>
expect(normalizePhone("+1.415.555.0100")).toBe("+14155550100"));
it("empty string returns +", () => expect(normalizePhone("")).toBe("+"));
it("international with leading zeros", () => expect(normalizePhone("0812-3456-789")).toBe("+08123456789"));
it("international with leading zeros", () =>
expect(normalizePhone("0812-3456-789")).toBe("+08123456789"));
});
// ─── Blind index (AC1) ────────────────────────────────────────────────────────
@ -56,8 +62,8 @@ describe("blind index — AC1: encrypted phone lookup", () => {
});
it("normalization is applied before hashing — equivalent inputs match", () => {
const a = phoneBlindIndex("628123456789", dek); // no +
const b = phoneBlindIndex("+628123456789", dek); // with +
const a = phoneBlindIndex("628123456789", dek); // no +
const b = phoneBlindIndex("+628123456789", dek); // with +
expect(a).toBe(b);
});
@ -90,7 +96,10 @@ const TAG_BYTES = 16;
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 encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
@ -102,7 +111,9 @@ function decryptColumn(ciphertext: string, dek: Buffer): string {
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");
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
}
describe("contact PII encryption round-trip", () => {
@ -165,7 +176,9 @@ function upsertContact(
dek: Buffer,
): { created: boolean; contact: FakeContact } {
const blind = phoneBlindIndex(phone, dek);
const existing = store.find((c) => c.appId === appId && c.blindIndex === blind);
const existing = store.find(
(c) => c.appId === appId && c.blindIndex === blind,
);
if (existing) {
if (name !== undefined) existing.name = name;
return { created: false, contact: existing };
@ -186,7 +199,13 @@ describe("contact deduplication — AC2: no duplicates on re-import", () => {
it("first insert creates record", () => {
const store: FakeContact[] = [];
const { created } = upsertContact(store, "app1", "+628111111111", "Alice", dek);
const { created } = upsertContact(
store,
"app1",
"+628111111111",
"Alice",
dek,
);
expect(created).toBe(true);
expect(store).toHaveLength(1);
});
@ -201,8 +220,8 @@ describe("contact deduplication — AC2: no duplicates on re-import", () => {
it("same phone different formatting → still deduplicates", () => {
const store: FakeContact[] = [];
upsertContact(store, "app1", "628111111111", "Alice", dek); // no +
upsertContact(store, "app1", "+628-111-111-111", "Alice", dek); // with + and dashes
upsertContact(store, "app1", "628111111111", "Alice", dek); // no +
upsertContact(store, "app1", "+628-111-111-111", "Alice", dek); // with + and dashes
expect(store).toHaveLength(1);
});
@ -215,14 +234,19 @@ describe("contact deduplication — AC2: no duplicates on re-import", () => {
it("500-row import then re-import = same count", () => {
const store: FakeContact[] = [];
const rows = Array.from({ length: 500 }, (_, i) => `+628${String(i).padStart(9, "0")}`);
const rows = Array.from(
{ length: 500 },
(_, i) => `+628${String(i).padStart(9, "0")}`,
);
// First import
for (const phone of rows) upsertContact(store, "app1", phone, undefined, dek);
for (const phone of rows)
upsertContact(store, "app1", phone, undefined, dek);
expect(store).toHaveLength(500);
// Re-import same rows
for (const phone of rows) upsertContact(store, "app1", phone, undefined, dek);
for (const phone of rows)
upsertContact(store, "app1", phone, undefined, dek);
expect(store).toHaveLength(500); // no duplicates
});
});
@ -254,7 +278,10 @@ function dryRunCsv(rows: CsvRow[]): DryRunResult {
describe("CSV dry-run — AC3: preview without persistence", () => {
it("returns first 10 rows as preview", () => {
const rows = Array.from({ length: 25 }, (_, i) => ({ phone: `+62811${String(i).padStart(3, "0")}`, name: `User${i}` }));
const rows = Array.from({ length: 25 }, (_, i) => ({
phone: `+62811${String(i).padStart(3, "0")}`,
name: `User${i}`,
}));
const { preview } = dryRunCsv(rows);
expect(preview).toHaveLength(10);
expect(preview[0].phone).toBe("+62811000");
@ -339,31 +366,55 @@ interface FakeMembership {
groupId: string;
}
function addToGroups(memberships: FakeMembership[], contactId: string, groupIds: string[]): void {
function addToGroups(
memberships: FakeMembership[],
contactId: string,
groupIds: string[],
): void {
for (const groupId of groupIds) {
const exists = memberships.some((m) => m.contactId === contactId && m.groupId === groupId);
const exists = memberships.some(
(m) => m.contactId === contactId && m.groupId === groupId,
);
if (!exists) memberships.push({ contactId, groupId });
}
}
function removeFromGroup(memberships: FakeMembership[], contactId: string, groupId: string): void {
const idx = memberships.findIndex((m) => m.contactId === contactId && m.groupId === groupId);
function removeFromGroup(
memberships: FakeMembership[],
contactId: string,
groupId: string,
): void {
const idx = memberships.findIndex(
(m) => m.contactId === contactId && m.groupId === groupId,
);
if (idx !== -1) memberships.splice(idx, 1);
}
function getGroupsForContact(memberships: FakeMembership[], contactId: string): string[] {
return memberships.filter((m) => m.contactId === contactId).map((m) => m.groupId);
function getGroupsForContact(
memberships: FakeMembership[],
contactId: string,
): string[] {
return memberships
.filter((m) => m.contactId === contactId)
.map((m) => m.groupId);
}
function getMembersOfGroup(memberships: FakeMembership[], groupId: string): string[] {
return memberships.filter((m) => m.groupId === groupId).map((m) => m.contactId);
function getMembersOfGroup(
memberships: FakeMembership[],
groupId: string,
): string[] {
return memberships
.filter((m) => m.groupId === groupId)
.map((m) => m.contactId);
}
describe("contact group membership — AC5: isolation", () => {
it("contact added to 2 groups appears in both", () => {
const m: FakeMembership[] = [];
addToGroups(m, "c1", ["g1", "g2"]);
expect(getGroupsForContact(m, "c1")).toEqual(expect.arrayContaining(["g1", "g2"]));
expect(getGroupsForContact(m, "c1")).toEqual(
expect.arrayContaining(["g1", "g2"]),
);
expect(getGroupsForContact(m, "c1")).toHaveLength(2);
});
@ -423,6 +474,8 @@ interface ValidationResult {
errors: Array<{ field: string; message: string }>;
}
// Mirrors production `validateCustomFields` in src/lib/actions/contacts.ts.
// Permissive: accepts coercible strings for number ("123") and bool ("true"/"false").
function validateCustomFields(
fields: Record<string, unknown>,
schemas: FieldSchema[],
@ -432,7 +485,10 @@ function validateCustomFields(
for (const schema of schemas) {
const value = fields[schema.key];
if (schema.required && (value === undefined || value === null || value === "")) {
if (
schema.required &&
(value === undefined || value === null || value === "")
) {
errors.push({ field: schema.key, message: `${schema.key} is required` });
continue;
}
@ -441,23 +497,41 @@ function validateCustomFields(
switch (schema.type) {
case "number":
if (typeof value !== "number" || isNaN(value)) {
errors.push({ field: schema.key, message: `${schema.key} must be a number` });
// Production: accepts numeric strings ("123") — rejects non-coercible ("abc")
if (typeof value !== "number" && isNaN(Number(value))) {
errors.push({
field: schema.key,
message: `${schema.key} must be a number`,
});
}
break;
case "bool":
if (typeof value !== "boolean") {
errors.push({ field: schema.key, message: `${schema.key} must be a boolean` });
// Production: accepts "true"/"false" strings in addition to booleans
if (
typeof value !== "boolean" &&
value !== "true" &&
value !== "false"
) {
errors.push({
field: schema.key,
message: `${schema.key} must be a boolean`,
});
}
break;
case "date":
if (typeof value !== "string" || isNaN(Date.parse(value))) {
errors.push({ field: schema.key, message: `${schema.key} must be a valid date string` });
errors.push({
field: schema.key,
message: `${schema.key} must be a valid date string`,
});
}
break;
case "text":
if (typeof value !== "string") {
errors.push({ field: schema.key, message: `${schema.key} must be a string` });
errors.push({
field: schema.key,
message: `${schema.key} must be a string`,
});
}
break;
}
@ -494,25 +568,37 @@ describe("custom field schema validation — AC6", () => {
});
it("wrong type for number fails", () => {
const { valid, errors } = validateCustomFields({ tier: "gold", score: "not-a-number" }, schemas);
const { valid, errors } = validateCustomFields(
{ tier: "gold", score: "not-a-number" },
schemas,
);
expect(valid).toBe(false);
expect(errors[0].field).toBe("score");
});
it("wrong type for bool fails", () => {
const { valid, errors } = validateCustomFields({ tier: "gold", active: "yes" }, schemas);
const { valid, errors } = validateCustomFields(
{ tier: "gold", active: "yes" },
schemas,
);
expect(valid).toBe(false);
expect(errors[0].field).toBe("active");
});
it("invalid date string fails", () => {
const { valid, errors } = validateCustomFields({ tier: "gold", joined_at: "not-a-date" }, schemas);
const { valid, errors } = validateCustomFields(
{ tier: "gold", joined_at: "not-a-date" },
schemas,
);
expect(valid).toBe(false);
expect(errors[0].field).toBe("joined_at");
});
it("valid ISO date passes", () => {
const { valid } = validateCustomFields({ tier: "gold", joined_at: "2026-05-16T00:00:00Z" }, schemas);
const { valid } = validateCustomFields(
{ tier: "gold", joined_at: "2026-05-16T00:00:00Z" },
schemas,
);
expect(valid).toBe(true);
});
@ -522,9 +608,55 @@ describe("custom field schema validation — AC6", () => {
});
it("extra fields not in schema are ignored", () => {
const { valid } = validateCustomFields({ tier: "gold", unknown_field: 999 }, schemas);
const { valid } = validateCustomFields(
{ tier: "gold", unknown_field: 999 },
schemas,
);
expect(valid).toBe(true);
});
// Permissive coercion — production allows these
it("numeric string passes for number field (coercible)", () => {
const { valid } = validateCustomFields(
{ tier: "gold", score: "42" },
schemas,
);
expect(valid).toBe(true);
});
it("non-coercible string fails for number field", () => {
const { valid, errors } = validateCustomFields(
{ tier: "gold", score: "not-a-number" },
schemas,
);
expect(valid).toBe(false);
expect(errors[0].field).toBe("score");
});
it('"true" string passes for bool field', () => {
const { valid } = validateCustomFields(
{ tier: "gold", active: "true" },
schemas,
);
expect(valid).toBe(true);
});
it('"false" string passes for bool field', () => {
const { valid } = validateCustomFields(
{ tier: "gold", active: "false" },
schemas,
);
expect(valid).toBe(true);
});
it("arbitrary string fails for bool field", () => {
const { valid, errors } = validateCustomFields(
{ tier: "gold", active: "yes" },
schemas,
);
expect(valid).toBe(false);
expect(errors[0].field).toBe("active");
});
});
// ─── Import worker queue name ─────────────────────────────────────────────────

485
tests/m5-unit.test.ts Normal file
View file

@ -0,0 +1,485 @@
/**
* M5 unit tests — pure logic, no DB/network/Redis required.
*
* Coverage:
* - HMAC signing: format, determinism, wrong-secret rejection
* - Retry delay schedule: bounds, out-of-bounds → null (no more retries)
* - Consecutive-failure unhealthy threshold (50)
* - Delivery status after success vs failure
* - Endpoint event-filter matching: exact, wildcard (*), empty-array (all)
* - Payload enrichment: event/event_id/app_id/timestamp always present
* - Secret generation: 64-char hex, uniqueness
* - Queue name: no colon (BullMQ constraint)
* - Delivery log retention cutoff logic
*/
import { describe, it, expect } from "vitest";
import { createHmac, randomBytes } from "node:crypto";
// ─── HMAC signing (FR-W3) ─────────────────────────────────────────────────────
function sign(body: string, timestamp: string, secret: string): string {
const mac = createHmac("sha256", secret)
.update(`${body}.${timestamp}`)
.digest("hex");
return `sha256=${mac}`;
}
function verifySignature(
body: string,
timestamp: string,
signature: string,
secret: string,
): boolean {
const expected = sign(body, timestamp, secret);
return expected === signature;
}
describe("HMAC webhook signing (FR-W3)", () => {
const SECRET =
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
const BODY = JSON.stringify({ event: "message.received", data: { id: "x" } });
const TS = "1747382400000";
it("signature starts with sha256=", () => {
expect(sign(BODY, TS, SECRET)).toMatch(/^sha256=[0-9a-f]{64}$/);
});
it("same inputs produce same signature", () => {
expect(sign(BODY, TS, SECRET)).toBe(sign(BODY, TS, SECRET));
});
it("different body produces different signature", () => {
const other = JSON.stringify({ event: "message.sent" });
expect(sign(BODY, TS, SECRET)).not.toBe(sign(other, TS, SECRET));
});
it("different timestamp produces different signature", () => {
expect(sign(BODY, TS, SECRET)).not.toBe(
sign(BODY, "9999999999999", SECRET),
);
});
it("different secret produces different signature", () => {
const other =
"0000000000000000000000000000000000000000000000000000000000000000";
expect(sign(BODY, TS, SECRET)).not.toBe(sign(BODY, TS, other));
});
it("correct signature verifies", () => {
const sig = sign(BODY, TS, SECRET);
expect(verifySignature(BODY, TS, sig, SECRET)).toBe(true);
});
it("tampered body fails verification", () => {
const sig = sign(BODY, TS, SECRET);
expect(verifySignature(BODY + "x", TS, sig, SECRET)).toBe(false);
});
it("wrong secret fails verification", () => {
const sig = sign(BODY, TS, SECRET);
const wrong = SECRET.replace("a", "b");
expect(verifySignature(BODY, TS, sig, wrong)).toBe(false);
});
it("separator is dot between body and timestamp", () => {
// Verify internal format: HMAC(body + "." + timestamp)
const manual = createHmac("sha256", SECRET)
.update(`${BODY}.${TS}`)
.digest("hex");
expect(sign(BODY, TS, SECRET)).toBe(`sha256=${manual}`);
});
});
// ─── Retry delay schedule (FR-W4) ────────────────────────────────────────────
const RETRY_DELAYS = [
0, 30_000, 120_000, 600_000, 3_600_000, 21_600_000, 86_400_000,
];
function nextDelay(attempt: number): number | undefined {
return (RETRY_DELAYS as (number | undefined)[])[attempt];
}
describe("retry delay schedule (FR-W4)", () => {
it("has exactly 7 entries", () => {
expect(RETRY_DELAYS).toHaveLength(7);
});
it("attempt 0: immediate (0ms)", () => {
expect(nextDelay(0)).toBe(0);
});
it("attempt 1: 30s", () => {
expect(nextDelay(1)).toBe(30_000);
});
it("attempt 2: 2m", () => {
expect(nextDelay(2)).toBe(120_000);
});
it("attempt 3: 10m", () => {
expect(nextDelay(3)).toBe(600_000);
});
it("attempt 4: 1h", () => {
expect(nextDelay(4)).toBe(3_600_000);
});
it("attempt 5: 6h", () => {
expect(nextDelay(5)).toBe(21_600_000);
});
it("attempt 6: 24h", () => {
expect(nextDelay(6)).toBe(86_400_000);
});
it("attempt 7: undefined (exhausted — no more retries)", () => {
expect(nextDelay(7)).toBeUndefined();
});
it("delays are strictly increasing", () => {
for (let i = 1; i < RETRY_DELAYS.length; i++) {
expect(RETRY_DELAYS[i]).toBeGreaterThan(RETRY_DELAYS[i - 1]);
}
});
it("next attempt scheduled when nextDelay is defined", () => {
const attempt = 2; // worker is on attempt 2, next = index 3
const delay = nextDelay(attempt + 1);
expect(delay).not.toBeUndefined();
expect(delay).toBe(600_000); // 10m
});
it("no next attempt when delay is undefined (last attempt)", () => {
const attempt = 6; // last index
const delay = nextDelay(attempt + 1);
expect(delay).toBeUndefined();
});
});
// ─── Consecutive failures / unhealthy threshold ───────────────────────────────
interface FakeEndpoint {
id: string;
consecutiveFailures: number;
unhealthySince: Date | null;
enabled: boolean;
}
function applyFailure(endpoint: FakeEndpoint): void {
endpoint.consecutiveFailures += 1;
if (endpoint.consecutiveFailures >= 50 && !endpoint.unhealthySince) {
endpoint.unhealthySince = new Date();
endpoint.enabled = false;
}
}
function applySuccess(endpoint: FakeEndpoint): void {
endpoint.consecutiveFailures = 0;
endpoint.unhealthySince = null;
}
describe("consecutive-failure unhealthy threshold", () => {
function makeEndpoint(): FakeEndpoint {
return {
id: "ep1",
consecutiveFailures: 0,
unhealthySince: null,
enabled: true,
};
}
it("49 failures → still healthy, enabled", () => {
const ep = makeEndpoint();
for (let i = 0; i < 49; i++) applyFailure(ep);
expect(ep.enabled).toBe(true);
expect(ep.unhealthySince).toBeNull();
expect(ep.consecutiveFailures).toBe(49);
});
it("50th failure → unhealthy, disabled", () => {
const ep = makeEndpoint();
for (let i = 0; i < 50; i++) applyFailure(ep);
expect(ep.enabled).toBe(false);
expect(ep.unhealthySince).toBeInstanceOf(Date);
});
it("success resets counter and clears unhealthy", () => {
const ep = makeEndpoint();
for (let i = 0; i < 10; i++) applyFailure(ep);
applySuccess(ep);
expect(ep.consecutiveFailures).toBe(0);
expect(ep.unhealthySince).toBeNull();
});
it("once unhealthy, further failures do not re-set unhealthySince", () => {
const ep = makeEndpoint();
for (let i = 0; i < 50; i++) applyFailure(ep);
const first = ep.unhealthySince!;
applyFailure(ep);
expect(ep.unhealthySince).toBe(first); // same Date object
});
it("success after unhealthy re-enables (clears unhealthySince)", () => {
const ep = makeEndpoint();
for (let i = 0; i < 50; i++) applyFailure(ep);
applySuccess(ep);
expect(ep.consecutiveFailures).toBe(0);
expect(ep.unhealthySince).toBeNull();
});
});
// ─── Delivery status logic ────────────────────────────────────────────────────
type DeliveryStatus = "pending" | "success" | "failed";
function deliveryStatusAfterAttempt(
success: boolean,
nextAttemptAt: Date | null,
): DeliveryStatus {
if (success) return "success";
return nextAttemptAt ? "pending" : "failed";
}
describe("delivery status after attempt", () => {
it("success → 'success'", () => {
expect(deliveryStatusAfterAttempt(true, null)).toBe("success");
});
it("failure with retry scheduled → 'pending'", () => {
expect(deliveryStatusAfterAttempt(false, new Date())).toBe("pending");
});
it("failure no retry left → 'failed'", () => {
expect(deliveryStatusAfterAttempt(false, null)).toBe("failed");
});
});
// ─── Endpoint event-filter matching ──────────────────────────────────────────
function endpointMatchesEvent(
endpointEvents: string[],
eventType: string,
): boolean {
if (endpointEvents.length === 0) return true; // empty = all events
return endpointEvents.includes(eventType) || endpointEvents.includes("*");
}
describe("endpoint event-filter matching", () => {
it("empty events array matches everything", () => {
expect(endpointMatchesEvent([], "message.received")).toBe(true);
expect(endpointMatchesEvent([], "contact.created")).toBe(true);
});
it("exact match", () => {
expect(endpointMatchesEvent(["message.received"], "message.received")).toBe(
true,
);
});
it("no match", () => {
expect(endpointMatchesEvent(["message.received"], "message.sent")).toBe(
false,
);
});
it("wildcard * matches any event", () => {
expect(endpointMatchesEvent(["*"], "anything.at.all")).toBe(true);
});
it("multiple specific events: matches first", () => {
expect(
endpointMatchesEvent(
["message.received", "message.sent"],
"message.sent",
),
).toBe(true);
});
it("multiple specific events: no match for unlisted", () => {
expect(
endpointMatchesEvent(
["message.received", "message.sent"],
"contact.created",
),
).toBe(false);
});
it("all FR-W2 event types recognized by wildcard", () => {
const allEvents = [
"session.status",
"session.deleted",
"message.received",
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"presence.update",
"contact.created",
"contact.updated",
];
for (const evt of allEvents) {
expect(endpointMatchesEvent(["*"], evt)).toBe(true);
}
});
});
// ─── Payload enrichment ───────────────────────────────────────────────────────
interface EnrichedPayload extends Record<string, unknown> {
event: string;
event_id: string;
app_id: string;
timestamp: string;
}
function enrichPayload(
base: Record<string, unknown>,
eventType: string,
eventId: string,
appId: string,
): EnrichedPayload {
return {
...base,
event: eventType,
event_id: eventId,
app_id: appId,
timestamp: new Date().toISOString(),
};
}
describe("payload enrichment", () => {
it("always includes event, event_id, app_id, timestamp", () => {
const p = enrichPayload(
{ data: 1 },
"message.received",
"evt-123",
"app-abc",
);
expect(p.event).toBe("message.received");
expect(p.event_id).toBe("evt-123");
expect(p.app_id).toBe("app-abc");
expect(typeof p.timestamp).toBe("string");
expect(Date.parse(p.timestamp)).not.toBeNaN();
});
it("preserves base payload fields", () => {
const p = enrichPayload({ foo: "bar", count: 42 }, "x", "y", "z");
expect(p.foo).toBe("bar");
expect(p.count).toBe(42);
});
it("enrichment fields override base if conflicting", () => {
// Shouldn't happen in practice, but enrichment wins
const p = enrichPayload({ event: "old" }, "new.event", "id", "app");
expect(p.event).toBe("new.event");
});
});
// ─── Secret generation ────────────────────────────────────────────────────────
function generateSecret(): string {
return randomBytes(32).toString("hex");
}
describe("webhook secret generation", () => {
it("produces 64-char hex string", () => {
const s = generateSecret();
expect(s).toHaveLength(64);
expect(s).toMatch(/^[0-9a-f]+$/);
});
it("each call produces unique secret", () => {
const secrets = new Set(Array.from({ length: 100 }, generateSecret));
expect(secrets.size).toBe(100);
});
});
// ─── Queue name ───────────────────────────────────────────────────────────────
describe("webhook queue name", () => {
const QUEUE_WEBHOOK = "webhook-delivery";
it("no colon in queue name (BullMQ Redis key constraint)", () => {
expect(QUEUE_WEBHOOK).not.toContain(":");
});
it("matches expected value", () => {
expect(QUEUE_WEBHOOK).toBe("webhook-delivery");
});
});
// ─── Delivery log retention ───────────────────────────────────────────────────
interface FakeDelivery {
id: string;
endpointId: string;
createdAt: Date;
}
function pruneDeliveries(
deliveries: FakeDelivery[],
endpointId: string,
retain: number,
): FakeDelivery[] {
const forEndpoint = deliveries
.filter((d) => d.endpointId === endpointId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); // newest first
if (forEndpoint.length < retain) return deliveries;
const cutoff = forEndpoint[retain - 1].createdAt;
return deliveries.filter(
(d) => d.endpointId !== endpointId || d.createdAt >= cutoff,
);
}
describe("delivery log retention (pruneDeliveries)", () => {
const RETAIN = 10;
function makeDeliveries(n: number, endpointId = "ep1"): FakeDelivery[] {
return Array.from({ length: n }, (_, i) => ({
id: `d${i}`,
endpointId,
createdAt: new Date(1_000_000 + i * 1000), // ascending timestamps
}));
}
it("fewer than retain — no deletion", () => {
const d = makeDeliveries(9);
expect(pruneDeliveries(d, "ep1", RETAIN)).toHaveLength(9);
});
it("exactly retain — no deletion", () => {
const d = makeDeliveries(10);
expect(pruneDeliveries(d, "ep1", RETAIN)).toHaveLength(10);
});
it("more than retain — oldest pruned, keep exactly retain", () => {
const d = makeDeliveries(15);
const result = pruneDeliveries(d, "ep1", RETAIN);
const forEp = result.filter((x) => x.endpointId === "ep1");
expect(forEp).toHaveLength(10);
// Newest 10 kept (indices 5-14)
const ids = forEp.map((x) => x.id).sort();
expect(ids).toEqual([
"d10",
"d11",
"d12",
"d13",
"d14",
"d5",
"d6",
"d7",
"d8",
"d9",
]);
});
it("other endpoints not affected by pruning", () => {
const d = [...makeDeliveries(15, "ep1"), ...makeDeliveries(5, "ep2")];
const result = pruneDeliveries(d, "ep1", RETAIN);
const ep2 = result.filter((x) => x.endpointId === "ep2");
expect(ep2).toHaveLength(5);
});
});

View file

@ -0,0 +1,524 @@
/**
* WhatsApp groups unit tests — pure logic, no DB/network required.
*
* Coverage:
* - JID group detection (@g.us suffix)
* - upsert merge logic (partial updates, undefined fields not clobbered)
* - adjustParticipantCount: add/remove deltas, floor at 0
* - listWhatsappGroups filter: by appId, by sessionId, unfiltered
* - Group subject/description normalization
* - Webhook event routing: ownAdded/ownRemoved/other add/remove
* - Webhook payload shapes for all 5 group event types
* - Own JID matching: exact, LID prefix (:0 suffix stripped)
*/
import { describe, it, expect } from "vitest";
// ─── JID group detection ──────────────────────────────────────────────────────
function isGroupJid(jid: string): boolean {
return jid.endsWith("@g.us");
}
describe("JID group detection", () => {
it("@g.us JID is a group", () => {
expect(isGroupJid("120363429229698693@g.us")).toBe(true);
});
it("@s.whatsapp.net JID is not a group", () => {
expect(isGroupJid("6281234567890@s.whatsapp.net")).toBe(false);
});
it("empty string is not a group", () => {
expect(isGroupJid("")).toBe(false);
});
it("partial @g.us without domain prefix is not a group", () => {
expect(isGroupJid("@g.us")).toBe(true); // ends with @g.us — still truthy by rule
});
it("random string without suffix is not a group", () => {
expect(isGroupJid("somegroup")).toBe(false);
});
});
// ─── Upsert merge logic ───────────────────────────────────────────────────────
interface FakeGroup {
jid: string;
sessionId: string;
appId: string;
subject: string | null;
description: string | null;
participantCount: number | null;
updatedAt: Date;
}
function upsertGroup(
store: Map<string, FakeGroup>,
data: {
appId: string;
sessionId: string;
jid: string;
subject?: string | null;
description?: string | null;
participantCount?: number | null;
},
): FakeGroup {
const key = `${data.sessionId}:${data.jid}`;
const existing = store.get(key);
const now = new Date();
if (!existing) {
const row: FakeGroup = {
jid: data.jid,
sessionId: data.sessionId,
appId: data.appId,
subject: data.subject ?? null,
description: data.description ?? null,
participantCount: data.participantCount ?? null,
updatedAt: now,
};
store.set(key, row);
return row;
}
if (data.subject !== undefined) existing.subject = data.subject;
if (data.description !== undefined) existing.description = data.description;
if (data.participantCount !== undefined) existing.participantCount = data.participantCount;
existing.updatedAt = now;
return existing;
}
describe("upsert merge logic", () => {
it("inserts new group", () => {
const store = new Map<string, FakeGroup>();
const g = upsertGroup(store, {
appId: "app1",
sessionId: "sess1",
jid: "123@g.us",
subject: "Dev Team",
participantCount: 5,
});
expect(g.subject).toBe("Dev Team");
expect(g.participantCount).toBe(5);
expect(store.size).toBe(1);
});
it("updates subject on re-upsert", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "Old" });
const g = upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "New" });
expect(g.subject).toBe("New");
expect(store.size).toBe(1);
});
it("undefined subject does not clobber existing subject", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "Keep me" });
const g = upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", description: "new desc" });
expect(g.subject).toBe("Keep me");
expect(g.description).toBe("new desc");
});
it("null subject explicitly clears subject", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "Has Name" });
const g = upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: null });
expect(g.subject).toBeNull();
});
it("different sessions create separate entries for same JID", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s1", jid: "123@g.us", subject: "A" });
upsertGroup(store, { appId: "a", sessionId: "s2", jid: "123@g.us", subject: "B" });
expect(store.size).toBe(2);
});
});
// ─── adjustParticipantCount ───────────────────────────────────────────────────
function adjustCount(current: number | null, delta: number): number {
return Math.max(0, (current ?? 0) + delta);
}
describe("adjustParticipantCount delta", () => {
it("add participants increases count", () => {
expect(adjustCount(5, 2)).toBe(7);
});
it("remove participants decreases count", () => {
expect(adjustCount(5, -2)).toBe(3);
});
it("remove more than current clamps to 0", () => {
expect(adjustCount(2, -10)).toBe(0);
});
it("null current treated as 0", () => {
expect(adjustCount(null, 3)).toBe(3);
});
it("remove from null/0 clamps to 0", () => {
expect(adjustCount(null, -5)).toBe(0);
});
it("zero delta leaves count unchanged", () => {
expect(adjustCount(8, 0)).toBe(8);
});
it("large add works correctly", () => {
expect(adjustCount(100, 50)).toBe(150);
});
});
// ─── listWhatsappGroups filter ────────────────────────────────────────────────
interface GroupRow {
id: string;
appId: string;
sessionId: string;
jid: string;
subject: string | null;
}
function listGroups(
rows: GroupRow[],
appId: string,
sessionId?: string,
): GroupRow[] {
return rows.filter(
(r) => r.appId === appId && (sessionId ? r.sessionId === sessionId : true),
);
}
describe("listWhatsappGroups filter", () => {
const rows: GroupRow[] = [
{ id: "1", appId: "app1", sessionId: "sessA", jid: "111@g.us", subject: "Alpha" },
{ id: "2", appId: "app1", sessionId: "sessB", jid: "222@g.us", subject: "Beta" },
{ id: "3", appId: "app2", sessionId: "sessC", jid: "333@g.us", subject: "Gamma" },
];
it("filters by appId only", () => {
expect(listGroups(rows, "app1")).toHaveLength(2);
});
it("filters by appId + sessionId", () => {
const result = listGroups(rows, "app1", "sessA");
expect(result).toHaveLength(1);
expect(result[0].subject).toBe("Alpha");
});
it("wrong appId returns empty", () => {
expect(listGroups(rows, "app999")).toHaveLength(0);
});
it("wrong sessionId returns empty", () => {
expect(listGroups(rows, "app1", "sessZ")).toHaveLength(0);
});
it("different app's groups not included", () => {
const result = listGroups(rows, "app1");
expect(result.every((r) => r.appId === "app1")).toBe(true);
});
});
// ─── Subject/description normalization ───────────────────────────────────────
function normalizeSubject(raw: string | undefined | null): string | null {
if (!raw?.trim()) return null;
return raw.trim();
}
describe("group subject normalization", () => {
it("undefined → null", () => {
expect(normalizeSubject(undefined)).toBeNull();
});
it("null → null", () => {
expect(normalizeSubject(null)).toBeNull();
});
it("empty string → null", () => {
expect(normalizeSubject("")).toBeNull();
});
it("whitespace-only → null", () => {
expect(normalizeSubject(" ")).toBeNull();
});
it("valid string trimmed", () => {
expect(normalizeSubject(" Dev Team ")).toBe("Dev Team");
});
it("normal string unchanged", () => {
expect(normalizeSubject("Marketing")).toBe("Marketing");
});
});
// ─── Own JID matching ─────────────────────────────────────────────────────────
function jidUser(jid: string): string {
return jid.split(":")[0].split("@")[0];
}
function isOwnJid(participant: string, ownJid: string): boolean {
return participant === ownJid || jidUser(participant) === jidUser(ownJid);
}
function participantsIncludeOwn(participants: string[], ownJid: string): boolean {
return participants.some((p) => isOwnJid(p, ownJid));
}
describe("own JID matching", () => {
const OWN = "6281234567890@s.whatsapp.net";
it("exact match", () => {
expect(isOwnJid(OWN, OWN)).toBe(true);
});
it("LID suffix variant matches (e.g. 628...@lid vs 628...@s.whatsapp.net)", () => {
// Baileys may use :0 device suffix
expect(isOwnJid("6281234567890:0@s.whatsapp.net", "6281234567890@s.whatsapp.net")).toBe(true);
});
it("different number does not match", () => {
expect(isOwnJid("6289999999999@s.whatsapp.net", OWN)).toBe(false);
});
it("empty participants → not own", () => {
expect(participantsIncludeOwn([], OWN)).toBe(false);
});
it("list with own JID → true", () => {
expect(participantsIncludeOwn([OWN, "6289@s.whatsapp.net"], OWN)).toBe(true);
});
it("list without own JID → false", () => {
expect(participantsIncludeOwn(["6289@s.whatsapp.net"], OWN)).toBe(false);
});
});
// ─── Webhook event routing ────────────────────────────────────────────────────
type GroupWebhookEvent =
| "group.joined"
| "group.left"
| "group.updated"
| "group.participants_added"
| "group.participants_removed";
interface GroupParticipantsUpdate {
id: string;
participants: string[];
action: "add" | "remove" | "promote" | "demote";
}
function routeParticipantsEvent(
update: GroupParticipantsUpdate,
ownJid: string,
): GroupWebhookEvent | null {
const ownInvolved = participantsIncludeOwn(update.participants, ownJid);
if (update.action === "add") {
return ownInvolved ? "group.joined" : "group.participants_added";
}
if (update.action === "remove") {
return ownInvolved ? "group.left" : "group.participants_removed";
}
return null; // promote/demote: no group webhook
}
describe("webhook event routing (group-participants.update)", () => {
const OWN = "6281234567890@s.whatsapp.net";
const GID = "120363429229698693@g.us";
const OTHER = "6289999999999@s.whatsapp.net";
it("add own JID → group.joined", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OWN], action: "add" }, OWN))
.toBe("group.joined");
});
it("remove own JID → group.left", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OWN], action: "remove" }, OWN))
.toBe("group.left");
});
it("add other JID → group.participants_added", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "add" }, OWN))
.toBe("group.participants_added");
});
it("remove other JID → group.participants_removed", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "remove" }, OWN))
.toBe("group.participants_removed");
});
it("promote → null (no group webhook)", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "promote" }, OWN))
.toBeNull();
});
it("demote → null (no group webhook)", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "demote" }, OWN))
.toBeNull();
});
it("multiple participants, own included → group.joined", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER, OWN], action: "add" }, OWN))
.toBe("group.joined");
});
});
// ─── Webhook payload shapes ───────────────────────────────────────────────────
interface GroupMeta {
jid: string;
subject: string | null;
description: string | null;
participantCount: number | null;
}
function buildGroupJoinedPayload(sessionId: string, meta: GroupMeta) {
return {
sessionId,
jid: meta.jid,
subject: meta.subject,
description: meta.description,
participantCount: meta.participantCount,
};
}
function buildGroupLeftPayload(sessionId: string, jid: string, participants: string[]) {
return { sessionId, jid, participants };
}
function buildGroupUpdatedPayload(sessionId: string, jid: string, subject?: string | null, description?: string | null) {
return { sessionId, jid, subject: subject ?? null, description: description ?? null };
}
function buildParticipantsPayload(sessionId: string, jid: string, participants: string[]) {
return { sessionId, jid, participants };
}
describe("group.joined payload", () => {
it("includes all metadata fields", () => {
const p = buildGroupJoinedPayload("sess1", {
jid: "123@g.us",
subject: "Dev",
description: "Chat",
participantCount: 5,
});
expect(p).toEqual({
sessionId: "sess1",
jid: "123@g.us",
subject: "Dev",
description: "Chat",
participantCount: 5,
});
});
it("null metadata fields preserved", () => {
const p = buildGroupJoinedPayload("sess1", {
jid: "123@g.us",
subject: null,
description: null,
participantCount: null,
});
expect(p.subject).toBeNull();
expect(p.description).toBeNull();
expect(p.participantCount).toBeNull();
});
});
describe("group.left payload", () => {
it("includes sessionId, jid, participants", () => {
const p = buildGroupLeftPayload("sess1", "123@g.us", ["628@s.whatsapp.net"]);
expect(p).toEqual({
sessionId: "sess1",
jid: "123@g.us",
participants: ["628@s.whatsapp.net"],
});
});
it("does NOT include subject or description", () => {
const p = buildGroupLeftPayload("s", "j", ["x"]);
expect(p).not.toHaveProperty("subject");
expect(p).not.toHaveProperty("description");
});
});
describe("group.updated payload", () => {
it("includes changed subject", () => {
const p = buildGroupUpdatedPayload("sess1", "123@g.us", "New Name", null);
expect(p.subject).toBe("New Name");
expect(p.description).toBeNull();
});
it("both null when no metadata in update", () => {
const p = buildGroupUpdatedPayload("sess1", "123@g.us");
expect(p.subject).toBeNull();
expect(p.description).toBeNull();
});
});
describe("group.participants_added / group.participants_removed payload", () => {
it("shape: sessionId, jid, participants array", () => {
const p = buildParticipantsPayload("sess1", "123@g.us", ["a@s.whatsapp.net", "b@s.whatsapp.net"]);
expect(p.sessionId).toBe("sess1");
expect(p.jid).toBe("123@g.us");
expect(p.participants).toHaveLength(2);
});
it("does NOT include subject, description, participantCount", () => {
const p = buildParticipantsPayload("s", "j", ["x"]);
expect(p).not.toHaveProperty("subject");
expect(p).not.toHaveProperty("description");
expect(p).not.toHaveProperty("participantCount");
});
it("empty participants array is valid", () => {
const p = buildParticipantsPayload("s", "j", []);
expect(p.participants).toHaveLength(0);
});
});
// ─── All 15 event types recognized ───────────────────────────────────────────
describe("ALL_EVENTS completeness", () => {
const ALL_EVENTS = [
"session.status",
"session.deleted",
"message.received",
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"presence.update",
"contact.created",
"contact.updated",
"group.joined",
"group.left",
"group.updated",
"group.participants_added",
"group.participants_removed",
];
it("has 15 event types", () => {
expect(ALL_EVENTS).toHaveLength(15);
});
it("no duplicates", () => {
expect(new Set(ALL_EVENTS).size).toBe(ALL_EVENTS.length);
});
it("all group events present", () => {
const groupEvents = ALL_EVENTS.filter((e) => e.startsWith("group."));
expect(groupEvents).toHaveLength(5);
});
it("group events follow noun.verb pattern", () => {
const groupEvents = ALL_EVENTS.filter((e) => e.startsWith("group."));
for (const e of groupEvents) {
expect(e).toMatch(/^group\.[a-z_]+$/);
}
});
});

View file

@ -14,7 +14,10 @@ const config = defineConfig({
nitro({
preset: "node",
rollupConfig: { external: [/^@sentry\//] },
plugins: ["./src/server/plugins/session-startup.ts"],
plugins: [
"./src/server/plugins/otel.ts",
"./src/server/plugins/session-startup.ts",
],
}),
tailwindcss(),
tanstackStart(),