feat: Add deployment guide and update README for Coolify setup; include dotenv for environment variable management
This commit is contained in:
+138
@@ -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.
|
||||||
@@ -83,6 +83,41 @@ This project uses the arc42 template. Chapters are in `docs/`.
|
|||||||
- npm 10+
|
- npm 10+
|
||||||
- Discord Developer Application (bot token + OAuth2 app)
|
- Discord Developer Application (bot token + OAuth2 app)
|
||||||
- PostgreSQL instance
|
- 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
|
### Installation
|
||||||
|
|
||||||
@@ -155,7 +190,7 @@ npm run db:setup
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `db:migrate`: applies schema migrations tracked in `schema_migrations`
|
- `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
|
- `db:setup`: runs migrate then seed
|
||||||
|
|
||||||
### Runtime Config Source
|
### Runtime Config Source
|
||||||
@@ -173,6 +208,16 @@ Recommended flow:
|
|||||||
3. Run `npm run db:seed` once while legacy env vars are still present.
|
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.
|
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:
|
Dashboard quality check:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -201,16 +246,7 @@ npm run build
|
|||||||
|
|
||||||
Pipeline file: `.gitea/workflows/ci-cd.yml`
|
Pipeline file: `.gitea/workflows/ci-cd.yml`
|
||||||
|
|
||||||
Flow:
|
For full deployment flow, webhook secret strategy, and two-resource Coolify deployment, see `DEPLOYMENT.md`.
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
Generated
+13
@@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "^14.26.4",
|
"discord.js": "^14.26.4",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"pg": "^8.20.0"
|
"pg": "^8.20.0"
|
||||||
},
|
},
|
||||||
@@ -3131,6 +3132,18 @@
|
|||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "^14.26.4",
|
"discord.js": "^14.26.4",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"pg": "^8.20.0"
|
"pg": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "dotenv/config";
|
||||||
import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations";
|
import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -10,6 +11,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "seed") {
|
if (command === "seed") {
|
||||||
|
await runMigrations();
|
||||||
const inserted = await seedInitialConfig();
|
const inserted = await seedInitialConfig();
|
||||||
console.log(`Database seed complete. Inserted ${inserted} config entries.`);
|
console.log(`Database seed complete. Inserted ${inserted} config entries.`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "dotenv/config";
|
||||||
import { REST, Routes } from "discord.js";
|
import { REST, Routes } from "discord.js";
|
||||||
import { commands } from "./commands";
|
import { commands } from "./commands";
|
||||||
import { loadRuntimeConfig } from "./config";
|
import { loadRuntimeConfig } from "./config";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "dotenv/config";
|
||||||
import { startBot } from "./bot";
|
import { startBot } from "./bot";
|
||||||
import { loadRuntimeConfig } from "./config";
|
import { loadRuntimeConfig } from "./config";
|
||||||
import { runMigrations } from "./db/migrations";
|
import { runMigrations } from "./db/migrations";
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
import { pingCommand } from "../../src/commands/ping";
|
import { pingCommand } from "../../src/commands/ping";
|
||||||
|
|
||||||
describe("pingCommand", () => {
|
describe("pingCommand", () => {
|
||||||
it("replies with Pong", async () => {
|
it("replies with Pong", async () => {
|
||||||
const reply = jest.fn().mockResolvedValue(undefined);
|
const reply = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await pingCommand.execute({ reply } as any);
|
await pingCommand.execute({
|
||||||
|
reply,
|
||||||
|
} as unknown as ChatInputCommandInteraction);
|
||||||
|
|
||||||
expect(reply).toHaveBeenCalledWith("Pong!");
|
expect(reply).toHaveBeenCalledWith("Pong!");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,15 +20,16 @@ describe("signUpCommand", () => {
|
|||||||
(getTourSchedule as jest.Mock).mockReturnValue(queue);
|
(getTourSchedule as jest.Mock).mockReturnValue(queue);
|
||||||
});
|
});
|
||||||
|
|
||||||
function createInteraction(action: string): any {
|
function createInteraction(action: string) {
|
||||||
return {
|
return {
|
||||||
inGuild: () => true,
|
inGuild: () => true,
|
||||||
guildId: "guild-1",
|
guildId: "guild-1",
|
||||||
user: { id: "user-1" },
|
user: { id: "user-1" },
|
||||||
guild: { id: "guild-1" },
|
guild: { id: "guild-1" },
|
||||||
memberPermissions: {
|
memberPermissions: {
|
||||||
has: jest.fn((permission: bigint) =>
|
has: jest.fn(
|
||||||
permission === PermissionFlagsBits.ManageChannels,
|
(permission: bigint) =>
|
||||||
|
permission === PermissionFlagsBits.ManageChannels,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { GuildMember } from "discord.js";
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
query: jest.fn(),
|
query: jest.fn(),
|
||||||
release: jest.fn(),
|
release: jest.fn(),
|
||||||
@@ -53,7 +55,7 @@ describe("MileageEngine flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as unknown as GuildMember;
|
||||||
|
|
||||||
const engine = new MileageEngine({
|
const engine = new MileageEngine({
|
||||||
databaseUrl: "postgres://test",
|
databaseUrl: "postgres://test",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChannelType } from "discord.js";
|
import { ChannelType, Guild } from "discord.js";
|
||||||
import { TourScheduleEngine } from "../../src/tour-schedule";
|
import { TourScheduleEngine } from "../../src/tour-schedule";
|
||||||
|
|
||||||
describe("TourScheduleEngine flow", () => {
|
describe("TourScheduleEngine flow", () => {
|
||||||
@@ -15,26 +15,24 @@ describe("TourScheduleEngine flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
channels: {
|
channels: {
|
||||||
fetch: jest
|
fetch: jest.fn().mockImplementation(async (channelId: string) => {
|
||||||
.fn()
|
if (channelId === "stage-1") {
|
||||||
.mockImplementation(async (channelId: string) => {
|
return {
|
||||||
if (channelId === "stage-1") {
|
type: ChannelType.GuildStageVoice,
|
||||||
return {
|
};
|
||||||
type: ChannelType.GuildStageVoice,
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelId === "announce-1") {
|
if (channelId === "announce-1") {
|
||||||
return {
|
return {
|
||||||
isSendable: () => true,
|
isSendable: () => true,
|
||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as any;
|
} as unknown as Guild;
|
||||||
|
|
||||||
const engine = new TourScheduleEngine({
|
const engine = new TourScheduleEngine({
|
||||||
stageChannelId: "stage-1",
|
stageChannelId: "stage-1",
|
||||||
|
|||||||
Reference in New Issue
Block a user