From 61514fb82f58fd185c0981771b2c5d20f99713ed Mon Sep 17 00:00:00 2001 From: Aswin Biju Date: Wed, 4 Nov 2026 20:44:36 +0530 Subject: [PATCH] feat(users): added a wizard to reset a password and supporting services for the srs PWA --- .env.example | 5 +- README.md | 5 +- deploy/deploy.sh | 2 +- scripts/cliIo.ts | 17 +- scripts/user.ts | 14 +- src/app.ts | 13 +- src/cli/userWizard.test.ts | 113 ++++++--- src/cli/userWizard.ts | 81 +++++-- src/config.test.ts | 20 ++ src/config.ts | 6 + src/db/migrate.test.ts | 2 +- .../004_user_delete_and_order_edit.sql | 5 + src/db/migrations/005_username_nocase.sql | 2 + src/routes/api/api.test.ts | 214 ++++++++++++++++++ src/routes/api/cors.ts | 16 ++ src/routes/api/draws.ts | 23 ++ src/routes/api/errors.ts | 30 +++ src/routes/api/guard.ts | 20 ++ src/routes/api/index.ts | 39 ++++ src/routes/api/orders.ts | 40 ++++ src/routes/api/schemas.ts | 10 + src/routes/api/session.test.ts | 122 ++++++++++ src/routes/api/session.ts | 70 ++++++ src/routes/api/shops.ts | 36 +++ src/routes/api/users.ts | 72 ++++++ src/services/accountRules.ts | 16 +- src/services/dateMath.test.ts | 32 +++ src/services/dateMath.ts | 18 ++ src/services/drawDate.ts | 4 +- src/services/draws.test.ts | 83 +++++++ src/services/draws.ts | 86 +++++++ src/services/loginThrottle.test.ts | 31 +++ src/services/loginThrottle.ts | 35 +++ src/services/orderEdit.test.ts | 87 +++++++ src/services/orderEdit.ts | 55 +++++ src/services/orderQueries.ts | 103 +++++++++ src/services/password.test.ts | 34 +++ src/services/password.ts | 34 +++ src/services/provisioning.ts | 7 + src/services/reports.test.ts | 166 ++++++++++++++ src/services/reports.ts | 152 +++++++++++++ src/services/shopAdmin.test.ts | 52 +++++ src/services/shopAdmin.ts | 35 +++ src/services/shopScope.test.ts | 22 +- src/services/shopScope.ts | 25 ++ src/services/shops.ts | 31 ++- src/services/token.test.ts | 43 ++++ src/services/token.ts | 42 ++++ src/services/userAdmin.test.ts | 60 ++++- src/services/userAdmin.ts | 40 +++- src/services/users.ts | 35 ++- src/testing/config.ts | 1 + src/testing/helpers.ts | 46 ++++ 53 files changed, 2259 insertions(+), 93 deletions(-) create mode 100644 src/db/migrations/004_user_delete_and_order_edit.sql create mode 100644 src/db/migrations/005_username_nocase.sql create mode 100644 src/routes/api/api.test.ts create mode 100644 src/routes/api/cors.ts create mode 100644 src/routes/api/draws.ts create mode 100644 src/routes/api/errors.ts create mode 100644 src/routes/api/guard.ts create mode 100644 src/routes/api/index.ts create mode 100644 src/routes/api/orders.ts create mode 100644 src/routes/api/schemas.ts create mode 100644 src/routes/api/session.test.ts create mode 100644 src/routes/api/session.ts create mode 100644 src/routes/api/shops.ts create mode 100644 src/routes/api/users.ts create mode 100644 src/services/dateMath.test.ts create mode 100644 src/services/dateMath.ts create mode 100644 src/services/draws.test.ts create mode 100644 src/services/draws.ts create mode 100644 src/services/loginThrottle.test.ts create mode 100644 src/services/loginThrottle.ts create mode 100644 src/services/orderEdit.test.ts create mode 100644 src/services/orderEdit.ts create mode 100644 src/services/orderQueries.ts create mode 100644 src/services/password.test.ts create mode 100644 src/services/password.ts create mode 100644 src/services/reports.test.ts create mode 100644 src/services/reports.ts create mode 100644 src/services/shopAdmin.test.ts create mode 100644 src/services/shopAdmin.ts create mode 100644 src/services/token.test.ts create mode 100644 src/services/token.ts diff --git a/.env.example b/.env.example index 5622b7c..b94bf01 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,12 @@ DB_PATH=./data/srs.db # Shared secret between the Hermes plugin and srs-api (header: x-srs-secret). Min 16 chars. # Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" SRS_SECRET=change-me-to-a-long-random-string - JWT_SECRET=change-me-to-another-long-random-string +# Development only +# Leave empty in production, where the app and the API share one address. +CORS_ORIGIN= + # Hermes WhatsApp bridge (local only) BRIDGE_URL=http://127.0.0.1:3000 DEFAULT_COUNTRY_CODE=91 diff --git a/README.md b/README.md index 87fa224..4366d9e 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,12 @@ Needs Node 22 or newer. npm install cp .env.example .env npm run migrate # creates ./data/srs.db -npm run user create # a short wizard: choose Admin or User +npm run user 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 list # show every account npm run shop create # add a shop (name + unique code) npm run shop list # show every shop npm run dev # http://127.0.0.1:8080 npm test npm run typecheck -``` \ No newline at end of file +``` diff --git a/deploy/deploy.sh b/deploy/deploy.sh index e5c28be..ba644c9 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -39,7 +39,7 @@ echo "== database backup" if [ -f "$DB_PATH" ]; then mkdir -p "$BACKUPS" sqlite3 "$DB_PATH" ".backup '$BACKUPS/srs-$(date +%Y%m%d-%H%M%S).db'" - ls -1t "$BACKUPS"/srs-*.db | tail -n +15 | xargs -r rm -f + ls -1t "$BACKUPS"/srs-*.db | tail -n +6 | xargs -r rm -f else echo "no database yet: skipped" fi diff --git a/scripts/cliIo.ts b/scripts/cliIo.ts index 2c6db10..f372fa5 100644 --- a/scripts/cliIo.ts +++ b/scripts/cliIo.ts @@ -25,14 +25,17 @@ export function makeIo(): { io: Io; close: () => void } { } }); + async function read(question: string): Promise { + rl.setPrompt(question); + rl.prompt(); + const line = queue.length > 0 ? queue.shift()! : ended ? null : await new Promise((r) => (waiting = r)); + if (line === null) throw new Cancelled(); + return line; + } + const io: Io = { - async ask(question) { - rl.setPrompt(question); - rl.prompt(); - const line = queue.length > 0 ? queue.shift()! : ended ? null : await new Promise((r) => (waiting = r)); - if (line === null) throw new Cancelled(); - return line.trim(); - }, + ask: async (question) => (await read(question)).trim(), + askExact: read, say(line = '') { console.log(line); }, diff --git a/scripts/user.ts b/scripts/user.ts index c2be545..b332a55 100644 --- a/scripts/user.ts +++ b/scripts/user.ts @@ -1,9 +1,10 @@ /** * npm run user create a short wizard: choose Admin or User + * npm run user password set a new password for an admin or a user * npm run user list show every account * */ -import { createWizard, listLines } from '../src/cli/userWizard'; +import { createWizard, listLines, passwordWizard } from '../src/cli/userWizard'; import { loadConfig } from '../src/config'; import { openDb } from '../src/db'; import { runMigrations } from '../src/db/migrate'; @@ -11,10 +12,11 @@ import { makeIo } from './cliIo'; async function main(): Promise { const command = process.argv[2]; - if (command !== 'create' && command !== 'list') { + if (command !== 'create' && command !== 'password' && command !== 'list') { console.log('usage:'); - console.log(' npm run user create create an admin or a user (a short wizard)'); - console.log(' npm run user list show every account'); + 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 list show every account'); return command ? 1 : 0; } @@ -29,7 +31,9 @@ async function main(): Promise { } const { io, close } = makeIo(); try { - return await createWizard(db, io, config.defaultCountryCode); + return command === 'password' + ? await passwordWizard(db, io) + : await createWizard(db, io, config.defaultCountryCode); } finally { close(); } diff --git a/src/app.ts b/src/app.ts index b2f2fef..0dd2710 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,11 @@ import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import type { Config } from './config'; import type { Db } from './db'; +import { apiRoutes } from './routes/api'; +import { toApiError } from './routes/api/errors'; +import type { Authenticator } from './routes/api/guard'; +import { bearerAuthenticator } from './routes/api/session'; +import { createTokenService } from './services/token'; import { healthRoutes } from './routes/health'; import { ingestRoutes } from './routes/ingest'; import type { Bridge } from './whatsapp/bridge'; @@ -15,6 +20,7 @@ export interface AppDeps { sendQueue?: SendQueue; /** Left out, photos are off. Only server.ts builds one that can reach the model. */ photoQueue?: PhotoQueue; + authenticate?: Authenticator; now?: () => Date; } @@ -23,7 +29,7 @@ export interface AppOptions { } export function createApp( - { db, config, bridge, sendQueue, photoQueue, now }: AppDeps, + { db, config, bridge, sendQueue, photoQueue, authenticate, now }: AppDeps, options: AppOptions = {}, ): FastifyInstance { const app = Fastify({ @@ -38,12 +44,17 @@ export function createApp( photoQueue ?? createPhotoQueue({ db, settings: config.orders, queue, log: (entry) => app.log.error(entry), now }); app.register(ingestRoutes({ db, secret: config.srsSecret, settings: config.orders, queue, photoQueue: photos, now })); + const tokens = config.jwtSecret ? createTokenService(config.jwtSecret, now) : undefined; + const who = authenticate ?? (tokens && bearerAuthenticator(db, tokens)); + if (who) app.register(apiRoutes({ db, config, authenticate: who, tokens, now }), { prefix: '/api' }); app.setNotFoundHandler((_req, reply) => { return reply.code(404).send({ error: 'not_found' }); }); app.setErrorHandler((err, req, reply) => { + const known = toApiError(err); + if (known) return reply.code(known.status).send(known.body); const status = (err as { statusCode?: number }).statusCode ?? 500; if (status === 413) return reply.code(413).send({ error: 'payload_too_large' }); if (status === 415) return reply.code(415).send({ error: 'unsupported_media_type' }); diff --git a/src/cli/userWizard.test.ts b/src/cli/userWizard.test.ts index 1a7bb6c..a950be7 100644 --- a/src/cli/userWizard.test.ts +++ b/src/cli/userWizard.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; import { makeDb, seedFixture } from '../testing/helpers'; import { shopsForUser } from '../services/shopScope'; -import { getUserById } from '../services/users'; -import { Cancelled, createWizard, listLines, type Io } from './userWizard'; +import { findUserByUsername, getUserById } from '../services/users'; +import { verifyPassword } from '../services/password'; +import { Cancelled, createWizard, listLines, passwordWizard, type Io } from './userWizard'; function scripted(answers: string[]) { const shown: string[] = []; @@ -26,7 +27,7 @@ const rows = (db: ReturnType, sql: string) => db.prepare(sql).all describe('creating an admin', () => { it('asks for the username and phone, then creates the admin', async () => { const db = makeDb(); - const s = scripted(['1', 'owner', '98470 49032', 'y']); + const s = scripted(['1', 'owner', '98470 49032', 'long enough', 'long enough']); expect(await createWizard(db, s.io)).toBe(0); expect(rows(db, "SELECT username, phone, is_admin, active FROM users")).toEqual([ { username: 'owner', phone: '919847049032', is_admin: 1, active: 1 }, @@ -36,40 +37,30 @@ describe('creating an admin', () => { it('counts the admins that exist', async () => { const db = makeDb(); - await createWizard(db, scripted(['1', 'owner', '919847049032', 'y']).io); - const s = scripted(['admin', 'partner', '919847011111', 'yes']); + await createWizard(db, scripted(['1', 'owner', '919847049032', 'long enough', 'long enough']).io); + const s = scripted(['admin', 'partner', '919847011111', 'long enough', 'long enough', 'yes']); await createWizard(db, s.io); expect(s.text()).toContain('Admins now: 2.'); }); it('asks again after a bad username or phone number', async () => { const db = makeDb(); - const s = scripted(['1', 'x', 'owner', '12', 'abc', '919847049032', 'y']); + const s = scripted(['1', 'x', 'owner', '12', 'abc', '919847049032', 'long enough', 'long enough']); expect(await createWizard(db, s.io)).toBe(0); expect(s.text()).toContain('Use letters, digits'); expect(s.text()).toContain('does not look like a phone number'); expect(rows(db, 'SELECT username FROM users')).toEqual([{ username: 'owner' }]); }); - it('creates nothing when you answer no, or just press Enter', async () => { - for (const answer of ['n', '']) { - const db = makeDb(); - const s = scripted(['1', 'owner', '919847049032', answer]); - expect(await createWizard(db, s.io)).toBe(0); - expect(rows(db, 'SELECT * FROM users')).toEqual([]); - expect(s.text()).toContain('Nothing was created'); - } - }); - it('refuses a username or phone that already exists', async () => { const db = makeDb(); - await createWizard(db, scripted(['1', 'owner', '919847049032', 'y']).io); + await createWizard(db, scripted(['1', 'owner', '919847049032', 'long enough', 'long enough']).io); - const dupName = scripted(['1', 'owner', '919000000009', 'y']); + const dupName = scripted(['1', 'owner', '919000000009', 'long enough', 'long enough']); expect(await createWizard(db, dupName.io)).toBe(1); expect(dupName.text()).toContain('already used'); - const dupPhone = scripted(['1', 'someone', '919847049032', 'y']); + const dupPhone = scripted(['1', 'someone', '919847049032', 'long enough', 'long enough']); expect(await createWizard(db, dupPhone.io)).toBe(1); expect(rows(db, 'SELECT COUNT(*) AS n FROM users')).toEqual([{ n: 1 }]); }); @@ -80,7 +71,7 @@ describe('creating a user', () => { const db = makeDb(); const f = seedFixture(db); // fixture shops sort as Shop A, Shop B, Shop C - const s = scripted(['2', 'new-user', '9847012345', '2,3', 'y']); + const s = scripted(['2', 'new-user', '9847012345', '2,3', 'long enough', 'long enough']); expect(await createWizard(db, s.io)).toBe(0); const row = db.prepare("SELECT id, is_admin, phone FROM users WHERE username = 'new-user'").get() as { id: number; @@ -89,13 +80,12 @@ describe('creating a user', () => { }; expect(row).toMatchObject({ is_admin: 0, phone: '919847012345' }); expect(shopsForUser(db, getUserById(db, row.id)!).map((x) => x.id)).toEqual([f.shops.b, f.shops.c]); - expect(s.text()).toContain('shops: Shop B, Shop C'); }); it('allows no phone and no shops', async () => { const db = makeDb(); seedFixture(db); - expect(await createWizard(db, scripted(['2', 'plain', '', '', 'y']).io)).toBe(0); + expect(await createWizard(db, scripted(['2', 'plain', '', '', 'long enough', 'long enough']).io)).toBe(0); expect(db.prepare("SELECT phone, is_admin FROM users WHERE username = 'plain'").get()).toEqual({ phone: null, is_admin: 0, @@ -105,15 +95,15 @@ describe('creating a user', () => { it('asks again for shop numbers that do not exist', async () => { const db = makeDb(); seedFixture(db); - const s = scripted(['2', 'u-one', '', '9', 'x', '1', 'y']); + const s = scripted(['2', 'u-one', '', '9', 'x', '1', 'long enough', 'long enough']); expect(await createWizard(db, s.io)).toBe(0); expect(s.text()).toContain('numbers from 1 to 3'); - expect(s.text()).toContain('shops: Shop A'); + expect(shopsForUser(db, findUserByUsername(db, 'u-one')!).map((x) => x.name)).toEqual(['Shop A']); }); it('skips the shop question when there are no shops yet', async () => { const db = makeDb(); - const s = scripted(['2', 'early', '', 'y']); + const s = scripted(['2', 'early', '', 'long enough', 'long enough']); expect(await createWizard(db, s.io)).toBe(0); expect(s.text()).toContain('There are no shops yet'); expect(rows(db, "SELECT username FROM users")).toEqual([{ username: 'early' }]); @@ -121,11 +111,40 @@ describe('creating a user', () => { it('can never make an admin through the user path', async () => { const db = makeDb(); - await createWizard(db, scripted(['2', 'normal', '', 'y']).io); + await createWizard(db, scripted(['2', 'normal', '', 'long enough', 'long enough']).io); expect(rows(db, 'SELECT is_admin FROM users')).toEqual([{ is_admin: 0 }]); }); }); +describe('the password', () => { + it('is stored as a hash that signs the account in', async () => { + const db = makeDb(); + await createWizard(db, scripted(['1', 'owner', '919847049032', 'correct horse', 'correct horse']).io); + const hash = (db.prepare('SELECT password_hash h FROM users').get() as { h: string }).h; + expect(hash).not.toContain('correct horse'); + expect(await verifyPassword('correct horse', hash)).toBe(true); + }); + + it('is asked again when it is too short or the two do not match', async () => { + const db = makeDb(); + const s = scripted(['1', 'owner', '919847049032', 'short', 'long enough', 'different one', 'long enough', 'long enough']); + expect(await createWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('The password must be at least 8 characters.'); + expect(s.text()).toContain('The two passwords are different.'); + expect(rows(db, 'SELECT username FROM users')).toEqual([{ username: 'owner' }]); + }); + + it('keeps spaces at the ends of the password', async () => { + const db = makeDb(); + const s = scripted(['1', 'owner', '919847049032']); + const io: Io = { ...s.io, askExact: async () => ' long enough ' }; + expect(await createWizard(db, io)).toBe(0); + const { h } = db.prepare("SELECT password_hash h FROM users WHERE username = 'owner'").get() as { h: string }; + expect(await verifyPassword(' long enough ', h)).toBe(true); + expect(await verifyPassword('long enough', h)).toBe(false); + }); +}); + describe('quitting', () => { it('q at any question stops with nothing created', async () => { const db = makeDb(); @@ -164,3 +183,45 @@ describe('listing', () => { expect(text).not.toContain('password'); }); }); + +describe('resetting a password', () => { + const hashOf = (db: ReturnType, id: number) => + (db.prepare('SELECT password_hash h FROM users WHERE id = ?').get(id) as { h: string }).h; + + it('sets a new password on an admin', async () => { + const db = makeDb(); + const f = seedFixture(db); + const s = scripted(['admin', 'brand new pass', 'brand new pass']); + expect(await passwordWizard(db, s.io)).toBe(0); + expect(await verifyPassword('brand new pass', hashOf(db, f.admin.id))).toBe(true); + expect(s.text()).toContain('ADMIN "admin"'); + expect(s.text()).toContain('Password changed for "admin"'); + }); + + it('asks again for an unknown username', async () => { + const db = makeDb(); + const f = seedFixture(db); + const s = scripted(['nobody', 'shop-a', 'brand new pass', 'brand new pass']); + expect(await passwordWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('No account with that username'); + expect(await verifyPassword('brand new pass', hashOf(db, f.shopUser.id))).toBe(true); + }); + + it('changes nothing when the two passwords never match', async () => { + const db = makeDb(); + const f = seedFixture(db); + const typos = scripted(['admin', ...Array.from({ length: 5 }, () => ['first try 1', 'second try 2']).flat()]); + expect(await passwordWizard(db, typos.io)).toBe(0); + expect(typos.text()).toContain('The password was not changed'); + expect(hashOf(db, f.admin.id)).toBe('x'); + }); + + it('keeps a switched-off account switched off', async () => { + const db = makeDb(); + const f = seedFixture(db); + const s = scripted(['gone', 'brand new pass', 'brand new pass']); + expect(await passwordWizard(db, s.io)).toBe(0); + expect(s.text()).toContain('stays switched off'); + expect(getUserById(db, f.disabledUser.id)?.active).toBe(false); + }); +}); diff --git a/src/cli/userWizard.ts b/src/cli/userWizard.ts index a72f6fd..ca05e97 100644 --- a/src/cli/userWizard.ts +++ b/src/cli/userWizard.ts @@ -1,8 +1,9 @@ import type { Db } from '../db'; import { isValidUsername, RuleError, USERNAME_HELP } from '../services/accountRules'; +import { cleanPassword, hashPassword } from '../services/password'; import { normalizePhone } from '../services/phone'; -import { countAdmins, createAdminAccount, createUserAccount } from '../services/provisioning'; -import { listUsers } from '../services/users'; +import { countAdmins, createAdminAccount, createUserAccount, setAccountPassword } from '../services/provisioning'; +import { findUserByUsername, listUsers, type User } from '../services/users'; export class Cancelled extends Error { constructor(message = 'cancelled') { @@ -13,6 +14,7 @@ export class Cancelled extends Error { export interface Io { ask(question: string): Promise; + askExact?(question: string): Promise; say(line?: string): void; } @@ -70,6 +72,25 @@ const parseShopNumbers = return { ok: true, value: [...new Set(numbers)] }; }; +const parsePassword = (a: string): Parsed => { + try { + return { ok: true, value: cleanPassword(a) }; + } catch (e) { + if (e instanceof RuleError) return { ok: false, error: `The ${e.message}.` }; + throw e; + } +}; + +async function askPasswordHash(io: Io): Promise { + const ask = (q: string) => (io.askExact ? io.askExact(q) : io.ask(q)); + for (let tries = 0; tries < MAX_TRIES; tries++) { + const password = await askUntil({ ...io, ask }, 'Password (at least 8 characters): ', parsePassword); + if ((await ask('Type the password again: ')) === password) return hashPassword(password); + io.say(' The two passwords are different. Try again.'); + } + throw new Cancelled('too many invalid answers'); +} + export async function confirm(io: Io, question: string): Promise { const a = (await io.ask(question)).toLowerCase(); return a === 'y' || a === 'yes'; @@ -98,15 +119,9 @@ export async function createWizard(db: Db, io: Io, defaultCountryCode = '91'): P if (kind === 'admin') { const username = await askUntil(io, 'Admin username: ', parseUsername); const phone = await askUntil(io, 'Admin phone number (with country code): ', parseRequiredPhone(defaultCountryCode)); - io.say(); - io.say(`About to create ADMIN "${username}" with phone ${phone}.`); - if (!(await confirm(io, 'Create it? [y/N]: '))) { - io.say('Cancelled. Nothing was created.'); - return 0; - } - createAdminAccount(db, { username, phone }, defaultCountryCode); + const passwordHash = await askPasswordHash(io); + createAdminAccount(db, { username, phone, passwordHash }, defaultCountryCode); io.say(`Created admin "${username}". Admins now: ${countAdmins(db)}.`); - io.say('Login is not built yet, so no password was set.'); return 0; } @@ -124,18 +139,9 @@ export async function createWizard(db: Db, io: Io, defaultCountryCode = '91'): P shopIds = picks.map((n) => shops[n - 1]!.id); } - const shopNames = shopIds.map((id) => shops.find((s) => s.id === id)!.name); - io.say(); - io.say(`About to create USER "${username}".`); - io.say(` phone: ${phone ?? '(none)'}`); - io.say(` shops: ${shopNames.length ? shopNames.join(', ') : '(none)'}`); - if (!(await confirm(io, 'Create it? [y/N]: '))) { - io.say('Cancelled. Nothing was created.'); - return 0; - } - createUserAccount(db, { username, phone, shopIds }, defaultCountryCode); + const passwordHash = await askPasswordHash(io); + createUserAccount(db, { username, phone, shopIds, passwordHash }, defaultCountryCode); io.say(`Created user "${username}".`); - io.say('Login is not built yet, so no password was set.'); return 0; } catch (e) { if (e instanceof Cancelled) { @@ -150,6 +156,39 @@ export async function createWizard(db: Db, io: Io, defaultCountryCode = '91'): P } } +export async function passwordWizard(db: Db, io: Io): Promise { + try { + io.say('Reset a password'); + io.say(' Works for admins and users. (type q at any question to quit)'); + io.say(); + + const parseAccount = (a: string): Parsed => { + const user = a.trim() === '' ? undefined : findUserByUsername(db, a.trim()); + return user + ? { ok: true, value: user } + : { ok: false, error: 'No account with that username. See them with: npm run user list' }; + }; + const user = await askUntil(io, 'Username: ', parseAccount); + const role = user.isAdmin ? 'ADMIN' : 'USER'; + io.say(` ${role} "${user.username}"${user.active ? '' : ' (switched off: it stays switched off)'}`); + + const passwordHash = await askPasswordHash(io); + setAccountPassword(db, user.id, passwordHash); + io.say(`Password changed for "${user.username}". They can sign in with it now.`); + return 0; + } catch (e) { + if (e instanceof Cancelled) { + io.say(e.message === 'cancelled' ? 'Cancelled. The password was not changed.' : `Stopped: ${e.message}. The password was not changed.`); + return 0; + } + if (e instanceof RuleError) { + io.say(`Could not change it: ${e.message}.`); + return 1; + } + throw e; + } +} + export function listLines(db: Db): string[] { const users = listUsers(db); if (users.length === 0) return ['No accounts yet. Run: npm run user create']; diff --git a/src/config.test.ts b/src/config.test.ts index f7e21e8..153a238 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -21,6 +21,26 @@ describe('loadConfig', () => { expect(c.dbPath).toBe('/tmp/x.db'); }); + describe('app sign-in settings', () => { + const base = { SRS_SECRET: 'a'.repeat(20) }; + + it('leaves sign-in and cross-origin access off by default', () => { + const c = loadConfig(base); + expect(c.jwtSecret).toBeUndefined(); + expect(c.corsOrigin).toBeUndefined(); + }); + + it('reads the token secret, which must be at least 16 characters', () => { + expect(loadConfig({ ...base, JWT_SECRET: 'b'.repeat(16) }).jwtSecret).toBe('b'.repeat(16)); + expect(() => loadConfig({ ...base, JWT_SECRET: 'short' })).toThrow(/JWT_SECRET/); + }); + + it('treats a blank CORS_ORIGIN as off', () => { + expect(loadConfig({ ...base, CORS_ORIGIN: ' ' }).corsOrigin).toBeUndefined(); + expect(loadConfig({ ...base, CORS_ORIGIN: 'http://localhost:5000' }).corsOrigin).toBe('http://localhost:5000'); + }); + }); + describe('results settings', () => { const base = { SRS_SECRET: 'a'.repeat(20) }; diff --git a/src/config.ts b/src/config.ts index ec255df..0fb64b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,10 @@ const schema = z.object({ DB_PATH: z.string().min(1).default('./data/srs.db'), SRS_SECRET: z.string().min(16, 'SRS_SECRET must be at least 16 characters'), JWT_SECRET: z.string().min(16).optional(), + CORS_ORIGIN: z + .string() + .optional() + .transform((v) => v?.trim() || undefined), BRIDGE_URL: z.string().url().default('http://127.0.0.1:3000'), DEFAULT_COUNTRY_CODE: z.string().regex(/^\d{1,3}$/, 'digits only').default('91'), ORDER_CUTOFF: hhmm('15:00'), @@ -72,6 +76,7 @@ export interface Config { dbPath: string; srsSecret: string; jwtSecret: string | undefined; + corsOrigin: string | undefined; bridgeUrl: string; defaultCountryCode: string; orders: OrderSettings; @@ -97,6 +102,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { dbPath: e.DB_PATH, srsSecret: e.SRS_SECRET, jwtSecret: e.JWT_SECRET, + corsOrigin: e.CORS_ORIGIN, bridgeUrl: e.BRIDGE_URL, defaultCountryCode: e.DEFAULT_COUNTRY_CODE, orders: { diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index 97b75fe..13ba80a 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -8,7 +8,7 @@ const throwsConstraint = (fn: () => unknown) => expect(fn).toThrow(/CHECK|UNIQUE describe('migrations', () => { it('apply once and are then a no-op', () => { const db = openDb(':memory:'); - expect(runMigrations(db)).toEqual(['001_init.sql', '002_conversation_flow.sql', '003_progress_update.sql']); + 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([]); }); }); diff --git a/src/db/migrations/004_user_delete_and_order_edit.sql b/src/db/migrations/004_user_delete_and_order_edit.sql new file mode 100644 index 0000000..04b325e --- /dev/null +++ b/src/db/migrations/004_user_delete_and_order_edit.sql @@ -0,0 +1,5 @@ +-- deleted_at set when an admin deletes a user. The row stays so every order keeps its author, but the +-- account is emptied (no phone, no password, username freed) and hidden from every list. +-- edited_at set when someone changes an order after it was saved, so the app can mark it as edited. +ALTER TABLE users ADD COLUMN deleted_at TEXT; +ALTER TABLE orders ADD COLUMN edited_at TEXT; diff --git a/src/db/migrations/005_username_nocase.sql b/src/db/migrations/005_username_nocase.sql new file mode 100644 index 0000000..96114c8 --- /dev/null +++ b/src/db/migrations/005_username_nocase.sql @@ -0,0 +1,2 @@ +-- Usernames are matched without regard to case at sign-in, so two may not differ only in case. +CREATE UNIQUE INDEX users_username_nocase ON users (username COLLATE NOCASE); diff --git a/src/routes/api/api.test.ts b/src/routes/api/api.test.ts new file mode 100644 index 0000000..ca98901 --- /dev/null +++ b/src/routes/api/api.test.ts @@ -0,0 +1,214 @@ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../../app'; +import type { Db } from '../../db'; +import { getUserById } from '../../services/users'; +import { verifyPassword } from '../../services/password'; +import { testConfig } from '../../testing/config'; +import { makeDb, seedDraw, seedFixture, seedOrder, type Fixture } from '../../testing/helpers'; +import type { Bridge } from '../../whatsapp/bridge'; + +const bridge: Bridge = { sendText: async () => ({ messageIds: [] }), health: async () => null }; + +let db: Db; +let f: Fixture; +let app: FastifyInstance; + +const authenticate = (req: { headers: Record }) => { + const id = Number(req.headers['x-test-user']); + return Number.isInteger(id) ? getUserById(db, id) : undefined; +}; + +const call = (as: number | null, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, payload?: unknown) => + app.inject({ method, url, payload: payload as object, headers: as === null ? {} : { 'x-test-user': String(as) } }); + +beforeEach(async () => { + db = makeDb(); + f = seedFixture(db); + app = createApp({ db, config: testConfig(), bridge, authenticate }); + await app.ready(); + seedDraw(db, { date: '2026-11-02', scheme: 'Bhagyathara', code: 'BT-78', prizes: [[5, 1000, '1234'], [8, 100, '1234']] }); +}); + +afterEach(() => app.close()); + +describe('access', () => { + it('turns away a request with no session', async () => { + const res = await call(null, 'GET', '/api/draws/latest'); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: 'unauthorized' }); + }); + + it('turns away a deactivated user', async () => { + expect((await call(f.disabledUser.id, 'GET', '/api/draws/latest')).statusCode).toBe(401); + }); + + it('is not mounted when no authenticator is given', async () => { + const bare = createApp({ db, config: testConfig(), bridge }); + expect((await bare.inject({ method: 'GET', url: '/api/draws/latest' })).statusCode).toBe(404); + await bare.close(); + }); + + it('keeps the health check public', async () => { + expect((await call(null, 'GET', '/api/health')).statusCode).toBe(200); + }); +}); + +describe('draws', () => { + it('gives the latest draw date', async () => { + expect((await call(f.admin.id, 'GET', '/api/draws/latest')).json()).toEqual({ date: '2026-11-02' }); + }); + + it('gives today before any result exists', async () => { + const empty = makeDb(); + const other = createApp({ db: empty, config: testConfig(), bridge, authenticate: () => ({ id: 1, username: 'a', phone: null, isAdmin: true, active: true }), now: () => new Date('2026-11-04T03:00:00Z') }); + expect((await other.inject({ method: 'GET', url: '/api/draws/latest' })).json()).toEqual({ date: '2026-11-04' }); + await other.close(); + }); + + it('gives a month and its results', async () => { + seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 3]] }); + const month = (await call(f.shopUser.id, 'GET', '/api/draws?month=2026-11')).json(); + expect(month.days).toHaveLength(30); + expect(month.days[1]).toMatchObject({ date: '2026-11-02', code: 'BT-78', published: true, hasWin: true }); + + const res = (await call(f.shopUser.id, 'GET', '/api/draws/2026-11-02/results')).json(); + expect(res.shops).toMatchObject([{ shop: { id: f.shops.a }, tickets: 3, winTickets: 3, prize: 3300 }]); + }); + + it('answers a bad date or month with a readable 400', async () => { + const res = await call(f.admin.id, 'GET', '/api/draws/2026-02-31/results'); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: 'invalid', message: 'Enter a valid date such as 2026-11-04.' }); + expect((await call(f.admin.id, 'GET', '/api/draws?month=nov')).statusCode).toBe(400); + }); +}); + +describe('shops', () => { + it('lists the shops of a day for the signed-in user', async () => { + const res = (await call(f.multiUser.id, 'GET', '/api/shops?date=2026-11-02')).json(); + expect(res.shops.map((s: { shop: { id: number } }) => s.shop.id)).toEqual([f.shops.a, f.shops.b]); + }); + + it('lets an admin add, rename and switch off a shop, and refuses a user', async () => { + const created = await call(f.admin.id, 'POST', '/api/shops', { name: 'New Shop', code: 'NEW' }); + expect(created.statusCode).toBe(201); + const id = created.json().id; + expect((await call(f.admin.id, 'PATCH', `/api/shops/${id}`, { name: 'Renamed' })).json()).toMatchObject({ name: 'Renamed' }); + expect((await call(f.admin.id, 'POST', '/api/shops', { name: 'SHOP B', code: 'OTHER' })).statusCode).toBe(409); + expect((await call(f.admin.id, 'PATCH', `/api/shops/${id}`, { active: false })).json()).toMatchObject({ active: false }); + + const refused = await call(f.shopUser.id, 'POST', '/api/shops', { name: 'Nope', code: 'NO' }); + expect(refused.statusCode).toBe(403); + }); + + it('offers only active shops for ordering', async () => { + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); + const res = (await call(f.multiUser.id, 'GET', '/api/shops/options')).json(); + expect(res.shops.map((s: { id: number }) => s.id)).toEqual([f.shops.a]); + }); + + it('gives a shop its day, and 404 for a shop the user cannot see', async () => { + seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 1]] }); + const ok = (await call(f.shopUser.id, 'GET', `/api/shops/${f.shops.a}/draws/2026-11-02`)).json(); + expect(ok.endings).toEqual([{ ending: '1234', qty: 1, hits: [{ tier: 5, amount: 1000 }, { tier: 8, amount: 100 }] }]); + const hidden = await call(f.shopUser.id, 'GET', `/api/shops/${f.shops.b}/draws/2026-11-02`); + expect(hidden.statusCode).toBe(404); + expect(hidden.json()).toEqual({ error: 'not_found', message: 'Shop not found.' }); + }); +}); + +describe('orders', () => { + let orderId: number; + beforeEach(() => { + orderId = seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 3], ['0001', 2]] }); + }); + + it('lists and reads orders', async () => { + const list = (await call(f.shopUser.id, 'GET', '/api/orders?date=2026-11-02')).json(); + expect(list.orders).toHaveLength(1); + const one = (await call(f.shopUser.id, 'GET', `/api/orders/${orderId}`)).json(); + expect(one).toMatchObject({ id: orderId, drawDate: '2026-11-02', edited: false, isPhoto: false }); + expect(one.lines.map((l: { ending: string }) => l.ending)).toEqual(['0001', '1234']); + }); + + it('edits a quantity, removes an ending and cancels', async () => { + expect((await call(f.shopUser.id, 'PUT', `/api/orders/${orderId}/lines/1234`, { qty: 9 })).statusCode).toBe(204); + expect((await call(f.shopUser.id, 'GET', `/api/orders/${orderId}`)).json()).toMatchObject({ edited: true }); + expect((await call(f.shopUser.id, 'DELETE', `/api/orders/${orderId}/lines/0001`)).statusCode).toBe(204); + expect((await call(f.shopUser.id, 'DELETE', `/api/orders/${orderId}`)).statusCode).toBe(204); + expect((await call(f.shopUser.id, 'GET', `/api/orders/${orderId}`)).statusCode).toBe(404); + }); + + it('refuses bad input and other shops', async () => { + expect((await call(f.shopUser.id, 'PUT', `/api/orders/${orderId}/lines/1234`, { qty: 0 })).statusCode).toBe(400); + expect((await call(f.shopUser.id, 'PUT', `/api/orders/${orderId}/lines/12`, { qty: 1 })).statusCode).toBe(400); + expect((await call(f.shopUser.id, 'PUT', `/api/orders/${orderId}/lines/1234`, {})).statusCode).toBe(400); + expect((await call(f.multiUser.id, 'GET', `/api/orders/${orderId}`)).statusCode).toBe(200); + expect((await call(f.disabledUser.id, 'DELETE', `/api/orders/${orderId}`)).statusCode).toBe(401); + }); +}); + +describe('users', () => { + it('is for admins only', async () => { + expect((await call(f.shopUser.id, 'GET', '/api/users')).statusCode).toBe(403); + expect((await call(f.shopUser.id, 'POST', '/api/users', { username: 'new.user', password: 'longenough' })).statusCode).toBe(403); + }); + + it('creates a user with a hashed password and returns the user with shops', async () => { + const res = await call(f.admin.id, 'POST', '/api/users', { + username: 'new.user', + phone: '98765 43210', + password: 'longenough', + shopIds: [f.shops.a, f.shops.b], + }); + expect(res.statusCode).toBe(201); + expect(res.json()).toEqual({ id: expect.any(Number), username: 'new.user', phone: '919876543210', isAdmin: false, active: true, shopIds: [f.shops.a, f.shops.b] }); + const hash = (db.prepare('SELECT password_hash h FROM users WHERE id = ?').get(res.json().id) as { h: string }).h; + expect(hash).not.toContain('longenough'); + expect(await verifyPassword('longenough', hash)).toBe(true); + }); + + it('refuses short passwords and duplicate usernames', async () => { + const short = await call(f.admin.id, 'POST', '/api/users', { username: 'someone', password: 'short' }); + expect(short.json()).toEqual({ error: 'invalid', message: 'Password must be at least 8 characters.' }); + const dup = await call(f.admin.id, 'POST', '/api/users', { username: 'shop-a', password: 'longenough' }); + expect(dup.statusCode).toBe(409); + }); + + it('lists, reads, updates, reassigns and deletes', async () => { + const list = (await call(f.admin.id, 'GET', '/api/users')).json(); + expect(list.users.map((u: { id: number }) => u.id)).toContain(f.shopUser.id); + + 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(await verifyPassword('brand new pass', (db.prepare('SELECT password_hash h FROM users WHERE id = ?').get(f.shopUser.id) as { h: string }).h)).toBe(true); + + expect((await call(f.admin.id, 'PUT', `/api/users/${f.shopUser.id}/shops`, { shopIds: [f.shops.c] })).statusCode).toBe(204); + expect((await call(f.admin.id, 'GET', `/api/users/${f.shopUser.id}`)).json().shopIds).toEqual([f.shops.c]); + + expect((await call(f.admin.id, 'DELETE', `/api/users/${f.shopUser.id}`)).statusCode).toBe(204); + expect((await call(f.admin.id, 'GET', `/api/users/${f.shopUser.id}`)).statusCode).toBe(404); + }); + + it('never changes or deletes an admin', async () => { + expect((await call(f.admin.id, 'PATCH', `/api/users/${f.admin2.id}`, { active: false })).statusCode).toBe(403); + expect((await call(f.admin.id, 'DELETE', `/api/users/${f.admin2.id}`)).statusCode).toBe(403); + }); + + it('answers an unknown route inside /api with the usual 404', async () => { + expect((await call(f.admin.id, 'GET', '/api/nothing')).statusCode).toBe(404); + }); +}); + +describe('cross-origin access in development', () => { + it('answers preflight requests only when an origin is configured', async () => { + const dev = createApp({ db, config: testConfig({ corsOrigin: 'http://localhost:5000' }), bridge, authenticate }); + const res = await dev.inject({ method: 'OPTIONS', url: '/api/shops' }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:5000'); + await dev.close(); + + expect((await call(f.admin.id, 'GET', '/api/draws/latest')).headers['access-control-allow-origin']).toBeUndefined(); + }); +}); diff --git a/src/routes/api/cors.ts b/src/routes/api/cors.ts new file mode 100644 index 0000000..e1e112f --- /dev/null +++ b/src/routes/api/cors.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from 'fastify'; + +export function allowOrigin(app: FastifyInstance, origin: string): void { + app.addHook('onRequest', async (_req, reply) => { + reply.header('access-control-allow-origin', origin).header('vary', 'origin'); + }); + + app.options('/*', async (_req, reply) => + reply + .header('access-control-allow-methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + .header('access-control-allow-headers', 'authorization, content-type') + .header('access-control-max-age', '600') + .code(204) + .send(), + ); +} diff --git a/src/routes/api/draws.ts b/src/routes/api/draws.ts new file mode 100644 index 0000000..29b13a1 --- /dev/null +++ b/src/routes/api/draws.ts @@ -0,0 +1,23 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db'; +import { latestDrawDate } from '../../services/draws'; +import { dayResults, monthCalendar } from '../../services/reports'; +import { istDate } from '../../services/ist'; +import { isoDate, monthText } from './schemas'; + +export const drawRoutes = + (db: Db, now: () => Date): FastifyPluginAsync => + async (app) => { + app.get('/draws', async (req) => { + const { month } = z.object({ month: monthText }).parse(req.query); + return { days: monthCalendar(db, req.user, month) }; + }); + + app.get('/draws/latest', async () => ({ date: latestDrawDate(db) ?? istDate(now()) })); + + app.get('/draws/:date/results', async (req) => { + const { date } = z.object({ date: isoDate }).parse(req.params); + return dayResults(db, req.user, date); + }); + }; diff --git a/src/routes/api/errors.ts b/src/routes/api/errors.ts new file mode 100644 index 0000000..70327c6 --- /dev/null +++ b/src/routes/api/errors.ts @@ -0,0 +1,30 @@ +import { ZodError } from 'zod'; +import { RuleError, type RuleErrorCode } from '../../services/accountRules'; + +const STATUS: Record = { + forbidden: 403, + target_is_admin: 403, + not_found: 404, + invalid: 400, + conflict: 409, +}; + +const sentence = (text: string): string => { + const t = text.trim(); + return `${t.charAt(0).toUpperCase()}${t.slice(1)}${/[.!?]$/.test(t) ? '' : '.'}`; +}; + +export interface ApiError { + status: number; + body: { error: string; message: string }; +} + +export function toApiError(err: unknown): ApiError | undefined { + if (err instanceof RuleError) { + return { status: STATUS[err.code], body: { error: err.code, message: sentence(err.message) } }; + } + if (err instanceof ZodError) { + return { status: 400, body: { error: 'invalid', message: sentence(err.issues[0]?.message ?? 'The request is not valid') } }; + } + return undefined; +} diff --git a/src/routes/api/guard.ts b/src/routes/api/guard.ts new file mode 100644 index 0000000..d206abd --- /dev/null +++ b/src/routes/api/guard.ts @@ -0,0 +1,20 @@ +import type { FastifyRequest, onRequestAsyncHookHandler } from 'fastify'; +import type { User } from '../../services/users'; + +declare module 'fastify' { + interface FastifyRequest { + user: User; + } +} + +export type Authenticator = (req: FastifyRequest) => User | undefined; + +export const authGuard = + (authenticate: Authenticator): onRequestAsyncHookHandler => + async (req, reply) => { + const user = authenticate(req); + if (!user || !user.active) { + return reply.code(401).send({ error: 'unauthorized', message: 'Your session has ended. Please sign in again.' }); + } + req.user = user; + }; diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 0000000..f4ff62c --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,39 @@ +import type { FastifyPluginAsync } from 'fastify'; +import type { Config } from '../../config'; +import type { Db } from '../../db'; +import type { TokenService } from '../../services/token'; +import type { User } from '../../services/users'; +import { allowOrigin } from './cors'; +import { drawRoutes } from './draws'; +import { authGuard, type Authenticator } from './guard'; +import { orderRoutes } from './orders'; +import { loginRoute, meRoute } from './session'; +import { shopRoutes } from './shops'; +import { userRoutes } from './users'; + +export interface ApiDeps { + db: Db; + config: Config; + authenticate: Authenticator; + tokens?: TokenService; + now?: () => Date; +} + +export const apiRoutes = + ({ db, config, authenticate, tokens, now = () => new Date() }: ApiDeps): FastifyPluginAsync => + async (app) => { + if (config.corsOrigin) allowOrigin(app, config.corsOrigin); + + if (tokens) await app.register(loginRoute(db, tokens, () => now().getTime())); + + await app.register(async (secured) => { + secured.decorateRequest('user', null as unknown as User); + secured.addHook('onRequest', authGuard(authenticate)); + + await secured.register(meRoute(db)); + await secured.register(drawRoutes(db, now)); + await secured.register(shopRoutes(db)); + await secured.register(orderRoutes(db)); + await secured.register(userRoutes(db, config.defaultCountryCode)); + }); + }; diff --git a/src/routes/api/orders.ts b/src/routes/api/orders.ts new file mode 100644 index 0000000..f31a32e --- /dev/null +++ b/src/routes/api/orders.ts @@ -0,0 +1,40 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db'; +import { cancelOrder, removeLine, setLineQty } from '../../services/orderEdit'; +import { dayOrders, getOrder } from '../../services/reports'; +import { dateQuery, endingParam, idParam } from './schemas'; + +const lineParams = z.object({ id: idParam, ending: endingParam }); +const qtyBody = z.object({ qty: z.number({ error: 'Enter a quantity.' }) }); + +export const orderRoutes = + (db: Db): FastifyPluginAsync => + async (app) => { + app.get('/orders', async (req) => { + const { date } = dateQuery.parse(req.query); + return { orders: dayOrders(db, req.user, date) }; + }); + + app.get('/orders/:id', async (req) => { + const { id } = z.object({ id: idParam }).parse(req.params); + return getOrder(db, req.user, id); + }); + + app.put('/orders/:id/lines/:ending', async (req, reply) => { + const { id, ending } = lineParams.parse(req.params); + setLineQty(db, req.user, id, ending, qtyBody.parse(req.body).qty); + return reply.code(204).send(); + }); + + app.delete('/orders/:id/lines/:ending', async (req, reply) => { + const { id, ending } = lineParams.parse(req.params); + removeLine(db, req.user, id, ending); + return reply.code(204).send(); + }); + + app.delete('/orders/:id', async (req, reply) => { + cancelOrder(db, req.user, z.object({ id: idParam }).parse(req.params).id); + return reply.code(204).send(); + }); + }; diff --git a/src/routes/api/schemas.ts b/src/routes/api/schemas.ts new file mode 100644 index 0000000..c8b7cba --- /dev/null +++ b/src/routes/api/schemas.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { isRealDate } from '../../services/dateMath'; + +export const isoDate = z.string().refine(isRealDate, 'Enter a valid date such as 2026-11-04.'); +export const monthText = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Enter a month such as 2026-11.'); +export const idParam = z.coerce.number().int().positive(); +export const endingParam = z.string().regex(/^\d{4}$/, 'An ending is four digits.'); + +export const idParams = z.object({ id: idParam }); +export const dateQuery = z.object({ date: isoDate }); diff --git a/src/routes/api/session.test.ts b/src/routes/api/session.test.ts new file mode 100644 index 0000000..10a230a --- /dev/null +++ b/src/routes/api/session.test.ts @@ -0,0 +1,122 @@ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../../app'; +import type { Db } from '../../db'; +import { hashPassword } from '../../services/password'; +import { TOKEN_LIFETIME_SECONDS } from '../../services/token'; +import { testConfig } from '../../testing/config'; +import { makeDb, seedFixture, type Fixture } from '../../testing/helpers'; +import type { Bridge } from '../../whatsapp/bridge'; + +const bridge: Bridge = { sendText: async () => ({ messageIds: [] }), health: async () => null }; +const SECRET = 'jwt-test-secret-0123456789'; + +let db: Db; +let f: Fixture; +let app: FastifyInstance; +let clock: Date; + +const setPassword = async (userId: number, password: string) => + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(await hashPassword(password), userId); + +const login = (username: string, password: unknown) => + app.inject({ method: 'POST', url: '/api/auth/login', payload: { username, password } as object }); + +beforeEach(async () => { + db = makeDb(); + f = seedFixture(db); + clock = new Date('2026-11-04T10:00:00Z'); + app = createApp({ db, config: testConfig({ jwtSecret: SECRET }), bridge, now: () => clock }); + await app.ready(); + await setPassword(f.admin.id, 'admin password'); + await setPassword(f.multiUser.id, 'multi password'); + await setPassword(f.disabledUser.id, 'gone password'); +}); + +afterEach(() => app.close()); + +describe('signing in', () => { + it('gives a token and the user for a correct password', async () => { + const res = await login('shop-ab', 'multi password'); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.user).toEqual({ id: f.multiUser.id, username: 'shop-ab', phone: f.multiUser.phone, isAdmin: false, active: true, shopIds: [f.shops.a, f.shops.b] }); + expect(JSON.stringify(body)).not.toContain('password'); + }); + + it('lets the token open the app until it expires', async () => { + const { token } = (await login('admin', 'admin password')).json(); + const me = () => app.inject({ method: 'GET', url: '/api/me', headers: { authorization: `Bearer ${token}` } }); + expect((await me()).json()).toMatchObject({ id: f.admin.id, isAdmin: true, shopIds: [] }); + + clock = new Date(clock.getTime() + TOKEN_LIFETIME_SECONDS * 1000); + expect((await me()).statusCode).toBe(401); + }); + + it('matches the username whatever its case', async () => { + expect((await login('Shop-AB', 'multi password')).statusCode).toBe(200); + }); + + it('refuses a wrong password and an unknown user with the same message', async () => { + const wrong = await login('admin', 'not it'); + const unknown = await login('nobody', 'whatever'); + expect(wrong.statusCode).toBe(401); + expect(unknown.json()).toEqual(wrong.json()); + expect(wrong.json().message).toBe('Incorrect username or password.'); + }); + + it('refuses an account with no password set', async () => { + expect((await login('shop-a', 'not-set')).statusCode).toBe(401); + }); + + it('tells a switched-off account so only after the right password', async () => { + expect((await login('gone', 'gone password')).json()).toMatchObject({ error: 'inactive' }); + expect((await login('gone', 'wrong')).statusCode).toBe(401); + }); + + it('refuses a deleted account', async () => { + db.prepare(`UPDATE users SET deleted_at = '2026-11-01T00:00:00Z' WHERE id = ?`).run(f.multiUser.id); + expect((await login('shop-ab', 'multi password')).statusCode).toBe(401); + }); + + it('asks for both fields', async () => { + const res = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { username: 'admin' } }); + expect(res.statusCode).toBe(400); + expect(res.json().message).toBe('Enter your password.'); + }); + + it('slows down repeated wrong passwords on one account', async () => { + for (let i = 0; i < 5; i++) expect((await login('admin', 'nope')).statusCode).toBe(401); + const blocked = await login('admin', 'admin password'); + expect(blocked.statusCode).toBe(429); + expect(blocked.json().error).toBe('too_many_attempts'); + + clock = new Date(clock.getTime() + 15 * 60 * 1000); + expect((await login('admin', 'admin password')).statusCode).toBe(200); + }); +}); + +describe('the session', () => { + it('is required for the app routes', async () => { + expect((await app.inject({ method: 'GET', url: '/api/me' })).statusCode).toBe(401); + expect((await app.inject({ method: 'GET', url: '/api/me', headers: { authorization: 'Bearer junk' } })).statusCode).toBe(401); + }); + + it('ends when the user is switched off or deleted', async () => { + const { token } = (await login('shop-ab', 'multi password')).json(); + const headers = { authorization: `Bearer ${token}` }; + db.prepare('UPDATE users SET active = 0 WHERE id = ?').run(f.multiUser.id); + expect((await app.inject({ method: 'GET', url: '/api/draws/latest', headers })).statusCode).toBe(401); + + db.prepare('UPDATE users SET active = 1, deleted_at = ? WHERE id = ?').run('2026-11-01T00:00:00Z', f.multiUser.id); + expect((await app.inject({ method: 'GET', url: '/api/draws/latest', headers })).statusCode).toBe(401); + }); +}); + +describe('without JWT_SECRET', () => { + it('leaves the app API off', async () => { + const off = createApp({ db, config: testConfig(), bridge }); + expect((await off.inject({ method: 'POST', url: '/api/auth/login', payload: {} })).statusCode).toBe(404); + await off.close(); + }); +}); diff --git a/src/routes/api/session.ts b/src/routes/api/session.ts new file mode 100644 index 0000000..9e617bd --- /dev/null +++ b/src/routes/api/session.ts @@ -0,0 +1,70 @@ +import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db'; +import { createThrottle } from '../../services/loginThrottle'; +import { hashPassword, verifyPassword } from '../../services/password'; +import type { TokenService } from '../../services/token'; +import { findUserByUsername, getUserById, withShops } from '../../services/users'; +import type { Authenticator } from './guard'; + +const loginBody = z.object({ + username: z.string({ error: 'Enter your username.' }).max(200), + password: z.string({ error: 'Enter your password.' }).max(1000), +}); + +const FAILURES_PER_ACCOUNT = 5; +const FAILURES_PER_ADDRESS = 30; +const WINDOW_MS = 15 * 60 * 1000; + +let decoyHash: Promise | undefined; +const decoy = (): Promise => (decoyHash ??= hashPassword('decoy-password-never-used')); + +export const bearerAuthenticator = + (db: Db, tokens: TokenService): Authenticator => + (req: FastifyRequest) => { + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) return undefined; + const id = tokens.verify(header.slice('Bearer '.length)); + return id === undefined ? undefined : getUserById(db, id); + }; + +export const loginRoute = + (db: Db, tokens: TokenService, now: () => number = Date.now): FastifyPluginAsync => + async (app) => { + const byAccount = createThrottle(FAILURES_PER_ACCOUNT, WINDOW_MS, now); + const byAddress = createThrottle(FAILURES_PER_ADDRESS, WINDOW_MS, now); + + app.post('/auth/login', async (req, reply) => { + const { username, password } = loginBody.parse(req.body); + const account = username.trim().toLowerCase(); + const address = req.ip; + + if (!byAccount.allowed(account) || !byAddress.allowed(address)) { + return reply.code(429).send({ error: 'too_many_attempts', message: 'Too many sign-in attempts. Try again in a few minutes.' }); + } + + const user = findUserByUsername(db, username.trim()); + const stored = user + ? (db.prepare('SELECT password_hash h FROM users WHERE id = ?').get(user.id) as { h: string }).h + : await decoy(); + const correct = await verifyPassword(password, stored); + + if (!user || !correct) { + byAccount.fail(account); + byAddress.fail(address); + return reply.code(401).send({ error: 'invalid_credentials', message: 'Incorrect username or password.' }); + } + if (!user.active) { + return reply.code(403).send({ error: 'inactive', message: 'This account is switched off. Contact your administrator.' }); + } + + byAccount.clear(account); + return { token: tokens.issue(user.id), user: withShops(db, [user])[0] }; + }); + }; + +export const meRoute = + (db: Db): FastifyPluginAsync => + async (app) => { + app.get('/me', async (req) => withShops(db, [req.user])[0]); + }; diff --git a/src/routes/api/shops.ts b/src/routes/api/shops.ts new file mode 100644 index 0000000..f8f3a61 --- /dev/null +++ b/src/routes/api/shops.ts @@ -0,0 +1,36 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db'; +import { dayShops, shopDay } from '../../services/reports'; +import { shopsForUser } from '../../services/shopScope'; +import { addShop, updateShop } from '../../services/shopAdmin'; +import { dateQuery, idParam, isoDate } from './schemas'; + +const newShop = z.object({ name: z.string(), code: z.string() }); +const shopPatch = z.object({ name: z.string().optional(), active: z.boolean().optional() }); + +export const shopRoutes = + (db: Db): FastifyPluginAsync => + async (app) => { + app.get('/shops', async (req) => { + const { date } = dateQuery.parse(req.query); + return { shops: dayShops(db, req.user, date) }; + }); + + app.get('/shops/options', async (req) => ({ shops: shopsForUser(db, req.user).map((s) => ({ ...s, active: true })) })); + + app.post('/shops', async (req, reply) => { + const shop = addShop(db, req.user, newShop.parse(req.body)); + return reply.code(201).send(shop); + }); + + app.patch('/shops/:id', async (req) => { + const { id } = z.object({ id: idParam }).parse(req.params); + return updateShop(db, req.user, id, shopPatch.parse(req.body)); + }); + + app.get('/shops/:id/draws/:date', async (req) => { + const { id, date } = z.object({ id: idParam, date: isoDate }).parse(req.params); + return shopDay(db, req.user, id, date); + }); + }; diff --git a/src/routes/api/users.ts b/src/routes/api/users.ts new file mode 100644 index 0000000..d3c0fe9 --- /dev/null +++ b/src/routes/api/users.ts @@ -0,0 +1,72 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db'; +import { cleanPassword, hashPassword } from '../../services/password'; +import { + createUser, + deleteUser, + getManagedUser, + listManagedUsers, + setUserShops, + updateUser, + type UserPatch, +} from '../../services/userAdmin'; +import { withShops } from '../../services/users'; +import { idParam } from './schemas'; + +const shopIds = z.array(z.number().int()); +const newUser = z.object({ + username: z.string(), + phone: z.string().nullish(), + password: z.string(), + shopIds: shopIds.default([]), +}); +const userPatch = z.object({ + username: z.string().optional(), + phone: z.string().nullable().optional(), + password: z.string().optional(), + active: z.boolean().optional(), +}); + +export const userRoutes = + (db: Db, defaultCountryCode: string): FastifyPluginAsync => + async (app) => { + app.get('/users', async (req) => ({ users: listManagedUsers(db, req.user) })); + + app.get('/users/:id', async (req) => { + const { id } = z.object({ id: idParam }).parse(req.params); + return getManagedUser(db, req.user, id); + }); + + app.post('/users', async (req, reply) => { + const body = newUser.parse(req.body); + const passwordHash = await hashPassword(cleanPassword(body.password)); + const user = createUser( + db, + req.user, + { username: body.username, phone: body.phone, passwordHash, shopIds: body.shopIds }, + defaultCountryCode, + ); + return reply.code(201).send(withShops(db, [user])[0]); + }); + + app.patch('/users/:id', async (req) => { + const { id } = z.object({ id: idParam }).parse(req.params); + const { password, ...rest } = userPatch.parse(req.body); + const patch: UserPatch = { ...rest }; + if (password !== undefined) patch.passwordHash = await hashPassword(cleanPassword(password)); + updateUser(db, req.user, id, patch, defaultCountryCode); + return getManagedUser(db, req.user, id); + }); + + app.put('/users/:id/shops', async (req, reply) => { + const { id } = z.object({ id: idParam }).parse(req.params); + setUserShops(db, req.user, id, z.object({ shopIds }).parse(req.body).shopIds); + return reply.code(204).send(); + }); + + app.delete('/users/:id', async (req, reply) => { + deleteUser(db, req.user, z.object({ id: idParam }).parse(req.params).id); + return reply.code(204).send(); + }); + }; diff --git a/src/services/accountRules.ts b/src/services/accountRules.ts index 4e1431d..aae7d6f 100644 --- a/src/services/accountRules.ts +++ b/src/services/accountRules.ts @@ -1,5 +1,6 @@ import type { Db } from '../db'; import { normalizePhone } from './phone'; +import { getUserById, type User } from './users'; export type RuleErrorCode = 'forbidden' | 'target_is_admin' | 'not_found' | 'invalid' | 'conflict'; @@ -31,14 +32,25 @@ export function cleanPhone(raw: string | null | undefined, defaultCountryCode: s return phone; } +export function requireAdmin(db: Db, actor: User): void { + const current = getUserById(db, actor.id); + if (!current || !current.isAdmin || !current.active) { + throw new RuleError('forbidden', 'only an active admin can do this'); + } +} + export const isUniqueViolation = (e: unknown): boolean => typeof e === 'object' && e !== null && 'code' in e && String((e as { code: unknown }).code).includes('UNIQUE'); export function writeShops(db: Db, userId: number, shopIds: number[]): void { const ids = [...new Set(shopIds)]; - const exists = db.prepare('SELECT 1 FROM shops WHERE id = ? AND active = 1'); + const usable = db.prepare( + `SELECT 1 FROM shops s + WHERE s.id = ? + AND (s.active = 1 OR EXISTS (SELECT 1 FROM user_shops us WHERE us.user_id = ? AND us.shop_id = s.id))`, + ); for (const id of ids) { - if (!Number.isInteger(id) || !exists.get(id)) { + if (!Number.isInteger(id) || !usable.get(id, userId)) { throw new RuleError('invalid', `shop ${id} does not exist or is inactive`); } } diff --git a/src/services/dateMath.test.ts b/src/services/dateMath.test.ts new file mode 100644 index 0000000..89635cd --- /dev/null +++ b/src/services/dateMath.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { addDays, isRealDate, monthBounds } from './dateMath'; + +describe('addDays', () => { + it('moves across month and year ends', () => { + expect(addDays('2026-10-31', 1)).toBe('2026-11-01'); + expect(addDays('2026-01-01', -1)).toBe('2025-12-31'); + expect(addDays('2028-02-28', 1)).toBe('2028-02-29'); + }); +}); + +describe('isRealDate', () => { + it('accepts real days only', () => { + expect(isRealDate('2026-11-04')).toBe(true); + expect(isRealDate('2026-02-31')).toBe(false); + expect(isRealDate('2026-13-01')).toBe(false); + expect(isRealDate('2026-1-1')).toBe(false); + expect(isRealDate('tomorrow')).toBe(false); + }); +}); + +describe('monthBounds', () => { + it('gives the first and last day', () => { + expect(monthBounds('2026-02')).toEqual({ first: '2026-02-01', last: '2026-02-28' }); + expect(monthBounds('2026-12')).toEqual({ first: '2026-12-01', last: '2026-12-31' }); + }); + it('rejects text that is not a month', () => { + expect(monthBounds('2026-00')).toBeUndefined(); + expect(monthBounds('2026-13')).toBeUndefined(); + expect(monthBounds('2026')).toBeUndefined(); + }); +}); diff --git a/src/services/dateMath.ts b/src/services/dateMath.ts new file mode 100644 index 0000000..52e70e8 --- /dev/null +++ b/src/services/dateMath.ts @@ -0,0 +1,18 @@ +const DAY_MS = 86_400_000; + +export const addDays = (date: string, days: number): string => + new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10); + +export function isRealDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const ms = Date.parse(`${value}T00:00:00Z`); + return !Number.isNaN(ms) && new Date(ms).toISOString().slice(0, 10) === value; +} + +export function monthBounds(month: string): { first: string; last: string } | undefined { + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) return undefined; + const first = `${month}-01`; + const [y, m] = [Number(month.slice(0, 4)), Number(month.slice(5, 7))]; + const nextFirst = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10); + return { first, last: addDays(nextFirst, -1) }; +} diff --git a/src/services/drawDate.ts b/src/services/drawDate.ts index d6cdd6e..971cca8 100644 --- a/src/services/drawDate.ts +++ b/src/services/drawDate.ts @@ -1,9 +1,9 @@ +import { addDays } from './dateMath'; import { istParts } from './ist'; const DEFAULT_CUTOFF_MINUTES = 15 * 60; -const nextDay = (date: string): string => - new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); +const nextDay = (date: string): string => addDays(date, 1); const isAfter = (t: { minutes: number; seconds: number }, limitMinutes: number): boolean => t.minutes > limitMinutes || (t.minutes === limitMinutes && t.seconds > 0); diff --git a/src/services/draws.test.ts b/src/services/draws.test.ts new file mode 100644 index 0000000..17949b6 --- /dev/null +++ b/src/services/draws.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb, seedDraw } from '../testing/helpers'; +import { + drawForOrderDate, + drawsInMonth, + getDrawOn, + latestDrawDate, + perTicketPrize, + prizeLookup, + windowForDate, +} from './draws'; + +describe('order windows', () => { + const db = makeDb(); + seedDraw(db, { date: '2026-11-02' }); + seedDraw(db, { date: '2026-11-05' }); + + it('gives a draw everything since the previous draw', () => { + expect(windowForDate(db, '2026-11-05')).toEqual({ after: '2026-11-02', upTo: '2026-11-05' }); + }); + + it('gives the first draw everything up to its date', () => { + expect(windowForDate(db, '2026-11-02')).toEqual({ after: '', upTo: '2026-11-02' }); + }); + + it('has nothing for a day that had no draw while a later draw exists', () => { + expect(windowForDate(db, '2026-11-03')).toBeNull(); + }); + + it('shows only that day while its result is still awaited', () => { + expect(windowForDate(db, '2026-11-06')).toEqual({ after: '2026-11-05', upTo: '2026-11-06' }); + }); +}); + +describe('finding draws', () => { + const db = makeDb(); + seedDraw(db, { date: '2026-10-30', code: 'A-1' }); + seedDraw(db, { date: '2026-11-02', code: 'B-2' }); + + it('reads a draw by date', () => { + expect(getDrawOn(db, '2026-11-02')?.code).toBe('B-2'); + expect(getDrawOn(db, '2026-11-01')).toBeUndefined(); + }); + + it('pays an order out by the first draw on or after its date', () => { + expect(drawForOrderDate(db, '2026-10-31')?.code).toBe('B-2'); + expect(drawForOrderDate(db, '2026-11-02')?.code).toBe('B-2'); + expect(drawForOrderDate(db, '2026-11-03')).toBeUndefined(); + }); + + it('knows the latest draw date and the draws of a month', () => { + expect(latestDrawDate(db)).toBe('2026-11-02'); + expect(drawsInMonth(db, '2026-11').map((d) => d.code)).toEqual(['B-2']); + expect(drawsInMonth(db, 'nonsense')).toEqual([]); + }); + + it('has no latest date on an empty database', () => { + expect(latestDrawDate(makeDb())).toBeUndefined(); + }); +}); + +describe('prizes', () => { + it('lists every tier an ending is in and adds them up per ticket', () => { + const db = makeDb(); + const id = seedDraw(db, { + date: '2026-11-02', + prizes: [ + [5, 5000, '1234'], + [8, 100, '1234'], + [8, 100, '1234'], // a repeat in the same tier is one prize + [9, 50, '9999'], + ], + }); + const lookup = prizeLookup(db, id); + expect(lookup.get('1234')).toEqual([ + { tier: 5, amount: 5000 }, + { tier: 8, amount: 100 }, + ]); + expect(perTicketPrize(lookup.get('1234')!)).toBe(5100); + expect(lookup.has('0000')).toBe(false); + expect(prizeLookup(db, undefined).size).toBe(0); + }); +}); diff --git a/src/services/draws.ts b/src/services/draws.ts new file mode 100644 index 0000000..92f8db1 --- /dev/null +++ b/src/services/draws.ts @@ -0,0 +1,86 @@ +import type { Db } from '../db'; +import { addDays, monthBounds } from './dateMath'; + +export interface Draw { + id: number; + date: string; + scheme: string; + code: string; +} + +export interface PrizeHit { + tier: number; + amount: number; +} + +export interface OrderWindow { + after: string; + upTo: string; +} + +interface DrawRow { + id: number; + draw_date: string; + scheme: string; + draw_code: string; +} + +const toDraw = (r: DrawRow): Draw => ({ id: r.id, date: r.draw_date, scheme: r.scheme, code: r.draw_code }); +const COLUMNS = 'id, draw_date, scheme, draw_code'; + +export function getDrawOn(db: Db, date: string): Draw | undefined { + const row = db.prepare(`SELECT ${COLUMNS} FROM draws WHERE draw_date = ?`).get(date) as DrawRow | undefined; + return row && toDraw(row); +} + +export function drawForOrderDate(db: Db, date: string): Draw | undefined { + const row = db.prepare(`SELECT ${COLUMNS} FROM draws WHERE draw_date >= ? ORDER BY draw_date LIMIT 1`).get(date) as + | DrawRow + | undefined; + return row && toDraw(row); +} + +export function latestDrawDate(db: Db): string | undefined { + const row = db.prepare('SELECT MAX(draw_date) AS d FROM draws').get() as { d: string | null }; + return row.d ?? undefined; +} + +export function drawsInMonth(db: Db, month: string): Draw[] { + const bounds = monthBounds(month); + if (!bounds) return []; + const rows = db + .prepare(`SELECT ${COLUMNS} FROM draws WHERE draw_date BETWEEN ? AND ? ORDER BY draw_date`) + .all(bounds.first, bounds.last) as DrawRow[]; + return rows.map(toDraw); +} + +function previousDrawDate(db: Db, date: string): string | undefined { + const row = db.prepare('SELECT MAX(draw_date) AS d FROM draws WHERE draw_date < ?').get(date) as { d: string | null }; + return row.d ?? undefined; +} + +export function windowOfDraw(db: Db, draw: Draw): OrderWindow { + return { after: previousDrawDate(db, draw.date) ?? '', upTo: draw.date }; +} + +export function windowForDate(db: Db, date: string): OrderWindow | null { + const draw = getDrawOn(db, date); + if (draw) return windowOfDraw(db, draw); + const latest = latestDrawDate(db); + if (latest !== undefined && latest > date) return null; + return { after: addDays(date, -1), upTo: date }; +} + +export type PrizeLookup = Map; + +export function prizeLookup(db: Db, drawId: number | undefined): PrizeLookup { + const lookup: PrizeLookup = new Map(); + if (drawId === undefined) return lookup; + const rows = db + .prepare('SELECT DISTINCT ending, tier, amount FROM prizes WHERE draw_id = ? ORDER BY ending, tier') + .all(drawId) as Array<{ ending: string; tier: number; amount: number }>; + for (const r of rows) lookup.set(r.ending, [...(lookup.get(r.ending) ?? []), { tier: r.tier, amount: r.amount }]); + return lookup; +} + +export const perTicketPrize = (hits: PrizeHit[]): number => hits.reduce((sum, h) => sum + h.amount, 0); diff --git a/src/services/loginThrottle.test.ts b/src/services/loginThrottle.test.ts new file mode 100644 index 0000000..1510f81 --- /dev/null +++ b/src/services/loginThrottle.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { createThrottle } from './loginThrottle'; + +describe('login throttle', () => { + it('blocks a key after the allowed number of failures', () => { + let t = 0; + const throttle = createThrottle(3, 1000, () => t); + for (let i = 0; i < 3; i++) { + expect(throttle.allowed('a')).toBe(true); + throttle.fail('a'); + } + expect(throttle.allowed('a')).toBe(false); + expect(throttle.allowed('b')).toBe(true); + }); + + it('lets the key back in once the window has passed', () => { + let t = 0; + const throttle = createThrottle(1, 1000, () => t); + throttle.fail('a'); + expect(throttle.allowed('a')).toBe(false); + t = 1000; + expect(throttle.allowed('a')).toBe(true); + }); + + it('forgets failures after a success', () => { + const throttle = createThrottle(1, 1000, () => 0); + throttle.fail('a'); + throttle.clear('a'); + expect(throttle.allowed('a')).toBe(true); + }); +}); diff --git a/src/services/loginThrottle.ts b/src/services/loginThrottle.ts new file mode 100644 index 0000000..7e371c5 --- /dev/null +++ b/src/services/loginThrottle.ts @@ -0,0 +1,35 @@ +export interface Throttle { + allowed(key: string): boolean; + fail(key: string): void; + clear(key: string): void; +} + +interface Entry { + failures: number; + since: number; +} + +const PURGE_ABOVE = 5_000; +export function createThrottle(maxFailures: number, windowMs: number, now: () => number = Date.now): Throttle { + const entries = new Map(); + + const live = (key: string): Entry | undefined => { + const e = entries.get(key); + if (e && now() - e.since >= windowMs) { + entries.delete(key); + return undefined; + } + return e; + }; + + return { + allowed: (key) => (live(key)?.failures ?? 0) < maxFailures, + fail(key) { + if (entries.size > PURGE_ABOVE) for (const k of entries.keys()) live(k); + const e = live(key); + if (e) e.failures++; + else entries.set(key, { failures: 1, since: now() }); + }, + clear: (key) => void entries.delete(key), + }; +} diff --git a/src/services/orderEdit.test.ts b/src/services/orderEdit.test.ts new file mode 100644 index 0000000..87c34db --- /dev/null +++ b/src/services/orderEdit.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Db } from '../db'; +import { makeDb, seedFixture, seedOrder, type Fixture } from '../testing/helpers'; +import { RuleError } from './accountRules'; +import { cancelOrder, removeLine, setLineQty } from './orderEdit'; +import { getUserById } from './users'; + +let db: Db; +let f: Fixture; +let orderId: number; +const shopUser = () => getUserById(db, f.shopUser.id)!; +const lines = () => + db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY ending').all(orderId) as Array<{ ending: string; qty: number }>; +const order = () => + db.prepare('SELECT status, edited_at FROM orders WHERE id = ?').get(orderId) as { status: string; edited_at: string | null }; + +beforeEach(() => { + db = makeDb(); + f = seedFixture(db); + orderId = seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 2], ['1234', 3], ['5678', 1]] }); +}); + +describe('changing a quantity', () => { + it('replaces every line of that ending with one and marks the order edited', () => { + setLineQty(db, shopUser(), orderId, '1234', 7); + expect(lines()).toEqual([{ ending: '1234', qty: 7 }, { ending: '5678', qty: 1 }]); + expect(order().edited_at).not.toBeNull(); + }); + + it('refuses a quantity outside 1 to 999', () => { + for (const qty of [0, -1, 1000, 1.5]) expect(() => setLineQty(db, shopUser(), orderId, '1234', qty)).toThrow(RuleError); + expect(order().edited_at).toBeNull(); + }); + + it('refuses an ending that is not in the order', () => { + expect(() => setLineQty(db, shopUser(), orderId, '9999', 1)).toThrow(RuleError); + }); +}); + +describe('removing an ending', () => { + it('removes every line of it', () => { + removeLine(db, shopUser(), orderId, '1234'); + expect(lines()).toEqual([{ ending: '5678', qty: 1 }]); + expect(order()).toMatchObject({ status: 'confirmed' }); + }); + + it('cancels the order when its last ending goes', () => { + removeLine(db, shopUser(), orderId, '1234'); + removeLine(db, shopUser(), orderId, '5678'); + expect(order().status).toBe('cancelled'); + }); +}); + +describe('cancelling an order', () => { + it('cancels it and keeps the record', () => { + cancelOrder(db, shopUser(), orderId); + expect(order().status).toBe('cancelled'); + expect(lines()).toHaveLength(3); + }); + + it('cannot be done twice', () => { + cancelOrder(db, shopUser(), orderId); + expect(() => cancelOrder(db, shopUser(), orderId)).toThrow(RuleError); + }); +}); + +describe('who may edit', () => { + it('lets an admin edit any order', () => { + cancelOrder(db, getUserById(db, f.admin.id)!, orderId); + expect(order().status).toBe('cancelled'); + }); + + it('refuses a user of another shop, as if the order did not exist', () => { + const other = seedOrder(db, { shopId: f.shops.b, userId: f.multiUser.id, date: '2026-11-02', lines: [['0001', 1]] }); + expect(() => cancelOrder(db, shopUser(), other)).toThrow(/order not found/); + }); + + it('refuses an inactive user', () => { + expect(() => cancelOrder(db, getUserById(db, f.disabledUser.id)!, orderId)).toThrow(RuleError); + }); + + it('still lets a user edit an order of a shop that was switched off', () => { + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.a); + setLineQty(db, shopUser(), orderId, '5678', 4); + expect(lines()).toContainEqual({ ending: '5678', qty: 4 }); + }); +}); diff --git a/src/services/orderEdit.ts b/src/services/orderEdit.ts new file mode 100644 index 0000000..700c496 --- /dev/null +++ b/src/services/orderEdit.ts @@ -0,0 +1,55 @@ +import type { Db } from '../db'; +import { MAX_QTY } from '../parser/limits'; +import { RuleError } from './accountRules'; +import { canViewShop } from './shopScope'; +import type { User } from './users'; + +const STAMP = `edited_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`; + +function editableOrder(db: Db, actor: User, orderId: number): void { + const row = db.prepare(`SELECT shop_id FROM orders WHERE id = ? AND status = 'confirmed'`).get(orderId) as + | { shop_id: number } + | undefined; + if (!row || !canViewShop(db, actor, row.shop_id)) throw new RuleError('not_found', 'order not found'); +} + +function requireEnding(db: Db, orderId: number, ending: string): void { + if (!db.prepare('SELECT 1 FROM order_lines WHERE order_id = ? AND ending = ?').get(orderId, ending)) { + throw new RuleError('not_found', 'that ending is not in this order'); + } +} + +const cancel = (db: Db, orderId: number): void => { + db.prepare(`UPDATE orders SET status = 'cancelled', ${STAMP} WHERE id = ?`).run(orderId); +}; + +export function setLineQty(db: Db, actor: User, orderId: number, ending: string, qty: number): void { + if (!Number.isInteger(qty) || qty < 1 || qty > MAX_QTY) { + throw new RuleError('invalid', `quantity must be a whole number from 1 to ${MAX_QTY}`); + } + db.transaction(() => { + editableOrder(db, actor, orderId); + requireEnding(db, orderId, ending); + db.prepare('DELETE FROM order_lines WHERE order_id = ? AND ending = ?').run(orderId, ending); + db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)').run(orderId, ending, qty); + db.prepare(`UPDATE orders SET ${STAMP} WHERE id = ?`).run(orderId); + })(); +} + +export function removeLine(db: Db, actor: User, orderId: number, ending: string): void { + db.transaction(() => { + editableOrder(db, actor, orderId); + requireEnding(db, orderId, ending); + db.prepare('DELETE FROM order_lines WHERE order_id = ? AND ending = ?').run(orderId, ending); + const left = db.prepare('SELECT COUNT(*) AS n FROM order_lines WHERE order_id = ?').get(orderId) as { n: number }; + if (left.n === 0) cancel(db, orderId); + else db.prepare(`UPDATE orders SET ${STAMP} WHERE id = ?`).run(orderId); + })(); +} + +export function cancelOrder(db: Db, actor: User, orderId: number): void { + db.transaction(() => { + editableOrder(db, actor, orderId); + cancel(db, orderId); + })(); +} diff --git a/src/services/orderQueries.ts b/src/services/orderQueries.ts new file mode 100644 index 0000000..e9b8229 --- /dev/null +++ b/src/services/orderQueries.ts @@ -0,0 +1,103 @@ +import type { Db } from '../db'; +import { perTicketPrize, type OrderWindow, type PrizeHit, type PrizeLookup } from './draws'; +import type { ShopListing } from './shopScope'; + +export interface OrderLineView { + ending: string; + qty: number; + hits: PrizeHit[]; +} + +export interface OrderView { + id: number; + shop: ShopListing; + drawDate: string; + createdAt: string; + edited: boolean; + isPhoto: boolean; + rawText: string | null; + lines: OrderLineView[]; +} + +export interface OrderFilter { + shops: ShopListing[]; + window?: OrderWindow; + orderId?: number; +} + +interface OrderRow { + id: number; + shop_id: number; + draw_date: string; + created_at: string; + edited_at: string | null; + source: string; + raw_text: string; +} + +export function readOrders(db: Db, filter: OrderFilter, prizes: PrizeLookup): OrderView[] { + if (filter.shops.length === 0) return []; + + const where = [`o.status = 'confirmed'`, `o.shop_id IN (${filter.shops.map(() => '?').join(', ')})`]; + const params: Array = filter.shops.map((s) => s.id); + if (filter.window) { + where.push('o.draw_date > ? AND o.draw_date <= ?'); + params.push(filter.window.after, filter.window.upTo); + } + if (filter.orderId !== undefined) { + where.push('o.id = ?'); + params.push(filter.orderId); + } + const clause = where.join(' AND '); + + const orders = db + .prepare( + `SELECT o.id, o.shop_id, o.draw_date, o.created_at, o.edited_at, o.source, o.raw_text + FROM orders o WHERE ${clause} ORDER BY o.created_at DESC, o.id DESC`, + ) + .all(...params) as OrderRow[]; + + const lineRows = db + .prepare( + `SELECT o.id AS order_id, ol.ending, SUM(ol.qty) AS qty + FROM orders o JOIN order_lines ol ON ol.order_id = o.id + WHERE ${clause} + GROUP BY o.id, ol.ending ORDER BY ol.ending`, + ) + .all(...params) as Array<{ order_id: number; ending: string; qty: number }>; + + const linesByOrder = new Map(); + for (const l of lineRows) { + const line = { ending: l.ending, qty: l.qty, hits: prizes.get(l.ending) ?? [] }; + linesByOrder.set(l.order_id, [...(linesByOrder.get(l.order_id) ?? []), line]); + } + + const shopById = new Map(filter.shops.map((s) => [s.id, s])); + return orders.map((o) => ({ + id: o.id, + shop: shopById.get(o.shop_id)!, + drawDate: o.draw_date, + createdAt: o.created_at, + edited: o.edited_at !== null, + isPhoto: o.source === 'whatsapp_photo', + rawText: o.raw_text, + lines: linesByOrder.get(o.id) ?? [], + })); +} + +export interface Tally { + orders: number; + tickets: number; + winTickets: number; + prize: number; +} + +export function tally(orders: OrderView[]): Tally { + const t: Tally = { orders: orders.length, tickets: 0, winTickets: 0, prize: 0 }; + for (const line of orders.flatMap((o) => o.lines)) { + t.tickets += line.qty; + if (line.hits.length > 0) t.winTickets += line.qty; + t.prize += line.qty * perTicketPrize(line.hits); + } + return t; +} diff --git a/src/services/password.test.ts b/src/services/password.test.ts new file mode 100644 index 0000000..d7c6e20 --- /dev/null +++ b/src/services/password.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { RuleError } from './accountRules'; +import { cleanPassword, hashPassword, verifyPassword } from './password'; + +describe('password hashing', () => { + it('accepts the right password and refuses a wrong one', async () => { + const hash = await hashPassword('correct horse'); + expect(await verifyPassword('correct horse', hash)).toBe(true); + expect(await verifyPassword('correct horsf', hash)).toBe(false); + }); + + it('salts every hash', async () => { + expect(await hashPassword('same password')).not.toBe(await hashPassword('same password')); + }); + + it('refuses anything that is not a stored hash', async () => { + expect(await verifyPassword('anything', 'not-set')).toBe(false); + expect(await verifyPassword('anything', '')).toBe(false); + expect(await verifyPassword('anything', 'scrypt$1$2')).toBe(false); + }); +}); + +describe('cleanPassword', () => { + it('needs at least 8 characters', () => { + expect(cleanPassword('12345678')).toBe('12345678'); + expect(() => cleanPassword('1234567')).toThrow(RuleError); + }); + it('has an upper limit', () => { + expect(() => cleanPassword('x'.repeat(201))).toThrow(RuleError); + }); + it('keeps spaces', () => { + expect(cleanPassword(' pass word ')).toBe(' pass word '); + }); +}); diff --git a/src/services/password.ts b/src/services/password.ts new file mode 100644 index 0000000..e9ae36a --- /dev/null +++ b/src/services/password.ts @@ -0,0 +1,34 @@ +import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'; +import { RuleError } from './accountRules'; + +export const PASSWORD_MIN = 8; +const PASSWORD_MAX = 200; + +const COST = { N: 16_384, r: 8, p: 1 } as const; +const KEY_BYTES = 64; +const SALT_BYTES = 16; + +const derive = (password: string, salt: Buffer, options: ScryptOptions): Promise => + new Promise((resolve, reject) => + scrypt(password, salt, KEY_BYTES, options, (err, key) => (err ? reject(err) : resolve(key))), + ); + +export function cleanPassword(raw: string): string { + if (raw.length < PASSWORD_MIN) throw new RuleError('invalid', `password must be at least ${PASSWORD_MIN} characters`); + if (raw.length > PASSWORD_MAX) throw new RuleError('invalid', `password must be at most ${PASSWORD_MAX} characters`); + return raw; +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(SALT_BYTES); + const key = await derive(password, salt, COST); + return ['scrypt', COST.N, COST.r, COST.p, salt.toString('base64'), key.toString('base64')].join('$'); +} + +export async function verifyPassword(password: string, stored: string): Promise { + const [kind, n, r, p, salt, key] = stored.split('$'); + if (kind !== 'scrypt' || !n || !r || !p || !salt || !key) return false; + const expected = Buffer.from(key, 'base64'); + const actual = await derive(password, Buffer.from(salt, 'base64'), { N: Number(n), r: Number(r), p: Number(p) }); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/src/services/provisioning.ts b/src/services/provisioning.ts index 14a4382..f0a7e44 100644 --- a/src/services/provisioning.ts +++ b/src/services/provisioning.ts @@ -49,6 +49,13 @@ export function createAdminAccount( return getUserById(db, insert(db, username, input.passwordHash ?? PLACEHOLDER_HASH, phone, 1))!; } +export function setAccountPassword(db: Db, userId: number, passwordHash: string): void { + const result = db + .prepare('UPDATE users SET password_hash = ? WHERE id = ? AND deleted_at IS NULL') + .run(passwordHash, userId); + if (result.changes === 0) throw new RuleError('not_found', 'that account does not exist'); +} + export function countAdmins(db: Db): number { return (db.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1').get() as { n: number }).n; } diff --git a/src/services/reports.test.ts b/src/services/reports.test.ts new file mode 100644 index 0000000..99e5ca5 --- /dev/null +++ b/src/services/reports.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Db } from '../db'; +import { makeDb, seedDraw, seedFixture, seedOrder, type Fixture } from '../testing/helpers'; +import { RuleError } from './accountRules'; +import { dayOrders, dayResults, dayShops, getOrder, monthCalendar, shopDay } from './reports'; +import { getUserById } from './users'; + +let db: Db; +let f: Fixture; +const as = (id: number) => getUserById(db, id)!; + +beforeEach(() => { + db = makeDb(); + f = seedFixture(db); + seedDraw(db, { + date: '2026-11-02', + scheme: 'Bhagyathara', + code: 'BT-78', + prizes: [ + [5, 1000, '1234'], + [8, 100, '1234'], + [9, 50, '5555'], + ], + }); + seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 2], ['0001', 5]] }); + seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 1], ['1234', 1]] }); + seedOrder(db, { shopId: f.shops.b, userId: f.multiUser.id, date: '2026-11-02', lines: [['5555', 4]] }); + seedOrder(db, { shopId: f.shops.c, userId: f.admin.id, date: '2026-11-02', lines: [['0002', 1]] }); +}); + +describe('results of a draw', () => { + it('totals each shop and pays every tier an ending is in', () => { + const r = dayResults(db, as(f.admin.id), '2026-11-02'); + expect(r.draw).toEqual({ date: '2026-11-02', scheme: 'Bhagyathara', code: 'BT-78', published: true, hasWin: true }); + const a = r.shops.find((s) => s.shop.id === f.shops.a)!; + // 4 tickets end 1234 and pay 1000 + 100 each; 5 tickets end 0001 and pay nothing. + expect(a).toMatchObject({ orders: 2, tickets: 9, winTickets: 4, prize: 4400 }); + expect(r.shops.find((s) => s.shop.id === f.shops.b)).toMatchObject({ tickets: 4, winTickets: 4, prize: 200 }); + }); + + it('lists the biggest prize first', () => { + const names = dayResults(db, as(f.admin.id), '2026-11-02').shops.map((s) => s.shop.id); + expect(names).toEqual([f.shops.a, f.shops.b, f.shops.c]); + }); + + it('shows a user only their own shops', () => { + const r = dayResults(db, as(f.shopUser.id), '2026-11-02'); + expect(r.shops.map((s) => s.shop.id)).toEqual([f.shops.a]); + }); + + it('shows an inactive user nothing', () => { + expect(dayResults(db, as(f.disabledUser.id), '2026-11-02').shops).toEqual([]); + }); + + it('leaves cancelled orders out', () => { + seedOrder(db, { shopId: f.shops.c, userId: f.admin.id, date: '2026-11-02', lines: [['1234', 50]], status: 'cancelled' }); + expect(dayResults(db, as(f.admin.id), '2026-11-02').shops.find((s) => s.shop.id === f.shops.c)?.prize).toBe(0); + }); + + it('keeps the past orders of a shop that was switched off', () => { + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.a); + expect(dayResults(db, as(f.shopUser.id), '2026-11-02').shops.map((s) => s.shop.id)).toEqual([f.shops.a]); + }); + + it('counts orders of the days between draws toward the next draw', () => { + seedOrder(db, { shopId: f.shops.c, userId: f.admin.id, date: '2026-11-03', lines: [['5555', 1]] }); + seedDraw(db, { date: '2026-11-04', code: 'BT-79', prizes: [[9, 70, '5555']] }); + const r = dayResults(db, as(f.admin.id), '2026-11-04'); + expect(r.shops.map((s) => [s.shop.id, s.prize])).toEqual([[f.shops.c, 70]]); + // The earlier draw is not affected by the later order. + expect(dayResults(db, as(f.admin.id), '2026-11-02').shops).toHaveLength(3); + }); + + it('shows a waiting date with orders but no scheme, code or prizes', () => { + seedOrder(db, { shopId: f.shops.c, userId: f.admin.id, date: '2026-11-03', lines: [['1234', 3]] }); + const r = dayResults(db, as(f.admin.id), '2026-11-03'); + expect(r.draw).toEqual({ date: '2026-11-03', scheme: null, code: null, published: false, hasWin: false }); + expect(r.shops).toMatchObject([{ tickets: 3, prize: 0 }]); + }); + + it('shows nothing for a date that never had a draw', () => { + seedOrder(db, { shopId: f.shops.c, userId: f.admin.id, date: '2026-11-03', lines: [['1234', 3]] }); + seedDraw(db, { date: '2026-11-05' }); + expect(dayResults(db, as(f.admin.id), '2026-11-03').shops).toEqual([]); + }); +}); + +describe('shops of a day', () => { + it('gives admins every shop, including switched-off ones', () => { + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.c); + const rows = dayShops(db, as(f.admin.id), '2026-11-02'); + expect(rows.map((r) => [r.shop.id, r.shop.active])).toEqual([[f.shops.a, true], [f.shops.b, true], [f.shops.c, false]]); + expect(rows[0]).toMatchObject({ tickets: 9, orders: 2, endings: 2 }); + }); + + it('hides switched-off shops from ordinary users', () => { + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); + expect(dayShops(db, as(f.multiUser.id), '2026-11-02').map((r) => r.shop.id)).toEqual([f.shops.a]); + }); + + it('lists shops with zero totals on a quiet day', () => { + expect(dayShops(db, as(f.shopUser.id), '2026-11-01')).toMatchObject([{ tickets: 0, orders: 0, endings: 0 }]); + }); +}); + +describe('one shop on one day', () => { + it('adds repeated endings together across orders and marks the winners', () => { + const d = shopDay(db, as(f.shopUser.id), f.shops.a, '2026-11-02'); + expect(d.endings).toEqual([ + { ending: '0001', qty: 5, hits: [] }, + { ending: '1234', qty: 4, hits: [{ tier: 5, amount: 1000 }, { tier: 8, amount: 100 }] }, + ]); + expect(d.draw.hasWin).toBe(true); + expect(d.orders).toHaveLength(2); + }); + + it('refuses a shop the user cannot see, without saying it exists', () => { + expect(() => shopDay(db, as(f.shopUser.id), f.shops.b, '2026-11-02')).toThrow(RuleError); + }); +}); + +describe('orders', () => { + it('lists the orders of a day, newest first', () => { + const early = seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-01', lines: [['1111', 1]], createdAt: '2026-10-31T05:00:00Z' }); + const late = seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-01', lines: [['2222', 1]], createdAt: '2026-10-31T09:00:00Z' }); + // 2026-11-01 comes before the first draw, so it is part of that draw's window. + const ids = dayOrders(db, as(f.shopUser.id), '2026-11-02').map((o) => o.id); + expect(ids.indexOf(late)).toBeLessThan(ids.indexOf(early)); + }); + + it('reads one order with its prizes, merging a repeated ending', () => { + const id = seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 1], ['1234', 2]], source: 'whatsapp_photo' }); + const o = getOrder(db, as(f.shopUser.id), id); + expect(o).toMatchObject({ id, drawDate: '2026-11-02', isPhoto: true, edited: false }); + expect(o.lines).toEqual([{ ending: '1234', qty: 3, hits: [{ tier: 5, amount: 1000 }, { tier: 8, amount: 100 }] }]); + }); + + it('hides orders of other shops and cancelled orders', () => { + const other = seedOrder(db, { shopId: f.shops.b, userId: f.multiUser.id, date: '2026-11-02', lines: [['0009', 1]] }); + expect(() => getOrder(db, as(f.shopUser.id), other)).toThrow(RuleError); + const gone = seedOrder(db, { shopId: f.shops.a, userId: f.shopUser.id, date: '2026-11-02', lines: [['1234', 1]], status: 'cancelled' }); + expect(() => getOrder(db, as(f.shopUser.id), gone)).toThrow(RuleError); + expect(() => getOrder(db, as(f.admin.id), 99999)).toThrow(RuleError); + }); +}); + +describe('month calendar', () => { + it('lists every day and marks published days with a win', () => { + const days = monthCalendar(db, as(f.admin.id), '2026-11'); + expect(days).toHaveLength(30); + expect(days[1]).toEqual({ date: '2026-11-02', scheme: 'Bhagyathara', code: 'BT-78', published: true, hasWin: true }); + expect(days[2]).toEqual({ date: '2026-11-03', scheme: null, code: null, published: false, hasWin: false }); + }); + + it('marks a win only for the shops the user can see', () => { + // shop-a wins; a user of shop C alone does not + const only = Number(db.prepare(`INSERT INTO users (username, password_hash, is_admin, active) VALUES ('c-only','x',0,1)`).run().lastInsertRowid); + db.prepare('INSERT INTO user_shops (user_id, shop_id) VALUES (?, ?)').run(only, f.shops.c); + expect(monthCalendar(db, as(only), '2026-11')[1]!.hasWin).toBe(false); + expect(monthCalendar(db, as(f.shopUser.id), '2026-11')[1]!.hasWin).toBe(true); + }); + + it('rejects text that is not a month', () => { + expect(() => monthCalendar(db, as(f.admin.id), '2026-13')).toThrow(RuleError); + }); +}); diff --git a/src/services/reports.ts b/src/services/reports.ts new file mode 100644 index 0000000..a5696c5 --- /dev/null +++ b/src/services/reports.ts @@ -0,0 +1,152 @@ +import type { Db } from '../db'; +import { addDays, monthBounds } from './dateMath'; +import { + drawForOrderDate, + drawsInMonth, + getDrawOn, + prizeLookup, + windowForDate, + windowOfDraw, + type Draw, + type OrderWindow, + type PrizeHit, +} from './draws'; +import { readOrders, tally, type OrderView, type Tally } from './orderQueries'; +import { RuleError } from './accountRules'; +import { canViewShop, viewableShops, type ShopListing } from './shopScope'; +import type { User } from './users'; + +export interface DrawView { + date: string; + scheme: string | null; + code: string | null; + published: boolean; + hasWin: boolean; +} + +export interface ShopResultView extends Tally { + shop: ShopListing; +} + +export interface ShopDayView { + shop: ShopListing; + tickets: number; + orders: number; + endings: number; +} + +export interface EndingTotal { + ending: string; + qty: number; + hits: PrizeHit[]; +} + +const drawView = (date: string, draw: Draw | undefined, hasWin: boolean): DrawView => ({ + date, + scheme: draw?.scheme ?? null, + code: draw?.code ?? null, + published: draw !== undefined, + hasWin, +}); + +function ordersOnDate(db: Db, shops: ShopListing[], date: string): { draw: Draw | undefined; orders: OrderView[] } { + const window = windowForDate(db, date); + const draw = getDrawOn(db, date); + return { draw, orders: window ? readOrders(db, { shops, window }, prizeLookup(db, draw?.id)) : [] }; +} + +export function dayResults(db: Db, user: User, date: string): { draw: DrawView; shops: ShopResultView[] } { + const shops = viewableShops(db, user); + const { draw, orders } = ordersOnDate(db, shops, date); + + const rows = shops + .map((shop) => ({ shop, ...tally(orders.filter((o) => o.shop.id === shop.id)) })) + .filter((r) => r.orders > 0) + .sort((a, b) => b.prize - a.prize || b.tickets - a.tickets || a.shop.name.localeCompare(b.shop.name)); + + return { draw: drawView(date, draw, rows.some((r) => r.prize > 0)), shops: rows }; +} + +export function dayShops(db: Db, user: User, date: string): ShopDayView[] { + const shops = viewableShops(db, user).filter((s) => user.isAdmin || s.active); + const { orders } = ordersOnDate(db, shops, date); + + return shops.map((shop) => { + const own = orders.filter((o) => o.shop.id === shop.id); + return { + shop, + tickets: tally(own).tickets, + orders: own.length, + endings: new Set(own.flatMap((o) => o.lines.map((l) => l.ending))).size, + }; + }); +} + +export function shopDay( + db: Db, + user: User, + shopId: number, + date: string, +): { shop: ShopListing; draw: DrawView; endings: EndingTotal[]; orders: OrderView[] } { + const shop = viewableShops(db, user).find((s) => s.id === shopId); + if (!shop) throw new RuleError('not_found', 'shop not found'); + const { draw, orders } = ordersOnDate(db, [shop], date); + + const totals = new Map(); + for (const line of orders.flatMap((o) => o.lines)) { + const total = totals.get(line.ending) ?? { ending: line.ending, qty: 0, hits: line.hits }; + total.qty += line.qty; + totals.set(line.ending, total); + } + const endings = [...totals.values()].sort((a, b) => a.ending.localeCompare(b.ending)); + + return { shop, draw: drawView(date, draw, endings.some((e) => e.hits.length > 0)), endings, orders }; +} + +export function dayOrders(db: Db, user: User, date: string): OrderView[] { + return ordersOnDate(db, viewableShops(db, user), date).orders; +} + +export function getOrder(db: Db, user: User, orderId: number): OrderView { + const row = db.prepare('SELECT shop_id, draw_date FROM orders WHERE id = ?').get(orderId) as + | { shop_id: number; draw_date: string } + | undefined; + if (!row || !canViewShop(db, user, row.shop_id)) throw new RuleError('not_found', 'order not found'); + + const shop = viewableShops(db, user).find((s) => s.id === row.shop_id)!; + const draw = drawForOrderDate(db, row.draw_date); + const [order] = readOrders(db, { shops: [shop], orderId }, prizeLookup(db, draw?.id)); + if (!order) throw new RuleError('not_found', 'order not found'); + return order; +} + +function hasWinningTicket(db: Db, shops: ShopListing[], draw: Draw, window: OrderWindow): boolean { + if (shops.length === 0) return false; + const found = db + .prepare( + `SELECT 1 + FROM orders o + JOIN order_lines ol ON ol.order_id = o.id + JOIN prizes p ON p.draw_id = ? AND p.ending = ol.ending + WHERE o.status = 'confirmed' AND o.draw_date > ? AND o.draw_date <= ? + AND o.shop_id IN (${shops.map(() => '?').join(', ')}) + LIMIT 1`, + ) + .get(draw.id, window.after, window.upTo, ...shops.map((s) => s.id)); + return found !== undefined; +} + +export function monthCalendar(db: Db, user: User, month: string): DrawView[] { + const bounds = monthBounds(month); + if (!bounds) throw new RuleError('invalid', 'month must look like 2026-11'); + const shops = viewableShops(db, user); + const published = new Map(drawsInMonth(db, month).map((d) => [d.date, d])); + + const days: DrawView[] = []; + for (let day = bounds.first; day <= bounds.last; day = addDays(day, 1)) { + const draw = published.get(day); + days.push(drawView(day, draw, draw ? hasWinningTicket(db, shops, draw, windowOfDraw(db, draw)) : false)); + } + return days; +} + diff --git a/src/services/shopAdmin.test.ts b/src/services/shopAdmin.test.ts new file mode 100644 index 0000000..dcc3b8a --- /dev/null +++ b/src/services/shopAdmin.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb, seedFixture } from '../testing/helpers'; +import { RuleError } from './accountRules'; +import { addShop, updateShop } from './shopAdmin'; +import { getUserById } from './users'; + +const setup = () => { + const db = makeDb(); + const f = seedFixture(db); + return { db, f, admin: getUserById(db, f.admin.id)!, user: getUserById(db, f.shopUser.id)! }; +}; + +describe('shop admin', () => { + it('lets only an admin add or change shops', () => { + const { db, f, user } = setup(); + expect(() => addShop(db, user, { name: 'New', code: 'N' })).toThrow(RuleError); + expect(() => updateShop(db, user, f.shops.a, { name: 'X' })).toThrow(RuleError); + }); + + it('adds a shop that is active', () => { + const { db, admin } = setup(); + expect(addShop(db, admin, { name: 'New Shop', code: 'NEW' })).toMatchObject({ name: 'New Shop', code: 'NEW', active: true }); + }); + + it('renames a shop and keeps its code', () => { + const { db, f, admin } = setup(); + expect(updateShop(db, admin, f.shops.a, { name: ' Renamed ' })).toMatchObject({ name: 'Renamed', code: 'A' }); + }); + + it('refuses a name another active shop has, in any letter case', () => { + const { db, f, admin } = setup(); + expect(() => updateShop(db, admin, f.shops.a, { name: 'shop b' })).toThrow(/already has that name/); + }); + + it('keeps its own name when only switching it off or on', () => { + const { db, f, admin } = setup(); + expect(updateShop(db, admin, f.shops.a, { active: false }).active).toBe(false); + expect(updateShop(db, admin, f.shops.a, { active: true }).active).toBe(true); + }); + + it('refuses switching a shop on when an active shop took its name', () => { + const { db, f, admin } = setup(); + updateShop(db, admin, f.shops.a, { active: false }); + updateShop(db, admin, f.shops.b, { name: 'Shop A' }); + expect(() => updateShop(db, admin, f.shops.a, { active: true })).toThrow(RuleError); + }); + + it('reports a shop that does not exist', () => { + const { db, admin } = setup(); + expect(() => updateShop(db, admin, 999, { name: 'X' })).toThrow(/shop not found/); + }); +}); diff --git a/src/services/shopAdmin.ts b/src/services/shopAdmin.ts new file mode 100644 index 0000000..f525152 --- /dev/null +++ b/src/services/shopAdmin.ts @@ -0,0 +1,35 @@ +import type { Db } from '../db'; +import { requireAdmin, RuleError } from './accountRules'; +import type { ShopListing } from './shopScope'; +import { assertNameFree, cleanShopName, createShop } from './shops'; +import type { User } from './users'; + +export interface ShopPatch { + name?: string; + active?: boolean; +} + +function readShop(db: Db, id: number): ShopListing | undefined { + const r = db.prepare('SELECT id, name, code, active FROM shops WHERE id = ?').get(id) as + | (Omit & { active: number }) + | undefined; + return r && { ...r, active: r.active === 1 }; +} + +export function addShop(db: Db, actor: User, input: { name: string; code: string }): ShopListing { + requireAdmin(db, actor); + return { ...createShop(db, input), active: true }; +} + +export function updateShop(db: Db, actor: User, shopId: number, patch: ShopPatch): ShopListing { + requireAdmin(db, actor); + const shop = readShop(db, shopId); + if (!shop) throw new RuleError('not_found', 'shop not found'); + + const name = patch.name === undefined ? shop.name : cleanShopName(patch.name); + const active = patch.active ?? shop.active; + if (active) assertNameFree(db, name, shop.id); + + db.prepare('UPDATE shops SET name = ?, active = ? WHERE id = ?').run(name, active ? 1 : 0, shop.id); + return { ...shop, name, active }; +} diff --git a/src/services/shopScope.test.ts b/src/services/shopScope.test.ts index 0951eaf..9d6c2fa 100644 --- a/src/services/shopScope.test.ts +++ b/src/services/shopScope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { makeDb, seedFixture } from '../testing/helpers'; -import { canUseShop, shopsForUser } from './shopScope'; +import { canUseShop, canViewShop, shopsForUser, viewableShops } from './shopScope'; import { getUserById } from './users'; describe('shopsForUser', () => { @@ -51,3 +51,23 @@ describe('canUseShop', () => { expect(canUseShop(db, user, 99999)).toBe(false); }); }); + +describe('viewableShops', () => { + it('keeps shops that were switched off, unlike shopsForUser', () => { + const db = makeDb(); + const f = seedFixture(db); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.a); + const user = getUserById(db, f.shopUser.id)!; + expect(shopsForUser(db, user)).toEqual([]); + expect(viewableShops(db, user)).toMatchObject([{ id: f.shops.a, active: false }]); + expect(canViewShop(db, user, f.shops.a)).toBe(true); + expect(canViewShop(db, user, f.shops.b)).toBe(false); + }); + + it('gives admins every shop and inactive users none', () => { + const db = makeDb(); + const f = seedFixture(db); + expect(viewableShops(db, getUserById(db, f.admin.id)!)).toHaveLength(3); + expect(viewableShops(db, getUserById(db, f.disabledUser.id)!)).toEqual([]); + }); +}); diff --git a/src/services/shopScope.ts b/src/services/shopScope.ts index 650d52a..9dfb64c 100644 --- a/src/services/shopScope.ts +++ b/src/services/shopScope.ts @@ -13,6 +13,10 @@ interface ShopRow { code: string; } +export interface ShopListing extends Shop { + active: boolean; +} + const toShop = (r: ShopRow): Shop => ({ id: r.id, name: r.name, code: r.code }); export function shopsForUser(db: Db, user: User): Shop[] { if (!user.active) return []; @@ -35,3 +39,24 @@ export function shopsForUser(db: Db, user: User): Shop[] { export function canUseShop(db: Db, user: User, shopId: number): boolean { return shopsForUser(db, user).some((s) => s.id === shopId); } + +export function viewableShops(db: Db, user: User): ShopListing[] { + if (!user.active) return []; + const rows = ( + user.isAdmin + ? db.prepare('SELECT id, name, code, active FROM shops ORDER BY name, id').all() + : db + .prepare( + `SELECT s.id, s.name, s.code, s.active + FROM shops s JOIN user_shops us ON us.shop_id = s.id + WHERE us.user_id = ? + ORDER BY s.name, s.id`, + ) + .all(user.id) + ) as Array; + return rows.map((r) => ({ ...toShop(r), active: r.active === 1 })); +} + +export function canViewShop(db: Db, user: User, shopId: number): boolean { + return viewableShops(db, user).some((s) => s.id === shopId); +} diff --git a/src/services/shops.ts b/src/services/shops.ts index 2496f8e..da55f44 100644 --- a/src/services/shops.ts +++ b/src/services/shops.ts @@ -1,26 +1,39 @@ import type { Db } from '../db'; import { isUniqueViolation, RuleError } from './accountRules'; -import type { Shop } from './shopScope'; +import type { Shop, ShopListing } from './shopScope'; -export interface ShopListing extends Shop { - active: boolean; -} +export type { ShopListing } from './shopScope'; export const SHOP_NAME_MAX = 100; export const SHOP_CODE_MAX = 20; -export function createShop(db: Db, input: { name: string; code: string }): Shop { - const name = input.name.trim(); - const code = input.code.trim(); +export function cleanShopName(raw: string): string { + const name = raw.trim(); if (name === '' || name.length > SHOP_NAME_MAX) { throw new RuleError('invalid', `the shop name must be 1 to ${SHOP_NAME_MAX} characters`); } + return name; +} + +function cleanShopCode(raw: string): string { + const code = raw.trim(); if (code === '' || code.length > SHOP_CODE_MAX || /\s/.test(code)) { throw new RuleError('invalid', `the shop code must be 1 to ${SHOP_CODE_MAX} characters with no spaces`); } + return code; +} - const sameName = db.prepare('SELECT 1 FROM shops WHERE active = 1 AND lower(name) = lower(?)').get(name); - if (sameName) throw new RuleError('conflict', 'an active shop already has that name'); +export function assertNameFree(db: Db, name: string, exceptShopId = 0): void { + const taken = db + .prepare('SELECT 1 FROM shops WHERE active = 1 AND id <> ? AND lower(name) = lower(?)') + .get(exceptShopId, name); + if (taken) throw new RuleError('conflict', 'an active shop already has that name'); +} + +export function createShop(db: Db, input: { name: string; code: string }): Shop { + const name = cleanShopName(input.name); + const code = cleanShopCode(input.code); + assertNameFree(db, name); try { const id = Number(db.prepare('INSERT INTO shops (name, code) VALUES (?, ?)').run(name, code).lastInsertRowid); diff --git a/src/services/token.test.ts b/src/services/token.test.ts new file mode 100644 index 0000000..f4aa5d0 --- /dev/null +++ b/src/services/token.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createTokenService, TOKEN_LIFETIME_SECONDS } from './token'; + +const SECRET = 'a-test-secret-of-sufficient-length'; +let clock = new Date('2026-11-04T10:00:00Z'); +const tokens = createTokenService(SECRET, () => clock); + +describe('session tokens', () => { + it('carry the user id back', () => { + expect(tokens.verify(tokens.issue(42))).toBe(42); + }); + + it('expire after 12 hours', () => { + const token = tokens.issue(1); + clock = new Date(clock.getTime() + (TOKEN_LIFETIME_SECONDS - 1) * 1000); + expect(tokens.verify(token)).toBe(1); + clock = new Date(clock.getTime() + 1000); + expect(tokens.verify(token)).toBeUndefined(); + clock = new Date('2026-11-04T10:00:00Z'); + }); + + it('are refused when signed with another secret', () => { + const other = createTokenService('a-different-secret-of-similar-length', () => clock); + expect(tokens.verify(other.issue(1))).toBeUndefined(); + }); + + it('are refused when the payload is changed', () => { + const [h, , s] = tokens.issue(1).split('.'); + const forged = Buffer.from(JSON.stringify({ sub: 2, iat: 0, exp: 9_999_999_999 })).toString('base64url'); + expect(tokens.verify(`${h}.${forged}.${s}`)).toBeUndefined(); + }); + + it('are refused when the header names another algorithm', () => { + const [, p, s] = tokens.issue(1).split('.'); + const none = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + expect(tokens.verify(`${none}.${p}.${s}`)).toBeUndefined(); + expect(tokens.verify(`${none}.${p}.`)).toBeUndefined(); + }); + + it('are refused when they are not tokens at all', () => { + for (const junk of ['', 'abc', 'a.b', 'a.b.c.d', '..']) expect(tokens.verify(junk)).toBeUndefined(); + }); +}); diff --git a/src/services/token.ts b/src/services/token.ts new file mode 100644 index 0000000..5c8ed1b --- /dev/null +++ b/src/services/token.ts @@ -0,0 +1,42 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const TOKEN_LIFETIME_SECONDS = 12 * 60 * 60; + +export interface TokenService { + issue(userId: number): string; + verify(token: string): number | undefined; +} + +const b64 = (data: string | Buffer): string => Buffer.from(data).toString('base64url'); +const HEADER = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + +export function createTokenService(secret: string, now: () => Date = () => new Date()): TokenService { + const sign = (data: string): Buffer => createHmac('sha256', secret).update(data).digest(); + + return { + issue(userId) { + const iat = Math.floor(now().getTime() / 1000); + const payload = b64(JSON.stringify({ sub: userId, iat, exp: iat + TOKEN_LIFETIME_SECONDS })); + return `${HEADER}.${payload}.${b64(sign(`${HEADER}.${payload}`))}`; + }, + + verify(token) { + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== HEADER) return undefined; + const [header, payload, signature] = parts as [string, string, string]; + + const expected = sign(`${header}.${payload}`); + const given = Buffer.from(signature, 'base64url'); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined; + + try { + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { sub?: unknown; exp?: unknown }; + const nowSeconds = Math.floor(now().getTime() / 1000); + if (!Number.isInteger(claims.sub) || typeof claims.exp !== 'number' || claims.exp <= nowSeconds) return undefined; + return claims.sub as number; + } catch { + return undefined; + } + }, + }; +} diff --git a/src/services/userAdmin.test.ts b/src/services/userAdmin.test.ts index 475d34d..fe0d2ac 100644 --- a/src/services/userAdmin.test.ts +++ b/src/services/userAdmin.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { makeDb, seedFixture } from '../testing/helpers'; import { shopsForUser } from './shopScope'; -import { createUser, RuleError, setUserShops, updateUser, type RuleErrorCode } from './userAdmin'; -import { getUserById } from './users'; +import { createUser, deleteUser, listManagedUsers, RuleError, setUserShops, updateUser, type RuleErrorCode } from './userAdmin'; +import { findUserByPhone, getUserById } from './users'; const setup = () => { const db = makeDb(); @@ -69,6 +69,7 @@ describe('creating users', () => { it('refuses a duplicate username or phone number', () => { const { db, f, admin } = setup(); expect(code(() => createUser(db, admin, { username: 'shop-a', passwordHash: 'h' }))).toBe('conflict'); + expect(code(() => createUser(db, admin, { username: 'Shop-A', passwordHash: 'h' }))).toBe('conflict'); expect(code(() => createUser(db, admin, { username: 'other', passwordHash: 'h', phone: f.shopUser.phone }))).toBe('conflict'); }); @@ -163,3 +164,58 @@ describe('assigning shops', () => { expect(shopsForUser(db, getUserById(db, f.shopUser.id)!).map((s) => s.id)).toEqual([f.shops.a]); }); }); + +describe('deleting a user', () => { + const del = (db: ReturnType, actor: ReturnType, id: number) => + code(() => deleteUser(db, actor!, id)); + + it('empties the account but keeps the row for the orders it wrote', () => { + const { db, f, admin } = setup(); + db.prepare( + `INSERT INTO orders (shop_id, draw_date, source, created_by_user_id, raw_text) VALUES (?, '2026-11-02', 'web', ?, 'x')`, + ).run(f.shops.a, f.shopUser.id); + expect(del(db, admin, f.shopUser.id)).toBe('no error'); + + const row = db.prepare('SELECT username, phone, active, password_hash, deleted_at FROM users WHERE id = ?').get(f.shopUser.id) as Record; + expect(row).toMatchObject({ phone: null, active: 0, password_hash: 'deleted' }); + expect(row.username).toBe(`shop-a#deleted-${f.shopUser.id}`); + expect(row.deleted_at).not.toBeNull(); + expect(db.prepare('SELECT COUNT(*) AS n FROM orders WHERE created_by_user_id = ?').get(f.shopUser.id)).toEqual({ n: 1 }); + expect(db.prepare('SELECT COUNT(*) AS n FROM user_shops WHERE user_id = ?').get(f.shopUser.id)).toEqual({ n: 0 }); + }); + + it('hides the user everywhere and frees the username and phone', () => { + const { db, f, admin } = setup(); + deleteUser(db, admin, f.shopUser.id); + expect(getUserById(db, f.shopUser.id)).toBeUndefined(); + expect(listManagedUsers(db, admin).map((u) => u.id)).not.toContain(f.shopUser.id); + expect(findUserByPhone(db, f.shopUser.phone)).toBeUndefined(); + expect(code(() => createUser(db, admin, { username: 'shop-a', passwordHash: 'h', phone: f.shopUser.phone }))).toBe('no error'); + }); + + it('refuses admins, unknown users and non-admin callers', () => { + const { db, f, admin, shopUser } = setup(); + expect(del(db, admin, f.admin2.id)).toBe('target_is_admin'); + expect(del(db, admin, 9999)).toBe('not_found'); + expect(del(db, shopUser, f.multiUser.id)).toBe('forbidden'); + expect(del(db, admin, f.shopUser.id)).toBe('no error'); + expect(del(db, admin, f.shopUser.id)).toBe('not_found'); + }); +}); + +describe('listing users with their shops', () => { + it('gives each user the shops assigned, switched off ones included, and admins none', () => { + const { db, f, admin } = setup(); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); + const byId = new Map(listManagedUsers(db, admin).map((u) => [u.id, u])); + expect(byId.get(f.multiUser.id)!.shopIds).toEqual([f.shops.a, f.shops.b]); + expect(byId.get(f.admin.id)!.shopIds).toEqual([]); + }); + + it('keeps a switched-off shop when the same list is saved again', () => { + const { db, f, admin } = setup(); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(f.shops.b); + expect(code(() => setUserShops(db, admin, f.multiUser.id, [f.shops.a, f.shops.b]))).toBe('no error'); + expect(code(() => setUserShops(db, admin, f.shopUser.id, [f.shops.b]))).toBe('invalid'); + }); +}); diff --git a/src/services/userAdmin.ts b/src/services/userAdmin.ts index 69638d9..28b700e 100644 --- a/src/services/userAdmin.ts +++ b/src/services/userAdmin.ts @@ -1,7 +1,7 @@ import type { Db } from '../db'; -import { cleanPhone, cleanUsername, isUniqueViolation, RuleError, writeShops } from './accountRules'; +import { cleanPhone, cleanUsername, isUniqueViolation, requireAdmin, RuleError, writeShops } from './accountRules'; import { createUserAccount } from './provisioning'; -import { getUserById, type User } from './users'; +import { getUserById, listUsers, withShops, type User, type UserWithShops } from './users'; export { RuleError } from './accountRules'; export type { RuleErrorCode } from './accountRules'; @@ -20,19 +20,25 @@ export interface UserPatch { passwordHash?: string; } -function requireAdmin(db: Db, actor: User): void { - const current = getUserById(db, actor.id); - if (!current || !current.isAdmin || !current.active) { - throw new RuleError('forbidden', 'only an active admin can manage users'); - } -} - function manageableTarget(db: Db, userId: number): User { const target = getUserById(db, userId); if (!target) throw new RuleError('not_found', 'user not found'); if (target.isAdmin) throw new RuleError('target_is_admin', 'admins cannot be changed here'); return target; } +const DELETED_HASH = 'deleted'; + +export function listManagedUsers(db: Db, actor: User): UserWithShops[] { + requireAdmin(db, actor); + return withShops(db, listUsers(db)); +} + +export function getManagedUser(db: Db, actor: User, userId: number): UserWithShops { + requireAdmin(db, actor); + const user = getUserById(db, userId); + if (!user) throw new RuleError('not_found', 'user not found'); + return withShops(db, [user])[0]!; +} export function createUser(db: Db, actor: User, input: NewUser, defaultCountryCode = '91'): User { requireAdmin(db, actor); @@ -85,3 +91,19 @@ export function setUserShops(db: Db, actor: User, userId: number, shopIds: numbe manageableTarget(db, userId); db.transaction(() => writeShops(db, userId, shopIds))(); } + +export function deleteUser(db: Db, actor: User, userId: number): void { + requireAdmin(db, actor); + manageableTarget(db, userId); + db.transaction(() => { + db.prepare( + `UPDATE users + SET active = 0, phone = NULL, password_hash = ?, + username = username || '#deleted-' || id, + deleted_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? AND is_admin = 0`, + ).run(DELETED_HASH, userId); + db.prepare('DELETE FROM user_shops WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM conversations WHERE user_id = ?').run(userId); + })(); +} diff --git a/src/services/users.ts b/src/services/users.ts index bd9169b..bf2b34e 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -8,6 +8,10 @@ export interface User { active: boolean; } +export interface UserWithShops extends User { + shopIds: number[]; +} + interface UserRow { id: number; username: string; @@ -17,6 +21,7 @@ interface UserRow { } const COLUMNS = 'id, username, phone, is_admin, active'; +const LIVE = 'deleted_at IS NULL'; function toUser(r: UserRow): User { return { @@ -29,16 +34,40 @@ function toUser(r: UserRow): User { } export function findUserByPhone(db: Db, phone: string): User | undefined { - const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE phone = ?`).get(phone) as UserRow | undefined; + const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE phone = ? AND ${LIVE}`).get(phone) as UserRow | undefined; + return row ? toUser(row) : undefined; +} + +export function findUserByUsername(db: Db, username: string): User | undefined { + const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE username = ? COLLATE NOCASE AND ${LIVE}`).get(username) as + | UserRow + | undefined; return row ? toUser(row) : undefined; } export function getUserById(db: Db, id: number): User | undefined { - const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE id = ?`).get(id) as UserRow | undefined; + const row = db.prepare(`SELECT ${COLUMNS} FROM users WHERE id = ? AND ${LIVE}`).get(id) as UserRow | undefined; return row ? toUser(row) : undefined; } export function listUsers(db: Db): User[] { - const rows = db.prepare(`SELECT ${COLUMNS} FROM users ORDER BY is_admin DESC, username`).all() as UserRow[]; + const rows = db + .prepare(`SELECT ${COLUMNS} FROM users WHERE ${LIVE} ORDER BY is_admin DESC, username`) + .all() as UserRow[]; return rows.map(toUser); } + +function shopIdsByUser(db: Db): Map { + const rows = db.prepare('SELECT user_id, shop_id FROM user_shops ORDER BY shop_id').all() as Array<{ + user_id: number; + shop_id: number; + }>; + const byUser = new Map(); + for (const r of rows) byUser.set(r.user_id, [...(byUser.get(r.user_id) ?? []), r.shop_id]); + return byUser; +} + +export function withShops(db: Db, users: User[]): UserWithShops[] { + const byUser = shopIdsByUser(db); + return users.map((u) => ({ ...u, shopIds: u.isAdmin ? [] : (byUser.get(u.id) ?? []) })); +} diff --git a/src/testing/config.ts b/src/testing/config.ts index 1fd0cb2..7390d65 100644 --- a/src/testing/config.ts +++ b/src/testing/config.ts @@ -7,6 +7,7 @@ export function testConfig(overrides: Partial = {}): Config { dbPath: ':memory:', srsSecret: 'test-secret-0123456789', jwtSecret: undefined, + corsOrigin: undefined, bridgeUrl: 'http://127.0.0.1:3000', defaultCountryCode: '91', orders: { cutoffMin: 15 * 60, askUntilMin: 15 * 60 + 30, remindMin: 10, expireMin: 30, updateQuietSec: 3 }, diff --git a/src/testing/helpers.ts b/src/testing/helpers.ts index 35bb940..02d4f2f 100644 --- a/src/testing/helpers.ts +++ b/src/testing/helpers.ts @@ -50,3 +50,49 @@ export const hasPdftotext: boolean = (() => { const r = spawnSync('pdftotext', ['-v'], { encoding: 'utf8' }); return !r.error; })(); + +export interface SeedDraw { + date: string; + scheme?: string; + code?: string; + /** [tier, amount, ending] */ + prizes?: Array<[number, number, string]>; +} + +/** Stores a draw with its prizes, the way a checked result does. Returns the draw id. */ +export function seedDraw(db: Db, d: SeedDraw): number { + const id = Number( + db + .prepare('INSERT INTO draws (draw_date, scheme, draw_code) VALUES (?, ?, ?)') + .run(d.date, d.scheme ?? 'Test Scheme', d.code ?? `T-${d.date}`).lastInsertRowid, + ); + const insert = db.prepare('INSERT INTO prizes (draw_id, tier, amount, ending) VALUES (?, ?, ?, ?)'); + for (const [tier, amount, ending] of d.prizes ?? []) insert.run(id, tier, amount, ending); + return id; +} + +export interface SeedOrder { + shopId: number; + userId: number; + date: string; + /** [ending, qty]; an ending may repeat. */ + lines: Array<[string, number]>; + status?: 'confirmed' | 'cancelled'; + source?: 'whatsapp_text' | 'whatsapp_photo' | 'web'; + createdAt?: string; +} + +export function seedOrder(db: Db, o: SeedOrder): number { + const id = Number( + db + .prepare( + `INSERT INTO orders (shop_id, draw_date, status, source, created_by_user_id, raw_text, created_at) + VALUES (?, ?, ?, ?, ?, 'raw', COALESCE(?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))`, + ) + .run(o.shopId, o.date, o.status ?? 'confirmed', o.source ?? 'whatsapp_text', o.userId, o.createdAt ?? null) + .lastInsertRowid, + ); + const insert = db.prepare('INSERT INTO order_lines (order_id, ending, qty) VALUES (?, ?, ?)'); + for (const [ending, qty] of o.lines) insert.run(id, ending, qty); + return id; +}