feat: added WhatsApp summary options for users
All checks were successful
deploy / test (push) Successful in 1m13s
deploy / deploy (push) Successful in 19s

This commit is contained in:
Aswin B. S 2026-11-05 00:37:57 +05:30
parent 61514fb82f
commit c6251810ae
19 changed files with 638 additions and 32 deletions

View File

@ -18,8 +18,10 @@ Needs Node 22 or newer.
npm install npm install
cp .env.example .env cp .env.example .env
npm run migrate # creates ./data/srs.db 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 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 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 user list # show every account
npm run shop create # add a shop (name + unique code) npm run shop create # add a shop (name + unique code)
npm run shop list # show every shop npm run shop list # show every shop

250
scripts/seed_local.py Normal file
View File

@ -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())

View File

@ -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 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 password set a new password for an admin or a user
* npm run user list show every account * 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 { loadConfig } from '../src/config';
import { openDb } from '../src/db'; import { openDb } from '../src/db';
import { runMigrations } from '../src/db/migrate'; import { runMigrations } from '../src/db/migrate';
@ -12,10 +13,12 @@ import { makeIo } from './cliIo';
async function main(): Promise<number> { async function main(): Promise<number> {
const command = process.argv[2]; 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('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 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 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'); console.log(' npm run user list show every account');
return command ? 1 : 0; return command ? 1 : 0;
} }
@ -31,9 +34,10 @@ async function main(): Promise<number> {
} }
const { io, close } = makeIo(); const { io, close } = makeIo();
try { try {
return command === 'password' if (command === 'manage') return await manageWizard(db, io, config.defaultCountryCode);
? await passwordWizard(db, io) if (command === 'password') return await passwordWizard(db, io);
: await createWizard(db, io, config.defaultCountryCode); if (command === 'results') return await resultsWizard(db, io);
return await createWizard(db, io, config.defaultCountryCode);
} finally { } finally {
close(); close();
} }

View File

@ -3,7 +3,7 @@ import { makeDb, seedFixture } from '../testing/helpers';
import { shopsForUser } from '../services/shopScope'; import { shopsForUser } from '../services/shopScope';
import { findUserByUsername, getUserById } from '../services/users'; import { findUserByUsername, getUserById } from '../services/users';
import { verifyPassword } from '../services/password'; 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[]) { function scripted(answers: string[]) {
const shown: string[] = []; const shown: string[] = [];
@ -177,9 +177,9 @@ describe('listing', () => {
const db = makeDb(); const db = makeDb();
seedFixture(db); seedFixture(db);
const text = listLines(db).join('\n'); const text = listLines(db).join('\n');
expect(text).toMatch(/admin\s+active\s+admin\s+919000000001\s+all shops/); expect(text).toMatch(/admin\s+active\s+off\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+active\s+off\s+shop-a\s+919000000002\s+Shop A/);
expect(text).toMatch(/user\s+disabled\s+gone/); expect(text).toMatch(/user\s+disabled\s+off\s+gone/);
expect(text).not.toContain('password'); expect(text).not.toContain('password');
}); });
}); });
@ -225,3 +225,52 @@ describe('resetting a password', () => {
expect(getUserById(db, f.disabledUser.id)?.active).toBe(false); expect(getUserById(db, f.disabledUser.id)?.active).toBe(false);
}); });
}); });
describe('result messages', () => {
const sendsResults = (db: ReturnType<typeof makeDb>, 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.');
});
});

View File

