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

@@ -40,8 +40,9 @@
"vite-plugin-wasm": "3.5.0"
},
"dependencies": {
"@directus/sdk": "^21.1.0",
"@sexy.pivoine.art/buttplug": "workspace:*",
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",
"javascript-time-ago": "^2.6.4",
"media-chrome": "^4.18.0",
"svelte-i18n": "^4.0.1"

View File

@@ -30,7 +30,7 @@ export const handle: Handle = async ({ event, resolve }) => {
});
// Handle authentication
const token = cookies.get("directus_session_token");
const token = cookies.get("session_token");
if (token) {
try {
@@ -42,7 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
userId: locals.authStatus.user?.id,
context: {
email: locals.authStatus.user?.email,
role: locals.authStatus.user?.role?.name,
role: locals.authStatus.user?.role,
},
});
} else {

View File

@@ -0,0 +1,25 @@
import { GraphQLClient } from "graphql-request";
import { env } from "$env/dynamic/public";
import type { CurrentUser } from "./types";
export const apiUrl = env.PUBLIC_API_URL || "http://localhost:3000/api";
export const getGraphQLClient = (fetchFn?: typeof globalThis.fetch) =>
new GraphQLClient(`${apiUrl}/graphql`, {
credentials: "include",
fetch: fetchFn || globalThis.fetch,
});
export const getAssetUrl = (
id: string,
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
) => {
if (!id) {
return null;
}
return `${apiUrl}/assets/${id}${transform ? "?transform=" + transform : ""}`;
};
export const isModel = (user: CurrentUser) => {
return user.role === "model";
};

View File

@@ -1,35 +1,3 @@
import { authentication, createDirectus, rest } from "@directus/sdk";
import { env } from "$env/dynamic/public";
import type { CurrentUser } from "./types";
export const directusApiUrl = env.PUBLIC_API_URL || "http://localhost:3000/api";
export const getDirectusInstance = (fetch?: typeof globalThis.fetch) => {
const options: { globals?: { fetch: typeof globalThis.fetch } } = fetch
? { globals: { fetch } }
: {};
const directus = createDirectus(directusApiUrl, options)
.with(rest())
.with(authentication("session"));
return directus;
};
export const getAssetUrl = (
id: string,
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
) => {
if (!id) {
return null;
}
return `${directusApiUrl}/assets/${id}${transform ? "?key=" + transform : ""}`;
};
export const isModel = (user: CurrentUser) => {
if (user.role.name === "Model") {
return true;
}
if (user.policies.find((p) => p.policy.name === "Model")) {
return true;
}
return false;
};
// Re-export from api.ts for backwards compatibility
// All components that import from $lib/directus continue to work
export { apiUrl as directusApiUrl, getAssetUrl, isModel, getGraphQLClient as getDirectusInstance } from "./api.js";

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,8 @@ export interface User {
export interface CurrentUser extends User {
avatar: File;
role: {
name: string;
};
policies: {
policy: {
name: string;
};
}[];
role: "model" | "viewer" | "admin";
policies: string[];
}
export interface AuthStatus {

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,

View File

@@ -14,7 +14,7 @@ export default defineConfig({
proxy: {
"/api": {
rewrite: (path) => path.replace(/^\/api/, ""),
target: "http://localhost:8055",
target: "http://localhost:4000",
changeOrigin: true,
secure: false,
ws: true,