From f6efd967339385d0632df93243ac4bd801e6a661 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sun, 17 May 2026 17:31:25 +0200 Subject: [PATCH] feat: Add deployment guide and update README for Coolify setup; include dotenv for environment variable management --- DEPLOYMENT.md | 138 ++++++++++++++++++ README.md | 58 ++++++-- package-lock.json | 13 ++ package.json | 1 + src/db/cli.ts | 2 + src/deploy-commands.ts | 1 + src/index.ts | 1 + tests/commands/ping.test.ts | 5 +- tests/commands/sign-up.test.ts | 7 +- tests/integration/mileage-engine-flow.test.ts | 4 +- tests/integration/tour-schedule-flow.test.ts | 34 ++--- 11 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 DEPLOYMENT.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..d1dc374 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,138 @@ +# Deployment Guide + +This document covers production deployment for the `omo-bot` Coolify project with **two separate resources**: + +- `omo-bot-backend` (Node.js bot + Admin API) +- `omo-bot-dashboard` (React/Vite static dashboard from `admin-dashboard/`) + +## Deployment Topology + +Recommended domains: + +- `api.yourdomain.com` -> `omo-bot-backend` +- `admin.yourdomain.com` -> `omo-bot-dashboard` + +Optional same-origin pattern: + +- `admin.yourdomain.com` serves dashboard and reverse-proxies `/admin/*` + `/health` to backend. + +## 1. Create Coolify Project and Resources + +1. In Coolify, create (or open) project `omo-bot`. +2. Add resource `omo-bot-backend` from this repository: + - Build context: repository root + - Start command: `npm start` + - Port: `8787` (Admin API) if exposed +3. Add resource `omo-bot-dashboard` from this repository: + - Base directory: `admin-dashboard` + - Build command: `npm run build` + - Publish directory: `dist` +4. Configure domains for each resource and enable TLS certificates. + +## 2. Configure Runtime Variables + +Backend (`omo-bot-backend`) minimum: + +- `DATABASE_URL` +- `CONFIG_DB_ENABLED=true` +- Any temporary legacy vars needed for first-time `db:seed` + +Dashboard (`omo-bot-dashboard`) minimum: + +- `VITE_ADMIN_API_BASE_URL` (for split-domain deployment, set to `https://api.yourdomain.com`) +- `VITE_DISCORD_CLIENT_ID` +- `VITE_DISCORD_REDIRECT_URI` (must match Discord OAuth2 redirect list) + +OAuth alignment: + +- Discord Developer Portal redirect URI must match dashboard redirect domain. +- Config DB key `OAUTH_BRIDGE_REDIRECT_URI` must match the same URI. + +## 3. Get Coolify Deployment Hooks and Tokens + +For each resource (`omo-bot-backend` and `omo-bot-dashboard`): + +1. Open resource in Coolify. +2. Go to Deployments/Webhooks (name may vary by Coolify version). +3. Copy the Deploy Webhook URL. +4. If webhook auth token is enabled, copy the token. + +Store both resources separately: + +- Backend deploy hook URL/token +- Dashboard deploy hook URL/token + +## 4. Configure Gitea Action Secrets/Variables + +In Gitea repository settings, add Actions secrets: + +- `COOLIFY_DEPLOY_HOOK_URL_BOT` +- `COOLIFY_DEPLOY_TOKEN_BOT` (optional) +- `COOLIFY_DEPLOY_HOOK_URL_DASHBOARD` +- `COOLIFY_DEPLOY_TOKEN_DASHBOARD` (optional) + +Current workflow in `.gitea/workflows/ci-cd.yml` uses a single pair: + +- `COOLIFY_DEPLOY_HOOK_URL` +- `COOLIFY_DEPLOY_TOKEN` + +If you use separate resources with separate hooks, update workflow deploy step to call both hooks. + +## 5. Example Deploy Step for Two Coolify Resources + +```yaml +deploy-coolify: + name: Deploy to Coolify + runs-on: ubuntu-latest + needs: + - bot-checks + - dashboard-checks + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + steps: + - name: Trigger backend deploy + env: + HOOK_URL: ${{ secrets.COOLIFY_DEPLOY_HOOK_URL_BOT }} + HOOK_TOKEN: ${{ secrets.COOLIFY_DEPLOY_TOKEN_BOT }} + run: | + if [ -z "$HOOK_URL" ]; then + echo "Missing COOLIFY_DEPLOY_HOOK_URL_BOT" + exit 1 + fi + if [ -n "$HOOK_TOKEN" ]; then + curl --fail --show-error --silent -X POST -H "Authorization: Bearer $HOOK_TOKEN" "$HOOK_URL" + else + curl --fail --show-error --silent -X POST "$HOOK_URL" + fi + + - name: Trigger dashboard deploy + env: + HOOK_URL: ${{ secrets.COOLIFY_DEPLOY_HOOK_URL_DASHBOARD }} + HOOK_TOKEN: ${{ secrets.COOLIFY_DEPLOY_TOKEN_DASHBOARD }} + run: | + if [ -z "$HOOK_URL" ]; then + echo "Missing COOLIFY_DEPLOY_HOOK_URL_DASHBOARD" + exit 1 + fi + if [ -n "$HOOK_TOKEN" ]; then + curl --fail --show-error --silent -X POST -H "Authorization: Bearer $HOOK_TOKEN" "$HOOK_URL" + else + curl --fail --show-error --silent -X POST "$HOOK_URL" + fi +``` + +## 6. Domain and DNS Checklist + +1. Create DNS records to Coolify host: + - `A` record for root/subdomain targets + - `CNAME` where appropriate +2. Verify certificates issued for both domains. +3. Verify dashboard can call API at configured `VITE_ADMIN_API_BASE_URL`. +4. Verify API health endpoint over TLS: + - `GET https://api.yourdomain.com/health` + +## 7. Post-Deploy Verification + +1. `npm run register:commands` (if bot app/guild IDs changed). +2. Confirm bot online in Discord and slash commands visible. +3. Open dashboard URL and run OAuth login flow. +4. Validate Admin API auth behavior with and without bearer token. diff --git a/README.md b/README.md index 2e8a996..82a6088 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,41 @@ This project uses the arc42 template. Chapters are in `docs/`. - npm 10+ - Discord Developer Application (bot token + OAuth2 app) - PostgreSQL instance +- Discord server where you can install bots with role/channel permissions + +### Discord Bot Setup (Portal) + +1. Open Discord Developer Portal and create a new application. +2. In `Bot` tab, click `Add Bot`. +3. Copy these values for runtime config (`bot_settings`): + - `DISCORD_TOKEN` from Bot tab (Reset Token if needed) + - `DISCORD_CLIENT_ID` from OAuth2 > General +4. In `Bot` > `Privileged Gateway Intents`, enable only: + - `Server Members Intent` (required by current code) +5. Keep these disabled unless code changes require them: + - `Message Content Intent` + - `Presence Intent` + +Current gateway intents used by this project (`src/bot.ts`): + +- `Guilds` +- `GuildMembers` (privileged) +- `GuildMessageReactions` + +### Discord Bot Install (OAuth2 URL) + +1. Open `OAuth2` > `URL Generator`. +2. Select scopes: + - `bot` + - `applications.commands` +3. Select bot permissions (minimum recommended for current features): + - `View Channels` + - `Send Messages` + - `Read Message History` + - `Add Reactions` + - `Manage Roles` (call-sheet role assignment) + - `Manage Channels` (queue moderation paths) +4. Use generated URL to add bot to your server. ### Installation @@ -155,7 +190,7 @@ npm run db:setup ``` - `db:migrate`: applies schema migrations tracked in `schema_migrations` -- `db:seed`: inserts initial config values from environment into `bot_settings` when missing +- `db:seed`: ensures migrations are applied, then inserts initial config values from environment into `bot_settings` when missing - `db:setup`: runs migrate then seed ### Runtime Config Source @@ -173,6 +208,16 @@ Recommended flow: 3. Run `npm run db:seed` once while legacy env vars are still present. 4. Remove legacy runtime vars from `.env` after confirming `bot_settings` contains required keys. +### Deployment + +Deployment setup and domain configuration moved to `DEPLOYMENT.md`. + +- Coolify project/resource setup +- Real domain DNS/TLS and routing patterns +- Discord OAuth production redirect alignment +- Gitea Actions secret configuration for deploy hooks +- Two-resource deploy flow (`omo-bot-backend` + `omo-bot-dashboard`) + Dashboard quality check: ```bash @@ -201,16 +246,7 @@ npm run build Pipeline file: `.gitea/workflows/ci-cd.yml` -Flow: - -- bot checks: `npm ci -> npm run lint -> npm run build -> npm run test` -- dashboard checks: `admin-dashboard/npm ci -> npm run lint -> npm run build` -- deploy: on push to `main`, trigger Coolify deploy webhook - -Required Gitea secrets: - -- `COOLIFY_DEPLOY_HOOK_URL` (required) -- `COOLIFY_DEPLOY_TOKEN` (optional bearer token) +For full deployment flow, webhook secret strategy, and two-resource Coolify deployment, see `DEPLOYMENT.md`. ## Contributing diff --git a/package-lock.json b/package-lock.json index a7c79ef..10aac25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "discord.js": "^14.26.4", + "dotenv": "^17.4.2", "express": "^5.2.1", "pg": "^8.20.0" }, @@ -3131,6 +3132,18 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 8ffcdee..05e5011 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "type": "commonjs", "dependencies": { "discord.js": "^14.26.4", + "dotenv": "^17.4.2", "express": "^5.2.1", "pg": "^8.20.0" }, diff --git a/src/db/cli.ts b/src/db/cli.ts index adf2233..14f4154 100644 --- a/src/db/cli.ts +++ b/src/db/cli.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations"; async function main(): Promise { @@ -10,6 +11,7 @@ async function main(): Promise { } if (command === "seed") { + await runMigrations(); const inserted = await seedInitialConfig(); console.log(`Database seed complete. Inserted ${inserted} config entries.`); return; diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index d45c911..0af0b1c 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { REST, Routes } from "discord.js"; import { commands } from "./commands"; import { loadRuntimeConfig } from "./config"; diff --git a/src/index.ts b/src/index.ts index 7cf57d9..7ab6e1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { startBot } from "./bot"; import { loadRuntimeConfig } from "./config"; import { runMigrations } from "./db/migrations"; diff --git a/tests/commands/ping.test.ts b/tests/commands/ping.test.ts index c57a3de..4889401 100644 --- a/tests/commands/ping.test.ts +++ b/tests/commands/ping.test.ts @@ -1,10 +1,13 @@ +import { ChatInputCommandInteraction } from "discord.js"; import { pingCommand } from "../../src/commands/ping"; describe("pingCommand", () => { it("replies with Pong", async () => { const reply = jest.fn().mockResolvedValue(undefined); - await pingCommand.execute({ reply } as any); + await pingCommand.execute({ + reply, + } as unknown as ChatInputCommandInteraction); expect(reply).toHaveBeenCalledWith("Pong!"); }); diff --git a/tests/commands/sign-up.test.ts b/tests/commands/sign-up.test.ts index ef516c2..03d05f7 100644 --- a/tests/commands/sign-up.test.ts +++ b/tests/commands/sign-up.test.ts @@ -20,15 +20,16 @@ describe("signUpCommand", () => { (getTourSchedule as jest.Mock).mockReturnValue(queue); }); - function createInteraction(action: string): any { + function createInteraction(action: string) { return { inGuild: () => true, guildId: "guild-1", user: { id: "user-1" }, guild: { id: "guild-1" }, memberPermissions: { - has: jest.fn((permission: bigint) => - permission === PermissionFlagsBits.ManageChannels, + has: jest.fn( + (permission: bigint) => + permission === PermissionFlagsBits.ManageChannels, ), }, options: { diff --git a/tests/integration/mileage-engine-flow.test.ts b/tests/integration/mileage-engine-flow.test.ts index 239d552..6a689f7 100644 --- a/tests/integration/mileage-engine-flow.test.ts +++ b/tests/integration/mileage-engine-flow.test.ts @@ -1,3 +1,5 @@ +import { GuildMember } from "discord.js"; + const mockClient = { query: jest.fn(), release: jest.fn(), @@ -53,7 +55,7 @@ describe("MileageEngine flow", () => { }), }, }, - } as any; + } as unknown as GuildMember; const engine = new MileageEngine({ databaseUrl: "postgres://test", diff --git a/tests/integration/tour-schedule-flow.test.ts b/tests/integration/tour-schedule-flow.test.ts index a34e36d..6630d16 100644 --- a/tests/integration/tour-schedule-flow.test.ts +++ b/tests/integration/tour-schedule-flow.test.ts @@ -1,4 +1,4 @@ -import { ChannelType } from "discord.js"; +import { ChannelType, Guild } from "discord.js"; import { TourScheduleEngine } from "../../src/tour-schedule"; describe("TourScheduleEngine flow", () => { @@ -15,26 +15,24 @@ describe("TourScheduleEngine flow", () => { }), }, channels: { - fetch: jest - .fn() - .mockImplementation(async (channelId: string) => { - if (channelId === "stage-1") { - return { - type: ChannelType.GuildStageVoice, - }; - } + fetch: jest.fn().mockImplementation(async (channelId: string) => { + if (channelId === "stage-1") { + return { + type: ChannelType.GuildStageVoice, + }; + } - if (channelId === "announce-1") { - return { - isSendable: () => true, - send, - }; - } + if (channelId === "announce-1") { + return { + isSendable: () => true, + send, + }; + } - return null; - }), + return null; + }), }, - } as any; + } as unknown as Guild; const engine = new TourScheduleEngine({ stageChannelId: "stage-1",