commit 6851d66b38ed4f5ac6433045f01465f716f9d29a Author: Aswin Biju Date: Tue Nov 3 10:09:46 2026 +0530 feat: SRS backend: orders intake, results pipeline, user management Fastify + TypeScript + SQLite backend for a single Kerala lottery agency. 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 0000000..8cac557 Binary files /dev/null and b/test/fixtures/results/sk73_2026-10-16.pdf differ 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', + }, +});