feat: webhhoks
This commit is contained in:
parent
ec194d0596
commit
e45dd79e8a
65 changed files with 6211 additions and 502 deletions
234
bun.lock
234
bun.lock
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
drizzle/0005_lush_moondragon.sql
Normal file
34
drizzle/0005_lush_moondragon.sql
Normal 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");
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1492
drizzle/meta/0005_snapshot.json
Normal file
1492
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
294
src/lib/actions/webhooks.ts
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
55
src/lib/events/dispatch.ts
Normal file
55
src/lib/events/dispatch.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
118
src/lib/queue/webhook-worker.ts
Normal file
118
src/lib/queue/webhook-worker.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
54
src/routes/api/v1/admin/contact-groups/$id/index.ts
Normal file
54
src/routes/api/v1/admin/contact-groups/$id/index.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
33
src/routes/api/v1/admin/contact-groups/$id/members/index.ts
Normal file
33
src/routes/api/v1/admin/contact-groups/$id/members/index.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
40
src/routes/api/v1/admin/contact-groups/index.ts
Normal file
40
src/routes/api/v1/admin/contact-groups/index.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
94
src/routes/api/v1/admin/monitoring/stats.ts
Normal file
94
src/routes/api/v1/admin/monitoring/stats.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
40
src/routes/api/v1/admin/webhooks/$id/deliveries.ts
Normal file
40
src/routes/api/v1/admin/webhooks/$id/deliveries.ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
65
src/routes/api/v1/admin/webhooks/$id/index.ts
Normal file
65
src/routes/api/v1/admin/webhooks/$id/index.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
62
src/routes/api/v1/admin/webhooks/$id/test.ts
Normal file
62
src/routes/api/v1/admin/webhooks/$id/test.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
42
src/routes/api/v1/admin/webhooks/index.ts
Normal file
42
src/routes/api/v1/admin/webhooks/index.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
39
src/routes/api/v1/webhooks/$id/deliveries.ts
Normal file
39
src/routes/api/v1/webhooks/$id/deliveries.ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
51
src/routes/api/v1/webhooks/$id/index.ts
Normal file
51
src/routes/api/v1/webhooks/$id/index.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
59
src/routes/api/v1/webhooks/$id/test.ts
Normal file
59
src/routes/api/v1/webhooks/$id/test.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
38
src/routes/api/v1/webhooks/index.ts
Normal file
38
src/routes/api/v1/webhooks/index.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
373
src/routes/apps/$appId/contacts/groups/index.tsx
Normal file
373
src/routes/apps/$appId/contacts/groups/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
362
src/routes/apps/$appId/contacts/settings/index.tsx
Normal file
362
src/routes/apps/$appId/contacts/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/routes/apps/$appId/webhooks/$webhookId/deliveries.tsx
Normal file
113
src/routes/apps/$appId/webhooks/$webhookId/deliveries.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
src/routes/apps/$appId/webhooks/index.tsx
Normal file
366
src/routes/apps/$appId/webhooks/index.tsx
Normal 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
288
src/routes/monitoring.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/server/plugins/otel.ts
Normal file
26
src/server/plugins/otel.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
438
tests/m5-unit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue