Files
sexy.pivoine.art/packages/bundle/src/endpoint/index.ts
Valknar XXX 0b08ce5900 wip: add custom /sexy/models endpoint to bypass permissions
- 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>
2025-10-28 11:04:34 +01:00

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