- 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>
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
import { GraphQLError } from "graphql";
|
|
import { builder } from "../builder";
|
|
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 { requireAdmin } from "../../lib/acl";
|
|
import { gamificationQueue } from "../../queues/index";
|
|
|
|
builder.queryField("recordings", (t) =>
|
|
t.field({
|
|
type: [RecordingType],
|
|
args: {
|
|
status: t.arg.string(),
|
|
tags: t.arg.string(),
|
|
linkedVideoId: t.arg.string(),
|
|
limit: t.arg.int(),
|
|
page: t.arg.int(),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
|
|
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
|
|
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
|
|
|
const limit = args.limit || 50;
|
|
const page = args.page || 1;
|
|
const offset = (page - 1) * limit;
|
|
|
|
return ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(recordings.date_created))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.queryField("recording", (t) =>
|
|
t.field({
|
|
type: RecordingType,
|
|
nullable: true,
|
|
args: {
|
|
id: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const recording = await ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(eq(recordings.id, args.id))
|
|
.limit(1);
|
|
|
|
if (!recording[0]) return null;
|
|
|
|
if (recording[0].user_id !== ctx.currentUser.id && !recording[0].public) {
|
|
throw new GraphQLError("Forbidden");
|
|
}
|
|
|
|
return recording[0];
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.queryField("communityRecordings", (t) =>
|
|
t.field({
|
|
type: [RecordingType],
|
|
args: {
|
|
limit: t.arg.int(),
|
|
offset: t.arg.int(),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
return ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(and(eq(recordings.status, "published"), eq(recordings.public, true)))
|
|
.orderBy(desc(recordings.date_created))
|
|
.limit(args.limit || 50)
|
|
.offset(args.offset || 0);
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("createRecording", (t) =>
|
|
t.field({
|
|
type: RecordingType,
|
|
args: {
|
|
title: t.arg.string({ required: true }),
|
|
description: t.arg.string(),
|
|
duration: t.arg.int({ required: true }),
|
|
events: t.arg({ type: "JSON", required: true }),
|
|
deviceInfo: t.arg({ type: "JSON", required: true }),
|
|
tags: t.arg.stringList(),
|
|
status: t.arg.string(),
|
|
linkedVideoId: t.arg.string(),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const slug = slugify(args.title);
|
|
|
|
const newRecording = await ctx.db
|
|
.insert(recordings)
|
|
.values({
|
|
title: args.title,
|
|
description: args.description || null,
|
|
slug,
|
|
duration: args.duration,
|
|
events: (args.events as object[]) || [],
|
|
device_info: (args.deviceInfo as object[]) || [],
|
|
user_id: ctx.currentUser.id,
|
|
tags: args.tags || [],
|
|
linked_video: args.linkedVideoId || null,
|
|
status: (args.status as "draft" | "published") || "draft",
|
|
public: false,
|
|
})
|
|
.returning();
|
|
|
|
const recording = newRecording[0];
|
|
|
|
if (recording.status === "published") {
|
|
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;
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("updateRecording", (t) =>
|
|
t.field({
|
|
type: RecordingType,
|
|
nullable: true,
|
|
args: {
|
|
id: t.arg.string({ required: true }),
|
|
title: t.arg.string(),
|
|
description: t.arg.string(),
|
|
tags: t.arg.stringList(),
|
|
status: t.arg.string(),
|
|
public: t.arg.boolean(),
|
|
linkedVideoId: t.arg.string(),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const existing = await ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(eq(recordings.id, args.id))
|
|
.limit(1);
|
|
|
|
if (!existing[0]) throw new GraphQLError("Recording not found");
|
|
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
|
|
|
const updates: Record<string, unknown> = { date_updated: new Date() };
|
|
if (args.title !== null && args.title !== undefined) {
|
|
updates.title = args.title;
|
|
updates.slug = slugify(args.title);
|
|
}
|
|
if (args.description !== null && args.description !== undefined)
|
|
updates.description = args.description;
|
|
if (args.tags !== null && args.tags !== undefined) updates.tags = args.tags;
|
|
if (args.status !== null && args.status !== undefined) updates.status = args.status;
|
|
if (args.public !== null && args.public !== undefined) updates.public = args.public;
|
|
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined)
|
|
updates.linked_video = args.linkedVideoId;
|
|
|
|
const updated = await ctx.db
|
|
.update(recordings)
|
|
.set(updates as Partial<typeof recordings.$inferInsert>)
|
|
.where(eq(recordings.id, args.id))
|
|
.returning();
|
|
|
|
const recording = updated[0];
|
|
|
|
if (args.status === "published" && existing[0].status !== "published") {
|
|
// 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) {
|
|
// 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;
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("deleteRecording", (t) =>
|
|
t.field({
|
|
type: "Boolean",
|
|
args: {
|
|
id: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const existing = await ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(eq(recordings.id, args.id))
|
|
.limit(1);
|
|
|
|
if (!existing[0]) throw new GraphQLError("Recording not found");
|
|
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
|
|
|
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
|
|
|
return true;
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("duplicateRecording", (t) =>
|
|
t.field({
|
|
type: RecordingType,
|
|
args: {
|
|
id: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
|
|
|
const original = await ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(eq(recordings.id, args.id))
|
|
.limit(1);
|
|
|
|
if (!original[0]) throw new GraphQLError("Recording not found");
|
|
if (original[0].status !== "published" || !original[0].public) {
|
|
throw new GraphQLError("Recording is not publicly shared");
|
|
}
|
|
|
|
const slug = `${slugify(original[0].title)}-copy-${Date.now()}`;
|
|
|
|
const duplicated = await ctx.db
|
|
.insert(recordings)
|
|
.values({
|
|
title: `${original[0].title} (Copy)`,
|
|
description: original[0].description,
|
|
slug,
|
|
duration: original[0].duration,
|
|
events: original[0].events || [],
|
|
device_info: original[0].device_info || [],
|
|
user_id: ctx.currentUser.id,
|
|
tags: original[0].tags || [],
|
|
status: "draft",
|
|
public: false,
|
|
original_recording_id: original[0].id,
|
|
})
|
|
.returning();
|
|
|
|
return duplicated[0];
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("recordRecordingPlay", (t) =>
|
|
t.field({
|
|
type: "JSON",
|
|
args: {
|
|
recordingId: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
const recording = await ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(eq(recordings.id, args.recordingId))
|
|
.limit(1);
|
|
|
|
if (!recording[0]) throw new GraphQLError("Recording not found");
|
|
|
|
const play = await ctx.db
|
|
.insert(recording_plays)
|
|
.values({
|
|
recording_id: args.recordingId,
|
|
user_id: ctx.currentUser?.id || null,
|
|
duration_played: 0,
|
|
completed: false,
|
|
})
|
|
.returning({ id: recording_plays.id });
|
|
|
|
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
|
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 };
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("updateRecordingPlay", (t) =>
|
|
t.field({
|
|
type: "Boolean",
|
|
args: {
|
|
playId: t.arg.string({ required: true }),
|
|
durationPlayed: t.arg.int({ required: true }),
|
|
completed: t.arg.boolean({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
const existing = await ctx.db
|
|
.select()
|
|
.from(recording_plays)
|
|
.where(eq(recording_plays.id, args.playId))
|
|
.limit(1);
|
|
|
|
if (!existing[0]) throw new GraphQLError("Play record not found");
|
|
const wasCompleted = existing[0].completed;
|
|
|
|
await ctx.db
|
|
.update(recording_plays)
|
|
.set({
|
|
duration_played: args.durationPlayed,
|
|
completed: args.completed,
|
|
date_updated: new Date(),
|
|
})
|
|
.where(eq(recording_plays.id, args.playId));
|
|
|
|
if (args.completed && !wasCompleted && ctx.currentUser) {
|
|
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;
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.queryField("adminListRecordings", (t) =>
|
|
t.field({
|
|
type: AdminRecordingListType,
|
|
args: {
|
|
search: t.arg.string(),
|
|
status: 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: SQL<unknown>[] = [];
|
|
if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`));
|
|
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const [rows, totalRows] = await Promise.all([
|
|
ctx.db
|
|
.select()
|
|
.from(recordings)
|
|
.where(where)
|
|
.orderBy(desc(recordings.date_created))
|
|
.limit(limit)
|
|
.offset(offset),
|
|
ctx.db.select({ total: count() }).from(recordings).where(where),
|
|
]);
|
|
|
|
return { items: rows, total: totalRows[0]?.total ?? 0 };
|
|
},
|
|
}),
|
|
);
|
|
|
|
builder.mutationField("adminDeleteRecording", (t) =>
|
|
t.field({
|
|
type: "Boolean",
|
|
args: {
|
|
id: t.arg.string({ required: true }),
|
|
},
|
|
resolve: async (_root, args, ctx) => {
|
|
requireAdmin(ctx);
|
|
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
|
return true;
|
|
},
|
|
}),
|
|
);
|