feat: SRS backend: orders intake, results pipeline, user management

Fastify + TypeScript + SQLite backend for a single Kerala lottery agency.
This commit is contained in:
Aswin B. S 2026-11-03 10:09:46 +05:30
commit 6851d66b38
60 changed files with 7151 additions and 0 deletions

13
.env.example Normal file
View 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
View 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
View 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
View File

15
ecosystem.config.cjs Normal file
View 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

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}

View 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);

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

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

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

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

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

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

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

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

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

View 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)! };
}

View 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

View 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

View 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

View 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

Binary file not shown.

View 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

View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});