diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8b36344 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +SHELL := /bin/bash +COMPOSE ?= docker compose +PROFILE ?= dev + +# Select backend service name based on profile +ifeq ($(PROFILE),prod) +BACKEND_SVC := backend-prod +else +BACKEND_SVC := backend +endif + +.PHONY: help dev-cli prod-cli \ + dev-up dev-down dev-up-rebuild \ + prod-up prod-down prod-up-rebuild + +help: + @echo "Available targets:" + @echo " make dev-cli # Start admin REPL in dev backend" + @echo " make prod-cli # Start admin REPL in prod backend" + @echo " make dev-up # docker compose --profile dev up" + @echo " make dev-up-rebuild # docker compose --profile dev up --build" + @echo " make dev-down # docker compose --profile dev down" + @echo " make prod-up # docker compose --profile prod up -d" + @echo " make prod-up-rebuild # docker compose --profile prod up -d --build" + @echo " make prod-down # docker compose --profile prod down" + @echo "Notes: GNU Make nie wspiera subkomend z odstępami (np. 'make prod up')." + @echo " Użyj odpowiednich celów z myślnikiem." + +# Admin CLI explicit targets +dev-cli: + $(COMPOSE) exec backend npm run cli + +prod-cli: + $(COMPOSE) exec backend-prod npm run cli + +# Development profile +dev-up: + $(COMPOSE) --profile dev up -d + +dev-up-rebuild: + $(COMPOSE) --profile dev up -d --build + +dev-down: + $(COMPOSE) --profile dev down + +# Production profile +prod-up: + $(COMPOSE) --profile prod up -d + +prod-up-rebuild: + $(COMPOSE) --profile prod up -d --build + +prod-down: + $(COMPOSE) --profile prod down diff --git a/README.md b/README.md index 69b6bc9..ef6e893 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,30 @@ Adds: - Click on another user's username - View profile: avatar, location, social media, statistics +## 🧰 Admin CLI + +Use an in-container admin console for quick maintenance. + +- Start REPL (default): `docker compose exec backend npm run cli` +- Explicit REPL: `docker compose exec backend npm run cli -- repl` +- List users: `docker compose exec backend npm run cli -- users:list --limit 20` +- 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` +- 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` +- List matches: `docker compose exec backend npm run cli -- matches:list --limit 20 [--status accepted|pending|completed]` +- Check-in user to event (simulate QR): `docker compose exec backend npm run cli -- events:checkin --username john_doe --slug warsaw-dance-2025` +- App logs (if LOG_FILE configured): `docker compose exec backend npm run cli -- logs:app --lines 200` +- Recent chat messages: `docker compose exec backend npm run cli -- logs:messages --limit 50` + +Production equivalents use `backend-prod` instead of `backend`. + +REPL specifics: +- Inside REPL use `run('users:list --limit 20')` or `.cli users:list --limit 20`. +- Top-level await works for Prisma: `await prisma.user.findMany({ take: 5 })`. + ## 🔐 Security ### Implemented Security Features: @@ -509,6 +533,7 @@ docker compose exec backend npm run test:coverage - `docs/PHASE_1.5.md` - Phase 1.5 documentation (Email & WSDC) - `docs/SECURITY_AUDIT.md` - Security audit & fixes - `docs/DEPLOYMENT.md` - Deployment guide +- `docs/ADMIN_CLI.md` - Admin CLI & REPL usage - `docs/COMPLETED.md` - Completed tasks archive - `docs/RESOURCES.md` - Links to documentation and learning resources diff --git a/backend/.repl_history b/backend/.repl_history new file mode 100644 index 0000000..11e032c --- /dev/null +++ b/backend/.repl_history @@ -0,0 +1,10 @@ +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:list +.cli users:lists \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index c22bce7..5c30de7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:seed": "node prisma/seed.js", - "prisma:studio": "prisma studio" + "prisma:studio": "prisma studio", + "cli": "node src/cli/index.js" }, "keywords": [ "webrtc", diff --git a/backend/src/__tests__/cli.test.js b/backend/src/__tests__/cli.test.js new file mode 100644 index 0000000..e5d1949 --- /dev/null +++ b/backend/src/__tests__/cli.test.js @@ -0,0 +1,126 @@ +const { + parseArgs, + runDispatch, + cmdUsersList, + cmdEventsCheckin, + cmdEventsDetails, + cmdEventsParticipants, + cmdMatchesList, +} = require('../cli/index'); + +jest.mock('../utils/db', () => { + const mock = { + user: { + findMany: jest.fn().mockResolvedValue([ + { id: 1, username: 'alice', email: 'alice@example.com', emailVerified: true, createdAt: new Date('2025-01-01') }, + { id: 2, username: 'bob', email: 'bob@example.com', emailVerified: false, createdAt: new Date('2025-02-01') }, + ]), + findUnique: jest.fn().mockImplementation(({ where }) => { + if (where && where.username === 'john_doe') return Promise.resolve({ id: 10, username: 'john_doe' }); + return Promise.resolve(null); + }), + create: jest.fn().mockResolvedValue({ id: 3, email: 'new@example.com', username: 'newuser', emailVerified: false }), + update: jest.fn().mockResolvedValue({ id: 1, email: 'alice@example.com', emailVerified: true }), + }, + event: { + findMany: jest.fn().mockResolvedValue([ + { id: 100, slug: 'event-1', name: 'Event 1', startDate: new Date('2025-06-01'), participantsCount: 5 }, + ]), + findUnique: jest.fn().mockImplementation(({ where }) => { + if (where && where.slug === 'warsaw-dance-2025') { + return Promise.resolve({ + id: 200, + slug: 'warsaw-dance-2025', + name: 'Warsaw Dance 2025', + location: 'Warsaw', + startDate: new Date('2025-06-01'), + endDate: new Date('2025-06-03'), + worldsdcId: 'wdc-2025', + participantsCount: 2, + description: 'Great event', + checkinToken: { token: 'tok123' }, + participants: [ + { joinedAt: new Date('2025-05-01'), user: { id: 10, username: 'john_doe' } }, + { joinedAt: new Date('2025-05-02'), user: { id: 11, username: 'sarah' } }, + ], + _count: { participants: 2, userHeats: 0, chatRooms: 1, matches: 0 }, + }); + } + return Promise.resolve(null); + }), + }, + eventParticipant: { + upsert: jest.fn().mockResolvedValue({ id: 1, userId: 10, eventId: 200, joinedAt: new Date('2025-05-01') }), + findMany: jest.fn().mockResolvedValue([ + { joinedAt: new Date('2025-05-01'), user: { id: 10, username: 'john_doe', email: 'john@example.com', firstName: 'John', lastName: 'D', country: 'PL', city: 'Warsaw' } }, + ]), + }, + chatRoom: { + findFirst: jest.fn().mockResolvedValue({ id: 999 }), + }, + message: { + count: jest.fn().mockResolvedValue(12), + findMany: jest.fn().mockResolvedValue([]), + }, + match: { + findMany: jest.fn().mockResolvedValue([ + { id: 301, slug: 'm1', status: 'pending', createdAt: new Date('2025-05-01'), event: { id: 200, slug: 'warsaw-dance-2025', name: 'Warsaw Dance 2025' }, user1: { id: 10, username: 'john_doe' }, user2: { id: 11, username: 'sarah' } }, + ]), + }, + }; + return { prisma: mock }; +}); + +describe('CLI helpers', () => { + test('parseArgs parses flags and positionals', () => { + const res = parseArgs(['users:list', '--limit', '20', '--csv', '--name', 'alice']); + expect(res._).toEqual(['users:list']); + expect(res.limit).toBe('20'); + expect(res.csv).toBe(true); + expect(res.name).toBe('alice'); + }); +}); + +describe('CLI commands (mocked prisma)', () => { + const origLog = console.log; + const origTable = console.table; + const logs = []; + beforeEach(() => { + logs.length = 0; + console.log = (/** @type {any[]} */ ...args) => logs.push(args.map(String).join(' ')); + console.table = jest.fn(); + }); + afterEach(() => { + console.log = origLog; + console.table = origTable; + jest.clearAllMocks(); + }); + + test('users:list outputs a table', async () => { + await cmdUsersList({ limit: 2 }); + expect(console.table).toHaveBeenCalled(); + }); + + test('events:checkin upserts participant', async () => { + await cmdEventsCheckin({ username: 'john_doe', slug: 'warsaw-dance-2025' }); + // Check info logs contain confirmation + expect(logs.join('\n')).toMatch(/Checked-in john_doe to warsaw-dance-2025/); + }); + + test('events:details prints summary and participants', async () => { + await cmdEventsDetails({ slug: 'warsaw-dance-2025', participants: 5 }); + expect(logs.join('\n')).toMatch(/Event details:/); + expect(console.table).toHaveBeenCalled(); + }); + + test('events:participants prints table', async () => { + await cmdEventsParticipants({ slug: 'warsaw-dance-2025', limit: 10 }); + expect(console.table).toHaveBeenCalled(); + }); + + test('matches:list outputs a table', async () => { + await cmdMatchesList({ limit: 10 }); + expect(console.table).toHaveBeenCalled(); + }); +}); + diff --git a/backend/src/cli/index.js b/backend/src/cli/index.js new file mode 100644 index 0000000..73ffe34 --- /dev/null +++ b/backend/src/cli/index.js @@ -0,0 +1,483 @@ +#!/usr/bin/env node +/* + * Admin CLI / REPL for spotlight.cam + * Usage examples (inside repo or via docker compose exec backend): + * npm run cli -- repl + * npm run cli -- users:list --limit 20 + * npm run cli -- users:create --email admin@example.com --username admin --password "Secret123!" + * npm run cli -- users:verify --email admin@example.com + * npm run cli -- events:list --limit 10 + */ + +require('dotenv').config(); +const repl = require('repl'); +const fs = require('fs'); +const path = require('path'); +const bcrypt = require('bcryptjs'); +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`); +} + +function parseArgs(argv) { + // Very small flag parser: --key value -> { key: value }, flags without value -> true + const out = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token.startsWith('--')) { + const key = token.replace(/^--/, ''); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + out[key] = true; + continue; + } + out[key] = next; + i += 1; + } else { + out._.push(token); + } + } + return out; +} + +const COMMAND_HANDLERS = { + 'users:list': cmdUsersList, + 'users:create': cmdUsersCreate, + 'users:verify': cmdUsersVerify, + 'events:list': cmdEventsList, + 'events:details': cmdEventsDetails, + 'events:participants': cmdEventsParticipants, + 'matches:list': cmdMatchesList, + 'events:checkin': cmdEventsCheckin, + 'logs:app': cmdLogsApp, + 'logs:messages': cmdLogsMessages, +}; + +async function runDispatch(line) { + const trimmed = (line || '').trim(); + if (!trimmed) return; + const [name, ...rest] = trimmed.split(/\s+/); + const handler = COMMAND_HANDLERS[name]; + if (!handler) { + console.error(`Unknown command in REPL: ${name}`); + printHelp(); + return; + } + const opts = parseArgs(rest); + await handler(opts); +} + +async function cmdRepl() { + const r = repl.start({ prompt: 'spotlight> ' }); + r.context.prisma = prisma; + r.context.bcrypt = bcrypt; + // Handy aliases + r.context.u = prisma.user; + r.context.e = prisma.event; + r.context.m = prisma.match; + r.context.ep = prisma.eventParticipant; + r.context.r = prisma.rating; + // CLI runner inside REPL + r.context.run = async (s) => { await runDispatch(String(s)); }; + r.context.cli = r.context.run; + + // .cli users:list --limit 20 + r.defineCommand('cli', { + help: 'Run admin CLI subcommand, e.g. .cli users:list --limit 20', + async action(input) { + try { + await runDispatch(input); + } catch (err) { + console.error('CLI error:', err.message); + } + this.displayPrompt(); + }, + }); + + // Persistent history (best-effort). Use /app/.repl_history by default. + const historyFile = process.env.REPL_HISTORY_FILE || path.join(process.cwd(), '.repl_history'); + try { + r.setupHistory(historyFile, () => {}); + } catch (e) { + // ignore if not supported + } + r.on('exit', async () => { + await prisma.$disconnect(); + process.exit(0); + }); +} + +async function cmdUsersList(opts) { + const limit = Number(opts.limit || 50); + const users = await prisma.user.findMany({ + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + username: true, + email: true, + emailVerified: true, + createdAt: true, + }, + }); + console.table(users); +} + +async function cmdUsersCreate(opts) { + const { email, username, password } = opts; + if (!email || !username || !password) { + console.error('Missing required flags: --email --username --password'); + process.exit(1); + } + const hash = await bcrypt.hash(password, 10); + const user = await prisma.user.create({ + data: { + email, + username, + passwordHash: hash, + firstName: opts.first || null, + lastName: opts.last || null, + }, + select: { id: true, email: true, username: true, emailVerified: true }, + }); + console.log('Created user:', user); +} + +async function cmdUsersVerify(opts) { + const { email } = opts; + if (!email) { + console.error('Missing required flag: --email'); + process.exit(1); + } + const updated = await prisma.user.update({ + where: { email }, + data: { + emailVerified: true, + verificationToken: null, + verificationCode: null, + verificationTokenExpiry: null, + }, + select: { id: true, email: true, emailVerified: true }, + }); + console.log(updated); +} + +async function cmdEventsList(opts) { + const limit = Number(opts.limit || 50); + const events = await prisma.event.findMany({ + take: limit, + orderBy: { startDate: 'desc' }, + select: { + id: true, + slug: true, + name: true, + startDate: true, + participantsCount: true, + }, + }); + console.table(events); +} + +async function cmdEventsDetails(opts) { + const { slug } = opts; + if (!slug) { + console.error('Missing required flag: --slug'); + process.exit(1); + } + const participantsLimitRaw = opts.participants; + const participantsLimit = Number.isFinite(Number(participantsLimitRaw)) ? Math.max(0, parseInt(participantsLimitRaw, 10)) : 10; + const event = await prisma.event.findUnique({ + where: { slug }, + include: { + checkinToken: { select: { token: true } }, + participants: { + take: participantsLimit, + include: { user: { select: { id: true, username: true } } }, + orderBy: { joinedAt: 'desc' }, + }, + _count: { select: { participants: true, userHeats: true, chatRooms: true, matches: true } }, + }, + }); + if (!event) { + console.error(`Event not found by slug: ${slug}`); + process.exit(1); + } + // Count messages in the event chat room (if exists) + let eventChatMessagesCount = 0; + let eventChatRoomId = null; + try { + const chatRoom = await prisma.chatRoom.findFirst({ where: { eventId: event.id, type: 'event' }, select: { id: true } }); + if (chatRoom) { + eventChatRoomId = chatRoom.id; + eventChatMessagesCount = await prisma.message.count({ where: { roomId: chatRoom.id } }); + } + } catch (_) { + // ignore counting errors in CLI context + } + const summary = { + id: event.id, + slug: event.slug, + name: event.name, + location: event.location, + startDate: event.startDate, + endDate: event.endDate, + worldsdcId: event.worldsdcId, + participantsCount: event.participantsCount, + description: event.description?.slice(0, 160) || null, + checkinToken: event.checkinToken?.token || null, + eventChatRoomId, + eventChatMessagesCount, + counts: event._count, + }; + console.log('Event details:'); + console.log(summary); + + if (event.participants && event.participants.length) { + console.log(`Recent participants (up to ${participantsLimit}):`); + const rows = event.participants.map((p) => ({ + id: p.user?.id, + username: p.user?.username, + joinedAt: p.joinedAt, + })); + console.table(rows); + } +} + +async function cmdEventsParticipants(opts) { + const { slug } = opts; + if (!slug) { + console.error('Missing required flag: --slug'); + process.exit(1); + } + 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); + } + const participants = await prisma.eventParticipant.findMany({ + where: { eventId: event.id }, + take: limit, + orderBy: { joinedAt: 'desc' }, + include: { + user: { + select: { + id: true, + username: true, + email: true, + firstName: true, + lastName: true, + country: true, + city: true, + }, + }, + }, + }); + + const rows = participants.map((p) => ({ + eventId: event.id, + slug: event.slug, + userId: p.user?.id, + username: p.user?.username, + email: p.user?.email, + firstName: p.user?.firstName || null, + lastName: p.user?.lastName || null, + country: p.user?.country || null, + city: p.user?.city || null, + joinedAt: p.joinedAt, + })); + + if (opts.csv) { + // Simple CSV output + const headers = Object.keys(rows[0] || { + eventId: '', slug: '', userId: '', username: '', email: '', firstName: '', lastName: '', country: '', city: '', joinedAt: '' + }); + const esc = (v) => { + if (v === null || v === undefined) return ''; + const s = String(v); + if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; + return s; + }; + console.log(headers.join(',')); + for (const row of rows) { + console.log(headers.map((h) => esc(row[h])).join(',')); + } + } else { + console.log(`Participants for ${event.slug} (${event.name}) — showing up to ${limit}`); + console.table(rows); + } +} + +async function cmdMatchesList(opts) { + const limit = Number(opts.limit || 50); + const status = opts.status || undefined; + const matches = await prisma.match.findMany({ + where: status ? { status } : undefined, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + slug: true, + status: true, + createdAt: true, + event: { select: { id: true, slug: true, name: true } }, + user1: { select: { id: true, username: true } }, + user2: { select: { id: true, username: true } }, + }, + }); + const rows = matches.map((m) => ({ + id: m.id, + slug: m.slug, + status: m.status, + event: m.event ? `${m.event.slug} (${m.event.name})` : null, + user1: m.user1?.username, + user2: m.user2?.username, + createdAt: m.createdAt, + })); + console.table(rows); +} + +async function cmdEventsCheckin(opts) { + const { username, slug } = opts; + if (!username || !slug) { + console.error('Missing required flags: --username --slug'); + process.exit(1); + } + const user = await prisma.user.findUnique({ where: { username }, select: { id: true, username: true } }); + if (!user) { + console.error(`User not found: ${username}`); + process.exit(1); + } + 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); + } + const ep = await prisma.eventParticipant.upsert({ + where: { userId_eventId: { userId: user.id, eventId: event.id } }, + update: {}, + create: { userId: user.id, eventId: event.id }, + select: { id: true, userId: true, eventId: true, joinedAt: true }, + }); + console.log(`Checked-in ${username} to ${event.slug} (${event.name})`); + console.log(ep); +} + +async function cmdLogsApp(opts) { + const lines = Number(opts.lines || 200); + const logfile = process.env.LOG_FILE || path.join(process.cwd(), 'app.log'); + 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); + } + const content = fs.readFileSync(logfile, 'utf8'); + const arr = content.split('\n'); + const tail = arr.slice(-lines).join('\n'); + console.log(tail); +} + +async function cmdLogsMessages(opts) { + const limit = Number(opts.limit || 50); + const messages = await prisma.message.findMany({ + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + type: true, + content: true, + createdAt: true, + user: { select: { username: true } }, + room: { select: { id: true, type: true, eventId: true } }, + }, + }); + const rows = messages.map((m) => ({ + id: m.id, + when: m.createdAt, + user: m.user?.username, + room: `${m.room?.type}#${m.room?.eventId || m.room?.id}`, + type: m.type, + content: m.content?.slice(0, 100), + })); + console.table(rows); +} + +async function main() { + const argv = process.argv.slice(2); + const [command, ...rest] = argv; + const opts = parseArgs(rest); + + try { + switch (command) { + case undefined: // default to REPL for convenience + await cmdRepl(); + break; + case 'help': + case '--help': + case '-h': + printHelp(); + break; + case 'repl': + await cmdRepl(); + break; + case 'users:list': + await cmdUsersList(opts); + break; + case 'users:create': + await cmdUsersCreate(opts); + break; + case 'users:verify': + await cmdUsersVerify(opts); + break; + case 'events:list': + await cmdEventsList(opts); + break; + case 'matches:list': + await cmdMatchesList(opts); + break; + case 'events:checkin': + await cmdEventsCheckin(opts); + break; + case 'logs:app': + await cmdLogsApp(opts); + break; + case 'logs:messages': + await cmdLogsMessages(opts); + break; + default: + console.error(`Unknown command: ${command}\n`); + printHelp(); + process.exitCode = 1; + } + } catch (err) { + console.error('CLI error:', err.message); + process.exitCode = 1; + } finally { + // Keep REPL alive; otherwise disconnect + if (command !== 'repl') { + await prisma.$disconnect(); + } + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { + parseArgs, + runDispatch, + COMMAND_HANDLERS, + // Export handlers for direct unit testing + cmdUsersList, + cmdUsersCreate, + cmdUsersVerify, + cmdEventsList, + cmdEventsDetails, + cmdEventsParticipants, + cmdMatchesList, + cmdEventsCheckin, + cmdLogsApp, + cmdLogsMessages, + }; +} diff --git a/docs/ADMIN_CLI.md b/docs/ADMIN_CLI.md new file mode 100644 index 0000000..e85bec0 --- /dev/null +++ b/docs/ADMIN_CLI.md @@ -0,0 +1,137 @@ +# Admin CLI & REPL — spotlight.cam + +Administrative console for maintenance tasks inside the backend container. Provides both one-shot commands and an interactive REPL with app context. + +--- + +## Quick Start + +- Development REPL: `docker compose exec backend npm run cli` +- Production REPL: `docker compose exec backend-prod npm run cli` +- One-shot examples: + - `docker compose exec backend npm run cli -- users:list --limit 20` + - `docker compose exec backend npm run cli -- events:checkin --username john_doe --slug warsaw-dance-2025` + +With Makefile shortcuts: +- `make dev-cli` / `make prod-cli` +- `make dev-up`, `make dev-down`, `make prod-up`, `make prod-down`, rebuild variants also available + +--- + +## REPL Features + +- Default entry: running `npm run cli` starts a Node.js REPL with: + - `prisma` client and `bcrypt` in context + - Aliases: `u = prisma.user`, `e = prisma.event`, `m = prisma.match`, `ep = prisma.eventParticipant`, `r = prisma.rating` + - Top-level await: `await u.findMany({ take: 5 })` + - Autocomplete (TAB) from Node REPL + - Persistent history in `.repl_history` (best-effort) +- Run CLI subcommands from inside REPL: + - `.cli users:list --limit 20` + - `run('events:checkin --username john_doe --slug warsaw-dance-2025')` + +--- + +## Commands + +### users:list +- Description: List users +- Options: + - `--limit `: number of rows (default: 50) +- Examples: + - CLI: `npm run cli -- users:list --limit 20` + - REPL: `.cli users:list --limit 20` + +### users:create +- Description: Create a user +- Required: + - `--email ` + - `--username ` + - `--password ` (hashed with bcrypt) +- Optional: + - `--first ` + - `--last ` +- Examples: + - CLI: `npm run cli -- users:create --email admin@example.com --username admin --password 'Secret123!'` + - REPL: `run("users:create --email a@b.c --username admin --password 'Secret123!'")` + +### users:verify +- Description: Mark user email as verified and clear verification fields +- Required: + - `--email ` +- Example: `npm run cli -- users:verify --email admin@example.com` + +### events:list +- Description: List events +- Options: + - `--limit ` (default: 50) +- Example: `npm run cli -- events:list --limit 10` + +### events:details +- Description: Show details for a specific event by slug +- Required: + - `--slug ` +- Options: + - `--participants `: number of recent participants to show (default: 10) +- Output: basic fields (id, slug, name, start/end, location, worldsdcId), participantsCount, optional check-in token, relation counts, event chat room id and message count, and up to N recent participants +- Examples: + - `npm run cli -- events:details --slug warsaw-dance-2025` + - `npm run cli -- events:details --slug warsaw-dance-2025 --participants 25` + +### matches:list +- Description: List matches +- Options: + - `--limit ` (default: 50) + - `--status ` filter +- Example: `npm run cli -- matches:list --status accepted --limit 20` + +### events:checkin +- Description: Attach user to event by username and event slug (simulate QR check-in) +- Required: + - `--username ` + - `--slug ` +- Behavior: Upserts into `event_participants` (idempotent) +- Example: `npm run cli -- events:checkin --username john_doe --slug warsaw-dance-2025` + +### events:participants +- Description: List participants for an event by slug +- Required: + - `--slug ` +- Options: + - `--limit `: number of rows (default: 100) + - `--csv`: output as CSV (stdout) +- Output: eventId, slug, userId, username, email, firstName, lastName, country, city, joinedAt +- Examples: + - `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` + +### logs:app +- Description: Tail application log file (if configured) +- Options: + - `--lines ` number of lines from end (default: 200) +- Env: + - `LOG_FILE` — absolute/relative path inside backend container (default: `./app.log` in working dir) +- Note: For container logs prefer `docker compose logs -f backend` + +### logs:messages +- Description: Recent chat messages from DB +- Options: + - `--limit ` (default: 50) +- Output: message id, time, user, room (event/private), type, first 100 chars + +--- + +## Troubleshooting + +- REPL not interactive: ensure TTY: `docker compose exec -it backend npm run cli` +- DB access errors: verify backend container env and DB service are running +- `events:checkin` says not found: confirm `username` and `slug` exist in DB +- Autocomplete: works with Node REPL; press TAB after `prisma.` to see model methods + +--- + +## Security Notes + +- CLI has full DB access; restrict usage to SSH + container exec +- Do not expose CLI via HTTP endpoints +- Consider audit logs for sensitive actions if needed