From 9458d1e8ad475a6c8f9ce2fcfcc8f6313132eec2 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 2 Mar 2026 23:15:15 +0100 Subject: [PATCH] Introduces configurable test database for bug reports Enables switching between production and testing MySQL databases based on the `ENABLE_TEST_DB` environment variable and an `X-DB-ENV` request header. Applies this dual database functionality primarily to bug report submission and administration features. New `TESTING_MYSQL_` environment variables are added for defining test database credentials. Refines HTTP request logging by excluding health checks and admin session validation endpoints to reduce noise. Allows `/health` endpoints to bypass API and Admin key guards. Temporarily disables HWID-based rate limiting for bug report submissions. --- .env.example | 18 ++++- src/config.ts | 13 +++- src/db/connection.ts | 31 +++++++- src/index.ts | 22 ++++-- src/middleware/auth.ts | 9 ++- src/middleware/rateLimit.ts | 15 ++-- src/routes/admin.ts | 98 ++++++++++++++++-------- src/routes/auth.ts | 37 ++++++--- src/routes/bugReports.ts | 64 ++++++++++------ src/services/bugReportService.ts | 126 ++++++++++++++++++------------- src/types/index.ts | 2 + 11 files changed, 297 insertions(+), 138 deletions(-) diff --git a/.env.example b/.env.example index 8f91e16..f05d2ee 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# MySQL +# MySQL Production DB MYSQL_HOST=mysql MYSQL_PORT=3306 MYSQL_USER=emly @@ -6,10 +6,21 @@ MYSQL_PASSWORD=change_me_in_production MYSQL_DATABASE=emly_bugreports MYSQL_ROOT_PASSWORD=change_root_password +# MySQL Testing DB (if ENABLE_TEST_DB is true) +TESTING_MYSQL_HOST=mysql +TESTING_MYSQL_PORT=3306 +TESTING_MYSQL_USER=emly +TESTING_MYSQL_PASSWORD=change_me_in_production +TESTING_MYSQL_DATABASE=emly_bugreports +TESTING_MYSQL_ROOT_PASSWORD=change_root_password + # API Keys API_KEY=change_me_client_key ADMIN_KEY=change_me_admin_key +# Hostname +HOSTNAME=amazing-kobold + # Server PORT=3000 DASHBOARD_PORT=3001 @@ -18,6 +29,9 @@ DASHBOARD_PORT=3001 RATE_LIMIT_MAX=5 RATE_LIMIT_WINDOW_HOURS=24 +# Test DB flag +ENABLE_TEST_DB = false + # Cloudflare Tunnel CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token -CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token \ No newline at end of file +CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token diff --git a/src/config.ts b/src/config.ts index ec1932b..badb743 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,13 @@ export const config = { password: process.env.MYSQL_PASSWORD || "", database: process.env.MYSQL_DATABASE || "emly_bugreports", }, + testing_mysql: { + host: process.env.TESTING_MYSQL_HOST || "localhost", + port: parseInt(process.env.TESTING_MYSQL_PORT || "3306"), + user: process.env.TESTING_MYSQL_USER || "emly", + password: process.env.TESTING_MYSQL_PASSWORD || "", + database: process.env.TESTING_MYSQL_DATABASE || "emly_bugreports", + }, apiKey: process.env.API_KEY || "", adminKey: process.env.ADMIN_KEY || "", port: parseInt(process.env.PORT || "3000"), @@ -13,12 +20,14 @@ export const config = { max: parseInt(process.env.RATE_LIMIT_MAX || "5"), windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"), }, + enableTestDB: process.env.ENABLE_TEST_DB === "true", } as const; // Validate required config on startup export function validateConfig(): void { if (!config.apiKey) throw new Error("API_KEY is required"); if (!config.adminKey) throw new Error("ADMIN_KEY is required"); - if (!config.mysql.password) - throw new Error("MYSQL_PASSWORD is required"); + if (!config.mysql.password) throw new Error("MYSQL_PASSWORD is required"); + if (!config.testing_mysql.password && config.enableTestDB) + throw new Error("TESTING_MYSQL_PASSWORD is required"); } diff --git a/src/db/connection.ts b/src/db/connection.ts index 6966d0a..2c24447 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -1,10 +1,25 @@ import mysql from "mysql2/promise"; import { config } from "../config"; +import { Log } from "../logger"; let pool: mysql.Pool | null = null; -export function getPool(): mysql.Pool { +export function getPool(useTestDb?: boolean): mysql.Pool { if (!pool) { + if (useTestDb && config.enableTestDB) { + Log("db", "using test db"); + return mysql.createPool({ + host: config.testing_mysql.host, + port: config.testing_mysql.port, + user: config.testing_mysql.user, + password: config.testing_mysql.password, + database: config.testing_mysql.database, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 5, + idleTimeout: 60000, + }); + } pool = mysql.createPool({ host: config.mysql.host, port: config.mysql.port, @@ -17,6 +32,20 @@ export function getPool(): mysql.Pool { idleTimeout: 60000, }); } + if (useTestDb && config.enableTestDB) { + Log("db", "using test db"); + return mysql.createPool({ + host: config.testing_mysql.host, + port: config.testing_mysql.port, + user: config.testing_mysql.user, + password: config.testing_mysql.password, + database: config.testing_mysql.database, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 5, + idleTimeout: 60000, + }); + } return pool; } diff --git a/src/index.ts b/src/index.ts index bc67232..3c75bed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { adminRoutes } from "./routes/admin"; import { authRoutes } from "./routes/auth"; import { initLogger, Log } from "./logger"; -const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); +const INSTANCE_ID = + process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); // Initialize logger initLogger(); @@ -30,15 +31,20 @@ const app = new Elysia() request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`); + if (url.pathname !== "/api/admin/auth/validate") + Log( + "HTTP", + `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`, + ); }) .onAfterResponse(({ request, set }) => { const url = new URL(request.url); - Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); + if (url.pathname !== "/api/admin/auth/validate") + Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); }) .onError(({ error, set, code }) => { console.error("Error processing request:", error); - console.log(code) + console.log(code); if (code === "NOT_FOUND") { set.status = 404; return { success: false, message: "Not found" }; @@ -51,7 +57,11 @@ const app = new Elysia() set.status = 500; return { success: false, message: "Internal server error" }; }) - .get("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() })) + .get("/health", () => ({ + status: "ok", + instance: INSTANCE_ID, + timestamp: new Date().toISOString(), + })) .get("/", () => ({ status: "ok", message: "API is running" })) .use(bugReportRoutes) .use(authRoutes) @@ -64,7 +74,7 @@ const app = new Elysia() Log( "SERVER", - `EMLy Bug Report API running on http://localhost:${app.server?.port}` + `EMLy Bug Report API running on http://localhost:${app.server?.port}`, ); // Graceful shutdown diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 0a36488..aa8b80a 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -6,13 +6,15 @@ export function apiKeyGuard(ctx: { request?: Request; set: any }) { const request = ctx.request; if (!request) return; // nothing to validate at setup time + if (request.url.includes("/health")) return; + const key = request.headers.get("x-api-key"); if (!key || key !== config.apiKey) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("AUTH", `Invalid API key from ip=${ip}`); + Log("AUTH-API-KEYGUARD", `Invalid API key from ip=${ip}`); ctx.set.status = 401; return { success: false, message: "Invalid or missing API key" }; } @@ -22,13 +24,16 @@ export function adminKeyGuard(ctx: { request?: Request; set: any }) { const request = ctx.request; if (!request) return; + if (request.url.includes("/health")) return; + if (request.url.includes("/bug-reports")) return; + const key = request.headers.get("x-admin-key"); if (!key || key !== config.adminKey) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("AUTH", `Invalid admin key from ip=${ip}`); + Log("AUTH-ADMIN-KEYGUARD", `Invalid admin key from ip=${ip}`); ctx.set.status = 401; return { success: false, message: "Invalid or missing admin key" }; } diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index cb16ed7..674a4b2 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -6,9 +6,12 @@ import { Log } from "../logger"; const excludedHwids = new Set([ // Add HWIDs here for development testing "95e025d1-7567-462e-9354-ac88b965cd22", + "50973d98-7dce-4496-9f9a-fee21655d38a", ]); -export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHandle( +export const hwidRateLimit = new Elysia({ + name: "hwid-rate-limit", +}).onBeforeHandle( { as: "scoped" }, // @ts-ignore async ({ body, error }) => { @@ -25,7 +28,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // Get current rate limit entry const [rows] = await pool.execute( "SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?", - [hwid] + [hwid], ); const entries = rows as { window_start: Date; count: number }[]; @@ -34,7 +37,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // First request from this HWID await pool.execute( "INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)", - [hwid, now] + [hwid, now], ); return {}; } @@ -47,7 +50,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // Window expired, reset await pool.execute( "UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?", - [now, hwid] + [now, hwid], ); return {}; } @@ -65,8 +68,8 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // Increment count await pool.execute( "UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?", - [hwid] + [hwid], ); return {}; - } + }, ); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 4b6461c..9ab472b 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -18,20 +18,34 @@ import { getUserById, } from "../services/userService"; import { Log } from "../logger"; -import type { BugReportStatus } from "../types"; +import type { BugReportStatus, DbEnv } from "../types"; export const adminRoutes = new Elysia({ prefix: "/api/admin" }) .onRequest(adminKeyGuard) .get( "/bug-reports", - async ({ query }) => { + async ({ query, headers }) => { const page = parseInt(query.page || "1"); const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); const status = query.status as BugReportStatus | undefined; const search = query.search || undefined; + const useTestDb: boolean = headers["x-db-env"] !== "prod" ? true : false; - Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`); - return await listBugReports({ page, pageSize, status, search }); + if (useTestDb) Log("ADMIN", `Fetching bug reports from test database`); + + Log( + "ADMIN", + `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`, + ); + return await listBugReports( + { + page, + pageSize, + status, + search, + }, + useTestDb, + ); }, { query: t.Object({ @@ -43,41 +57,48 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) t.Literal("in_review"), t.Literal("resolved"), t.Literal("closed"), - ]) + ]), ), search: t.Optional(t.String()), }), detail: { summary: "List bug reports (paginated, filterable)" }, - } + }, ) .get( "/bug-reports/count", - async () => { - const count = await countNewReports(); + async ({ headers }) => { + const count = await countNewReports( + headers["x-db-env"] !== "prod" ? true : false, + ); return { count }; }, - { detail: { summary: "Count new bug reports" } } + { detail: { summary: "Count new bug reports" } }, ) .get( "/bug-reports/:id", - async ({ params, status }) => { + async ({ params, status, headers }) => { Log("ADMIN", `Get bug report id=${params.id}`); - const result = await getBugReport(parseInt(params.id)); - if (!result) return status(404, { success: false, message: "Report not found" }); + const result = await getBugReport( + parseInt(params.id), + headers["x-db-env"] !== "prod" ? true : false, + ); + if (!result) + return status(404, { success: false, message: "Report not found" }); return result; }, { params: t.Object({ id: t.String() }), detail: { summary: "Get bug report with file metadata" }, - } + }, ) .patch( "/bug-reports/:id/status", - async ({ params, body, status }) => { + async ({ params, body, status, headers }) => { Log("ADMIN", `Update status id=${params.id} status=${body.status}`); const updated = await updateBugReportStatus( parseInt(params.id), - body.status + body.status, + headers["x-db-env"] !== "prod" ? true : false, ); if (!updated) return status(404, { success: false, message: "Report not found" }); @@ -94,12 +115,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ]), }), detail: { summary: "Update bug report status" }, - } + }, ) .get( "/bug-reports/:id/files/:fileId", - async ({ params, status, set }) => { - const file = await getFile(parseInt(params.id), parseInt(params.fileId)); + async ({ params, status, set, headers }) => { + const file = await getFile( + parseInt(params.id), + parseInt(params.fileId), + headers["x-db-env"] !== "prod" ? true : false, + ); if (!file) return status(404, { success: false, message: "File not found" }); @@ -111,13 +136,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String(), fileId: t.String() }), detail: { summary: "Download a bug report file" }, - } + }, ) .get( "/bug-reports/:id/download", - async ({ params, status, set }) => { + async ({ params, status, set, headers }) => { Log("ADMIN", `Download zip for report id=${params.id}`); - const zipBuffer = await generateReportZip(parseInt(params.id)); + const zipBuffer = await generateReportZip( + parseInt(params.id), + headers["x-db-env"] !== "prod" ? true : false, + ); if (!zipBuffer) return status(404, { success: false, message: "Report not found" }); @@ -130,13 +158,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String() }), detail: { summary: "Download all files for a bug report as ZIP" }, - } + }, ) .delete( "/bug-reports/:id", - async ({ params, status }) => { + async ({ params, status, headers }) => { Log("ADMIN", `Delete bug report id=${params.id}`); - const deleted = await deleteBugReport(parseInt(params.id)); + const deleted = await deleteBugReport( + parseInt(params.id), + headers["x-db-env"] !== "prod" ? true : false, + ); if (!deleted) return status(404, { success: false, message: "Report not found" }); return { success: true, message: "Report deleted" }; @@ -144,16 +175,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String() }), detail: { summary: "Delete a bug report and its files" }, - } + }, ) // User management .get( "/users", - async () => { + async ({ headers }) => { Log("ADMIN", "List users"); return await listUsers(); }, - { detail: { summary: "List all users" } } + { detail: { summary: "List all users" } }, ) .post( "/users", @@ -177,7 +208,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) role: t.Union([t.Literal("admin"), t.Literal("user")]), }), detail: { summary: "Create a new user" }, - } + }, ) .patch( "/users/:id", @@ -195,7 +226,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) enabled: t.Optional(t.Boolean()), }), detail: { summary: "Update user displayname or enabled status" }, - } + }, ) .post( "/users/:id/reset-password", @@ -210,7 +241,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) params: t.Object({ id: t.String() }), body: t.Object({ password: t.String({ minLength: 1 }) }), detail: { summary: "Reset a user's password" }, - } + }, ) .delete( "/users/:id", @@ -221,7 +252,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) if (!user) throw status(404, { success: false, message: "User not found" }); if (user.role === "admin") - return status(400, { success: false, message: "Cannot delete an admin user" }); + return status(400, { + success: false, + message: "Cannot delete an admin user", + }); const deleted = await deleteUser(params.id); if (!deleted) @@ -231,5 +265,5 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String() }), detail: { summary: "Delete a user (non-admin only)" }, - } + }, ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 4e85aea..2e037b3 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,19 +1,30 @@ import { Elysia, t } from "elysia"; import { adminKeyGuard } from "../middleware/auth"; -import { loginUser, validateSession, logoutSession } from "../services/authService"; +import { + loginUser, + validateSession, + logoutSession, +} from "../services/authService"; import { Log } from "../logger"; export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) .onRequest(adminKeyGuard) .post( "/login", - async ({ body, error }) => { + async ({ body, status }) => { const result = await loginUser(body.username, body.password); if (!result) { Log("AUTH", `Login failed for username=${body.username}`); - return error(401, { success: false, message: "Invalid credentials or account disabled" }); + return status(401, { + success: false, + message: "Invalid credentials or account disabled", + }); } - return { success: true, session_id: result.session_id, user: result.user }; + return { + success: true, + session_id: result.session_id, + user: result.user, + }; }, { body: t.Object({ @@ -21,7 +32,7 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) password: t.String({ minLength: 1 }), }), detail: { summary: "Login with username/password" }, - } + }, ) .post( "/logout", @@ -34,22 +45,28 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) }, { detail: { summary: "Logout and invalidate session" }, - } + }, ) .get( "/validate", - async ({ headers, error }) => { + async ({ headers, status }) => { const sessionId = headers["x-session-token"]; if (!sessionId) { - return error(401, { success: false, message: "No session token provided" }); + return status(401, { + success: false, + message: "No session token provided", + }); } const user = await validateSession(sessionId); if (!user) { - return error(401, { success: false, message: "Invalid or expired session" }); + return status(401, { + success: false, + message: "Invalid or expired session", + }); } return { success: true, user }; }, { detail: { summary: "Validate session and return user" }, - } + }, ); diff --git a/src/routes/bugReports.ts b/src/routes/bugReports.ts index 0b05ce3..9092eeb 100644 --- a/src/routes/bugReports.ts +++ b/src/routes/bugReports.ts @@ -14,11 +14,15 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) .onRequest(apiKeyGuard) - .use(hwidRateLimit) + //.use(hwidRateLimit) .post( "/", - async ({ body, request, set }) => { - const { name, email, description, hwid, hostname, os_user, system_info } = body; + async ({ body, request, set, headers }) => { + console.log(headers); + const { name, email, description, hwid, hostname, os_user, system_info } = + body; + const useTestDB = headers["x-db-env"] !== "prod"; + console.log("Creating a bug report in the test DB..."); // Get submitter IP from headers or connection const submitterIp = @@ -26,7 +30,10 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) request.headers.get("x-real-ip") || "unknown"; - Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`); + Log( + "BUGREPORT", + `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`, + ); // Parse system_info - may arrive as a JSON string or already-parsed object let systemInfo: Record | null = null; @@ -43,31 +50,40 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) } // Create the bug report - const reportId = await createBugReport({ - name, - email, - description, - hwid: hwid || "", - hostname: hostname || "", - os_user: os_user || "", - submitter_ip: submitterIp, - system_info: systemInfo, - }); + const reportId = await createBugReport( + { + name, + email, + description, + hwid: hwid || "", + hostname: hostname || "", + os_user: os_user || "", + submitter_ip: submitterIp, + system_info: systemInfo, + }, + useTestDB, + ); // Process file uploads for (const { field, role, mime } of FILE_ROLES) { const file = body[field as keyof typeof body]; if (file && file instanceof File) { const buffer = Buffer.from(await file.arrayBuffer()); - Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`); - await addFile({ - report_id: reportId, - file_role: role, - filename: file.name || `${field}.bin`, - mime_type: file.type || mime, - file_size: buffer.length, - data: buffer, - }); + Log( + "BUGREPORT", + `File uploaded: role=${role} size=${buffer.length} bytes`, + ); + await addFile( + { + report_id: reportId, + file_role: role, + filename: file.name || `${field}.bin`, + mime_type: file.type || mime, + file_size: buffer.length, + data: buffer, + }, + useTestDB, + ); } } @@ -103,5 +119,5 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) }), }, detail: { summary: "Submit a bug report" }, - } + }, ); diff --git a/src/services/bugReportService.ts b/src/services/bugReportService.ts index 13b0fbc..a985a38 100644 --- a/src/services/bugReportService.ts +++ b/src/services/bugReportService.ts @@ -10,17 +10,20 @@ import type { PaginatedResponse, } from "../types"; -export async function createBugReport(data: { - name: string; - email: string; - description: string; - hwid: string; - hostname: string; - os_user: string; - submitter_ip: string; - system_info: Record | null; -}): Promise { - const pool = getPool(); +export async function createBugReport( + data: { + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + system_info: Record | null; + }, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( `INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, @@ -33,20 +36,23 @@ export async function createBugReport(data: { data.os_user, data.submitter_ip, data.system_info ? JSON.stringify(data.system_info) : null, - ] + ], ); return result.insertId; } -export async function addFile(data: { - report_id: number; - file_role: FileRole; - filename: string; - mime_type: string; - file_size: number; - data: Buffer; -}): Promise { - const pool = getPool(); +export async function addFile( + data: { + report_id: number; + file_role: FileRole; + filename: string; + mime_type: string; + file_size: number; + data: Buffer; + }, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( `INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data) VALUES (?, ?, ?, ?, ?, ?)`, @@ -57,18 +63,21 @@ export async function addFile(data: { data.mime_type, data.file_size, data.data, - ] + ], ); return result.insertId; } -export async function listBugReports(opts: { - page: number; - pageSize: number; - status?: BugReportStatus; - search?: string; -}): Promise> { - const pool = getPool(); +export async function listBugReports( + opts: { + page: number; + pageSize: number; + status?: BugReportStatus; + search?: string; + }, + useTestDb?: boolean, +): Promise> { + const pool = getPool(useTestDb ? true : false); const { page, pageSize, status, search } = opts; const offset = (page - 1) * pageSize; @@ -83,7 +92,7 @@ export async function listBugReports(opts: { if (search) { const like = `%${search}%`; conditions.push( - "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)" + "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)", ); params.push(like, like, like, like); } @@ -93,7 +102,6 @@ export async function listBugReports(opts: { const [countRows] = await pool.execute( `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, - params ); const total = (countRows[0] as { total: number }).total; @@ -105,7 +113,6 @@ export async function listBugReports(opts: { GROUP BY br.id ORDER BY br.created_at DESC LIMIT ${pageSize} OFFSET ${offset}`, - params ); return { @@ -117,20 +124,23 @@ export async function listBugReports(opts: { }; } -export async function countNewReports(): Promise { - const pool = getPool(); +export async function countNewReports(useTestDb?: boolean): Promise { + const pool = getPool(useTestDb ? true : false); const [rows] = await pool.execute( - "SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'" + "SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'", ); return (rows[0] as { count: number }).count; } -export async function generateReportZip(reportId: number): Promise { - const pool = getPool(); +export async function generateReportZip( + reportId: number, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [reportRows] = await pool.execute( "SELECT * FROM bug_reports WHERE id = ?", - [reportId] + [reportId], ); if ((reportRows as unknown[]).length === 0) return null; @@ -138,7 +148,7 @@ export async function generateReportZip(reportId: number): Promise( "SELECT * FROM bug_report_files WHERE report_id = ?", - [reportId] + [reportId], ); const files = fileRows as BugReportFile[]; @@ -163,7 +173,11 @@ export async function generateReportZip(reportId: number): Promise[] } | null> { - const pool = getPool(); + const pool = getPool(useTestDb ? true : false); const [reportRows] = await pool.execute( "SELECT * FROM bug_reports WHERE id = ?", - [id] + [id], ); if ((reportRows as unknown[]).length === 0) return null; const [fileRows] = await pool.execute( "SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?", - [id] + [id], ); return { @@ -201,35 +216,40 @@ export async function getBugReport( export async function getFile( reportId: number, - fileId: number + fileId: number, + useTestDb?: boolean, ): Promise { - const pool = getPool(); + const pool = getPool(useTestDb ? true : false); const [rows] = await pool.execute( "SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?", - [fileId, reportId] + [fileId, reportId], ); if ((rows as unknown[]).length === 0) return null; return rows[0] as BugReportFile; } -export async function deleteBugReport(id: number): Promise { - const pool = getPool(); +export async function deleteBugReport( + id: number, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( "DELETE FROM bug_reports WHERE id = ?", - [id] + [id], ); return result.affectedRows > 0; } export async function updateBugReportStatus( id: number, - status: BugReportStatus + status: BugReportStatus, + useTestDb?: boolean, ): Promise { - const pool = getPool(); + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( "UPDATE bug_reports SET status = ? WHERE id = ?", - [status, id] + [status, id], ); return result.affectedRows > 0; } diff --git a/src/types/index.ts b/src/types/index.ts index 6b933a4..f2f58f9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,3 +55,5 @@ export interface PaginatedResponse { pageSize: number; totalPages: number; } + +export type DbEnv = "prod" | "test";