Handle tags field when sent as JSON string by parsing it into an array before insertion. Fixes "malformed array literal" error when saving recordings with tags. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
934 lines
26 KiB
TypeScript
934 lines
26 KiB
TypeScript
import { checkAchievements } from "./gamification";
|
|
|
|
const createPolicyFilter = (policy) => ({
|
|
_or: [
|
|
{
|
|
policies: {
|
|
policy: {
|
|
name: {
|
|
_eq: policy,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
role: {
|
|
name: {
|
|
_eq: policy,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
export default {
|
|
id: "sexy",
|
|
handler: (router, context) => {
|
|
const { services, getSchema, database } = context;
|
|
const { ItemsService } = services;
|
|
|
|
router.get("/stats", async (_req, res) => {
|
|
const usersService = new ItemsService("directus_users", {
|
|
schema: await getSchema(),
|
|
});
|
|
const modelsCount = await usersService.readByQuery({
|
|
aggregate: {
|
|
count: ["*"],
|
|
},
|
|
filter: createPolicyFilter("Model"),
|
|
});
|
|
const viewersCount = await usersService.readByQuery({
|
|
aggregate: {
|
|
count: ["*"],
|
|
},
|
|
filter: createPolicyFilter("Viewer"),
|
|
});
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
schema: await getSchema(),
|
|
});
|
|
const videosCount = await videosService.readByQuery({
|
|
aggregate: {
|
|
count: ["*"],
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
models_count: modelsCount[0].count,
|
|
viewers_count: viewersCount[0].count,
|
|
videos_count: videosCount[0].count,
|
|
});
|
|
});
|
|
|
|
// GET /sexy/models - Public endpoint to fetch models (bypasses permissions)
|
|
router.get("/models", async (req, res) => {
|
|
try {
|
|
const { featured, limit } = req.query;
|
|
|
|
// Build query using Knex to bypass permissions
|
|
let query = database
|
|
.select("u.*")
|
|
.from("directus_users as u")
|
|
.leftJoin("directus_roles as r", "u.role", "r.id")
|
|
.where("r.name", "Model")
|
|
.orderBy("u.id", "desc");
|
|
|
|
|
|
if (limit) {
|
|
query = query.limit(parseInt(limit as string));
|
|
}
|
|
|
|
const models = await query;
|
|
|
|
// Fetch related photos and banner for each model
|
|
for (const model of models) {
|
|
// Fetch photos
|
|
const photos = await database
|
|
.select("df.*")
|
|
.from("sexy_model_photos as mp")
|
|
.leftJoin("directus_files as df", "mp.directus_files_id", "df.id")
|
|
.where("mp.directus_users_id", model.id);
|
|
|
|
model.photos = photos.map((p) => ({ directus_files_id: p }));
|
|
|
|
// Fetch banner
|
|
if (model.banner) {
|
|
const banner = await database
|
|
.select("*")
|
|
.from("directus_files")
|
|
.where("id", model.banner)
|
|
.first();
|
|
model.banner = banner;
|
|
}
|
|
}
|
|
|
|
res.json(models);
|
|
} catch (error: any) {
|
|
console.error("Models endpoint error:", error);
|
|
res.status(500).json({ error: error.message || "Failed to fetch models" });
|
|
}
|
|
});
|
|
|
|
// GET /sexy/models/:slug - Get single model by slug
|
|
router.get("/models/:slug", async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
|
|
const model = await database
|
|
.select("u.*")
|
|
.from("directus_users as u")
|
|
.leftJoin("directus_roles as r", "u.role", "r.id")
|
|
.where("r.name", "Model")
|
|
.where(database.raw("LOWER(u.first_name || ' ' || u.last_name)"), slug.toLowerCase().replace(/-/g, " "))
|
|
.first();
|
|
|
|
if (!model) {
|
|
return res.status(404).json({ error: "Model not found" });
|
|
}
|
|
|
|
// Fetch photos
|
|
const photos = await database
|
|
.select("df.*")
|
|
.from("sexy_model_photos as mp")
|
|
.leftJoin("directus_files as df", "mp.directus_files_id", "df.id")
|
|
.where("mp.directus_users_id", model.id);
|
|
|
|
model.photos = photos.map((p) => ({ directus_files_id: p }));
|
|
|
|
// Fetch banner
|
|
if (model.banner) {
|
|
const banner = await database
|
|
.select("*")
|
|
.from("directus_files")
|
|
.where("id", model.banner)
|
|
.first();
|
|
model.banner = banner;
|
|
}
|
|
|
|
res.json(model);
|
|
} catch (error: any) {
|
|
console.error("Model by slug error:", error);
|
|
res.status(500).json({ error: error.message || "Failed to fetch model" });
|
|
}
|
|
});
|
|
|
|
// GET /sexy/videos - List videos
|
|
router.get("/videos", async (req, res) => {
|
|
try {
|
|
const { model_id, limit } = req.query;
|
|
|
|
let query = database
|
|
.select("v.*")
|
|
.from("sexy_videos as v")
|
|
.where("v.upload_date", "<=", new Date().toISOString())
|
|
.orderBy("v.upload_date", "desc");
|
|
|
|
if (model_id) {
|
|
query = query
|
|
.leftJoin("sexy_videos_models as vm", "v.id", "vm.sexy_videos_id")
|
|
.where("vm.directus_users_id", model_id);
|
|
}
|
|
|
|
if (limit) {
|
|
query = query.limit(parseInt(limit as string));
|
|
}
|
|
|
|
const videos = await query;
|
|
|
|
// Fetch models and movie for each video
|
|
for (const video of videos) {
|
|
// Fetch models
|
|
const models = await database
|
|
.select("u.*")
|
|
.from("sexy_videos_models as vm")
|
|
.leftJoin("directus_users as u", "vm.directus_users_id", "u.id")
|
|
.where("vm.sexy_videos_id", video.id);
|
|
|
|
video.models = models;
|
|
|
|
// Fetch movie file
|
|
if (video.movie) {
|
|
const movie = await database
|
|
.select("*")
|
|
.from("directus_files")
|
|
.where("id", video.movie)
|
|
.first();
|
|
video.movie = movie;
|
|
}
|
|
}
|
|
|
|
res.json(videos);
|
|
} catch (error: any) {
|
|
console.error("Videos endpoint error:", error);
|
|
res.status(500).json({ error: error.message || "Failed to fetch videos" });
|
|
}
|
|
});
|
|
|
|
// GET /sexy/articles - List articles
|
|
router.get("/articles", async (req, res) => {
|
|
try {
|
|
const { featured, limit } = req.query;
|
|
|
|
let query = database
|
|
.select("a.*")
|
|
.from("sexy_articles as a")
|
|
.where("a.publish_date", "<=", new Date().toISOString())
|
|
.orderBy("a.publish_date", "desc");
|
|
|
|
query = query.where("a.featured", true);
|
|
|
|
if (limit) {
|
|
query = query.limit(parseInt(limit as string));
|
|
}
|
|
|
|
const articles = await query;
|
|
|
|
// Fetch author for each article
|
|
for (const article of articles) {
|
|
if (article.author) {
|
|
const author = await database
|
|
.select("*")
|
|
.from("directus_users")
|
|
.where("id", article.author)
|
|
.first();
|
|
article.author = author;
|
|
}
|
|
}
|
|
|
|
res.json(articles);
|
|
} catch (error: any) {
|
|
console.error("Articles endpoint error:", error);
|
|
res.status(500).json({ error: error.message || "Failed to fetch articles" });
|
|
}
|
|
});
|
|
|
|
// GET /sexy/recordings - List user's recordings
|
|
router.get("/recordings", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
const { status, tags, linked_video, limit, page } = req.query;
|
|
const filter: any = {
|
|
user_created: {
|
|
_eq: accountability.user,
|
|
},
|
|
};
|
|
|
|
if (status) filter.status = { _eq: status };
|
|
if (tags) filter.tags = { _contains: tags };
|
|
if (linked_video) filter.linked_video = { _eq: linked_video };
|
|
|
|
const recordings = await recordingsService.readByQuery({
|
|
filter,
|
|
limit: limit ? parseInt(limit as string) : 50,
|
|
page: page ? parseInt(page as string) : 1,
|
|
sort: ["-date_created"],
|
|
});
|
|
|
|
res.json(recordings);
|
|
});
|
|
|
|
// GET /sexy/recordings/:id - Get single recording
|
|
router.get("/recordings/:id", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
try {
|
|
const recording = await recordingsService.readOne(req.params.id);
|
|
|
|
// Check if user owns the recording or if it's public
|
|
if (
|
|
recording.user_created !== accountability.user &&
|
|
!recording.public
|
|
) {
|
|
return res.status(403).json({ error: "Forbidden" });
|
|
}
|
|
|
|
res.json(recording);
|
|
} catch (error) {
|
|
res.status(404).json({ error: "Recording not found" });
|
|
}
|
|
});
|
|
|
|
// POST /sexy/recordings - Create new recording
|
|
router.post("/recordings", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
const { title, description, duration, events, device_info, tags, linked_video, status } = req.body;
|
|
|
|
// Validate required fields
|
|
if (!title || !duration || !events || !device_info) {
|
|
return res.status(400).json({
|
|
error: "Missing required fields: title, duration, events, device_info",
|
|
});
|
|
}
|
|
|
|
// Validate events structure
|
|
if (!Array.isArray(events) || events.length === 0) {
|
|
return res.status(400).json({ error: "Events must be a non-empty array" });
|
|
}
|
|
|
|
// Generate slug from title
|
|
const slug = title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
|
|
try {
|
|
const recording = await recordingsService.createOne({
|
|
title,
|
|
description,
|
|
slug,
|
|
duration,
|
|
events,
|
|
device_info,
|
|
tags: Array.isArray(tags) ? tags : (typeof tags === 'string' ? (tags.startsWith('[') ? JSON.parse(tags) : []) : []),
|
|
linked_video: linked_video || null,
|
|
status: status || "draft",
|
|
public: false,
|
|
});
|
|
|
|
res.status(201).json(recording);
|
|
} catch (error: any) {
|
|
console.error("Failed to create recording:", error);
|
|
res.status(500).json({
|
|
error: error.message || "Failed to create recording",
|
|
details: error.toString()
|
|
});
|
|
}
|
|
});
|
|
|
|
// PATCH /sexy/recordings/:id - Update recording
|
|
router.patch("/recordings/:id", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
try {
|
|
const existing = await recordingsService.readOne(req.params.id);
|
|
|
|
// Only allow owner to update
|
|
if (existing.user_created !== accountability.user) {
|
|
return res.status(403).json({ error: "Forbidden" });
|
|
}
|
|
|
|
const { title, description, tags, status, public: isPublic, linked_video } = req.body;
|
|
const updates: any = {};
|
|
|
|
if (title !== undefined) {
|
|
updates.title = title;
|
|
updates.slug = title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
if (description !== undefined) updates.description = description;
|
|
if (tags !== undefined) updates.tags = tags;
|
|
if (status !== undefined) updates.status = status;
|
|
if (isPublic !== undefined) updates.public = isPublic;
|
|
if (linked_video !== undefined) updates.linked_video = linked_video;
|
|
|
|
const recording = await recordingsService.updateOne(req.params.id, updates);
|
|
res.json(recording);
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to update recording" });
|
|
}
|
|
});
|
|
|
|
// DELETE /sexy/recordings/:id - Delete (archive) recording
|
|
router.delete("/recordings/:id", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const recordingsService = new ItemsService("sexy_recordings", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
try {
|
|
const existing = await recordingsService.readOne(req.params.id);
|
|
|
|
// Only allow owner to delete
|
|
if (existing.user_created !== accountability.user) {
|
|
return res.status(403).json({ error: "Forbidden" });
|
|
}
|
|
|
|
// Soft delete by setting status to archived
|
|
await recordingsService.updateOne(req.params.id, {
|
|
status: "archived",
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to delete recording" });
|
|
}
|
|
});
|
|
|
|
// POST /sexy/videos/:id/like - Like a video
|
|
router.post("/videos/:id/like", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const videoId = req.params.id;
|
|
const userId = accountability.user;
|
|
|
|
try {
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
// Check if already liked
|
|
const existing = await likesService.readByQuery({
|
|
filter: { video_id: videoId, user_id: userId },
|
|
limit: 1,
|
|
});
|
|
|
|
if (existing.length > 0) {
|
|
return res.status(400).json({ error: "Already liked" });
|
|
}
|
|
|
|
// Create like
|
|
await likesService.createOne({
|
|
video_id: videoId,
|
|
user_id: userId,
|
|
});
|
|
|
|
// Increment likes_count
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
schema: await getSchema(),
|
|
});
|
|
const video = await videosService.readOne(videoId);
|
|
await videosService.updateOne(videoId, {
|
|
likes_count: (video.likes_count || 0) + 1,
|
|
});
|
|
|
|
res.json({ liked: true, likes_count: (video.likes_count || 0) + 1 });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to like video" });
|
|
}
|
|
});
|
|
|
|
// DELETE /sexy/videos/:id/like - Unlike a video
|
|
router.delete("/videos/:id/like", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const videoId = req.params.id;
|
|
const userId = accountability.user;
|
|
|
|
try {
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
// Find and delete like
|
|
const existing = await likesService.readByQuery({
|
|
filter: { video_id: videoId, user_id: userId },
|
|
limit: 1,
|
|
});
|
|
|
|
if (existing.length === 0) {
|
|
return res.status(400).json({ error: "Not liked" });
|
|
}
|
|
|
|
await likesService.deleteOne(existing[0].id);
|
|
|
|
// Decrement likes_count
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
schema: await getSchema(),
|
|
});
|
|
const video = await videosService.readOne(videoId);
|
|
await videosService.updateOne(videoId, {
|
|
likes_count: Math.max((video.likes_count || 0) - 1, 0),
|
|
});
|
|
|
|
res.json({ liked: false, likes_count: Math.max((video.likes_count || 0) - 1, 0) });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to unlike video" });
|
|
}
|
|
});
|
|
|
|
// GET /sexy/videos/:id/like-status - Get like status for a video
|
|
router.get("/videos/:id/like-status", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.json({ liked: false });
|
|
}
|
|
|
|
const videoId = req.params.id;
|
|
const userId = accountability.user;
|
|
|
|
try {
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
schema: await getSchema(),
|
|
accountability,
|
|
});
|
|
|
|
const existing = await likesService.readByQuery({
|
|
filter: { video_id: videoId, user_id: userId },
|
|
limit: 1,
|
|
});
|
|
|
|
res.json({ liked: existing.length > 0 });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to get like status" });
|
|
}
|
|
});
|
|
|
|
// POST /sexy/videos/:id/play - Record a video play
|
|
router.post("/videos/:id/play", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
const videoId = req.params.id;
|
|
const { session_id } = req.body;
|
|
|
|
try {
|
|
const playsService = new ItemsService("sexy_video_plays", {
|
|
schema: await getSchema(),
|
|
});
|
|
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
schema: await getSchema(),
|
|
});
|
|
|
|
// Record play
|
|
const play = await playsService.createOne({
|
|
video_id: videoId,
|
|
user_id: accountability?.user || null,
|
|
session_id: session_id || null,
|
|
});
|
|
|
|
// Increment plays_count
|
|
const video = await videosService.readOne(videoId);
|
|
await videosService.updateOne(videoId, {
|
|
plays_count: (video.plays_count || 0) + 1,
|
|
});
|
|
|
|
res.json({ success: true, play_id: play, plays_count: (video.plays_count || 0) + 1 });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to record play" });
|
|
}
|
|
});
|
|
|
|
// PATCH /sexy/videos/:id/play/:playId - Update play progress
|
|
router.patch("/videos/:id/play/:playId", async (req, res) => {
|
|
const { playId } = req.params;
|
|
const { duration_watched, completed } = req.body;
|
|
|
|
try {
|
|
const playsService = new ItemsService("sexy_video_plays", {
|
|
schema: await getSchema(),
|
|
});
|
|
|
|
await playsService.updateOne(playId, {
|
|
duration_watched,
|
|
completed,
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to update play" });
|
|
}
|
|
});
|
|
|
|
// GET /sexy/analytics - Get analytics for the authenticated user's content
|
|
router.get("/analytics", async (req, res) => {
|
|
const accountability = req.accountability;
|
|
if (!accountability?.user) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
try {
|
|
const userId = accountability.user;
|
|
|
|
// Get all videos by this user
|
|
const videosService = new ItemsService("sexy_videos", {
|
|
schema: await getSchema(),
|
|
});
|
|
|
|
const videos = await videosService.readByQuery({
|
|
filter: {
|
|
models: {
|
|
directus_users_id: {
|
|
_eq: userId,
|
|
},
|
|
},
|
|
},
|
|
fields: ["id", "title", "slug", "likes_count", "plays_count", "upload_date"],
|
|
limit: -1,
|
|
});
|
|
|
|
if (videos.length === 0) {
|
|
return res.json({
|
|
total_videos: 0,
|
|
total_likes: 0,
|
|
total_plays: 0,
|
|
videos: [],
|
|
});
|
|
}
|
|
|
|
const videoIds = videos.map((v) => v.id);
|
|
|
|
// Get play analytics
|
|
const playsService = new ItemsService("sexy_video_plays", {
|
|
schema: await getSchema(),
|
|
});
|
|
|
|
const plays = await playsService.readByQuery({
|
|
filter: {
|
|
video_id: {
|
|
_in: videoIds,
|
|
},
|
|
},
|
|
fields: ["video_id", "date_created", "duration_watched", "completed"],
|
|
limit: -1,
|
|
});
|
|
|
|
// Get like analytics
|
|
const likesService = new ItemsService("sexy_video_likes", {
|
|
schema: await getSchema(),
|
|
});
|
|
|
|
const likes = await likesService.readByQuery({
|
|
filter: {
|
|
video_id: {
|
|
_in: videoIds,
|
|
},
|
|
},
|
|
fields: ["video_id", "date_created"],
|
|
limit: -1,
|
|
});
|
|
|
|
// Calculate totals
|
|
const totalLikes = videos.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
|
const totalPlays = videos.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
|
|
|
// Group plays by date for timeline
|
|
const playsByDate = plays.reduce((acc, play) => {
|
|
const date = new Date(play.date_created).toISOString().split("T")[0];
|
|
if (!acc[date]) acc[date] = 0;
|
|
acc[date]++;
|
|
return acc;
|
|
}, {});
|
|
|
|
// Group likes by date for timeline
|
|
const likesByDate = likes.reduce((acc, like) => {
|
|
const date = new Date(like.date_created).toISOString().split("T")[0];
|
|
if (!acc[date]) acc[date] = 0;
|
|
acc[date]++;
|
|
return acc;
|
|
}, {});
|
|
|
|
// Video-specific analytics
|
|
const videoAnalytics = videos.map((video) => {
|
|
const videoPlays = plays.filter((p) => p.video_id === video.id);
|
|
const completedPlays = videoPlays.filter((p) => p.completed).length;
|
|
const avgWatchTime =
|
|
videoPlays.length > 0
|
|
? videoPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) /
|
|
videoPlays.length
|
|
: 0;
|
|
|
|
return {
|
|
id: video.id,
|
|
title: video.title,
|
|
slug: video.slug,
|
|
upload_date: video.upload_date,
|
|
likes: video.likes_count || 0,
|
|
plays: video.plays_count || 0,
|
|
completed_plays: completedPlays,
|
|
completion_rate: video.plays_count ? (completedPlays / video.plays_count) * 100 : 0,
|
|
avg_watch_time: Math.round(avgWatchTime),
|
|
};
|
|
});
|
|
|
|
res.json({
|
|
total_videos: videos.length,
|
|
total_likes: totalLikes,
|
|
total_plays: totalPlays,
|
|
plays_by_date: playsByDate,
|
|
likes_by_date: likesByDate,
|
|
videos: videoAnalytics,
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Analytics error:", error);
|
|
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" });
|
|
}
|
|
});
|
|
},
|
|
};
|