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