- 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>
154 lines
4.4 KiB
TypeScript
154 lines
4.4 KiB
TypeScript
import { GraphQLError } from "graphql";
|
|
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 { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
|
import { gamificationQueue } from "../../queues/index";
|
|
|
|
builder.queryField("commentsForVideo", (t) =>
|
|
t.field({
|
|
type: [CommentType],
|
|
args: {
|
|
videoId: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
const commentList = await ctx.db
|
|
.select()
|
|
.from(comments)
|
|
.where(and(eq(comments.collection, "videos"), eq(comments.item_id, args.videoId)))
|
|
.orderBy(desc(comments.date_created));
|
|
|
|
return Promise.all(
|
|
commentList.map(async (c) => {
|
|
const user = await ctx.db
|
|
.select({
|
|
id: users.id,
|
|
first_name: users.first_name,
|
|
last_name: users.last_name,
|
|
artist_name: users.artist_name,
|
|
avatar: users.avatar,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, c.user_id))
|
|
.limit(1);
|
|
return { ...c, user: user[0] || null };
|
|
}),
|
|
);
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("createCommentForVideo", (t) =>
|
|
t.field({
|
|
type: CommentType,
|
|
args: {
|
|
videoId: t.arg.string({ required: true }),
|
|
comment: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const newComment = await ctx.db
|
|
.insert(comments)
|
|
.values({
|
|
collection: "videos",
|
|
item_id: args.videoId,
|
|
comment: args.comment,
|
|
user_id: ctx.currentUser.id,
|
|
})
|
|
.returning();
|
|
|
|
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({
|
|
id: users.id,
|
|
first_name: users.first_name,
|
|
last_name: users.last_name,
|
|
artist_name: users.artist_name,
|
|
avatar: users.avatar,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, ctx.currentUser.id))
|
|
.limit(1);
|
|
|
|
return { ...newComment[0], user: user[0] || null };
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("deleteComment", (t) =>
|
|
t.field({
|
|
type: "Boolean",
|
|
args: {
|
|
id: t.arg.int({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
const comment = await ctx.db.select().from(comments).where(eq(comments.id, args.id)).limit(1);
|
|
if (!comment[0]) throw new GraphQLError("Comment not found");
|
|
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
|
await ctx.db.delete(comments).where(eq(comments.id, args.id));
|
|
return true;
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.queryField("adminListComments", (t) =>
|
|
t.field({
|
|
type: AdminCommentListType,
|
|
args: {
|
|
search: t.arg.string(),
|
|
limit: t.arg.int(),
|
|
offset: t.arg.int(),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
requireAdmin(ctx);
|
|
const limit = args.limit ?? 50;
|
|
const offset = args.offset ?? 0;
|
|
|
|
const conditions = args.search ? [ilike(comments.comment, `%${args.search}%`)] : [];
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const [commentList, totalRows] = await Promise.all([
|
|
ctx.db
|
|
.select()
|
|
.from(comments)
|
|
.where(where)
|
|
.orderBy(desc(comments.date_created))
|
|
.limit(limit)
|
|
.offset(offset),
|
|
ctx.db.select({ total: count() }).from(comments).where(where),
|
|
]);
|
|
|
|
const items = await Promise.all(
|
|
commentList.map(async (c) => {
|
|
const user = await ctx.db
|
|
.select({
|
|
id: users.id,
|
|
first_name: users.first_name,
|
|
last_name: users.last_name,
|
|
artist_name: users.artist_name,
|
|
avatar: users.avatar,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, c.user_id))
|
|
.limit(1);
|
|
return { ...c, user: user[0] || null };
|
|
}),
|
|
);
|
|
|
|
return { items, total: totalRows[0]?.total ?? 0 };
|
|
},
|
|
}),
|
|
);
|