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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# PostgreSQL connection string for the database
DATABASE_URL="postgres://r00t:t00r@localhost:5432/postgres"
# Max postgres pool size per process (default 10)
DATABASE_POOL_MAX=10

# Redis connection string
REDIS_URL="redis://localhost:6379/0"

# BullMQ worker concurrency per shard (default 4)
WORKER_CONCURRENCY=4

# Discord application details
DISCORD_APPLICATION_ID=""
DISCORD_APPLICATION_PUBLIC_KEY=""
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ ENV NODE_ENV=production

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${PORT:-3000}/api/health" || exit 1

ENTRYPOINT ["./docker-entrypoint.sh"]
48 changes: 48 additions & 0 deletions drizzle/0000_loud_mother_askani.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
CREATE TYPE "public"."DiscordActivityType" AS ENUM('Custom', 'Listening', 'Streaming', 'Playing');--> statement-breakpoint
CREATE TYPE "public"."MotivationFrequency" AS ENUM('Daily', 'Weekly', 'Monthly');--> statement-breakpoint
CREATE TYPE "public"."SuggestionStatus" AS ENUM('Pending', 'Approved', 'Rejected');--> statement-breakpoint
CREATE TABLE "DiscordActivity" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"activity" text NOT NULL,
"type" "DiscordActivityType" DEFAULT 'Custom' NOT NULL,
"url" text,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "Guild" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guildId" text NOT NULL,
"motivationChannelId" text,
"motivationFrequency" "MotivationFrequency" DEFAULT 'Daily' NOT NULL,
"motivationTime" text DEFAULT '08:00' NOT NULL,
"motivationDay" integer,
"timezone" text DEFAULT 'America/Chicago' NOT NULL,
"lastMotivationSentAt" timestamp,
"isPremium" boolean DEFAULT false NOT NULL,
"joinedAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "Guild_guildId_unique" UNIQUE("guildId")
);
--> statement-breakpoint
CREATE TABLE "MotivationQuote" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"quote" text NOT NULL,
"author" text NOT NULL,
"addedBy" text NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "SuggestionQuote" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"quote" text NOT NULL,
"author" text NOT NULL,
"addedBy" text NOT NULL,
"status" "SuggestionStatus" DEFAULT 'Pending' NOT NULL,
"reviewedBy" text,
"reviewedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "guild_motivation_channel_idx" ON "Guild" USING btree ("motivationChannelId");--> statement-breakpoint
CREATE INDEX "suggestion_status_idx" ON "SuggestionQuote" USING btree ("status");
60 changes: 58 additions & 2 deletions src/api/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,65 @@
import express from "express";

import { queryClient } from "../../database/index.js";
import redisClient from "../../redis/index.js";
import env from "../../utils/env.js";
import logger from "../../utils/logger.js";

const router: express.Router = express.Router();

router.get("/", (_req, res) => {
res.json({ status: "ok" });
const PROBE_TIMEOUT_MS = 1500;

/**
* Note: on timeout the underlying probe query keeps running until the driver
* gives up (postgres-js `connect_timeout: 10`, ioredis default command behavior).
* The health endpoint is expected to be called infrequently (Coolify/k8s probe
* cadence, seconds-apart), so a backed-up probe is tolerable. If this endpoint
* ever moves to high-QPS monitoring, switch to `postgres().cancel()` on the
* pending query and a dedicated ioredis connection with command timeout.
*/
function withTimeout<T>(promise: PromiseLike<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
Promise.resolve(promise).then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
}
);
});
}
Comment on lines +20 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

withTimeout does not cancel the underlying probe.

On timeout the outer promise rejects but the queryClient\SELECT 1`query andredisClient.ping() keep running in the background, still holding a pool connection until the driver itself gives up (connect_timeout: 10` on Postgres). Under a real DB stall, a burst of health calls can pile up pending queries and make the bad state worse rather than surface it quickly.

