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:
566
packages/backend/src/scripts/data-migration.ts
Normal file
566
packages/backend/src/scripts/data-migration.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Data Migration: Directus → Custom Backend
|
||||
*
|
||||
* Migrates data from Directus tables to the new schema.
|
||||
* Run with: tsx src/scripts/data-migration.ts
|
||||
*
|
||||
* Environment variables:
|
||||
* DATABASE_URL - PostgreSQL connection (same DB)
|
||||
* OLD_UPLOAD_DIR - Path to Directus uploads (e.g. /old-uploads)
|
||||
* NEW_UPLOAD_DIR - Path to new upload dir (e.g. /data/uploads)
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgresql://sexy:sexy@localhost:5432/sexy";
|
||||
const OLD_UPLOAD_DIR = process.env.OLD_UPLOAD_DIR || "/old-uploads";
|
||||
const NEW_UPLOAD_DIR = process.env.NEW_UPLOAD_DIR || "/data/uploads";
|
||||
|
||||
const pool = new Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
async function query(sql: string, params: unknown[] = []) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
return await client.query(sql, params);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function copyFile(src: string, dest: string) {
|
||||
const dir = path.dirname(dest);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dest);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function migrateFiles() {
|
||||
console.log("📁 Migrating files...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, title, description, filename_disk, type, filesize, duration, uploaded_by, date_created
|
||||
FROM directus_files`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of rows) {
|
||||
// Check if already migrated
|
||||
const existing = await query("SELECT id FROM files WHERE id = $1", [file.id]);
|
||||
if (existing.rows.length > 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO files (id, title, description, filename, mime_type, filesize, duration, uploaded_by, date_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
file.id,
|
||||
file.title,
|
||||
file.description,
|
||||
file.filename_disk || `${file.id}`,
|
||||
file.type,
|
||||
file.filesize,
|
||||
file.duration,
|
||||
file.uploaded_by,
|
||||
file.date_created,
|
||||
],
|
||||
);
|
||||
|
||||
// Copy file to new location
|
||||
const srcPath = path.join(OLD_UPLOAD_DIR, file.filename_disk || "");
|
||||
const destPath = path.join(NEW_UPLOAD_DIR, file.id, file.filename_disk || `${file.id}`);
|
||||
const copied = copyFile(srcPath, destPath);
|
||||
|
||||
if (!copied) {
|
||||
console.warn(` ⚠️ File not found on disk: ${file.filename_disk}`);
|
||||
}
|
||||
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Files: ${migrated} migrated, ${skipped} already existed`);
|
||||
}
|
||||
|
||||
async function migrateUsers() {
|
||||
console.log("👥 Migrating users...");
|
||||
const { rows } = await query(
|
||||
`SELECT u.id, u.email, u.password, u.first_name, u.last_name,
|
||||
u.description, u.avatar, u.date_created,
|
||||
u.artist_name, u.slug, u.email_notifications_key,
|
||||
r.name as role_name
|
||||
FROM directus_users u
|
||||
LEFT JOIN directus_roles r ON u.role = r.id
|
||||
WHERE u.status = 'active'`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
|
||||
for (const user of rows) {
|
||||
const existing = await query("SELECT id FROM users WHERE id = $1", [user.id]);
|
||||
if (existing.rows.length > 0) {
|
||||
migrated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const role =
|
||||
user.role_name === "Model"
|
||||
? "model"
|
||||
: user.role_name === "Administrator"
|
||||
? "admin"
|
||||
: "viewer";
|
||||
|
||||
// Fetch tags from custom user fields if they exist
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
const tagsRes = await query("SELECT tags FROM directus_users WHERE id = $1", [user.id]);
|
||||
if (tagsRes.rows[0]?.tags) {
|
||||
tags = Array.isArray(tagsRes.rows[0].tags)
|
||||
? tagsRes.rows[0].tags
|
||||
: JSON.parse(tagsRes.rows[0].tags || "[]");
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await query(
|
||||
`INSERT INTO users (id, email, password_hash, first_name, last_name, artist_name, slug,
|
||||
description, tags, role, avatar, email_verified, date_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
user.id,
|
||||
user.email,
|
||||
user.password || "MIGRATED_NO_PASSWORD",
|
||||
user.first_name,
|
||||
user.last_name,
|
||||
user.artist_name,
|
||||
user.slug,
|
||||
user.description,
|
||||
JSON.stringify(tags),
|
||||
role,
|
||||
user.avatar,
|
||||
true, // Assume existing users are verified
|
||||
user.date_created,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Users: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateUserPhotos() {
|
||||
console.log("🖼️ Migrating user photos...");
|
||||
const { rows } = await query(
|
||||
`SELECT directus_users_id as user_id, directus_files_id as file_id, sort
|
||||
FROM junction_directus_users_files`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
const userExists = await query("SELECT id FROM users WHERE id = $1", [row.user_id]);
|
||||
const fileExists = await query("SELECT id FROM files WHERE id = $1", [row.file_id]);
|
||||
if (!userExists.rows.length || !fileExists.rows.length) continue;
|
||||
|
||||
await query(
|
||||
`INSERT INTO user_photos (user_id, file_id, sort) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[row.user_id, row.file_id, row.sort || 0],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ User photos: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateArticles() {
|
||||
console.log("📰 Migrating articles...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, slug, title, excerpt, content, image, tags, publish_date,
|
||||
author, category, featured, date_created, date_updated
|
||||
FROM sexy_articles`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const article of rows) {
|
||||
await query(
|
||||
`INSERT INTO articles (id, slug, title, excerpt, content, image, tags, publish_date,
|
||||
author, category, featured, date_created, date_updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
article.id,
|
||||
article.slug,
|
||||
article.title,
|
||||
article.excerpt,
|
||||
article.content,
|
||||
article.image,
|
||||
Array.isArray(article.tags) ? JSON.stringify(article.tags) : article.tags,
|
||||
article.publish_date,
|
||||
article.author,
|
||||
article.category,
|
||||
article.featured,
|
||||
article.date_created,
|
||||
article.date_updated,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Articles: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateVideos() {
|
||||
console.log("🎬 Migrating videos...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, slug, title, description, image, movie, tags, upload_date,
|
||||
premium, featured, likes_count, plays_count
|
||||
FROM sexy_videos`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const video of rows) {
|
||||
await query(
|
||||
`INSERT INTO videos (id, slug, title, description, image, movie, tags, upload_date,
|
||||
premium, featured, likes_count, plays_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
video.id,
|
||||
video.slug,
|
||||
video.title,
|
||||
video.description,
|
||||
video.image,
|
||||
video.movie,
|
||||
Array.isArray(video.tags) ? JSON.stringify(video.tags) : video.tags,
|
||||
video.upload_date,
|
||||
video.premium,
|
||||
video.featured,
|
||||
video.likes_count || 0,
|
||||
video.plays_count || 0,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Videos: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateVideoModels() {
|
||||
console.log("🔗 Migrating video models...");
|
||||
const { rows } = await query(
|
||||
`SELECT sexy_videos_id as video_id, directus_users_id as user_id
|
||||
FROM sexy_videos_models`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
const videoExists = await query("SELECT id FROM videos WHERE id = $1", [row.video_id]);
|
||||
const userExists = await query("SELECT id FROM users WHERE id = $1", [row.user_id]);
|
||||
if (!videoExists.rows.length || !userExists.rows.length) continue;
|
||||
|
||||
await query(
|
||||
`INSERT INTO video_models (video_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[row.video_id, row.user_id],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Video models: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateVideoLikes() {
|
||||
console.log("❤️ Migrating video likes...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
await query(
|
||||
`INSERT INTO video_likes (id, video_id, user_id, date_created) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[row.id, row.video_id, row.user_id, row.date_created],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Video likes: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateVideoPlays() {
|
||||
console.log("▶️ Migrating video plays...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, video_id, user_id, session_id, duration_watched, completed, date_created
|
||||
FROM sexy_video_plays`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
await query(
|
||||
`INSERT INTO video_plays (id, video_id, user_id, session_id, duration_watched, completed, date_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
row.id,
|
||||
row.video_id,
|
||||
row.user_id,
|
||||
row.session_id,
|
||||
row.duration_watched,
|
||||
row.completed,
|
||||
row.date_created,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Video plays: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateRecordings() {
|
||||
console.log("🎙️ Migrating recordings...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, title, description, slug, duration, events, device_info,
|
||||
user_created as user_id, status, tags, linked_video, featured, public,
|
||||
original_recording_id, date_created, date_updated
|
||||
FROM sexy_recordings`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const recording of rows) {
|
||||
await query(
|
||||
`INSERT INTO recordings (id, title, description, slug, duration, events, device_info,
|
||||
user_id, status, tags, linked_video, featured, public,
|
||||
original_recording_id, date_created, date_updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
recording.id,
|
||||
recording.title,
|
||||
recording.description,
|
||||
recording.slug,
|
||||
recording.duration,
|
||||
typeof recording.events === "string" ? recording.events : JSON.stringify(recording.events),
|
||||
typeof recording.device_info === "string"
|
||||
? recording.device_info
|
||||
: JSON.stringify(recording.device_info),
|
||||
recording.user_id,
|
||||
recording.status,
|
||||
Array.isArray(recording.tags) ? JSON.stringify(recording.tags) : recording.tags,
|
||||
recording.linked_video,
|
||||
recording.featured,
|
||||
recording.public,
|
||||
recording.original_recording_id,
|
||||
recording.date_created,
|
||||
recording.date_updated,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Recordings: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateRecordingPlays() {
|
||||
console.log("▶️ Migrating recording plays...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, user_id, recording_id, duration_played, completed, date_created
|
||||
FROM sexy_recording_plays`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
await query(
|
||||
`INSERT INTO recording_plays (id, recording_id, user_id, duration_played, completed, date_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[row.id, row.recording_id, row.user_id, row.duration_played, row.completed, row.date_created],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Recording plays: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateComments() {
|
||||
console.log("💬 Migrating comments...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, collection, item, comment, user_created as user_id, date_created
|
||||
FROM directus_comments
|
||||
WHERE collection IN ('sexy_videos', 'sexy_recordings')`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
// Map collection names
|
||||
const collection = row.collection === "sexy_videos" ? "videos" : "recordings";
|
||||
|
||||
await query(
|
||||
`INSERT INTO comments (collection, item_id, comment, user_id, date_created)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[collection, row.item, row.comment, row.user_id, row.date_created],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Comments: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateAchievements() {
|
||||
console.log("🏆 Migrating achievements...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, code, name, description, icon, category, required_count, points_reward, status, sort
|
||||
FROM sexy_achievements`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
await query(
|
||||
`INSERT INTO achievements (id, code, name, description, icon, category, required_count, points_reward, status, sort)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
row.id,
|
||||
row.code,
|
||||
row.name,
|
||||
row.description,
|
||||
row.icon,
|
||||
row.category,
|
||||
row.required_count,
|
||||
row.points_reward,
|
||||
row.status,
|
||||
row.sort,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Achievements: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateUserAchievements() {
|
||||
console.log("🎖️ Migrating user achievements...");
|
||||
const { rows } = await query(
|
||||
`SELECT user_id, achievement_id, progress, date_unlocked FROM sexy_user_achievements`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
const userExists = await query("SELECT id FROM users WHERE id = $1", [row.user_id]);
|
||||
const achievementExists = await query("SELECT id FROM achievements WHERE id = $1", [
|
||||
row.achievement_id,
|
||||
]);
|
||||
if (!userExists.rows.length || !achievementExists.rows.length) continue;
|
||||
|
||||
await query(
|
||||
`INSERT INTO user_achievements (user_id, achievement_id, progress, date_unlocked)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, achievement_id) DO NOTHING`,
|
||||
[row.user_id, row.achievement_id, row.progress, row.date_unlocked],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ User achievements: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateUserPoints() {
|
||||
console.log("💎 Migrating user points...");
|
||||
const { rows } = await query(
|
||||
`SELECT user_id, action, points, recording_id, date_created FROM sexy_user_points`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
const userExists = await query("SELECT id FROM users WHERE id = $1", [row.user_id]);
|
||||
if (!userExists.rows.length) continue;
|
||||
|
||||
await query(
|
||||
`INSERT INTO user_points (user_id, action, points, recording_id, date_created)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[row.user_id, row.action, row.points, row.recording_id, row.date_created],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ User points: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function migrateUserStats() {
|
||||
console.log("📊 Migrating user stats...");
|
||||
const { rows } = await query(
|
||||
`SELECT user_id, total_raw_points, total_weighted_points, recordings_count,
|
||||
playbacks_count, comments_count, achievements_count, last_updated
|
||||
FROM sexy_user_stats`,
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
const userExists = await query("SELECT id FROM users WHERE id = $1", [row.user_id]);
|
||||
if (!userExists.rows.length) continue;
|
||||
|
||||
await query(
|
||||
`INSERT INTO user_stats (user_id, total_raw_points, total_weighted_points, recordings_count,
|
||||
playbacks_count, comments_count, achievements_count, last_updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id) DO NOTHING`,
|
||||
[
|
||||
row.user_id,
|
||||
row.total_raw_points,
|
||||
row.total_weighted_points,
|
||||
row.recordings_count,
|
||||
row.playbacks_count,
|
||||
row.comments_count,
|
||||
row.achievements_count,
|
||||
row.last_updated,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
console.log(` ✅ User stats: ${migrated} migrated`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🚀 Starting data migration from Directus to custom backend...\n");
|
||||
|
||||
try {
|
||||
// Verify connection
|
||||
await query("SELECT 1");
|
||||
console.log("✅ Database connected\n");
|
||||
|
||||
// Migration order respects FK dependencies
|
||||
await migrateFiles();
|
||||
await migrateUsers();
|
||||
await migrateUserPhotos();
|
||||
await migrateArticles();
|
||||
await migrateVideos();
|
||||
await migrateVideoModels();
|
||||
await migrateVideoLikes();
|
||||
await migrateVideoPlays();
|
||||
await migrateRecordings();
|
||||
await migrateRecordingPlays();
|
||||
await migrateComments();
|
||||
await migrateAchievements();
|
||||
await migrateUserAchievements();
|
||||
await migrateUserPoints();
|
||||
await migrateUserStats();
|
||||
|
||||
console.log("\n🎉 Migration complete!");
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user