feat: webhhoks

This commit is contained in:
Edqe14 2026-05-16 14:20:49 +07:00
parent ec194d0596
commit e45dd79e8a
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
65 changed files with 6211 additions and 502 deletions

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

@ -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

@ -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

View file

@ -36,6 +36,13 @@
"when": 1778872231629,
"tag": "0004_striped_ben_grimm",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1778876071537,
"tag": "0005_lush_moondragon",
"breakpoints": true
}
]
}
}

View file

@ -23,6 +23,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",

View file

@ -9,6 +9,8 @@ import {
ChevronLeft,
Key,
Users,
Webhook,
Activity,
} from "lucide-react";
import { appQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
@ -82,6 +84,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 +94,7 @@ function GlobalNav({ pathname }: { pathname: string }) {
const isActive =
to === "/apps"
? pathname === "/apps" || pathname === "/apps/"
: pathname === to;
: pathname.startsWith(to);
return (
<Link
@ -128,12 +131,15 @@ function AppContextNav({
}) {
const sessionsPath = `/apps/${appId}/sessions`;
const contactsPath = `/apps/${appId}/contacts`;
const webhooksPath = `/apps/${appId}/webhooks`;
const isContacts = pathname.startsWith(contactsPath);
const isSessions = pathname.startsWith(sessionsPath);
const isWebhooks = pathname.startsWith(webhooksPath);
const isKeys =
!isContacts &&
!isSessions &&
!isWebhooks &&
(pathname === `/apps/${appId}` ||
pathname === `/apps/${appId}/` ||
pathname.startsWith(`/apps/${appId}/`));
@ -221,6 +227,24 @@ 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>
{/* Separator */}
<div className="border-t border-zinc-900 my-2 mx-1" />

View file

@ -66,6 +66,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 +343,83 @@ export const customFieldSchemasRelations = relations(
}),
);
// ─── 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

@ -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,11 +1,6 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "#/db/index";
import {
apps,
contacts,
contactTags,
contactGroupMembers,
} from "#/db/schema";
import { apps, contacts, contactTags, contactGroupMembers } from "#/db/schema";
import { unwrapDek } from "#/lib/crypto";
import {
encryptContact,
@ -13,6 +8,8 @@ import {
phoneBlindIndex,
normalizePhone,
} from "#/lib/contacts-crypto";
import { dispatchEvent } from "#/lib/events/dispatch";
import { listCustomFieldSchemas } from "#/lib/actions/custom-field-schemas";
export type Contact = typeof contacts.$inferSelect;
@ -31,6 +28,30 @@ export interface DecryptedContact {
updatedAt: Date;
}
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 async function getAppDek(appId: string): Promise<Buffer> {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
@ -52,11 +73,18 @@ export async function createContact(
pushName?: string;
},
dek?: Buffer,
): Promise<{ ok: true; contact: Contact } | { ok: false; error: string; status: number }> {
): Promise<
{ ok: true; contact: Contact } | { 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 +109,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,6 +121,13 @@ export async function createContact(
})
.returning();
const isCreated =
contact.createdAt.getTime() === contact.updatedAt.getTime();
dispatchEvent(appId, isCreated ? "contact.created" : "contact.updated", {
contactId: contact.id,
source: contact.source,
}).catch(() => {});
return { ok: true, contact };
}
@ -162,10 +193,7 @@ export async function listContacts(
if (opts.tag) {
const taggedContactIds = (
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);
@ -189,10 +217,7 @@ export async function listContacts(
if (memberIds.length === 0) return { contacts: [], total: 0 };
rows = await db.query.contacts.findMany({
where: and(
eq(contacts.appId, appId),
inArray(contacts.id, memberIds),
),
where: and(eq(contacts.appId, appId), inArray(contacts.id, memberIds)),
});
} else {
rows = await db.query.contacts.findMany({
@ -230,12 +255,10 @@ export async function listContacts(
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;
},
);
decryptedRows = decryptedRows.filter((r) => {
const row = rows.find((raw) => raw.id === r.id);
return row?.phoneBlindIndex === blind;
});
} catch {
decryptedRows = decryptedRows.filter(
(r) =>
@ -279,12 +302,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 +325,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 +351,8 @@ export async function updateContact(
.where(eq(contacts.id, contactId))
.returning();
dispatchEvent(appId, "contact.updated", { contactId }).catch(() => {});
return { ok: true, contact: updated };
}
@ -340,14 +375,18 @@ export async function upsertContactFromWhatsApp(
jid: string,
pushName?: string,
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 phone = jid.split("@")[0];
if (!phone || !/^\d+$/.test(phone)) {
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 ?? ""}"`);
console.log(
`[contacts] upsertFromWA jid="${jid}" pushName="${pushName ?? ""}"`,
);
const result = await createContact(
appId,
{

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 };
}

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

@ -0,0 +1,294 @@
import { and, desc, eq, lt, sql } from "drizzle-orm";
import { db } from "#/db/index";
import { apps, webhookEndpoints, webhookDeliveries } from "#/db/schema";
import { encryptColumn, decryptColumn, unwrapDek } from "#/lib/crypto";
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);
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);
}
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<{ endpoint: WebhookEndpointPublic; secret: string }> {
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 { 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) 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));
// Reset consecutive failures on success
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,
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 delivery = await db.query.webhookDeliveries.findFirst({
where: eq(webhookDeliveries.id, deliveryId),
columns: { endpointId: true },
});
if (!delivery) return;
const [endpoint] = await db
.update(webhookEndpoints)
.set({
consecutiveFailures: sql`${webhookEndpoints.consecutiveFailures} + 1`,
})
.where(eq(webhookEndpoints.id, delivery.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 oldestKept = keep[keep.length - 1] as (typeof keep)[number] | undefined;
if (!oldestKept) return;
const cutoffRow = await db.query.webhookDeliveries.findFirst({
where: eq(webhookDeliveries.id, oldestKept.id),
columns: { createdAt: true },
});
if (!cutoffRow) return;
await db
.delete(webhookDeliveries)
.where(
and(
eq(webhookDeliveries.endpointId, endpointId),
lt(webhookDeliveries.createdAt, cutoffRow.createdAt),
),
);
}

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

@ -126,11 +126,13 @@ export async function startSession(
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]));
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 ??
@ -146,7 +148,10 @@ 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]));
console.log(
`[contacts.update] sample[0] raw=`,
JSON.stringify(updates[0]),
);
}
});
@ -170,9 +175,10 @@ 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;
console.log(
`[contacts] msg-fallback upsert jid="${jid}" pushName="${pushName ?? ""}"`,
);
import("#/lib/actions/contacts")
.then(({ upsertContactFromWhatsApp }) =>
upsertContactFromWhatsApp(appId, jid, pushName, dek),

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

@ -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);
}
}

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,27 @@ 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 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

