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:
2026-03-04 18:07:18 +01:00
parent de16b64255
commit 9d7afbe1b5
46 changed files with 4186 additions and 442 deletions

View File

@@ -0,0 +1,320 @@
import { GraphQLError } from "graphql";
import { builder } from "../builder.js";
import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index.js";
import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index.js";
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
async function enrichVideo(db: any, video: any) {
// Fetch models
const modelRows = await db
.select({
id: users.id,
artist_name: users.artist_name,
slug: users.slug,
avatar: users.avatar,
})
.from(video_models)
.leftJoin(users, eq(video_models.user_id, users.id))
.where(eq(video_models.video_id, video.id));
// Fetch movie file
let movieFile = null;
if (video.movie) {
const mf = await db.select().from(files).where(eq(files.id, video.movie)).limit(1);
movieFile = mf[0] || null;
}
// Count likes
const likesCount = await db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, video.id));
const playsCount = await db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, video.id));
return {
...video,
models: modelRows,
movie_file: movieFile,
likes_count: likesCount[0]?.count || 0,
plays_count: playsCount[0]?.count || 0,
};
}
builder.queryField("videos", (t) =>
t.field({
type: [VideoType],
args: {
modelId: t.arg.string(),
featured: t.arg.boolean(),
limit: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
let query = ctx.db
.select({ v: videos })
.from(videos)
.where(lte(videos.upload_date, new Date()))
.orderBy(desc(videos.upload_date));
if (args.modelId) {
const videoIds = await ctx.db
.select({ video_id: video_models.video_id })
.from(video_models)
.where(eq(video_models.user_id, args.modelId));
if (videoIds.length === 0) return [];
query = ctx.db
.select({ v: videos })
.from(videos)
.where(and(
lte(videos.upload_date, new Date()),
inArray(videos.id, videoIds.map((v: any) => v.video_id)),
))
.orderBy(desc(videos.upload_date));
}
if (args.featured !== null && args.featured !== undefined) {
query = ctx.db
.select({ v: videos })
.from(videos)
.where(and(
lte(videos.upload_date, new Date()),
eq(videos.featured, args.featured),
))
.orderBy(desc(videos.upload_date));
}
if (args.limit) {
query = (query as any).limit(args.limit);
}
const rows = await query;
const videoList = rows.map((r: any) => r.v || r);
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
},
}),
);
builder.queryField("video", (t) =>
t.field({
type: VideoType,
nullable: true,
args: {
slug: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
const video = await ctx.db
.select()
.from(videos)
.where(and(eq(videos.slug, args.slug), lte(videos.upload_date, new Date())))
.limit(1);
if (!video[0]) return null;
return enrichVideo(ctx.db, video[0]);
},
}),
);
builder.queryField("videoLikeStatus", (t) =>
t.field({
type: VideoLikeStatusType,
args: {
videoId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) return { liked: false };
const existing = await ctx.db
.select()
.from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
.limit(1);
return { liked: existing.length > 0 };
},
}),
);
builder.mutationField("likeVideo", (t) =>
t.field({
type: VideoLikeResponseType,
args: {
videoId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const existing = await ctx.db
.select()
.from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
.limit(1);
if (existing.length > 0) throw new GraphQLError("Already liked");
await ctx.db.insert(video_likes).values({
video_id: args.videoId,
user_id: ctx.currentUser.id,
});
await ctx.db
.update(videos)
.set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 })
.where(eq(videos.id, args.videoId));
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
return { liked: true, likes_count: likesCount[0]?.count || 1 };
},
}),
);
builder.mutationField("unlikeVideo", (t) =>
t.field({
type: VideoLikeResponseType,
args: {
videoId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const existing = await ctx.db
.select()
.from(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
.limit(1);
if (existing.length === 0) throw new GraphQLError("Not liked");
await ctx.db
.delete(video_likes)
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)));
await ctx.db
.update(videos)
.set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) })
.where(eq(videos.id, args.videoId));
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
return { liked: false, likes_count: likesCount[0]?.count || 0 };
},
}),
);
builder.mutationField("recordVideoPlay", (t) =>
t.field({
type: VideoPlayResponseType,
args: {
videoId: t.arg.string({ required: true }),
sessionId: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
const play = await ctx.db.insert(video_plays).values({
video_id: args.videoId,
user_id: ctx.currentUser?.id || null,
session_id: args.sessionId || null,
}).returning({ id: video_plays.id });
const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId));
await ctx.db
.update(videos)
.set({ plays_count: playsCount[0]?.count || 0 })
.where(eq(videos.id, args.videoId));
return {
success: true,
play_id: play[0].id,
plays_count: playsCount[0]?.count || 0,
};
},
}),
);
builder.mutationField("updateVideoPlay", (t) =>
t.field({
type: "Boolean",
args: {
videoId: t.arg.string({ required: true }),
playId: t.arg.string({ required: true }),
durationWatched: t.arg.int({ required: true }),
completed: t.arg.boolean({ required: true }),
},
resolve: async (_root, args, ctx) => {
await ctx.db
.update(video_plays)
.set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() })
.where(eq(video_plays.id, args.playId));
return true;
},
}),
);
builder.queryField("analytics", (t) =>
t.field({
type: "JSON",
nullable: true,
resolve: async (_root, _args, ctx) => {
if (!ctx.currentUser || ctx.currentUser.role !== "model") {
throw new GraphQLError("Unauthorized");
}
const userId = ctx.currentUser.id;
// Get all videos by this user (via video_models)
const modelVideoIds = await ctx.db
.select({ video_id: video_models.video_id })
.from(video_models)
.where(eq(video_models.user_id, userId));
if (modelVideoIds.length === 0) {
return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] };
}
const videoIds = modelVideoIds.map((v: any) => v.video_id);
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
const plays = await ctx.db.select().from(video_plays).where(inArray(video_plays.video_id, videoIds));
const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds));
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
const playsByDate = plays.reduce((acc: any, play) => {
const date = new Date(play.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
return acc;
}, {});
const likesByDate = likes.reduce((acc: any, like) => {
const date = new Date(like.date_created).toISOString().split("T")[0];
if (!acc[date]) acc[date] = 0;
acc[date]++;
return acc;
}, {});
const videoAnalytics = videoList.map((video) => {
const vPlays = plays.filter((p) => p.video_id === video.id);
const completedPlays = vPlays.filter((p) => p.completed).length;
const avgWatchTime = vPlays.length > 0
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
: 0;
return {
id: video.id,
title: video.title,
slug: video.slug,
upload_date: video.upload_date,
likes: video.likes_count || 0,
plays: video.plays_count || 0,
completed_plays: completedPlays,
completion_rate: video.plays_count ? (completedPlays / video.plays_count) * 100 : 0,
avg_watch_time: Math.round(avgWatchTime),
};
});
return {
total_videos: videoList.length,
total_likes: totalLikes,
total_plays: totalPlays,
plays_by_date: playsByDate,
likes_by_date: likesByDate,
videos: videoAnalytics,
};
},
}),
);