feat: added WhatsApp summary options for users
This commit is contained in:
parent
61514fb82f
commit
c6251810ae
@ -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
250
scripts/seed_local.py
Normal 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())
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
9
src/db/migrations/006_send_results.sql
Normal file
9
src/db/migrations/006_send_results.sql
Normal 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)
|
||||||
|
);
|
||||||
@ -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) {
|
||||||
|
|||||||
104
src/results/sendResults.test.ts
Normal file
104
src/results/sendResults.test.ts
Normal 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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
57
src/results/sendResults.ts
Normal file
57
src/results/sendResults.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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 = ?');
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user