feat: replace Directus with custom Node.js GraphQL backend
Removes Directus 11 and replaces it with a lean, purpose-built backend: - packages/backend/: Fastify v5 + GraphQL Yoga v5 + Pothos (code-first) with Drizzle ORM, Redis sessions (session_token cookie), argon2 auth, Nodemailer, fluent-ffmpeg, and full gamification system ported from bundle - Frontend: @directus/sdk replaced by graphql-request v7; services.ts fully rewritten with identical signatures; directus.ts now re-exports from api.ts - Cookie renamed directus_session_token → session_token - Dev proxy target updated 8055 → 4000 - compose.yml: Directus service removed, backend service added (port 4000) - Dockerfile.backend: new multi-stage image with ffmpeg - Dockerfile: bundle build step and ffmpeg removed from frontend image - data-migration.ts: one-time script to migrate all Directus/sexy_ tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
333
packages/backend/src/graphql/resolvers/recordings.ts
Normal file
333
packages/backend/src/graphql/resolvers/recordings.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { RecordingType } from "../types/index.js";
|
||||
import { recordings, recording_plays } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { slugify } from "../../lib/slugify.js";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
|
||||
|
||||
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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user