@ -16,6 +16,7 @@ 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 function createQueue(name: string): Queue {
return new Queue(name, { connection: getRedisConnection() });

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,118 @@
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, 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,
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";

View file

@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as MonitoringRouteImport } from './routes/monitoring'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuditLogRouteImport } from './routes/audit-log'
import { Route as IndexRouteImport } from './routes/index'
@ -23,8 +24,10 @@ import { Route as AppsAppIdIndexRouteImport } from './routes/apps/$appId/index'
import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me'
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
import { Route as AppsAppIdWebhooksIndexRouteImport } from './routes/apps/$appId/webhooks/index'
import { Route as AppsAppIdSessionsIndexRouteImport } from './routes/apps/$appId/sessions/index'
import { Route as AppsAppIdContactsIndexRouteImport } from './routes/apps/$appId/contacts/index'
import { Route as ApiV1WebhooksIndexRouteImport } from './routes/api/v1/webhooks/index'
import { Route as ApiV1SessionsIndexRouteImport } from './routes/api/v1/sessions/index'
import { Route as ApiV1MediaIndexRouteImport } from './routes/api/v1/media/index'
import { Route as ApiV1CustomFieldSchemasIndexRouteImport } from './routes/api/v1/custom-field-schemas/index'
@ -37,26 +40,37 @@ import { Route as AppsAppIdContactsContactIdRouteImport } from './routes/apps/$a
import { Route as ApiV1CustomFieldSchemasIdRouteImport } from './routes/api/v1/custom-field-schemas/$id'
import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
import { Route as AppsAppIdContactsSettingsIndexRouteImport } from './routes/apps/$appId/contacts/settings/index'
import { Route as AppsAppIdContactsGroupsIndexRouteImport } from './routes/apps/$appId/contacts/groups/index'
import { Route as ApiV1WebhooksIdIndexRouteImport } from './routes/api/v1/webhooks/$id/index'
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
import { Route as ApiV1ContactsSyncFromWhatsappIndexRouteImport } from './routes/api/v1/contacts/sync-from-whatsapp/index'
import { Route as ApiV1ContactsImportIndexRouteImport } from './routes/api/v1/contacts/import/index'
import { Route as ApiV1ContactsIdIndexRouteImport } from './routes/api/v1/contacts/$id/index'
import { Route as ApiV1ContactGroupsIdIndexRouteImport } from './routes/api/v1/contact-groups/$id/index'
import { Route as ApiV1AdminWebhooksIndexRouteImport } from './routes/api/v1/admin/webhooks/index'
import { Route as ApiV1AdminSessionsIndexRouteImport } from './routes/api/v1/admin/sessions/index'
import { Route as ApiV1AdminCustomFieldSchemasIndexRouteImport } from './routes/api/v1/admin/custom-field-schemas/index'
import { Route as ApiV1AdminContactsIndexRouteImport } from './routes/api/v1/admin/contacts/index'
import { Route as ApiV1AdminContactGroupsIndexRouteImport } from './routes/api/v1/admin/contact-groups/index'
import { Route as ApiV1AdminAppsIndexRouteImport } from './routes/api/v1/admin/apps/index'
import { Route as AppsAppIdWebhooksWebhookIdDeliveriesRouteImport } from './routes/apps/$appId/webhooks/$webhookId/deliveries'
import { Route as ApiV1WebhooksIdTestRouteImport } from './routes/api/v1/webhooks/$id/test'
import { Route as ApiV1WebhooksIdDeliveriesRouteImport } from './routes/api/v1/webhooks/$id/deliveries'
import { Route as ApiV1SessionsIdQrRouteImport } from './routes/api/v1/sessions/$id/qr'
import { Route as ApiV1SessionsIdPresenceRouteImport } from './routes/api/v1/sessions/$id/presence'
import { Route as ApiV1SessionsIdPairRouteImport } from './routes/api/v1/sessions/$id/pair'
import { Route as ApiV1SessionsIdHealthRouteImport } from './routes/api/v1/sessions/$id/health'
import { Route as ApiV1SessionsIdDisconnectRouteImport } from './routes/api/v1/sessions/$id/disconnect'
import { Route as ApiV1ContactsImportJobIdRouteImport } from './routes/api/v1/contacts/import/$jobId'
import { Route as ApiV1AdminMonitoringStatsRouteImport } from './routes/api/v1/admin/monitoring/stats'
import { Route as ApiV1AdminCustomFieldSchemasIdRouteImport } from './routes/api/v1/admin/custom-field-schemas/$id'
import { Route as ApiV1SessionsIdMessagesIndexRouteImport } from './routes/api/v1/sessions/$id/messages/index'
import { Route as ApiV1ContactGroupsIdMembersIndexRouteImport } from './routes/api/v1/contact-groups/$id/members/index'
import { Route as ApiV1AdminWebhooksIdIndexRouteImport } from './routes/api/v1/admin/webhooks/$id/index'
import { Route as ApiV1AdminSessionsIdIndexRouteImport } from './routes/api/v1/admin/sessions/$id/index'
import { Route as ApiV1AdminContactsIdIndexRouteImport } from './routes/api/v1/admin/contacts/$id/index'
import { Route as ApiV1AdminContactGroupsIdIndexRouteImport } from './routes/api/v1/admin/contact-groups/$id/index'
import { Route as ApiV1AdminAppsIdIndexRouteImport } from './routes/api/v1/admin/apps/$id/index'
import { Route as ApiV1SessionsIdMessagesVideoRouteImport } from './routes/api/v1/sessions/$id/messages/video'
import { Route as ApiV1SessionsIdMessagesTextRouteImport } from './routes/api/v1/sessions/$id/messages/text'
@ -65,15 +79,24 @@ import { Route as ApiV1SessionsIdMessagesDocumentRouteImport } from './routes/ap
import { Route as ApiV1SessionsIdMessagesBroadcastRouteImport } from './routes/api/v1/sessions/$id/messages/broadcast'
import { Route as ApiV1SessionsIdMessagesAudioRouteImport } from './routes/api/v1/sessions/$id/messages/audio'
import { Route as ApiV1ContactGroupsIdMembersContactIdRouteImport } from './routes/api/v1/contact-groups/$id/members/$contactId'
import { Route as ApiV1AdminWebhooksIdTestRouteImport } from './routes/api/v1/admin/webhooks/$id/test'
import { Route as ApiV1AdminWebhooksIdDeliveriesRouteImport } from './routes/api/v1/admin/webhooks/$id/deliveries'
import { Route as ApiV1AdminSessionsIdQrRouteImport } from './routes/api/v1/admin/sessions/$id/qr'
import { Route as ApiV1AdminSessionsIdPairRouteImport } from './routes/api/v1/admin/sessions/$id/pair'
import { Route as ApiV1AdminSessionsIdDisconnectRouteImport } from './routes/api/v1/admin/sessions/$id/disconnect'
import { Route as ApiV1AdminContactsIdTagsRouteImport } from './routes/api/v1/admin/contacts/$id/tags'
import { Route as ApiV1AdminAppsIdSyncContactsRouteImport } from './routes/api/v1/admin/apps/$id/sync-contacts'
import { Route as ApiV1AdminContactGroupsIdMembersIndexRouteImport } from './routes/api/v1/admin/contact-groups/$id/members/index'
import { Route as ApiV1AdminAppsIdApiKeysIndexRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/index'
import { Route as ApiV1SessionsIdMessagesMsgIdReadRouteImport } from './routes/api/v1/sessions/$id/messages/$msgId/read'
import { Route as ApiV1AdminContactGroupsIdMembersContactIdRouteImport } from './routes/api/v1/admin/contact-groups/$id/members/$contactId'
import { Route as ApiV1AdminAppsIdApiKeysKeyIdRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/$keyId'
const MonitoringRoute = MonitoringRouteImport.update({
id: '/monitoring',
path: '/monitoring',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@ -144,6 +167,11 @@ const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
path: '/api/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdWebhooksIndexRoute = AppsAppIdWebhooksIndexRouteImport.update({
id: '/webhooks/',
path: '/webhooks/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const AppsAppIdSessionsIndexRoute = AppsAppIdSessionsIndexRouteImport.update({
id: '/sessions/',
path: '/sessions/',
@ -154,6 +182,11 @@ const AppsAppIdContactsIndexRoute = AppsAppIdContactsIndexRouteImport.update({
path: '/contacts/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1WebhooksIndexRoute = ApiV1WebhooksIndexRouteImport.update({
id: '/api/v1/webhooks/',
path: '/api/v1/webhooks/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
id: '/api/v1/sessions/',
path: '/api/v1/sessions/',
@ -218,6 +251,23 @@ const ApiAuthTotpEnrollRoute = ApiAuthTotpEnrollRouteImport.update({
path: '/api/auth/totp/enroll',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdContactsSettingsIndexRoute =
AppsAppIdContactsSettingsIndexRouteImport.update({
id: '/contacts/settings/',
path: '/contacts/settings/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const AppsAppIdContactsGroupsIndexRoute =
AppsAppIdContactsGroupsIndexRouteImport.update({
id: '/contacts/groups/',
path: '/contacts/groups/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1WebhooksIdIndexRoute = ApiV1WebhooksIdIndexRouteImport.update({
id: '/api/v1/webhooks/$id/',
path: '/api/v1/webhooks/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdIndexRoute = ApiV1SessionsIdIndexRouteImport.update({
id: '/api/v1/sessions/$id/',
path: '/api/v1/sessions/$id/',
@ -246,6 +296,11 @@ const ApiV1ContactGroupsIdIndexRoute =
path: '/api/v1/contact-groups/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWebhooksIndexRoute = ApiV1AdminWebhooksIndexRouteImport.update({
id: '/api/v1/admin/webhooks/',
path: '/api/v1/admin/webhooks/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIndexRoute = ApiV1AdminSessionsIndexRouteImport.update({
id: '/api/v1/admin/sessions/',
path: '/api/v1/admin/sessions/',
@ -262,11 +317,34 @@ const ApiV1AdminContactsIndexRoute = ApiV1AdminContactsIndexRouteImport.update({
path: '/api/v1/admin/contacts/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminContactGroupsIndexRoute =
ApiV1AdminContactGroupsIndexRouteImport.update({
id: '/api/v1/admin/contact-groups/',
path: '/api/v1/admin/contact-groups/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIndexRoute = ApiV1AdminAppsIndexRouteImport.update({
id: '/api/v1/admin/apps/',
path: '/api/v1/admin/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdWebhooksWebhookIdDeliveriesRoute =
AppsAppIdWebhooksWebhookIdDeliveriesRouteImport.update({
id: '/webhooks/$webhookId/deliveries',
path: '/webhooks/$webhookId/deliveries',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1WebhooksIdTestRoute = ApiV1WebhooksIdTestRouteImport.update({
id: '/api/v1/webhooks/$id/test',
path: '/api/v1/webhooks/$id/test',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1WebhooksIdDeliveriesRoute =
ApiV1WebhooksIdDeliveriesRouteImport.update({
id: '/api/v1/webhooks/$id/deliveries',
path: '/api/v1/webhooks/$id/deliveries',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdQrRoute = ApiV1SessionsIdQrRouteImport.update({
id: '/api/v1/sessions/$id/qr',
path: '/api/v1/sessions/$id/qr',
@ -299,6 +377,12 @@ const ApiV1ContactsImportJobIdRoute =
path: '/api/v1/contacts/import/$jobId',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminMonitoringStatsRoute =
ApiV1AdminMonitoringStatsRouteImport.update({
id: '/api/v1/admin/monitoring/stats',
path: '/api/v1/admin/monitoring/stats',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminCustomFieldSchemasIdRoute =
ApiV1AdminCustomFieldSchemasIdRouteImport.update({
id: '/api/v1/admin/custom-field-schemas/$id',
@ -317,6 +401,12 @@ const ApiV1ContactGroupsIdMembersIndexRoute =
path: '/api/v1/contact-groups/$id/members/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWebhooksIdIndexRoute =
ApiV1AdminWebhooksIdIndexRouteImport.update({
id: '/api/v1/admin/webhooks/$id/',
path: '/api/v1/admin/webhooks/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdIndexRoute =
ApiV1AdminSessionsIdIndexRouteImport.update({
id: '/api/v1/admin/sessions/$id/',
@ -329,6 +419,12 @@ const ApiV1AdminContactsIdIndexRoute =
path: '/api/v1/admin/contacts/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminContactGroupsIdIndexRoute =
ApiV1AdminContactGroupsIdIndexRouteImport.update({
id: '/api/v1/admin/contact-groups/$id/',
path: '/api/v1/admin/contact-groups/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/',
path: '/api/v1/admin/apps/$id/',
@ -376,6 +472,18 @@ const ApiV1ContactGroupsIdMembersContactIdRoute =
path: '/api/v1/contact-groups/$id/members/$contactId',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWebhooksIdTestRoute =
ApiV1AdminWebhooksIdTestRouteImport.update({
id: '/api/v1/admin/webhooks/$id/test',
path: '/api/v1/admin/webhooks/$id/test',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWebhooksIdDeliveriesRoute =
ApiV1AdminWebhooksIdDeliveriesRouteImport.update({
id: '/api/v1/admin/webhooks/$id/deliveries',
path: '/api/v1/admin/webhooks/$id/deliveries',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdQrRoute = ApiV1AdminSessionsIdQrRouteImport.update({
id: '/api/v1/admin/sessions/$id/qr',
path: '/api/v1/admin/sessions/$id/qr',
@ -405,6 +513,12 @@ const ApiV1AdminAppsIdSyncContactsRoute =
path: '/api/v1/admin/apps/$id/sync-contacts',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminContactGroupsIdMembersIndexRoute =
ApiV1AdminContactGroupsIdMembersIndexRouteImport.update({
id: '/api/v1/admin/contact-groups/$id/members/',
path: '/api/v1/admin/contact-groups/$id/members/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysIndexRoute =
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/',
@ -417,6 +531,12 @@ const ApiV1SessionsIdMessagesMsgIdReadRoute =
path: '/api/v1/sessions/$id/messages/$msgId/read',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminContactGroupsIdMembersContactIdRoute =
ApiV1AdminContactGroupsIdMembersContactIdRouteImport.update({
id: '/api/v1/admin/contact-groups/$id/members/$contactId',
path: '/api/v1/admin/contact-groups/$id/members/$contactId',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysKeyIdRoute =
ApiV1AdminAppsIdApiKeysKeyIdRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/$keyId',
@ -428,6 +548,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/monitoring': typeof MonitoringRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRouteWithChildren
@ -451,29 +572,42 @@ export interface FileRoutesByFullPath {
'/api/v1/custom-field-schemas/': typeof ApiV1CustomFieldSchemasIndexRoute
'/api/v1/media/': typeof ApiV1MediaIndexRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/api/v1/webhooks/': typeof ApiV1WebhooksIndexRoute
'/apps/$appId/contacts/': typeof AppsAppIdContactsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/apps/$appId/webhooks/': typeof AppsAppIdWebhooksIndexRoute
'/api/v1/admin/custom-field-schemas/$id': typeof ApiV1AdminCustomFieldSchemasIdRoute
'/api/v1/admin/monitoring/stats': typeof ApiV1AdminMonitoringStatsRoute
'/api/v1/contacts/import/$jobId': typeof ApiV1ContactsImportJobIdRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/webhooks/$id/deliveries': typeof ApiV1WebhooksIdDeliveriesRoute
'/api/v1/webhooks/$id/test': typeof ApiV1WebhooksIdTestRoute
'/apps/$appId/webhooks/$webhookId/deliveries': typeof AppsAppIdWebhooksWebhookIdDeliveriesRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/contact-groups/': typeof ApiV1AdminContactGroupsIndexRoute
'/api/v1/admin/contacts/': typeof ApiV1AdminContactsIndexRoute
'/api/v1/admin/custom-field-schemas/': typeof ApiV1AdminCustomFieldSchemasIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/admin/webhooks/': typeof ApiV1AdminWebhooksIndexRoute
'/api/v1/contact-groups/$id/': typeof ApiV1ContactGroupsIdIndexRoute
'/api/v1/contacts/$id/': typeof ApiV1ContactsIdIndexRoute
'/api/v1/contacts/import/': typeof ApiV1ContactsImportIndexRoute
'/api/v1/contacts/sync-from-whatsapp/': typeof ApiV1ContactsSyncFromWhatsappIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/webhooks/$id/': typeof ApiV1WebhooksIdIndexRoute
'/apps/$appId/contacts/groups/': typeof AppsAppIdContactsGroupsIndexRoute
'/apps/$appId/contacts/settings/': typeof AppsAppIdContactsSettingsIndexRoute
'/api/v1/admin/apps/$id/sync-contacts': typeof ApiV1AdminAppsIdSyncContactsRoute
'/api/v1/admin/contacts/$id/tags': typeof ApiV1AdminContactsIdTagsRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/admin/webhooks/$id/deliveries': typeof ApiV1AdminWebhooksIdDeliveriesRoute
'/api/v1/admin/webhooks/$id/test': typeof ApiV1AdminWebhooksIdTestRoute
'/api/v1/contact-groups/$id/members/$contactId': typeof ApiV1ContactGroupsIdMembersContactIdRoute
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
@ -482,18 +616,23 @@ export interface FileRoutesByFullPath {
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/contact-groups/$id/': typeof ApiV1AdminContactGroupsIdIndexRoute
'/api/v1/admin/contacts/$id/': typeof ApiV1AdminContactsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/webhooks/$id/': typeof ApiV1AdminWebhooksIdIndexRoute
'/api/v1/contact-groups/$id/members/': typeof ApiV1ContactGroupsIdMembersIndexRoute
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/contact-groups/$id/members/$contactId': typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
'/api/v1/admin/contact-groups/$id/members/': typeof ApiV1AdminContactGroupsIdMembersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/monitoring': typeof MonitoringRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/sessions/$id': typeof SessionsIdRoute
@ -516,29 +655,42 @@ export interface FileRoutesByTo {
'/api/v1/custom-field-schemas': typeof ApiV1CustomFieldSchemasIndexRoute
'/api/v1/media': typeof ApiV1MediaIndexRoute
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
'/api/v1/webhooks': typeof ApiV1WebhooksIndexRoute
'/apps/$appId/contacts': typeof AppsAppIdContactsIndexRoute
'/apps/$appId/sessions': typeof AppsAppIdSessionsIndexRoute
'/apps/$appId/webhooks': typeof AppsAppIdWebhooksIndexRoute
'/api/v1/admin/custom-field-schemas/$id': typeof ApiV1AdminCustomFieldSchemasIdRoute
'/api/v1/admin/monitoring/stats': typeof ApiV1AdminMonitoringStatsRoute
'/api/v1/contacts/import/$jobId': typeof ApiV1ContactsImportJobIdRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/webhooks/$id/deliveries': typeof ApiV1WebhooksIdDeliveriesRoute
'/api/v1/webhooks/$id/test': typeof ApiV1WebhooksIdTestRoute
'/apps/$appId/webhooks/$webhookId/deliveries': typeof AppsAppIdWebhooksWebhookIdDeliveriesRoute
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/contact-groups': typeof ApiV1AdminContactGroupsIndexRoute
'/api/v1/admin/contacts': typeof ApiV1AdminContactsIndexRoute
'/api/v1/admin/custom-field-schemas': typeof ApiV1AdminCustomFieldSchemasIndexRoute
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/admin/webhooks': typeof ApiV1AdminWebhooksIndexRoute
'/api/v1/contact-groups/$id': typeof ApiV1ContactGroupsIdIndexRoute
'/api/v1/contacts/$id': typeof ApiV1ContactsIdIndexRoute
'/api/v1/contacts/import': typeof ApiV1ContactsImportIndexRoute
'/api/v1/contacts/sync-from-whatsapp': typeof ApiV1ContactsSyncFromWhatsappIndexRoute
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
'/api/v1/webhooks/$id': typeof ApiV1WebhooksIdIndexRoute
'/apps/$appId/contacts/groups': typeof AppsAppIdContactsGroupsIndexRoute
'/apps/$appId/contacts/settings': typeof AppsAppIdContactsSettingsIndexRoute
'/api/v1/admin/apps/$id/sync-contacts': typeof ApiV1AdminAppsIdSyncContactsRoute
'/api/v1/admin/contacts/$id/tags': typeof ApiV1AdminContactsIdTagsRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/admin/webhooks/$id/deliveries': typeof ApiV1AdminWebhooksIdDeliveriesRoute
'/api/v1/admin/webhooks/$id/test': typeof ApiV1AdminWebhooksIdTestRoute
'/api/v1/contact-groups/$id/members/$contactId': typeof ApiV1ContactGroupsIdMembersContactIdRoute
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
@ -547,19 +699,24 @@ export interface FileRoutesByTo {
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
'/api/v1/admin/apps/$id': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/contact-groups/$id': typeof ApiV1AdminContactGroupsIdIndexRoute
'/api/v1/admin/contacts/$id': typeof ApiV1AdminContactsIdIndexRoute
'/api/v1/admin/sessions/$id': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/webhooks/$id': typeof ApiV1AdminWebhooksIdIndexRoute
'/api/v1/contact-groups/$id/members': typeof ApiV1ContactGroupsIdMembersIndexRoute
'/api/v1/sessions/$id/messages': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/contact-groups/$id/members/$contactId': typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
'/api/v1/admin/contact-groups/$id/members': typeof ApiV1AdminContactGroupsIdMembersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/monitoring': typeof MonitoringRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRouteWithChildren
@ -583,29 +740,42 @@ export interface FileRoutesById {
'/api/v1/custom-field-schemas/': typeof ApiV1CustomFieldSchemasIndexRoute
'/api/v1/media/': typeof ApiV1MediaIndexRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/api/v1/webhooks/': typeof ApiV1WebhooksIndexRoute
'/apps/$appId/contacts/': typeof AppsAppIdContactsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/apps/$appId/webhooks/': typeof AppsAppIdWebhooksIndexRoute
'/api/v1/admin/custom-field-schemas/$id': typeof ApiV1AdminCustomFieldSchemasIdRoute
'/api/v1/admin/monitoring/stats': typeof ApiV1AdminMonitoringStatsRoute
'/api/v1/contacts/import/$jobId': typeof ApiV1ContactsImportJobIdRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/webhooks/$id/deliveries': typeof ApiV1WebhooksIdDeliveriesRoute
'/api/v1/webhooks/$id/test': typeof ApiV1WebhooksIdTestRoute
'/apps/$appId/webhooks/$webhookId/deliveries': typeof AppsAppIdWebhooksWebhookIdDeliveriesRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/contact-groups/': typeof ApiV1AdminContactGroupsIndexRoute
'/api/v1/admin/contacts/': typeof ApiV1AdminContactsIndexRoute
'/api/v1/admin/custom-field-schemas/': typeof ApiV1AdminCustomFieldSchemasIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/admin/webhooks/': typeof ApiV1AdminWebhooksIndexRoute
'/api/v1/contact-groups/$id/': typeof ApiV1ContactGroupsIdIndexRoute
'/api/v1/contacts/$id/': typeof ApiV1ContactsIdIndexRoute
'/api/v1/contacts/import/': typeof ApiV1ContactsImportIndexRoute
'/api/v1/contacts/sync-from-whatsapp/': typeof ApiV1ContactsSyncFromWhatsappIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/webhooks/$id/': typeof ApiV1WebhooksIdIndexRoute
'/apps/$appId/contacts/groups/': typeof AppsAppIdContactsGroupsIndexRoute
'/apps/$appId/contacts/settings/': typeof AppsAppIdContactsSettingsIndexRoute
'/api/v1/admin/apps/$id/sync-contacts': typeof ApiV1AdminAppsIdSyncContactsRoute
'/api/v1/admin/contacts/$id/tags': typeof ApiV1AdminContactsIdTagsRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/admin/webhooks/$id/deliveries': typeof ApiV1AdminWebhooksIdDeliveriesRoute
'/api/v1/admin/webhooks/$id/test': typeof ApiV1AdminWebhooksIdTestRoute
'/api/v1/contact-groups/$id/members/$contactId': typeof ApiV1ContactGroupsIdMembersContactIdRoute
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
@ -614,13 +784,17 @@ export interface FileRoutesById {
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/contact-groups/$id/': typeof ApiV1AdminContactGroupsIdIndexRoute
'/api/v1/admin/contacts/$id/': typeof ApiV1AdminContactsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/webhooks/$id/': typeof ApiV1AdminWebhooksIdIndexRoute
'/api/v1/contact-groups/$id/members/': typeof ApiV1ContactGroupsIdMembersIndexRoute
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/contact-groups/$id/members/$contactId': typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
'/api/v1/admin/contact-groups/$id/members/': typeof ApiV1AdminContactGroupsIdMembersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -628,6 +802,7 @@ export interface FileRouteTypes {
| '/'
| '/audit-log'
| '/login'
| '/monitoring'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
@ -651,29 +826,42 @@ export interface FileRouteTypes {
| '/api/v1/custom-field-schemas/'
| '/api/v1/media/'
| '/api/v1/sessions/'
| '/api/v1/webhooks/'
| '/apps/$appId/contacts/'
| '/apps/$appId/sessions/'
| '/apps/$appId/webhooks/'
| '/api/v1/admin/custom-field-schemas/$id'
| '/api/v1/admin/monitoring/stats'
| '/api/v1/contacts/import/$jobId'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/presence'
| '/api/v1/sessions/$id/qr'
| '/api/v1/webhooks/$id/deliveries'
| '/api/v1/webhooks/$id/test'
| '/apps/$appId/webhooks/$webhookId/deliveries'
| '/api/v1/admin/apps/'
| '/api/v1/admin/contact-groups/'
| '/api/v1/admin/contacts/'
| '/api/v1/admin/custom-field-schemas/'
| '/api/v1/admin/sessions/'
| '/api/v1/admin/webhooks/'
| '/api/v1/contact-groups/$id/'
| '/api/v1/contacts/$id/'
| '/api/v1/contacts/import/'
| '/api/v1/contacts/sync-from-whatsapp/'
| '/api/v1/sessions/$id/'
| '/api/v1/webhooks/$id/'
| '/apps/$appId/contacts/groups/'
| '/apps/$appId/contacts/settings/'
| '/api/v1/admin/apps/$id/sync-contacts'
| '/api/v1/admin/contacts/$id/tags'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/admin/webhooks/$id/deliveries'
| '/api/v1/admin/webhooks/$id/test'
| '/api/v1/contact-groups/$id/members/$contactId'
| '/api/v1/sessions/$id/messages/audio'
| '/api/v1/sessions/$id/messages/broadcast'
@ -682,18 +870,23 @@ export interface FileRouteTypes {
| '/api/v1/sessions/$id/messages/text'
| '/api/v1/sessions/$id/messages/video'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/contact-groups/$id/'
| '/api/v1/admin/contacts/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/admin/webhooks/$id/'
| '/api/v1/contact-groups/$id/members/'
| '/api/v1/sessions/$id/messages/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/contact-groups/$id/members/$contactId'
| '/api/v1/sessions/$id/messages/$msgId/read'
| '/api/v1/admin/apps/$id/api-keys/'
| '/api/v1/admin/contact-groups/$id/members/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/audit-log'
| '/login'
| '/monitoring'
| '/api/health'
| '/api/ready'
| '/sessions/$id'
@ -716,29 +909,42 @@ export interface FileRouteTypes {
| '/api/v1/custom-field-schemas'
| '/api/v1/media'
| '/api/v1/sessions'
| '/api/v1/webhooks'
| '/apps/$appId/contacts'
| '/apps/$appId/sessions'
| '/apps/$appId/webhooks'
| '/api/v1/admin/custom-field-schemas/$id'
| '/api/v1/admin/monitoring/stats'
| '/api/v1/contacts/import/$jobId'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/presence'
| '/api/v1/sessions/$id/qr'
| '/api/v1/webhooks/$id/deliveries'
| '/api/v1/webhooks/$id/test'
| '/apps/$appId/webhooks/$webhookId/deliveries'
| '/api/v1/admin/apps'
| '/api/v1/admin/contact-groups'
| '/api/v1/admin/contacts'
| '/api/v1/admin/custom-field-schemas'
| '/api/v1/admin/sessions'
| '/api/v1/admin/webhooks'
| '/api/v1/contact-groups/$id'
| '/api/v1/contacts/$id'
| '/api/v1/contacts/import'
| '/api/v1/contacts/sync-from-whatsapp'
| '/api/v1/sessions/$id'
| '/api/v1/webhooks/$id'
| '/apps/$appId/contacts/groups'
| '/apps/$appId/contacts/settings'
| '/api/v1/admin/apps/$id/sync-contacts'
| '/api/v1/admin/contacts/$id/tags'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/admin/webhooks/$id/deliveries'
| '/api/v1/admin/webhooks/$id/test'
| '/api/v1/contact-groups/$id/members/$contactId'
| '/api/v1/sessions/$id/messages/audio'
| '/api/v1/sessions/$id/messages/broadcast'
@ -747,18 +953,23 @@ export interface FileRouteTypes {
| '/api/v1/sessions/$id/messages/text'
| '/api/v1/sessions/$id/messages/video'
| '/api/v1/admin/apps/$id'
| '/api/v1/admin/contact-groups/$id'
| '/api/v1/admin/contacts/$id'
| '/api/v1/admin/sessions/$id'
| '/api/v1/admin/webhooks/$id'
| '/api/v1/contact-groups/$id/members'
| '/api/v1/sessions/$id/messages'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/contact-groups/$id/members/$contactId'
| '/api/v1/sessions/$id/messages/$msgId/read'
| '/api/v1/admin/apps/$id/api-keys'
| '/api/v1/admin/contact-groups/$id/members'
id:
| '__root__'
| '/'
| '/audit-log'
| '/login'
| '/monitoring'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
@ -782,29 +993,42 @@ export interface FileRouteTypes {
| '/api/v1/custom-field-schemas/'
| '/api/v1/media/'
| '/api/v1/sessions/'
| '/api/v1/webhooks/'
| '/apps/$appId/contacts/'
| '/apps/$appId/sessions/'
| '/apps/$appId/webhooks/'
| '/api/v1/admin/custom-field-schemas/$id'
| '/api/v1/admin/monitoring/stats'
| '/api/v1/contacts/import/$jobId'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/presence'
| '/api/v1/sessions/$id/qr'
| '/api/v1/webhooks/$id/deliveries'
| '/api/v1/webhooks/$id/test'
| '/apps/$appId/webhooks/$webhookId/deliveries'
| '/api/v1/admin/apps/'
| '/api/v1/admin/contact-groups/'
| '/api/v1/admin/contacts/'
| '/api/v1/admin/custom-field-schemas/'
| '/api/v1/admin/sessions/'
| '/api/v1/admin/webhooks/'
| '/api/v1/contact-groups/$id/'
| '/api/v1/contacts/$id/'
| '/api/v1/contacts/import/'
| '/api/v1/contacts/sync-from-whatsapp/'
| '/api/v1/sessions/$id/'
| '/api/v1/webhooks/$id/'
| '/apps/$appId/contacts/groups/'
| '/apps/$appId/contacts/settings/'
| '/api/v1/admin/apps/$id/sync-contacts'
| '/api/v1/admin/contacts/$id/tags'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/admin/webhooks/$id/deliveries'
| '/api/v1/admin/webhooks/$id/test'
| '/api/v1/contact-groups/$id/members/$contactId'
| '/api/v1/sessions/$id/messages/audio'
| '/api/v1/sessions/$id/messages/broadcast'
@ -813,19 +1037,24 @@ export interface FileRouteTypes {
| '/api/v1/sessions/$id/messages/text'
| '/api/v1/sessions/$id/messages/video'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/contact-groups/$id/'
| '/api/v1/admin/contacts/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/admin/webhooks/$id/'
| '/api/v1/contact-groups/$id/members/'
| '/api/v1/sessions/$id/messages/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/contact-groups/$id/members/$contactId'
| '/api/v1/sessions/$id/messages/$msgId/read'
| '/api/v1/admin/apps/$id/api-keys/'
| '/api/v1/admin/contact-groups/$id/members/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuditLogRoute: typeof AuditLogRoute
LoginRoute: typeof LoginRoute
MonitoringRoute: typeof MonitoringRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiReadyRoute: typeof ApiReadyRoute
AppsAppIdRoute: typeof AppsAppIdRouteWithChildren
@ -844,27 +1073,36 @@ export interface RootRouteChildren {
ApiV1CustomFieldSchemasIndexRoute: typeof ApiV1CustomFieldSchemasIndexRoute
ApiV1MediaIndexRoute: typeof ApiV1MediaIndexRoute
ApiV1SessionsIndexRoute: typeof ApiV1SessionsIndexRoute
ApiV1WebhooksIndexRoute: typeof ApiV1WebhooksIndexRoute
ApiV1AdminCustomFieldSchemasIdRoute: typeof ApiV1AdminCustomFieldSchemasIdRoute
ApiV1AdminMonitoringStatsRoute: typeof ApiV1AdminMonitoringStatsRoute
ApiV1ContactsImportJobIdRoute: typeof ApiV1ContactsImportJobIdRoute
ApiV1SessionsIdDisconnectRoute: typeof ApiV1SessionsIdDisconnectRoute
ApiV1SessionsIdHealthRoute: typeof ApiV1SessionsIdHealthRoute
ApiV1SessionsIdPairRoute: typeof ApiV1SessionsIdPairRoute
ApiV1SessionsIdPresenceRoute: typeof ApiV1SessionsIdPresenceRoute
ApiV1SessionsIdQrRoute: typeof ApiV1SessionsIdQrRoute
ApiV1WebhooksIdDeliveriesRoute: typeof ApiV1WebhooksIdDeliveriesRoute
ApiV1WebhooksIdTestRoute: typeof ApiV1WebhooksIdTestRoute
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
ApiV1AdminContactGroupsIndexRoute: typeof ApiV1AdminContactGroupsIndexRoute
ApiV1AdminContactsIndexRoute: typeof ApiV1AdminContactsIndexRoute
ApiV1AdminCustomFieldSchemasIndexRoute: typeof ApiV1AdminCustomFieldSchemasIndexRoute
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
ApiV1AdminWebhooksIndexRoute: typeof ApiV1AdminWebhooksIndexRoute
ApiV1ContactGroupsIdIndexRoute: typeof ApiV1ContactGroupsIdIndexRoute
ApiV1ContactsIdIndexRoute: typeof ApiV1ContactsIdIndexRoute
ApiV1ContactsImportIndexRoute: typeof ApiV1ContactsImportIndexRoute
ApiV1ContactsSyncFromWhatsappIndexRoute: typeof ApiV1ContactsSyncFromWhatsappIndexRoute
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
ApiV1WebhooksIdIndexRoute: typeof ApiV1WebhooksIdIndexRoute
ApiV1AdminAppsIdSyncContactsRoute: typeof ApiV1AdminAppsIdSyncContactsRoute
ApiV1AdminContactsIdTagsRoute: typeof ApiV1AdminContactsIdTagsRoute
ApiV1AdminSessionsIdDisconnectRoute: typeof ApiV1AdminSessionsIdDisconnectRoute
ApiV1AdminSessionsIdPairRoute: typeof ApiV1AdminSessionsIdPairRoute
ApiV1AdminSessionsIdQrRoute: typeof ApiV1AdminSessionsIdQrRoute
ApiV1AdminWebhooksIdDeliveriesRoute: typeof ApiV1AdminWebhooksIdDeliveriesRoute
ApiV1AdminWebhooksIdTestRoute: typeof ApiV1AdminWebhooksIdTestRoute
ApiV1ContactGroupsIdMembersContactIdRoute: typeof ApiV1ContactGroupsIdMembersContactIdRoute
ApiV1SessionsIdMessagesAudioRoute: typeof ApiV1SessionsIdMessagesAudioRoute
ApiV1SessionsIdMessagesBroadcastRoute: typeof ApiV1SessionsIdMessagesBroadcastRoute
@ -873,17 +1111,28 @@ export interface RootRouteChildren {
ApiV1SessionsIdMessagesTextRoute: typeof ApiV1SessionsIdMessagesTextRoute
ApiV1SessionsIdMessagesVideoRoute: typeof ApiV1SessionsIdMessagesVideoRoute
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
ApiV1AdminContactGroupsIdIndexRoute: typeof ApiV1AdminContactGroupsIdIndexRoute
ApiV1AdminContactsIdIndexRoute: typeof ApiV1AdminContactsIdIndexRoute
ApiV1AdminSessionsIdIndexRoute: typeof ApiV1AdminSessionsIdIndexRoute
ApiV1AdminWebhooksIdIndexRoute: typeof ApiV1AdminWebhooksIdIndexRoute
ApiV1ContactGroupsIdMembersIndexRoute: typeof ApiV1ContactGroupsIdMembersIndexRoute
ApiV1SessionsIdMessagesIndexRoute: typeof ApiV1SessionsIdMessagesIndexRoute
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
ApiV1AdminContactGroupsIdMembersContactIdRoute: typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
ApiV1SessionsIdMessagesMsgIdReadRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRoute
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
ApiV1AdminContactGroupsIdMembersIndexRoute: typeof ApiV1AdminContactGroupsIdMembersIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/monitoring': {
id: '/monitoring'
path: '/monitoring'
fullPath: '/monitoring'
preLoaderRoute: typeof MonitoringRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@ -982,6 +1231,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/webhooks/': {
id: '/apps/$appId/webhooks/'
path: '/webhooks'
fullPath: '/apps/$appId/webhooks/'
preLoaderRoute: typeof AppsAppIdWebhooksIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/apps/$appId/sessions/': {
id: '/apps/$appId/sessions/'
path: '/sessions'
@ -996,6 +1252,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppsAppIdContactsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/webhooks/': {
id: '/api/v1/webhooks/'
path: '/api/v1/webhooks'
fullPath: '/api/v1/webhooks/'
preLoaderRoute: typeof ApiV1WebhooksIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/': {
id: '/api/v1/sessions/'
path: '/api/v1/sessions'
@ -1080,6 +1343,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthTotpEnrollRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/contacts/settings/': {
id: '/apps/$appId/contacts/settings/'
path: '/contacts/settings'
fullPath: '/apps/$appId/contacts/settings/'
preLoaderRoute: typeof AppsAppIdContactsSettingsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/apps/$appId/contacts/groups/': {
id: '/apps/$appId/contacts/groups/'
path: '/contacts/groups'
fullPath: '/apps/$appId/contacts/groups/'
preLoaderRoute: typeof AppsAppIdContactsGroupsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/webhooks/$id/': {
id: '/api/v1/webhooks/$id/'
path: '/api/v1/webhooks/$id'
fullPath: '/api/v1/webhooks/$id/'
preLoaderRoute: typeof ApiV1WebhooksIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/': {
id: '/api/v1/sessions/$id/'
path: '/api/v1/sessions/$id'
@ -1115,6 +1399,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1ContactGroupsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/webhooks/': {
id: '/api/v1/admin/webhooks/'
path: '/api/v1/admin/webhooks'
fullPath: '/api/v1/admin/webhooks/'
preLoaderRoute: typeof ApiV1AdminWebhooksIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/': {
id: '/api/v1/admin/sessions/'
path: '/api/v1/admin/sessions'
@ -1136,6 +1427,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1AdminContactsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/contact-groups/': {
id: '/api/v1/admin/contact-groups/'
path: '/api/v1/admin/contact-groups'
fullPath: '/api/v1/admin/contact-groups/'
preLoaderRoute: typeof ApiV1AdminContactGroupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/': {
id: '/api/v1/admin/apps/'
path: '/api/v1/admin/apps'
@ -1143,6 +1441,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1AdminAppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/webhooks/$webhookId/deliveries': {
id: '/apps/$appId/webhooks/$webhookId/deliveries'
path: '/webhooks/$webhookId/deliveries'
fullPath: '/apps/$appId/webhooks/$webhookId/deliveries'
preLoaderRoute: typeof AppsAppIdWebhooksWebhookIdDeliveriesRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/webhooks/$id/test': {
id: '/api/v1/webhooks/$id/test'
path: '/api/v1/webhooks/$id/test'
fullPath: '/api/v1/webhooks/$id/test'
preLoaderRoute: typeof ApiV1WebhooksIdTestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/webhooks/$id/deliveries': {
id: '/api/v1/webhooks/$id/deliveries'
path: '/api/v1/webhooks/$id/deliveries'
fullPath: '/api/v1/webhooks/$id/deliveries'
preLoaderRoute: typeof ApiV1WebhooksIdDeliveriesRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/qr': {
id: '/api/v1/sessions/$id/qr'
path: '/api/v1/sessions/$id/qr'
@ -1185,6 +1504,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1ContactsImportJobIdRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/monitoring/stats': {
id: '/api/v1/admin/monitoring/stats'
path: '/api/v1/admin/monitoring/stats'
fullPath: '/api/v1/admin/monitoring/stats'
preLoaderRoute: typeof ApiV1AdminMonitoringStatsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/custom-field-schemas/$id': {
id: '/api/v1/admin/custom-field-schemas/$id'
path: '/api/v1/admin/custom-field-schemas/$id'
@ -1206,6 +1532,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1ContactGroupsIdMembersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/webhooks/$id/': {
id: '/api/v1/admin/webhooks/$id/'
path: '/api/v1/admin/webhooks/$id'
fullPath: '/api/v1/admin/webhooks/$id/'
preLoaderRoute: typeof ApiV1AdminWebhooksIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/': {
id: '/api/v1/admin/sessions/$id/'
path: '/api/v1/admin/sessions/$id'
@ -1220,6 +1553,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1AdminContactsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/contact-groups/$id/': {
id: '/api/v1/admin/contact-groups/$id/'
path: '/api/v1/admin/contact-groups/$id'
fullPath: '/api/v1/admin/contact-groups/$id/'
preLoaderRoute: typeof ApiV1AdminContactGroupsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/': {
id: '/api/v1/admin/apps/$id/'
path: '/api/v1/admin/apps/$id'
@ -1276,6 +1616,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1ContactGroupsIdMembersContactIdRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/webhooks/$id/test': {
id: '/api/v1/admin/webhooks/$id/test'
path: '/api/v1/admin/webhooks/$id/test'
fullPath: '/api/v1/admin/webhooks/$id/test'
preLoaderRoute: typeof ApiV1AdminWebhooksIdTestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/webhooks/$id/deliveries': {
id: '/api/v1/admin/webhooks/$id/deliveries'
path: '/api/v1/admin/webhooks/$id/deliveries'
fullPath: '/api/v1/admin/webhooks/$id/deliveries'
preLoaderRoute: typeof ApiV1AdminWebhooksIdDeliveriesRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/qr': {
id: '/api/v1/admin/sessions/$id/qr'
path: '/api/v1/admin/sessions/$id/qr'
@ -1311,6 +1665,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1AdminAppsIdSyncContactsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/contact-groups/$id/members/': {
id: '/api/v1/admin/contact-groups/$id/members/'
path: '/api/v1/admin/contact-groups/$id/members'
fullPath: '/api/v1/admin/contact-groups/$id/members/'
preLoaderRoute: typeof ApiV1AdminContactGroupsIdMembersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/': {
id: '/api/v1/admin/apps/$id/api-keys/'
path: '/api/v1/admin/apps/$id/api-keys'
@ -1325,6 +1686,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/contact-groups/$id/members/$contactId': {
id: '/api/v1/admin/contact-groups/$id/members/$contactId'
path: '/api/v1/admin/contact-groups/$id/members/$contactId'
fullPath: '/api/v1/admin/contact-groups/$id/members/$contactId'
preLoaderRoute: typeof ApiV1AdminContactGroupsIdMembersContactIdRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/$keyId': {
id: '/api/v1/admin/apps/$id/api-keys/$keyId'
path: '/api/v1/admin/apps/$id/api-keys/$keyId'
@ -1343,6 +1711,10 @@ interface AppsAppIdRouteChildren {
AppsAppIdSessionsNewRoute: typeof AppsAppIdSessionsNewRoute
AppsAppIdContactsIndexRoute: typeof AppsAppIdContactsIndexRoute
AppsAppIdSessionsIndexRoute: typeof AppsAppIdSessionsIndexRoute
AppsAppIdWebhooksIndexRoute: typeof AppsAppIdWebhooksIndexRoute
AppsAppIdWebhooksWebhookIdDeliveriesRoute: typeof AppsAppIdWebhooksWebhookIdDeliveriesRoute
AppsAppIdContactsGroupsIndexRoute: typeof AppsAppIdContactsGroupsIndexRoute
AppsAppIdContactsSettingsIndexRoute: typeof AppsAppIdContactsSettingsIndexRoute
}
const AppsAppIdRouteChildren: AppsAppIdRouteChildren = {
@ -1353,6 +1725,11 @@ const AppsAppIdRouteChildren: AppsAppIdRouteChildren = {
AppsAppIdSessionsNewRoute: AppsAppIdSessionsNewRoute,
AppsAppIdContactsIndexRoute: AppsAppIdContactsIndexRoute,
AppsAppIdSessionsIndexRoute: AppsAppIdSessionsIndexRoute,
AppsAppIdWebhooksIndexRoute: AppsAppIdWebhooksIndexRoute,
AppsAppIdWebhooksWebhookIdDeliveriesRoute:
AppsAppIdWebhooksWebhookIdDeliveriesRoute,
AppsAppIdContactsGroupsIndexRoute: AppsAppIdContactsGroupsIndexRoute,
AppsAppIdContactsSettingsIndexRoute: AppsAppIdContactsSettingsIndexRoute,
}
const AppsAppIdRouteWithChildren = AppsAppIdRoute._addFileChildren(
@ -1363,6 +1740,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditLogRoute: AuditLogRoute,
LoginRoute: LoginRoute,
MonitoringRoute: MonitoringRoute,
ApiHealthRoute: ApiHealthRoute,
ApiReadyRoute: ApiReadyRoute,
AppsAppIdRoute: AppsAppIdRouteWithChildren,
@ -1381,29 +1759,38 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1CustomFieldSchemasIndexRoute: ApiV1CustomFieldSchemasIndexRoute,
ApiV1MediaIndexRoute: ApiV1MediaIndexRoute,
ApiV1SessionsIndexRoute: ApiV1SessionsIndexRoute,
ApiV1WebhooksIndexRoute: ApiV1WebhooksIndexRoute,
ApiV1AdminCustomFieldSchemasIdRoute: ApiV1AdminCustomFieldSchemasIdRoute,
ApiV1AdminMonitoringStatsRoute: ApiV1AdminMonitoringStatsRoute,
ApiV1ContactsImportJobIdRoute: ApiV1ContactsImportJobIdRoute,
ApiV1SessionsIdDisconnectRoute: ApiV1SessionsIdDisconnectRoute,
ApiV1SessionsIdHealthRoute: ApiV1SessionsIdHealthRoute,
ApiV1SessionsIdPairRoute: ApiV1SessionsIdPairRoute,
ApiV1SessionsIdPresenceRoute: ApiV1SessionsIdPresenceRoute,
ApiV1SessionsIdQrRoute: ApiV1SessionsIdQrRoute,
ApiV1WebhooksIdDeliveriesRoute: ApiV1WebhooksIdDeliveriesRoute,
ApiV1WebhooksIdTestRoute: ApiV1WebhooksIdTestRoute,
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
ApiV1AdminContactGroupsIndexRoute: ApiV1AdminContactGroupsIndexRoute,
ApiV1AdminContactsIndexRoute: ApiV1AdminContactsIndexRoute,
ApiV1AdminCustomFieldSchemasIndexRoute:
ApiV1AdminCustomFieldSchemasIndexRoute,
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
ApiV1AdminWebhooksIndexRoute: ApiV1AdminWebhooksIndexRoute,
ApiV1ContactGroupsIdIndexRoute: ApiV1ContactGroupsIdIndexRoute,
ApiV1ContactsIdIndexRoute: ApiV1ContactsIdIndexRoute,
ApiV1ContactsImportIndexRoute: ApiV1ContactsImportIndexRoute,
ApiV1ContactsSyncFromWhatsappIndexRoute:
ApiV1ContactsSyncFromWhatsappIndexRoute,
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
ApiV1WebhooksIdIndexRoute: ApiV1WebhooksIdIndexRoute,
ApiV1AdminAppsIdSyncContactsRoute: ApiV1AdminAppsIdSyncContactsRoute,
ApiV1AdminContactsIdTagsRoute: ApiV1AdminContactsIdTagsRoute,
ApiV1AdminSessionsIdDisconnectRoute: ApiV1AdminSessionsIdDisconnectRoute,
ApiV1AdminSessionsIdPairRoute: ApiV1AdminSessionsIdPairRoute,
ApiV1AdminSessionsIdQrRoute: ApiV1AdminSessionsIdQrRoute,
ApiV1AdminWebhooksIdDeliveriesRoute: ApiV1AdminWebhooksIdDeliveriesRoute,
ApiV1AdminWebhooksIdTestRoute: ApiV1AdminWebhooksIdTestRoute,
ApiV1ContactGroupsIdMembersContactIdRoute:
ApiV1ContactGroupsIdMembersContactIdRoute,
ApiV1SessionsIdMessagesAudioRoute: ApiV1SessionsIdMessagesAudioRoute,
@ -1413,13 +1800,19 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1SessionsIdMessagesTextRoute: ApiV1SessionsIdMessagesTextRoute,
ApiV1SessionsIdMessagesVideoRoute: ApiV1SessionsIdMessagesVideoRoute,
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
ApiV1AdminContactGroupsIdIndexRoute: ApiV1AdminContactGroupsIdIndexRoute,
ApiV1AdminContactsIdIndexRoute: ApiV1AdminContactsIdIndexRoute,
ApiV1AdminSessionsIdIndexRoute: ApiV1AdminSessionsIdIndexRoute,
ApiV1AdminWebhooksIdIndexRoute: ApiV1AdminWebhooksIdIndexRoute,
ApiV1ContactGroupsIdMembersIndexRoute: ApiV1ContactGroupsIdMembersIndexRoute,
ApiV1SessionsIdMessagesIndexRoute: ApiV1SessionsIdMessagesIndexRoute,
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
ApiV1AdminContactGroupsIdMembersContactIdRoute:
ApiV1AdminContactGroupsIdMembersContactIdRoute,
ApiV1SessionsIdMessagesMsgIdReadRoute: ApiV1SessionsIdMessagesMsgIdReadRoute,
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
ApiV1AdminContactGroupsIdMembersIndexRoute:
ApiV1AdminContactGroupsIdMembersIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

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,33 @@
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] as unknown[],
daily: [...msgDaily] as unknown[],
},
sessions: [...sessionCounts] as unknown[],
webhooks: [...webhookStats] as unknown[],
reconnects: [...reconnectCounts] 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,42 @@
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 { endpoint, secret } = await createWebhookEndpoint(
body.app_id,
body.url,
body.events,
body.secret ?? undefined,
);
return json({ ...endpoint, secret }, 201);
},
},
},
});

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

@ -75,8 +75,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

@ -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,38 @@
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 { endpoint, secret } = await createWebhookEndpoint(
auth.appId,
body.url,
body.events,
body.secret ?? undefined,
);
return json({ ...endpoint, secret }, 201);
},
},
},
});

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,373 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
ArrowLeft,
Users,
Plus,
Trash2,
ChevronDown,
ChevronRight,
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 }: { appId: string; group: Group }) {
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);
}
}
return (
<div className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
onClick={() => setExpanded((v) => !v)}
>
<div className="flex items-center gap-3">
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div>
<p className="font-medium text-sm">{group.name}</p>
{group.description && (
<p className="text-xs text-muted-foreground">{group.description}</p>
)}
</div>
</div>
<span className="text-xs text-muted-foreground">
{members.length} member{members.length !== 1 ? "s" : ""}
</span>
</button>
{expanded && (
<div className="border-t px-4 py-3">
{members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members.</p>
) : (
<div className="space-y-1 mb-3">
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between py-1"
>
<div>
<span className="text-sm">{m.name ?? m.pushName ?? m.phone}</span>
{m.name && (
<span className="text-xs text-muted-foreground ml-2">
{m.phone}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={() => removeMember(m.id)}
>
<UserMinus className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => setAddOpen(true)}
>
<UserPlus className="h-3.5 w-3.5" />
Add contacts
</Button>
</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"
onClick={() => addMember(c.id)}
>
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-[10px] font-medium uppercase">
{(c.name ?? c.pushName ?? c.phone).charAt(0)}
</span>
</div>
<div>
<p className="text-sm">{c.name ?? c.pushName ?? c.phone}</p>
<p className="text-xs text-muted-foreground">{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="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">
<Users className="h-5 w-5" /> Groups
</h1>
<div className="ml-auto">
<Button size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New group
</Button>
</div>
</div>
{(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{!(isPending as boolean) && groups.length === 0 && (
<p className="text-sm text-muted-foreground">No groups yet.</p>
)}
<div className="space-y-2">
{groups.map((g) => (
<div key={g.id} className="relative">
<GroupRow appId={appId} group={g} />
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={() => setDeleteId(g.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
{/* Create dialog */}
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New group</DialogTitle>
<DialogDescription>
Create a contact group to segment your audience.
</DialogDescription>
</DialogHeader>
<form onSubmit={createGroup} className="space-y-3">
<div className="space-y-1">
<Label>Name</Label>
<Input
placeholder="e.g. VIP Customers"
value={newName}
onChange={(e) => setNewName(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label>Description (optional)</Label>
<Input
placeholder="What this group represents"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
</div>
{createError && (
<p className="text-sm text-red-500">{createError}</p>
)}
<Button type="submit" disabled={creating}>
{creating ? "Creating…" : "Create"}
</Button>
</form>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete group</DialogTitle>
<DialogDescription>
Removes the group. Contacts are not deleted.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={deleteGroup}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -72,10 +72,13 @@ function ContactImportPage() {
(c) => c.includes("phone") || c.includes("mobile") || c === "tel",
);
const nameIdx = lc.findIndex(
(c) => c.includes("name") && !c.includes("first") && !c.includes("last"),
(c) =>
c.includes("name") && !c.includes("first") && !c.includes("last"),
);
const firstNameIdx = lc.findIndex((c) => c.includes("first"));
const emailIdx = lc.findIndex((c) => c.includes("email") || c.includes("mail"));
const emailIdx = lc.findIndex(
(c) => c.includes("email") || c.includes("mail"),
);
if (phoneIdx >= 0) setPhoneCol(cols[phoneIdx]);
if (nameIdx >= 0) setNameCol(cols[nameIdx]);
@ -92,7 +95,11 @@ function ContactImportPage() {
setPreviewError("");
try {
const formData = new FormData();
formData.append("file", new Blob([csvContent], { type: "text/csv" }), "import.csv");
formData.append(
"file",
new Blob([csvContent], { type: "text/csv" }),
"import.csv",
);
formData.append(
"column_map",
JSON.stringify({
@ -113,17 +120,14 @@ function ContactImportPage() {
);
// Note: import endpoint uses API key auth; use admin endpoint instead
const adminRes = await fetch(
`/api/v1/contacts/import`,
{
method: "POST",
headers: {
// Use session cookie — this goes through admin proxy logic
},
credentials: "include",
body: formData,
const adminRes = await fetch(`/api/v1/contacts/import`, {
method: "POST",
headers: {
// Use session cookie — this goes through admin proxy logic
},
);
credentials: "include",
body: formData,
});
// Actually for the dashboard, we call the contacts import endpoint
// but it requires API key. Let's use the admin contacts endpoint approach.
@ -144,7 +148,9 @@ function ContactImportPage() {
const dataLines = lines.slice(1, 11); // preview first 10
const previewRows: PreviewRow[] = dataLines
.map((line) => {
const vals = line.split(",").map((v) => v.trim().replace(/^"(.*)"$/, "$1"));
const vals = line
.split(",")
.map((v) => v.trim().replace(/^"(.*)"$/, "$1"));
return {
phone: vals[phoneIdx] ?? "",
name: nameIdx >= 0 ? (vals[nameIdx] ?? undefined) : undefined,
@ -172,8 +178,11 @@ function ContactImportPage() {
const keysRes = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
credentials: "include",
});
const keys: Array<{ id: string; revokedAt: string | null; display: string }> =
await keysRes.json();
const keys: Array<{
id: string;
revokedAt: string | null;
display: string;
}> = await keysRes.json();
const activeKey = keys.find((k) => !k.revokedAt);
if (!activeKey) {
@ -206,7 +215,9 @@ function ContactImportPage() {
for (let i = 0; i < dataLines.length; i++) {
const line = dataLines[i];
const vals = line.split(",").map((v) => v.trim().replace(/^"(.*)"$/, "$1"));
const vals = line
.split(",")
.map((v) => v.trim().replace(/^"(.*)"$/, "$1"));
const phone = vals[phoneIdx];
if (!phone) {
failed++;
@ -223,8 +234,8 @@ function ContactImportPage() {
credentials: "include",
body: JSON.stringify({
phone,
name: nameIdx >= 0 ? (vals[nameIdx] || undefined) : undefined,
email: emailIdx >= 0 ? (vals[emailIdx] || undefined) : undefined,
name: nameIdx >= 0 ? vals[nameIdx] || undefined : undefined,
email: emailIdx >= 0 ? vals[emailIdx] || undefined : undefined,
source: "csv",
}),
},
@ -304,7 +315,13 @@ function ContactImportPage() {
{/* Step indicators */}
<div className="flex items-center gap-2 mb-8">
{(["upload", "map", "preview"] as Step[]).map((s, i) => {
const stepOrder: Step[] = ["upload", "map", "preview", "importing", "done"];
const stepOrder: Step[] = [
"upload",
"map",
"preview",
"importing",
"done",
];
const currentIdx = stepOrder.indexOf(step);
const sIdx = stepOrder.indexOf(s);
const isDone = currentIdx > sIdx;
@ -345,7 +362,8 @@ function ContactImportPage() {
Upload CSV file
</h2>
<p className="text-[13px] text-gray-500">
Select a CSV file with contact data. The file must have a header row.
Select a CSV file with contact data. The file must have a header
row.
</p>
</div>
<div
@ -481,7 +499,8 @@ function ContactImportPage() {
Preview
</h2>
<p className="text-[13px] text-gray-500">
Showing first {preview.length} of {totalRows} rows. Review before importing.
Showing first {preview.length} of {totalRows} rows. Review
before importing.
</p>
</div>
@ -553,7 +572,9 @@ function ContactImportPage() {
<p className="text-[14px] font-semibold text-gray-900 mb-1">
Importing contacts…
</p>
<p className="text-[13px] text-gray-500">This may take a moment.</p>
<p className="text-[13px] text-gray-500">
This may take a moment.
</p>
</div>
</div>
)}
@ -576,7 +597,9 @@ function ContactImportPage() {
<p className="text-[22px] font-bold text-emerald-700">
{importResult.created}
</p>
<p className="text-[11px] text-emerald-600 font-medium">Created</p>
<p className="text-[11px] text-emerald-600 font-medium">
Created
</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
<CheckCircle

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";
@ -175,8 +177,32 @@ 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>
<Link to="/apps/$appId/contacts/settings/" params={{ appId }}>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-[13px] gap-1.5"
>
<Settings size={12} strokeWidth={2} />
Fields
</Button>
</Link>
<Link to="/apps/$appId/contacts/import" params={{ appId }}>
<Button
variant="outline"
@ -508,10 +534,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">

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,113 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, XCircle, Clock } from "lucide-react";
import { webhookDeliveriesQuery } from "#/lib/query";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
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;
}
const STATUS_ICON = {
success: <CheckCircle2 className="h-4 w-4 text-emerald-600" />,
failed: <XCircle className="h-4 w-4 text-red-500" />,
pending: <Clock className="h-4 w-4 text-amber-500" />,
};
const STATUS_BADGE: Record<string, string> = {
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
failed: "border-red-200 bg-red-50 text-red-700",
pending: "border-amber-200 bg-amber-50 text-amber-700",
};
function DeliveriesPage() {
const { appId, webhookId } = Route.useParams();
const { data: deliveriesData, isPending } = useQuery<Delivery[]>(
webhookDeliveriesQuery(appId, webhookId),
);
const deliveries = deliveriesData ?? [];
return (
<div className="p-6 max-w-4xl">
<div className="flex items-center gap-3 mb-6">
<Link to="/apps/$appId/webhooks/" params={{ appId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1" /> Webhooks
</Button>
</Link>
<h1 className="text-xl font-semibold">Delivery log</h1>
</div>
{(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{!(isPending as boolean) && deliveries.length === 0 && (
<p className="text-sm text-muted-foreground">No deliveries yet.</p>
)}
<div className="space-y-2">
{deliveries.map((d) => (
<div
key={d.id}
className="border rounded-lg p-3 flex items-start gap-3"
>
<div className="mt-0.5 shrink-0">{STATUS_ICON[d.status]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
STATUS_BADGE[d.status],
)}
>
{d.status}
</span>
<Badge variant="secondary" className="text-xs">
{d.eventType}
</Badge>
{d.httpStatus && (
<span className="text-xs text-muted-foreground">
HTTP {d.httpStatus}
</span>
)}
<span className="text-xs text-muted-foreground">
attempt {d.attempt}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>{new Date(d.createdAt).toLocaleString()}</span>
{d.deliveredAt && (
<span>
delivered {new Date(d.deliveredAt).toLocaleString()}
</span>
)}
{d.nextAttemptAt && d.status === "pending" && (
<span>
retry at {new Date(d.nextAttemptAt).toLocaleString()}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,366 @@
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,
} 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 { Badge } from "#/components/ui/badge";
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;
}
const ALL_EVENTS = [
"session.status",
"session.deleted",
"message.received",
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"presence.update",
"contact.created",
"contact.updated",
];
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 [testResult, setTestResult] = useState<{
ok: boolean;
httpStatus: number | null;
error: string | null;
} | null>(null);
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) throw new Error(await res.text());
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);
setTestResult(null);
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 }),
});
setTestResult(await res.json());
} catch {
setTestResult({ ok: false, httpStatus: null, error: "Request failed" });
} finally {
setTestingId(null);
}
}
return (
<div className="p-6 max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Webhook className="h-5 w-5" />
<h1 className="text-xl font-semibold">Webhooks</h1>
</div>
<Button
size="sm"
onClick={() => {
setNewOpen(true);
setCreatedSecret(null);
}}
>
<Plus className="h-4 w-4 mr-1" /> Add endpoint
</Button>
</div>
{(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{!(isPending as boolean) && webhooks.length === 0 && (
<p className="text-sm text-muted-foreground">
No webhook endpoints yet.
</p>
)}
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="border rounded-lg p-4 flex items-start justify-between gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{wh.enabled ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
) : (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
)}
<span className="font-mono text-sm truncate">{wh.url}</span>
{wh.unhealthySince && (
<Badge variant="destructive" className="shrink-0">
Unhealthy
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1 mt-1">
{(wh.events.length === 0 ? ["all events"] : wh.events).map(
(ev) => (
<Badge key={ev} variant="secondary" className="text-xs">
{ev}
</Badge>
),
)}
</div>
{wh.consecutiveFailures > 0 && (
<p className="text-xs text-red-500 mt-1">
{wh.consecutiveFailures} consecutive failure(s)
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
disabled={testingId === wh.id}
onClick={() => testWebhook(wh.id)}
>
<TestTube className="h-3 w-3 mr-1" />
{testingId === wh.id ? "Testing…" : "Test"}
</Button>
<Link
to="/apps/$appId/webhooks/$webhookId/deliveries"
params={{ appId, webhookId: wh.id }}
>
<Button variant="outline" size="sm">
Log
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(wh.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
{testResult && (
<div
className={cn(
"mt-4 p-3 rounded-lg text-sm border",
testResult.ok
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-red-200 bg-red-50 text-red-800",
)}
>
{testResult.ok ? (
<>Test delivered — HTTP {testResult.httpStatus}</>
) : (
<>
Test failed —{" "}
{testResult.error ?? `HTTP ${testResult.httpStatus}`}
</>
)}
</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">
<p className="text-sm text-emerald-700 font-medium">
Endpoint created. Copy your signing secret — it won't be shown
again.
</p>
<code className="block bg-muted rounded p-3 text-xs break-all select-all">
{createdSecret}
</code>
<Button
onClick={() => {
setNewOpen(false);
setCreatedSecret(null);
}}
>
Done
</Button>
</div>
) : (
<form onSubmit={createWebhook} className="space-y-4">
<div className="space-y-1">
<Label>URL</Label>
<Input
placeholder="https://example.com/webhook"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label>Events (empty = all)</Label>
<div className="flex flex-wrap gap-2 mt-1">
{ALL_EVENTS.map((ev) => (
<button
key={ev}
type="button"
onClick={() => toggleEvent(ev)}
className={cn(
"text-xs px-2 py-1 rounded border transition-colors",
newEvents.includes(ev)
? "bg-primary text-primary-foreground border-primary"
: "border-border hover:bg-muted",
)}
>
{ev}
</button>
))}
</div>
</div>
{createError && (
<p className="text-sm text-red-500">{createError}</p>
)}
<Button type="submit" disabled={creating}>
{creating ? "Creating…" : "Create"}
</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>
);
}

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

@ -0,0 +1,288 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
MessageSquare,
Webhook,
RefreshCw,
Server,
} from "lucide-react";
import { monitoringStatsQuery } from "#/lib/query";
import { getSessionFn } from "#/server/auth";
import { Button } from "#/components/ui/button";
import { Badge } from "#/components/ui/badge";
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, string> = {
CONNECTED: "bg-emerald-50 text-emerald-700 border-emerald-200",
DISCONNECTED: "bg-amber-50 text-amber-700 border-amber-200",
DEAD: "bg-red-50 text-red-700 border-red-200",
LOGGED_OUT: "bg-gray-50 text-gray-600 border-gray-200",
PENDING_QR: "bg-blue-50 text-blue-700 border-blue-200",
PENDING_PAIRING: "bg-blue-50 text-blue-700 border-blue-200",
CONNECTING: "bg-sky-50 text-sky-700 border-sky-200",
};
function StatCard({
label,
value,
sub,
}: {
label: string;
value: string | number;
sub?: string;
}) {
return (
<div className="border rounded-lg p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className="text-2xl font-bold">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</div>
);
}
function MessageBar({ row, max }: { row: HourlyRow; max: number }) {
const pct = max > 0 ? (row.count / max) * 100 : 0;
return (
<div className="flex items-center gap-2 text-xs">
<span className="w-12 text-right text-muted-foreground shrink-0">
{new Date(row.hour).getHours()}:00
</span>
<div className="flex-1 h-4 bg-muted rounded overflow-hidden">
<div
className={cn(
"h-full rounded",
row.direction === "in" ? "bg-blue-400" : "bg-emerald-400",
)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-8 text-muted-foreground">{row.count}</span>
<Badge variant="outline" className="text-xs py-0">
{row.direction === "in" ? "↓" : "↑"}
</Badge>
</div>
);
}
function MonitoringPage() {
const {
data: stats,
isPending,
refetch,
} = useQuery<MonitoringStats>(monitoringStatsQuery);
const hourlyRows = stats?.messages.hourly ?? [];
const maxHourlyCount = Math.max(1, ...hourlyRows.map((r) => r.count));
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;
return (
<div className="p-6 max-w-5xl">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Activity className="h-5 w-5" />
<h1 className="text-xl font-semibold">Monitoring</h1>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-3.5 w-3.5 mr-1" /> Refresh
</Button>
</div>
{(isPending as boolean) && (
<p className="text-sm text-muted-foreground mb-4">Loading…</p>
)}
{/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<StatCard label="Messages received (24h)" value={totalIn24h} />
<StatCard label="Messages sent (24h)" value={totalOut24h} />
<StatCard label="Connected sessions" value={connectedSessions} />
<StatCard
label="Webhook success rate (24h)"
value={webhookSuccessRate !== null ? `${webhookSuccessRate}%` : "—"}
sub={webhookTotal > 0 ? `${webhookTotal} total` : undefined}
/>
</div>
{/* Queue depths */}
<div className="grid grid-cols-2 gap-3 mb-6">
<StatCard
label="Send queue depth"
value={stats?.queues.send ?? "—"}
sub="pending jobs"
/>
<StatCard
label="Webhook queue depth"
value={stats?.queues.webhook ?? "—"}
sub="pending jobs"
/>
</div>
{/* Messages per hour */}
<div className="border rounded-lg p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="h-4 w-4" />
<h2 className="font-medium text-sm">Messages per hour (last 24h)</h2>
</div>
{hourlyRows.length === 0 ? (
<p className="text-xs text-muted-foreground">No data</p>
) : (
<div className="space-y-1">
{hourlyRows.map((row, i) => (
<MessageBar key={i} row={row} max={maxHourlyCount} />
))}
</div>
)}
<div className="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 mr-1" />
Inbound
</span>
<span>
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1" />
Outbound
</span>
</div>
</div>
{/* Session status */}
<div className="border rounded-lg p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<Server className="h-4 w-4" />
<h2 className="font-medium text-sm">Session status breakdown</h2>
</div>
{!stats?.sessions.length ? (
<p className="text-xs text-muted-foreground">No sessions</p>
) : (
<div className="flex flex-wrap gap-2">
{stats.sessions.map((s) => (
<div
key={s.status}
className={cn(
"text-xs px-3 py-1.5 rounded border font-medium",
SESSION_STATUS_STYLE[s.status] || "border-border",
)}
>
{s.status}: {s.count}
</div>
))}
</div>
)}
</div>
{/* Webhook delivery stats */}
<div className="border rounded-lg p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<Webhook className="h-4 w-4" />
<h2 className="font-medium text-sm">Webhook deliveries (last 24h)</h2>
</div>
{!stats?.webhooks.length ? (
<p className="text-xs text-muted-foreground">No deliveries</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground">
<th className="text-left pb-2">Status</th>
<th className="text-right pb-2">Count</th>
<th className="text-right pb-2">p50 latency</th>
<th className="text-right pb-2">p95 latency</th>
</tr>
</thead>
<tbody>
{stats.webhooks.map((w) => (
<tr key={w.status} className="border-t">
<td className="py-1.5 capitalize">{w.status}</td>
<td className="text-right">{w.count}</td>
<td className="text-right">
{w.p50_ms != null ? `${Math.round(w.p50_ms)}ms` : "—"}
</td>
<td className="text-right">
{w.p95_ms != null ? `${Math.round(w.p95_ms)}ms` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Top reconnecting sessions */}
{(stats?.reconnects.length ?? 0) > 0 && (
<div className="border rounded-lg p-4">
<h2 className="font-medium text-sm mb-3">
Sessions by reconnect count
</h2>
<div className="space-y-1">
{stats.reconnects.map((r) => (
<div
key={r.id}
className="flex items-center justify-between text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{r.id}
</span>
<span className="font-medium ml-4 shrink-0">
{r.reconnect_count} reconnects
</span>
</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,7 @@ 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 type { Worker } from "bullmq";
const _g = globalThis as {
@ -10,6 +11,7 @@ const _g = globalThis as {
__wahooBroadcastWorker?: Worker;
__wahooCleanupWorker?: Worker;
__wahooImportWorker?: Worker;
__wahooWebhookWorker?: Worker;
__wahooShutdownRegistered?: boolean;
};
@ -26,6 +28,7 @@ export default async function () {
startBroadcastWorker();
await startCleanupWorker();
startImportWorker();
startWebhookWorker();
console.log("[session-startup] queue workers started");
} catch (err) {
console.error("[session-startup] failed to start queue workers:", err);
@ -40,6 +43,7 @@ export default async function () {
_g.__wahooBroadcastWorker?.close(),
_g.__wahooCleanupWorker?.close(),
_g.__wahooImportWorker?.close(),
_g.__wahooWebhookWorker?.close(),
]);
process.exit(0);
};

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,46 @@ 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 ─────────────────────────────────────────────────

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

@ -0,0 +1,438 @@
/**
* 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

@ -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(),