Not a blocker given health checks are typically low-QPS, but worth being aware of — especially if this endpoint is wired up to a liveness probe that runs often. For postgres-js you can call .cancel() on the pending query, and for ioredis you can send ping() on a dedicated connection with a short command timeout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/routes/health.ts` around lines 10 - 24, The withTimeout wrapper
currently only rejects the outer promise but does not cancel the underlying
probe, so long-running queries keep holding resources; update withTimeout to
accept or produce a cancellation hook and use it on timeout (e.g., when wrapping
queryClient `query()` capture the returned query object and call its `.cancel()`
on timeout, and when wrapping `redisClient.ping()` use a dedicated Redis
connection or a command timeout and explicitly close or abort that command on
timeout). Locate the withTimeout function and change its signature to allow
passing in a cancel callback or the cancellable object, ensure the timeout
handler invokes that cancel (calling the postgres-js `.cancel()` on the pending
query and closing/aborting the dedicated ioredis command/connection), and make
sure to clear the timeout when the underlying operation completes or errors.


router.get("/", async (_req, res) => {
const [dbResult, redisResult] = await Promise.allSettled([
withTimeout(queryClient`SELECT 1`, PROBE_TIMEOUT_MS, "db"),
withTimeout(redisClient.ping(), PROBE_TIMEOUT_MS, "redis"),
]);

const db = dbResult.status === "fulfilled" ? "ok" : "error";
const redis = redisResult.status === "fulfilled" ? "ok" : "error";
const status = db === "ok" && redis === "ok" ? "ok" : "degraded";

const body: Record<string, unknown> = { status, db, redis };
const includeDetails = env.NODE_ENV !== "production";

if (dbResult.status === "rejected") {
logger.error("API", "Health probe failed (db)", dbResult.reason);
if (includeDetails) {
body["dbError"] = (dbResult.reason as Error)?.message ?? String(dbResult.reason);
}
}
if (redisResult.status === "rejected") {
logger.error("API", "Health probe failed (redis)", redisResult.reason);
if (includeDetails) {
body["redisError"] = (redisResult.reason as Error)?.message ?? String(redisResult.reason);
}
}

res.status(status === "ok" ? 200 : 503).json(body);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

export default router;
74 changes: 58 additions & 16 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@ import redis from "./redis/index.js";
import env from "./utils/env.js";
import logger from "./utils/logger.js";

/**
* Load environment variables from .env file.
*/
config();

/**
* Verify database connectivity via a simple query.
*/
let redisReady = false;

queryClient`SELECT 1`
.then(() => {
logger.database.connected("PostgreSQL");
Expand All @@ -23,16 +19,22 @@ queryClient`SELECT 1`
process.exit(1);
});

/**
* Load Redis connection and connect to Redis Server if failed to connect, throw error.
*/
redis
.on("connect", () => {
.on("ready", () => {
redisReady = true;
logger.database.connected("Redis");
})
.on("end", () => {
logger.warn("Database", "Redis connection closed");
})
.on("error", (err: Error) => {
logger.database.error("Redis", err);
process.exit(1);
// ioredis emits transient errors during reconnect attempts; only escalate
// if we never managed to connect at all.
if (!redisReady) {
logger.database.error("Redis", err);
} else {
logger.warn("Database", `Redis transient error: ${err.message}`);
}
});

const server = api.listen(api.get("port"), () => {
Expand All @@ -44,12 +46,10 @@ server.on("error", (err: unknown) => {
process.exit(1);
});

/**
* Discord.js Sharding Manager
*/
const manager = new ShardingManager("./src/bot.ts", {
token: env.DISCORD_APPLICATION_BOT_TOKEN,
totalShards: "auto",
respawn: true,
});

manager.on("shardCreate", (shard) => {
Expand All @@ -60,4 +60,46 @@ manager.on("shardCreate", (shard) => {
}
});

manager.spawn();
void manager.spawn();

let shuttingDown = false;

async function shutdown(signal: string): Promise<void> {
if (shuttingDown) {return;}
shuttingDown = true;
logger.info("App", `Received ${signal}, shutting down gracefully`);

// Stop accepting new HTTP work first.
await new Promise<void>((resolve) => {
server.close(() => resolve());
setTimeout(resolve, 5000).unref();
});
logger.info("App", "HTTP server closed");

// Tell every shard to log out cleanly.
try {
await Promise.all(manager.shards.map((s) => s.kill()));
logger.info("App", "Shards terminated");
} catch (err) {
logger.warn("App", "Error terminating shards", { error: err });
}

try {
await queryClient.end({ timeout: 5 });
logger.info("App", "Postgres pool closed");
} catch (err) {
logger.warn("App", "Error closing Postgres", { error: err });
}

try {
redis.disconnect();
logger.info("App", "Redis disconnected");
} catch (err) {
logger.warn("App", "Error disconnecting Redis", { error: err });
}

process.exit(0);
}

process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
19 changes: 14 additions & 5 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,25 @@ client.login(env.DISCORD_APPLICATION_BOT_TOKEN);
* Initialize BullMQ worker to handle background jobs.
*/
import { Queue } from "bullmq";
import worker from "./worker/index.js";
import type { ConnectionOptions } from "bullmq";
import startWorker from "./worker/index.js";
import redisClient from "./redis/index.js";

const queueName = "fluffboost-jobs";

const queue = new Queue(queueName, {
connection: env.REDIS_URL
? { url: env.REDIS_URL }
: { host: "localhost", port: 6379 },
connection: redisClient as unknown as ConnectionOptions,
});

worker(queue);
// Gate worker startup on ClientReady. Otherwise BullMQ can dequeue jobs
// (e.g. send-motivation) before Discord login completes, causing
// client.channels.fetch / client.users.fetch calls inside job handlers to
// fail against an un-authenticated client.
client.once(Events.ClientReady, () => {
startWorker(queue).catch((err) => {
logger.error("Worker", "Failed to start worker", err);
process.exit(1);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default client;
69 changes: 14 additions & 55 deletions src/commands/admin/activity/list.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,33 @@
import { Client, CommandInteraction, MessageFlags } from "discord.js";
import type { Client, CommandInteraction } from "discord.js";

import { desc } from "drizzle-orm";

import type { DiscordActivity } from "../../../database/schema.js";

import logger from "../../../utils/logger.js";
import { safeErrorReply } from "../../../utils/commandErrors.js";
import { withCommandLogging } from "../../../utils/commandErrors.js";
import { isUserPermitted } from "../../../utils/permissions.js";
import { db } from "../../../database/index.js";
import { discordActivities } from "../../../database/schema.js";
import { replyWithTextFile } from "../../../utils/replyHelpers.js";

export default async function (
_client: Client,
interaction: CommandInteraction
): Promise<void> {
try {
logger.commands.executing(
"admin activity list",
interaction.user.username,
interaction.user.id
);

const isAllowed = await isUserPermitted(interaction);

if (!isAllowed) {
return;
}
await withCommandLogging("admin activity list", interaction, async () => {
if (!(await isUserPermitted(interaction))) {return;}

const activities = await db
.select()
.from(discordActivities)
.orderBy(desc(discordActivities.createdAt));

if (activities.length === 0) {
await interaction.reply({
content: "No activities found at the moment. Feel free to add some!",
flags: MessageFlags.Ephemeral,
});
return;
}

let text = "ID - Activity - Type - URL\n";
activities.forEach((activity: DiscordActivity) => {
text += `${activity.id} - ${activity.activity} - ${activity.type} - ${
activity.url || "N/A"
}\n`;
await replyWithTextFile({
interaction,
rows: activities,
header: "ID - Activity - Type - URL",
formatRow: (a) => `${a.id} - ${a.activity} - ${a.type} - ${a.url || "N/A"}`,
filename: "activities.txt",
emptyMessage: "No activities found at the moment. Feel free to add some!",
ephemeral: false,
});

await interaction.reply({
files: [
{
attachment: Buffer.from(text),
name: "activities.txt",
},
],
});

logger.commands.success(
"admin activity list",
interaction.user.username,
interaction.user.id
);
} catch (err) {
logger.commands.error(
"admin activity list",
interaction.user.username,
interaction.user.id,
err
);

await safeErrorReply(interaction);
}
});
}
Loading
Loading