feat(shops): added a CLI to create and list shops

This commit is contained in:
Aswin B. S 2026-11-04 11:44:40 +05:30
parent 9d77360dc8
commit 58ab67a50c
10 changed files with 320 additions and 44 deletions

View File

@ -20,6 +20,8 @@ cp .env.example .env
npm run migrate # creates ./data/srs.db 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
npm run user list # show every account npm run user list # show every account
npm run shop create # add a shop (name + unique code)
npm run shop list # show every shop
npm run dev # http://127.0.0.1:8080 npm run dev # http://127.0.0.1:8080
npm test npm test
npm run typecheck npm run typecheck

View File

@ -13,6 +13,7 @@
"migrate": "tsx src/db/migrate.ts", "migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js", "migrate:prod": "node dist/db/migrate.js",
"user": "tsx scripts/user.ts", "user": "tsx scripts/user.ts",
"shop": "tsx scripts/shop.ts",
"results": "tsx scripts/results.ts", "results": "tsx scripts/results.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

41
scripts/cliIo.ts Normal file
View 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
View 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);
},
);

View File

@ -3,50 +3,11 @@
* npm run user list show every account * npm run user list show every account
* *
*/ */
import { createInterface } from 'node:readline'; import { createWizard, listLines } from '../src/cli/userWizard';
import { Cancelled, createWizard, listLines, type Io } from '../src/cli/userWizard';
import { loadConfig } from '../src/config'; import { loadConfig } from '../src/config';
import { openDb } from '../src/db'; import { openDb } from '../src/db';
import { runMigrations } from '../src/db/migrate'; import { runMigrations } from '../src/db/migrate';
import { makeIo } from './cliIo';
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() };
}
async function main(): Promise<number> { async function main(): Promise<number> {
const command = process.argv[2]; const command = process.argv[2];

View 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
View 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(' ')),
];
}

View File

@ -16,11 +16,11 @@ export interface Io {
say(line?: string): void; 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; 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++) { for (let tries = 0; tries < MAX_TRIES; tries++) {
const answer = await io.ask(question); const answer = await io.ask(question);
if (answer.toLowerCase() === 'q') throw new Cancelled(); if (answer.toLowerCase() === 'q') throw new Cancelled();
@ -70,7 +70,7 @@ const parseShopNumbers =
return { ok: true, value: [...new Set(numbers)] }; 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(); const a = (await io.ask(question)).toLowerCase();
return a === 'y' || a === 'yes'; return a === 'y' || a === 'yes';
} }

View 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
View 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 }));
}