From 6851d66b38ed4f5ac6433045f01465f716f9d29a Mon Sep 17 00:00:00 2001 From: Aswin Biju Date: Tue, 3 Nov 2026 10:09:46 +0530 Subject: [PATCH] feat: SRS backend: orders intake, results pipeline, user management Fastify + TypeScript + SQLite backend for a single Kerala lottery agency. --- .env.example | 13 + .gitignore | 10 + README.md | 26 + data/.gitkeep | 0 ecosystem.config.cjs | 15 + package-lock.json | 2763 +++++++++++++++++ package.json | 33 + scripts/copy-migrations.mjs | 5 + scripts/results.ts | 65 + scripts/user.ts | 86 + src/app.ts | 44 + src/cli/userWizard.test.ts | 166 + src/cli/userWizard.ts | 177 ++ src/config.test.ts | 23 + src/config.ts | 45 + src/db/index.ts | 16 + src/db/migrate.test.ts | 125 + src/db/migrate.ts | 48 + src/db/migrations/001_init.sql | 90 + src/parser/parseOrder.test.ts | 42 + src/parser/parseOrder.ts | 5 + src/parser/types.ts | 28 + src/results/job.test.ts | 122 + src/results/job.ts | 97 + src/results/parseResult.test.ts | 103 + src/results/parseResult.ts | 215 ++ src/results/pdfText.ts | 26 + src/results/portal.ts | 112 + src/results/scheduler.test.ts | 91 + src/results/scheduler.ts | 81 + src/results/store.ts | 33 + src/routes/auth.ts | 14 + src/routes/health.ts | 41 + src/routes/ingest.test.ts | 235 ++ src/routes/ingest.ts | 86 + src/server.ts | 45 + src/services/accountRules.ts | 48 + src/services/drawDate.test.ts | 26 + src/services/drawDate.ts | 18 + src/services/phone.test.ts | 39 + src/services/phone.ts | 20 + src/services/provisioning.ts | 54 + src/services/shopScope.test.ts | 53 + src/services/shopScope.ts | 37 + src/services/userAdmin.test.ts | 165 + src/services/userAdmin.ts | 87 + src/services/users.ts | 44 + src/testing/helpers.ts | 42 + src/whatsapp/bridge.ts | 35 + src/whatsapp/conversation.ts | 68 + .../results/br111_2026-09-26_layout.txt | 419 +++ test/fixtures/results/bt78_2026-11-02_raw.txt | 110 + .../results/dl73_2026-10-14_layout.txt | 205 ++ .../results/kn645_2026-10-15_layout.txt | 207 ++ test/fixtures/results/sk73_2026-10-16.pdf | Bin 0 -> 87181 bytes .../results/sk73_2026-10-16_layout.txt | 200 ++ test/fixtures/results/sm79_2026-11-01_raw.txt | 111 + tsconfig.build.json | 10 + tsconfig.json | 19 + vitest.config.ts | 8 + 60 files changed, 7151 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 ecosystem.config.cjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/copy-migrations.mjs create mode 100644 scripts/results.ts create mode 100644 scripts/user.ts create mode 100644 src/app.ts create mode 100644 src/cli/userWizard.test.ts create mode 100644 src/cli/userWizard.ts create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/db/index.ts create mode 100644 src/db/migrate.test.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/migrations/001_init.sql create mode 100644 src/parser/parseOrder.test.ts create mode 100644 src/parser/parseOrder.ts create mode 100644 src/parser/types.ts create mode 100644 src/results/job.test.ts create mode 100644 src/results/job.ts create mode 100644 src/results/parseResult.test.ts create mode 100644 src/results/parseResult.ts create mode 100644 src/results/pdfText.ts create mode 100644 src/results/portal.ts create mode 100644 src/results/scheduler.test.ts create mode 100644 src/results/scheduler.ts create mode 100644 src/results/store.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/ingest.test.ts create mode 100644 src/routes/ingest.ts create mode 100644 src/server.ts create mode 100644 src/services/accountRules.ts create mode 100644 src/services/drawDate.test.ts create mode 100644 src/services/drawDate.ts create mode 100644 src/services/phone.test.ts create mode 100644 src/services/phone.ts create mode 100644 src/services/provisioning.ts create mode 100644 src/services/shopScope.test.ts create mode 100644 src/services/shopScope.ts create mode 100644 src/services/userAdmin.test.ts create mode 100644 src/services/userAdmin.ts create mode 100644 src/services/users.ts create mode 100644 src/testing/helpers.ts create mode 100644 src/whatsapp/bridge.ts create mode 100644 src/whatsapp/conversation.ts create mode 100644 test/fixtures/results/br111_2026-09-26_layout.txt create mode 100644 test/fixtures/results/bt78_2026-11-02_raw.txt create mode 100644 test/fixtures/results/dl73_2026-10-14_layout.txt create mode 100644 test/fixtures/results/kn645_2026-10-15_layout.txt create mode 100644 test/fixtures/results/sk73_2026-10-16.pdf create mode 100644 test/fixtures/results/sk73_2026-10-16_layout.txt create mode 100644 test/fixtures/results/sm79_2026-11-01_raw.txt create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..013748e --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +PORT=8080 +HOST=127.0.0.1 +DB_PATH=./data/srs.db + +# Shared secret between the Hermes plugin and srs-api (header: x-srs-secret). Min 16 chars. +# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SRS_SECRET=change-me-to-a-long-random-string + +JWT_SECRET=change-me-to-another-long-random-string + +# Hermes WhatsApp bridge (local only) +BRIDGE_URL=http://127.0.0.1:3000 +DEFAULT_COUNTRY_CODE=91 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da9274b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.env +data/* +!data/.gitkeep +*.db +*.db-wal +*.db-shm +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..669b4af --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# srs-api + +Backend for SRS: the order parser, the WhatsApp conversation flow, and the API for the Flutter PWA. +One Node/TypeScript service (Fastify) with a SQLite database. It runs on the server under pm2, behind Caddy. + +``` +Flutter PWA ──HTTPS──▶ Caddy ──/api/*──▶ ┐ + ├─▶ srs-api (127.0.0.1:8080) ──▶ SQLite file +Hermes plugin ──localhost + secret──────▶ ┘ │ + └─▶ WhatsApp bridge (127.0.0.1:3000): replies, 3 PM summary +``` + +## Run it on your computer + +Needs Node 22 or newer. + +```bash +npm install +cp .env.example .env +npm run migrate # creates ./data/srs.db +npm run user create # a short wizard: choose Admin or User +npm run user list # show every account +npm run dev # http://127.0.0.1:8080 +npm test +npm run typecheck +``` \ No newline at end of file diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..230a5e6 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,15 @@ +module.exports = { + apps: [ + { + name: 'srs-api', + script: 'dist/server.js', + instances: 1, + exec_mode: 'fork', + autorestart: true, + max_memory_restart: '300M', + env: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..022f9a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2763 @@ +{ + "name": "srs-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "srs-api", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^12.0.0", + "fastify": "^5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.0.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", + "integrity": "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.4.tgz", + "integrity": "sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.1.tgz", + "integrity": "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.1.tgz", + "integrity": "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.1.2.tgz", + "integrity": "sha512-89WiRG+hW3VQSfCwg4Hb8b43PeyEX15K5+lEfsZ4EUth/FyOjS/lv8HsVuswUNXWiSnxMGNouE/fq/0w3VumHQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.2.tgz", + "integrity": "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.4.tgz", + "integrity": "sha512-/v/pUAPOcwp+r1Hjjdg0cBV1TE89CzGKrKh2FgkIQw2QBcIMmVPOCSfuOZStCn4Xh/98HXfo/ht6ztcssctPNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.4.tgz", + "integrity": "sha512-6tmmB/P52Vt167FXXEw5auiEUgTtZz7aqUgWHqYXToIUE0HFDY6fTdAyeekjjsNk6O/8dtpSKtClxSj1mMneKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.4.tgz", + "integrity": "sha512-8Z9Q4RmotVmuL4llOvqX814JNiOK1oqoBv+glG5KbpRfUiypqzz2/fxIzK4Pq129l4dT5a8Gszmpc0/XPYoFuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.4.tgz", + "integrity": "sha512-pnq6hssxTtwuRAl6Kdi/+IDY+b1IhkUetjhUkdLAAJCssjfqtL2Oqy/FXJVJFtAWOu1oRlArIvf0Yk1O92x2HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.4.tgz", + "integrity": "sha512-BeIrGZvHMVdJg/N+ohZfFywG/gF41684YhDj2wBvouj7m9ziP1tZDUNwzXRiDY+3WIKngaC4hiUBqVtBPGP5og==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.4.tgz", + "integrity": "sha512-fRFX/oqNg/g780DKoS8RIfYcZD59Ru5O/bSeK1N7gQljBFrTxJY/q5EX4eMs5SrJf32VgVBVVYCdfPpzAwPPnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.4.tgz", + "integrity": "sha512-bSrBRopG1q+0RuOsW0NtYTtIODlvNA3T3ebx5WnQVdSxWIoXFmj+rZdHUHdSLgFdZ5M6VGmekYE7r4RMGQXjfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.4.tgz", + "integrity": "sha512-L8IvdrJVtl65g1ozwDSyjGPG+FmibhCdFUwmqTItJLw0S3+Oj4QzFs7rfgTUiK7rsPjkCLdG1/GApFxoynXFJQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.4.tgz", + "integrity": "sha512-h7PcoQOzC31jshNpQ48J0akB9tsghjkSjSl5AZ6XsVGTxVsln7t7C4SQyw62tNVSaz2SeHgk4VT2ELbL17BSHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.4.tgz", + "integrity": "sha512-KSPuOBNHoOl7AsVjeY1KZci8+0up0U4TFAHU5IzBYN8P+Z6cB9DZq28+JQycZW7f6WLLa/tSzhV7Rdy95bVi+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.4.tgz", + "integrity": "sha512-0E/mKmVk1PlQeQYygIwQm0jlwjANTzGSiNBMPrFx+rdFGFj/2Axh4p6XwcrystQIqXZVudTaGsdbDpzYgJ4wSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.4.tgz", + "integrity": "sha512-Fvw7dOSc2Sa9UOipRZVI0zOgdLCzsjVyD/6JuHXzUHTXFu7mMgmeX/0/bCVBIut1dz+nWz3pV4WpS/Qg0XVnxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.4.tgz", + "integrity": "sha512-jzxbVHaE7V3F+P0wEK7UydQtvv7Twfdtx58jI5/3VRx1jJxyrn+qFwfXmqjVFxPhBl+zjaRs+ZJMFJw7vdHaQA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.4.tgz", + "integrity": "sha512-fhtrzp9p4GjEJVaigaap9g6X9nP6xgJtKiLLXSDgpE18HXufA5aqfltGn+KAqMtVZReJZfOrXm//y5YSA9BWbA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.4.tgz", + "integrity": "sha512-uynaF7IUHwAckV0S+yC1f5esy9gB+aqCBsPH5BOck32TYdCuAn2lVG5tvUcFnEsrviGEmRZxE1NHSOieUPYJEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.4.tgz", + "integrity": "sha512-uaqRFdfb8eIa2bVHN/FE3ndCvR0zV2xv0AwjCfxrqSu6l6fp/54VhjfhslKlf2RIik9L1jwlt4E56PeVX6tQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.4.tgz", + "integrity": "sha512-JZRow7kmxUDEASnxHuVG5itTm+nTXi9BQUABcVUnUEngC+OPwMrpx1uJ9hIA/v+IcAEBL52ktCSPXk5OJn9Jqg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.4.tgz", + "integrity": "sha512-rdrh4e+Lq9fCNKoDPaCqeHXbQCe7yl77Ukl45tlSClwYdZ1gpFGZz1XLJdYtY++FRlfL5aHvb6/0n3GDW/Nxsw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.4.tgz", + "integrity": "sha512-wL7MS4r0fYcxluF3ne2plofHZiB/LsqNCnyaUlyiKpAJLb6agPSFz7DEFCfkonWAZXMrLwCmC08b2ZEnEdJkog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.4.tgz", + "integrity": "sha512-4H0XHfd2VufGjX7NjUyeue78sD0bRIoKSGjGqO+F5nSvL6jN48NAU7esdmtFEYxZXwObLtt3MJ/kHS0jASMW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.4.tgz", + "integrity": "sha512-osRHQbE55pZpCLetpMsFmhkapyZnAUH73qRKMh+npi3boUom6u6hdaTTPSxlInXxwJHsn9r2uDhdz0V7Yoxx2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.4.tgz", + "integrity": "sha512-95+exYEyOwrZePsE0czi4q7X5lxKPa21+lmyAZlwbYENITQ0Unk4HAQAFp/p+KhXBHBx9mUABXRYjOC5A1wY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.4.tgz", + "integrity": "sha512-FOXOcKhNQpO4Po+Ynan6IbtM67SrXZbgpRJWVYwz53CT5veI7sVzDwVBmWS65N5Imv+wK3SVhIPAl+C5DXXNCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.4.tgz", + "integrity": "sha512-cklKh0gJOkAZQcWziQa3hrjxZKN+PCHZ7SPTyGJDAiBVr05ZXlEjGFCG7zFJMaVibxsmzAe1FimN4u/HBns9+Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.4.tgz", + "integrity": "sha512-AegwxJPc9bvUp8fQtlsUHkFMOMkGylWU7glyL+YD4vwG+QaYoGXYLSsQoeTD7kvoLZrVlMWNVhTg1dedY5C1VQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.4.tgz", + "integrity": "sha512-RJZnrwTWj7Iq2ooBwoGbDdC65tDwYawneGcAfFMJqVUrRZMcskka6nTkBpXR/47G7kzn3EW2qupExnaBy11vwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.1.tgz", + "integrity": "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.2.1.tgz", + "integrity": "sha512-GU16K+1IH8XSJ/suUaX/ONlJ5glCFvmBdra2nGI2ZGpBBXzqM/r/lzO02gjHYcRad1KKj6WvC+/RROggpg6acg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.3.tgz", + "integrity": "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.8", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.8.tgz", + "integrity": "sha512-moP8exb2sCHLUbJkwp+I6C0padhy4D8lp0SdmzY7HhSdZuqm7O/u3o2aq7u8okaQ//33QDzj3G/u+FsSdB8Qzw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.10.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.10.0.tgz", + "integrity": "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", + "integrity": "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "@fastify/deepmerge": "^3.1.0", + "cookie": "^2.0.0", + "process-warning": "^5.0.0", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.101.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz", + "integrity": "sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.1.tgz", + "integrity": "sha512-Z/nyvABwcRnRD2mMP7Kz6G1dFZZqX1mNErOnxF42OCQKE07b9k9hK9SQxHL3LFEZUdzQaLM4dId1E/E6Vm4WhA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.67.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.4.tgz", + "integrity": "sha512-Lsv3oKcz5jgOQJej/p7nfwbVEdAbDT+z6OPf/SCdI+nF+q2ENp0pTKT35ZwXAmH8g45mfrIiJeEyAqKYSYoL9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.4", + "@rollup/rollup-android-arm64": "4.67.4", + "@rollup/rollup-android-x64": "4.67.4", + "@rollup/rollup-darwin-arm64": "4.67.4", + "@rollup/rollup-darwin-x64": "4.67.4", + "@rollup/rollup-freebsd-arm64": "4.67.4", + "@rollup/rollup-freebsd-x64": "4.67.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.4", + "@rollup/rollup-linux-arm-musleabihf": "4.67.4", + "@rollup/rollup-linux-arm64-gnu": "4.67.4", + "@rollup/rollup-linux-arm64-musl": "4.67.4", + "@rollup/rollup-linux-loong64-gnu": "4.67.4", + "@rollup/rollup-linux-loong64-musl": "4.67.4", + "@rollup/rollup-linux-ppc64-gnu": "4.67.4", + "@rollup/rollup-linux-ppc64-musl": "4.67.4", + "@rollup/rollup-linux-riscv64-gnu": "4.67.4", + "@rollup/rollup-linux-riscv64-musl": "4.67.4", + "@rollup/rollup-linux-s390x-gnu": "4.67.4", + "@rollup/rollup-linux-x64-gnu": "4.67.4", + "@rollup/rollup-linux-x64-musl": "4.67.4", + "@rollup/rollup-openbsd-x64": "4.67.4", + "@rollup/rollup-openharmony-arm64": "4.67.4", + "@rollup/rollup-win32-arm64-msvc": "4.67.4", + "@rollup/rollup-win32-ia32-msvc": "4.67.4", + "@rollup/rollup-win32-x64-gnu": "4.67.4", + "@rollup/rollup-win32-x64-msvc": "4.67.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.2.2.tgz", + "integrity": "sha512-df017qMeYuXo4emL09nlyMpi2i2MtEIKS/6jtXH3vmjFgIRsOi852/5RmhJ4EkvN3ZF8jXadM/EkTPXgc8yPIw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..33635ab --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "srs-api", + "version": "0.1.0", + "private": true, + "description": "SRS backend: order parser, WhatsApp conversation flow, shops/users API for the PWA", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs", + "start": "node dist/server.js", + "migrate": "tsx src/db/migrate.ts", + "migrate:prod": "node dist/db/migrate.js", + "user": "tsx scripts/user.ts", + "results": "tsx scripts/results.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "better-sqlite3": "^12.0.0", + "fastify": "^5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.0.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } +} diff --git a/scripts/copy-migrations.mjs b/scripts/copy-migrations.mjs new file mode 100644 index 0000000..44bf7f8 --- /dev/null +++ b/scripts/copy-migrations.mjs @@ -0,0 +1,5 @@ +import { cpSync, mkdirSync } from 'node:fs'; + +mkdirSync('dist/db', { recursive: true }); +cpSync('src/db/migrations', 'dist/db/migrations', { recursive: true }); +console.log('migrations copied to dist/db/migrations'); diff --git a/scripts/results.ts b/scripts/results.ts new file mode 100644 index 0000000..60efa58 --- /dev/null +++ b/scripts/results.ts @@ -0,0 +1,65 @@ +/** + * Fetch, parse, validate and store recent Kerala lottery results. + * npm run results last 7 days from the portal + * npm run results -- --days 5 --force + * npm run results -- --pdf-dir ./pdfs parse PDFs you already have (no network) + * Exit code: 0 all fine, 2 a draw was held, 3 a fetch failed. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../src/config'; +import { openDb } from '../src/db'; +import { runMigrations } from '../src/db/migrate'; +import { processPdf, runResultsJob } from '../src/results/job'; +import { hasErrors } from '../src/results/parseResult'; +import { storeDraw } from '../src/results/store'; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main(): Promise { + const config = loadConfig(); + const db = openDb(config.dbPath); + runMigrations(db); + + const dir = arg('pdf-dir'); + if (dir) { + let held = 0; + for (const f of readdirSync(dir).filter((n) => n.toLowerCase().endsWith('.pdf')).sort()) { + const { draw, issues } = await processPdf(readFileSync(join(dir, f))); + if (draw) storeDraw(db, draw, issues); + if (draw && !hasErrors(issues)) { + console.log(`${f}: ${draw.schemeCode}-${draw.drawNo} ${draw.drawDate} ready`); + } else { + held++; + console.log(`${f}: HELD ${issues.map((i) => i.message).join('; ')}`); + } + } + return held ? 2 : 0; + } + + const days = arg('days'); + const today = arg('today'); + const reports = await runResultsJob({ + db, + force: process.argv.includes('--force'), + ...(days ? { days: Number(days) } : {}), + ...(today ? { today } : {}), + }); + for (const r of reports) { + const detail = r.issues.length ? ' - ' + r.issues.map((i) => `${i.level} ${i.message}`).join('; ') : ''; + console.log(`${r.code} ${r.date}: ${r.status}${detail}`); + } + if (reports.some((r) => r.status === 'fetch_failed')) return 3; + return reports.some((r) => r.status === 'held') ? 2 : 0; +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(3); + }, +); diff --git a/scripts/user.ts b/scripts/user.ts new file mode 100644 index 0000000..1adf6d5 --- /dev/null +++ b/scripts/user.ts @@ -0,0 +1,86 @@ +/** + * npm run user create a short wizard: choose Admin or User + * npm run user list show every account + * + */ +import { createInterface } from 'node:readline'; +import { Cancelled, createWizard, listLines, type Io } from '../src/cli/userWizard'; +import { loadConfig } from '../src/config'; +import { openDb } from '../src/db'; +import { runMigrations } from '../src/db/migrate'; + +function makeIo(): { io: Io; close: () => void } { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const queue: string[] = []; + let waiting: ((line: string | null) => void) | null = null; + let ended = false; + + rl.on('line', (line) => { + if (waiting) { + const w = waiting; + waiting = null; + w(line); + } else { + queue.push(line); + } + }); + rl.on('close', () => { + ended = true; + if (waiting) { + const w = waiting; + waiting = null; + w(null); + } + }); + + const io: Io = { + async ask(question) { + rl.setPrompt(question); + rl.prompt(); + const line = queue.length > 0 ? queue.shift()! : ended ? null : await new Promise((r) => (waiting = r)); + if (line === null) throw new Cancelled(); + return line.trim(); + }, + say(line = '') { + console.log(line); + }, + }; + return { io, close: () => rl.close() }; +} + +async function main(): Promise { + const command = process.argv[2]; + if (command !== 'create' && command !== 'list') { + console.log('usage:'); + console.log(' npm run user create create an admin or a user (a short wizard)'); + console.log(' npm run user list show every account'); + return command ? 1 : 0; + } + + const config = loadConfig(); + const db = openDb(config.dbPath); + runMigrations(db); + + try { + if (command === 'list') { + for (const line of listLines(db)) console.log(line); + return 0; + } + const { io, close } = makeIo(); + try { + return await createWizard(db, io, config.defaultCountryCode); + } finally { + close(); + } + } finally { + db.close(); + } +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error(String(err)); + process.exit(1); + }, +); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..6a1c528 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,44 @@ +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import type { Config } from './config'; +import type { Db } from './db'; +import { healthRoutes } from './routes/health'; +import { ingestRoutes } from './routes/ingest'; +import type { Bridge } from './whatsapp/bridge'; + +export interface AppDeps { + db: Db; + config: Config; + bridge: Bridge; +} + +export interface AppOptions { + logger?: boolean; +} + +export function createApp({ db, config, bridge }: AppDeps, options: AppOptions = {}): FastifyInstance { + const app = Fastify({ + logger: options.logger ?? false, + bodyLimit: 12 * 1024 * 1024, + trustProxy: true, + }); + + app.register(healthRoutes({ db, bridge, secret: config.srsSecret })); + app.register(ingestRoutes({ db, secret: config.srsSecret })); + + + app.setNotFoundHandler((_req, reply) => { + return reply.code(404).send({ error: 'not_found' }); + }); + + app.setErrorHandler((err, req, reply) => { + const status = (err as { statusCode?: number }).statusCode ?? 500; + if (status === 413) return reply.code(413).send({ error: 'payload_too_large' }); + if (status === 415) return reply.code(415).send({ error: 'unsupported_media_type' }); + if (status >= 400 && status < 500) return reply.code(400).send({ error: 'invalid_json' }); + req.log.error({ err }, 'unhandled error'); + return reply.code(500).send({ error: 'internal_error' }); + }); + + return app; +} diff --git a/src/cli/userWizard.test.ts b/src/cli/userWizard.test.ts new file mode 100644 index 0000000..1a7bb6c --- /dev/null +++ b/src/cli/userWizard.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb, seedFixture } from '../testing/helpers'; +import { shopsForUser } from '../services/shopScope'; +import { getUserById } from '../services/users'; +import { Cancelled, createWizard, listLines, type Io } from './userWizard'; + +function scripted(answers: string[]) { + const shown: string[] = []; + const queue = [...answers]; + const io: Io = { + async ask(question) { + shown.push(question); + const next = queue.shift(); + if (next === undefined) throw new Cancelled(); + return next.trim(); + }, + say(line = '') { + shown.push(line); + }, + }; + return { io, text: () => shown.join('\n'), leftover: () => queue.length }; +} + +const rows = (db: ReturnType, sql: string) => db.prepare(sql).all(); + +describe('creating an admin', () => { + it('asks for the username and phone, then creates the admin', async () => { + const db = makeDb(); + const s = scripted(['1', 'owner', '98470 49032', 'y']); + expect(await createWizard(db, s.io)).toBe(0); + expect(rows(db, "SELECT username, phone, is_admin, active FROM users")).toEqual([ + { username: 'owner', phone: '919847049032', is_admin: 1, active: 1 }, + ]); + expect(s.text()).toContain('Created admin "owner". Admins now: 1.'); + }); + + it('counts the admins that exist', async () => { + const db = makeDb(); + await createWizard(db, scripted(['1', 'owner', '919847049032', 'y']).io); + const s = scripted(['admin', 'partner', '919847011111', 'yes']); + await createWizard(db, s.io); + expect(s.text()).toContain('Admins now: 2.'); + }); + + it('asks again after a bad username or phone number', async () => { + const db = makeDb(); + const s = scripted(['1', 'x', 'owner', '12', 'abc', '919847049032', 'y']); + expect(await createWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('Use letters, digits'); + expect(s.text()).toContain('does not look like a phone number'); + expect(rows(db, 'SELECT username FROM users')).toEqual([{ username: 'owner' }]); + }); + + it('creates nothing when you answer no, or just press Enter', async () => { + for (const answer of ['n', '']) { + const db = makeDb(); + const s = scripted(['1', 'owner', '919847049032', answer]); + expect(await createWizard(db, s.io)).toBe(0); + expect(rows(db, 'SELECT * FROM users')).toEqual([]); + expect(s.text()).toContain('Nothing was created'); + } + }); + + it('refuses a username or phone that already exists', async () => { + const db = makeDb(); + await createWizard(db, scripted(['1', 'owner', '919847049032', 'y']).io); + + const dupName = scripted(['1', 'owner', '919000000009', 'y']); + expect(await createWizard(db, dupName.io)).toBe(1); + expect(dupName.text()).toContain('already used'); + + const dupPhone = scripted(['1', 'someone', '919847049032', 'y']); + expect(await createWizard(db, dupPhone.io)).toBe(1); + expect(rows(db, 'SELECT COUNT(*) AS n FROM users')).toEqual([{ n: 1 }]); + }); +}); + +describe('creating a user', () => { + it('picks shops by number', async () => { + const db = makeDb(); + const f = seedFixture(db); + // fixture shops sort as Shop A, Shop B, Shop C + const s = scripted(['2', 'new-user', '9847012345', '2,3', 'y']); + expect(await createWizard(db, s.io)).toBe(0); + const row = db.prepare("SELECT id, is_admin, phone FROM users WHERE username = 'new-user'").get() as { + id: number; + is_admin: number; + phone: string; + }; + expect(row).toMatchObject({ is_admin: 0, phone: '919847012345' }); + expect(shopsForUser(db, getUserById(db, row.id)!).map((x) => x.id)).toEqual([f.shops.b, f.shops.c]); + expect(s.text()).toContain('shops: Shop B, Shop C'); + }); + + it('allows no phone and no shops', async () => { + const db = makeDb(); + seedFixture(db); + expect(await createWizard(db, scripted(['2', 'plain', '', '', 'y']).io)).toBe(0); + expect(db.prepare("SELECT phone, is_admin FROM users WHERE username = 'plain'").get()).toEqual({ + phone: null, + is_admin: 0, + }); + }); + + it('asks again for shop numbers that do not exist', async () => { + const db = makeDb(); + seedFixture(db); + const s = scripted(['2', 'u-one', '', '9', 'x', '1', 'y']); + expect(await createWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('numbers from 1 to 3'); + expect(s.text()).toContain('shops: Shop A'); + }); + + it('skips the shop question when there are no shops yet', async () => { + const db = makeDb(); + const s = scripted(['2', 'early', '', 'y']); + expect(await createWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('There are no shops yet'); + expect(rows(db, "SELECT username FROM users")).toEqual([{ username: 'early' }]); + }); + + it('can never make an admin through the user path', async () => { + const db = makeDb(); + await createWizard(db, scripted(['2', 'normal', '', 'y']).io); + expect(rows(db, 'SELECT is_admin FROM users')).toEqual([{ is_admin: 0 }]); + }); +}); + +describe('quitting', () => { + it('q at any question stops with nothing created', async () => { + const db = makeDb(); + const s = scripted(['1', 'owner', 'q']); + expect(await createWizard(db, s.io)).toBe(0); + expect(rows(db, 'SELECT * FROM users')).toEqual([]); + }); + + it('stops when the input ends', async () => { + const db = makeDb(); + expect(await createWizard(db, scripted(['1', 'owner']).io)).toBe(0); + expect(rows(db, 'SELECT * FROM users')).toEqual([]); + }); + + it('stops after too many wrong answers', async () => { + const db = makeDb(); + const s = scripted(['9', '9', '9', '9', '9', '1']); + expect(await createWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('too many invalid answers'); + expect(rows(db, 'SELECT * FROM users')).toEqual([]); + }); +}); + +describe('listing', () => { + it('says so when there are no accounts', () => { + expect(listLines(makeDb())).toEqual(['No accounts yet. Run: npm run user create']); + }); + + it('shows role, status, phone and shops, never a password', () => { + const db = makeDb(); + seedFixture(db); + const text = listLines(db).join('\n'); + expect(text).toMatch(/admin\s+active\s+admin\s+919000000001\s+all shops/); + expect(text).toMatch(/user\s+active\s+shop-a\s+919000000002\s+Shop A/); + expect(text).toMatch(/user\s+disabled\s+gone/); + expect(text).not.toContain('password'); + }); +}); diff --git a/src/cli/userWizard.ts b/src/cli/userWizard.ts new file mode 100644 index 0000000..da61979 --- /dev/null +++ b/src/cli/userWizard.ts @@ -0,0 +1,177 @@ +import type { Db } from '../db'; +import { isValidUsername, RuleError, USERNAME_HELP } from '../services/accountRules'; +import { normalizePhone } from '../services/phone'; +import { countAdmins, createAdminAccount, createUserAccount } from '../services/provisioning'; +import { listUsers } from '../services/users'; + +export class Cancelled extends Error { + constructor(message = 'cancelled') { + super(message); + this.name = 'Cancelled'; + } +} + +export interface Io { + ask(question: string): Promise; + say(line?: string): void; +} + +type Parsed = { ok: true; value: T } | { ok: false; error: string }; + +const MAX_TRIES = 5; + +async function askUntil(io: Io, question: string, parse: (answer: string) => Parsed): Promise { + for (let tries = 0; tries < MAX_TRIES; tries++) { + const answer = await io.ask(question); + if (answer.toLowerCase() === 'q') throw new Cancelled(); + const result = parse(answer); + if (result.ok) return result.value; + io.say(` ${result.error}`); + } + throw new Cancelled('too many invalid answers'); +} + +const parseKind = (a: string): Parsed<'admin' | 'user'> => { + const v = a.toLowerCase(); + if (v === '1' || v === 'admin') return { ok: true, value: 'admin' }; + if (v === '2' || v === 'user') return { ok: true, value: 'user' }; + return { ok: false, error: 'Please type 1 for Admin or 2 for User (or q to quit).' }; +}; + +const parseUsername = (a: string): Parsed => + isValidUsername(a) ? { ok: true, value: a.trim() } : { ok: false, error: `Use ${USERNAME_HELP}.` }; + +const phoneError = 'That does not look like a phone number. Include the country code, for example 919876543210.'; + +const parseRequiredPhone = + (cc: string) => + (a: string): Parsed => { + const phone = normalizePhone(a, cc); + return phone ? { ok: true, value: phone } : { ok: false, error: phoneError }; + }; + +const parseOptionalPhone = + (cc: string) => + (a: string): Parsed => { + if (a === '') return { ok: true, value: null }; + const phone = normalizePhone(a, cc); + return phone ? { ok: true, value: phone } : { ok: false, error: phoneError }; + }; + +const parseShopNumbers = + (count: number) => + (a: string): Parsed => { + if (a === '') return { ok: true, value: [] }; + const picks = a.split(/[,\s]+/).filter(Boolean); + const numbers = picks.map((p) => (/^\d+$/.test(p) ? Number(p) : NaN)); + if (numbers.some((n) => !Number.isInteger(n) || n < 1 || n > count)) { + return { ok: false, error: `Type numbers from 1 to ${count} separated by commas, for example 1,3. Press Enter for none.` }; + } + return { ok: true, value: [...new Set(numbers)] }; + }; + +async function confirm(io: Io, question: string): Promise { + const a = (await io.ask(question)).toLowerCase(); + return a === 'y' || a === 'yes'; +} + +interface ShopChoice { + id: number; + name: string; + code: string; +} + +function activeShops(db: Db): ShopChoice[] { + return db.prepare('SELECT id, name, code FROM shops WHERE active = 1 ORDER BY name, id').all() as ShopChoice[]; +} + +export async function createWizard(db: Db, io: Io, defaultCountryCode = '91'): Promise { + try { + io.say('Create an account'); + io.say(' 1) Admin - sees everything and manages users. Cannot be changed from the website.'); + io.say(' 2) User - sees and adds data to the shops you pick.'); + io.say(' (type q at any question to quit)'); + io.say(); + + const kind = await askUntil(io, 'What do you want to create? [1/2]: ', parseKind); + + if (kind === 'admin') { + const username = await askUntil(io, 'Admin username: ', parseUsername); + const phone = await askUntil(io, 'Admin phone number (with country code): ', parseRequiredPhone(defaultCountryCode)); + io.say(); + io.say(`About to create ADMIN "${username}" with phone ${phone}.`); + if (!(await confirm(io, 'Create it? [y/N]: '))) { + io.say('Cancelled. Nothing was created.'); + return 0; + } + createAdminAccount(db, { username, phone }, defaultCountryCode); + io.say(`Created admin "${username}". Admins now: ${countAdmins(db)}.`); + io.say('Login is not built yet, so no password was set.'); + return 0; + } + + const username = await askUntil(io, 'Username: ', parseUsername); + const phone = await askUntil(io, 'Phone number with country code (Enter to skip): ', parseOptionalPhone(defaultCountryCode)); + + const shops = activeShops(db); + let shopIds: number[] = []; + if (shops.length === 0) { + io.say('There are no shops yet, so no shops can be assigned. Add shops first, then assign them to this user.'); + } else { + io.say('Shops this user can see and add data to:'); + shops.forEach((s, i) => io.say(` ${i + 1}) ${s.name} (${s.code})`)); + const picks = await askUntil(io, 'Type the numbers separated by commas (Enter for none): ', parseShopNumbers(shops.length)); + shopIds = picks.map((n) => shops[n - 1]!.id); + } + + const shopNames = shopIds.map((id) => shops.find((s) => s.id === id)!.name); + io.say(); + io.say(`About to create USER "${username}".`); + io.say(` phone: ${phone ?? '(none)'}`); + io.say(` shops: ${shopNames.length ? shopNames.join(', ') : '(none)'}`); + if (!(await confirm(io, 'Create it? [y/N]: '))) { + io.say('Cancelled. Nothing was created.'); + return 0; + } + createUserAccount(db, { username, phone, shopIds }, defaultCountryCode); + io.say(`Created user "${username}".`); + io.say('Login is not built yet, so no password was set.'); + return 0; + } catch (e) { + if (e instanceof Cancelled) { + io.say(e.message === 'cancelled' ? 'Cancelled. Nothing was created.' : `Stopped: ${e.message}. Nothing was created.`); + return 0; + } + if (e instanceof RuleError) { + io.say(`Could not create it: ${e.message}.`); + return 1; + } + throw e; + } +} + +export function listLines(db: Db): string[] { + const users = listUsers(db); + if (users.length === 0) return ['No accounts yet. Run: npm run user create']; + + const shopsOf = db.prepare( + `SELECT s.name FROM user_shops us JOIN shops s ON s.id = us.shop_id WHERE us.user_id = ? ORDER BY s.name`, + ); + + const lines = ['ROLE STATUS USERNAME PHONE SHOPS']; + for (const u of users) { + const shops = u.isAdmin + ? 'all shops' + : (shopsOf.all(u.id) as Array<{ name: string }>).map((r) => r.name).join(', ') || '(none)'; + lines.push( + [ + (u.isAdmin ? 'admin' : 'user').padEnd(6), + (u.active ? 'active' : 'disabled').padEnd(9), + u.username.padEnd(20), + (u.phone ?? '-').padEnd(15), + shops, + ].join(' '), + ); + } + return lines; +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..7c05b71 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { loadConfig } from './config'; + +describe('loadConfig', () => { + it('applies defaults', () => { + const c = loadConfig({ SRS_SECRET: 'a'.repeat(20) }); + expect(c.port).toBe(8080); + expect(c.host).toBe('127.0.0.1'); + expect(c.bridgeUrl).toBe('http://127.0.0.1:3000'); + expect(c.defaultCountryCode).toBe('91'); + }); + + it('refuses a missing or short secret', () => { + expect(() => loadConfig({})).toThrow(/SRS_SECRET/); + expect(() => loadConfig({ SRS_SECRET: 'short' })).toThrow(/at least 16/); + }); + + it('reads overrides', () => { + const c = loadConfig({ SRS_SECRET: 'a'.repeat(20), PORT: '9000', DB_PATH: '/tmp/x.db' }); + expect(c.port).toBe(9000); + expect(c.dbPath).toBe('/tmp/x.db'); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b3f7ffc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,45 @@ +import { existsSync } from 'node:fs'; +import { z } from 'zod'; + +const schema = z.object({ + PORT: z.coerce.number().int().min(1).max(65535).default(8080), + HOST: z.string().min(1).default('127.0.0.1'), + DB_PATH: z.string().min(1).default('./data/srs.db'), + SRS_SECRET: z.string().min(16, 'SRS_SECRET must be at least 16 characters'), + JWT_SECRET: z.string().min(16).optional(), + BRIDGE_URL: z.string().url().default('http://127.0.0.1:3000'), + DEFAULT_COUNTRY_CODE: z.string().regex(/^\d{1,3}$/, 'digits only').default('91'), +}); + +export interface Config { + port: number; + host: string; + dbPath: string; + srsSecret: string; + jwtSecret: string | undefined; + bridgeUrl: string; + defaultCountryCode: string; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + if (env === process.env && existsSync('.env')) { + process.loadEnvFile('.env'); + } + const parsed = schema.safeParse(env); + if (!parsed.success) { + const problems = parsed.error.issues + .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`) + .join('\n'); + throw new Error(`Invalid configuration:\n${problems}`); + } + const e = parsed.data; + return { + port: e.PORT, + host: e.HOST, + dbPath: e.DB_PATH, + srsSecret: e.SRS_SECRET, + jwtSecret: e.JWT_SECRET, + bridgeUrl: e.BRIDGE_URL, + defaultCountryCode: e.DEFAULT_COUNTRY_CODE, + }; +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..32d8538 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,16 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export type Db = Database.Database; +export function openDb(path: string): Db { + if (path !== ':memory:') { + mkdirSync(dirname(path), { recursive: true }); + } + const db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.pragma('busy_timeout = 5000'); + db.pragma('synchronous = NORMAL'); + return db; +} diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts new file mode 100644 index 0000000..b95d39c --- /dev/null +++ b/src/db/migrate.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { openDb } from './index'; +import { runMigrations } from './migrate'; +import { makeDb, seedFixture } from '../testing/helpers'; + +const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE|FOREIGN KEY|constraint/i); + +describe('migrations', () => { + it('apply once and are then a no-op', () => { + const db = openDb(':memory:'); + expect(runMigrations(db)).toEqual(['001_init.sql']); + expect(runMigrations(db)).toEqual([]); + }); +}); + +describe('schema rules', () => { + const orderFor = (db: ReturnType) => { + const f = seedFixture(db); + const id = Number( + db + .prepare( + `INSERT INTO orders (shop_id, draw_date, source, created_by_user_id, raw_text) + VALUES (?, '2026-11-03', 'whatsapp_text', ?, '0888')`, + ) + .run(f.shops.a, f.admin.id).lastInsertRowid, + ); + return { f, orderId: id }; + }; + + it('accepts a normal order line, including leading zeros', () => { + const db = makeDb(); + const { orderId } = orderFor(db); + db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)').run(orderId, '0057', 2); + const row = db.prepare('SELECT ending, qty FROM order_lines').get() as { ending: string; qty: number }; + expect(row).toEqual({ ending: '0057', qty: 2 }); + }); + + it('rejects an ending that is not exactly 4 digits', () => { + const db = makeDb(); + const { orderId } = orderFor(db); + const add = (ending: string) => () => + db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, 1)').run(orderId, ending); + throwsConstraint(add('888')); + throwsConstraint(add('08888')); + throwsConstraint(add('08a8')); + }); + + it('rejects a quantity of zero or less', () => { + const db = makeDb(); + const { orderId } = orderFor(db); + const add = (qty: number) => () => + db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)').run(orderId, '0888', qty); + throwsConstraint(add(0)); + throwsConstraint(add(-1)); + }); + + it('requires a well-formed draw date on every order', () => { + const db = makeDb(); + const f = seedFixture(db); + const add = (date: string | null) => () => + db + .prepare("INSERT INTO orders (shop_id, draw_date, source, created_by_user_id, raw_text) VALUES (?, ?, 'web', ?, '0888')") + .run(f.shops.a, date, f.admin.id); + expect(add('2026-11-03')).not.toThrow(); + throwsConstraint(add(null)); + throwsConstraint(add('3/11/2026')); + }); + + it('only allows prize tiers 4 to 9', () => { + const db = makeDb(); + const draw = Number( + db.prepare("INSERT INTO draws (draw_date, scheme, draw_code) VALUES ('2026-11-03', 'TEST', 'TS-1')").run().lastInsertRowid, + ); + const add = (tier: number) => () => + db.prepare('INSERT INTO prizes (draw_id, tier, amount, ending) VALUES (?, ?, 100, ?)').run(draw, tier, '1234'); + expect(add(4)).not.toThrow(); + throwsConstraint(add(3)); + throwsConstraint(add(10)); + }); + + it('keeps phone numbers unique but allows several users without one', () => { + const db = makeDb(); + const f = seedFixture(db); + const add = (username: string, phone: string | null) => () => + db + .prepare('INSERT INTO users (username, password_hash, phone) VALUES (?, ?, ?)') + .run(username, 'x', phone); + throwsConstraint(add('dup', f.admin.phone)); + expect(add('nophone1', null)).not.toThrow(); + expect(add('nophone2', null)).not.toThrow(); + }); + + it('refuses a phone number that is not digits of a sensible length', () => { + const db = makeDb(); + const add = (phone: string) => () => + db.prepare('INSERT INTO users (username, password_hash, phone) VALUES (?, ?, ?)').run('u' + phone, 'x', phone); + throwsConstraint(add('+919847049032')); + throwsConstraint(add('98470 49032')); + throwsConstraint(add('1234567')); // too short + expect(add('919847049032')).not.toThrow(); + }); + + it('keeps usernames unique', () => { + const db = makeDb(); + seedFixture(db); + const add = () => db.prepare("INSERT INTO users (username, password_hash) VALUES ('admin', 'x')").run(); + throwsConstraint(add); + }); + + it('refuses a duplicate WhatsApp message id', () => { + const db = makeDb(); + const add = () => + db.prepare("INSERT INTO ingest_log (wa_message_id, kind, outcome) VALUES ('M1', 'text', 'accepted')").run(); + expect(add).not.toThrow(); + throwsConstraint(add); + }); + + it('deletes an order\'s lines together with the order', () => { + const db = makeDb(); + const { orderId } = orderFor(db); + db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, 1)').run(orderId, '0888'); + db.prepare('DELETE FROM orders WHERE id = ?').run(orderId); + expect(db.prepare('SELECT COUNT(*) AS n FROM order_lines').get()).toEqual({ n: 0 }); + }); +}); diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..fee389d --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Db } from './index'; +import { openDb } from './index'; + +const MIGRATIONS_DIR = join(__dirname, 'migrations'); + +export function runMigrations(db: Db, dir: string = MIGRATIONS_DIR): string[] { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ) + `); + + const done = new Set( + db + .prepare('SELECT version FROM schema_migrations') + .all() + .map((r) => (r as { version: string }).version), + ); + + const files = readdirSync(dir) + .filter((f) => f.endsWith('.sql')) + .sort(); + + const applied: string[] = []; + for (const file of files) { + if (done.has(file)) continue; + const sql = readFileSync(join(dir, file), 'utf8'); + const apply = db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(file); + }); + apply(); + applied.push(file); + } + return applied; +} + +if (require.main === module) { + const { loadConfig } = require('../config') as typeof import('../config'); + const config = loadConfig(); + const db = openDb(config.dbPath); + const applied = runMigrations(db); + console.log(applied.length ? `applied: ${applied.join(', ')}` : 'database is up to date'); + db.close(); +} diff --git a/src/db/migrations/001_init.sql b/src/db/migrations/001_init.sql new file mode 100644 index 0000000..7b8ebbe --- /dev/null +++ b/src/db/migrations/001_init.sql @@ -0,0 +1,90 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + phone TEXT UNIQUE + CHECK (phone IS NULL OR (length(phone) BETWEEN 8 AND 15 AND phone NOT GLOB '*[^0-9]*')), + is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)), + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE shops ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + code TEXT NOT NULL UNIQUE, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE user_shops ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shop_id INTEGER NOT NULL REFERENCES shops(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, shop_id) +); + +CREATE TABLE draws ( + id INTEGER PRIMARY KEY, + draw_date TEXT NOT NULL, + scheme TEXT NOT NULL, + draw_code TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); +CREATE INDEX idx_draws_date ON draws (draw_date); + +CREATE TABLE prizes ( + id INTEGER PRIMARY KEY, + draw_id INTEGER NOT NULL REFERENCES draws(id) ON DELETE CASCADE, + tier INTEGER NOT NULL CHECK (tier BETWEEN 4 AND 9), + amount INTEGER NOT NULL CHECK (amount >= 0), + ending TEXT NOT NULL CHECK (length(ending) = 4 AND ending NOT GLOB '*[^0-9]*') +); +CREATE INDEX idx_prizes_draw_ending ON prizes (draw_id, ending); + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + shop_id INTEGER NOT NULL REFERENCES shops(id), + draw_date TEXT NOT NULL CHECK (draw_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'cancelled')), + source TEXT NOT NULL CHECK (source IN ('whatsapp_text', 'whatsapp_photo', 'web')), + created_by_user_id INTEGER NOT NULL REFERENCES users(id), + raw_text TEXT NOT NULL, + photo_path TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); +CREATE INDEX idx_orders_shop_draw_date ON orders (shop_id, draw_date); + +CREATE TABLE order_lines ( + id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + ending TEXT NOT NULL CHECK (length(ending) = 4 AND ending NOT GLOB '*[^0-9]*'), + qty INTEGER NOT NULL CHECK (qty > 0) +); +CREATE INDEX idx_order_lines_order ON order_lines (order_id); +CREATE INDEX idx_order_lines_ending ON order_lines (ending); + +CREATE TABLE conversations ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + chat_id TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'IDLE' + CHECK (state IN ('IDLE', 'COLLECTING', 'ASKING', 'AWAITING_SHOP', 'CONFIRM')), + buffer_text TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'whatsapp_text' CHECK (source IN ('whatsapp_text', 'whatsapp_photo')), + parsed_json TEXT, + shop_options_json TEXT, + selected_shop_id INTEGER REFERENCES shops(id), + last_activity_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE ingest_log ( + id INTEGER PRIMARY KEY, + wa_message_id TEXT NOT NULL UNIQUE, + sender_phone TEXT, + user_id INTEGER REFERENCES users(id), + kind TEXT NOT NULL CHECK (kind IN ('text', 'image')), + outcome TEXT NOT NULL, + received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); +CREATE INDEX idx_ingest_received ON ingest_log (received_at); diff --git a/src/parser/parseOrder.test.ts b/src/parser/parseOrder.test.ts new file mode 100644 index 0000000..d286f02 --- /dev/null +++ b/src/parser/parseOrder.test.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseOrder } from './parseOrder'; + +const samplePath = join(__dirname, '../../test/fixtures/sample-order.txt'); + +describe('parseOrder: notation', () => { + it.todo('a bare 4-digit ending is 1 ticket'); + it.todo('0057x2 and 9155*40 set the quantity'); + it.todo('0003-2 (dash plus 1-3 digits) is a quantity'); + it.todo('5104-5106 (dash plus 4 digits) is a range'); + it.todo('0888(1)0003(1)9077(1) with no separators reads as three endings'); + it.todo('strips the WhatsApp copy prefix [dd/mm, hh:mm] Name:'); + it.todo('"Totel 54 number" is checked by walking back until the ticket sum or line count matches'); + it.todo('a bare number on its own line (250) is a total'); + it.todo('never guesses: unreadable text goes to `unreadable`'); +}); + +describe.skipIf(!existsSync(samplePath))('parseOrder: the real 90-line sample', () => { + const result = () => parseOrder(readFileSync(samplePath, 'utf8')); + + it('reads 338 tickets and leaves nothing unreadable', () => { + const r = result(); + expect(r.totalTickets).toBe(338); + expect(r.unreadable).toEqual([]); + }); + + it('matches the stated totals 54 and 250', () => { + const r = result(); + expect(r.totals.find((t) => t.stated === 54)?.matched).toBe(true); + expect(r.totals.find((t) => t.stated === 250)?.matched).toBe(true); + }); + + it('finds the repeated endings', () => { + expect([...result().repeated].sort()).toEqual(['0003', '0057', '0888', '6678']); + }); + + it('reports 27 endings not covered by any total', () => { + expect(result().uncovered).toHaveLength(27); + }); +}); diff --git a/src/parser/parseOrder.ts b/src/parser/parseOrder.ts new file mode 100644 index 0000000..31d8c9b --- /dev/null +++ b/src/parser/parseOrder.ts @@ -0,0 +1,5 @@ +import type { ParseResult } from './types'; + +export function parseOrder(_text: string): ParseResult { + throw new Error('parseOrder is not implemented yet: port the tested parser into src/parser/parseOrder.ts'); +} diff --git a/src/parser/types.ts b/src/parser/types.ts new file mode 100644 index 0000000..a4267d4 --- /dev/null +++ b/src/parser/types.ts @@ -0,0 +1,28 @@ + +export interface OrderLine { + /** The last 4 digits, as a string so leading zeros survive ("0057"). */ + ending: string; + /** How many tickets. Always at least 1. */ + qty: number; +} + +/** A total the sender wrote, such as "Totel 54 number" or a bare "250" on its own line. */ +export interface TotalCheck { + /** The number the sender wrote. */ + stated: number; + /** True if the tickets before that total add up to it (by ticket count or by line count). */ + matched: boolean; +} + +export interface ParseResult { + lines: OrderLine[]; + /** Sum of qty over all lines. */ + totalTickets: number; + totals: TotalCheck[]; + /** Endings that no stated total covers. The bot lists them and asks. */ + uncovered: string[]; + /** Endings that appear more than once. The bot asks whether to add them up. */ + repeated: string[]; + /** Text the parser could not read. It is never guessed: the bot asks. */ + unreadable: string[]; +} diff --git a/src/results/job.test.ts b/src/results/job.test.ts new file mode 100644 index 0000000..d565e4d --- /dev/null +++ b/src/results/job.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { makeDb } from '../testing/helpers'; +import { extractPdfText } from './pdfText'; +import { processPdf, runResultsJob } from './job'; +import { parseResult, validateDraw } from './parseResult'; +import { parsePortalListing, selectRecent } from './portal'; +import { storeDraw } from './store'; + +const FX = join(__dirname, '../../test/fixtures/results'); +const layout = (n: string) => readFileSync(join(FX, n), 'utf8'); +const sk73Pdf = readFileSync(join(FX, 'sk73_2026-10-16.pdf')); + +const row = (name: string, code: string, no: number, d: string, m: string, y: string, item: string) => + `${name}-${d}/${m}/${y} (${code}-${no})${d}-${m}-${y}Download`; +const LISTING = + '' + + row('SUVARNA KERALAM', 'SK', 73, '16', '10', '2026', '75410') + + row('KARUNYA PLUS', 'KN', 645, '15', '10', '2026', '75409') + + row('DHANALEKSHMI', 'DL', 73, '14', '10', '2026', '75408') + + row('STHREE-SAKTHI', 'SS', 541, '13', '10', '2026', '75407') + + '
'; + +describe('portal listing', () => { + it('reads every row, including unclosed ones, oldest first', () => { + const es = parsePortalListing(LISTING); + expect(es.map((e) => e.code)).toEqual(['SS', 'DL', 'KN', 'SK']); + const top = es.at(-1)!; + expect([top.drawNo, top.date, top.name]).toEqual([73, '2026-10-16', 'SUVARNA KERALAM']); + expect(top.url.endsWith('#75410')).toBe(true); + }); + + it('selects the last N days including today', () => { + const es = selectRecent(parsePortalListing(LISTING), '2026-10-16', 3); + expect(es.map((e) => `${e.code}-${e.drawNo}`)).toEqual(['DL-73', 'KN-645', 'SK-73']); + }); +}); + +describe('storeDraw', () => { + const draw = () => parseResult(layout('sk73_2026-10-16_layout.txt')); + + it('stores tiers 4-9 only', () => { + const db = makeDb(); + const d = draw(); + expect(storeDraw(db, d, validateDraw(d))).toBe('stored'); + expect(db.prepare("SELECT scheme, draw_date FROM draws WHERE draw_code = 'SK-73'").get()).toEqual({ + scheme: 'Suvarna Keralam', + draw_date: '2026-10-16', + }); + const n = db.prepare('SELECT tier, COUNT(*) AS n FROM prizes GROUP BY tier ORDER BY tier').all(); + expect(n).toEqual([4, 5, 6, 7, 8, 9].map((tier, i) => ({ tier, n: [19, 6, 25, 76, 92, 144][i] }))); + }); + + it('is idempotent', () => { + const db = makeDb(); + const d = draw(); + storeDraw(db, d, []); + storeDraw(db, d, []); + expect((db.prepare('SELECT COUNT(*) AS n FROM prizes').get() as { n: number }).n).toBe(19 + 6 + 25 + 76 + 92 + 144); + expect((db.prepare('SELECT COUNT(*) AS n FROM draws').get() as { n: number }).n).toBe(1); + }); + + it('a held draw writes nothing, and never disturbs a stored one', () => { + const db = makeDb(); + const d = draw(); + const err = [{ level: 'ERROR' as const, message: 'x' }]; + expect(storeDraw(db, d, err)).toBe('held'); + expect((db.prepare('SELECT COUNT(*) AS n FROM draws').get() as { n: number }).n).toBe(0); + expect((db.prepare('SELECT COUNT(*) AS n FROM prizes').get() as { n: number }).n).toBe(0); + storeDraw(db, d, []); + storeDraw(db, d, err); + expect((db.prepare('SELECT COUNT(*) AS n FROM draws').get() as { n: number }).n).toBe(1); + expect((db.prepare('SELECT COUNT(*) AS n FROM prizes').get() as { n: number }).n).toBe(19 + 6 + 25 + 76 + 92 + 144); + }); +}); + +describe('processPdf (real pdftotext on the real SK-73 PDF)', () => { + it('parses and validates clean', async () => { + const { draw, issues } = await processPdf(sk73Pdf); + expect(draw?.schemeCode).toBe('SK'); + expect(issues).toEqual([]); + }); + + it('catches a PDF that is not the draw the listing claims', async () => { + const entry = { name: 'x', code: 'KN', drawNo: 645, date: '2026-10-15', url: 'u#1' }; + const { issues } = await processPdf(sk73Pdf, entry); + expect(issues.some((i) => i.message.includes('listing says'))).toBe(true); + }); + + it('reports an unreadable PDF instead of throwing', async () => { + const { draw, issues } = await processPdf(Buffer.from('not a pdf'), undefined, extractPdfText); + expect(draw).toBeNull(); + expect(issues[0]?.level).toBe('ERROR'); + }); +}); + +describe('runResultsJob', () => { + const fetchImpl = (async () => new Response(LISTING)) as typeof fetch; + const base = { today: '2026-10-16', days: 3, fetchImpl }; + + it('stores a good draw, holds a mismatched one, reports a failed fetch, skips ready ones on rerun', async () => { + const db = makeDb(); + const getPdf = async (e: { code: string }) => { + if (e.code === 'SK') return sk73Pdf; + if (e.code === 'KN') return sk73Pdf; + return null; + }; + const first = await runResultsJob({ db, ...base, getPdf }); + const status = Object.fromEntries(first.map((r) => [r.code, r.status])); + expect(status).toEqual({ 'DL-73': 'fetch_failed', 'KN-645': 'held', 'SK-73': 'ready' }); + + const again = await runResultsJob({ db, ...base, getPdf }); + expect(Object.fromEntries(again.map((r) => [r.code, r.status]))['SK-73']).toBe('already_ready'); + }); + + it('fails loudly when the listing has no draws', async () => { + const db = makeDb(); + const empty = (async () => new Response('')) as typeof fetch; + await expect(runResultsJob({ db, today: '2026-10-16', fetchImpl: empty })).rejects.toThrow(/no draws/); + }); +}); diff --git a/src/results/job.ts b/src/results/job.ts new file mode 100644 index 0000000..99e1269 --- /dev/null +++ b/src/results/job.ts @@ -0,0 +1,97 @@ +import type { Db } from '../db'; +import { extractPdfText } from './pdfText'; +import { hasErrors, parseResult, validateDraw, type Draw, type Issue } from './parseResult'; +import { + downloadPdf, + getText, + parsePortalListing, + PORTAL_URL, + selectRecent, + todayIst, + type Entry, + type FetchLike, +} from './portal'; +import { drawCode, isDrawStored, storeDraw } from './store'; + +export interface ProcessedPdf { + draw: Draw | null; + issues: Issue[]; +} + +export async function processPdf( + pdf: Buffer, + entry?: Entry, + extract: (pdf: Buffer) => Promise = extractPdfText, +): Promise { + let draw: Draw; + try { + draw = parseResult(await extract(pdf)); + } catch (err) { + return { draw: null, issues: [{ level: 'ERROR', message: `cannot parse (${(err as Error).message})` }] }; + } + const issues = validateDraw(draw); + if (entry) { + const want = [entry.code, entry.drawNo, entry.date].join(' '); + const got = [draw.schemeCode, draw.drawNo, draw.drawDate].join(' '); + if (want !== got) issues.push({ level: 'ERROR', message: `listing says ${want} but the PDF is ${got}` }); + } + return { draw, issues }; +} + +export type DrawStatus = 'ready' | 'held' | 'already_ready' | 'fetch_failed'; + +export interface DrawReport { + code: string; + date: string; + status: DrawStatus; + issues: Issue[]; +} + +export interface JobOptions { + db: Db; + days?: number; + today?: string; + force?: boolean; + listingUrl?: string; + fetchImpl?: FetchLike; + getPdf?: (entry: Entry) => Promise; + extract?: (pdf: Buffer) => Promise; +} + +export async function runResultsJob(opts: JobOptions): Promise { + const { db, days = 7, force = false, fetchImpl = fetch } = opts; + const today = opts.today ?? todayIst(); + const listingUrl = opts.listingUrl ?? PORTAL_URL; + const html = await getText(listingUrl, fetchImpl); + const entries = parsePortalListing(html, listingUrl); + if (!entries.length) { + throw new Error('the listing page returned no draws: its layout may have changed, or access is blocked'); + } + + const reports: DrawReport[] = []; + for (const entry of selectRecent(entries, today, days)) { + const code = drawCode({ schemeCode: entry.code, drawNo: entry.drawNo }); + const base = { code, date: entry.date }; + if (!force && isDrawStored(db, code)) { + reports.push({ ...base, status: 'already_ready', issues: [] }); + continue; + } + let pdf: Buffer | null; + try { + pdf = opts.getPdf ? await opts.getPdf(entry) : await downloadPdf(entry, fetchImpl); + if (!pdf) throw new Error('no download link for this draw'); + } catch (err) { + reports.push({ ...base, status: 'fetch_failed', issues: [{ level: 'ERROR', message: (err as Error).message }] }); + continue; + } + const { draw, issues } = await processPdf(pdf, entry, opts.extract); + if (draw && !hasErrors(issues)) { + storeDraw(db, draw, issues); + reports.push({ ...base, status: 'ready', issues }); + } else { + reports.push({ ...base, status: 'held', issues }); + } + if (!opts.getPdf) await new Promise((r) => setTimeout(r, 1000)); + } + return reports; +} diff --git a/src/results/parseResult.test.ts b/src/results/parseResult.test.ts new file mode 100644 index 0000000..7b51992 --- /dev/null +++ b/src/results/parseResult.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { hasErrors, parseResult, validateDraw, type Draw } from './parseResult'; + +const fixture = (name: string) => readFileSync(join(__dirname, '../../test/fixtures/results', name), 'utf8'); +const real = (name: string): Draw => parseResult(fixture(name)); +const counts = (d: Draw) => Object.fromEntries(d.tiers.map((t) => [t.tier, t.tickets.length + t.endings.length])); +const by = (d: Draw) => Object.fromEntries(d.tiers.map((t) => [t.tier, t])); + +describe('real results (pdftotext -layout)', () => { + it('parses SK-73 exactly', () => { + const d = real('sk73_2026-10-16_layout.txt'); + expect([d.schemeCode, d.drawNo, d.drawDate]).toEqual(['SK', 73, '2026-10-16']); + expect(d.schemeName).toBe('Suvarna Keralam'); + expect(d.drawTime).toBe('15:00'); + expect(d.publishedAt).toMatch(/^2026-10-16 16:\d\d:\d\d IST$/); + const t = by(d); + expect(t['1']!.tickets).toEqual(['RA404595']); + expect(t['2']!.tickets).toEqual(['RH448539']); + expect(t['3']!.tickets).toEqual(['RG847845']); + expect(t.consolation!.tickets.map((x) => x.slice(0, 2)).sort()).toEqual('RB RC RD RE RF RG RH RJ RK RL RM'.split(' ')); + expect([4, 5, 6, 7, 8, 9].map((n) => counts(d)[String(n)])).toEqual([19, 6, 25, 76, 92, 144]); + expect(t['9']!.endings).not.toContain('2026'); + expect(t['4']!.endings[0]).toBe('0356'); + expect(t['4']!.endings.at(-1)).toBe('9139'); + expect(t['9']!.endings[0]).toBe('0006'); + expect(t['9']!.endings.at(-1)).toBe('9925'); + expect(validateDraw(d)).toEqual([]); + }); + + it('parses KN-645', () => { + const d = real('kn645_2026-10-15_layout.txt'); + expect([d.schemeCode, d.drawNo, d.drawDate]).toEqual(['KN', 645, '2026-10-15']); + const c = counts(d); + expect([4, 5, 6, 7, 8, 9].map((n) => c[String(n)])).toEqual([19, 6, 25, 76, 84, 156]); + expect(by(d)['1']!.tickets).toEqual(['PF502881']); + expect(validateDraw(d)).toEqual([]); + }); + + it('parses DL-73', () => { + const d = real('dl73_2026-10-14_layout.txt'); + expect([d.schemeCode, d.drawNo, d.drawDate]).toEqual(['DL', 73, '2026-10-14']); + const c = counts(d); + expect([4, 5, 6, 7, 8, 9].map((n) => c[String(n)])).toEqual([19, 6, 25, 76, 96, 138]); + expect(validateDraw(d)).toEqual([]); + }); + + it('parses the Thiruvonam bumper BR-111', () => { + const d = real('br111_2026-09-26_layout.txt'); + expect([d.schemeCode, d.drawNo, d.drawDate]).toEqual(['BR', 111, '2026-09-26']); + expect(by(d)['1']!.amount).toBe(300000000); + const c = counts(d); + expect([2, 3, 4, 5, 6, 7, 8, 9].map((n) => c[String(n)])).toEqual([20, 20, 10, 10, 60, 90, 138, 306]); + expect(validateDraw(d)).toEqual([]); + }); +}); + +describe('raw-order text (what pdftotext -raw gives)', () => { + it('BT-78: the 8th prize tail on page 3 is not mistaken for the 9th', () => { + const d = real('bt78_2026-11-02_raw.txt'); + expect([d.schemeCode, d.drawNo, d.drawDate, d.drawTime]).toEqual(['BT', 78, '2026-11-02', '15:00']); + expect(d.publishedAt).toBe('2026-11-02 16:36:38 IST'); + const c = counts(d); + expect([c['8'], c['9']]).toEqual([94, 144]); + expect(by(d)['8']!.endings.at(-1)).toBe('9903'); + expect(by(d)['9']!.endings[0]).toBe('0028'); + expect(validateDraw(d)).toEqual([]); + }); + + it('SM-79 validates clean', () => { + expect(validateDraw(real('sm79_2026-11-01_raw.txt'))).toEqual([]); + }); +}); + +describe('validation catches damage', () => { + it('a dropped 7th-prize ending is held', () => { + const bad = fixture('sk73_2026-10-16_layout.txt').replace('2340 ', ''); + expect(validateDraw(parseResult(bad)).some((i) => i.level === 'ERROR' && i.message.includes('tier 7'))).toBe(true); + }); + + it('a stray out-of-order ending is held', () => { + const bad = fixture('dl73_2026-10-14_layout.txt').replace('0356 ', '0356 0100 '); + expect(hasErrors(validateDraw(parseResult(bad)))).toBe(true); + }); + + it('a missing tier is held', () => { + const cut = fixture('sk73_2026-10-16_layout.txt').replace(/9th Prize.*/s, ''); + expect(validateDraw(parseResult(cut)).some((i) => i.message.includes('tier 9 missing'))).toBe(true); + }); + + it('an unreadable header fails loudly', () => { + expect(() => parseResult('garbage\n1st Prize-Rs :100/-\nRN 123456')).toThrow(); + }); + + it('an unknown scheme publishes with a warning, not an error', () => { + const d = real('sk73_2026-10-16_layout.txt'); + d.schemeCode = 'ZZ'; + const issues = validateDraw(d); + expect(hasErrors(issues)).toBe(false); + expect(issues.some((i) => i.level === 'WARN' && i.message.includes('no count profile'))).toBe(true); + }); +}); diff --git a/src/results/parseResult.ts b/src/results/parseResult.ts new file mode 100644 index 0000000..254875b --- /dev/null +++ b/src/results/parseResult.ts @@ -0,0 +1,215 @@ +export type MatchType = 'full_ticket' | 'last_n_digits' | 'same_number_other_series' | 'unknown'; + +export interface Tier { + /** "1".."9" or "consolation" */ + tier: string; + amount: number; + matchType: MatchType; + tickets: string[]; + endings: string[]; + declaredCount: number | null; +} + +export interface Draw { + schemeCode: string; + drawNo: number; + /** ISO date, YYYY-MM-DD */ + drawDate: string; + tiers: Tier[]; + /** e.g. "Suvarna Keralam", read from the PDF header */ + schemeName: string; + /** 24h IST, e.g. "15:00" */ + drawTime: string; + publishedAt: string; +} + +export type IssueLevel = 'ERROR' | 'WARN'; +export interface Issue { + level: IssueLevel; + message: string; +} + +// Extractors differ in spacing: some glue neighbours ("404595RE 404595"), so no \b here. +const TICKET = /(?\d+)\s*(?:st|nd|rd|th)|(?Cons(?:olation)?))\s+Prize\s*[-–:]*\s*Rs\.?\s*:?\s*(?[\d,]+)\s*\/?-?(?[^\n]*)/gi; +const DRAWN = /drawn\s+(\d+)\s+times/i; +const DRAW_ID = /NO\.?\s*([A-Z]{1,3})\s*-?\s*(\d+)/i; +// "SUVARNA KERALAM LOTTERY NO.SK-73rd" (bumpers print LOTTERY twice) +const SCHEME_NAME = /([A-Z][A-Z ]*?)\s+(?:LOTTERY\s+)+NO\./; +// layout text: " Modernization..."; raw text: "Modernization...Page N" then "" on the next line +const PUBLISHED = /(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}:\d{2}:\d{2})\s+Modernization/i; +const PUBLISHED_RAW = /Modernization[^\n]*\n\s*(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}:\d{2}:\d{2})/i; +const DRAW_TIME = /\d{1,2}\/\d{1,2}\/\d{4}\s*,?\s*(\d{1,2}):(\d{2})\s*([AP]M)/i; +const DRAW_DATE = /(\d{1,2})\/(\d{1,2})\/(\d{4})/; + +const pad2 = (n: number | string) => String(n).padStart(2, '0'); + +function clean(body: string): string { + for (const pat of NOISE) body = body.replace(pat, ' '); + return body; +} + +export function stripFurniture(text: string): string { + return text.replace(FOOTER, '').replace(TRAILER, ''); +} + +function titleCase(s: string): string { + return s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase()); +} + +export function parseResult(rawText: string): Draw { + const pub = PUBLISHED.exec(rawText) ?? PUBLISHED_RAW.exec(rawText); + let publishedAt = ''; + if (pub) { + const [, dd, mm, yy, hms] = pub as unknown as [string, string, string, string, string]; + publishedAt = `${yy}-${pad2(mm)}-${pad2(dd)} ${pad2(hms.split(':')[0]!)}:${hms.slice(-5)} IST`; + } + const text = stripFurniture(rawText); + const head = text.slice(0, 4000).replace(/\s+/g, ' '); + const m = DRAW_ID.exec(head); + const d = DRAW_DATE.exec(head); + if (!m || !d) throw new Error('could not read draw number/date from header'); + const schemeCode = m[1]!.toUpperCase(); + const drawNo = Number(m[2]); + const drawDate = `${d[3]}-${pad2(d[2]!)}-${pad2(d[1]!)}`; + + const heads = [...text.matchAll(HEADER)]; + const tiers: Tier[] = heads.map((h, i) => { + const bodyStart = h.index + h[0].length; + const end = i + 1 < heads.length ? heads[i + 1]!.index : text.length; + const g = h.groups!; + const body = clean(`${g.rest ?? ''}\n${text.slice(bodyStart, end)}`); + const tickets = [...body.matchAll(TICKET)].map((t) => t[1]! + t[2]!); + const withoutTickets = body.replace(TICKET, ' '); + const endings = [...withoutTickets.matchAll(ENDING)].map((e) => e[1]!); + const amount = Number((g.amt ?? '0').replace(/,/g, '')); + const declared = DRAWN.exec(g.rest ?? ''); + + let t: Tier; + if (g.cons) { + t = { tier: 'consolation', amount, matchType: 'same_number_other_series', tickets, endings: [], declaredCount: null }; + } else if (tickets.length && !endings.length) { + t = { tier: g.ord!, amount, matchType: 'full_ticket', tickets, endings: [], declaredCount: null }; + } else if (endings.length && !tickets.length) { + t = { tier: g.ord!, amount, matchType: 'last_n_digits', tickets: [], endings, declaredCount: null }; + } else { + t = { tier: g.ord!, amount, matchType: 'unknown', tickets, endings, declaredCount: null }; + } + if (declared) t.declaredCount = Number(declared[1]); + return t; + }); + + const nm = SCHEME_NAME.exec(head); + const tm = DRAW_TIME.exec(head); + let drawTime = ''; + if (tm) { + const h = Number(tm[1]) % 12; + drawTime = `${pad2(h + (tm[3]!.toUpperCase() === 'PM' ? 12 : 0))}:${tm[2]}`; + } + return { + schemeCode, + drawNo, + drawDate, + tiers, + schemeName: nm ? titleCase(nm[1]!) : '', + drawTime, + publishedAt, + }; +} + +export type CountProfile = Record; + +const WEEKLY: CountProfile = { + '1': 1, '2': 1, '3': 1, consolation: 11, + '4': 19, '5': 6, '6': 25, '7': 76, '8': [80, 100], '9': [130, 160], +}; +export const PROFILES: Record = { + SM: WEEKLY, + BT: WEEKLY, + SS: WEEKLY, + DL: WEEKLY, + KN: WEEKLY, + SK: WEEKLY, + KR: WEEKLY, + BR: { '1': 1, consolation: 9, '2': 20, '3': 20, '4': 10, '5': 10, '6': null, '7': null, '8': null, '9': null }, +}; + +export function validateDraw(draw: Draw, profile?: CountProfile): Issue[] { + const out: Issue[] = []; + const by = new Map(draw.tiers.map((t) => [t.tier, t])); + const prof = profile ?? PROFILES[draw.schemeCode]; + if (!prof) { + out.push({ level: 'WARN', message: `no count profile for scheme ${draw.schemeCode}: counts unverified` }); + } else { + for (const [tier, want] of Object.entries(prof)) { + if (want === null) continue; + const t = by.get(tier); + const got = t ? t.tickets.length + t.endings.length : 0; + const [lo, hi] = Array.isArray(want) ? want : [want, want]; + if (got < lo || got > hi) { + out.push({ level: 'ERROR', message: `tier ${tier}: expected ${JSON.stringify(want)} items, parsed ${got}` }); + } + } + } + for (let n = 1; n <= 9; n++) { + if (!by.has(String(n))) out.push({ level: 'ERROR', message: `tier ${n} missing` }); + } + if (!by.has('consolation')) out.push({ level: 'WARN', message: 'no consolation section' }); + + for (const t of draw.tiers) { + if (t.matchType === 'unknown') out.push({ level: 'ERROR', message: `tier ${t.tier}: mixed/empty content` }); + if (t.matchType === 'full_ticket' && !t.tickets.length) out.push({ level: 'ERROR', message: `tier ${t.tier}: no tickets` }); + if (t.matchType === 'last_n_digits' && !t.endings.length) out.push({ level: 'ERROR', message: `tier ${t.tier}: no endings` }); + if (t.declaredCount !== null) { + const got = t.endings.length + t.tickets.length; + if (got !== t.declaredCount) { + out.push({ level: 'ERROR', message: `tier ${t.tier}: declared ${t.declaredCount}, parsed ${got}` }); + } + } + if (new Set(t.endings).size !== t.endings.length) out.push({ level: 'WARN', message: `tier ${t.tier}: duplicate endings` }); + if (t.endings.some((a, i) => i + 1 < t.endings.length && Number(a) >= Number(t.endings[i + 1]))) { + out.push({ level: 'ERROR', message: `tier ${t.tier}: endings not strictly ascending` }); + } + if (new Set(t.tickets).size !== t.tickets.length) out.push({ level: 'ERROR', message: `tier ${t.tier}: duplicate tickets` }); + } + + const first = by.get('1'); + if (first && first.tickets.length !== 1) { + out.push({ level: 'ERROR', message: `1st prize should be exactly 1 ticket, got ${first.tickets.length}` }); + } + const cons = by.get('consolation'); + if (first && cons && first.tickets.length === 1) { + const winner = first.tickets[0]!; + const bad = cons.tickets.filter((x) => x.slice(2) !== winner.slice(2) || x.slice(0, 2) === winner.slice(0, 2)); + if (bad.length) { + out.push({ level: 'ERROR', message: `consolation tickets not same-number/other-series: ${bad.slice(0, 3).join(', ')}` }); + } + } + + const full = draw.tiers.filter((t) => t.matchType === 'full_ticket').flatMap((t) => t.tickets); + if (new Set(full).size !== full.length) out.push({ level: 'ERROR', message: 'same full ticket appears in two tiers' }); + const amts = draw.tiers + .filter((t) => /^\d+$/.test(t.tier)) + .sort((a, b) => Number(a.tier) - Number(b.tier)) + .map((t) => t.amount); + if (amts.some((a, i) => i + 1 < amts.length && a < amts[i + 1]!)) { + out.push({ level: 'ERROR', message: `tier amounts not descending: ${amts.join(', ')}` }); + } + return out; +} + +export const hasErrors = (issues: Issue[]): boolean => issues.some((i) => i.level === 'ERROR'); diff --git a/src/results/pdfText.ts b/src/results/pdfText.ts new file mode 100644 index 0000000..e617e9c --- /dev/null +++ b/src/results/pdfText.ts @@ -0,0 +1,26 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export async function extractPdfText(pdf: Buffer, bin = 'pdftotext', timeoutMs = 30_000): Promise { + const dir = await mkdtemp(join(tmpdir(), 'srs-pdf-')); + try { + const file = join(dir, 'result.pdf'); + await writeFile(file, pdf); + return await new Promise((resolve, reject) => { + execFile( + bin, + ['-raw', '-enc', 'UTF-8', file, '-'], + { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024, timeout: timeoutMs }, + (err, stdout) => { + if (!err) return resolve(stdout.replace(/\r\n/g, '\n')); + const missing = (err as NodeJS.ErrnoException).code === 'ENOENT'; + reject(new Error(missing ? 'pdftotext not found: install poppler-utils' : `pdftotext failed: ${err.message}`)); + }, + ); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/src/results/portal.ts b/src/results/portal.ts new file mode 100644 index 0000000..d44445b --- /dev/null +++ b/src/results/portal.ts @@ -0,0 +1,112 @@ +export const PORTAL_BASE = 'https://www.lotteryagent.kerala.gov.in'; +export const PORTAL_URL = `${PORTAL_BASE}/result/public/`; +const USER_AGENT = 'lottery-agency-app/0.1 (result checker for a registered agency)'; + +export interface Entry { + name: string; + code: string; + drawNo: number; + date: string; + url: string; +} + +const PORTAL_ROW = + /\s*([^<]*?\(\s*([A-Z]{1,3})\s*-\s*(\d+)\s*\))\s*<\/td>\s*\s*(\d{1,2})-(\d{1,2})-(\d{4})\s*<\/td>\s*(.*?)<\/td>/gs; +const ITEM_ID = /data-item-id="([^"]+)"/; + +const isoDate = (y: number, m: number, d: number): string | null => { + const dt = new Date(Date.UTC(y, m - 1, d)); + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return null; + return dt.toISOString().slice(0, 10); +}; + +export function parsePortalListing(html: string, baseUrl = PORTAL_URL): Entry[] { + const out = new Map(); + for (const m of html.matchAll(PORTAL_ROW)) { + const [, label, code, no, d, mth, y, cell] = m as unknown as string[]; + const date = isoDate(Number(y), Number(mth), Number(d)); + if (!date) continue; + const item = ITEM_ID.exec(cell!); + const name = label!.replace(/-\d{1,2}\/\d{1,2}\/\d{4}.*$/s, '').trim(); + out.set(`${code}-${Number(no)}`, { + name, + code: code!, + drawNo: Number(no), + date, + url: baseUrl + (item ? `#${item[1]}` : ''), + }); + } + return [...out.values()].sort((a, b) => a.date.localeCompare(b.date)); +} + +export function selectRecent(entries: Entry[], today: string, days: number): Entry[] { + const lo = new Date(Date.parse(`${today}T00:00:00Z`) - (days - 1) * 86_400_000).toISOString().slice(0, 10); + return entries.filter((e) => e.date >= lo && e.date <= today).sort((a, b) => a.date.localeCompare(b.date)); +} + +export function todayIst(now = new Date()): string { + return new Date(now.getTime() + 5.5 * 3_600_000).toISOString().slice(0, 10); +} + +export type FetchLike = typeof fetch; + +class Session { + private cookies = new Map(); + constructor(private readonly doFetch: FetchLike) {} + + async request(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('User-Agent', USER_AGENT); + if (this.cookies.size) headers.set('Cookie', [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ')); + const res = await this.doFetch(url, { ...init, headers, signal: AbortSignal.timeout(30_000) }); + for (const line of res.headers.getSetCookie?.() ?? []) { + const [pair] = line.split(';'); + const eq = pair!.indexOf('='); + if (eq > 0) this.cookies.set(pair!.slice(0, eq).trim(), pair!.slice(eq + 1).trim()); + } + return res; + } +} + +export async function getText(url: string, doFetch: FetchLike = fetch, retries = 3): Promise { + let last: unknown; + for (let i = 0; i < retries; i++) { + try { + const res = await doFetch(url, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(30_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.text(); + } catch (err) { + last = err; + if (i + 1 < retries) await new Promise((r) => setTimeout(r, 2 ** i * 1000)); + } + } + throw new Error(`could not fetch ${url}: ${(last as Error)?.message ?? last}`); +} + +export class NotAPdf extends Error {} + +export async function downloadPdf(entry: Entry, doFetch: FetchLike = fetch): Promise { + const itemId = entry.url.split('#')[1]; + if (!itemId) return null; + const s = new Session(doFetch); + + await (await s.request(PORTAL_URL)).arrayBuffer(); + await ( + await s.request(`${PORTAL_BASE}/getexprestion/single`, { + method: 'POST', + body: '', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + Origin: PORTAL_BASE, + Accept: 'application/json, text/javascript, */*; q=0.01', + Referer: PORTAL_URL, + }, + }) + ).arrayBuffer(); + + const res = await s.request(`${PORTAL_BASE}/results/${encodeURIComponent(itemId)}`, { headers: { Referer: PORTAL_URL } }); + if (!res.ok) throw new Error(`portal returned HTTP ${res.status} for the PDF`); + const data = Buffer.from(await res.arrayBuffer()); + if (data.subarray(0, 4).toString('latin1') !== '%PDF') throw new NotAPdf('the portal returned something that is not a PDF'); + return data; +} diff --git a/src/results/scheduler.test.ts b/src/results/scheduler.test.ts new file mode 100644 index 0000000..288b06d --- /dev/null +++ b/src/results/scheduler.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb } from '../testing/helpers'; +import type { DrawReport } from './job'; +import { createResultsScheduler } from './scheduler'; + +const z = (hhmmZ: string) => new Date(`2026-11-02T${hhmmZ}:00Z`); + +function setup(report: DrawReport[] = []) { + const db = makeDb(); + const logs: Record[] = []; + let calls = 0; + let clock = z('10:29'); + const s = createResultsScheduler({ + db, + log: (e) => logs.push(e), + now: () => clock, + run: async () => { + calls++; + return report; + }, + }); + return { db, logs, s, calls: () => calls, at: (d: Date) => (clock = d) }; +} + +describe('results scheduler', () => { + it('does nothing before 4 PM IST', async () => { + const x = setup(); + await x.s.tick(); + expect(x.calls()).toBe(0); + }); + + it('checks at 4 PM, then only every 10 minutes', async () => { + const x = setup(); + x.at(z('10:30')); + await x.s.tick(); + x.at(z('10:35')); + await x.s.tick(); + x.at(z('10:40')); + await x.s.tick(); + expect(x.calls()).toBe(2); + }); + + it('stops for the day once a draw dated today is ready', async () => { + const x = setup(); + x.at(z('11:00')); + await x.s.tick(); + x.db + .prepare(`INSERT INTO draws (draw_date, scheme, draw_code) VALUES ('2026-11-02', 'S', 'BT-78')`) + .run(); + x.at(z('11:10')); + await x.s.tick(); + x.at(z('11:20')); + await x.s.tick(); + expect(x.calls()).toBe(1); + }); + + it('after 7 PM it logs once and does not check', async () => { + const x = setup(); + x.at(z('13:30')); // 19:00 IST + await x.s.tick(); + await x.s.tick(); + expect(x.calls()).toBe(0); + expect(x.logs.filter((l) => String(l.msg).includes('by 7 PM'))).toHaveLength(1); + }); + + it('logs a held draw and survives a failing check', async () => { + const held: DrawReport[] = [ + { + code: 'BT-78', + date: '2026-11-02', + status: 'held', + issues: [{ level: 'ERROR', message: 'tier 7: expected 76 items, parsed 75' }], + }, + ]; + const x = setup(held); + x.at(z('11:00')); + await x.s.tick(); + expect(x.logs[0]).toMatchObject({ msg: 'results: draw not stored', draw: 'BT-78' }); + + const failing = createResultsScheduler({ + db: x.db, + log: (e) => x.logs.push(e), + now: () => z('11:30'), + run: async () => { + throw new Error('portal down'); + }, + }); + await failing.tick(); + expect(x.logs.at(-1)).toMatchObject({ level: 'error', error: 'portal down' }); + }); +}); diff --git a/src/results/scheduler.ts b/src/results/scheduler.ts new file mode 100644 index 0000000..61f1815 --- /dev/null +++ b/src/results/scheduler.ts @@ -0,0 +1,81 @@ +import type { Db } from '../db'; +import { runResultsJob, type DrawReport } from './job'; +import { todayIst } from './portal'; + +const WINDOW_START_MIN = 16 * 60; // 4 PM IST +const WINDOW_END_MIN = 19 * 60; // 7 PM IST +const EVERY_MS = 10 * 60_000; + +export interface SchedulerOptions { + db: Db; + log: (entry: Record) => void; + now?: () => Date; + run?: (db: Db) => Promise; +} + +const minutesIst = (d: Date): number => { + const s = new Date(d.getTime() + 5.5 * 3_600_000); + return s.getUTCHours() * 60 + s.getUTCMinutes(); +}; + +function todaysDrawReady(db: Db, today: string): boolean { + return !!db.prepare('SELECT 1 FROM draws WHERE draw_date = ? LIMIT 1').get(today); +} + +export function createResultsScheduler(opts: SchedulerOptions) { + const now = opts.now ?? (() => new Date()); + const run = opts.run ?? ((db: Db) => runResultsJob({ db })); + let lastRunAt = 0; + let running = false; + let loggedGiveUpFor = ''; + + async function tick(): Promise { + const t = now(); + const today = todayIst(t); + const minutes = minutesIst(t); + + if (minutes >= WINDOW_END_MIN) { + if (loggedGiveUpFor !== today && !todaysDrawReady(opts.db, today)) { + loggedGiveUpFor = today; + opts.log({ level: 'warn', msg: 'results: no ready draw for today by 7 PM', date: today }); + } + return; + } + if (minutes < WINDOW_START_MIN || running) return; + if (todaysDrawReady(opts.db, today)) return; + if (t.getTime() - lastRunAt < EVERY_MS - 30_000) return; + + running = true; + lastRunAt = t.getTime(); + try { + const reports = await run(opts.db); + for (const r of reports) { + if (r.status === 'held' || r.status === 'fetch_failed') { + opts.log({ + level: 'warn', + msg: 'results: draw not stored', + draw: r.code, + date: r.date, + status: r.status, + issues: r.issues.map((i) => i.message), + }); + } else if (r.status === 'ready') { + opts.log({ level: 'info', msg: 'results: draw stored', draw: r.code, date: r.date }); + } + } + } catch (err) { + opts.log({ level: 'error', msg: 'results: check failed', error: (err as Error).message }); + } finally { + running = false; + } + } + + function start(): () => void { + const timer = setInterval(() => void tick(), 60_000); + timer.unref(); + void tick(); + return () => clearInterval(timer); + } + + return { tick, start }; +} diff --git a/src/results/store.ts b/src/results/store.ts new file mode 100644 index 0000000..bf14839 --- /dev/null +++ b/src/results/store.ts @@ -0,0 +1,33 @@ +import type { Db } from '../db'; +import { hasErrors, type Draw, type Issue } from './parseResult'; + +export const drawCode = (d: Pick): string => `${d.schemeCode}-${d.drawNo}`; + +export type StoreOutcome = 'stored' | 'held'; + +export function storeDraw(db: Db, draw: Draw, issues: Issue[]): StoreOutcome { + const code = drawCode(draw); + const scheme = draw.schemeName || draw.schemeCode; + + if (hasErrors(issues)) return 'held'; + + const tx = db.transaction((): StoreOutcome => { + db.prepare( + `INSERT INTO draws (draw_date, scheme, draw_code) VALUES (?, ?, ?) + ON CONFLICT(draw_code) DO UPDATE SET draw_date = excluded.draw_date, scheme = excluded.scheme`, + ).run(draw.drawDate, scheme, code); + const row = db.prepare('SELECT id FROM draws WHERE draw_code = ?').get(code) as { id: number }; + db.prepare('DELETE FROM prizes WHERE draw_id = ?').run(row.id); + const ins = db.prepare('INSERT INTO prizes (draw_id, tier, amount, ending) VALUES (?, ?, ?, ?)'); + for (const t of draw.tiers) { + if (t.matchType !== 'last_n_digits' || !/^[4-9]$/.test(t.tier)) continue; + for (const ending of t.endings) ins.run(row.id, Number(t.tier), t.amount, ending); + } + return 'stored'; + }); + return tx(); +} + +export function isDrawStored(db: Db, code: string): boolean { + return !!db.prepare('SELECT 1 FROM draws WHERE draw_code = ?').get(code); +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..76cf728 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,14 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { onRequestAsyncHookHandler } from 'fastify'; + +const sha256 = (s: string) => createHash('sha256').update(s).digest(); +export function requireSecret(secret: string): onRequestAsyncHookHandler { + const expected = sha256(secret); + return async (req, reply) => { + const header = req.headers['x-srs-secret']; + const given = Array.isArray(header) ? header[0] : header; + if (!given || !timingSafeEqual(sha256(given), expected)) { + return reply.code(401).send({ error: 'unauthorized' }); + } + }; +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..31f239f --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,41 @@ +import type { FastifyPluginAsync } from 'fastify'; +import type { Db } from '../db'; +import type { Bridge } from '../whatsapp/bridge'; +import { requireSecret } from './auth'; + +interface HealthDeps { + db: Db; + bridge: Bridge; + secret: string; +} + +export const healthRoutes = + ({ db, bridge, secret }: HealthDeps): FastifyPluginAsync => + async (app) => { + const dbOk = (): boolean => { + try { + db.prepare('SELECT 1').get(); + return true; + } catch { + return false; + } + }; + + app.get('/api/health', async (_req, reply) => { + const ok = dbOk(); + return reply.code(ok ? 200 : 503).send({ ok }); + }); + + app.get('/internal/health', { onRequest: requireSecret(secret) }, async (_req, reply) => { + const database = dbOk(); + const b = await bridge.health(); + const whatsapp = b?.status ?? 'unreachable'; + const ok = database && whatsapp === 'connected'; + return reply.code(ok ? 200 : 503).send({ + ok, + database, + whatsapp, + whatsappQueueLength: b?.queueLength ?? null, + }); + }); + }; diff --git a/src/routes/ingest.test.ts b/src/routes/ingest.test.ts new file mode 100644 index 0000000..cca14df --- /dev/null +++ b/src/routes/ingest.test.ts @@ -0,0 +1,235 @@ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../app'; +import type { Config } from '../config'; +import type { Db } from '../db'; +import { makeDb, seedFixture, type Fixture } from '../testing/helpers'; +import type { Bridge } from '../whatsapp/bridge'; + +const SECRET = 'test-secret-0123456789'; + +const config: Config = { + port: 0, + host: '127.0.0.1', + dbPath: ':memory:', + srsSecret: SECRET, + jwtSecret: undefined, + bridgeUrl: 'http://127.0.0.1:3000', + defaultCountryCode: '91', +}; + +let db: Db; +let f: Fixture; +let app: FastifyInstance; +let bridgeStatus: string | null; + +const fakeBridge: Bridge = { + async sendText() { + return { messageIds: [] }; + }, + async health() { + return bridgeStatus ? { status: bridgeStatus, queueLength: 0 } : null; + }, +}; + +beforeEach(async () => { + db = makeDb(); + f = seedFixture(db); + bridgeStatus = 'connected'; + app = createApp({ db, config, bridge: fakeBridge }); + await app.ready(); +}); + +afterEach(async () => { + await app.close(); + db.close(); +}); + +const jid = (phone: string) => `${phone}@s.whatsapp.net`; + +function ingest(body: unknown, secret: string | null = SECRET) { + return app.inject({ + method: 'POST', + url: '/ingest', + headers: { 'content-type': 'application/json', ...(secret ? { 'x-srs-secret': secret } : {}) }, + payload: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +const text = (id: string, phone: string, t: string, extra: object = {}) => ({ + kind: 'text', + message_id: id, + sender_jid: jid(phone), + chat_id: '145483561476218@lid', + text: t, + ...extra, +}); + +const conversations = () => db.prepare('SELECT * FROM conversations').all() as Array>; +const logRows = () => db.prepare('SELECT * FROM ingest_log ORDER BY id').all() as Array>; + +describe('POST /ingest: access', () => { + it('rejects a missing or wrong secret', async () => { + expect((await ingest(text('m1', f.admin.phone, '0888'), null)).statusCode).toBe(401); + expect((await ingest(text('m1', f.admin.phone, '0888'), 'wrong-secret-value-123')).statusCode).toBe(401); + expect(logRows()).toHaveLength(0); + }); + + it('checks the secret before reading the body', async () => { + const res = await ingest('{not json', null); + expect(res.statusCode).toBe(401); + }); + + it('rejects a malformed body', async () => { + const res = await ingest({ kind: 'text', message_id: 'm1' }); + expect(res.statusCode).toBe(400); + expect(res.json<{ error: string }>().error).toBe('invalid_payload'); + }); + + it('rejects broken JSON from an authorised caller', async () => { + const res = await ingest('{not json'); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: 'invalid_json' }); + }); +}); + +describe('POST /ingest: who gets in', () => { + it('ignores an unknown number completely', async () => { + const res = await ingest(text('m1', '919111111111', '0888x2')); + expect(res.json()).toEqual({ handled: false }); + expect(conversations()).toHaveLength(0); + const [row] = logRows(); + expect(row?.outcome).toBe('ignored_unknown'); + }); + + it('ignores a sender whose id hides the phone number (@lid)', async () => { + const res = await ingest({ ...text('m1', '919000000001', '0888'), sender_jid: '145483561476218@lid' }); + expect(res.json()).toEqual({ handled: false }); + expect(conversations()).toHaveLength(0); + }); + + it('ignores a disabled user', async () => { + const res = await ingest(text('m1', f.disabledUser.phone, '0888')); + expect(res.json()).toEqual({ handled: false }); + expect(conversations()).toHaveLength(0); + expect(logRows()[0]?.outcome).toBe('ignored_inactive'); + }); + + it('accepts a known user and starts collecting', async () => { + const res = await ingest(text('m1', f.shopUser.phone, '0888x2')); + expect(res.json()).toEqual({ handled: true, duplicate: false }); + const [c] = conversations(); + expect(c).toMatchObject({ user_id: f.shopUser.id, state: 'COLLECTING', buffer_text: '0888x2' }); + expect(c?.chat_id).toBe('145483561476218@lid'); + }); + + it('accepts a sender id that carries a device suffix', async () => { + // WhatsApp can send ids like 919000000001:7@s.whatsapp.net + const res = await ingest({ ...text('m1', f.admin.phone, '1111'), sender_jid: `${f.admin.phone}:7@s.whatsapp.net` }); + expect(res.json()).toEqual({ handled: true, duplicate: false }); + }); +}); + +describe('POST /ingest: collecting text', () => { + it('adds later messages from the same user on new lines', async () => { + await ingest(text('m1', f.shopUser.phone, '0888x2')); + await ingest(text('m2', f.shopUser.phone, '3472')); + expect(conversations()[0]?.buffer_text).toBe('0888x2\n3472'); + }); + + it('keeps long text byte for byte', async () => { + const long = Array.from({ length: 100 }, (_, i) => `${1000 + i}*${(i % 9) + 1}`).join('\n'); + expect(long).toHaveLength(699); + await ingest(text('m1', f.shopUser.phone, long)); + expect(conversations()[0]?.buffer_text).toBe(long); + }); + + it('keeps different users apart', async () => { + await ingest(text('m1', f.shopUser.phone, 'AAA1')); + await ingest(text('m2', f.admin.phone, 'BBB2')); + const byUser = Object.fromEntries(conversations().map((c) => [c.user_id as number, c.buffer_text])); + expect(byUser[f.shopUser.id]).toBe('AAA1'); + expect(byUser[f.admin.id]).toBe('BBB2'); + }); + + it('applies a repeated message id only once', async () => { + await ingest(text('m1', f.shopUser.phone, '0888x2')); + const again = await ingest(text('m1', f.shopUser.phone, '0888x2')); + expect(again.json()).toEqual({ handled: true, duplicate: true }); + expect(conversations()[0]?.buffer_text).toBe('0888x2'); + expect(logRows()).toHaveLength(1); + }); + + it('treats text as an answer, not order text, while the bot is waiting for one', async () => { + await ingest(text('m1', f.shopUser.phone, '0888x2')); + db.prepare("UPDATE conversations SET state = 'AWAITING_SHOP'").run(); + await ingest(text('m2', f.shopUser.phone, '2')); + const [c] = conversations(); + expect(c).toMatchObject({ state: 'AWAITING_SHOP', buffer_text: '0888x2' }); + }); +}); + +describe('GET /api/health and /internal/health', () => { + it('reports ok on the public route without details', async () => { + const res = await app.inject({ method: 'GET', url: '/api/health' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + }); + + it('needs the secret for the internal route', async () => { + expect((await app.inject({ method: 'GET', url: '/internal/health' })).statusCode).toBe(401); + }); + + it('reports WhatsApp connected or not', async () => { + const get = () => app.inject({ method: 'GET', url: '/internal/health', headers: { 'x-srs-secret': SECRET } }); + + const good = await get(); + expect(good.statusCode).toBe(200); + expect(good.json()).toMatchObject({ ok: true, whatsapp: 'connected' }); + + bridgeStatus = 'disconnected'; + const bad = await get(); + expect(bad.statusCode).toBe(503); + expect(bad.json()).toMatchObject({ ok: false, whatsapp: 'disconnected' }); + + bridgeStatus = null; + expect((await get()).json()).toMatchObject({ ok: false, whatsapp: 'unreachable' }); + }); +}); + +describe('unknown routes and odd requests', () => { + it('answers 404 as JSON', async () => { + const res = await app.inject({ method: 'GET', url: '/nope' }); + expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ error: 'not_found' }); + }); + + it('refuses a body above the size limit', async () => { + const huge = JSON.stringify({ kind: 'text', message_id: 'x', sender_jid: 'y', chat_id: 'z', text: 'a'.repeat(13 * 1024 * 1024) }); + const res = await ingest(huge); + expect(res.statusCode).toBe(413); + expect(res.json()).toEqual({ error: 'payload_too_large' }); + }); + + it('refuses plain text sent instead of JSON', async () => { + const res = await app.inject({ + method: 'POST', + url: '/ingest', + headers: { 'content-type': 'text/plain', 'x-srs-secret': SECRET }, + payload: 'hello', + }); + expect(res.statusCode).toBe(400); + expect(res.json<{ error: string }>().error).toBe('invalid_payload'); + }); + + it('refuses a content type it cannot read', async () => { + const res = await app.inject({ + method: 'POST', + url: '/ingest', + headers: { 'content-type': 'application/xml', 'x-srs-secret': SECRET }, + payload: '', + }); + expect(res.statusCode).toBe(415); + expect(res.json()).toEqual({ error: 'unsupported_media_type' }); + }); +}); diff --git a/src/routes/ingest.ts b/src/routes/ingest.ts new file mode 100644 index 0000000..feed32e --- /dev/null +++ b/src/routes/ingest.ts @@ -0,0 +1,86 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db'; +import { phoneFromJid } from '../services/phone'; +import { findUserByPhone } from '../services/users'; +import { receiveText } from '../whatsapp/conversation'; +import { requireSecret } from './auth'; + +const base = z.object({ + message_id: z.string().min(1).max(200), + sender_jid: z.string().min(1).max(200), + chat_id: z.string().min(1).max(200), +}); + +const payloadSchema = z.discriminatedUnion('kind', [ + base.extend({ kind: z.literal('text'), text: z.string().min(1).max(100_000) }), + base.extend({ + kind: z.literal('image'), + image_base64: z.string().min(1).max(12_000_000), + mime_type: z.string().max(100).default('image/jpeg'), + }), +]); + +interface IngestDeps { + db: Db; + secret: string; +} + +const isUniqueViolation = (e: unknown): boolean => + typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).startsWith('SQLITE_CONSTRAINT'); + +export const ingestRoutes = + ({ db, secret }: IngestDeps): FastifyPluginAsync => + async (app) => { + const insertLog = db.prepare( + `INSERT INTO ingest_log (wa_message_id, sender_phone, user_id, kind, outcome) + VALUES (@wa_message_id, @sender_phone, @user_id, @kind, @outcome)`, + ); + + app.post('/ingest', { onRequest: requireSecret(secret) }, async (req, reply) => { + const parsed = payloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ + error: 'invalid_payload', + issues: parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`), + }); + } + const msg = parsed.data; + + const senderPhone = phoneFromJid(msg.sender_jid); + const user = senderPhone ? findUserByPhone(db, senderPhone) : undefined; + + let outcome: string; + if (!user) outcome = 'ignored_unknown'; + else if (!user.active) outcome = 'ignored_inactive'; + else outcome = 'accepted'; + + const accepted = outcome === 'accepted' && user !== undefined; + + const apply = db.transaction((): { duplicate: boolean } => { + try { + insertLog.run({ + wa_message_id: msg.message_id, + sender_phone: senderPhone, + user_id: user?.id ?? null, + kind: msg.kind, + outcome, + }); + } catch (e) { + if (isUniqueViolation(e)) return { duplicate: true }; + throw e; + } + + if (accepted && msg.kind === 'text') { + receiveText(db, user, msg.chat_id, msg.text); + } + return { duplicate: false }; + }); + + const { duplicate } = apply(); + + if (duplicate) return { handled: true, duplicate: true }; + if (!accepted) return { handled: false }; + return { handled: true, duplicate: false }; + }); + }; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b1b30d0 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,45 @@ +import { createApp } from './app'; +import { loadConfig } from './config'; +import { openDb } from './db'; +import { runMigrations } from './db/migrate'; +import { createResultsScheduler } from './results/scheduler'; +import { createBridge } from './whatsapp/bridge'; + +async function main(): Promise { + const config = loadConfig(); + const db = openDb(config.dbPath); + + const applied = runMigrations(db); + if (applied.length > 0) { + console.log(JSON.stringify({ level: 'info', msg: 'migrations applied', applied })); + } + + const bridge = createBridge(config.bridgeUrl); + const app = createApp({ db, config, bridge }, { logger: true }); + + // TODO(conversation): on startup, re-arm the ~20 s collection timers for conversations that are + // still COLLECTING, so a restart does not lose half-finished orders. + + const stopScheduler = createResultsScheduler({ db, log: (e) => app.log.info(e) }).start(); + + await app.listen({ port: config.port, host: config.host }); + + let stopping = false; + const shutdown = async (signal: string): Promise => { + if (stopping) return; + stopping = true; + app.log.info({ signal }, 'shutting down'); + setTimeout(() => process.exit(1), 10_000).unref(); + stopScheduler(); + await app.close(); + db.close(); + process.exit(0); + }; + process.on('SIGTERM', () => void shutdown('SIGTERM')); + process.on('SIGINT', () => void shutdown('SIGINT')); +} + +main().catch((err) => { + console.error(JSON.stringify({ level: 'fatal', msg: 'failed to start', error: String(err) })); + process.exit(1); +}); diff --git a/src/services/accountRules.ts b/src/services/accountRules.ts new file mode 100644 index 0000000..4e1431d --- /dev/null +++ b/src/services/accountRules.ts @@ -0,0 +1,48 @@ +import type { Db } from '../db'; +import { normalizePhone } from './phone'; + +export type RuleErrorCode = 'forbidden' | 'target_is_admin' | 'not_found' | 'invalid' | 'conflict'; + +export class RuleError extends Error { + constructor( + public readonly code: RuleErrorCode, + message: string, + ) { + super(message); + this.name = 'RuleError'; + } +} + +export const USERNAME_HELP = 'letters, digits, dot, dash or underscore, 3 to 50 characters'; +const USERNAME = /^[A-Za-z0-9._-]{3,50}$/; + +export const isValidUsername = (raw: string): boolean => USERNAME.test(raw.trim()); + +export function cleanUsername(raw: string): string { + const username = raw.trim(); + if (!USERNAME.test(username)) throw new RuleError('invalid', `username must be ${USERNAME_HELP}`); + return username; +} + +export function cleanPhone(raw: string | null | undefined, defaultCountryCode: string): string | null { + if (raw === undefined || raw === null || raw.trim() === '') return null; + const phone = normalizePhone(raw, defaultCountryCode); + if (!phone) throw new RuleError('invalid', 'that is not a valid phone number'); + return phone; +} + +export const isUniqueViolation = (e: unknown): boolean => + typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).includes('UNIQUE'); + +export function writeShops(db: Db, userId: number, shopIds: number[]): void { + const ids = [...new Set(shopIds)]; + const exists = db.prepare('SELECT 1 FROM shops WHERE id = ? AND active = 1'); + for (const id of ids) { + if (!Number.isInteger(id) || !exists.get(id)) { + throw new RuleError('invalid', `shop ${id} does not exist or is inactive`); + } + } + db.prepare('DELETE FROM user_shops WHERE user_id = ?').run(userId); + const insert = db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)'); + for (const id of ids) insert.run(userId, id); +} diff --git a/src/services/drawDate.test.ts b/src/services/drawDate.test.ts new file mode 100644 index 0000000..4519214 --- /dev/null +++ b/src/services/drawDate.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { drawDateForOrder } from './drawDate'; + +const at = (iso: string) => drawDateForOrder(new Date(iso)); + +describe('drawDateForOrder (3 PM IST cutoff)', () => { + it('before 3 PM is the same day', () => { + expect(at('2026-11-02T08:30:00Z')).toBe('2026-11-02'); + }); + it('exactly 3 PM is still the same day', () => { + expect(at('2026-11-02T09:30:00Z')).toBe('2026-11-02'); + }); + it('one second after 3 PM is the next day', () => { + expect(at('2026-11-02T09:30:01Z')).toBe('2026-11-03'); + }); + it('late evening is the next day', () => { + expect(at('2026-11-02T17:00:00Z')).toBe('2026-11-03'); + }); + it('uses the Indian date, not the UTC date', () => { + expect(at('2026-11-02T20:00:00Z')).toBe('2026-11-03'); + expect(at('2026-11-01T19:00:00Z')).toBe('2026-11-02'); + }); + it('rolls over month and year ends', () => { + expect(at('2026-12-31T12:00:00Z')).toBe('2027-01-01'); + }); +}); diff --git a/src/services/drawDate.ts b/src/services/drawDate.ts new file mode 100644 index 0000000..3e12617 --- /dev/null +++ b/src/services/drawDate.ts @@ -0,0 +1,18 @@ +const IST_OFFSET_MS = 5.5 * 3_600_000; +const CUTOFF_MINUTES = 15 * 60; + +function ist(at: Date): { date: string; minutes: number; seconds: number } { + const shifted = new Date(at.getTime() + IST_OFFSET_MS); + return { + date: shifted.toISOString().slice(0, 10), + minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(), + seconds: shifted.getUTCSeconds(), + }; +} + +export function drawDateForOrder(receivedAt: Date): string { + const t = ist(receivedAt); + const afterCutoff = t.minutes > CUTOFF_MINUTES || (t.minutes === CUTOFF_MINUTES && t.seconds > 0); + if (!afterCutoff) return t.date; + return new Date(Date.parse(`${t.date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); +} diff --git a/src/services/phone.test.ts b/src/services/phone.test.ts new file mode 100644 index 0000000..e0240c1 --- /dev/null +++ b/src/services/phone.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { normalizePhone, phoneFromJid } from './phone'; + +describe('normalizePhone', () => { + it('keeps digits and the country code', () => { + expect(normalizePhone('+91 98470 49032')).toBe('919847049032'); + expect(normalizePhone('919847049032')).toBe('919847049032'); + }); + + it('adds the default country code to a 10-digit number', () => { + expect(normalizePhone('9847049032')).toBe('919847049032'); + expect(normalizePhone('9847049032', '44')).toBe('449847049032'); + }); + + it('drops a leading 0 or 00', () => { + expect(normalizePhone('09847049032')).toBe('919847049032'); + expect(normalizePhone('0091 9847049032')).toBe('919847049032'); + }); + + it('rejects things that cannot be a phone number', () => { + expect(normalizePhone('')).toBeNull(); + expect(normalizePhone('abc')).toBeNull(); + expect(normalizePhone('12345')).toBeNull(); + expect(normalizePhone('1'.repeat(16))).toBeNull(); + }); +}); + +describe('phoneFromJid', () => { + it('reads the number from a phone id', () => { + expect(phoneFromJid('919847049032@s.whatsapp.net')).toBe('919847049032'); + expect(phoneFromJid('919847049032:12@s.whatsapp.net')).toBe('919847049032'); + }); + + it('returns null for ids that hide the number', () => { + expect(phoneFromJid('145483561476218@lid')).toBeNull(); + expect(phoneFromJid('120363000000000000@g.us')).toBeNull(); + expect(phoneFromJid('not a jid')).toBeNull(); + }); +}); diff --git a/src/services/phone.ts b/src/services/phone.ts new file mode 100644 index 0000000..75cf49e --- /dev/null +++ b/src/services/phone.ts @@ -0,0 +1,20 @@ + +export function normalizePhone(input: string, defaultCountryCode = '91'): string | null { + let digits = input.replace(/\D/g, ''); + if (digits.startsWith('00')) digits = digits.slice(2); + if (digits.length === 0) return null; + + if (digits.startsWith('0')) { + digits = defaultCountryCode + digits.replace(/^0+/, ''); + } else if (digits.length === 10) { + digits = defaultCountryCode + digits; + } + + if (digits.length < 8 || digits.length > 15) return null; + return digits; +} + +export function phoneFromJid(jid: string): string | null { + const m = /^(\d{6,15})(?::\d+)?@(?:s\.whatsapp\.net|c\.us)$/.exec(jid.trim()); + return m ? (m[1] ?? null) : null; +} diff --git a/src/services/provisioning.ts b/src/services/provisioning.ts new file mode 100644 index 0000000..14a4382 --- /dev/null +++ b/src/services/provisioning.ts @@ -0,0 +1,54 @@ +import type { Db } from '../db'; +import { cleanPhone, cleanUsername, isUniqueViolation, RuleError, writeShops } from './accountRules'; +import { getUserById, type User } from './users'; + +export const PLACEHOLDER_HASH = 'not-set'; + +export interface NewAccount { + username: string; + passwordHash?: string; + phone?: string | null; + shopIds?: number[]; +} + +function insert(db: Db, username: string, passwordHash: string, phone: string | null, isAdmin: 0 | 1): number { + try { + return Number( + db + .prepare('INSERT INTO users (username, password_hash, phone, is_admin, active) VALUES (?, ?, ?, ?, 1)') + .run(username, passwordHash, phone, isAdmin).lastInsertRowid, + ); + } catch (e) { + if (isUniqueViolation(e)) throw new RuleError('conflict', 'that username or phone number is already used'); + throw e; + } +} + +export function createUserAccount(db: Db, input: NewAccount, defaultCountryCode = '91'): User { + const username = cleanUsername(input.username); + const phone = cleanPhone(input.phone, defaultCountryCode); + + const id = db.transaction((): number => { + const newId = insert(db, username, input.passwordHash ?? PLACEHOLDER_HASH, phone, 0); + writeShops(db, newId, input.shopIds ?? []); + return newId; + })(); + + return getUserById(db, id)!; +} + +export function createAdminAccount( + db: Db, + input: { username: string; phone: string; passwordHash?: string }, + defaultCountryCode = '91', +): User { + const username = cleanUsername(input.username); + const phone = cleanPhone(input.phone, defaultCountryCode); + if (!phone) throw new RuleError('invalid', 'an admin needs a phone number'); + + return getUserById(db, insert(db, username, input.passwordHash ?? PLACEHOLDER_HASH, phone, 1))!; +} + +export function countAdmins(db: Db): number { + return (db.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1').get() as { n: number }).n; +} diff --git a/src/services/shopScope.test.ts b/src/services/shopScope.test.ts new file mode 100644 index 0000000..0951eaf --- /dev/null +++ b/src/services/shopScope.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb, seedFixture } from '../testing/helpers'; +import { canUseShop, shopsForUser } from './shopScope'; +import { getUserById } from './users'; + +describe('shopsForUser', () => { + it('gives an admin every active shop', () => { + const db = makeDb(); + const f = seedFixture(db); + const admin = getUserById(db, f.admin.id)!; + expect(shopsForUser(db, admin).map((s) => s.name)).toEqual(['Shop A', 'Shop B', 'Shop C']); + }); + + it('gives a shop user only their assigned shops', () => { + const db = makeDb(); + const f = seedFixture(db); + const user = getUserById(db, f.shopUser.id)!; + expect(shopsForUser(db, user).map((s) => s.id)).toEqual([f.shops.a]); + }); + + it('gives a user with no assigned shops nothing', () => { + const db = makeDb(); + const f = seedFixture(db); + db.prepare('DELETE FROM user_shops').run(); + expect(shopsForUser(db, getUserById(db, f.shopUser.id)!)).toEqual([]); + }); + + it('hides inactive shops, even from an admin', () => { + const db = makeDb(); + const f = seedFixture(db); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); + const admin = getUserById(db, f.admin.id)!; + expect(shopsForUser(db, admin).map((s) => s.name)).toEqual(['Shop A', 'Shop C']); + }); + + it('gives a disabled user nothing', () => { + const db = makeDb(); + const f = seedFixture(db); + db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(f.disabledUser.id, f.shops.a); + expect(shopsForUser(db, getUserById(db, f.disabledUser.id)!)).toEqual([]); + }); +}); + +describe('canUseShop', () => { + it('allows only shops the user may see', () => { + const db = makeDb(); + const f = seedFixture(db); + const user = getUserById(db, f.shopUser.id)!; + expect(canUseShop(db, user, f.shops.a)).toBe(true); + expect(canUseShop(db, user, f.shops.b)).toBe(false); + expect(canUseShop(db, user, 99999)).toBe(false); + }); +}); diff --git a/src/services/shopScope.ts b/src/services/shopScope.ts new file mode 100644 index 0000000..650d52a --- /dev/null +++ b/src/services/shopScope.ts @@ -0,0 +1,37 @@ +import type { Db } from '../db'; +import type { User } from './users'; + +export interface Shop { + id: number; + name: string; + code: string; +} + +interface ShopRow { + id: number; + name: string; + code: string; +} + +const toShop = (r: ShopRow): Shop => ({ id: r.id, name: r.name, code: r.code }); +export function shopsForUser(db: Db, user: User): Shop[] { + if (!user.active) return []; + + const rows = user.isAdmin + ? (db.prepare('SELECT id, name, code FROM shops WHERE active = 1 ORDER BY name, id').all() as ShopRow[]) + : (db + .prepare( + `SELECT s.id, s.name, s.code + FROM shops s + JOIN user_shops us ON us.shop_id = s.id + WHERE us.user_id = ? AND s.active = 1 + ORDER BY s.name, s.id`, + ) + .all(user.id) as ShopRow[]); + + return rows.map(toShop); +} + +export function canUseShop(db: Db, user: User, shopId: number): boolean { + return shopsForUser(db, user).some((s) => s.id === shopId); +} diff --git a/src/services/userAdmin.test.ts b/src/services/userAdmin.test.ts new file mode 100644 index 0000000..475d34d --- /dev/null +++ b/src/services/userAdmin.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb, seedFixture } from '../testing/helpers'; +import { shopsForUser } from './shopScope'; +import { createUser, RuleError, setUserShops, updateUser, type RuleErrorCode } from './userAdmin'; +import { getUserById } from './users'; + +const setup = () => { + const db = makeDb(); + const f = seedFixture(db); + return { + db, + f, + admin: getUserById(db, f.admin.id)!, + shopUser: getUserById(db, f.shopUser.id)!, + }; +}; + +const code = (fn: () => unknown): RuleErrorCode | 'no error' => { + try { + fn(); + return 'no error'; + } catch (e) { + if (e instanceof RuleError) return e.code; + throw e; + } +}; + +describe('who may manage users', () => { + it('lets an ordinary user do nothing', () => { + const { db, f, shopUser } = setup(); + expect(code(() => createUser(db, shopUser, { username: 'newbie', passwordHash: 'h' }))).toBe('forbidden'); + expect(code(() => updateUser(db, shopUser, f.disabledUser.id, { active: true }))).toBe('forbidden'); + expect(code(() => setUserShops(db, shopUser, f.disabledUser.id, [f.shops.a]))).toBe('forbidden'); + }); + + it('lets a disabled admin do nothing', () => { + const { db, f, admin } = setup(); + db.prepare('UPDATE users SET active = 0 WHERE id = ?').run(admin.id); + expect(code(() => createUser(db, admin, { username: 'newbie', passwordHash: 'h' }))).toBe('forbidden'); + }); + + it('does not trust the actor object, only the database', () => { + const { db, shopUser } = setup(); + const forged = { ...shopUser, isAdmin: true }; + expect(code(() => createUser(db, forged, { username: 'newbie', passwordHash: 'h' }))).toBe('forbidden'); + }); +}); + +describe('creating users', () => { + it('lets an admin create a user with a phone number and shops', () => { + const { db, f, admin } = setup(); + const u = createUser(db, admin, { + username: 'shop-b', + passwordHash: 'h', + phone: '98470 12345', + shopIds: [f.shops.b, f.shops.c], + }); + expect(u).toMatchObject({ username: 'shop-b', phone: '919847012345', isAdmin: false, active: true }); + expect(shopsForUser(db, u).map((s) => s.id)).toEqual([f.shops.b, f.shops.c]); + }); + + it('can never create an admin, even if asked', () => { + const { db, admin } = setup(); + const u = createUser(db, admin, { username: 'sneaky', passwordHash: 'h', isAdmin: true } as never); + expect(u.isAdmin).toBe(false); + expect(db.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1').get()).toEqual({ n: 2 }); + }); + + it('refuses a duplicate username or phone number', () => { + const { db, f, admin } = setup(); + expect(code(() => createUser(db, admin, { username: 'shop-a', passwordHash: 'h' }))).toBe('conflict'); + expect(code(() => createUser(db, admin, { username: 'other', passwordHash: 'h', phone: f.shopUser.phone }))).toBe('conflict'); + }); + + it('refuses bad input', () => { + const { db, admin } = setup(); + expect(code(() => createUser(db, admin, { username: 'x', passwordHash: 'h' }))).toBe('invalid'); + expect(code(() => createUser(db, admin, { username: 'has space', passwordHash: 'h' }))).toBe('invalid'); + expect(code(() => createUser(db, admin, { username: 'okname', passwordHash: '' }))).toBe('invalid'); + expect(code(() => createUser(db, admin, { username: 'okname', passwordHash: 'h', phone: '12' }))).toBe('invalid'); + }); + + it('creates nothing when a shop is invalid', () => { + const { db, admin } = setup(); + expect(code(() => createUser(db, admin, { username: 'halfway', passwordHash: 'h', shopIds: [99999] }))).toBe('invalid'); + expect(db.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'halfway'").get()).toEqual({ n: 0 }); + }); +}); + +describe('admins are fixed', () => { + it('cannot be edited, deactivated or given shops through the application', () => { + const { db, f, admin } = setup(); + expect(code(() => updateUser(db, admin, f.admin2.id, { active: false }))).toBe('target_is_admin'); + expect(code(() => updateUser(db, admin, f.admin2.id, { phone: '919999999999' }))).toBe('target_is_admin'); + expect(code(() => updateUser(db, admin, f.admin2.id, { passwordHash: 'new' }))).toBe('target_is_admin'); + expect(code(() => setUserShops(db, admin, f.admin2.id, [f.shops.a]))).toBe('target_is_admin'); + }); + + it('cannot even edit themselves', () => { + const { db, admin } = setup(); + expect(code(() => updateUser(db, admin, admin.id, { active: false }))).toBe('target_is_admin'); + }); + + it('are untouched after all those attempts', () => { + const { db, f, admin } = setup(); + code(() => updateUser(db, admin, f.admin2.id, { active: false })); + expect(getUserById(db, f.admin2.id)).toMatchObject({ isAdmin: true, active: true, phone: f.admin2.phone }); + }); +}); + +describe('changing ordinary users', () => { + it('changes phone and username, and turns a user off and on', () => { + const { db, f, admin } = setup(); + const u = updateUser(db, admin, f.shopUser.id, { phone: '9847099999', username: 'renamed' }); + expect(u).toMatchObject({ phone: '919847099999', username: 'renamed' }); + expect(updateUser(db, admin, f.shopUser.id, { active: false }).active).toBe(false); + expect(updateUser(db, admin, f.shopUser.id, { active: true }).active).toBe(true); + }); + + it('removes a phone number with null', () => { + const { db, f, admin } = setup(); + expect(updateUser(db, admin, f.shopUser.id, { phone: null }).phone).toBeNull(); + }); + + it('refuses a phone number another user has', () => { + const { db, f, admin } = setup(); + expect(code(() => updateUser(db, admin, f.shopUser.id, { phone: f.admin.phone }))).toBe('conflict'); + }); + + it('reports an unknown user', () => { + const { db, admin } = setup(); + expect(code(() => updateUser(db, admin, 99999, { active: false }))).toBe('not_found'); + }); + + it('takes the shops away from a deactivated user', () => { + const { db, f, admin } = setup(); + const u = updateUser(db, admin, f.shopUser.id, { active: false }); + expect(shopsForUser(db, u)).toEqual([]); + }); +}); + +describe('assigning shops', () => { + it('replaces the whole set', () => { + const { db, f, admin } = setup(); + setUserShops(db, admin, f.shopUser.id, [f.shops.b, f.shops.c]); + const u = getUserById(db, f.shopUser.id)!; + expect(shopsForUser(db, u).map((s) => s.id)).toEqual([f.shops.b, f.shops.c]); + setUserShops(db, admin, f.shopUser.id, []); + expect(shopsForUser(db, u)).toEqual([]); + }); + + it('ignores a repeated shop id', () => { + const { db, f, admin } = setup(); + setUserShops(db, admin, f.shopUser.id, [f.shops.b, f.shops.b]); + expect(shopsForUser(db, getUserById(db, f.shopUser.id)!).map((s) => s.id)).toEqual([f.shops.b]); + }); + + it('changes nothing when one shop is unknown or inactive', () => { + const { db, f, admin } = setup(); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.c); + expect(code(() => setUserShops(db, admin, f.shopUser.id, [f.shops.b, f.shops.c]))).toBe('invalid'); + expect(code(() => setUserShops(db, admin, f.shopUser.id, [f.shops.b, 99999]))).toBe('invalid'); + expect(shopsForUser(db, getUserById(db, f.shopUser.id)!).map((s) => s.id)).toEqual([f.shops.a]); + }); +}); diff --git a/src/services/userAdmin.ts b/src/services/userAdmin.ts new file mode 100644 index 0000000..69638d9 --- /dev/null +++ b/src/services/userAdmin.ts @@ -0,0 +1,87 @@ +import type { Db } from '../db'; +import { cleanPhone, cleanUsername, isUniqueViolation, RuleError, writeShops } from './accountRules'; +import { createUserAccount } from './provisioning'; +import { getUserById, type User } from './users'; + +export { RuleError } from './accountRules'; +export type { RuleErrorCode } from './accountRules'; + +export interface NewUser { + username: string; + passwordHash: string; + phone?: string | null; + shopIds?: number[]; +} + +export interface UserPatch { + username?: string; + phone?: string | null; + active?: boolean; + passwordHash?: string; +} + +function requireAdmin(db: Db, actor: User): void { + const current = getUserById(db, actor.id); + if (!current || !current.isAdmin || !current.active) { + throw new RuleError('forbidden', 'only an active admin can manage users'); + } +} + +function manageableTarget(db: Db, userId: number): User { + const target = getUserById(db, userId); + if (!target) throw new RuleError('not_found', 'user not found'); + if (target.isAdmin) throw new RuleError('target_is_admin', 'admins cannot be changed here'); + return target; +} + +export function createUser(db: Db, actor: User, input: NewUser, defaultCountryCode = '91'): User { + requireAdmin(db, actor); + if (!input.passwordHash) throw new RuleError('invalid', 'password is required'); + return createUserAccount( + db, + { username: input.username, passwordHash: input.passwordHash, phone: input.phone, shopIds: input.shopIds }, + defaultCountryCode, + ); +} + +export function updateUser(db: Db, actor: User, userId: number, patch: UserPatch, defaultCountryCode = '91'): User { + requireAdmin(db, actor); + manageableTarget(db, userId); + + const sets: string[] = []; + const values: Array = []; + + if (patch.username !== undefined) { + sets.push('username = ?'); + values.push(cleanUsername(patch.username)); + } + if (patch.phone !== undefined) { + sets.push('phone = ?'); + values.push(cleanPhone(patch.phone, defaultCountryCode)); + } + if (patch.active !== undefined) { + sets.push('active = ?'); + values.push(patch.active ? 1 : 0); + } + if (patch.passwordHash !== undefined) { + if (!patch.passwordHash) throw new RuleError('invalid', 'password cannot be empty'); + sets.push('password_hash = ?'); + values.push(patch.passwordHash); + } + + if (sets.length > 0) { + try { + db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ? AND is_admin = 0`).run(...values, userId); + } catch (e) { + if (isUniqueViolation(e)) throw new RuleError('conflict', 'that username or phone number is already used'); + throw e; + } + } + return getUserById(db, userId)!; +} + +export function setUserShops(db: Db, actor: User, userId: number, shopIds: number[]): void { + requireAdmin(db, actor); + manageableTarget(db, userId); + db.transaction(() => writeShops(db, userId, shopIds))(); +} diff --git a/src/services/users.ts b/src/services/users.ts new file mode 100644 index 0000000..bd9169b --- /dev/null +++ b/src/services/users.ts @@ -0,0 +1,44 @@ +import type { Db } from '../db'; + +export interface User { + id: number; + username: string; + phone: string | null; + isAdmin: boolean; + active: boolean; +} + +interface UserRow { + id: number; + username: string; + phone: string | null; + is_admin: number; + active: number; +} + +const COLUMNS = 'id, username, phone, is_admin, active'; + +function toUser(r: UserRow): User { + return { + id: r.id, + username: r.username, + phone: r.phone, + isAdmin: r.is_admin === 1, + active: r.active === 1, + }; +} + +export function findUserByPhone(db: Db, phone: string): User | undefined { + const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE phone = ?`).get(phone) as UserRow | undefined; + return row ? toUser(row) : undefined; +} + +export function getUserById(db: Db, id: number): User | undefined { + const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE id = ?`).get(id) as UserRow | undefined; + return row ? toUser(row) : undefined; +} + +export function listUsers(db: Db): User[] { + const rows = db.prepare(`SELECT ${COLUMNS} FROM users ORDER BY is_admin DESC, username`).all() as UserRow[]; + return rows.map(toUser); +} diff --git a/src/testing/helpers.ts b/src/testing/helpers.ts new file mode 100644 index 0000000..cfcb8b2 --- /dev/null +++ b/src/testing/helpers.ts @@ -0,0 +1,42 @@ +import type { Db } from '../db'; +import { openDb } from '../db'; +import { runMigrations } from '../db/migrate'; + +/** A fresh in-memory database with every migration applied. */ +export function makeDb(): Db { + const db = openDb(':memory:'); + runMigrations(db); + return db; +} + +export interface Fixture { + shops: { a: number; b: number; c: number }; + admin: { id: number; phone: string }; + admin2: { id: number; phone: string }; + shopUser: { id: number; phone: string }; + disabledUser: { id: number; phone: string }; +} + +export function seedFixture(db: Db): Fixture { + const shop = (code: string, name: string) => + Number(db.prepare('INSERT INTO shops (name, code) VALUES (?, ?)').run(name, code).lastInsertRowid); + const shops = { a: shop('A', 'Shop A'), b: shop('B', 'Shop B'), c: shop('C', 'Shop C') }; + + const user = (username: string, phone: string, isAdmin: number, active: number) => ({ + id: Number( + db + .prepare('INSERT INTO users (username, password_hash, phone, is_admin, active) VALUES (?, ?, ?, ?, ?)') + .run(username, 'x', phone, isAdmin, active).lastInsertRowid, + ), + phone, + }); + + const admin = user('admin', '919000000001', 1, 1); + const admin2 = user('admin2', '919000000004', 1, 1); + const shopUser = user('shop-a', '919000000002', 0, 1); + const disabledUser = user('gone', '919000000003', 0, 0); + + db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(shopUser.id, shops.a); + + return { shops, admin, admin2, shopUser, disabledUser }; +} diff --git a/src/whatsapp/bridge.ts b/src/whatsapp/bridge.ts new file mode 100644 index 0000000..35d159f --- /dev/null +++ b/src/whatsapp/bridge.ts @@ -0,0 +1,35 @@ + +export interface Bridge { + sendText(chatId: string, message: string): Promise<{ messageIds: string[] }>; + health(): Promise<{ status: string; queueLength?: number } | null>; +} + +export function createBridge(baseUrl: string, fetchImpl: typeof fetch = fetch): Bridge { + const base = baseUrl.replace(/\/+$/, ''); + + return { + async sendText(chatId, message) { + const res = await fetchImpl(`${base}/send`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ chatId, message }), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) { + throw new Error(`bridge /send failed with HTTP ${res.status}`); + } + const body = (await res.json()) as { messageIds?: string[] }; + return { messageIds: body.messageIds ?? [] }; + }, + + async health() { + try { + const res = await fetchImpl(`${base}/health`, { signal: AbortSignal.timeout(2_000) }); + if (!res.ok) return null; + return (await res.json()) as { status: string; queueLength?: number }; + } catch { + return null; + } + }, + }; +} diff --git a/src/whatsapp/conversation.ts b/src/whatsapp/conversation.ts new file mode 100644 index 0000000..26a9bf9 --- /dev/null +++ b/src/whatsapp/conversation.ts @@ -0,0 +1,68 @@ +import type { Db } from '../db'; +import type { User } from '../services/users'; + +/** + * Where a person is in the WhatsApp conversation. One row per user in `conversations`. + * + * IDLE nothing in progress + * COLLECTING order text is arriving; the ~20 s quiet timer is running + * ASKING the bot asked a question about the parse (repeated endings, a failed total, ...) + * AWAITING_SHOP the bot showed the numbered shop list and waits for a number + * CONFIRM the bot showed "Save ...? 1 Yes / 2 Edit / 3 Cancel" and waits + */ +export type ConversationState = 'IDLE' | 'COLLECTING' | 'ASKING' | 'AWAITING_SHOP' | 'CONFIRM'; + +export interface ConversationRow { + id: number; + user_id: number; + chat_id: string; + state: ConversationState; + buffer_text: string; + source: 'whatsapp_text' | 'whatsapp_photo'; + parsed_json: string | null; + shop_options_json: string | null; + selected_shop_id: number | null; + last_activity_at: string; + created_at: string; +} + +export type ReceiveResult = + | { kind: 'collecting'; conversation: ConversationRow } + | { kind: 'answer'; conversation: ConversationRow }; + +const WAITING_FOR_ANSWER: ConversationState[] = ['ASKING', 'AWAITING_SHOP', 'CONFIRM']; + +const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + +function getConversation(db: Db, userId: number): ConversationRow | undefined { + return db.prepare('SELECT * FROM conversations WHERE user_id = ?').get(userId) as ConversationRow | undefined; +} + +export function receiveText(db: Db, user: User, chatId: string, text: string): ReceiveResult { + const existing = getConversation(db, user.id); + + if (!existing) { + db.prepare( + `INSERT INTO conversations (user_id, chat_id, state, buffer_text, source, last_activity_at) + VALUES (?, ?, 'COLLECTING', ?, 'whatsapp_text', ?)`, + ).run(user.id, chatId, text, nowIso()); + return { kind: 'collecting', conversation: getConversation(db, user.id)! }; + } + + if (WAITING_FOR_ANSWER.includes(existing.state)) { + db.prepare('UPDATE conversations SET chat_id = ?, last_activity_at = ? WHERE id = ?').run( + chatId, + nowIso(), + existing.id, + ); + return { kind: 'answer', conversation: getConversation(db, user.id)! }; + } + + const buffer = existing.state === 'COLLECTING' ? `${existing.buffer_text}\n${text}` : text; + db.prepare( + `UPDATE conversations + SET state = 'COLLECTING', buffer_text = ?, chat_id = ?, source = 'whatsapp_text', last_activity_at = ? + WHERE id = ?`, + ).run(buffer, chatId, nowIso(), existing.id); + return { kind: 'collecting', conversation: getConversation(db, user.id)! }; +} diff --git a/test/fixtures/results/br111_2026-09-26_layout.txt b/test/fixtures/results/br111_2026-09-26_layout.txt new file mode 100644 index 0000000..fcac80f --- /dev/null +++ b/test/fixtures/results/br111_2026-09-26_layout.txt @@ -0,0 +1,419 @@ + KERALA STATE LOTTERIES - RESULT + www.statelottery.kerala.gov.in PHONE:- 0471-2305230 DIRECTOR:- 0471-2305193 + www.kerala.gov.in OFFICE:- 0471-2301740 EMAIL:- cru.dir.lotteries@kerala.gov.in + THIRUVONAM BUMPER LOTTERY LOTTERY NO.BR-111th DRAW held on:- 26/09/2026,2:00 PM + + AT GORKY BHAVAN, NEAR BAKERY JUNCTION, THIRUVANANTHAPURAM + + + 1st Prize Rs :300000000/- 1) TL 360615 (KANNUR) + + Cons Prize-Rs :500000/- TA 360615 TB 360615 TC 360615 TD 360615 TE 360615 + + TG 360615 TH 360615 TJ 360615 TK 360615 + + 2nd Prize Rs :10000000/- 1) TA 300530 (THRISSUR) + + 2) TA 957486 (CHITTUR) + + 3) TB 439962 (ERNAKULAM) + + 4) TB 642974 (ATTINGAL) + + 5) TC 810428 (PALAKKAD) + + 6) TC 952340 (VAIKKOM) + + 7) TD 279706 (PALAKKAD) + + 8) TD 305900 (KOTTAYAM) + + 9) TE 328611 (KAYAMKULAM) + + 10) TE 970611 (ATTINGAL) + + 11) TG 355002 (ADIMALY) + + 12) TG 805847 (KOTTAYAM) + + 13) TH 192282 (KOZHIKKODE) + + 14) TH 902375 (ERNAKULAM) + + 15) TJ 150990 (ALAPPUZHA) + + 16) TJ 978616 (THRISSUR) + + 17) TK 375661 (KOLLAM) + + 18) TK 976006 (KAYAMKULAM) + + 19) TL 792519 (PALAKKAD) + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 1 + 20) TL 888018 (ALAPPUZHA) + + 3rd Prize Rs :2500000/- 1) TA 190509 (KOZHIKKODE) + + 2) TA 398143 (CHITTUR) + + 3) TB 342811 (PAYYANUR) + + 4) TB 884286 (THIRUVANANTHAPURAM) + + 5) TC 429837 (THIRUVANANTHAPURAM) + + 6) TC 790518 (KAYAMKULAM) + + 7) TD 391100 (THRISSUR) + + 8) TD 539041 (CHITTUR) + + 9) TE 574804 (PALAKKAD) + + 10) TE 683330 (PATTAMBI) + + 11) TG 132958 (CHITTUR) + + 12) TG 971190 (ATTINGAL) + + 13) TH 190728 (KOZHIKKODE) + + 14) TH 838226 (MALAPPURAM) + + 15) TJ 484920 (PUNALUR) + + 16) TJ 857456 (PATTAMBI) + + 17) TK 117233 (KAYAMKULAM) + + 18) TK 540538 (MOOVATTUPUZHA) + + 19) TL 214267 (KANNUR) + + 20) TL 340163 (CHITTUR) + + 4th Prize Rs :500000/- 1) TA 369775 (THIRUVANANTHAPURAM) + + 2) TB 558547 (IRINJALAKUDA) + + 3) TC 278059 (PAYYANUR) + + 4) TD 905827 (ERNAKULAM) + + 5) TE 594498 (ERNAKULAM) + + 6) TG 136478 (THRISSUR) + + 7) TH 410444 (PALAKKAD) + + 8) TJ 308307 (KOZHIKKODE) + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 2 + 9) TK 757590 (ERNAKULAM) + + 10) TL 206074 (ERNAKULAM) + + 5th Prize Rs :200000/- 1) TA 848952 (ATTINGAL) + + 2) TB 111460 (THIRUR) + + 3) TC 595357 (ERNAKULAM) + + 4) TD 339006 (VADAKARA) + + 5) TE 708462 (THRISSUR) + + 6) TG 120016 (KANHANGAD) + + 7) TH 856481 (PATTAMBI) + + 8) TJ 184151 (KOTTAYAM) + + 9) TK 826615 (PALAKKAD) + + 10) TL 914962 (KANNUR) + + FOR THE TICKETS ENDING WITH THE FOLLOWING NUMBERS + + 6th Prize-Rs :5000/- 0178 0239 0306 0422 0785 + + 0870 1511 1546 1553 1697 + + 1715 1855 1887 2078 2229 + + 2611 2702 2808 3009 3018 + + 3367 3452 3596 3671 4151 + + 4272 4340 4354 4417 4432 + + 4441 4801 5229 5307 5365 + + 5375 5486 5592 5633 6012 + + 6236 6294 6577 6673 7209 + + 7223 7343 7400 7413 7547 + + 7975 7977 8093 8313 8397 + + 8519 8582 8611 9405 9419 + + 7th Prize-Rs :2000/- 0344 0376 0583 0619 0665 + + 0891 0979 1126 1443 1567 + + 1650 1804 1957 2010 2130 + + 2259 2327 2460 2672 2742 + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 3 + 2793 2825 2891 3133 3333 + + 3467 3772 3776 4062 4199 + + 4215 4480 4596 4701 4703 + + 4729 4900 5254 5293 5338 + + 5340 5423 5426 5431 5497 + + 5547 5571 5693 5784 5806 + + 5836 5941 5977 6480 6532 + + 6626 6716 6859 7117 7188 + + 7190 7274 7339 7727 7790 + + 7827 7913 7945 7973 8000 + + 8048 8160 8375 8475 8483 + + 8772 9014 9058 9059 9070 + + 9097 9180 9238 9359 9368 + + 9541 9598 9787 9832 9883 + + 8th Prize-Rs :1000/- 0072 0079 0218 0250 0355 + + 0374 0514 0528 0555 0629 + + 0651 0752 0753 0780 0868 + + 0872 0880 0946 0966 1017 + + 1034 1064 1223 1285 1315 + + 1321 1377 1409 1483 1534 + + 1652 1706 1918 1932 1961 + + 1984 2013 2135 2136 2221 + + 2414 2584 2733 2834 3004 + + 3057 3061 3106 3137 3190 + + 3205 3346 3414 3426 3465 + + 3778 3815 3859 3970 3990 + + 4336 4342 4660 4699 4995 + + 5013 5081 5096 5145 5257 + + 5278 5314 5464 5493 5526 + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 4 + 5565 5620 5946 6121 6186 + + 6203 6411 6457 6465 6466 + + 6527 6545 6583 6829 6949 + + 6973 7019 7105 7136 7149 + + 7200 7207 7208 7225 7226 + + 7247 7381 7481 7520 7555 + + 7592 7614 7664 7667 7766 + + 7808 7946 8064 8080 8101 + + 8119 8202 8272 8315 8384 + + 8659 8701 8855 8874 8936 + + 9018 9063 9129 9173 9206 + + 9323 9352 9356 9414 9588 + + 9616 9811 9970 + + 9th Prize-Rs :500/- 0009 0036 0045 0049 0061 + + 0087 0142 0205 0225 0230 + + 0283 0289 0303 0372 0391 + + 0444 0494 0525 0552 0562 + + 0589 0608 0675 0689 0697 + + 0714 0735 0737 0749 0777 + + 0801 0813 0818 0853 0856 + + 0945 0993 1012 1047 1049 + + 1070 1133 1145 1150 1169 + + 1170 1184 1204 1238 1246 + + 1350 1376 1386 1390 1470 + + 1474 1551 1552 1572 1581 + + 1624 1640 1643 1676 1795 + + 1883 1925 1989 2047 2059 + + 2072 2102 2104 2122 2245 + + 2279 2313 2338 2383 2421 + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 5 + 2465 2478 2543 2565 2567 + + 2653 2716 2744 2758 2765 + + 2773 2816 2821 2843 2864 + + 2875 2922 2929 2941 2944 + + 2950 2955 3023 3029 3047 + + 3070 3156 3162 3285 3297 + + 3352 3393 3413 3417 3459 + + 3464 3513 3548 3570 3580 + + 3692 3693 3740 3840 3843 + + 3866 3961 4021 4030 4039 + + 4170 4209 4213 4317 4377 + + 4415 4423 4456 4457 4508 + + 4570 4571 4600 4609 4615 + + 4628 4717 4723 4741 4765 + + 4789 4837 4881 4902 4910 + + 4911 4950 4991 5077 5087 + + 5088 5154 5174 5198 5212 + + 5233 5236 5253 5282 5324 + + 5330 5335 5337 5341 5363 + + 5368 5429 5430 5457 5533 + + 5539 5711 5735 5754 5805 + + 5830 5853 5857 5907 5970 + + 5974 5998 6019 6033 6129 + + 6158 6197 6207 6231 6276 + + 6297 6338 6346 6377 6427 + + 6487 6507 6554 6556 6571 + + 6603 6625 6676 6685 6781 + + 6783 6793 6891 6971 7014 + + 7028 7032 7089 7229 7296 + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 6 + 7329 7374 7392 7461 7501 + + 7511 7518 7534 7541 7586 + + 7588 7600 7607 7699 7823 + + 7962 8030 8058 8078 8091 + + 8092 8132 8133 8135 8139 + + 8234 8251 8319 8332 8346 + + 8453 8551 8590 8618 8624 + + 8627 8631 8638 8695 8727 + + 8745 8748 8796 8831 8833 + + 8834 8926 8968 8972 9086 + + 9096 9101 9157 9179 9182 + + 9210 9255 9262 9272 9283 + + 9291 9319 9383 9502 9518 + + 9521 9539 9560 9593 9627 + + 9678 9706 9721 9722 9762 + + 9784 9841 9908 9930 9931 + + 9952 + + + + The prize winners are advised to verify the winning numbers with the results published in the Kerala + Government Gazette and surrender the winning tickets within 90 days. + SAJU KUMAR.P + Deputy Director + Directorate Of State Lotteries ,Thiruvananthapuram + + + + + This is a digitally signed document, Authenticity may be + verified through https://statelottery.kerala.gov.in/ + + + + +26/09/2026 17:26:29 Modernization & IT Software Division : Department of State Lotteries Page 7 + \ No newline at end of file diff --git a/test/fixtures/results/bt78_2026-11-02_raw.txt b/test/fixtures/results/bt78_2026-11-02_raw.txt new file mode 100644 index 0000000..8b19ad2 --- /dev/null +++ b/test/fixtures/results/bt78_2026-11-02_raw.txt @@ -0,0 +1,110 @@ +KERALA STATE LOTTERIES - RESULT +www.statelottery.kerala.gov.in PHONE:- 0471-2305230 DIRECTOR:- 0471-2305193 +www.kerala.gov.in OFFICE:- 0471-2301740 EMAIL:- cru.dir.lotteries@kerala.gov.in +BHAGYATHARA LOTTERY NO.BT-78th DRAW held on:- 02/11/2026,3:00 PM +AT GORKY BHAVAN, NEAR BAKERY JUNCTION, THIRUVANANTHAPURAM +1st Prize Rs :10000000/- 1) BR 399494 (KANNUR) +Cons Prize-Rs :5000/- BN 399494 BO 399494 BP 399494 BS 399494 BT 399494 +BU 399494 BV 399494 BW 399494 BX 399494 BY 399494 +BZ 399494 +2nd Prize Rs :3000000/- 1) BT 208354 (KOZHIKKODE) +3rd Prize Rs :500000/- 1) BT 435156 (THRISSUR) +FOR THE TICKETS ENDING WITH THE FOLLOWING NUMBERS +4th Prize-Rs :5000/- 0619 1710 1999 2590 3423 +3556 3985 4800 5753 6761 +7037 7174 7350 7954 8972 +9130 9203 9682 9951 +5th Prize-Rs :2000/- 4114 5029 5955 6339 6677 +8045 +6th Prize-Rs :1000/- 0119 0614 1619 2118 2621 +3161 3461 3523 3657 4159 +4262 4553 5110 5636 6868 +6956 7049 7062 7188 7246 +8059 8951 9079 9321 9433 +7th Prize-Rs :500/- 0113 0131 0139 0189 0386 +0446 0579 0603 0639 0649 +0795 0910 0912 0995 1135 +1249 1511 1744 1800 2137 +Page 1 +Modernization & IT Software Division : Department of State Lotteries +02/11/2026 16:36:38 + 2180 2230 2309 2420 2757 +3214 3300 3374 3390 3631 +3883 3956 3994 4065 4296 +4540 4782 4822 5428 5721 +5779 5971 6060 6084 6099 +6353 6429 6515 6561 6726 +6862 7048 7054 7265 7365 +7597 8040 8049 8055 8278 +8413 8511 8549 8587 8664 +8777 8827 8918 8939 9072 +9265 9352 9699 9705 9801 +9909 +8th Prize-Rs :200/- 0077 0317 0324 0399 0468 +0527 0528 0613 0681 0756 +0817 0885 0970 0972 1019 +1150 1279 1309 1612 1684 +1834 1961 2144 2290 2291 +2297 2298 2363 2367 2695 +2737 2858 2968 3210 3287 +3307 3394 3588 3625 3918 +3973 3988 4014 4017 4106 +4140 4141 4266 4286 4294 +4320 4481 4483 4517 5070 +5649 5664 5822 5843 6012 +6138 6208 6355 6536 6549 +6937 6996 7090 7136 7196 +7214 7276 7399 7427 7948 +8044 8150 8152 8175 8209 +8225 8340 8443 8562 8581 +Page 2 +Modernization & IT Software Division : Department of State Lotteries +02/11/2026 16:36:38 + 8608 8828 8856 9084 9111 +9691 9695 9851 9903 +9th Prize-Rs :100/- 0028 0170 0377 0412 0426 +0634 0718 0787 0882 0914 +0965 1013 1076 1171 1242 +1427 1509 1525 1568 1598 +1734 1762 1863 1876 1888 +1966 2277 2373 2543 2769 +2835 2882 2951 2981 3073 +3101 3163 3221 3420 3444 +3683 3748 3818 3821 3832 +3898 3945 4025 4034 4163 +4201 4283 4388 4524 4575 +4667 4768 4823 4875 5018 +5094 5157 5178 5189 5211 +5359 5533 5536 5805 5846 +5871 5899 6057 6063 6090 +6101 6370 6445 6459 6521 +6526 6600 6617 6650 6652 +6675 6723 6752 6804 6808 +6929 6938 7063 7216 7221 +7269 7273 7279 7472 7508 +7611 7672 7692 7699 7800 +7999 8408 8593 8695 8733 +8773 8846 8854 8862 8904 +8917 8946 8954 8962 9025 +9032 9101 9114 9144 9231 +9261 9288 9296 9344 9404 +9444 9450 9480 9520 9526 +Page 3 +Modernization & IT Software Division : Department of State Lotteries +02/11/2026 16:36:38 + 9558 9593 9598 9673 9729 +9768 9782 9976 9979 +The prize winners are advised to verify the winning numbers with the results published in the Kerala +Government Gazette and surrender the winning tickets within 90 days. +SAJU KUMAR.P +Deputy Director +Next BHAGYATHARA Draw will be held on 09/11/2026 at +GORKY BHAVAN, NEAR BAKERY JUNCTION, +THIRUVANANTHAPURAM +Directorate Of State Lotteries ,Thiruvananthapuram +This is a digitally signed document, Authenticity may be +verified through https://statelottery.kerala.gov.in/ +Page 4 +Modernization & IT Software Division : Department of State Lotteries +02/11/2026 16:36:38 + \ No newline at end of file diff --git a/test/fixtures/results/dl73_2026-10-14_layout.txt b/test/fixtures/results/dl73_2026-10-14_layout.txt new file mode 100644 index 0000000..4b5d30f --- /dev/null +++ b/test/fixtures/results/dl73_2026-10-14_layout.txt @@ -0,0 +1,205 @@ + KERALA STATE LOTTERIES - RESULT + www.statelottery.kerala.gov.in PHONE:- 0471-2305230 DIRECTOR:- 0471-2305193 + www.kerala.gov.in OFFICE:- 0471-2301740 EMAIL:- cru.dir.lotteries@kerala.gov.in + DHANALEKSHMI LOTTERY NO.DL-73rd DRAW held on:- 14/10/2026,3:00 PM + + AT GORKY BHAVAN, NEAR BAKERY JUNCTION, THIRUVANANTHAPURAM + + + 1st Prize Rs :10000000/- 1) DM 669350 (ADIMALY) + + Cons Prize-Rs :5000/- DA 669350 DB 669350 DC 669350 DD 669350 DE 669350 + + DF 669350 DG 669350 DH 669350 DJ 669350 DK 669350 + + DL 669350 + + 2nd Prize Rs :3000000/- 1) DL 508275 (IRINJALAKUDA) + + 3rd Prize Rs :500000/- 1) DC 261852 (ERNAKULAM) + + FOR THE TICKETS ENDING WITH THE FOLLOWING NUMBERS + + 4th Prize-Rs :5000/- 0356 0558 0837 1103 1219 + + 2105 2185 2653 2681 2783 + + 2793 3655 4194 6230 8155 + + 8806 8816 9229 9365 + + 5th Prize-Rs :2000/- 2390 4738 6480 7475 7881 + + 9693 + + 6th Prize-Rs :1000/- 0221 1068 1293 1907 2216 + + 2412 2803 2905 3351 3584 + + 3747 3868 4163 6316 6394 + + 7413 7801 7988 8676 8711 + + 8870 8985 9192 9707 9852 + + 7th Prize-Rs :500/- 0076 0100 0169 0245 0372 + + 0397 0647 0790 0862 0872 + + 0873 1169 1511 1543 1663 + + 1919 2292 2724 2894 3011 + + + + +14/10/2026 16:31:45 Modernization & IT Software Division : Department of State Lotteries Page 1 + 3107 3142 3342 3357 3394 + + 3615 3748 3797 3813 3982 + + 4162 4219 4482 4506 4652 + + 4795 4855 4902 4998 5082 + + 5169 5184 5344 5625 5713 + + 5726 5916 6082 6450 6624 + + 7073 7117 7203 7265 7282 + + 7444 7559 7611 7619 7730 + + 7840 8009 8058 8112 8194 + + 8555 8772 8901 8917 8961 + + 9105 9155 9465 9500 9845 + + 9874 + + 8th Prize-Rs :200/- 0148 0227 0386 0394 0420 + + 0494 0505 0587 0601 1057 + + 1150 1190 1282 1415 1500 + + 1504 1537 1685 1706 1750 + + 1849 2004 2259 2299 2312 + + 2456 2602 2798 3061 3070 + + 3089 3223 3295 3303 3435 + + 3501 3612 3760 3850 3924 + + 3933 3992 4251 4323 4413 + + 4432 4447 4449 4454 4532 + + 4648 4657 4710 4866 5022 + + 5188 5212 5225 5229 5514 + + 5773 5884 5939 6099 6132 + + 6215 6391 6457 6546 6920 + + 7022 7221 7280 7283 7325 + + 7856 8212 8412 8431 8545 + + 8680 8708 8885 8974 9076 + + + + +14/10/2026 16:31:45 Modernization & IT Software Division : Department of State Lotteries Page 2 + 9121 9201 9276 9358 9403 + + 9609 9611 9641 9724 9828 + + 9940 + + 9th Prize-Rs :100/- 0125 0153 0346 0369 0382 + + 0459 0695 0827 0850 1155 + + 1279 1295 1301 1372 1418 + + 1476 1513 1624 1655 1942 + + 1953 1973 2155 2156 2180 + + 2235 2421 2520 2533 2645 + + 2694 2775 2933 2943 2973 + + 2990 3077 3146 3160 3168 + + 3207 3314 3341 3590 3625 + + 3663 3717 3842 3936 3939 + + 3964 4015 4029 4170 4237 + + 4253 4295 4339 4390 4420 + + 4438 4499 4526 4621 4846 + + 4848 5159 5166 5177 5178 + + 5291 5319 5323 5332 5509 + + 5695 5772 5794 5821 5841 + + 5854 5866 5873 6070 6159 + + 6226 6269 6302 6311 6312 + + 6326 6566 6588 6599 6616 + + 6656 6830 6869 6892 7024 + + 7076 7126 7243 7247 7272 + + 7318 7337 7343 7519 7523 + + 7556 7568 7607 7616 7687 + + 7761 7796 7826 7869 7876 + + 7898 7907 8024 8067 8090 + + 8697 8945 9175 9186 9194 + + + + +14/10/2026 16:31:45 Modernization & IT Software Division : Department of State Lotteries Page 3 + 9195 9199 9310 9437 9561 + + 9620 9709 9759 + + + + The prize winners are advised to verify the winning numbers with the results published in the Kerala + Government Gazette and surrender the winning tickets within 90 days. + SAJU KUMAR.P + Deputy Director + Next DHANALEKSHMI Draw will be held on 21/10/2026 Directorate Of State Lotteries ,Thiruvananthapuram + at GORKY BHAVAN, NEAR BAKERY JUNCTION, + THIRUVANANTHAPURAM + + + + + This is a digitally signed document, Authenticity may be + verified through https://statelottery.kerala.gov.in/ + + + + +14/10/2026 16:31:45 Modernization & IT Software Division : Department of State Lotteries Page 4 + \ No newline at end of file diff --git a/test/fixtures/results/kn645_2026-10-15_layout.txt b/test/fixtures/results/kn645_2026-10-15_layout.txt new file mode 100644 index 0000000..0ee166e --- /dev/null +++ b/test/fixtures/results/kn645_2026-10-15_layout.txt @@ -0,0 +1,207 @@ + KERALA STATE LOTTERIES - RESULT + www.statelottery.kerala.gov.in PHONE:- 0471-2305230 DIRECTOR:- 0471-2305193 + www.kerala.gov.in OFFICE:- 0471-2301740 EMAIL:- cru.dir.lotteries@kerala.gov.in + KARUNYA PLUS LOTTERY NO.KN-645th DRAW held on:- 15/10/2026,3:00 PM + + AT GORKY BHAVAN, NEAR BAKERY JUNCTION, THIRUVANANTHAPURAM + + + 1st Prize Rs :10000000/- 1) PF 502881 (KANNUR) + + Cons Prize-Rs :5000/- PA 502881 PB 502881 PC 502881 PD 502881 PE 502881 + + PG 502881 PH 502881 PJ 502881 PK 502881 PL 502881 + + PM 502881 + + 2nd Prize Rs :3000000/- 1) PA 557675 (KOZHIKKODE) + + 3rd Prize Rs :500000/- 1) PF 541391 (KOTTAYAM) + + FOR THE TICKETS ENDING WITH THE FOLLOWING NUMBERS + + 4th Prize-Rs :5000/- 0431 2596 3054 3315 3979 + + 4531 4647 4739 4920 5371 + + 5482 5655 6679 6693 7071 + + 7165 8360 9258 9715 + + 5th Prize-Rs :2000/- 0830 2286 3249 6312 6314 + + 8389 + + 6th Prize-Rs :1000/- 0865 1260 1346 1699 2043 + + 2318 2865 3233 3432 3459 + + 3655 3915 4303 4709 5050 + + 5143 5838 6359 6793 7239 + + 7530 8112 8271 8567 8780 + + 7th Prize-Rs :500/- 0330 0562 0619 0915 1362 + + 1635 1673 1677 1907 1948 + + 2042 2133 2356 2359 2469 + + 2577 2748 2812 2917 2961 + + + + +15/10/2026 16:38:25 Modernization & IT Software Division : Department of State Lotteries Page 1 + 2983 3034 3037 3149 3191 + + 3573 3781 3853 4022 4042 + + 4049 4112 4209 4381 4477 + + 4628 4850 4928 5021 5095 + + 5264 5302 5668 5874 6075 + + 6116 6187 6390 6513 6531 + + 6732 6999 7157 7350 7395 + + 7500 7551 7596 7666 8001 + + 8027 8345 8370 8427 8507 + + 8514 8588 9039 9202 9207 + + 9662 9705 9830 9843 9876 + + 9952 + + 8th Prize-Rs :200/- 0059 0124 0274 0334 0519 + + 0560 0701 0765 0772 0800 + + 0827 1070 1089 1570 1615 + + 1644 1698 1888 2007 2229 + + 2297 2351 2423 2452 2654 + + 2766 2969 3247 3264 3399 + + 3575 3611 3836 3874 4388 + + 4420 4610 4640 4811 4852 + + 5293 5609 5615 5638 6080 + + 6114 6263 6332 6606 6746 + + 6809 7091 7098 7127 7197 + + 7226 7268 7468 7546 7575 + + 7623 7727 7746 7849 7864 + + 7955 7995 8066 8161 8296 + + 8371 8682 8813 8825 8917 + + 9009 9138 9214 9339 9367 + + 9554 9605 9606 9669 + + + + +15/10/2026 16:38:25 Modernization & IT Software Division : Department of State Lotteries Page 2 + 9th Prize-Rs :100/- 0050 0195 0335 0577 0611 + + 0691 0696 0741 0743 0874 + + 1072 1201 1208 1252 1465 + + 1600 1676 1685 1709 1773 + + 1781 2096 2127 2163 2215 + + 2243 2475 2493 2549 2607 + + 2762 2841 2927 2966 2975 + + 3029 3056 3062 3067 3144 + + 3257 3333 3361 3412 3454 + + 3461 3499 3531 3542 3584 + + 3595 3597 3640 3700 3703 + + 3738 3744 3806 3822 3910 + + 3947 4001 4059 4160 4219 + + 4289 4306 4478 4513 4537 + + 4549 4582 4659 4696 4712 + + 4749 4822 4984 5000 5059 + + 5071 5085 5104 5130 5159 + + 5467 5529 5798 5814 5892 + + 5925 5930 5942 6015 6124 + + 6200 6317 6355 6552 6608 + + 6647 6673 6731 6749 6935 + + 7003 7021 7030 7152 7353 + + 7390 7391 7586 7631 7662 + + 7757 7832 7922 7939 7951 + + 7989 8014 8044 8315 8355 + + 8358 8397 8463 8470 8487 + + 8532 8555 8612 8631 8641 + + 8708 8721 8755 8797 8805 + + 8886 9006 9015 9122 9206 + + + + +15/10/2026 16:38:25 Modernization & IT Software Division : Department of State Lotteries Page 3 + 9242 9245 9439 9531 9612 + + 9649 9650 9697 9817 9841 + + 9971 + + + + The prize winners are advised to verify the winning numbers with the results published in the Kerala + Government Gazette and surrender the winning tickets within 90 days. + SAJU KUMAR.P + Deputy Director + Next KARUNYA PLUS Draw will be held on 22/10/2026 at Directorate Of State Lotteries ,Thiruvananthapuram + GORKY BHAVAN, NEAR BAKERY JUNCTION, + THIRUVANANTHAPURAM + + + + + This is a digitally signed document, Authenticity may be + verified through https://statelottery.kerala.gov.in/ + + + + +15/10/2026 16:38:25 Modernization & IT Software Division : Department of State Lotteries Page 4 + \ No newline at end of file diff --git a/test/fixtures/results/sk73_2026-10-16.pdf b/test/fixtures/results/sk73_2026-10-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8cac557e1b977a5405603d3afa926e76dc05482d GIT binary patch literal 87181 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 XkXvBTk9Z&!=^Gd;YHN?1YV&^phpC|; literal 0 HcmV?d00001 diff --git a/test/fixtures/results/sk73_2026-10-16_layout.txt b/test/fixtures/results/sk73_2026-10-16_layout.txt new file mode 100644 index 0000000..a871490 --- /dev/null +++ b/test/fixtures/results/sk73_2026-10-16_layout.txt @@ -0,0 +1,200 @@ + KERALA STATE LOTTERIES - RESULT + www.statelottery.kerala.gov.in PHONE:- 0471-2305230 DIRECTOR:- 0471-2305193 + www.kerala.gov.in OFFICE:- 0471-2301740 EMAIL:- cru.dir.lotteries@kerala.gov.in + SUVARNA KERALAM LOTTERY NO.SK-73rd DRAW held on:- 16/10/2026,3:00 PM + + AT GORKY BHAVAN, NEAR BAKERY JUNCTION, THIRUVANANTHAPURAM + + + 1st Prize Rs :10000000/- 1) RA 404595 (ERNAKULAM) + + Cons Prize-Rs :5000/- RB 404595 RC 404595 RD 404595 RE 404595 RF 404595 + + RG 404595 RH 404595 RJ 404595 RK 404595 RL 404595 + + RM 404595 + + 2nd Prize Rs :3000000/- 1) RH 448539 (ADOOR) + + 3rd Prize Rs :500000/- 1) RG 847845 (ALAPPUZHA) + + FOR THE TICKETS ENDING WITH THE FOLLOWING NUMBERS + + 4th Prize-Rs :5000/- 0356 0502 0647 1081 1161 + + 1255 3923 4209 4528 4787 + + 4857 7043 7319 7400 8007 + + 8121 8123 8967 9139 + + 5th Prize-Rs :2000/- 1241 1932 3134 4585 4926 + + 5857 + + 6th Prize-Rs :1000/- 0415 0572 0732 0779 1205 + + 2306 3003 3704 3723 3752 + + 3921 5488 5668 5709 5861 + + 5936 6138 6140 6924 7130 + + 7382 7393 7894 8204 9245 + + 7th Prize-Rs :500/- 0267 0310 0389 0532 0615 + + 0660 0979 0989 1155 1182 + + 1617 1795 1804 1842 1851 + + 2009 2044 2109 2217 2289 + + + + +16/10/2026 16:37:08 Modernization & IT Software Division : Department of State Lotteries Page 1 + 2340 2415 2832 3083 3101 + + 3219 3345 3790 3825 3845 + + 3990 4021 4446 4462 4550 + + 4753 4825 4850 5008 5124 + + 5627 5862 6388 6437 6483 + + 6582 6666 6678 6689 6831 + + 6882 6925 6978 7025 7092 + + 7118 7300 7426 7462 7498 + + 7507 7741 7906 8307 8373 + + 8394 8484 8495 8541 8558 + + 8944 9443 9557 9820 9860 + + 9994 + + 8th Prize-Rs :200/- 0061 0452 0623 0651 0772 + + 0950 1116 1146 1160 1247 + + 1285 1295 1531 1618 1635 + + 1690 1904 2005 2166 2301 + + 2417 2544 2613 2816 2863 + + 3016 3203 3246 3255 3415 + + 3430 3691 3701 3727 3767 + + 3927 4023 4282 4322 4503 + + 4755 4757 4835 4942 4993 + + 5063 5200 5219 5247 5367 + + 5373 5407 5477 5527 5634 + + 5640 5644 5859 5866 6127 + + 6135 6139 6227 6383 6443 + + 6992 7005 7031 7333 7341 + + 7362 7364 7637 7854 7959 + + 8207 8240 8270 8374 8514 + + 8527 8587 8628 8763 8982 + + + + +16/10/2026 16:37:09 Modernization & IT Software Division : Department of State Lotteries Page 2 + 9341 9350 9512 9538 9854 + + 9882 9953 + + 9th Prize-Rs :100/- 0006 0022 0038 0056 0107 + + 0193 0195 0254 0268 0273 + + 0374 0521 0610 0716 0794 + + 0813 0858 1048 1163 1229 + + 1427 1433 1440 1508 1510 + + 1550 1558 1679 1692 2017 + + 2143 2180 2200 2271 2311 + + 2324 2365 2480 2487 2494 + + 2566 2654 2707 2752 2794 + + 2847 2878 2991 3010 3064 + + 3089 3129 3482 3488 3644 + + 3803 3838 3867 3924 4031 + + 4053 4246 4251 4421 4535 + + 4577 4695 4796 4836 4891 + + 4921 5071 5149 5254 5267 + + 5339 5379 5619 5703 5708 + + 6164 6271 6314 6407 6410 + + 6557 6573 6650 6727 6779 + + 6784 6797 6885 6899 6917 + + 6981 6991 7059 7314 7342 + + 7383 7483 7512 7533 7601 + + 7727 7729 7877 7924 7931 + + 8052 8057 8058 8074 8254 + + 8261 8289 8364 8382 8694 + + 8727 8805 8901 9057 9159 + + 9251 9329 9363 9390 9446 + + 9524 9611 9621 9635 9649 + + + + +16/10/2026 16:37:09 Modernization & IT Software Division : Department of State Lotteries Page 3 + 9652 9749 9784 9859 9867 + + 9889 9896 9919 9925 + + + + The prize winners are advised to verify the winning numbers with the results published in the Kerala + Government Gazette and surrender the winning tickets within 90 days. + Sd/- + SHAJU P A + Joint Director + Next SUVARNA KERALAM Draw will be held on Directorate Of State Lotteries ,Thiruvananthapuram + 23/10/2026 at GORKY BHAVAN, NEAR BAKERY + JUNCTION, THIRUVANANTHAPURAM + + + + +16/10/2026 16:37:09 Modernization & IT Software Division : Department of State Lotteries Page 4 + \ No newline at end of file diff --git a/test/fixtures/results/sm79_2026-11-01_raw.txt b/test/fixtures/results/sm79_2026-11-01_raw.txt new file mode 100644 index 0000000..179b742 --- /dev/null +++ b/test/fixtures/results/sm79_2026-11-01_raw.txt @@ -0,0 +1,111 @@ +KERALA STATE LOTTERIES - RESULT +www.statelottery.kerala.gov.in PHONE:- 0471-2305230 DIRECTOR:- 0471-2305193 +www.kerala.gov.in OFFICE:- 0471-2301740 EMAIL:- cru.dir.lotteries@kerala.gov.in +SAMRUDHI LOTTERY NO.SM-79th DRAW held on:- 01/11/2026,3:00 PM +AT GORKY BHAVAN, NEAR BAKERY JUNCTION, THIRUVANANTHAPURAM +1st Prize Rs :10000000/- 1) MA 847261 (KANHANGAD) +Cons Prize-Rs :5000/- MB 847261 MC 847261 MD 847261 ME 847261 MF 847261 +MG 847261 MH 847261 MJ 847261 MK 847261 ML 847261 +MM 847261 +2nd Prize Rs :2500000/- 1) ME 352847 (ALAPPUZHA) +3rd Prize Rs :500000/- 1) MK 247531 (PATHANAMTHITTA) +FOR THE TICKETS ENDING WITH THE FOLLOWING NUMBERS +4th Prize-Rs :5000/- 0141 0979 1500 1549 2299 +4452 4669 5230 5246 6037 +6339 6863 7191 7268 7670 +8127 8616 9281 9692 +5th Prize-Rs :2000/- 3645 4628 7130 7474 8087 +8424 +6th Prize-Rs :1000/- 0271 0900 1159 1211 1790 +3500 4063 4409 4438 4770 +4886 5264 5546 6046 6125 +6653 6942 6947 7078 7239 +7294 7380 7591 8101 8697 +7th Prize-Rs :500/- 0028 0151 0249 0252 0296 +0354 0415 0497 1167 1221 +1234 1334 1348 1501 1771 +1981 2012 2298 2436 2812 +Page 1 +Modernization & IT Software Division : Department of State Lotteries +01/11/2026 16:40:24 + 2846 2852 3385 3432 3798 +3873 4107 4168 4326 4439 +4461 4650 4806 4934 4962 +5248 5306 5463 5697 5791 +5804 5843 6050 6091 6241 +6485 6491 6622 6673 6697 +6778 6851 7143 7174 7205 +7253 7337 7453 7531 7647 +7752 8105 8209 8271 8419 +8420 8744 9026 9049 9084 +9239 9450 9467 9703 9879 +9945 +8th Prize-Rs :200/- 0200 0268 0613 0655 0672 +0853 1239 1244 1795 1833 +1866 1979 2021 2130 2159 +2454 2462 2493 2605 2933 +3097 3202 3357 3374 3382 +3391 3445 3506 3511 3817 +3829 4075 4220 4338 4436 +4543 4592 4647 4687 4732 +4932 5058 5261 5380 5467 +5479 5522 5574 5580 5650 +5655 5912 6049 6070 6183 +6270 6317 6336 6468 6762 +7019 7023 7050 7109 7192 +7324 7465 7481 7492 7523 +7558 7667 7815 7871 7969 +8081 8152 8224 8299 8313 +8460 8775 8826 9098 9159 +Page 2 +Modernization & IT Software Division : Department of State Lotteries +01/11/2026 16:40:24 + 9188 9247 9289 9318 9333 +9714 9833 +9th Prize-Rs :100/- 0017 0041 0043 0152 0197 +0205 0280 0360 0369 0372 +0513 0563 0618 0823 0859 +0941 1028 1039 1166 1172 +1296 1394 1443 1511 1514 +1712 1779 1807 1812 1893 +1939 1941 1958 2008 2045 +2062 2152 2214 2285 2396 +2459 2461 2492 2532 2592 +2996 3029 3109 3641 3710 +3713 3715 3727 3729 3737 +3845 3999 4015 4059 4071 +4131 4146 4206 4247 4281 +4521 4556 4818 4904 4950 +5025 5032 5052 5091 5125 +5198 5210 5231 5252 5256 +5379 5449 5510 5551 5590 +5664 5667 5676 5741 5807 +5820 5941 6077 6093 6097 +6109 6124 6269 6350 6456 +6499 6526 6573 6631 6658 +6678 6690 6776 6825 6897 +7046 7284 7334 7362 7485 +7524 7607 7618 7635 7642 +7682 7712 7934 7940 7981 +8007 8085 8326 8336 8394 +8440 8568 8576 8590 8653 +Page 3 +Modernization & IT Software Division : Department of State Lotteries +01/11/2026 16:40:24 + 8674 8700 8758 8803 8809 +9250 9389 9616 9627 9643 +9834 9871 9888 9946 9976 +The prize winners are advised to verify the winning numbers with the results published in the Kerala +Government Gazette and surrender the winning tickets within 90 days. +SAJU KUMAR.P +Deputy Director +Next SAMRUDHI Draw will be held on 08/11/2026 at +GORKY BHAVAN, NEAR BAKERY JUNCTION, +THIRUVANANTHAPURAM +Directorate Of State Lotteries ,Thiruvananthapuram +This is a digitally signed document, Authenticity may be +verified through https://statelottery.kerala.gov.in/ +Page 4 +Modernization & IT Software Division : Department of State Lotteries +01/11/2026 16:40:24 + \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..43aec6a --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/testing/**"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c215f69 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..96eb6ab --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});