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,9 @@
import argon2 from "argon2";
export async function hash(password: string): Promise<string> {
return argon2.hash(password);
}
export async function verify(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}

View File

@@ -0,0 +1,28 @@
import Redis from "ioredis";
export type SessionUser = {
id: string;
email: string;
role: "model" | "viewer" | "admin";
first_name: string | null;
last_name: string | null;
artist_name: string | null;
slug: string | null;
avatar: string | null;
};
export const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
export async function setSession(token: string, user: SessionUser): Promise<void> {
await redis.set(`session:${token}`, JSON.stringify(user), "EX", 86400);
}
export async function getSession(token: string): Promise<SessionUser | null> {
const data = await redis.get(`session:${token}`);
if (!data) return null;
return JSON.parse(data) as SessionUser;
}
export async function deleteSession(token: string): Promise<void> {
await redis.del(`session:${token}`);
}

View File

@@ -0,0 +1,32 @@
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "localhost",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_SECURE === "true",
auth: process.env.SMTP_USER ? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
} : undefined,
});
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
const BASE_URL = process.env.PUBLIC_URL || "http://localhost:3000";
export async function sendVerification(email: string, token: string): Promise<void> {
await transporter.sendMail({
from: FROM,
to: email,
subject: "Verify your email",
html: `<p>Click <a href="${BASE_URL}/signup/verify?token=${token}">here</a> to verify your email.</p>`,
});
}
export async function sendPasswordReset(email: string, token: string): Promise<void> {
await transporter.sendMail({
from: FROM,
to: email,
subject: "Reset your password",
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
});
}

View File

@@ -0,0 +1,10 @@
import ffmpeg from "fluent-ffmpeg";
export function extractDuration(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) return reject(err);
resolve(Math.round(metadata.format.duration || 0));
});
});
}

View File

