From c6251810aefae3021344243998c0c0037ea29913 Mon Sep 17 00:00:00 2001 From: Aswin Biju Date: Thu, 5 Nov 2026 00:37:57 +0530 Subject: [PATCH] feat: added WhatsApp summary options for users --- README.md | 2 + scripts/seed_local.py | 250 +++++++++++++++++++++++++ scripts/user.ts | 14 +- src/cli/userWizard.test.ts | 57 +++++- src/cli/userWizard.ts | 74 +++++++- src/db/migrate.test.ts | 2 +- src/db/migrations/006_send_results.sql | 9 + src/results/scheduler.ts | 6 + src/results/sendResults.test.ts | 104 ++++++++++ src/results/sendResults.ts | 57 ++++++ src/routes/api/api.test.ts | 6 +- src/routes/api/session.test.ts | 2 +- src/routes/api/users.ts | 1 + src/services/draws.ts | 5 + src/services/provisioning.ts | 11 +- src/services/userAdmin.test.ts | 28 ++- src/services/userAdmin.ts | 14 +- src/services/users.ts | 5 +- src/whatsapp/messages.ts | 23 +++ 19 files changed, 638 insertions(+), 32 deletions(-) create mode 100644 scripts/seed_local.py create mode 100644 src/db/migrations/006_send_results.sql create mode 100644 src/results/sendResults.test.ts create mode 100644 src/results/sendResults.ts diff --git a/README.md b/README.md index 4366d9e..af80acd 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ Needs Node 22 or newer. npm install cp .env.example .env npm run migrate # creates ./data/srs.db +npm run user manage # one menu for all of the below npm run user create # a short wizard: choose Admin or User, then set a password npm run user password # set a new password for an admin or a user +npm run user results # turn WhatsApp result messages on or off (admins start on, users off) npm run user list # show every account npm run shop create # add a shop (name + unique code) npm run shop list # show every shop diff --git a/scripts/seed_local.py b/scripts/seed_local.py new file mode 100644 index 0000000..947380d --- /dev/null +++ b/scripts/seed_local.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import os +import secrets +import sqlite3 +import sys +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +IST = timezone(timedelta(hours=5, minutes=30)) + +PASSWORD = "staff123" + +SHOPS = [("KZK", "Kazhakkoottam"), ("SKM", "Sreekaryam"), ("ULR", "Ulloor"), ("PTM", "Pattom")] + +USERS = [ + ("kzk.staff", ["KZK", "SKM"], True), + ("skm.staff", ["SKM"], True), + ("ptm.staff", ["PTM"], False), +] + +SCHEMES = { + 0: ("Bhagyathara", "BT"), + 1: ("Sthree Sakthi", "SS"), + 2: ("Dhanalekshmi", "DL"), + 3: ("Karunya Plus", "KN"), + 4: ("Suvarna Keralam", "SK"), + 5: ("Karunya", "KR"), + 6: ("Samrudhi", "SM"), +} + +PRIZES = { + "2851": (5, 2000), + "1360": (6, 1000), + "0987": (7, 500), + "6184": (7, 500), + "0376": (8, 200), + "1903": (8, 200), + "3821": (8, 200), + "2564": (9, 100), + "0119": (9, 100), + "4821": (4, 5000), + "7342": (6, 1000), + "5590": (9, 100), +} + +KZK_ALL = [ + ("0003", 5), ("0018", 2), ("0057", 2), ("0166", 1), ("0231", 6), ("0376", 2), ("0418", 5), ("0745", 2), + ("0887", 10), ("0888", 1), ("0987", 4), ("1122", 3), ("1207", 4), ("1360", 1), ("1573", 3), ("1892", 2), + ("2290", 2), ("2417", 1), ("2564", 3), ("2689", 1), ("3076", 5), ("3345", 10), ("3422", 2), ("3472", 1), + ("3484", 4), ("3584", 3), ("3611", 3), ("3704", 4), ("4129", 1), ("4410", 1), ("4455", 6), ("4568", 6), + ("4852", 2), ("5043", 4), ("5104", 1), ("5105", 5), ("5106", 3), ("5127", 3), ("5936", 10), ("6358", 2), + ("6602", 6), ("6861", 2), ("7021", 1), ("7216", 10), ("7788", 2), ("7790", 12), ("8147", 3), ("8301", 2), + ("8890", 1), ("9064", 1), ("9077", 3), ("9155", 1), +] + + +def split_kzk() -> tuple[dict[str, int], dict[str, int]]: + photo: dict[str, int] = {} + text: dict[str, int] = {} + left = 64 + for ending, qty in KZK_ALL: + take = min(left, qty) + if take: + photo[ending] = take + left -= take + if qty - take: + text[ending] = qty - take + return photo, text + + +def yesterday_orders() -> list[dict]: + photo, text = split_kzk() + return [ + {"shop": "KZK", "time": "12:41", "photo": True, "lines": photo}, + {"shop": "KZK", "time": "11:52", "photo": False, "lines": text, "edited": "13:05"}, + {"shop": "SKM", "time": "12:18", "photo": False, "lines": { + "0887": 1, "0166": 1, "3611": 1, "4568": 2, "6861": 1, "0018": 1, "0057": 2, "2851": 1, "6184": 1, + "0119": 1, "9155": 20, "5124": 5, "0003": 3, "7731": 2, "4402": 4, "1288": 3, "3399": 2, "8020": 3}}, + {"shop": "SKM", "time": "10:47", "photo": False, "lines": { + "1001": 10, "1102": 12, "2203": 8, "3304": 14, "4405": 6, "5506": 10, "6607": 9, "7708": 7, + "8809": 10}}, + {"shop": "ULR", "time": "11:30", "photo": True, "lines": { + "1360": 1, "1903": 1, "1010": 20, "2020": 15, "3030": 12, "4040": 18, "5050": 14, "6060": 10, + "7070": 12, "8080": 7}}, + {"shop": "PTM", "time": "10:05", "photo": False, "lines": { + "3821": 1, "1111": 15, "2222": 12, "3333": 10, "4444": 8, "5555": 10, "6666": 9, "7777": 5}}, + ] + + +def today_orders() -> list[dict]: + return [ + {"shop": "KZK", "time": "09:20", "photo": False, "lines": {"0042": 10, "7311": 5, "2208": 5}}, + {"shop": "KZK", "time": "11:10", "photo": False, "lines": {"0987": 2, "4455": 4, "9064": 3, "1573": 6}}, + {"shop": "SKM", "time": "10:15", "photo": False, "lines": {"1102": 8, "3304": 6, "9155": 10, "5124": 2}}, + {"shop": "ULR", "time": "12:05", "photo": True, "lines": {"5521": 8, "3190": 2}}, + {"shop": "PTM", "time": "09:45", "photo": False, "lines": {"1111": 10, "4444": 5, "6666": 4}}, + ] + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64, maxmem=64 * 1024 * 1024) + return "$".join(["scrypt", "16384", "8", "1", base64.b64encode(salt).decode(), base64.b64encode(key).decode()]) + + +def db_path_from_env() -> Path: + value = os.environ.get("DB_PATH") + env_file = ROOT / ".env" + if value is None and env_file.exists(): + for raw in env_file.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if line.startswith("DB_PATH="): + value = line.split("=", 1)[1].strip().strip("'\"") + return (ROOT / (value or "./data/srs.db")).resolve() + + +def utc_stamp(day: date, hhmm: str) -> str: + h, m = (int(x) for x in hhmm.split(":")) + at = datetime(day.year, day.month, day.day, h, m, tzinfo=IST).astimezone(timezone.utc) + return at.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def raw_text(code: str, lines: dict[str, int]) -> str: + return "\n".join([code] + [f"{e} - {q}" for e, q in sorted(lines.items())]) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Seed the local SRS database with example data.") + ap.add_argument("--db", help="database file (default: DB_PATH from .env, or ./data/srs.db)") + ap.add_argument("--date", help="treat this IST day as today (YYYY-MM-DD)") + args = ap.parse_args() + + path = Path(args.db).resolve() if args.db else db_path_from_env() + if not path.exists(): + print(f"No database at {path}. Run `npm run migrate` first.") + return 1 + today = date.fromisoformat(args.date) if args.date else datetime.now(IST).date() + yesterday = today - timedelta(days=1) + now_ist = datetime.now(IST) + + db = sqlite3.connect(path, timeout=10) + db.execute("PRAGMA foreign_keys = ON") + tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + if not {"schema_migrations", "users", "shops", "orders", "order_lines", "draws", "prizes"} <= tables: + print(f"{path} has no SRS schema. Run `npm run migrate` first.") + return 1 + + summary: list[str] = [] + try: + with db: + # Shops + shop_ids: dict[str, int] = {} + for code, name in SHOPS: + row = db.execute("SELECT id FROM shops WHERE code = ?", (code,)).fetchone() + if row: + shop_ids[code] = row[0] + else: + shop_ids[code] = db.execute( + "INSERT INTO shops (name, code) VALUES (?, ?)", (name, code)).lastrowid + summary.append(f"shop {code} {name}") + + user_ids: dict[str, int] = {} + for username, codes, active in USERS: + row = db.execute( + "SELECT id FROM users WHERE username = ? COLLATE NOCASE AND deleted_at IS NULL", + (username,)).fetchone() + if row: + user_ids[username] = row[0] + continue + uid = db.execute( + "INSERT INTO users (username, password_hash, phone, is_admin, active) VALUES (?, ?, NULL, 0, ?)", + (username, hash_password(PASSWORD), 1 if active else 0)).lastrowid + for code in codes: + db.execute("INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)", (uid, shop_ids[code])) + user_ids[username] = uid + summary.append(f"user {username} / {PASSWORD}" + ("" if active else " (inactive)")) + + admin = db.execute( + "SELECT id FROM users WHERE is_admin = 1 AND deleted_at IS NULL ORDER BY id LIMIT 1").fetchone() + if not admin: + raise SystemExit("No admin account. Create one with `npm run user create` first.") + author = {"KZK": user_ids["kzk.staff"], "SKM": user_ids["skm.staff"], "ULR": admin[0], "PTM": admin[0]} + + # Orders + seeded_shops = tuple(shop_ids.values()) + marks = ",".join("?" * len(seeded_shops)) + + def add_orders(day: date, orders: list[dict]) -> None: + iso = day.isoformat() + exists = db.execute( + f"SELECT COUNT(*) FROM orders WHERE draw_date = ? AND shop_id IN ({marks})", + (iso, *seeded_shops)).fetchone()[0] + if exists: + summary.append(f"{iso}: skipped orders, these shops already have {exists}") + return + tickets = 0 + for o in orders: + t = o["time"] + if day == now_ist.date(): + t = min(t, now_ist.strftime("%H:%M")) + oid = db.execute( + "INSERT INTO orders (shop_id, draw_date, source, created_by_user_id, raw_text, created_at," + " edited_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (shop_ids[o["shop"]], iso, "whatsapp_photo" if o["photo"] else "whatsapp_text", + author[o["shop"]], raw_text(o["shop"], o["lines"]), utc_stamp(day, t), + utc_stamp(day, o["edited"]) if o.get("edited") else None)).lastrowid + db.executemany( + "INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)", + [(oid, e, q) for e, q in o["lines"].items()]) + tickets += sum(o["lines"].values()) + summary.append(f"{iso}: {len(orders)} orders, {tickets} tickets") + + add_orders(yesterday, yesterday_orders()) + add_orders(today, today_orders()) + + y = yesterday.isoformat() + if db.execute("SELECT 1 FROM draws WHERE draw_date = ?", (y,)).fetchone(): + summary.append(f"{y}: a draw already exists, kept it (no demo draw added)") + else: + scheme, prefix = SCHEMES[yesterday.weekday()] + code = f"{prefix}-{900 + yesterday.toordinal() % 100}" + draw_id = db.execute( + "INSERT INTO draws (draw_date, scheme, draw_code) VALUES (?, ?, ?)", (y, scheme, code)).lastrowid + db.executemany( + "INSERT INTO prizes (draw_id, tier, amount, ending) VALUES (?, ?, ?, ?)", + [(draw_id, t, a, e) for e, (t, a) in PRIZES.items()]) + if "result_sends" in tables: + db.execute( + "INSERT OR IGNORE INTO result_sends (draw_id, user_id) SELECT ?, id FROM users", (draw_id,)) + summary.append(f"{y}: draw {scheme} {code} with {len(PRIZES)} prizes") + except sqlite3.OperationalError as e: + print(f"Could not write to {path}: {e}") + return 1 + finally: + db.close() + + print(f"Seeded {path}") + print(f"Today is {today.isoformat()} (IST)") + for line in summary or ["nothing to add: everything is already there"]: + print(f" {line}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/user.ts b/scripts/user.ts index b332a55..3276554 100644 --- a/scripts/user.ts +++ b/scripts/user.ts @@ -1,10 +1,11 @@ /** + * npm run user manage one menu: create, change a password, result messages on/off, list * npm run user create a short wizard: choose Admin or User * npm run user password set a new password for an admin or a user * npm run user list show every account * */ -import { createWizard, listLines, passwordWizard } from '../src/cli/userWizard'; +import { createWizard, listLines, manageWizard, passwordWizard, resultsWizard } from '../src/cli/userWizard'; import { loadConfig } from '../src/config'; import { openDb } from '../src/db'; import { runMigrations } from '../src/db/migrate'; @@ -12,10 +13,12 @@ import { makeIo } from './cliIo'; async function main(): Promise { const command = process.argv[2]; - if (command !== 'create' && command !== 'password' && command !== 'list') { + if (!['manage', 'create', 'password', 'results', 'list'].includes(command ?? '')) { console.log('usage:'); + console.log(' npm run user manage one menu for everything below'); console.log(' npm run user create create an admin or a user (a short wizard)'); console.log(' npm run user password set a new password for an admin or a user'); + console.log(' npm run user results turn result messages on or off for an admin or a user'); console.log(' npm run user list show every account'); return command ? 1 : 0; } @@ -31,9 +34,10 @@ async function main(): Promise { } const { io, close } = makeIo(); try { - return command === 'password' - ? await passwordWizard(db, io) - : await createWizard(db, io, config.defaultCountryCode); + if (command === 'manage') return await manageWizard(db, io, config.defaultCountryCode); + if (command === 'password') return await passwordWizard(db, io); + if (command === 'results') return await resultsWizard(db, io); + return await createWizard(db, io, config.defaultCountryCode); } finally { close(); } diff --git a/src/cli/userWizard.test.ts b/src/cli/userWizard.test.ts index a950be7..fb4a27a 100644 --- a/src/cli/userWizard.test.ts +++ b/src/cli/userWizard.test.ts @@ -3,7 +3,7 @@ import { makeDb, seedFixture } from '../testing/helpers'; import { shopsForUser } from '../services/shopScope'; import { findUserByUsername, getUserById } from '../services/users'; import { verifyPassword } from '../services/password'; -import { Cancelled, createWizard, listLines, passwordWizard, type Io } from './userWizard'; +import { Cancelled, createWizard, listLines, manageWizard, passwordWizard, resultsWizard, type Io } from './userWizard'; function scripted(answers: string[]) { const shown: string[] = []; @@ -177,9 +177,9 @@ describe('listing', () => { 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).toMatch(/admin\s+active\s+off\s+admin\s+919000000001\s+all shops/); + expect(text).toMatch(/user\s+active\s+off\s+shop-a\s+919000000002\s+Shop A/); + expect(text).toMatch(/user\s+disabled\s+off\s+gone/); expect(text).not.toContain('password'); }); }); @@ -225,3 +225,52 @@ describe('resetting a password', () => { expect(getUserById(db, f.disabledUser.id)?.active).toBe(false); }); }); + +describe('result messages', () => { + const sendsResults = (db: ReturnType, id: number) => + (db.prepare('SELECT send_results s FROM users WHERE id = ?').get(id) as { s: number }).s; + + it('are on for a new admin and off for a new user', async () => { + const db = makeDb(); + await createWizard(db, scripted(['1', 'owner', '919847049032', 'long enough', 'long enough']).io); + await createWizard(db, scripted(['2', 'helper', '', 'long enough', 'long enough']).io); + expect(rows(db, 'SELECT username, send_results FROM users ORDER BY id')).toEqual([ + { username: 'owner', send_results: 1 }, + { username: 'helper', send_results: 0 }, + ]); + }); + + it('flip each time, for an admin too, without asking to confirm', async () => { + const db = makeDb(); + const f = seedFixture(db); + const first = scripted(['ADMIN']); + expect(await resultsWizard(db, first.io)).toBe(0); + expect(first.text()).toContain('are now ON'); + expect(sendsResults(db, f.admin.id)).toBe(1); + expect(first.leftover()).toBe(0); + + await resultsWizard(db, scripted(['admin']).io); + expect(sendsResults(db, f.admin.id)).toBe(0); + }); +}); + +describe('the manage menu', () => { + it('opens each task by number', async () => { + const db = makeDb(); + const f = seedFixture(db); + await manageWizard(db, scripted(['3', 'shop-a']).io); + expect(getUserById(db, f.shopUser.id)?.sendResults).toBe(true); + + await manageWizard(db, scripted(['2', 'shop-a', 'brand new pass', 'brand new pass']).io); + const { h } = db.prepare('SELECT password_hash h FROM users WHERE id = ?').get(f.shopUser.id) as { h: string }; + expect(await verifyPassword('brand new pass', h)).toBe(true); + + const list = scripted(['4']); + await manageWizard(db, list.io); + expect(list.text()).toContain('USERNAME'); + + const quit = scripted(['9', 'q']); + expect(await manageWizard(db, quit.io)).toBe(0); + expect(quit.text()).toContain('Type 1, 2, 3 or 4.'); + }); +}); diff --git a/src/cli/userWizard.ts b/src/cli/userWizard.ts index ca05e97..cd0ea34 100644 --- a/src/cli/userWizard.ts +++ b/src/cli/userWizard.ts @@ -2,7 +2,13 @@ import type { Db } from '../db'; import { isValidUsername, RuleError, USERNAME_HELP } from '../services/accountRules'; import { cleanPassword, hashPassword } from '../services/password'; import { normalizePhone } from '../services/phone'; -import { countAdmins, createAdminAccount, createUserAccount, setAccountPassword } from '../services/provisioning'; +import { + countAdmins, + createAdminAccount, + createUserAccount, + setAccountPassword, + setResultsSending, +} from '../services/provisioning'; import { findUserByUsername, listUsers, type User } from '../services/users'; export class Cancelled extends Error { @@ -156,19 +162,22 @@ export async function createWizard(db: Db, io: Io, defaultCountryCode = '91'): P } } +const parseAccount = + (db: Db) => + (a: string): Parsed => { + const user = a.trim() === '' ? undefined : findUserByUsername(db, a.trim()); + return user + ? { ok: true, value: user } + : { ok: false, error: 'No account with that username. See them with: npm run user list' }; + }; + export async function passwordWizard(db: Db, io: Io): Promise { try { io.say('Reset a password'); io.say(' Works for admins and users. (type q at any question to quit)'); io.say(); - const parseAccount = (a: string): Parsed => { - const user = a.trim() === '' ? undefined : findUserByUsername(db, a.trim()); - return user - ? { ok: true, value: user } - : { ok: false, error: 'No account with that username. See them with: npm run user list' }; - }; - const user = await askUntil(io, 'Username: ', parseAccount); + const user = await askUntil(io, 'Username: ', parseAccount(db)); const role = user.isAdmin ? 'ADMIN' : 'USER'; io.say(` ${role} "${user.username}"${user.active ? '' : ' (switched off: it stays switched off)'}`); @@ -189,6 +198,52 @@ export async function passwordWizard(db: Db, io: Io): Promise { } } +export async function resultsWizard(db: Db, io: Io): Promise { + try { + io.say('Result messages on WhatsApp'); + io.say(' Works for admins and users. (type q at any question to quit)'); + io.say(); + const user = await askUntil(io, 'Username: ', parseAccount(db)); + const on = !user.sendResults; + setResultsSending(db, user.id, on); + const role = user.isAdmin ? 'ADMIN' : 'USER'; + io.say(`Result messages for ${role} "${user.username}" are now ${on ? 'ON' : 'OFF'}.`); + if (on && !user.phone) io.say(' This account has no phone number, so nothing can be sent until one is added.'); + return 0; + } catch (e) { + if (e instanceof Cancelled) { + io.say('Cancelled. Nothing was changed.'); + return 0; + } + throw e; + } +} + +/** `npm run user manage`: one menu for every account task. */ +export async function manageWizard(db: Db, io: Io, defaultCountryCode = '91'): Promise { + io.say('Manage accounts'); + io.say(' 1) Create an admin or a user'); + io.say(' 2) Change a password'); + io.say(' 3) Turn result messages on or off'); + io.say(' 4) List accounts'); + io.say(); + const parseChoice = (a: string): Parsed => + /^[1-4]$/.test(a) ? { ok: true, value: Number(a) } : { ok: false, error: 'Type 1, 2, 3 or 4.' }; + let choice: number; + try { + choice = await askUntil(io, 'What do you want to do? [1-4]: ', parseChoice); + } catch (e) { + if (e instanceof Cancelled) return 0; + throw e; + } + io.say(); + if (choice === 1) return createWizard(db, io, defaultCountryCode); + if (choice === 2) return passwordWizard(db, io); + if (choice === 3) return resultsWizard(db, io); + for (const line of listLines(db)) io.say(line); + return 0; +} + export function listLines(db: Db): string[] { const users = listUsers(db); if (users.length === 0) return ['No accounts yet. Run: npm run user create']; @@ -197,7 +252,7 @@ export function listLines(db: Db): string[] { `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']; + const lines = ['ROLE STATUS RESULTS USERNAME PHONE SHOPS']; for (const u of users) { const shops = u.isAdmin ? 'all shops' @@ -206,6 +261,7 @@ export function listLines(db: Db): string[] { [ (u.isAdmin ? 'admin' : 'user').padEnd(6), (u.active ? 'active' : 'disabled').padEnd(9), + (u.sendResults ? 'on' : 'off').padEnd(8), u.username.padEnd(20), (u.phone ?? '-').padEnd(15), shops, diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index 13ba80a..16233de 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -8,7 +8,7 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE describe('migrations', () => { it('apply once and are then a no-op', () => { const db = openDb(':memory:'); - expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql', '003_progress_update.sql', '004_user_delete_and_order_edit.sql', '005_username_nocase.sql']); + expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql', '003_progress_update.sql', '004_user_delete_and_order_edit.sql', '005_username_nocase.sql', '006_send_results.sql']); expect(runMigrations(db)).toEqual([]); }); }); diff --git a/src/db/migrations/006_send_results.sql b/src/db/migrations/006_send_results.sql new file mode 100644 index 0000000..e14740a --- /dev/null +++ b/src/db/migrations/006_send_results.sql @@ -0,0 +1,9 @@ +ALTER TABLE users ADD COLUMN send_results INTEGER NOT NULL DEFAULT 0 CHECK (send_results IN (0, 1)); +UPDATE users SET send_results = 1 WHERE is_admin = 1; + +CREATE TABLE result_sends ( + draw_id INTEGER NOT NULL REFERENCES draws(id), + user_id INTEGER NOT NULL REFERENCES users(id), + sent_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (draw_id, user_id) +); diff --git a/src/results/scheduler.ts b/src/results/scheduler.ts index 710a324..78cefbf 100644 --- a/src/results/scheduler.ts +++ b/src/results/scheduler.ts @@ -11,6 +11,7 @@ export interface SchedulerOptions { retryMin?: number; now?: () => Date; run?: (db: Db) => Promise; + onStored?: (drawCode: string) => void; } function todaysDrawReady(db: Db, today: string): boolean { @@ -59,6 +60,11 @@ export function createResultsScheduler(opts: SchedulerOptions) { }); } else if (r.status === 'ready') { opts.log({ level: 'info', msg: 'results: draw stored', draw: r.code, date: r.date }); + try { + opts.onStored?.(r.code); + } catch (err) { + opts.log({ level: 'error', msg: 'results: sending failed', draw: r.code, error: (err as Error).message }); + } } } } catch (err) { diff --git a/src/results/sendResults.test.ts b/src/results/sendResults.test.ts new file mode 100644 index 0000000..de17de6 --- /dev/null +++ b/src/results/sendResults.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb, seedDraw, seedFixture, seedOrder } from '../testing/helpers'; +import type { SendQueue } from '../whatsapp/sendQueue'; +import { createResultsScheduler } from './scheduler'; +import { sendDrawResults } from './sendResults'; + +function setup() { + const db = makeDb(); + const f = seedFixture(db); + const sent: Array<{ chatId: string; text: string }> = []; + const queue: SendQueue = { + enqueue: (chatId, messages) => messages.forEach((text) => sent.push({ chatId, text })), + idle: async () => {}, + }; + const on = (...ids: number[]) => ids.forEach((id) => db.prepare('UPDATE users SET send_results = 1 WHERE id = ?').run(id)); + seedDraw(db, { date: '2026-11-03', scheme: 'Karunya', code: 'KR-700', prizes: [[5, 500, '1234']] }); + seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-03', lines: [['1234', 3], ['9999', 1]] }); + seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-03', lines: [['1234', 2]] }); + seedOrder(db, { shopId: f.shops.b, userId: f.multiUser.id, date: '2026-11-03', lines: [['5555', 2]] }); + return { db, f, sent, queue, on }; +} + +describe('sending results', () => { + it('gives admins every shop with orders and users only their own', () => { + const { db, f, sent, queue, on } = setup(); + on(f.admin.id, f.shopUser.id); + db.prepare("INSERT INTO conversations (user_id, chat_id) VALUES (?, '111@lid')").run(f.shopUser.id); + + expect(sendDrawResults(db, queue, 'KR-700')).toBe(2); + expect(sent).toEqual([ + { + chatId: `${f.admin.phone}@s.whatsapp.net`, + text: [ + '🎯 *Results: Karunya KR-700* (3 Nov)', + '', + '*Shop A*: Rs 2,500', + ' 1234 x5 - 5th prize, Rs 500 each', + '', + '*Shop B*: no winners', + '', + '*Total*: Rs 2,500', + ].join('\n'), + }, + { + chatId: '111@lid', + text: ['🎯 *Results: Karunya KR-700* (3 Nov)', '', '*Shop A*: Rs 2,500', ' 1234 x5 - 5th prize, Rs 500 each'].join('\n'), + }, + ]); + }); + + it('tells a user with orders but no winners so', () => { + const { db, f, sent, queue, on } = setup(); + on(f.multiUser.id); + db.prepare("DELETE FROM order_lines WHERE ending = '1234'").run(); + sendDrawResults(db, queue, 'KR-700'); + expect(sent[0]!.text).toContain('*Shop A*: no winners'); + expect(sent[0]!.text).toContain('*Total*: no winners'); + }); + + it('skips people who have it off, are switched off, have no phone, or had no orders', () => { + const { db, f, sent, queue, on } = setup(); + on(f.disabledUser.id); + const noPhone = Number( + db.prepare("INSERT INTO users (username, password_hash, send_results) VALUES ('nophone', 'x', 1)").run().lastInsertRowid, + ); + db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(noPhone, f.shops.a); + const shopC = Number( + db + .prepare("INSERT INTO users (username, password_hash, phone, send_results) VALUES ('c-only', 'x', '919000000009', 1)") + .run().lastInsertRowid, + ); + db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(shopC, f.shops.c); + + expect(sendDrawResults(db, queue, 'KR-700')).toBe(0); + expect(sent).toEqual([]); + }); + + it('never sends the same draw to someone twice', () => { + const { db, f, sent, queue, on } = setup(); + on(f.admin.id); + sendDrawResults(db, queue, 'KR-700'); + expect(sendDrawResults(db, queue, 'KR-700')).toBe(0); + expect(sent).toHaveLength(1); + }); + + it('is run by the scheduler for each draw it stores, and a failure there is only logged', async () => { + const db = makeDb(); + const stored: string[] = []; + const logs: Array> = []; + const s = createResultsScheduler({ + db, + log: (e) => logs.push(e), + now: () => new Date('2026-11-03T11:00:00Z'), + run: async () => [{ code: 'KR-700', date: '2026-11-03', status: 'ready', issues: [] }], + onStored: (code) => { + stored.push(code); + throw new Error('bridge down'); + }, + }); + await s.tick(); + expect(stored).toEqual(['KR-700']); + expect(logs).toContainEqual(expect.objectContaining({ msg: 'results: sending failed', draw: 'KR-700' })); + }); +}); diff --git a/src/results/sendResults.ts b/src/results/sendResults.ts new file mode 100644 index 0000000..c0b687c --- /dev/null +++ b/src/results/sendResults.ts @@ -0,0 +1,57 @@ +import type { Db } from '../db'; +import { getDrawByCode } from '../services/draws'; +import { dayOrders } from '../services/reports'; +import { getUserById } from '../services/users'; +import { resultSummary, type ResultShop } from '../whatsapp/messages'; +import type { LogFn, SendQueue } from '../whatsapp/sendQueue'; + +export function sendDrawResults(db: Db, queue: SendQueue, drawCode: string, log: LogFn = () => {}): number { + const draw = getDrawByCode(db, drawCode); + if (!draw) return 0; + + const outgoing = db.transaction(() => { + const ids = db + .prepare( + `SELECT id FROM users + WHERE send_results = 1 AND active = 1 AND phone IS NOT NULL AND deleted_at IS NULL + AND id NOT IN (SELECT user_id FROM result_sends WHERE draw_id = ?)`, + ) + .all(draw.id) as Array<{ id: number }>; + const chatOf = db.prepare('SELECT chat_id FROM conversations WHERE user_id = ?'); + const record = db.prepare('INSERT OR IGNORE INTO result_sends (draw_id, user_id) VALUES (?, ?)'); + + const out: Array<{ chatId: string; text: string }> = []; + for (const { id } of ids) { + const user = getUserById(db, id); + if (!user?.phone) continue; + const byShop = new Map(); + for (const order of dayOrders(db, user, draw.date)) { + const shop = byShop.get(order.shop.id) ?? { name: order.shop.name, lines: [] }; + shop.lines.push(...order.lines); + byShop.set(order.shop.id, shop); + } + if (byShop.size === 0) continue; + if (record.run(draw.id, id).changes === 0) continue; + + const shops = [...byShop.values()].sort((a, b) => a.name.localeCompare(b.name)); + for (const shop of shops) shop.lines = mergeEndings(shop.lines); + const chat = chatOf.get(id) as { chat_id: string } | undefined; + out.push({ chatId: chat?.chat_id ?? `${user.phone}@s.whatsapp.net`, text: resultSummary(draw, shops) }); + } + return out; + })(); + + for (const m of outgoing) queue.enqueue(m.chatId, [m.text]); + log({ level: 'info', msg: 'results: summaries queued', draw: draw.code, count: outgoing.length }); + return outgoing.length; +} + +function mergeEndings(lines: ResultShop['lines']): ResultShop['lines'] { + const by = new Map(); + for (const l of lines) { + const seen = by.get(l.ending); + if (seen) seen.qty += l.qty; + else by.set(l.ending, { ...l }); + } + return [...by.values()].sort((a, b) => a.ending.localeCompare(b.ending)); +} diff --git a/src/routes/api/api.test.ts b/src/routes/api/api.test.ts index ca98901..c50a237 100644 --- a/src/routes/api/api.test.ts +++ b/src/routes/api/api.test.ts @@ -61,7 +61,7 @@ describe('draws', () => { it('gives today before any result exists', async () => { const empty = makeDb(); - const other = createApp({ db: empty, config: testConfig(), bridge, authenticate: () => ({ id: 1, username: 'a', phone: null, isAdmin: true, active: true }), now: () => new Date('2026-11-04T03:00:00Z') }); + const other = createApp({ db: empty, config: testConfig(), bridge, authenticate: () => ({ id: 1, username: 'a', phone: null, isAdmin: true, active: true, sendResults: true }), now: () => new Date('2026-11-04T03:00:00Z') }); expect((await other.inject({ method: 'GET', url: '/api/draws/latest' })).json()).toEqual({ date: '2026-11-04' }); await other.close(); }); @@ -163,7 +163,7 @@ describe('users', () => { shopIds: [f.shops.a, f.shops.b], }); expect(res.statusCode).toBe(201); - expect(res.json()).toEqual({ id: expect.any(Number), username: 'new.user', phone: '919876543210', isAdmin: false, active: true, shopIds: [f.shops.a, f.shops.b] }); + expect(res.json()).toEqual({ id: expect.any(Number), username: 'new.user', phone: '919876543210', isAdmin: false, active: true, sendResults: false, shopIds: [f.shops.a, f.shops.b] }); const hash = (db.prepare('SELECT password_hash h FROM users WHERE id = ?').get(res.json().id) as { h: string }).h; expect(hash).not.toContain('longenough'); expect(await verifyPassword('longenough', hash)).toBe(true); @@ -179,6 +179,8 @@ describe('users', () => { it('lists, reads, updates, reassigns and deletes', async () => { const list = (await call(f.admin.id, 'GET', '/api/users')).json(); expect(list.users.map((u: { id: number }) => u.id)).toContain(f.shopUser.id); + expect(list.users.some((u: { isAdmin: boolean }) => u.isAdmin)).toBe(false); + expect((await call(f.admin.id, 'GET', `/api/users/${f.admin2.id}`)).statusCode).toBe(403); const patched = await call(f.admin.id, 'PATCH', `/api/users/${f.shopUser.id}`, { username: 'renamed', phone: null, active: false, password: 'brand new pass' }); expect(patched.json()).toMatchObject({ username: 'renamed', phone: null, active: false }); diff --git a/src/routes/api/session.test.ts b/src/routes/api/session.test.ts index 10a230a..5eae483 100644 --- a/src/routes/api/session.test.ts +++ b/src/routes/api/session.test.ts @@ -40,7 +40,7 @@ describe('signing in', () => { const res = await login('shop-ab', 'multi password'); expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.user).toEqual({ id: f.multiUser.id, username: 'shop-ab', phone: f.multiUser.phone, isAdmin: false, active: true, shopIds: [f.shops.a, f.shops.b] }); + expect(body.user).toEqual({ id: f.multiUser.id, username: 'shop-ab', phone: f.multiUser.phone, isAdmin: false, active: true, sendResults: false, shopIds: [f.shops.a, f.shops.b] }); expect(JSON.stringify(body)).not.toContain('password'); }); diff --git a/src/routes/api/users.ts b/src/routes/api/users.ts index d3c0fe9..2627db4 100644 --- a/src/routes/api/users.ts +++ b/src/routes/api/users.ts @@ -26,6 +26,7 @@ const userPatch = z.object({ phone: z.string().nullable().optional(), password: z.string().optional(), active: z.boolean().optional(), + sendResults: z.boolean().optional(), }); export const userRoutes = diff --git a/src/services/draws.ts b/src/services/draws.ts index 92f8db1..b17dfb5 100644 --- a/src/services/draws.ts +++ b/src/services/draws.ts @@ -33,6 +33,11 @@ export function getDrawOn(db: Db, date: string): Draw | undefined { return row && toDraw(row); } +export function getDrawByCode(db: Db, code: string): Draw | undefined { + const row = db.prepare(`SELECT ${COLUMNS} FROM draws WHERE draw_code = ?`).get(code) as DrawRow | undefined; + return row && toDraw(row); +} + export function drawForOrderDate(db: Db, date: string): Draw | undefined { const row = db.prepare(`SELECT ${COLUMNS} FROM draws WHERE draw_date >= ? ORDER BY draw_date LIMIT 1`).get(date) as | DrawRow diff --git a/src/services/provisioning.ts b/src/services/provisioning.ts index f0a7e44..772c9d1 100644 --- a/src/services/provisioning.ts +++ b/src/services/provisioning.ts @@ -15,8 +15,8 @@ function insert(db: Db, username: string, passwordHash: string, phone: string | try { return Number( db - .prepare('INSERT INTO users (username, password_hash, phone, is_admin, active) VALUES (?, ?, ?, ?, 1)') - .run(username, passwordHash, phone, isAdmin).lastInsertRowid, + .prepare('INSERT INTO users (username, password_hash, phone, is_admin, active, send_results) VALUES (?, ?, ?, ?, 1, ?)') + .run(username, passwordHash, phone, isAdmin, isAdmin).lastInsertRowid, ); } catch (e) { if (isUniqueViolation(e)) throw new RuleError('conflict', 'that username or phone number is already used'); @@ -56,6 +56,13 @@ export function setAccountPassword(db: Db, userId: number, passwordHash: string) if (result.changes === 0) throw new RuleError('not_found', 'that account does not exist'); } +export function setResultsSending(db: Db, userId: number, on: boolean): void { + const result = db + .prepare('UPDATE users SET send_results = ? WHERE id = ? AND deleted_at IS NULL') + .run(on ? 1 : 0, userId); + if (result.changes === 0) throw new RuleError('not_found', 'that account does not exist'); +} + export function countAdmins(db: Db): number { return (db.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1').get() as { n: number }).n; } diff --git a/src/services/userAdmin.test.ts b/src/services/userAdmin.test.ts index fe0d2ac..8150b7c 100644 --- a/src/services/userAdmin.test.ts +++ b/src/services/userAdmin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { makeDb, seedFixture } from '../testing/helpers'; import { shopsForUser } from './shopScope'; -import { createUser, deleteUser, listManagedUsers, RuleError, setUserShops, updateUser, type RuleErrorCode } from './userAdmin'; +import { createUser, deleteUser, getManagedUser, listManagedUsers, RuleError, setUserShops, updateUser, type RuleErrorCode } from './userAdmin'; import { findUserByPhone, getUserById } from './users'; const setup = () => { @@ -94,6 +94,7 @@ describe('admins are fixed', () => { 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(() => updateUser(db, admin, f.admin2.id, { sendResults: false }))).toBe('target_is_admin'); expect(code(() => setUserShops(db, admin, f.admin2.id, [f.shops.a]))).toBe('target_is_admin'); }); @@ -118,6 +119,12 @@ describe('changing ordinary users', () => { expect(updateUser(db, admin, f.shopUser.id, { active: true }).active).toBe(true); }); + it('turns result messages on and off for a user', () => { + const { db, f, admin } = setup(); + expect(updateUser(db, admin, f.shopUser.id, { sendResults: true }).sendResults).toBe(true); + expect(updateUser(db, admin, f.shopUser.id, { sendResults: false }).sendResults).toBe(false); + }); + it('removes a phone number with null', () => { const { db, f, admin } = setup(); expect(updateUser(db, admin, f.shopUser.id, { phone: null }).phone).toBeNull(); @@ -204,12 +211,27 @@ describe('deleting a user', () => { }); describe('listing users with their shops', () => { - it('gives each user the shops assigned, switched off ones included, and admins none', () => { + it('gives each user the shops assigned, switched off ones included', () => { const { db, f, admin } = setup(); db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); const byId = new Map(listManagedUsers(db, admin).map((u) => [u.id, u])); expect(byId.get(f.multiUser.id)!.shopIds).toEqual([f.shops.a, f.shops.b]); - expect(byId.get(f.admin.id)!.shopIds).toEqual([]); + }); + + it('lists users only, never admins', () => { + const { db, f, admin } = setup(); + const list = listManagedUsers(db, admin); + expect(list.every((u) => !u.isAdmin)).toBe(true); + expect(list.map((u) => u.id)).not.toContain(f.admin.id); + expect(list.map((u) => u.id)).not.toContain(f.admin2.id); + expect(list.map((u) => u.id)).toContain(f.disabledUser.id); + }); + + it('will not open an admin', () => { + const { db, f, admin } = setup(); + expect(code(() => getManagedUser(db, admin, f.admin2.id))).toBe('target_is_admin'); + expect(code(() => getManagedUser(db, admin, admin.id))).toBe('target_is_admin'); + expect(code(() => getManagedUser(db, admin, f.shopUser.id))).toBe('no error'); }); it('keeps a switched-off shop when the same list is saved again', () => { diff --git a/src/services/userAdmin.ts b/src/services/userAdmin.ts index 28b700e..7523c2b 100644 --- a/src/services/userAdmin.ts +++ b/src/services/userAdmin.ts @@ -18,6 +18,7 @@ export interface UserPatch { phone?: string | null; active?: boolean; passwordHash?: string; + sendResults?: boolean; } function manageableTarget(db: Db, userId: number): User { @@ -30,14 +31,15 @@ const DELETED_HASH = 'deleted'; export function listManagedUsers(db: Db, actor: User): UserWithShops[] { requireAdmin(db, actor); - return withShops(db, listUsers(db)); + return withShops( + db, + listUsers(db).filter((u) => !u.isAdmin), + ); } export function getManagedUser(db: Db, actor: User, userId: number): UserWithShops { requireAdmin(db, actor); - const user = getUserById(db, userId); - if (!user) throw new RuleError('not_found', 'user not found'); - return withShops(db, [user])[0]!; + return withShops(db, [manageableTarget(db, userId)])[0]!; } export function createUser(db: Db, actor: User, input: NewUser, defaultCountryCode = '91'): User { @@ -69,6 +71,10 @@ export function updateUser(db: Db, actor: User, userId: number, patch: UserPatch sets.push('active = ?'); values.push(patch.active ? 1 : 0); } + if (patch.sendResults !== undefined) { + sets.push('send_results = ?'); + values.push(patch.sendResults ? 1 : 0); + } if (patch.passwordHash !== undefined) { if (!patch.passwordHash) throw new RuleError('invalid', 'password cannot be empty'); sets.push('password_hash = ?'); diff --git a/src/services/users.ts b/src/services/users.ts index bf2b34e..cc6ff1e 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -6,6 +6,7 @@ export interface User { phone: string | null; isAdmin: boolean; active: boolean; + sendResults: boolean; } export interface UserWithShops extends User { @@ -18,9 +19,10 @@ interface UserRow { phone: string | null; is_admin: number; active: number; + send_results: number; } -const COLUMNS = 'id, username, phone, is_admin, active'; +const COLUMNS = 'id, username, phone, is_admin, active, send_results'; const LIVE = 'deleted_at IS NULL'; function toUser(r: UserRow): User { @@ -30,6 +32,7 @@ function toUser(r: UserRow): User { phone: r.phone, isAdmin: r.is_admin === 1, active: r.active === 1, + sendResults: r.send_results === 1, }; } diff --git a/src/whatsapp/messages.ts b/src/whatsapp/messages.ts index c29b77e..e74438b 100644 --- a/src/whatsapp/messages.ts +++ b/src/whatsapp/messages.ts @@ -237,3 +237,26 @@ export function splitQuestion(lineCount: number, ticketCount: number): string { '3. A new order (throw the old one away)', ].join('\n'); } + +export interface ResultShop { + name: string; + lines: { ending: string; qty: number; hits: { tier: number; amount: number }[] }[]; +} + +const rupees = (n: number): string => `Rs ${n.toLocaleString('en-IN')}`; + +export function resultSummary(draw: { scheme: string; code: string; date: string }, shops: ResultShop[]): string { + const out = [`🎯 *Results: ${draw.scheme} ${draw.code}* (${formatDay(draw.date)})`]; + let total = 0; + for (const shop of shops) { + const wins = shop.lines.filter((l) => l.hits.length > 0); + const prize = wins.reduce((sum, l) => sum + l.qty * l.hits.reduce((s, h) => s + h.amount, 0), 0); + total += prize; + out.push('', prize > 0 ? `*${shop.name}*: ${rupees(prize)}` : `*${shop.name}*: no winners`); + for (const l of wins) { + for (const h of l.hits) out.push(` ${l.ending} x${l.qty} - ${h.tier}th prize, ${rupees(h.amount)} each`); + } + } + if (shops.length > 1) out.push('', total > 0 ? `*Total*: ${rupees(total)}` : '*Total*: no winners'); + return out.join('\n'); +}