Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": ["<node_internals>/**"]
},
{
"name": "Attach",
"type": "node",
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ mysql -u <username> -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
@@ -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 # Optional override; defaults are set in code
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
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,6 +125,7 @@ const connectToDb = async (): Promise<boolean> => {
const overrideOptions = {
...options,
charset: 'utf8mb4',
entities: resolveTypeOrmEntities(),
synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true',
};
return createConnection(overrideOptions)
Expand Down Expand Up @@ -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
)
) {
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/jobs/fun-fact.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
14 changes: 12 additions & 2 deletions packages/backend/src/jobs/fun-fact.job.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] },
Expand All @@ -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();
Expand Down
31 changes: 24 additions & 7 deletions packages/backend/src/jobs/fun-fact.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,27 @@ export class FunFactJob {

private async fetchQuote(): Promise<QuotePayload> {
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)) {
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}` };
}
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' };
}
Expand All @@ -164,7 +177,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) {
Expand Down
57 changes: 57 additions & 0 deletions packages/backend/src/jobs/run-fun-fact-job.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
});
27 changes: 27 additions & 0 deletions packages/backend/src/shared/db/typeorm-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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();

// If TYPEORM_ENTITIES is configured, use it exclusively.
if (configuredEntities.length > 0) {
return Array.from(new Set(configuredEntities));
}

// 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')];
};
Loading
Loading