feat(users): added a wizard to reset a password and supporting services for the srs PWA
This commit is contained in:
parent
5063791b62
commit
61514fb82f
@ -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
|
||||
|
||||
@ -18,7 +18,8 @@ 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -25,14 +25,17 @@ export function makeIo(): { io: Io; close: () => void } {
|
||||
}
|
||||
});
|
||||
|
||||
const io: Io = {
|
||||
async ask(question) {
|
||||
async function read(question: string): Promise<string> {
|
||||
rl.setPrompt(question);
|
||||
rl.prompt();
|
||||
const line = queue.length > 0 ? queue.shift()! : ended ? null : await new Promise<string | null>((r) => (waiting = r));
|
||||
if (line === null) throw new Cancelled();
|
||||
return line.trim();
|
||||
},
|
||||
return line;
|
||||
}
|
||||
|
||||
const io: Io = {
|
||||
ask: async (question) => (await read(question)).trim(),
|
||||
askExact: read,
|
||||
say(line = '') {
|
||||
console.log(line);
|
||||
},
|
||||
|
||||
@ -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,9 +12,10 @@ import { makeIo } from './cliIo';
|
||||
|
||||
async function main(): Promise<number> {
|
||||
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 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<number> {
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
13
src/app.ts
13
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' });
|
||||
|
||||
@ -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<typeof makeDb>, sql: string) => db.prepare(sql).all
|
||||
describe('creating an admin', () => {
|
||||
it('asks for the username and phone, then creates the admin', async () => {
|
||||
const db = makeDb();
|
||||
const s = scripted(['1', 'owner', '98470 49032', 'y']);
|
||||
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<typeof makeDb>, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string>;
|
||||
askExact?(question: string): Promise<string>;
|
||||
say(line?: string): void;
|
||||
}
|
||||
|
||||
@ -70,6 +72,25 @@ const parseShopNumbers =
|
||||
return { ok: true, value: [...new Set(numbers)] };
|
||||
};
|
||||
|
||||
const parsePassword = (a: string): Parsed<string> => {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<User> => {
|
||||
const user = a.trim() === '' ? undefined : findUserByUsername(db, a.trim());
|
||||
return user
|
||||
? { ok: true, value: user }
|
||||
: { ok: false, error: 'No account with that username. See them with: npm run user list' };
|
||||
};
|
||||
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'];
|
||||
|
||||
@ -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) };
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
5
src/db/migrations/004_user_delete_and_order_edit.sql
Normal file
5
src/db/migrations/004_user_delete_and_order_edit.sql
Normal file
@ -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;
|
||||
2
src/db/migrations/005_username_nocase.sql
Normal file
2
src/db/migrations/005_username_nocase.sql
Normal file
@ -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);
|
||||
214
src/routes/api/api.test.ts
Normal file
214
src/routes/api/api.test.ts
Normal file
@ -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<string, unknown> }) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
16
src/routes/api/cors.ts
Normal file
16
src/routes/api/cors.ts
Normal file
@ -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(),
|
||||
);
|
||||
}
|
||||
23
src/routes/api/draws.ts
Normal file
23
src/routes/api/draws.ts
Normal file
@ -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);
|
||||
});
|
||||
};
|
||||
30
src/routes/api/errors.ts
Normal file
30
src/routes/api/errors.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ZodError } from 'zod';
|
||||
import { RuleError, type RuleErrorCode } from '../../services/accountRules';
|
||||
|
||||
const STATUS: Record<RuleErrorCode, number> = {
|
||||
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;
|
||||
}
|
||||
20
src/routes/api/guard.ts
Normal file
20
src/routes/api/guard.ts
Normal file
@ -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;
|
||||
};
|
||||
39
src/routes/api/index.ts
Normal file
39
src/routes/api/index.ts
Normal file
@ -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));
|
||||
});
|
||||
};
|
||||
40
src/routes/api/orders.ts
Normal file
40
src/routes/api/orders.ts
Normal file
@ -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();
|
||||
});
|
||||
};
|
||||
10
src/routes/api/schemas.ts
Normal file
10
src/routes/api/schemas.ts
Normal file
@ -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 });
|
||||
122
src/routes/api/session.test.ts
Normal file
122
src/routes/api/session.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
70
src/routes/api/session.ts
Normal file
70
src/routes/api/session.ts
Normal file
@ -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<string> | undefined;
|
||||
const decoy = (): Promise<string> => (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]);
|
||||
};
|
||||
36
src/routes/api/shops.ts
Normal file
36
src/routes/api/shops.ts
Normal file
@ -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);
|
||||
});
|
||||
};
|
||||
72
src/routes/api/users.ts
Normal file
72
src/routes/api/users.ts
Normal file
@ -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();
|
||||
});
|
||||
};
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
32
src/services/dateMath.test.ts
Normal file
32
src/services/dateMath.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
18
src/services/dateMath.ts
Normal file
18
src/services/dateMath.ts
Normal file
@ -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) };
|
||||
}
|
||||
@ -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);
|
||||
|
||||
83
src/services/draws.test.ts
Normal file
83
src/services/draws.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
86
src/services/draws.ts
Normal file
86
src/services/draws.ts
Normal file
@ -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<string, PrizeHit[]>;
|
||||
|
||||
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);
|
||||
31
src/services/loginThrottle.test.ts
Normal file
31
src/services/loginThrottle.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
35
src/services/loginThrottle.ts
Normal file
35
src/services/loginThrottle.ts
Normal file
@ -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<string, Entry>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
87
src/services/orderEdit.test.ts
Normal file
87
src/services/orderEdit.test.ts
Normal file
@ -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 });
|
||||
});
|
||||
});
|
||||
55
src/services/orderEdit.ts
Normal file
55
src/services/orderEdit.ts
Normal file
@ -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);
|
||||
})();
|
||||
}
|
||||
103
src/services/orderQueries.ts
Normal file
103
src/services/orderQueries.ts
Normal file
@ -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<string | number> = 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<number, OrderLineView[]>();
|
||||
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;
|
||||
}
|
||||
34
src/services/password.test.ts
Normal file
34
src/services/password.test.ts
Normal file
@ -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 ');
|
||||
});
|
||||
});
|
||||
34
src/services/password.ts
Normal file
34
src/services/password.ts
Normal file
@ -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<Buffer> =>
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
166
src/services/reports.test.ts
Normal file
166
src/services/reports.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
152
src/services/reports.ts
Normal file
152
src/services/reports.ts
Normal file
@ -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<string, EndingTotal>();
|
||||
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;
|
||||
}
|
||||
|
||||
52
src/services/shopAdmin.test.ts
Normal file
52
src/services/shopAdmin.test.ts
Normal file
@ -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/);
|
||||
});
|
||||
});
|
||||
35
src/services/shopAdmin.ts
Normal file
35
src/services/shopAdmin.ts
Normal file
@ -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<ShopListing, 'active'> & { 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 };
|
||||
}
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<ShopRow & { active: number }>;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
43
src/services/token.test.ts
Normal file
43
src/services/token.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
42
src/services/token.ts
Normal file
42
src/services/token.ts
Normal file
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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<typeof makeDb>, actor: ReturnType<typeof getUserById>, 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<string, unknown>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
})();
|
||||
}
|
||||
|
||||
@ -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<number, number[]> {
|
||||
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<number, number[]>();
|
||||
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) ?? []) }));
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export function testConfig(overrides: Partial<Config> = {}): 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 },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user