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

@@ -3,8 +3,8 @@ import { builder } from "../builder";
import { CommentType, AdminCommentListType } from "../types/index";
import { comments, users } from "../../db/schema/index";
import { eq, and, desc, ilike, count } from "drizzle-orm";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("commentsForVideo", (t) =>
t.field({
@@ -59,10 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
})
.returning();
// Gamification (non-blocking)
awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE")
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "social"))
.catch((e) => console.error("Gamification error on comment:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "COMMENT_CREATE",
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "social",
});
const user = await ctx.db
.select({

View File

@@ -4,8 +4,8 @@ import { RecordingType, AdminRecordingListType } from "../types/index";
import { recordings, recording_plays } from "../../db/schema/index";
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
import { slugify } from "../../lib/slugify";
import { awardPoints, checkAchievements } from "../../lib/gamification";
import { requireAdmin } from "../../lib/acl";
import { gamificationQueue } from "../../queues/index";
builder.queryField("recordings", (t) =>
t.field({
@@ -122,11 +122,18 @@ builder.mutationField("createRecording", (t) =>
const recording = newRecording[0];
// Gamification (non-blocking)
if (recording.status === "published") {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
.catch((e) => console.error("Gamification error on recording create:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
}
return recording;
@@ -180,15 +187,45 @@ builder.mutationField("updateRecording", (t) =>
const recording = updated[0];
// Gamification (non-blocking)
if (args.status === "published" && existing[0].status !== "published") {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
.catch((e) => console.error("Gamification error on recording publish:", e));
// draft → published: award creation points
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} else if (args.status === "draft" && existing[0].status === "published") {
// published → draft: revoke creation points
await gamificationQueue.add("revokePoints", {
job: "revokePoints",
userId: ctx.currentUser.id,
action: "RECORDING_CREATE",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
.catch((e) => console.error("Gamification error on recording feature:", e));
// newly featured while published: award featured bonus
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_FEATURED",
recordingId: recording.id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "recordings",
});
}
return recording;
@@ -290,11 +327,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
})
.returning({ id: recording_plays.id });
// Gamification (non-blocking)
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
.catch((e) => console.error("Gamification error on recording play:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_PLAY",
recordingId: args.recordingId,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "playback",
});
}
return { success: true, play_id: play[0].id };
@@ -329,11 +373,18 @@ builder.mutationField("updateRecordingPlay", (t) =>
})
.where(eq(recording_plays.id, args.playId));
// Gamification (non-blocking)
if (args.completed && !wasCompleted && ctx.currentUser) {
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id)
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
.catch((e) => console.error("Gamification error on recording complete:", e));
await gamificationQueue.add("awardPoints", {
job: "awardPoints",
userId: ctx.currentUser.id,
action: "RECORDING_COMPLETE",
recordingId: existing[0].recording_id,
});
await gamificationQueue.add("checkAchievements", {
job: "checkAchievements",
userId: ctx.currentUser.id,
category: "playback",
});
}
return true;