Backend resolvers: typed enrichArticle/enrichVideo/enrichModel with DB and $inferSelect types, SQL<unknown>[] for conditions arrays, proper enum casts for status/role fields, $inferInsert for .set() updates, typed raw SQL result rows in gamification, ReplyLike interface for ctx.reply in auth. Frontend: typed catch blocks with Error/interface casts, isActiveLink param, adminGetUser response, tags filter callback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
324 lines
9.8 KiB
TypeScript
324 lines
9.8 KiB
TypeScript
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);
|
|
}
|
|
}
|