From 457de6c1c4e317329ee974d69e0331ed21a2d497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sat, 15 Nov 2025 21:41:01 +0100 Subject: [PATCH] fix(cli): keep REPL alive on errors and consolidate help\n\n- Replace process.exit(1) with thrown errors in handlers\n- REPL catches and prints CLI errors without exiting\n- Consolidated help to include all commands and examples\n- Add events:import:wsdc command mapping and alias --- README.md | 1 + backend/.repl_history | 38 ++++++--- backend/src/cli/index.js | 76 ++++++++++++----- backend/src/services/import/worldsdc.js | 103 ++++++++++++++++++++++++ docs/ADMIN_CLI.md | 12 +++ 5 files changed, 202 insertions(+), 28 deletions(-) create mode 100644 backend/src/services/import/worldsdc.js diff --git a/README.md b/README.md index ef6e893..5987b29 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,7 @@ Use an in-container admin console for quick maintenance. - Create user: `docker compose exec backend npm run cli -- users:create --email admin@example.com --username admin --password 'Secret123!'` - Verify email: `docker compose exec backend npm run cli -- users:verify --email admin@example.com` - List events: `docker compose exec backend npm run cli -- events:list --limit 10` + - Import WSDC calendar (dry-run): `docker compose exec backend npm run cli -- events:import:wsdc --dry-run --since 2024-01-01 --until 2024-12-31` - Event details by slug: `docker compose exec backend npm run cli -- events:details --slug warsaw-dance-2025 [--participants 25]` - Event participants: `docker compose exec backend npm run cli -- events:participants --slug warsaw-dance-2025 --limit 100` - Event participants CSV: `docker compose exec backend npm run cli -- events:participants --slug warsaw-dance-2025 --limit 200 --csv > participants.csv` diff --git a/backend/.repl_history b/backend/.repl_history index 11e032c..ac4c158 100644 --- a/backend/.repl_history +++ b/backend/.repl_history @@ -1,10 +1,30 @@ -dopisz do docs/ szczegóły jakie opcje sÄ… w CLI REPL -exit -Headers. -Headers -.cli logs -.cli lgos -.cli prisma:seed -.cli events:list +.cli users:verify --email test@radziel.com +.cli users:create --email test@radziel.com --username radziel --password QWEqwe123 --first Radek --last Gierwialo +users:create --mail test@radziel.com --username radziel --password QWEqwe123 --first Radek --last Gierwialo +users:create --email test@radziel.com --username radziel --password QWEqwe123 --first Radek --last Gierwialo +.cli users +.cli users:crete .cli users:list -.cli users:lists \ No newline at end of file +.cli events:list +.cli events:import:worldsdc --limit 20 +.cli events:details --slug cmi0g1dtf0001tbvvwraelctn +.cli events:list +.cli clear +clear +.cli help +.cli +.cli events +.events: +events: +events:lists +events:list +users +event:let +: +event +. +help +.exity +.cli events:import:worldsdc --dry-run --limit 20 +exit +.cli events:import:worldsdc --dry-run --limit 20… w CLI REPL \ No newline at end of file diff --git a/backend/src/cli/index.js b/backend/src/cli/index.js index 73ffe34..8bcc000 100644 --- a/backend/src/cli/index.js +++ b/backend/src/cli/index.js @@ -18,6 +18,7 @@ const { prisma } = require('../utils/db'); function printHelp() { console.log(`spotlight.cam admin CLI\n\nCommands:\n repl Start interactive REPL with context (prisma, bcrypt)\n users:list [--limit ] List users\n users:create --email --username --password

[--first ] [--last ]\n Create a user (email verified = false)\n users:verify --email Mark user's email as verified\n events:list [--limit ] List events\n\nExamples:\n npm run cli -- repl\n npm run cli -- users:list --limit 20\n npm run cli -- users:create --email admin@example.com --username admin --password "Secret123!"\n npm run cli -- users:verify --email admin@example.com\n npm run cli -- events:list --limit 10\n`); + printHelpExtra(); } function parseArgs(argv) { @@ -41,6 +42,10 @@ function parseArgs(argv) { return out; } +function printHelpExtra() { + console.log(`More commands:\n events:details --slug \n events:participants --slug [--limit ] [--csv]\n events:import:wsdc [--dry-run] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit ]\n matches:list [--limit ] [--status ]\n events:checkin --username --slug \n logs:app [--lines ]\n logs:messages [--limit ]\n`); +} + const COMMAND_HANDLERS = { 'users:list': cmdUsersList, 'users:create': cmdUsersCreate, @@ -48,6 +53,9 @@ const COMMAND_HANDLERS = { 'events:list': cmdEventsList, 'events:details': cmdEventsDetails, 'events:participants': cmdEventsParticipants, + 'events:import:wsdc': cmdEventsImportWsdc, + // Backward-compat alias + 'events:import:worldsdc': cmdEventsImportWsdc, 'matches:list': cmdMatchesList, 'events:checkin': cmdEventsCheckin, 'logs:app': cmdLogsApp, @@ -62,6 +70,7 @@ async function runDispatch(line) { if (!handler) { console.error(`Unknown command in REPL: ${name}`); printHelp(); + printHelpExtra(); return; } const opts = parseArgs(rest); @@ -127,8 +136,7 @@ async function cmdUsersList(opts) { async function cmdUsersCreate(opts) { const { email, username, password } = opts; if (!email || !username || !password) { - console.error('Missing required flags: --email --username --password'); - process.exit(1); + throw new Error('Missing required flags: --email --username --password'); } const hash = await bcrypt.hash(password, 10); const user = await prisma.user.create({ @@ -147,8 +155,7 @@ async function cmdUsersCreate(opts) { async function cmdUsersVerify(opts) { const { email } = opts; if (!email) { - console.error('Missing required flag: --email'); - process.exit(1); + throw new Error('Missing required flag: --email'); } const updated = await prisma.user.update({ where: { email }, @@ -182,8 +189,7 @@ async function cmdEventsList(opts) { async function cmdEventsDetails(opts) { const { slug } = opts; if (!slug) { - console.error('Missing required flag: --slug'); - process.exit(1); + throw new Error('Missing required flag: --slug'); } const participantsLimitRaw = opts.participants; const participantsLimit = Number.isFinite(Number(participantsLimitRaw)) ? Math.max(0, parseInt(participantsLimitRaw, 10)) : 10; @@ -200,8 +206,7 @@ async function cmdEventsDetails(opts) { }, }); if (!event) { - console.error(`Event not found by slug: ${slug}`); - process.exit(1); + throw new Error(`Event not found by slug: ${slug}`); } // Count messages in the event chat room (if exists) let eventChatMessagesCount = 0; @@ -247,14 +252,12 @@ async function cmdEventsDetails(opts) { async function cmdEventsParticipants(opts) { const { slug } = opts; if (!slug) { - console.error('Missing required flag: --slug'); - process.exit(1); + throw new Error('Missing required flag: --slug'); } const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, parseInt(opts.limit, 10)) : 100; const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } }); if (!event) { - console.error(`Event not found by slug: ${slug}`); - process.exit(1); + throw new Error(`Event not found by slug: ${slug}`); } const participants = await prisma.eventParticipant.findMany({ where: { eventId: event.id }, @@ -309,6 +312,31 @@ async function cmdEventsParticipants(opts) { } } +async function cmdEventsImportWsdc(opts) { + const { importWorldsdc } = require('../services/import/worldsdc'); + const since = opts.since ? new Date(opts.since) : null; + const until = opts.until ? new Date(opts.until) : null; + const limit = opts.limit ? parseInt(String(opts.limit), 10) : undefined; + const dryRun = Boolean(opts['dry-run'] || opts.dry || opts.dry_run); + + const result = await importWorldsdc({ since, until, limit, dryRun }); + console.log('Import summary:', { + fetched: result.fetched, + considered: result.considered, + created: result.created.length, + skipped: result.skipped.length, + dryRun, + }); + if (result.created.length) { + console.log('To create / created:'); + console.table(result.created.map(e => ({ name: e.name, startDate: e.startDate, endDate: e.endDate, location: e.location || null, sourceUrl: e.sourceUrl }))); + } + if (result.skipped.length) { + console.log('Skipped:'); + console.table(result.skipped.map(e => ({ name: e.name, startDate: e.startDate, reason: e.reason }))); + } +} + async function cmdMatchesList(opts) { const limit = Number(opts.limit || 50); const status = opts.status || undefined; @@ -341,18 +369,15 @@ async function cmdMatchesList(opts) { async function cmdEventsCheckin(opts) { const { username, slug } = opts; if (!username || !slug) { - console.error('Missing required flags: --username --slug'); - process.exit(1); + throw new Error('Missing required flags: --username --slug'); } const user = await prisma.user.findUnique({ where: { username }, select: { id: true, username: true } }); if (!user) { - console.error(`User not found: ${username}`); - process.exit(1); + throw new Error(`User not found: ${username}`); } const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } }); if (!event) { - console.error(`Event not found by slug: ${slug}`); - process.exit(1); + throw new Error(`Event not found by slug: ${slug}`); } const ep = await prisma.eventParticipant.upsert({ where: { userId_eventId: { userId: user.id, eventId: event.id } }, @@ -370,7 +395,7 @@ async function cmdLogsApp(opts) { if (!fs.existsSync(logfile)) { console.error(`Log file not found: ${logfile}`); console.error('Tip: view container logs with: docker compose logs -f backend'); - process.exit(1); + throw new Error('Log file not found'); } const content = fs.readFileSync(logfile, 'utf8'); const arr = content.split('\n'); @@ -417,6 +442,7 @@ async function main() { case '--help': case '-h': printHelp(); + printHelpExtra(); break; case 'repl': await cmdRepl(); @@ -433,6 +459,16 @@ async function main() { case 'events:list': await cmdEventsList(opts); break; + case 'events:details': + await cmdEventsDetails(opts); + break; + case 'events:participants': + await cmdEventsParticipants(opts); + break; + case 'events:import:wsdc': + case 'events:import:worldsdc': + await cmdEventsImportWsdc(opts); + break; case 'matches:list': await cmdMatchesList(opts); break; @@ -448,6 +484,7 @@ async function main() { default: console.error(`Unknown command: ${command}\n`); printHelp(); + printHelpExtra(); process.exitCode = 1; } } catch (err) { @@ -475,6 +512,7 @@ if (require.main === module) { cmdEventsList, cmdEventsDetails, cmdEventsParticipants, + cmdEventsImportWsdc, cmdMatchesList, cmdEventsCheckin, cmdLogsApp, diff --git a/backend/src/services/import/worldsdc.js b/backend/src/services/import/worldsdc.js new file mode 100644 index 0000000..18072db --- /dev/null +++ b/backend/src/services/import/worldsdc.js @@ -0,0 +1,103 @@ +const https = require('https'); + +function fetchUrl(url) { + return new Promise((resolve, reject) => { + https.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + // Follow redirect + return resolve(fetchUrl(res.headers.location)); + } + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode}`)); + } + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve(data)); + }).on('error', reject); + }); +} + +function parseEventsFromCalendar(html) { + const idx = html.indexOf('"events":['); + if (idx === -1) return []; + let i = idx + '"events":'.length; // at [ + // Extract the JSON array by bracket counting + let bracket = 0; + let started = false; + let out = ''; + while (i < html.length) { + const ch = html[i]; + out += ch; + if (ch === '[') { bracket++; started = true; } + else if (ch === ']') { bracket--; if (started && bracket === 0) break; } + i++; + } + const jsonText = out.trim(); + let arr; + try { + arr = JSON.parse(jsonText); + } catch (e) { + // Attempt to unescape slashes and retry + arr = JSON.parse(jsonText.replace(/\\\//g, '/')); + } + if (!Array.isArray(arr)) return []; + return arr.map((e) => ({ + name: e.title || null, + startDate: e.start || null, + endDate: e.end || null, + sourceUrl: e.url || null, + })).filter((e) => e.name && e.startDate); +} + +async function importWorldsdc({ since = null, until = null, limit, dryRun = false } = {}) { + const { prisma } = require('../../utils/db'); + const url = 'https://www.worldsdc.com/events/calendar/'; + const html = await fetchUrl(url); + const events = parseEventsFromCalendar(html); + const normalizeDate = (s) => new Date(s.split('T')[0] || s); + let list = events.map((e) => ({ + name: e.name.trim(), + startDate: normalizeDate(e.startDate), + endDate: e.endDate ? normalizeDate(e.endDate) : normalizeDate(e.startDate), + location: null, // not provided by calendar source + sourceUrl: e.sourceUrl || null, + })); + const fetched = list.length; + if (since) list = list.filter((e) => e.startDate >= since); + if (until) list = list.filter((e) => e.startDate <= until); + if (limit) list = list.slice(0, limit); + + const created = []; + const skipped = []; + for (const e of list) { + const existing = await prisma.event.findFirst({ + where: { name: e.name, startDate: e.startDate }, + select: { id: true }, + }); + if (existing) { + skipped.push({ ...e, reason: 'exists' }); + continue; + } + if (dryRun) { + created.push(e); + continue; + } + const saved = await prisma.event.create({ + data: { + name: e.name, + location: 'Unknown', + startDate: e.startDate, + endDate: e.endDate, + description: null, + // worldsdcId and participantsCount are intentionally not set here + }, + select: { id: true, slug: true }, + }); + created.push({ ...e, id: saved.id, slug: saved.slug }); + } + return { fetched, considered: list.length, created, skipped }; +} + +module.exports = { parseEventsFromCalendar, importWorldsdc }; + diff --git a/docs/ADMIN_CLI.md b/docs/ADMIN_CLI.md index e85bec0..69ddbf2 100644 --- a/docs/ADMIN_CLI.md +++ b/docs/ADMIN_CLI.md @@ -105,6 +105,18 @@ With Makefile shortcuts: - `npm run cli -- events:participants --slug warsaw-dance-2025 --limit 50` - `npm run cli -- events:participants --slug warsaw-dance-2025 --limit 200 --csv > participants.csv` +### events:import:wsdc +- Description: Import events from worldsdc.com calendar page. +- Notes: Uses the calendar’s embedded data (title/start/end/url). Does not set `participants` or `worldsdcId`. +- Options: + - `--dry-run`: show what would be created without writing to DB + - `--since YYYY-MM-DD`: only events on/after date + - `--until YYYY-MM-DD`: only events on/before date + - `--limit `: limit considered items after filtering +- Examples: + - `npm run cli -- events:import:wsdc --dry-run --since 2024-01-01 --until 2024-12-31` + - `npm run cli -- events:import:wsdc --limit 50` + ### logs:app - Description: Tail application log file (if configured) - Options: