feat: add BullMQ job queue with admin monitoring UI
All checks were successful
Build and Push Backend Image / build (push) Successful in 48s
Build and Push Buttplug Image / build (push) Successful in 3m26s
Build and Push Frontend Image / build (push) Successful in 1m11s

- Add BullMQ to backend; mail jobs (verification, password reset) now enqueued instead of sent inline
- Mail worker processes jobs with 3-attempt exponential backoff retry
- Admin GraphQL resolvers: adminQueues, adminQueueJobs, adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue
- Admin frontend page at /admin/queues: queue cards with counts, job table with status filter, retry/remove/pause actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 18:25:09 +01:00
parent 6dcdc0130b
commit ea23233645
16 changed files with 927 additions and 6 deletions

View File

@@ -0,0 +1,153 @@
import { GraphQLError } from "graphql";
import type { Job } from "bullmq";
import { builder } from "../builder.js";
import { JobType, QueueInfoType } from "../types/index.js";
import { queues } from "../../queues/index.js";
import { requireAdmin } from "../../lib/acl.js";
const JOB_STATUSES = ["waiting", "active", "completed", "failed", "delayed"] as const;
type JobStatus = (typeof JOB_STATUSES)[number];
async function toJobData(job: Job, queueName: string) {
const status = await job.getState();
return {
id: job.id ?? "",
name: job.name,
queue: queueName,
status,
data: job.data as unknown,
result: job.returnvalue as unknown,
failedReason: job.failedReason ?? null,
attemptsMade: job.attemptsMade,
createdAt: new Date(job.timestamp),
processedAt: job.processedOn ? new Date(job.processedOn) : null,
finishedAt: job.finishedOn ? new Date(job.finishedOn) : null,
progress: typeof job.progress === "number" ? job.progress : null,
};
}
builder.queryField("adminQueues", (t) =>
t.field({
type: [QueueInfoType],
resolve: async (_root, _args, ctx) => {
requireAdmin(ctx);
return Promise.all(
Object.entries(queues).map(async ([name, queue]) => {
const counts = await queue.getJobCounts(
"waiting",
"active",
"completed",
"failed",
"delayed",
"paused",
);
const isPaused = await queue.isPaused();
return {
name,
counts: {
waiting: counts.waiting ?? 0,
active: counts.active ?? 0,
completed: counts.completed ?? 0,
failed: counts.failed ?? 0,
delayed: counts.delayed ?? 0,
paused: counts.paused ?? 0,
},
isPaused,
};
}),
);
},
}),
);
builder.queryField("adminQueueJobs", (t) =>
t.field({
type: [JobType],
args: {
queue: t.arg.string({ required: true }),
status: t.arg.string(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const limit = args.limit ?? 25;
const offset = args.offset ?? 0;
const statuses: JobStatus[] = args.status
? [args.status as JobStatus]
: [...JOB_STATUSES];
const jobs = await queue.getJobs(statuses, offset, offset + limit - 1);
return Promise.all(jobs.map((job) => toJobData(job, args.queue)));
},
}),
);
builder.mutationField("adminRetryJob", (t) =>
t.field({
type: "Boolean",
args: {
queue: t.arg.string({ required: true }),
jobId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const job = await queue.getJob(args.jobId);
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
await job.retry();
return true;
},
}),
);
builder.mutationField("adminRemoveJob", (t) =>
t.field({
type: "Boolean",
args: {
queue: t.arg.string({ required: true }),
jobId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
const job = await queue.getJob(args.jobId);
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
await job.remove();
return true;
},
}),
);
builder.mutationField("adminPauseQueue", (t) =>
t.field({
type: "Boolean",
args: { queue: t.arg.string({ required: true }) },
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
await queue.pause();
return true;
},
}),
);
builder.mutationField("adminResumeQueue", (t) =>
t.field({
type: "Boolean",
args: { queue: t.arg.string({ required: true }) },
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const queue = queues[args.queue];
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
await queue.resume();
return true;
},
}),
);