feat: gamification queue with deduplication and unpublish revoke

- Add migration 0004: partial unique index on user_points (user_id, action, recording_id)
  for RECORDING_CREATE and RECORDING_FEATURED to prevent earn-on-republish farming
- Add revokePoints() to gamification lib; awardPoints() now uses onConflictDoNothing
- Add gamificationQueue (BullMQ) with 3-attempt exponential backoff
- Add gamification worker handling awardPoints, revokePoints, checkAchievements jobs
- Move all inline gamification calls in recordings + comments resolvers to queue
- Revoke RECORDING_CREATE points when a recording is unpublished (published → draft)
- Register gamification worker at server startup alongside mail worker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 19:50:33 +01:00
parent 1b724e86c9
commit 5f40a812d3
8 changed files with 183 additions and 34 deletions

View File

@@ -0,0 +1,49 @@
import { Worker } from "bullmq";
import { redisConnectionOpts } from "../connection.js";
import { awardPoints, revokePoints, checkAchievements } from "../../lib/gamification.js";
import { db } from "../../db/connection.js";
import { logger } from "../../lib/logger.js";
import type { POINT_VALUES } from "../../lib/gamification.js";
const log = logger.child({ component: "gamification-worker" });
export type GamificationJobData =
| { job: "awardPoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId: string }
| { job: "checkAchievements"; userId: string; category?: string };
export function startGamificationWorker(): Worker {
const worker = new Worker(
"gamification",
async (bullJob) => {
const data = bullJob.data as GamificationJobData;
log.info({ jobId: bullJob.id, job: data.job, userId: data.userId }, "Processing gamification job");
switch (data.job) {
case "awardPoints":
await awardPoints(db, data.userId, data.action, data.recordingId);
break;
case "revokePoints":
await revokePoints(db, data.userId, data.action, data.recordingId);
break;
case "checkAchievements":
await checkAchievements(db, data.userId, data.category);
break;
default:
throw new Error(`Unknown gamification job: ${(data as GamificationJobData).job}`);
}
log.info({ jobId: bullJob.id, job: data.job }, "Gamification job completed");
},
{ connection: redisConnectionOpts },
);
worker.on("failed", (bullJob, err) => {
log.error(
{ jobId: bullJob?.id, job: (bullJob?.data as GamificationJobData)?.job, err: err.message },
"Gamification job failed",
);
});
return worker;
}