@@ -0,0 +1,324 @@
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection.js";
import {
user_points,
user_stats,
recordings,
recording_plays,
comments,
user_achievements,
achievements,
users,
} from "../db/schema/index.js";
export const POINT_VALUES = {
RECORDING_CREATE: 50,
RECORDING_PLAY: 10,
RECORDING_COMPLETE: 5,
COMMENT_CREATE: 5,
RECORDING_FEATURED: 100,
} as const;
const DECAY_LAMBDA = 0.005;
export async function awardPoints(
db: DB,
userId: string,
action: keyof typeof POINT_VALUES,
recordingId?: string,
): Promise<void> {
const points = POINT_VALUES[action];
await db.insert(user_points).values({
user_id: userId,
action,
points,
recording_id: recordingId || null,
date_created: new Date(),
});
await updateUserStats(db, userId);
}
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
const now = new Date();
const result = await db.execute(sql`
SELECT SUM(
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (${now}::timestamptz - date_created)) / 86400)
) as weighted_score
FROM user_points
WHERE user_id = ${userId}
`);
return parseFloat((result.rows[0] as any)?.weighted_score || "0");
}
export async function updateUserStats(db: DB, userId: string): Promise<void> {
const now = new Date();
const rawPointsResult = await db
.select({ total: sum(user_points.points) })
.from(user_points)
.where(eq(user_points.user_id, userId));
const totalRawPoints = parseInt(String(rawPointsResult[0]?.total || "0"));
const totalWeightedPoints = await calculateWeightedScore(db, userId);
const recordingsResult = await db
.select({ count: count() })
.from(recordings)
.where(and(eq(recordings.user_id, userId), eq(recordings.status, "published")));
const recordingsCount = recordingsResult[0]?.count || 0;
// Get playbacks count (excluding own recordings)
const ownRecordingIds = await db
.select({ id: recordings.id })
.from(recordings)
.where(eq(recordings.user_id, userId));
const ownIds = ownRecordingIds.map((r) => r.id);
let playbacksCount = 0;
if (ownIds.length > 0) {
const playbacksResult = await db.execute(sql`
SELECT COUNT(*) as count FROM recording_plays
WHERE user_id = ${userId}
AND recording_id NOT IN (${sql.join(ownIds.map(id => sql`${id}`), sql`, `)})
`);
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
} else {
const playbacksResult = await db
.select({ count: count() })
.from(recording_plays)
.where(eq(recording_plays.user_id, userId));
playbacksCount = playbacksResult[0]?.count || 0;
}
const commentsResult = await db
.select({ count: count() })
.from(comments)
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
const commentsCount = commentsResult[0]?.count || 0;
const achievementsResult = await db
.select({ count: count() })
.from(user_achievements)
.where(and(eq(user_achievements.user_id, userId), isNotNull(user_achievements.date_unlocked)));
const achievementsCount = achievementsResult[0]?.count || 0;
const existing = await db
.select()
.from(user_stats)
.where(eq(user_stats.user_id, userId))
.limit(1);
if (existing.length > 0) {
await db
.update(user_stats)
.set({
total_raw_points: totalRawPoints,
total_weighted_points: totalWeightedPoints,
recordings_count: recordingsCount,
playbacks_count: playbacksCount,
comments_count: commentsCount,
achievements_count: achievementsCount,
last_updated: now,
})
.where(eq(user_stats.user_id, userId));
} else {
await db.insert(user_stats).values({
user_id: userId,
total_raw_points: totalRawPoints,
total_weighted_points: totalWeightedPoints,
recordings_count: recordingsCount,
playbacks_count: playbacksCount,
comments_count: commentsCount,
achievements_count: achievementsCount,
last_updated: now,
});
}
}
export async function checkAchievements(
db: DB,
userId: string,
category?: string,
): Promise<void> {
let achievementsQuery = db
.select()
.from(achievements)
.where(eq(achievements.status, "published"));
if (category) {
achievementsQuery = db
.select()
.from(achievements)
.where(and(eq(achievements.status, "published"), eq(achievements.category, category)));
}
const achievementsList = await achievementsQuery;
for (const achievement of achievementsList) {
const progress = await getAchievementProgress(db, userId, achievement);
const existing = await db
.select()
.from(user_achievements)
.where(
and(
eq(user_achievements.user_id, userId),
eq(user_achievements.achievement_id, achievement.id),
),
)
.limit(1);
const isUnlocked = progress >= achievement.required_count;
const wasUnlocked = existing[0]?.date_unlocked !== null;
if (existing.length > 0) {
await db
.update(user_achievements)
.set({
progress,
date_unlocked: isUnlocked ? (existing[0].date_unlocked || new Date()) : null,
})
.where(
and(
eq(user_achievements.user_id, userId),
eq(user_achievements.achievement_id, achievement.id),
),
);
} else {
await db.insert(user_achievements).values({
user_id: userId,
achievement_id: achievement.id,
progress,
date_unlocked: isUnlocked ? new Date() : null,
});
}
if (isUnlocked && !wasUnlocked && achievement.points_reward > 0) {
await db.insert(user_points).values({
user_id: userId,
action: `ACHIEVEMENT_${achievement.code}`,
points: achievement.points_reward,
recording_id: null,
date_created: new Date(),
});
await updateUserStats(db, userId);
}
}
}
async function getAchievementProgress(
db: DB,
userId: string,
achievement: typeof achievements.$inferSelect,
): Promise<number> {
const { code } = achievement;
if (["first_recording", "recording_10", "recording_50", "recording_100"].includes(code)) {
const result = await db
.select({ count: count() })
.from(recordings)
.where(and(eq(recordings.user_id, userId), eq(recordings.status, "published")));
return result[0]?.count || 0;
}
if (code === "featured_recording") {
const result = await db
.select({ count: count() })
.from(recordings)
.where(
and(
eq(recordings.user_id, userId),
eq(recordings.status, "published"),
eq(recordings.featured, true),
),
);
return result[0]?.count || 0;
}
if (["first_play", "play_100", "play_500"].includes(code)) {
const result = await db.execute(sql`
SELECT COUNT(*) as count
FROM recording_plays rp
LEFT JOIN recordings r ON rp.recording_id = r.id
WHERE rp.user_id = ${userId}
AND r.user_id != ${userId}
`);
return parseInt((result.rows[0] as any)?.count || "0");
}
if (["completionist_10", "completionist_100"].includes(code)) {
const result = await db
.select({ count: count() })
.from(recording_plays)
.where(and(eq(recording_plays.user_id, userId), eq(recording_plays.completed, true)));
return result[0]?.count || 0;
}
if (["first_comment", "comment_50", "comment_250"].includes(code)) {
const result = await db
.select({ count: count() })
.from(comments)
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
return result[0]?.count || 0;
}
if (code === "early_adopter") {
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user[0]) {
const joinDate = new Date(user[0].date_created);
const platformLaunch = new Date("2025-01-01");
const oneMonthAfterLaunch = new Date(platformLaunch);
oneMonthAfterLaunch.setMonth(oneMonthAfterLaunch.getMonth() + 1);
return joinDate <= oneMonthAfterLaunch ? 1 : 0;
}
}
if (code === "one_year") {
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user[0]) {
const joinDate = new Date(user[0].date_created);
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
return joinDate <= oneYearAgo ? 1 : 0;
}
}
if (code === "balanced_creator") {
const recordingsResult = await db
.select({ count: count() })
.from(recordings)
.where(and(eq(recordings.user_id, userId), eq(recordings.status, "published")));
const playsResult = await db.execute(sql`
SELECT COUNT(*) as count FROM recording_plays rp
LEFT JOIN recordings r ON rp.recording_id = r.id
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
`);
const rc = recordingsResult[0]?.count || 0;
const pc = parseInt((playsResult.rows[0] as any)?.count || "0");
return rc >= 50 && pc >= 100 ? 1 : 0;
}
if (code === "top_10_rank") {
const userStat = await db
.select()
.from(user_stats)
.where(eq(user_stats.user_id, userId))
.limit(1);
if (!userStat[0]) return 0;
const rankResult = await db
.select({ count: count() })
.from(user_stats)
.where(gt(user_stats.total_weighted_points, userStat[0].total_weighted_points || 0));
const userRank = (rankResult[0]?.count || 0) + 1;
return userRank <= 10 ? 1 : 0;
}
return 0;
}
export async function recalculateAllWeightedScores(db: DB): Promise<void> {
const allUsers = await db.select({ user_id: user_stats.user_id }).from(user_stats);
for (const u of allUsers) {
await updateUserStats(db, u.user_id);
}
}

View File

@@ -0,0 +1,5 @@
import slugifyLib from "slugify";
export function slugify(text: string): string {
return slugifyLib(text, { lower: true, strict: true });
}