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:
324
packages/backend/src/lib/gamification.ts
Normal file
324
packages/backend/src/lib/gamification.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user