2026-03-04 18:07:18 +01:00
|
|
|
import { GraphQLError } from "graphql";
|
2026-03-04 18:42:58 +01:00
|
|
|
import { builder } from "../builder";
|
|
|
|
|
import { RecordingType } from "../types/index";
|
|
|
|
|
import { recordings, recording_plays } from "../../db/schema/index";
|
2026-03-04 18:07:18 +01:00
|
|
|
import { eq, and, desc } from "drizzle-orm";
|
2026-03-04 18:42:58 +01:00
|
|
|
import { slugify } from "../../lib/slugify";
|
|
|
|
|
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
2026-03-04 18:07:18 +01:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
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<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 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
|
|
|
|
|
.update(recordings)
|
|
|
|
|
.set({ status: "archived", date_updated: new Date() })
|
|
|
|
|
.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;
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|