- 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>
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
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;
|
|
},
|
|
}),
|
|
);
|