feat: implement recording-focused gamification system
- Database schema with 5 new tables: - sexy_recording_plays: Track recording playback - sexy_user_points: Individual point actions - sexy_achievements: Predefined achievement definitions - sexy_user_achievements: User progress tracking - sexy_user_stats: Cached statistics for leaderboards - Seeded 17 achievements across 4 categories - Backend gamification helper functions with time-weighted scoring - Three new API endpoints: - GET /sexy/gamification/leaderboard - GET /sexy/gamification/user/:id - GET /sexy/gamification/achievements - Recording play endpoints with automatic point awards - Time-decay formula (λ=0.005) for balanced rankings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
177
gamification-schema.sql
Normal file
177
gamification-schema.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
-- Gamification System Schema for Sexy Recordings Platform
|
||||
-- Created: 2025-10-28
|
||||
-- Description: Recording-focused gamification with time-weighted scoring
|
||||
|
||||
-- ====================
|
||||
-- Table: sexy_recording_plays
|
||||
-- ====================
|
||||
-- Tracks when users play recordings (similar to video plays)
|
||||
CREATE TABLE IF NOT EXISTS sexy_recording_plays (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
|
||||
recording_id UUID NOT NULL REFERENCES sexy_recordings(id) ON DELETE CASCADE,
|
||||
duration_played INTEGER, -- Duration played in milliseconds
|
||||
completed BOOLEAN DEFAULT FALSE, -- True if >= 90% watched
|
||||
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
date_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_plays_user ON sexy_recording_plays(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_plays_recording ON sexy_recording_plays(recording_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_plays_date ON sexy_recording_plays(date_created);
|
||||
|
||||
COMMENT ON TABLE sexy_recording_plays IS 'Tracks user playback of recordings for analytics and gamification';
|
||||
COMMENT ON COLUMN sexy_recording_plays.completed IS 'True if user watched at least 90% of the recording';
|
||||
|
||||
-- ====================
|
||||
-- Table: sexy_user_points
|
||||
-- ====================
|
||||
-- Tracks individual point-earning actions with timestamps for time-weighted scoring
|
||||
CREATE TABLE IF NOT EXISTS sexy_user_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
|
||||
action VARCHAR(50) NOT NULL, -- e.g., "RECORDING_CREATE", "RECORDING_PLAY", "COMMENT_CREATE"
|
||||
points INTEGER NOT NULL, -- Raw points earned
|
||||
recording_id UUID REFERENCES sexy_recordings(id) ON DELETE SET NULL, -- Optional reference
|
||||
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_points_user ON sexy_user_points(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_points_date ON sexy_user_points(date_created);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_points_action ON sexy_user_points(action);
|
||||
|
||||
COMMENT ON TABLE sexy_user_points IS 'Individual point-earning actions for gamification system';
|
||||
COMMENT ON COLUMN sexy_user_points.action IS 'Type of action: RECORDING_CREATE, RECORDING_PLAY, RECORDING_COMPLETE, COMMENT_CREATE, RECORDING_FEATURED';
|
||||
COMMENT ON COLUMN sexy_user_points.points IS 'Raw points before time-weighted decay calculation';
|
||||
|
||||
-- ====================
|
||||
-- Table: sexy_achievements
|
||||
-- ====================
|
||||
-- Predefined achievement definitions
|
||||
CREATE TABLE IF NOT EXISTS sexy_achievements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(50) UNIQUE NOT NULL, -- Unique identifier (e.g., "first_recording", "recording_100")
|
||||
name VARCHAR(255) NOT NULL, -- Display name
|
||||
description TEXT, -- Achievement description
|
||||
icon VARCHAR(255), -- Icon identifier or emoji
|
||||
category VARCHAR(50) NOT NULL, -- e.g., "recordings", "playback", "social", "special"
|
||||
required_count INTEGER, -- Number of actions needed to unlock
|
||||
points_reward INTEGER DEFAULT 0, -- Bonus points awarded upon unlock
|
||||
sort INTEGER DEFAULT 0, -- Display order
|
||||
status VARCHAR(20) DEFAULT 'published' -- published, draft, archived
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_achievements_category ON sexy_achievements(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_achievements_code ON sexy_achievements(code);
|
||||
|
||||
COMMENT ON TABLE sexy_achievements IS 'Predefined achievement definitions for gamification';
|
||||
COMMENT ON COLUMN sexy_achievements.code IS 'Unique code used in backend logic (e.g., first_recording, play_100)';
|
||||
COMMENT ON COLUMN sexy_achievements.category IS 'Achievement category: recordings, playback, social, special';
|
||||
|
||||
-- ====================
|
||||
-- Table: sexy_user_achievements
|
||||
-- ====================
|
||||
-- Junction table tracking unlocked achievements per user
|
||||
CREATE TABLE IF NOT EXISTS sexy_user_achievements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
|
||||
achievement_id UUID NOT NULL REFERENCES sexy_achievements(id) ON DELETE CASCADE,
|
||||
progress INTEGER DEFAULT 0, -- Current progress toward unlocking
|
||||
date_unlocked TIMESTAMP WITH TIME ZONE, -- NULL if not yet unlocked
|
||||
UNIQUE(user_id, achievement_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON sexy_user_achievements(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement ON sexy_user_achievements(achievement_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_unlocked ON sexy_user_achievements(date_unlocked) WHERE date_unlocked IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE sexy_user_achievements IS 'Tracks which achievements users have unlocked';
|
||||
COMMENT ON COLUMN sexy_user_achievements.progress IS 'Current progress (e.g., 7/10 recordings created)';
|
||||
COMMENT ON COLUMN sexy_user_achievements.date_unlocked IS 'NULL if achievement not yet unlocked';
|
||||
|
||||
-- ====================
|
||||
-- Table: sexy_user_stats
|
||||
-- ====================
|
||||
-- Cached aggregate statistics for efficient leaderboard queries
|
||||
CREATE TABLE IF NOT EXISTS sexy_user_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID UNIQUE NOT NULL REFERENCES directus_users(id) ON DELETE CASCADE,
|
||||
total_raw_points INTEGER DEFAULT 0, -- Sum of all points (no decay)
|
||||
total_weighted_points NUMERIC(10,2) DEFAULT 0, -- Time-weighted score for rankings
|
||||
recordings_count INTEGER DEFAULT 0, -- Number of published recordings
|
||||
playbacks_count INTEGER DEFAULT 0, -- Number of recordings played
|
||||
comments_count INTEGER DEFAULT 0, -- Number of comments on recordings
|
||||
achievements_count INTEGER DEFAULT 0, -- Number of unlocked achievements
|
||||
last_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW() -- Cache timestamp
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_stats_weighted ON sexy_user_stats(total_weighted_points DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_stats_user ON sexy_user_stats(user_id);
|
||||
|
||||
COMMENT ON TABLE sexy_user_stats IS 'Cached user statistics for fast leaderboard queries';
|
||||
COMMENT ON COLUMN sexy_user_stats.total_raw_points IS 'Sum of all points without time decay';
|
||||
COMMENT ON COLUMN sexy_user_stats.total_weighted_points IS 'Time-weighted score using exponential decay (λ=0.005)';
|
||||
COMMENT ON COLUMN sexy_user_stats.last_updated IS 'Timestamp for cache invalidation';
|
||||
|
||||
-- ====================
|
||||
-- Insert Initial Achievements
|
||||
-- ====================
|
||||
|
||||
-- 🎬 Recordings (Creation)
|
||||
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
|
||||
('first_recording', 'First Recording', 'Create your first recording', '🎬', 'recordings', 1, 50, 1),
|
||||
('recording_10', 'Recording Enthusiast', 'Create 10 recordings', '📹', 'recordings', 10, 100, 2),
|
||||
('recording_50', 'Prolific Creator', 'Create 50 recordings', '🎥', 'recordings', 50, 500, 3),
|
||||
('recording_100', 'Recording Master', 'Create 100 recordings', '🏆', 'recordings', 100, 1000, 4),
|
||||
('featured_recording', 'Featured Creator', 'Get a recording featured', '⭐', 'recordings', 1, 200, 5)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- ▶️ Playback (Consumption)
|
||||
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
|
||||
('first_play', 'First Play', 'Play your first recording', '▶️', 'playback', 1, 25, 10),
|
||||
('play_100', 'Active Player', 'Play 100 recordings', '🎮', 'playback', 100, 250, 11),
|
||||
('play_500', 'Playback Enthusiast', 'Play 500 recordings', '🔥', 'playback', 500, 1000, 12),
|
||||
('completionist_10', 'Completionist', 'Complete 10 recordings to 90%+', '✅', 'playback', 10, 100, 13),
|
||||
('completionist_100', 'Super Completionist', 'Complete 100 recordings', '💯', 'playback', 100, 500, 14)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 💬 Social (Community)
|
||||
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
|
||||
('first_comment', 'First Comment', 'Leave your first comment', '💬', 'social', 1, 25, 20),
|
||||
('comment_50', 'Conversationalist', 'Leave 50 comments', '💭', 'social', 50, 200, 21),
|
||||
('comment_250', 'Community Voice', 'Leave 250 comments', '📣', 'social', 250, 750, 22)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- ⭐ Special (Milestones)
|
||||
INSERT INTO sexy_achievements (code, name, description, icon, category, required_count, points_reward, sort) VALUES
|
||||
('early_adopter', 'Early Adopter', 'Join in the first month', '🚀', 'special', 1, 500, 30),
|
||||
('one_year', 'One Year Anniversary', 'Be a member for 1 year', '🎂', 'special', 1, 1000, 31),
|
||||
('balanced_creator', 'Balanced Creator', '50 recordings + 100 plays', '⚖️', 'special', 1, 500, 32),
|
||||
('top_10_rank', 'Top 10 Leaderboard', 'Reach top 10 on leaderboard', '🏅', 'special', 1, 2000, 33)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- ====================
|
||||
-- Verification Queries
|
||||
-- ====================
|
||||
|
||||
-- Count tables created
|
||||
SELECT
|
||||
'sexy_recording_plays' as table_name,
|
||||
COUNT(*) as row_count
|
||||
FROM sexy_recording_plays
|
||||
UNION ALL
|
||||
SELECT 'sexy_user_points', COUNT(*) FROM sexy_user_points
|
||||
UNION ALL
|
||||
SELECT 'sexy_achievements', COUNT(*) FROM sexy_achievements
|
||||
UNION ALL
|
||||
SELECT 'sexy_user_achievements', COUNT(*) FROM sexy_user_achievements
|
||||
UNION ALL
|
||||
SELECT 'sexy_user_stats', COUNT(*) FROM sexy_user_stats;
|
||||
|
||||
-- Show created achievements
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*) as achievement_count
|
||||
FROM sexy_achievements
|
||||
GROUP BY category
|
||||
ORDER BY category;
|
||||
336
packages/bundle/src/endpoint/gamification.ts
Normal file
336
packages/bundle/src/endpoint/gamification.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Gamification Helper Functions
|
||||
* Handles points, achievements, and user stats for recording-focused gamification system
|
||||
*/
|
||||
|
||||
import type { Knex } from "knex";
|
||||
|
||||
/**
|
||||
* Point values for different actions
|
||||
*/
|
||||
export const POINT_VALUES = {
|
||||
RECORDING_CREATE: 50,
|
||||
RECORDING_PLAY: 10,
|
||||
RECORDING_COMPLETE: 5,
|
||||
COMMENT_CREATE: 5,
|
||||
RECORDING_FEATURED: 100,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Time decay constant for weighted scoring
|
||||
* λ = 0.005 means ~14% decay per month
|
||||
*/
|
||||
const DECAY_LAMBDA = 0.005;
|
||||
|
||||
/**
|
||||
* Award points to a user for a specific action
|
||||
*/
|
||||
export async function awardPoints(
|
||||
database: Knex,
|
||||
userId: string,
|
||||
action: keyof typeof POINT_VALUES,
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const points = POINT_VALUES[action];
|
||||
|
||||
await database("sexy_user_points").insert({
|
||||
user_id: userId,
|
||||
action,
|
||||
points,
|
||||
recording_id: recordingId || null,
|
||||
date_created: new Date(),
|
||||
});
|
||||
|
||||
// Update cached stats
|
||||
await updateUserStats(database, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate time-weighted score using exponential decay
|
||||
* Score = Σ (points × e^(-λ × age_in_days))
|
||||
*/
|
||||
export async function calculateWeightedScore(
|
||||
database: Knex,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
const result = await database("sexy_user_points")
|
||||
.where({ user_id: userId })
|
||||
.select(
|
||||
database.raw(`
|
||||
SUM(
|
||||
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (? - date_created)) / 86400)
|
||||
) as weighted_score
|
||||
`, [now]),
|
||||
);
|
||||
|
||||
return result[0]?.weighted_score || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create user stats cache
|
||||
*/
|
||||
export async function updateUserStats(database: Knex, userId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
// Calculate raw points
|
||||
const rawPointsResult = await database("sexy_user_points")
|
||||
.where({ user_id: userId })
|
||||
.sum("points as total");
|
||||
const totalRawPoints = rawPointsResult[0]?.total || 0;
|
||||
|
||||
// Calculate weighted points
|
||||
const totalWeightedPoints = await calculateWeightedScore(database, userId);
|
||||
|
||||
// Get recordings count
|
||||
const recordingsResult = await database("sexy_recordings")
|
||||
.where({ user_created: userId, status: "published" })
|
||||
.count("* as count");
|
||||
const recordingsCount = recordingsResult[0]?.count || 0;
|
||||
|
||||
// Get playbacks count (excluding own recordings)
|
||||
const playbacksResult = await database("sexy_recording_plays")
|
||||
.where({ user_id: userId })
|
||||
.whereNotIn("recording_id", function () {
|
||||
this.select("id").from("sexy_recordings").where("user_created", userId);
|
||||
})
|
||||
.count("* as count");
|
||||
const playbacksCount = playbacksResult[0]?.count || 0;
|
||||
|
||||
// Get comments count (on recordings only)
|
||||
const commentsResult = await database("comments")
|
||||
.where({ user_created: userId, collection: "sexy_recordings" })
|
||||
.count("* as count");
|
||||
const commentsCount = commentsResult[0]?.count || 0;
|
||||
|
||||
// Get achievements count
|
||||
const achievementsResult = await database("sexy_user_achievements")
|
||||
.where({ user_id: userId })
|
||||
.whereNotNull("date_unlocked")
|
||||
.count("* as count");
|
||||
const achievementsCount = achievementsResult[0]?.count || 0;
|
||||
|
||||
// Upsert stats
|
||||
const existing = await database("sexy_user_stats")
|
||||
.where({ user_id: userId })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await database("sexy_user_stats")
|
||||
.where({ user_id: userId })
|
||||
.update({
|
||||
total_raw_points: totalRawPoints,
|
||||
total_weighted_points: totalWeightedPoints,
|
||||
recordings_count: recordingsCount,
|
||||
playbacks_count: playbacksCount,
|
||||
comments_count: commentsCount,
|
||||
achievements_count: achievementsCount,
|
||||
last_updated: now,
|
||||
});
|
||||
} else {
|
||||
await database("sexy_user_stats").insert({
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and update achievement progress for a user
|
||||
*/
|
||||
export async function checkAchievements(
|
||||
database: Knex,
|
||||
userId: string,
|
||||
category?: string,
|
||||
): Promise<void> {
|
||||
// Get all achievements (optionally filtered by category)
|
||||
let achievementsQuery = database("sexy_achievements")
|
||||
.where({ status: "published" });
|
||||
|
||||
if (category) {
|
||||
achievementsQuery = achievementsQuery.where({ category });
|
||||
}
|
||||
|
||||
const achievements = await achievementsQuery;
|
||||
|
||||
for (const achievement of achievements) {
|
||||
const progress = await getAchievementProgress(database, userId, achievement);
|
||||
|
||||
// Check if already unlocked
|
||||
const existing = await database("sexy_user_achievements")
|
||||
.where({ user_id: userId, achievement_id: achievement.id })
|
||||
.first();
|
||||
|
||||
const isUnlocked = progress >= achievement.required_count;
|
||||
const wasUnlocked = existing?.date_unlocked !== null;
|
||||
|
||||
if (existing) {
|
||||
// Update progress
|
||||
await database("sexy_user_achievements")
|
||||
.where({ user_id: userId, achievement_id: achievement.id })
|
||||
.update({
|
||||
progress,
|
||||
date_unlocked: isUnlocked ? (existing.date_unlocked || new Date()) : null,
|
||||
});
|
||||
} else {
|
||||
// Insert new progress
|
||||
await database("sexy_user_achievements").insert({
|
||||
user_id: userId,
|
||||
achievement_id: achievement.id,
|
||||
progress,
|
||||
date_unlocked: isUnlocked ? new Date() : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Award bonus points if newly unlocked
|
||||
if (isUnlocked && !wasUnlocked && achievement.points_reward > 0) {
|
||||
await database("sexy_user_points").insert({
|
||||
user_id: userId,
|
||||
action: `ACHIEVEMENT_${achievement.code}`,
|
||||
points: achievement.points_reward,
|
||||
recording_id: null,
|
||||
date_created: new Date(),
|
||||
});
|
||||
|
||||
// Refresh stats after awarding bonus
|
||||
await updateUserStats(database, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a specific achievement
|
||||
*/
|
||||
async function getAchievementProgress(
|
||||
database: Knex,
|
||||
userId: string,
|
||||
achievement: any,
|
||||
): Promise<number> {
|
||||
const { code } = achievement;
|
||||
|
||||
// Recordings achievements
|
||||
if (code === "first_recording" || code === "recording_10" || code === "recording_50" || code === "recording_100") {
|
||||
const result = await database("sexy_recordings")
|
||||
.where({ user_created: userId, status: "published" })
|
||||
.count("* as count");
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// Featured recording
|
||||
if (code === "featured_recording") {
|
||||
const result = await database("sexy_recordings")
|
||||
.where({ user_created: userId, status: "published", featured: true })
|
||||
.count("* as count");
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// Playback achievements (excluding own recordings)
|
||||
if (code === "first_play" || code === "play_100" || code === "play_500") {
|
||||
const result = await database("sexy_recording_plays as rp")
|
||||
.leftJoin("sexy_recordings as r", "rp.recording_id", "r.id")
|
||||
.where({ "rp.user_id": userId })
|
||||
.where("r.user_created", "!=", userId)
|
||||
.count("* as count");
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// Completionist achievements
|
||||
if (code === "completionist_10" || code === "completionist_100") {
|
||||
const result = await database("sexy_recording_plays")
|
||||
.where({ user_id: userId, completed: true })
|
||||
.count("* as count");
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// Social achievements
|
||||
if (code === "first_comment" || code === "comment_50" || code === "comment_250") {
|
||||
const result = await database("comments")
|
||||
.where({ user_created: userId, collection: "sexy_recordings" })
|
||||
.count("* as count");
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// Special: Early adopter (joined in first month)
|
||||
if (code === "early_adopter") {
|
||||
const user = await database("directus_users")
|
||||
.where({ id: userId })
|
||||
.first();
|
||||
|
||||
if (user) {
|
||||
const joinDate = new Date(user.date_created);
|
||||
const platformLaunch = new Date("2025-01-01"); // Adjust to actual launch date
|
||||
const oneMonthAfterLaunch = new Date(platformLaunch);
|
||||
oneMonthAfterLaunch.setMonth(oneMonthAfterLaunch.getMonth() + 1);
|
||||
|
||||
return joinDate <= oneMonthAfterLaunch ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Special: One year anniversary
|
||||
if (code === "one_year") {
|
||||
const user = await database("directus_users")
|
||||
.where({ id: userId })
|
||||
.first();
|
||||
|
||||
if (user) {
|
||||
const joinDate = new Date(user.date_created);
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
|
||||
return joinDate <= oneYearAgo ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Special: Balanced creator (50 recordings + 100 plays)
|
||||
if (code === "balanced_creator") {
|
||||
const recordings = await database("sexy_recordings")
|
||||
.where({ user_created: userId, status: "published" })
|
||||
.count("* as count");
|
||||
const plays = await database("sexy_recording_plays as rp")
|
||||
.leftJoin("sexy_recordings as r", "rp.recording_id", "r.id")
|
||||
.where({ "rp.user_id": userId })
|
||||
.where("r.user_created", "!=", userId)
|
||||
.count("* as count");
|
||||
|
||||
const recordingsCount = recordings[0]?.count || 0;
|
||||
const playsCount = plays[0]?.count || 0;
|
||||
|
||||
return (recordingsCount >= 50 && playsCount >= 100) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Special: Top 10 rank
|
||||
if (code === "top_10_rank") {
|
||||
const userStats = await database("sexy_user_stats")
|
||||
.where({ user_id: userId })
|
||||
.first();
|
||||
|
||||
if (!userStats) return 0;
|
||||
|
||||
const rank = await database("sexy_user_stats")
|
||||
.where("total_weighted_points", ">", userStats.total_weighted_points)
|
||||
.count("* as count");
|
||||
|
||||
const userRank = (rank[0]?.count || 0) + 1;
|
||||
return userRank <= 10 ? 1 : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate all weighted scores (for cron job)
|
||||
*/
|
||||
export async function recalculateAllWeightedScores(database: Knex): Promise<void> {
|
||||
const users = await database("sexy_user_stats").select("user_id");
|
||||
|
||||
for (const user of users) {
|
||||
await updateUserStats(database, user.user_id);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { checkAchievements } from "./gamification";
|
||||
|
||||
const createPolicyFilter = (policy) => ({
|
||||
_or: [
|
||||
{
|
||||
@@ -728,5 +730,204 @@ export default {
|
||||
res.status(500).json({ error: error.message || "Failed to get analytics" });
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================
|
||||
// GAMIFICATION ENDPOINTS
|
||||
// =========================================
|
||||
|
||||
// GET /sexy/gamification/leaderboard - Get top users by weighted score
|
||||
router.get("/gamification/leaderboard", async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const leaderboard = await database("sexy_user_stats as s")
|
||||
.leftJoin("directus_users as u", "s.user_id", "u.id")
|
||||
.select(
|
||||
"u.id as user_id",
|
||||
"u.artist_name as display_name",
|
||||
"u.avatar",
|
||||
"s.total_weighted_points",
|
||||
"s.total_raw_points",
|
||||
"s.recordings_count",
|
||||
"s.playbacks_count",
|
||||
"s.achievements_count",
|
||||
)
|
||||
.orderBy("s.total_weighted_points", "desc")
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// Add rank to each entry
|
||||
const leaderboardWithRank = leaderboard.map((entry, index) => ({
|
||||
...entry,
|
||||
rank: offset + index + 1,
|
||||
}));
|
||||
|
||||
res.json({ data: leaderboardWithRank });
|
||||
} catch (error: any) {
|
||||
console.error("Leaderboard error:", error);
|
||||
res.status(500).json({ error: error.message || "Failed to get leaderboard" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /sexy/gamification/user/:id - Get gamification stats for a user
|
||||
router.get("/gamification/user/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get user stats
|
||||
const stats = await database("sexy_user_stats")
|
||||
.where({ user_id: id })
|
||||
.first();
|
||||
|
||||
// Calculate rank
|
||||
let rank = 1;
|
||||
if (stats) {
|
||||
const rankResult = await database("sexy_user_stats")
|
||||
.where("total_weighted_points", ">", stats.total_weighted_points)
|
||||
.count("* as count");
|
||||
rank = (rankResult[0]?.count || 0) + 1;
|
||||
}
|
||||
|
||||
// Get unlocked achievements
|
||||
const achievements = await database("sexy_user_achievements as ua")
|
||||
.leftJoin("sexy_achievements as a", "ua.achievement_id", "a.id")
|
||||
.where({ "ua.user_id": id })
|
||||
.whereNotNull("ua.date_unlocked")
|
||||
.select(
|
||||
"a.id",
|
||||
"a.code",
|
||||
"a.name",
|
||||
"a.description",
|
||||
"a.icon",
|
||||
"a.category",
|
||||
"ua.date_unlocked",
|
||||
"ua.progress",
|
||||
"a.required_count",
|
||||
)
|
||||
.orderBy("ua.date_unlocked", "desc");
|
||||
|
||||
// Get recent points
|
||||
const recentPoints = await database("sexy_user_points")
|
||||
.where({ user_id: id })
|
||||
.select("action", "points", "date_created", "recording_id")
|
||||
.orderBy("date_created", "desc")
|
||||
.limit(10);
|
||||
|
||||
res.json({
|
||||
stats: stats ? { ...stats, rank } : null,
|
||||
achievements,
|
||||
recent_points: recentPoints,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("User gamification error:", error);
|
||||
res.status(500).json({ error: error.message || "Failed to get user gamification data" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /sexy/gamification/achievements - Get all achievements
|
||||
router.get("/gamification/achievements", async (req, res) => {
|
||||
try {
|
||||
const achievements = await database("sexy_achievements")
|
||||
.where({ status: "published" })
|
||||
.select(
|
||||
"id",
|
||||
"code",
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
"category",
|
||||
"required_count",
|
||||
"points_reward",
|
||||
)
|
||||
.orderBy("sort", "asc");
|
||||
|
||||
res.json({ data: achievements });
|
||||
} catch (error: any) {
|
||||
console.error("Achievements error:", error);
|
||||
res.status(500).json({ error: error.message || "Failed to get achievements" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sexy/recordings/:id/play - Record a recording play (with gamification)
|
||||
router.post("/recordings/:id/play", async (req, res) => {
|
||||
const accountability = req.accountability;
|
||||
const recordingId = req.params.id;
|
||||
|
||||
try {
|
||||
// Get recording to check ownership
|
||||
const recording = await database("sexy_recordings")
|
||||
.where({ id: recordingId })
|
||||
.first();
|
||||
|
||||
if (!recording) {
|
||||
return res.status(404).json({ error: "Recording not found" });
|
||||
}
|
||||
|
||||
// Record play
|
||||
const play = await database("sexy_recording_plays").insert({
|
||||
user_id: accountability?.user || null,
|
||||
recording_id: recordingId,
|
||||
duration_played: 0,
|
||||
completed: false,
|
||||
date_created: new Date(),
|
||||
}).returning("id");
|
||||
|
||||
const playId = play[0]?.id || play[0];
|
||||
|
||||
// Award points if user is authenticated and not playing own recording
|
||||
if (accountability?.user && recording.user_created !== accountability.user) {
|
||||
const { awardPoints, POINT_VALUES } = await import("./gamification");
|
||||
await awardPoints(database, accountability.user, "RECORDING_PLAY", recordingId);
|
||||
await checkAchievements(database, accountability.user, "playback");
|
||||
}
|
||||
|
||||
res.json({ success: true, play_id: playId });
|
||||
} catch (error: any) {
|
||||
console.error("Recording play error:", error);
|
||||
res.status(500).json({ error: error.message || "Failed to record play" });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /sexy/recordings/:id/play/:playId - Update play progress (with gamification)
|
||||
router.patch("/recordings/:id/play/:playId", async (req, res) => {
|
||||
const { playId } = req.params;
|
||||
const { duration_played, completed } = req.body;
|
||||
const accountability = req.accountability;
|
||||
|
||||
try {
|
||||
// Get existing play record
|
||||
const existingPlay = await database("sexy_recording_plays")
|
||||
.where({ id: playId })
|
||||
.first();
|
||||
|
||||
if (!existingPlay) {
|
||||
return res.status(404).json({ error: "Play record not found" });
|
||||
}
|
||||
|
||||
const wasCompleted = existingPlay.completed;
|
||||
|
||||
// Update play record
|
||||
await database("sexy_recording_plays")
|
||||
.where({ id: playId })
|
||||
.update({
|
||||
duration_played,
|
||||
completed,
|
||||
date_updated: new Date(),
|
||||
});
|
||||
|
||||
// Award completion points if newly completed
|
||||
if (completed && !wasCompleted && accountability?.user) {
|
||||
const { awardPoints } = await import("./gamification");
|
||||
await awardPoints(database, accountability.user, "RECORDING_COMPLETE", existingPlay.recording_id);
|
||||
await checkAchievements(database, accountability.user, "playback");
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Update play error:", error);
|
||||
res.status(500).json({ error: error.message || "Failed to update play" });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user