From 1ef47d6d47fc000dc93ba91fc4b8d3e0f2db8b9d Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 1 Apr 2026 09:40:48 -0400 Subject: [PATCH 1/6] Fixed jobs: --- packages/backend/.env.example | 66 +++++++++++++++++++ packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 +- packages/backend/src/jobs/fun-fact.const.ts | 4 +- .../backend/src/jobs/fun-fact.job.spec.ts | 14 +++- packages/backend/src/jobs/fun-fact.job.ts | 27 ++++++-- packages/backend/src/jobs/run-fun-fact-job.ts | 57 ++++++++++++++++ .../backend/src/shared/db/typeorm-options.ts | 20 ++++++ packages/backend/src/shared/logger/logger.ts | 45 +++++++++---- 9 files changed, 214 insertions(+), 23 deletions(-) create mode 100644 packages/backend/.env.example create mode 100644 packages/backend/src/jobs/run-fun-fact-job.ts create mode 100644 packages/backend/src/shared/db/typeorm-options.ts diff --git a/packages/backend/.env.example b/packages/backend/.env.example new file mode 100644 index 00000000..c55419a3 --- /dev/null +++ b/packages/backend/.env.example @@ -0,0 +1,66 @@ +# ========================= +# Required: Slack bot tokens +# ========================= +MUZZLE_BOT_TOKEN=xoxb-your-bot-token +MUZZLE_BOT_USER_TOKEN=xoxp-your-user-token +MUZZLE_BOT_SIGNING_SECRET=your-signing-secret + +# Slash command / hook auth tokens +CLAPPER_TOKEN=your-clapper-token +MOCKER_TOKEN=your-mocker-token +DEFINE_TOKEN=your-define-token +BLIND_TOKEN=your-blind-token +HOOK_TOKEN=your-hook-token + +# ========================= +# Required: Database (TypeORM) +# ========================= +TYPEORM_CONNECTION=mysql +TYPEORM_HOST=localhost +TYPEORM_PORT=3306 +TYPEORM_USERNAME=root +TYPEORM_PASSWORD=your-password +TYPEORM_DATABASE=mockerdbdev +TYPEORM_ENTITIES=/absolute/path/to/mocker/packages/backend/src/shared/db/models/*.ts +TYPEORM_SYNCHRONIZE=true + +# ========================= +# API server +# ========================= +PORT=3000 +NODE_ENV=development + +# ========================= +# Search/Auth UI +# ========================= +SEARCH_FRONTEND_URL=http://localhost:5173 +SEARCH_AUTH_SECRET=replace-with-a-long-random-secret +ALLOWED_TEAM_DOMAIN=your-slack-team-id + +# Slack OAuth +SLACK_CLIENT_ID=your-slack-client-id +SLACK_CLIENT_SECRET=your-slack-client-secret +SLACK_REDIRECT_URI=http://localhost:3000/auth/slack/callback + +# ========================= +# Optional: External APIs +# ========================= +OPENAI_API_KEY=sk-your-openai-key +GOOGLE_GEMINI_API_KEY=your-gemini-api-key +GOOGLE_TRANSLATE_API_KEY=your-google-translate-api-key +FINNHUB_API_KEY=your-finnhub-api-key +API_NINJA_KEY=your-api-ninjas-key + +# ========================= +# Optional: Job tuning +# ========================= +FUN_FACT_SLACK_CHANNEL=#general +FACT_TARGET_COUNT=5 +MAX_FACT_ATTEMPTS=50 +MAX_JOKE_ATTEMPTS=20 + +# ========================= +# Optional: Runtime/storage +# ========================= +IMAGE_DIR=/tmp/mocker-images +REDIS_CONTAINER_NAME=localhost diff --git a/packages/backend/package.json b/packages/backend/package.json index 92d3bea7..3abdb9d3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -14,6 +14,7 @@ "start": "npm run start:dev", "start:prod": "node dist/index.js", "start:dev": "nodemon --watch 'src/**/*.ts' --ignore 'src/**/*.spec.ts' --exec 'ts-node' src/index.ts", + "job:fun-fact": "ts-node src/jobs/run-fun-fact-job.ts", "test": "jest --silent", "test:watch": "jest --watch", "test:coverage": "node ./scripts/run-coverage-gate.js" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3a7fd4aa..accd7a02 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,6 +33,7 @@ import { AIService } from './ai/ai.service'; import { DailyMemoryJob } from './ai/daily-memory.job'; import { FunFactJob } from './jobs/fun-fact.job'; import { PricingJob } from './jobs/pricing.job'; +import { resolveTypeOrmEntities } from './shared/db/typeorm-options'; import { portfolioController } from './portfolio/portfolio.controller'; import { hookController } from './hook/hook.controller'; import { searchController } from './search/search.controller'; @@ -124,6 +125,7 @@ const connectToDb = async (): Promise => { const overrideOptions = { ...options, charset: 'utf8mb4', + entities: resolveTypeOrmEntities(), synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true', }; return createConnection(overrideOptions) @@ -157,7 +159,6 @@ const checkForEnvVariables = (): void => { process.env.TYPEORM_USERNAME && process.env.TYPEORM_PASSWORD && process.env.TYPEORM_DATABASE && - process.env.TYPEORM_ENTITIES && process.env.TYPEORM_SYNCHRONIZE ) ) { diff --git a/packages/backend/src/jobs/fun-fact.const.ts b/packages/backend/src/jobs/fun-fact.const.ts index 37696e39..42ce28df 100644 --- a/packages/backend/src/jobs/fun-fact.const.ts +++ b/packages/backend/src/jobs/fun-fact.const.ts @@ -4,6 +4,6 @@ export const MAX_JOKE_ATTEMPTS = parseInt(process.env.MAX_JOKE_ATTEMPTS ?? '20', export const FUN_FACT_SLACK_CHANNEL = process.env.FUN_FACT_SLACK_CHANNEL ?? '#general'; export const USELESS_FACTS_URL = 'https://uselessfacts.jsph.pl/random.json?language=en'; -export const API_NINJAS_URL = 'https://api.api-ninjas.com/v1/facts?limit=1'; -export const QUOTE_URL = 'https://quotes.rest/qod.json?category=inspire'; +export const API_NINJAS_URL = 'https://api.api-ninjas.com/v1/facts'; +export const QUOTE_URL = 'https://zenquotes.io/api/today'; export const JOKE_URL = 'https://v2.jokeapi.dev/joke/Miscellaneous,Pun,Spooky?blacklistFlags=racist,sexist'; diff --git a/packages/backend/src/jobs/fun-fact.job.spec.ts b/packages/backend/src/jobs/fun-fact.job.spec.ts index 72bba6d5..ad7411d9 100644 --- a/packages/backend/src/jobs/fun-fact.job.spec.ts +++ b/packages/backend/src/jobs/fun-fact.job.spec.ts @@ -274,7 +274,17 @@ describe('FunFactJob', () => { // --------------------------------------------------------------------------- describe('fetchQuote()', () => { - it('returns formatted quote text on success', async () => { + it('returns formatted quote text on zenquotes success payload', async () => { + (Axios.get as jest.Mock).mockResolvedValue({ + data: [{ q: 'Be yourself', a: 'Oscar Wilde' }], + }); + + const result = await harness.fetchQuote(); + + expect(result).toEqual({ text: 'Be yourself - Oscar Wilde' }); + }); + + it('returns formatted quote text on legacy success payload', async () => { (Axios.get as jest.Mock).mockResolvedValue({ data: { contents: { quotes: [{ quote: 'Be yourself', author: 'Oscar Wilde', id: '1' }] }, @@ -296,7 +306,7 @@ describe('FunFactJob', () => { it('returns error payload when the quotes array is empty', async () => { (Axios.get as jest.Mock).mockResolvedValue({ - data: { contents: { quotes: [] } }, + data: [], }); const result = await harness.fetchQuote(); diff --git a/packages/backend/src/jobs/fun-fact.job.ts b/packages/backend/src/jobs/fun-fact.job.ts index 302f12c0..71f64abd 100644 --- a/packages/backend/src/jobs/fun-fact.job.ts +++ b/packages/backend/src/jobs/fun-fact.job.ts @@ -138,14 +138,23 @@ export class FunFactJob { private async fetchQuote(): Promise { try { - const response = await Axios.get<{ - contents?: { quotes?: Array<{ quote: string; author: string; id: string }> }; - }>(QUOTE_URL); - const quote = response.data.contents?.quotes?.[0]; - if (!quote) { + const response = await Axios.get< + Array<{ q?: string; a?: string }> | { contents?: { quotes?: Array<{ quote: string; author: string }> } } + >(QUOTE_URL); + + if (Array.isArray(response.data)) { + const quote = response.data[0]; + if (quote.q && quote.a) { + return { text: `${quote.q} - ${quote.a}` }; + } return { text: '', error: 'Quote API returned no quotes' }; } - return { text: `${quote.quote} - ${quote.author}` }; + + const legacyQuote = response.data.contents?.quotes?.[0]; + if (!legacyQuote) { + return { text: '', error: 'Quote API returned no quotes' }; + } + return { text: `${legacyQuote.quote} - ${legacyQuote.author}` }; } catch { return { text: '', error: 'Issue with quote API - non 200 status code' }; } @@ -164,7 +173,11 @@ export class FunFactJob { title: string; }>; }>; - }>(`https://en.wikipedia.org/api/rest_v1/feed/onthisday/all/${month}/${day}`); + }>(`https://en.wikipedia.org/api/rest_v1/feed/onthisday/all/${month}/${day}`, { + headers: { + 'User-Agent': 'mocker-fun-fact-job/1.0 (+https://muzzle.lol)', + }, + }); const selected = response.data.selected?.[0]; if (!selected) { diff --git a/packages/backend/src/jobs/run-fun-fact-job.ts b/packages/backend/src/jobs/run-fun-fact-job.ts new file mode 100644 index 00000000..c63cc3da --- /dev/null +++ b/packages/backend/src/jobs/run-fun-fact-job.ts @@ -0,0 +1,57 @@ +import 'reflect-metadata'; +import 'dotenv/config'; + +import { createConnection, getConnectionOptions } from 'typeorm'; +import { logger } from '../shared/logger/logger'; +import { resolveTypeOrmEntities } from '../shared/db/typeorm-options'; +import { FunFactJob } from './fun-fact.job'; + +const runLogger = logger.child({ module: 'RunFunFactJob' }); + +const validateRequiredEnv = (): void => { + const requiredVars = [ + 'MUZZLE_BOT_TOKEN', + 'MUZZLE_BOT_USER_TOKEN', + 'TYPEORM_CONNECTION', + 'TYPEORM_HOST', + 'TYPEORM_USERNAME', + 'TYPEORM_PASSWORD', + 'TYPEORM_DATABASE', + 'TYPEORM_SYNCHRONIZE', + ]; + + const missing = requiredVars.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } +}; + +const run = async (): Promise => { + validateRequiredEnv(); + + const options = await getConnectionOptions(); + const overrideOptions = { + ...options, + charset: 'utf8mb4', + entities: resolveTypeOrmEntities(), + synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true', + }; + + const connection = await createConnection(overrideOptions); + + try { + runLogger.info('Connected to database, running fun-fact job'); + await new FunFactJob().run(); + runLogger.info('Fun-fact job runner completed'); + } finally { + if (connection.isConnected) { + await connection.close(); + runLogger.info('Database connection closed'); + } + } +}; + +run().catch((error: unknown) => { + runLogger.error('Fun-fact job runner failed', error); + process.exit(1); +}); diff --git a/packages/backend/src/shared/db/typeorm-options.ts b/packages/backend/src/shared/db/typeorm-options.ts new file mode 100644 index 00000000..7579e0c6 --- /dev/null +++ b/packages/backend/src/shared/db/typeorm-options.ts @@ -0,0 +1,20 @@ +import path from 'path'; + +const parseConfiguredEntities = (): string[] => { + const configured = process.env.TYPEORM_ENTITIES ?? ''; + return configured + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +}; + +export const resolveTypeOrmEntities = (): string[] => { + const configuredEntities = parseConfiguredEntities(); + + const defaultEntities = [ + path.join(process.cwd(), 'src/shared/db/models/*.{ts,js}'), + path.join(process.cwd(), 'dist/shared/db/models/*.js'), + ]; + + return Array.from(new Set([...configuredEntities, ...defaultEntities])); +}; diff --git a/packages/backend/src/shared/logger/logger.ts b/packages/backend/src/shared/logger/logger.ts index 19f287c5..d344d1ac 100644 --- a/packages/backend/src/shared/logger/logger.ts +++ b/packages/backend/src/shared/logger/logger.ts @@ -17,23 +17,42 @@ type LogInfo = { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; +const safeStringify = (value: unknown): string => { + const seen = new WeakSet(); + return JSON.stringify(value, (_key, nestedValue) => { + if (typeof nestedValue === 'object' && nestedValue !== null) { + if (seen.has(nestedValue)) { + return '[Circular]'; + } + seen.add(nestedValue); + } + return nestedValue; + }); +}; + const serializeError = (error: unknown): Record => { if (error instanceof Error) { - const details = Object.entries(error).reduce>((accumulator, [key, value]) => { - accumulator[key] = value; - return accumulator; - }, {}); - - return { + const serialized: Record = { name: error.name, message: error.message, stack: error.stack, - ...details, }; + + const maybeCode = Reflect.get(error, 'code'); + if (typeof maybeCode === 'string' || typeof maybeCode === 'number') { + serialized.code = maybeCode; + } + + const maybeStatus = Reflect.get(error, 'status'); + if (typeof maybeStatus === 'number') { + serialized.status = maybeStatus; + } + + return serialized; } if (isRecord(error)) { - return { ...error }; + return JSON.parse(safeStringify(error)); } return { value: error }; @@ -62,7 +81,7 @@ const normalizeLogInfo = format((info: LogInfo) => { normalizedInfo.error = { name: normalizedInfo.name ?? 'Error', message: - typeof normalizedInfo.message === 'string' ? normalizedInfo.message : JSON.stringify(normalizedInfo.message), + typeof normalizedInfo.message === 'string' ? normalizedInfo.message : safeStringify(normalizedInfo.message), stack: normalizedInfo.stack, }; } @@ -78,6 +97,10 @@ const jsonLineFormat = printf((info: LogInfo) => { return accumulator; } + if (key === 'config' || key === 'request' || key === 'response') { + return accumulator; + } + accumulator[key] = value; return accumulator; }, {}); @@ -86,7 +109,7 @@ const jsonLineFormat = printf((info: LogInfo) => { timestamp: loggedAt, level, module, - message: typeof message === 'string' ? message : JSON.stringify(message), + message: typeof message === 'string' ? message : safeStringify(message), }; if (context && Object.keys(context).length > 0) { @@ -109,7 +132,7 @@ const jsonLineFormat = printf((info: LogInfo) => { payload.meta = meta; } - return JSON.stringify(payload); + return safeStringify(payload); }); export const logger = createLogger({ From b0ee9d3ee0a586014fab4bdec1dec4f9ed2831b1 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 1 Apr 2026 09:41:14 -0400 Subject: [PATCH 2/6] Fixed jobs: --- .vscode/launch.json | 10 ++++++++++ README.md | 12 ++++++++++++ package.json | 1 + 3 files changed, 23 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index be082847..cc770234 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,6 +8,16 @@ "runtimeArgs": ["-r", "ts-node/register"], "args": ["${workspaceFolder}/src/index.ts"] }, + { + "name": "Debug backend fun-fact job", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/packages/backend", + "runtimeArgs": ["-r", "ts-node/register"], + "program": "${workspaceFolder}/packages/backend/src/jobs/run-fun-fact-job.ts", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, { "name": "Attach", "type": "node", diff --git a/README.md b/README.md index c4731937..32e2b660 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,12 @@ mysql -u -p mockerdbdev < DB_SEED.sql Create `.env` files in `packages/backend` and `packages/frontend` (or set them globally). +For backend, start from the checked-in example: + +```bash +cp packages/backend/.env.example packages/backend/.env +``` + #### Backend (`packages/backend/.env`) ```bash @@ -139,6 +145,12 @@ GOOGLE_TRANSLATE_API_KEY=your-google-translate-key #### Frontend (`packages/frontend/.env`) +For frontend, start from the checked-in example: + +```bash +cp packages/frontend/.env.example packages/frontend/.env +``` + ```bash # Backend API URL VITE_API_BASE_URL=http://localhost:3000 diff --git a/package.json b/package.json index 02fbd59b..60656a5e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prepare": "husky", "start": "npm run start -w @mocker/backend", "start:prod": "npm run start:prod -w @mocker/backend", + "job:fun-fact": "npm run job:fun-fact -w @mocker/backend", "test": "npm run test --workspaces --if-present", "test:backend": "npm run test -w @mocker/backend", "test:coverage": "npm run test:coverage -w @mocker/backend", From 957ad6794e8d9147438a7b384af0a984737e6914 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 1 Apr 2026 09:43:57 -0400 Subject: [PATCH 3/6] Fixed tests --- packages/backend/src/jobs/fun-fact.job.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/jobs/fun-fact.job.ts b/packages/backend/src/jobs/fun-fact.job.ts index 71f64abd..95991c16 100644 --- a/packages/backend/src/jobs/fun-fact.job.ts +++ b/packages/backend/src/jobs/fun-fact.job.ts @@ -143,6 +143,10 @@ export class FunFactJob { >(QUOTE_URL); if (Array.isArray(response.data)) { + if (response.data.length === 0) { + return { text: '', error: 'Quote API returned no quotes' }; + } + const quote = response.data[0]; if (quote.q && quote.a) { return { text: `${quote.q} - ${quote.a}` }; From 4243db76e99ee46ef84984f5f10e526c66018408 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 1 Apr 2026 09:49:45 -0400 Subject: [PATCH 4/6] Update packages/backend/src/shared/logger/logger.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/shared/logger/logger.ts | 30 +++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/shared/logger/logger.ts b/packages/backend/src/shared/logger/logger.ts index d344d1ac..267499cc 100644 --- a/packages/backend/src/shared/logger/logger.ts +++ b/packages/backend/src/shared/logger/logger.ts @@ -19,15 +19,31 @@ const isRecord = (value: unknown): value is Record => typeof va const safeStringify = (value: unknown): string => { const seen = new WeakSet(); - return JSON.stringify(value, (_key, nestedValue) => { - if (typeof nestedValue === 'object' && nestedValue !== null) { - if (seen.has(nestedValue)) { - return '[Circular]'; + + try { + const json = JSON.stringify(value, (_key, nestedValue) => { + if (typeof nestedValue === 'object' && nestedValue !== null) { + if (seen.has(nestedValue)) { + return '[Circular]'; + } + seen.add(nestedValue); } - seen.add(nestedValue); + return nestedValue; + }); + + if (typeof json === 'string') { + return json; } - return nestedValue; - }); + } catch { + // Fall through to the fallback below. + } + + // Fallback: ensure we always return a string, even for unsupported values. + try { + return String(value); + } catch { + return '[Unserializable]'; + } }; const serializeError = (error: unknown): Record => { From 5ed294febf021a0bc2cc18b4ce71e2ee565c1c6b Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 1 Apr 2026 09:50:04 -0400 Subject: [PATCH 5/6] Update packages/backend/src/shared/db/typeorm-options.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../backend/src/shared/db/typeorm-options.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/shared/db/typeorm-options.ts b/packages/backend/src/shared/db/typeorm-options.ts index 7579e0c6..70d6ada5 100644 --- a/packages/backend/src/shared/db/typeorm-options.ts +++ b/packages/backend/src/shared/db/typeorm-options.ts @@ -11,10 +11,17 @@ const parseConfiguredEntities = (): string[] => { export const resolveTypeOrmEntities = (): string[] => { const configuredEntities = parseConfiguredEntities(); - const defaultEntities = [ - path.join(process.cwd(), 'src/shared/db/models/*.{ts,js}'), - path.join(process.cwd(), 'dist/shared/db/models/*.js'), - ]; + // If TYPEORM_ENTITIES is configured, use it exclusively. + if (configuredEntities.length > 0) { + return Array.from(new Set(configuredEntities)); + } - return Array.from(new Set([...configuredEntities, ...defaultEntities])); + // Otherwise, select either the src or dist pattern, but not both. + const isTsRuntime = __filename.endsWith('.ts'); + + if (isTsRuntime) { + return [path.join(process.cwd(), 'src/shared/db/models/*.{ts,js}')]; + } + + return [path.join(process.cwd(), 'dist/shared/db/models/*.js')]; }; From 72f27e8bedd184ec5628a7995affc63e9d8d4dfb Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 1 Apr 2026 09:50:13 -0400 Subject: [PATCH 6/6] Update packages/backend/.env.example Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/backend/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/.env.example b/packages/backend/.env.example index c55419a3..8a67973f 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -21,7 +21,7 @@ TYPEORM_PORT=3306 TYPEORM_USERNAME=root TYPEORM_PASSWORD=your-password TYPEORM_DATABASE=mockerdbdev -TYPEORM_ENTITIES=/absolute/path/to/mocker/packages/backend/src/shared/db/models/*.ts +# TYPEORM_ENTITIES=/absolute/path/to/mocker/packages/backend/src/shared/db/models/*.ts # Optional override; defaults are set in code TYPEORM_SYNCHRONIZE=true # =========================