diff --git a/README.md b/README.md index 669b4af..87fa224 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ 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 list # show every account +npm run shop create # add a shop (name + unique code) +npm run shop list # show every shop npm run dev # http://127.0.0.1:8080 npm test npm run typecheck diff --git a/package.json b/package.json index 33635ab..86d0598 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "migrate": "tsx src/db/migrate.ts", "migrate:prod": "node dist/db/migrate.js", "user": "tsx scripts/user.ts", + "shop": "tsx scripts/shop.ts", "results": "tsx scripts/results.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/cliIo.ts b/scripts/cliIo.ts new file mode 100644 index 0000000..2c6db10 --- /dev/null +++ b/scripts/cliIo.ts @@ -0,0 +1,41 @@ +import { createInterface } from 'node:readline'; +import { Cancelled, type Io } from '../src/cli/userWizard'; + +export function makeIo(): { io: Io; close: () => void } { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const queue: string[] = []; + let waiting: ((line: string | null) => void) | null = null; + let ended = false; + + rl.on('line', (line) => { + if (waiting) { + const w = waiting; + waiting = null; + w(line); + } else { + queue.push(line); + } + }); + rl.on('close', () => { + ended = true; + if (waiting) { + const w = waiting; + waiting = null; + w(null); + } + }); + + const io: Io = { + async ask(question) { + rl.setPrompt(question); + rl.prompt(); + const line = queue.length > 0 ? queue.shift()! : ended ? null : await new Promise((r) => (waiting = r)); + if (line === null) throw new Cancelled(); + return line.trim(); + }, + say(line = '') { + console.log(line); + }, + }; + return { io, close: () => rl.close() }; +} diff --git a/scripts/shop.ts b/scripts/shop.ts new file mode 100644 index 0000000..45b0a5c --- /dev/null +++ b/scripts/shop.ts @@ -0,0 +1,46 @@ +/** + * npm run shop create a short wizard: name and code + * npm run shop list show every shop + */ +import { createShopWizard, shopListLines } from '../src/cli/shopWizard'; +import { loadConfig } from '../src/config'; +import { openDb } from '../src/db'; +import { runMigrations } from '../src/db/migrate'; +import { makeIo } from './cliIo'; + +async function main(): Promise { + const command = process.argv[2]; + if (command !== 'create' && command !== 'list') { + console.log('usage:'); + console.log(' npm run shop create add a shop (a short wizard)'); + console.log(' npm run shop list show every shop'); + return command ? 1 : 0; + } + + const config = loadConfig(); + const db = openDb(config.dbPath); + runMigrations(db); + + try { + if (command === 'list') { + for (const line of shopListLines(db)) console.log(line); + return 0; + } + const { io, close } = makeIo(); + try { + return await createShopWizard(db, io); + } finally { + close(); + } + } finally { + db.close(); + } +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error(String(err)); + process.exit(1); + }, +); diff --git a/scripts/user.ts b/scripts/user.ts index 1adf6d5..c2be545 100644 --- a/scripts/user.ts +++ b/scripts/user.ts @@ -3,50 +3,11 @@ * npm run user list show every account * */ -import { createInterface } from 'node:readline'; -import { Cancelled, createWizard, listLines, type Io } from '../src/cli/userWizard'; +import { createWizard, listLines } from '../src/cli/userWizard'; import { loadConfig } from '../src/config'; import { openDb } from '../src/db'; import { runMigrations } from '../src/db/migrate'; - -function makeIo(): { io: Io; close: () => void } { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const queue: string[] = []; - let waiting: ((line: string | null) => void) | null = null; - let ended = false; - - rl.on('line', (line) => { - if (waiting) { - const w = waiting; - waiting = null; - w(line); - } else { - queue.push(line); - } - }); - rl.on('close', () => { - ended = true; - if (waiting) { - const w = waiting; - waiting = null; - w(null); - } - }); - - const io: Io = { - async ask(question) { - rl.setPrompt(question); - rl.prompt(); - const line = queue.length > 0 ? queue.shift()! : ended ? null : await new Promise((r) => (waiting = r)); - if (line === null) throw new Cancelled(); - return line.trim(); - }, - say(line = '') { - console.log(line); - }, - }; - return { io, close: () => rl.close() }; -} +import { makeIo } from './cliIo'; async function main(): Promise { const command = process.argv[2]; diff --git a/src/cli/shopWizard.test.ts b/src/cli/shopWizard.test.ts new file mode 100644 index 0000000..8d13674 --- /dev/null +++ b/src/cli/shopWizard.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb } from '../testing/helpers'; +import { createShop } from '../services/shops'; +import { createShopWizard, shopListLines } from './shopWizard'; +import { Cancelled, type Io } from './userWizard'; + +function scripted(answers: string[]) { + const shown: string[] = []; + const queue = [...answers]; + const io: Io = { + async ask(question) { + shown.push(question); + const next = queue.shift(); + if (next === undefined) throw new Cancelled(); + return next.trim(); + }, + say(line = '') { + shown.push(line); + }, + }; + return { io, text: () => shown.join('\n') }; +} + +const shops = (db: ReturnType) => db.prepare('SELECT name, code, active FROM shops').all(); + +describe('creating a shop', () => { + it('asks for name and code, confirms, then creates it', async () => { + const db = makeDb(); + const s = scripted(['Lakshmi Agency', 'LK1', 'y']); + expect(await createShopWizard(db, s.io)).toBe(0); + expect(shops(db)).toEqual([{ name: 'Lakshmi Agency', code: 'LK1', active: 1 }]); + expect(s.text()).toContain('Created shop "Lakshmi Agency" (LK1).'); + }); + + it('creates nothing when the answer is not yes', async () => { + const db = makeDb(); + expect(await createShopWizard(db, scripted(['Lakshmi', 'LK1', 'n']).io)).toBe(0); + expect(shops(db)).toEqual([]); + }); + + it('asks again after a bad code, and q quits', async () => { + const db = makeDb(); + const s = scripted(['Lakshmi', 'a b', 'LK1', 'y']); + await createShopWizard(db, s.io); + expect(s.text()).toContain('no spaces'); + expect(shops(db)).toHaveLength(1); + + const q = scripted(['Another', 'q']); + expect(await createShopWizard(db, q.io)).toBe(0); + expect(shops(db)).toHaveLength(1); + }); + + it('reports a duplicate and exits 1', async () => { + const db = makeDb(); + createShop(db, { name: 'Lakshmi', code: 'LK1' }); + const s = scripted(['Other', 'LK1', 'y']); + expect(await createShopWizard(db, s.io)).toBe(1); + expect(s.text()).toContain('Could not create it: that shop code is already used.'); + }); +}); + +describe('listing shops', () => { + it('says so when there are none', () => { + expect(shopListLines(makeDb())).toEqual(['No shops yet. Run: npm run shop create']); + }); + + it('shows status, code and name', () => { + const db = makeDb(); + createShop(db, { name: 'Lakshmi', code: 'LK1' }); + expect(shopListLines(db).join('\n')).toMatch(/active\s+LK1\s+Lakshmi/); + }); +}); diff --git a/src/cli/shopWizard.ts b/src/cli/shopWizard.ts new file mode 100644 index 0000000..4232bb3 --- /dev/null +++ b/src/cli/shopWizard.ts @@ -0,0 +1,52 @@ +import type { Db } from '../db'; +import { RuleError } from '../services/accountRules'; +import { createShop, listShops, SHOP_CODE_MAX, SHOP_NAME_MAX } from '../services/shops'; +import { askUntil, Cancelled, confirm, type Io, type Parsed } from './userWizard'; + +const parseName = (a: string): Parsed => + a.trim() !== '' && a.trim().length <= SHOP_NAME_MAX + ? { ok: true, value: a.trim() } + : { ok: false, error: `Type a name of 1 to ${SHOP_NAME_MAX} characters.` }; + +const parseCode = (a: string): Parsed => + a.trim() !== '' && a.trim().length <= SHOP_CODE_MAX && !/\s/.test(a.trim()) + ? { ok: true, value: a.trim() } + : { ok: false, error: `Type a code of 1 to ${SHOP_CODE_MAX} characters with no spaces.` }; + +export async function createShopWizard(db: Db, io: Io): Promise { + try { + io.say('Create a shop (type q at any question to quit)'); + io.say(); + const name = await askUntil(io, 'Shop name: ', parseName); + const code = await askUntil(io, 'Short code (unique, no spaces): ', parseCode); + io.say(); + io.say(`About to create shop "${name}" with code ${code}.`); + if (!(await confirm(io, 'Create it? [y/N]: '))) { + io.say('Cancelled. Nothing was created.'); + return 0; + } + const shop = createShop(db, { name, code }); + io.say(`Created shop "${shop.name}" (${shop.code}).`); + io.say('Assign it to users with: npm run user create'); + return 0; + } catch (e) { + if (e instanceof Cancelled) { + io.say(e.message === 'cancelled' ? 'Cancelled. Nothing was created.' : `Stopped: ${e.message}. Nothing was created.`); + return 0; + } + if (e instanceof RuleError) { + io.say(`Could not create it: ${e.message}.`); + return 1; + } + throw e; + } +} + +export function shopListLines(db: Db): string[] { + const shops = listShops(db); + if (shops.length === 0) return ['No shops yet. Run: npm run shop create']; + return [ + 'STATUS CODE NAME', + ...shops.map((s) => [(s.active ? 'active' : 'disabled').padEnd(9), s.code.padEnd(20), s.name].join(' ')), + ]; +} diff --git a/src/cli/userWizard.ts b/src/cli/userWizard.ts index da61979..a72f6fd 100644 --- a/src/cli/userWizard.ts +++ b/src/cli/userWizard.ts @@ -16,11 +16,11 @@ export interface Io { say(line?: string): void; } -type Parsed = { ok: true; value: T } | { ok: false; error: string }; +export type Parsed = { ok: true; value: T } | { ok: false; error: string }; const MAX_TRIES = 5; -async function askUntil(io: Io, question: string, parse: (answer: string) => Parsed): Promise { +export async function askUntil(io: Io, question: string, parse: (answer: string) => Parsed): Promise { for (let tries = 0; tries < MAX_TRIES; tries++) { const answer = await io.ask(question); if (answer.toLowerCase() === 'q') throw new Cancelled(); @@ -70,7 +70,7 @@ const parseShopNumbers = return { ok: true, value: [...new Set(numbers)] }; }; -async function confirm(io: Io, question: string): Promise { +export async function confirm(io: Io, question: string): Promise { const a = (await io.ask(question)).toLowerCase(); return a === 'y' || a === 'yes'; } diff --git a/src/services/shops.test.ts b/src/services/shops.test.ts new file mode 100644 index 0000000..5e0b16f --- /dev/null +++ b/src/services/shops.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { makeDb } from '../testing/helpers'; +import { RuleError, type RuleErrorCode } from './accountRules'; +import { createShop, listShops } from './shops'; + +const code = (fn: () => unknown): RuleErrorCode | 'no error' => { + try { + fn(); + return 'no error'; + } catch (e) { + if (e instanceof RuleError) return e.code; + throw e; + } +}; + +describe('createShop', () => { + it('creates an active shop and trims the name and code', () => { + const db = makeDb(); + const shop = createShop(db, { name: ' Lakshmi Agency ', code: ' LK1 ' }); + expect(shop).toEqual({ id: expect.any(Number), name: 'Lakshmi Agency', code: 'LK1' }); + expect(db.prepare('SELECT active FROM shops WHERE id = ?').get(shop.id)).toEqual({ active: 1 }); + }); + + it('refuses a blank or oversized name or code', () => { + const db = makeDb(); + expect(code(() => createShop(db, { name: ' ', code: 'A' }))).toBe('invalid'); + expect(code(() => createShop(db, { name: 'A shop', code: '' }))).toBe('invalid'); + expect(code(() => createShop(db, { name: 'x'.repeat(101), code: 'A' }))).toBe('invalid'); + expect(code(() => createShop(db, { name: 'A shop', code: 'x'.repeat(21) }))).toBe('invalid'); + expect(listShops(db)).toEqual([]); + }); + + it('refuses a code with spaces inside', () => { + expect(code(() => createShop(makeDb(), { name: 'A shop', code: 'A B' }))).toBe('invalid'); + }); + + it('refuses a code already used, even by an inactive shop', () => { + const db = makeDb(); + const first = createShop(db, { name: 'One', code: 'A' }); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(first.id); + expect(code(() => createShop(db, { name: 'Two', code: 'A' }))).toBe('conflict'); + }); + + it('refuses a name already used by an active shop, ignoring case', () => { + const db = makeDb(); + createShop(db, { name: 'Lakshmi', code: 'A' }); + expect(code(() => createShop(db, { name: ' lakshmi ', code: 'B' }))).toBe('conflict'); + }); +}); + +describe('listShops', () => { + it('lists active and inactive shops by name', () => { + const db = makeDb(); + createShop(db, { name: 'B shop', code: 'B' }); + const a = createShop(db, { name: 'A shop', code: 'A' }); + db.prepare('UPDATE shops SET active = 0 WHERE id = ?').run(a.id); + expect(listShops(db)).toEqual([ + { id: a.id, name: 'A shop', code: 'A', active: false }, + { id: expect.any(Number), name: 'B shop', code: 'B', active: true }, + ]); + }); +}); diff --git a/src/services/shops.ts b/src/services/shops.ts new file mode 100644 index 0000000..2496f8e --- /dev/null +++ b/src/services/shops.ts @@ -0,0 +1,39 @@ +import type { Db } from '../db'; +import { isUniqueViolation, RuleError } from './accountRules'; +import type { Shop } from './shopScope'; + +export interface ShopListing extends Shop { + active: boolean; +} + +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(); + if (name === '' || name.length > SHOP_NAME_MAX) { + throw new RuleError('invalid', `the shop name must be 1 to ${SHOP_NAME_MAX} characters`); + } + 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`); + } + + 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'); + + try { + const id = Number(db.prepare('INSERT INTO shops (name, code) VALUES (?, ?)').run(name, code).lastInsertRowid); + return { id, name, code }; + } catch (e) { + if (isUniqueViolation(e)) throw new RuleError('conflict', 'that shop code is already used'); + throw e; + } +} + +export function listShops(db: Db): ShopListing[] { + const rows = db.prepare('SELECT id, name, code, active FROM shops ORDER BY name, id').all() as Array< + Shop & { active: number } + >; + return rows.map((r) => ({ id: r.id, name: r.name, code: r.code, active: r.active === 1 })); +}