feat(shops): added a CLI to create and list shops
This commit is contained in:
parent
9d77360dc8
commit
58ab67a50c
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
41
scripts/cliIo.ts
Normal file
41
scripts/cliIo.ts
Normal file
@ -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<string | null>((r) => (waiting = r));
|
||||
if (line === null) throw new Cancelled();
|
||||
return line.trim();
|
||||
},
|
||||
say(line = '') {
|
||||
console.log(line);
|
||||
},
|
||||
};
|
||||
return { io, close: () => rl.close() };
|
||||
}
|
||||
46
scripts/shop.ts
Normal file
46
scripts/shop.ts
Normal file
@ -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<number> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
@ -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<string | null>((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<number> {
|
||||
const command = process.argv[2];
|
||||
|
||||
72
src/cli/shopWizard.test.ts
Normal file
72
src/cli/shopWizard.test.ts
Normal file
@ -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<typeof makeDb>) => 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/);
|
||||
});
|
||||
});
|
||||
52
src/cli/shopWizard.ts
Normal file
52
src/cli/shopWizard.ts
Normal file
@ -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<string> =>
|
||||
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<string> =>
|
||||
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<number> {
|
||||
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(' ')),
|
||||
];
|
||||
}
|
||||
@ -16,11 +16,11 @@ export interface Io {
|
||||
say(line?: string): void;
|
||||
}
|
||||
|
||||
type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
const MAX_TRIES = 5;
|
||||
|
||||
async function askUntil<T>(io: Io, question: string, parse: (answer: string) => Parsed<T>): Promise<T> {
|
||||
export async function askUntil<T>(io: Io, question: string, parse: (answer: string) => Parsed<T>): Promise<T> {
|
||||
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<boolean> {
|
||||
export async function confirm(io: Io, question: string): Promise<boolean> {
|
||||
const a = (await io.ask(question)).toLowerCase();
|
||||
return a === 'y' || a === 'yes';
|
||||
}
|
||||
|
||||
62
src/services/shops.test.ts
Normal file
62
src/services/shops.test.ts
Normal file
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
39
src/services/shops.ts
Normal file
39
src/services/shops.ts
Normal file
@ -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 }));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user