diff --git a/gamification-schema.sql b/gamification-schema.sql new file mode 100644 index 0000000..f6cb79e --- /dev/null +++ b/gamification-schema.sql @@ -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; diff --git a/packages/bundle/src/endpoint/gamification.ts b/packages/bundle/src/endpoint/gamification.ts new file mode 100644 index 0000000..56a5f2a --- /dev/null +++ b/packages/bundle/src/endpoint/gamification.ts @@ -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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + const users = await database("sexy_user_stats").select("user_id"); + + for (const user of users) { + await updateUserStats(database, user.user_id); + } +} diff --git a/packages/bundle/src/endpoint/index.ts b/packages/bundle/src/endpoint/index.ts index 5083cb8..3178309 100644 --- a/packages/bundle/src/endpoint/index.ts +++ b/packages/bundle/src/endpoint/index.ts @@ -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" }); + } + }); }, };