diff --git a/webapp/README.md b/webapp/README.md index 2fcf561..a3a8206 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -20,15 +20,22 @@ This service ports the Discord AdventureBot experience to a standalone web stack ```bash npm install ``` -3. Run the development server with automatic reloads: +3. Create or refresh the database schema and seed data from the repository dump: + ```bash + npm run db:setup + ``` + This script creates the configured database if it does not exist, then installs the AdventureBot tables and seed rows from + `database/dump.sql`. + +4. Run the development server with automatic reloads: ```bash npm run dev ``` -4. Build the production bundle: +5. Build the production bundle: ```bash npm run build ``` -5. Start the compiled server: +6. Start the compiled server: ```bash npm start ``` diff --git a/webapp/package.json b/webapp/package.json index 627f5cf..b531f37 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -9,7 +9,8 @@ "dev": "ts-node-dev --respawn --transpile-only src/server.ts", "build": "tsc --project tsconfig.json", "start": "node dist/server.js", - "lint": "eslint \"src/**/*.ts\"" + "lint": "eslint \"src/**/*.ts\"", + "db:setup": "npm run build && node dist/db/setup.js" }, "engines": { "node": ">=18" @@ -37,4 +38,4 @@ "ts-node-dev": "^2.0.0", "typescript": "5.5.4" } -} +} \ No newline at end of file diff --git a/webapp/src/db/setup.ts b/webapp/src/db/setup.ts new file mode 100644 index 0000000..78308b6 --- /dev/null +++ b/webapp/src/db/setup.ts @@ -0,0 +1,85 @@ +import fs from 'fs'; +import path from 'path'; +import mysql, { RowDataPacket } from 'mysql2/promise'; +import { getEnv } from '../config/env.js'; +import { logger } from '../logger.js'; + +const env = getEnv(); + +const loadSetupStatements = (): string[] => { + const sqlPath = path.resolve(process.cwd(), '..', 'database', 'dump.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + + const cleaned = sql + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('--')) + .join('\n'); + + return cleaned + .split(';') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +}; + +const databaseExists = async (connection: mysql.Connection): Promise => { + const [rows] = await connection.query( + 'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?', + [env.mysql.database] + ); + + return rows.length > 0; +}; + +const hasSchema = async (connection: mysql.Connection): Promise => { + const [rows] = await connection.query("SHOW TABLES LIKE 'difficulties'"); + return rows.length > 0; +}; + +export const ensureDatabaseSetup = async (): Promise => { + const adminConnection = await mysql.createConnection({ + host: env.mysql.host, + port: env.mysql.port, + user: env.mysql.user, + password: env.mysql.password, + }); + + try { + const exists = await databaseExists(adminConnection); + if (!exists) { + await adminConnection.query(`CREATE DATABASE IF NOT EXISTS \`${env.mysql.database}\``); + logger.info({ database: env.mysql.database }, 'Created missing database'); + } + } finally { + await adminConnection.end(); + } + + const connection = await mysql.createConnection({ + host: env.mysql.host, + port: env.mysql.port, + user: env.mysql.user, + password: env.mysql.password, + database: env.mysql.database, + multipleStatements: false, + }); + + try { + const schemaPresent = await hasSchema(connection); + if (schemaPresent) { + logger.info('Database schema already present; skipping setup'); + return; + } + + const statements = loadSetupStatements(); + for (const statement of statements) { + await connection.query(statement); + } + + logger.info('Database schema and seed data installed'); + } catch (error) { + logger.error({ err: error }, 'Database setup failed'); + throw error; + } finally { + await connection.end(); + } +}; diff --git a/webapp/src/server.ts b/webapp/src/server.ts index 07622a5..b615484 100644 --- a/webapp/src/server.ts +++ b/webapp/src/server.ts @@ -1,10 +1,21 @@ import { createApp } from './app.js'; import { getEnv } from './config/env.js'; +import { ensureDatabaseSetup } from './db/setup.js'; import { logger } from './logger.js'; const env = getEnv(); const app = createApp(); -app.listen(env.port, () => { - logger.info({ port: env.port, nodeEnv: env.nodeEnv }, 'Web app server listening'); -}); +const startServer = async () => { + try { + await ensureDatabaseSetup(); + app.listen(env.port, () => { + logger.info({ port: env.port, nodeEnv: env.nodeEnv }, 'Web app server listening'); + }); + } catch (error) { + logger.error({ err: error }, 'Failed to start server'); + process.exit(1); + } +}; + +startServer(); diff --git a/webapp/src/services/difficulties.ts b/webapp/src/services/difficulties.ts index e4e8d1d..fc67691 100644 --- a/webapp/src/services/difficulties.ts +++ b/webapp/src/services/difficulties.ts @@ -1,6 +1,6 @@ -import { Difficulty, DifficultyDefinition } from './types.js'; +import { Difficulty, DifficultyDefinition, difficultyKeys } from './types.js'; -export const difficultyOrder: Difficulty[] = ['easy', 'medium', 'hard', 'crazy_catto']; +export const difficultyOrder: Difficulty[] = [...difficultyKeys]; export const difficultyDefinitions: Record = { easy: { @@ -63,4 +63,4 @@ export const difficultyDefinitions: Record = { export const getDifficultyDefinition = (key: Difficulty): DifficultyDefinition => difficultyDefinitions[key]; -export const difficultyKeys = Object.keys(difficultyDefinitions) as Difficulty[]; +export { difficultyKeys }; diff --git a/webapp/src/services/types.ts b/webapp/src/services/types.ts index db3f5ba..07cc666 100644 --- a/webapp/src/services/types.ts +++ b/webapp/src/services/types.ts @@ -1,4 +1,5 @@ -export type Difficulty = 'easy' | 'medium' | 'hard' | 'crazy_catto'; +export const difficultyKeys = ['easy', 'medium', 'hard', 'crazy_catto'] as const; +export type Difficulty = (typeof difficultyKeys)[number]; export interface DifficultyDefinition { key: Difficulty;