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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user