feat: SRS backend: orders intake, results pipeline, user management
Fastify + TypeScript + SQLite backend for a single Kerala lottery agency.
This commit is contained in:
commit
6851d66b38
13
.env.example
Normal file
13
.env.example
Normal file
@ -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
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.log
|
||||
.DS_Store
|
||||
26
README.md
Normal file
26
README.md
Normal file
@ -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
|
||||
```
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
15
ecosystem.config.cjs
Normal file
15
ecosystem.config.cjs
Normal file
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
2763
package-lock.json
generated
Normal file
2763
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
5
scripts/copy-migrations.mjs
Normal file
5
scripts/copy-migrations.mjs
Normal file
@ -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');
|
||||
65
scripts/results.ts
Normal file
65
scripts/results.ts
Normal file
@ -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<number> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
86
scripts/user.ts
Normal file
86
scripts/user.ts
Normal file
@ -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<string | null>((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<number> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
44
src/app.ts
Normal file
44
src/app.ts
Normal file
@ -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;
|
||||
}
|
||||
166
src/cli/userWizard.test.ts
Normal file
166
src/cli/userWizard.test.ts
Normal file
@ -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<typeof makeDb>, 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');
|
||||
});
|
||||
});
|
||||
177
src/cli/userWizard.ts
Normal file
177
src/cli/userWizard.ts
Normal file
@ -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<string>;
|
||||
say(line?: string): void;
|
||||
}
|
||||
|
||||
type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
const MAX_TRIES = 5;
|
||||
|
||||
async function askUntil<T>(io: Io, question: string, parse: (answer: string) => Parsed<T>): Promise<T> {
|
||||
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<string> =>
|
||||
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<string> => {
|
||||
const phone = normalizePhone(a, cc);
|
||||
return phone ? { ok: true, value: phone } : { ok: false, error: phoneError };
|
||||
};
|
||||
|
||||
const parseOptionalPhone =
|
||||
(cc: string) =>
|
||||
(a: string): Parsed<string | null> => {
|
||||
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<number[]> => {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
23
src/config.test.ts
Normal file
23
src/config.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
45
src/config.ts
Normal file
45
src/config.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
16
src/db/index.ts
Normal file
16
src/db/index.ts
Normal file
@ -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;
|
||||
}
|
||||
125
src/db/migrate.test.ts
Normal file
125
src/db/migrate.test.ts
Normal file
@ -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<typeof makeDb>) => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
48
src/db/migrate.ts
Normal file
48
src/db/migrate.ts
Normal file
@ -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();
|
||||
}
|
||||
90
src/db/migrations/001_init.sql
Normal file
90
src/db/migrations/001_init.sql
Normal file
@ -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);
|
||||
42
src/parser/parseOrder.test.ts
Normal file
42
src/parser/parseOrder.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
5
src/parser/parseOrder.ts
Normal file
5
src/parser/parseOrder.ts
Normal file
@ -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');
|
||||
}
|
||||
28
src/parser/types.ts
Normal file
28
src/parser/types.ts
Normal file
@ -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[];
|
||||
}
|
||||
122
src/results/job.test.ts
Normal file
122
src/results/job.test.ts
Normal file
@ -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) =>
|
||||
`<tr><td>${name}-${d}/${m}/${y} (${code}-${no})</td><td>${d}-${m}-${y}</td><td><a data-item-id="${item}">Download</a></td>`;
|
||||
const LISTING =
|
||||
'<table>' +
|
||||
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') +
|
||||
'</table>';
|
||||
|
||||
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('<html></html>')) as typeof fetch;
|
||||
await expect(runResultsJob({ db, today: '2026-10-16', fetchImpl: empty })).rejects.toThrow(/no draws/);
|
||||
});
|
||||
});
|
||||
97
src/results/job.ts
Normal file
97
src/results/job.ts
Normal file
@ -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<string> = extractPdfText,
|
||||
): Promise<ProcessedPdf> {
|
||||
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<Buffer | null>;
|
||||
extract?: (pdf: Buffer) => Promise<string>;
|
||||
}
|
||||
|
||||
export async function runResultsJob(opts: JobOptions): Promise<DrawReport[]> {
|
||||
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;
|
||||
}
|
||||
103
src/results/parseResult.test.ts
Normal file
103
src/results/parseResult.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
215
src/results/parseResult.ts
Normal file
215
src/results/parseResult.ts
Normal file
@ -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 = /(?<![A-Z])([A-Z]{2})[ \t]{0,3}(\d{6})(?!\d)/g;
|
||||
// "/" and ":" guards keep dates (16/10/2026) and "Rs :5000" from being read as endings.
|
||||
const ENDING = /(?<![\d,/:])(\d{4})(?![\d,/])/g;
|
||||
// Page footer: "16/10/2026 16:37:08 Modernization & IT Software Division ... Page 1"
|
||||
const FOOTER = /^[^\n]*Modernization\s*&\s*IT[^\n]*Page\s*\d+[^\n]*$/gim;
|
||||
// Everything after the closing notice is boilerplate (signature, next draw date).
|
||||
const TRAILER = /The\s+prize\s+winners\s+are\s+advised.*/is;
|
||||
const NOISE: RegExp[] = [
|
||||
// agent block ends at the agency number, so tickets sharing the line survive
|
||||
/Agent\s*Name\s*:?[^\n]*?Agency\s*No\.?\s*:?\s*[A-Z]{0,3}\s*\d+/gi,
|
||||
/Agency\s*No\.?\s*:?\s*[A-Z]{0,3}\s*\d+/gi,
|
||||
/Agent\s*Name\s*:?[^\n]*/gi, // fallback: no agency number given
|
||||
/\([A-Z][A-Z .]+\)/g, // district names in brackets e.g. (KANNUR)
|
||||
];
|
||||
const HEADER =
|
||||
/(?:(?<ord>\d+)\s*(?:st|nd|rd|th)|(?<cons>Cons(?:olation)?))\s+Prize\s*[-–:]*\s*Rs\.?\s*:?\s*(?<amt>[\d,]+)\s*\/?-?(?<rest>[^\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: "<stamp> Modernization..."; raw text: "Modernization...Page N" then "<stamp>" 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<string, number | [number, number] | null>;
|
||||
|
||||
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<string, CountProfile> = {
|
||||
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');
|
||||
26
src/results/pdfText.ts
Normal file
26
src/results/pdfText.ts
Normal file
@ -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<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'srs-pdf-'));
|
||||
try {
|
||||
const file = join(dir, 'result.pdf');
|
||||
await writeFile(file, pdf);
|
||||
return await new Promise<string>((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 });
|
||||
}
|
||||
}
|
||||
112
src/results/portal.ts
Normal file
112
src/results/portal.ts
Normal file
@ -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 =
|
||||
/<td>\s*([^<]*?\(\s*([A-Z]{1,3})\s*-\s*(\d+)\s*\))\s*<\/td>\s*<td>\s*(\d{1,2})-(\d{1,2})-(\d{4})\s*<\/td>\s*<td>(.*?)<\/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<string, Entry>();
|
||||
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<string, string>();
|
||||
constructor(private readonly doFetch: FetchLike) {}
|
||||
|
||||
async request(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
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<string> {
|
||||
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<Buffer | null> {
|
||||
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;
|
||||
}
|
||||
91
src/results/scheduler.test.ts
Normal file
91
src/results/scheduler.test.ts
Normal file
@ -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<string, unknown>[] = [];
|
||||
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' });
|
||||
});
|
||||
});
|
||||
81
src/results/scheduler.ts
Normal file
81
src/results/scheduler.ts
Normal file
@ -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<string, unknown>) => void;
|
||||
now?: () => Date;
|
||||
run?: (db: Db) => Promise<DrawReport[]>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
33
src/results/store.ts
Normal file
33
src/results/store.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { Db } from '../db';
|
||||
import { hasErrors, type Draw, type Issue } from './parseResult';
|
||||
|
||||
export const drawCode = (d: Pick<Draw, 'schemeCode' | 'drawNo'>): 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);
|
||||
}
|
||||
14
src/routes/auth.ts
Normal file
14
src/routes/auth.ts
Normal file
@ -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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
41
src/routes/health.ts
Normal file
41
src/routes/health.ts
Normal file
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
235
src/routes/ingest.test.ts
Normal file
235
src/routes/ingest.test.ts
Normal file
@ -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<Record<string, unknown>>;
|
||||
const logRows = () => db.prepare('SELECT * FROM ingest_log ORDER BY id').all() as Array<Record<string, unknown>>;
|
||||
|
||||
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: '<a/>',
|
||||
});
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(res.json()).toEqual({ error: 'unsupported_media_type' });
|
||||
});
|
||||
});
|
||||
86
src/routes/ingest.ts
Normal file
86
src/routes/ingest.ts
Normal file
@ -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 };
|
||||
});
|
||||
};
|
||||
45
src/server.ts
Normal file
45
src/server.ts
Normal file
@ -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<void> {
|
||||
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<void> => {
|
||||
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);
|
||||
});
|
||||
48
src/services/accountRules.ts
Normal file
48
src/services/accountRules.ts
Normal file
@ -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);
|
||||
}
|
||||
26
src/services/drawDate.test.ts
Normal file
26
src/services/drawDate.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
18
src/services/drawDate.ts
Normal file
18
src/services/drawDate.ts
Normal file
@ -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);
|
||||
}
|
||||
39
src/services/phone.test.ts
Normal file
39
src/services/phone.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
20
src/services/phone.ts
Normal file
20
src/services/phone.ts
Normal file
@ -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;
|
||||
}
|
||||
54
src/services/provisioning.ts
Normal file
54
src/services/provisioning.ts
Normal file
@ -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;
|
||||
}
|
||||
53
src/services/shopScope.test.ts
Normal file
53
src/services/shopScope.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
37
src/services/shopScope.ts
Normal file
37
src/services/shopScope.ts
Normal file
@ -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);
|
||||
}
|
||||
165
src/services/userAdmin.test.ts
Normal file
165
src/services/userAdmin.test.ts
Normal file
@ -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]);
|
||||
});
|
||||
});
|
||||
87
src/services/userAdmin.ts
Normal file
87
src/services/userAdmin.ts
Normal file
@ -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<string | number | null> = [];
|
||||
|
||||
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))();
|
||||
}
|
||||
44
src/services/users.ts
Normal file
44
src/services/users.ts
Normal file
@ -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);
|
||||
}
|
||||
42
src/testing/helpers.ts
Normal file
42
src/testing/helpers.ts
Normal file
@ -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 };
|
||||
}
|
||||
35
src/whatsapp/bridge.ts
Normal file
35
src/whatsapp/bridge.ts
Normal file
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
68
src/whatsapp/conversation.ts
Normal file
68
src/whatsapp/conversation.ts
Normal file
@ -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)! };
|
||||
}
|
||||
419
test/fixtures/results/br111_2026-09-26_layout.txt
vendored
Normal file
419
test/fixtures/results/br111_2026-09-26_layout.txt
vendored
Normal file
@ -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
|
||||
|
||||
110
test/fixtures/results/bt78_2026-11-02_raw.txt
vendored
Normal file
110
test/fixtures/results/bt78_2026-11-02_raw.txt
vendored
Normal file
@ -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
|
||||
|
||||
205
test/fixtures/results/dl73_2026-10-14_layout.txt
vendored
Normal file
205
test/fixtures/results/dl73_2026-10-14_layout.txt
vendored
Normal file
@ -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
|
||||
|
||||
207
test/fixtures/results/kn645_2026-10-15_layout.txt
vendored
Normal file
207
test/fixtures/results/kn645_2026-10-15_layout.txt
vendored
Normal file
@ -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
|
||||
|
||||
BIN
test/fixtures/results/sk73_2026-10-16.pdf
vendored
Normal file
BIN
test/fixtures/results/sk73_2026-10-16.pdf
vendored
Normal file
Binary file not shown.
200
test/fixtures/results/sk73_2026-10-16_layout.txt
vendored
Normal file
200
test/fixtures/results/sk73_2026-10-16_layout.txt
vendored
Normal file
@ -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
|
||||
|
||||
111
test/fixtures/results/sm79_2026-11-01_raw.txt
vendored
Normal file
111
test/fixtures/results/sm79_2026-11-01_raw.txt
vendored
Normal file
@ -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
|
||||
|
||||
10
tsconfig.build.json
Normal file
10
tsconfig.build.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/testing/**"]
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user