@ -2,7 +2,13 @@ import type { Db } from '../db';
import { isValidUsername, RuleError, USERNAME_HELP } from '../services/accountRules'; import { isValidUsername, RuleError, USERNAME_HELP } from '../services/accountRules';
import { cleanPassword, hashPassword } from '../services/password'; import { cleanPassword, hashPassword } from '../services/password';
import { normalizePhone } from '../services/phone'; 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'; import { findUserByUsername, listUsers, type User } from '../services/users';
export class Cancelled extends Error { 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<User> => {
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<number> { export async function passwordWizard(db: Db, io: Io): Promise<number> {
try { try {
io.say('Reset a password'); io.say('Reset a password');
io.say(' Works for admins and users. (type q at any question to quit)'); io.say(' Works for admins and users. (type q at any question to quit)');
io.say(); io.say();
const parseAccount = (a: string): Parsed<User> => { const user = await askUntil(io, 'Username: ', parseAccount(db));
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 role = user.isAdmin ? 'ADMIN' : 'USER'; const role = user.isAdmin ? 'ADMIN' : 'USER';
io.say(` ${role} "${user.username}"${user.active ? '' : ' (switched off: it stays switched off)'}`); 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<number> {
} }
} }
export async function resultsWizard(db: Db, io: Io): Promise<number> {
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<number> {
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<number> =>
/^[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[] { export function listLines(db: Db): string[] {
const users = listUsers(db); const users = listUsers(db);
if (users.length === 0) return ['No accounts yet. Run: npm run user create']; 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`, `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) { for (const u of users) {
const shops = u.isAdmin const shops = u.isAdmin
? 'all shops' ? 'all shops'
@ -206,6 +261,7 @@ export function listLines(db: Db): string[] {
[ [
(u.isAdmin ? 'admin' : 'user').padEnd(6), (u.isAdmin ? 'admin' : 'user').padEnd(6),
(u.active ? 'active' : 'disabled').padEnd(9), (u.active ? 'active' : 'disabled').padEnd(9),
(u.sendResults ? 'on' : 'off').padEnd(8),
u.username.padEnd(20), u.username.padEnd(20),
(u.phone ?? '-').padEnd(15), (u.phone ?? '-').padEnd(15),
shops, shops,

View File

@ -8,7 +8,7 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE
describe('migrations', () => { describe('migrations', () => {
it('apply once and are then a no-op', () => { it('apply once and are then a no-op', () => {
const db = openDb(':memory:'); 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([]); expect(runMigrations(db)).toEqual([]);
}); });
}); });

View File

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

View File

@ -11,6 +11,7 @@ export interface SchedulerOptions {
retryMin?: number; retryMin?: number;
now?: () => Date; now?: () => Date;
run?: (db: Db) => Promise<DrawReport[]>; run?: (db: Db) => Promise<DrawReport[]>;
onStored?: (drawCode: string) => void;
} }
function todaysDrawReady(db: Db, today: string): boolean { function todaysDrawReady(db: Db, today: string): boolean {
@ -59,6 +60,11 @@ export function createResultsScheduler(opts: SchedulerOptions) {
}); });
} else if (r.status === 'ready') { } else if (r.status === 'ready') {
opts.log({ level: 'info', msg: 'results: draw stored', draw: r.code, date: r.date }); 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) { } catch (err) {

View File

@ -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<Record<string, unknown>> = [];
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' }));
});
});

View File

@ -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<number, ResultShop>();
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<string, ResultShop['lines'][number]>();
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));
}

View File

@ -61,7 +61,7 @@ describe('draws', () => {
it('gives today before any result exists', async () => { it('gives today before any result exists', async () => {
const empty = makeDb(); 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' }); expect((await other.inject({ method: 'GET', url: '/api/draws/latest' })).json()).toEqual({ date: '2026-11-04' });
await other.close(); await other.close();
}); });
@ -163,7 +163,7 @@ describe('users', () => {
shopIds: [f.shops.a, f.shops.b], shopIds: [f.shops.a, f.shops.b],
}); });
expect(res.statusCode).toBe(201); 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; 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(hash).not.toContain('longenough');
expect(await verifyPassword('longenough', hash)).toBe(true); expect(await verifyPassword('longenough', hash)).toBe(true);
@ -179,6 +179,8 @@ describe('users', () => {
it('lists, reads, updates, reassigns and deletes', async () => { it('lists, reads, updates, reassigns and deletes', async () => {
const list = (await call(f.admin.id, 'GET', '/api/users')).json(); 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.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' }); 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 }); expect(patched.json()).toMatchObject({ username: 'renamed', phone: null, active: false });

View File

@ -40,7 +40,7 @@ describe('signing in', () => {
const res = await login('shop-ab', 'multi password'); const res = await login('shop-ab', 'multi password');
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = res.json(); 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'); expect(JSON.stringify(body)).not.toContain('password');
}); });

View File

@ -26,6 +26,7 @@ const userPatch = z.object({
phone: z.string().nullable().optional(), phone: z.string().nullable().optional(),
password: z.string().optional(), password: z.string().optional(),
active: z.boolean().optional(), active: z.boolean().optional(),
sendResults: z.boolean().optional(),
}); });
export const userRoutes = export const userRoutes =

View File

@ -33,6 +33,11 @@ export function getDrawOn(db: Db, date: string): Draw | undefined {
return row && toDraw(row); 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 { 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 const row = db.prepare(`SELECT ${COLUMNS} FROM draws WHERE draw_date >= ? ORDER BY draw_date LIMIT 1`).get(date) as
| DrawRow | DrawRow

View File

@ -15,8 +15,8 @@ function insert(db: Db, username: string, passwordHash: string, phone: string |
try { try {
return Number( return Number(
db db
.prepare('INSERT INTO users (username, password_hash, phone, is_admin, active) VALUES (?, ?, ?, ?, 1)') .prepare('INSERT INTO users (username, password_hash, phone, is_admin, active, send_results) VALUES (?, ?, ?, ?, 1, ?)')
.run(username, passwordHash, phone, isAdmin).lastInsertRowid, .run(username, passwordHash, phone, isAdmin, isAdmin).lastInsertRowid,
); );
} catch (e) { } catch (e) {
if (isUniqueViolation(e)) throw new RuleError('conflict', 'that username or phone number is already used'); 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'); 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 { export function countAdmins(db: Db): number {
return (db.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1').get() as { n: number }).n; return (db.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1').get() as { n: number }).n;
} }

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { makeDb, seedFixture } from '../testing/helpers'; import { makeDb, seedFixture } from '../testing/helpers';
import { shopsForUser } from './shopScope'; 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'; import { findUserByPhone, getUserById } from './users';
const setup = () => { 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, { 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, { 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, { 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'); 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); 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', () => { it('removes a phone number with null', () => {
const { db, f, admin } = setup(); const { db, f, admin } = setup();
expect(updateUser(db, admin, f.shopUser.id, { phone: null }).phone).toBeNull(); 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', () => { 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(); const { db, f, admin } = setup();
db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); 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])); 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.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', () => { it('keeps a switched-off shop when the same list is saved again', () => {

View File

@ -18,6 +18,7 @@ export interface UserPatch {
phone?: string | null; phone?: string | null;
active?: boolean; active?: boolean;
passwordHash?: string; passwordHash?: string;
sendResults?: boolean;
} }
function manageableTarget(db: Db, userId: number): User { function manageableTarget(db: Db, userId: number): User {
@ -30,14 +31,15 @@ const DELETED_HASH = 'deleted';
export function listManagedUsers(db: Db, actor: User): UserWithShops[] { export function listManagedUsers(db: Db, actor: User): UserWithShops[] {
requireAdmin(db, actor); 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 { export function getManagedUser(db: Db, actor: User, userId: number): UserWithShops {
requireAdmin(db, actor); requireAdmin(db, actor);
const user = getUserById(db, userId); return withShops(db, [manageableTarget(db, userId)])[0]!;
if (!user) throw new RuleError('not_found', 'user not found');
return withShops(db, [user])[0]!;
} }
export function createUser(db: Db, actor: User, input: NewUser, defaultCountryCode = '91'): User { 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 = ?'); sets.push('active = ?');
values.push(patch.active ? 1 : 0); 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 !== undefined) {
if (!patch.passwordHash) throw new RuleError('invalid', 'password cannot be empty'); if (!patch.passwordHash) throw new RuleError('invalid', 'password cannot be empty');
sets.push('password_hash = ?'); sets.push('password_hash = ?');

View File

@ -6,6 +6,7 @@ export interface User {
phone: string | null; phone: string | null;
isAdmin: boolean; isAdmin: boolean;
active: boolean; active: boolean;
sendResults: boolean;
} }
export interface UserWithShops extends User { export interface UserWithShops extends User {
@ -18,9 +19,10 @@ interface UserRow {
phone: string | null; phone: string | null;
is_admin: number; is_admin: number;
active: 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'; const LIVE = 'deleted_at IS NULL';
function toUser(r: UserRow): User { function toUser(r: UserRow): User {
@ -30,6 +32,7 @@ function toUser(r: UserRow): User {
phone: r.phone, phone: r.phone,
isAdmin: r.is_admin === 1, isAdmin: r.is_admin === 1,
active: r.active === 1, active: r.active === 1,
sendResults: r.send_results === 1,
}; };
} }

View File

@ -237,3 +237,26 @@ export function splitQuestion(lineCount: number, ticketCount: number): string {
'3. A new order (throw the old one away)', '3. A new order (throw the old one away)',
].join('\n'); ].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');
}