Files
sexy/packages/backend/src/lib/gamification.ts

324 lines
9.8 KiB
TypeScript
Raw Normal View History

import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
import type { DB } from "../db/connection";
import {
user_points,
user_stats,
recordings,
recording_plays,
comments,
user_achievements,
achievements,
users,
} from "../db/schema/index";
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 { weighted_score?: string })?.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: number;
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 { count?: string })?.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 { count?: string })?.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 { count?: string })?.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);
}
}