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

@@ -1,5 +1,17 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { gql } from "graphql-request";
import { getGraphQLClient } from "$lib/api";
const LEADERBOARD_QUERY = gql`
query Leaderboard($limit: Int, $offset: Int) {
leaderboard(limit: $limit, offset: $offset) {
user_id display_name avatar
total_weighted_points total_raw_points
recordings_count playbacks_count achievements_count rank
}
}
`;
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
// Guard: Redirect to login if not authenticated
@@ -11,22 +23,27 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const limit = parseInt(url.searchParams.get("limit") || "100");
const offset = parseInt(url.searchParams.get("offset") || "0");
const response = await fetch(
`/api/sexy/gamification/leaderboard?limit=${limit}&offset=${offset}`,
);
if (!response.ok) {
throw new Error("Failed to fetch leaderboard");
}
const data = await response.json();
const client = getGraphQLClient(fetch);
const data = await client.request<{
leaderboard: {
user_id: string;
display_name: string | null;
avatar: string | null;
total_weighted_points: number | null;
total_raw_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
achievements_count: number | null;
rank: number;
}[];
}>(LEADERBOARD_QUERY, { limit, offset });
return {
leaderboard: data.data || [],
leaderboard: data.leaderboard || [],
pagination: {
limit,
offset,
hasMore: data.data?.length === limit,
hasMore: data.leaderboard?.length === limit,
},
};
} catch (error) {

View File

@@ -1,5 +1,25 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { gql } from "graphql-request";
import { getGraphQLClient } from "$lib/api";
const USER_PROFILE_QUERY = gql`
query UserProfile($id: String!) {
userProfile(id: $id) {
id first_name last_name email description avatar date_created
}
userGamification(userId: $id) {
stats {
user_id total_raw_points total_weighted_points
recordings_count playbacks_count comments_count achievements_count rank
}
achievements {
id code name description icon category date_unlocked progress required_count
}
recent_points { action points date_created recording_id }
}
}
`;
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
// Guard: Redirect to login if not authenticated
@@ -10,38 +30,44 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
const { id } = params;
try {
// Fetch user profile data from Directus
const userResponse = await fetch(`/api/users/${id}?fields=id,first_name,last_name,email,description,avatar,date_created,location`);
const client = getGraphQLClient(fetch);
const data = await client.request<{
userProfile: {
id: string;
first_name: string | null;
last_name: string | null;
email: string;
description: string | null;
avatar: string | null;
date_created: string;
} | null;
userGamification: {
stats: {
user_id: string;
total_raw_points: number | null;
total_weighted_points: number | null;
recordings_count: number | null;
playbacks_count: number | null;
comments_count: number | null;
achievements_count: number | null;
rank: number;
} | null;
achievements: unknown[];
recent_points: unknown[];
} | null;
}>(USER_PROFILE_QUERY, { id });
if (!userResponse.ok) {
if (!data.userProfile) {
throw redirect(404, "/");
}
const userData = await userResponse.json();
const user = userData.data;
// Fetch user's comments count
const commentsResponse = await fetch(`/api/comments?filter[user_created][_eq]=${id}&aggregate[count]=*`);
const commentsData = await commentsResponse.json();
const commentsCount = commentsData.data?.[0]?.count || 0;
// Fetch user's video likes count
const likesResponse = await fetch(`/api/items/sexy_video_likes?filter[user_id][_eq]=${id}&aggregate[count]=*`);
const likesData = await likesResponse.json();
const likesCount = likesData.data?.[0]?.count || 0;
// Fetch gamification data
const gamificationResponse = await fetch(`/api/sexy/gamification/user/${id}`);
let gamification = null;
if (gamificationResponse.ok) {
gamification = await gamificationResponse.json();
}
const gamification = data.userGamification;
return {
user,
user: data.userProfile,
stats: {
comments_count: commentsCount,
likes_count: likesCount,
comments_count: gamification?.stats?.comments_count || 0,
likes_count: 0,
},
gamification,
isOwnProfile: locals.authStatus.user?.id === id,