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 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
|
||||||
|
|||||||
@ -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
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
|
* 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];
|
||||||
|
|||||||
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;
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
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