import { GraphQLError } from "graphql"; import { builder } from "../builder"; import { RecordingType } from "../types/index"; import { recordings, recording_plays } from "../../db/schema/index"; import { eq, and, desc, ne } from "drizzle-orm"; import { slugify } from "../../lib/slugify"; import { awardPoints, checkAchievements } from "../../lib/gamification"; 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 any)); else conditions.push(ne(recordings.status, "archived" as any)); 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 any) || "draft", public: false, }) .returning(); const recording = newRecording[0]; // Gamification: award points if published if (recording.status === "published") { await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id); await checkAchievements(ctx.db, ctx.currentUser.id, "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 = { 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 any) .where(eq(recordings.id, args.id)) .returning(); const recording = updated[0]; // Gamification: if newly published if (args.status === "published" && existing[0].status !== "published") { await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id); await checkAchievements(ctx.db, ctx.currentUser.id, "recordings"); } if (args.status === "published" && recording.featured && !existing[0].featured) { await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id); await checkAchievements(ctx.db, ctx.currentUser.id, "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 }); // Gamification if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) { await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId); await checkAchievements(ctx.db, ctx.currentUser.id, "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 awardPoints( ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id, ); await checkAchievements(ctx.db, ctx.currentUser.id, "playback"); } return true; }, }), );