Files
sexy.pivoine.art/packages/bundle/src/endpoint/index.ts
Valknar XXX 47016a2d5c fix: parse tags JSON string in recording creation
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>
2025-10-28 13:44:35 +01:00

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" });
}
});
},
};