- Add /sexy/models endpoint in bundle with accountability: null - Update getModels() and getFeaturedModels() to use custom endpoint - Still experiencing permissions issues with field access - Need to configure Directus public role permissions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
578 lines
16 KiB
TypeScript
578 lines
16 KiB
TypeScript
const createPolicyFilter = (policy) => ({
|
|
_or: [
|
|
{
|
|
policies: {
|
|
policy: {
|
|
name: {
|
|
_eq: policy,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
role: {
|
|
name: {
|
|
_eq: policy,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
export default {
|
|
id: "sexy",
|
|
handler: (router, context) => {
|
|
const { services, getSchema } = 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;
|
|
const usersService = new ItemsService("directus_users", {
|
|
schema: await getSchema(),
|
|
accountability: null,
|
|
});
|
|
|
|
const filter: any = createPolicyFilter("Model");
|
|
if (featured === "true") {
|
|
filter._and = [filter, { featured: { _eq: true } }];
|
|
}
|
|
|
|
const models = await usersService.readByQuery({
|
|
filter,
|
|
fields: ["*", "photos.directus_files_id.*", "banner.*"],
|
|
sort: ["-id"],
|
|
limit: limit ? parseInt(limit as string) : -1,
|
|
});
|
|
|
|
res.json(models);
|
|
} catch (error: any) {
|
|
res.status(500).json({ error: error.message || "Failed to fetch models" });
|
|
}
|
|
});
|
|
|
|
// 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" });
|
|
}
|
|
});
|
|
},
|
|
};
|