- Add GET /sexy/videos/:slug endpoint in bundle that bypasses Directus permissions - Simplify getVideoBySlug to use the new custom endpoint instead of direct API call - Fixes "Video not found" error on production for public video pages - Custom endpoint uses database query like other public endpoints (/sexy/models, etc) - Ensures videos are accessible to unauthenticated users while respecting upload_date 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
625 lines
14 KiB
TypeScript
625 lines
14 KiB
TypeScript
import { getDirectusInstance, directusApiUrl } from "$lib/directus";
|
|
import {
|
|
readItems,
|
|
registerUser,
|
|
updateMe,
|
|
readMe,
|
|
registerUserVerify,
|
|
passwordRequest,
|
|
passwordReset,
|
|
customEndpoint,
|
|
readFolders,
|
|
deleteFile,
|
|
uploadFiles,
|
|
createComment,
|
|
readComments,
|
|
aggregate,
|
|
} from "@directus/sdk";
|
|
import type { Analytics, Article, Model, Recording, Stats, User, Video, VideoLikeStatus, VideoLikeResponse, VideoPlayResponse } from "$lib/types";
|
|
import { PUBLIC_URL } from "$env/static/public";
|
|
import { logger } from "$lib/logger";
|
|
|
|
// Helper to log API calls
|
|
async function loggedApiCall<T>(
|
|
operationName: string,
|
|
operation: () => Promise<T>,
|
|
context?: Record<string, unknown>
|
|
): Promise<T> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
logger.debug(`🔄 API: ${operationName}`, { context });
|
|
const result = await operation();
|
|
const duration = Date.now() - startTime;
|
|
logger.info(`✅ API: ${operationName} succeeded`, { duration, context });
|
|
return result;
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
logger.error(`❌ API: ${operationName} failed`, {
|
|
duration,
|
|
context,
|
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const userFields = [
|
|
"*",
|
|
{
|
|
avatar: ["*"],
|
|
policies: ["*", { policy: ["name", "id"] }],
|
|
role: ["*", { policies: [{ policy: ["name", "id"] }] }],
|
|
},
|
|
];
|
|
|
|
export async function isAuthenticated(token: string) {
|
|
return loggedApiCall(
|
|
"isAuthenticated",
|
|
async () => {
|
|
try {
|
|
const directus = getDirectusInstance(fetch);
|
|
directus.setToken(token);
|
|
const user = await directus.request(
|
|
readMe({
|
|
fields: userFields,
|
|
}),
|
|
);
|
|
return { authenticated: true, user };
|
|
} catch {
|
|
return { authenticated: false };
|
|
}
|
|
},
|
|
{ hasToken: !!token },
|
|
);
|
|
}
|
|
|
|
export async function register(
|
|
email: string,
|
|
password: string,
|
|
firstName: string,
|
|
lastName: string,
|
|
) {
|
|
return loggedApiCall(
|
|
"register",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(
|
|
registerUser(email, password, {
|
|
verification_url: `${PUBLIC_URL || "http://localhost:3000"}/signup/verify`,
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
}),
|
|
);
|
|
},
|
|
{ email, firstName, lastName },
|
|
);
|
|
}
|
|
|
|
export async function verify(token: string, fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall(
|
|
"verify",
|
|
async () => {
|
|
const directus = fetch
|
|
? getDirectusInstance((args) => fetch(args, { redirect: "manual" }))
|
|
: getDirectusInstance(fetch);
|
|
return directus.request(registerUserVerify(token));
|
|
},
|
|
{ hasToken: !!token },
|
|
);
|
|
}
|
|
|
|
export async function login(email: string, password: string) {
|
|
return loggedApiCall(
|
|
"login",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.login({ email, password });
|
|
},
|
|
{ email },
|
|
);
|
|
}
|
|
|
|
export async function logout() {
|
|
return loggedApiCall("logout", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.logout();
|
|
});
|
|
}
|
|
|
|
export async function requestPassword(email: string) {
|
|
return loggedApiCall(
|
|
"requestPassword",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(
|
|
passwordRequest(email, `${PUBLIC_URL || "http://localhost:3000"}/password/reset`),
|
|
);
|
|
},
|
|
{ email },
|
|
);
|
|
}
|
|
|
|
export async function resetPassword(token: string, password: string) {
|
|
return loggedApiCall(
|
|
"resetPassword",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(passwordReset(token, password));
|
|
},
|
|
{ hasToken: !!token },
|
|
);
|
|
}
|
|
|
|
export async function getArticles(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall("getArticles", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Article[]>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: "/sexy/articles",
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function getArticleBySlug(
|
|
slug: string,
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getArticleBySlug",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus
|
|
.request<Article[]>(
|
|
readItems("sexy_articles", {
|
|
fields: ["*", "author.*"],
|
|
filter: { slug: { _eq: slug } },
|
|
}),
|
|
)
|
|
.then((articles) => {
|
|
if (articles.length === 0) {
|
|
throw new Error("Article not found");
|
|
}
|
|
return articles[0];
|
|
});
|
|
},
|
|
{ slug },
|
|
);
|
|
}
|
|
|
|
export async function getVideos(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall("getVideos", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Video[]>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: "/sexy/videos",
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function getVideosForModel(id, fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall(
|
|
"getVideosForModel",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Video[]>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: `/sexy/videos?model_id=${id}`,
|
|
}),
|
|
);
|
|
},
|
|
{ modelId: id },
|
|
);
|
|
}
|
|
|
|
export async function getFeaturedVideos(
|
|
limit: number,
|
|
fetchFn: typeof globalThis.fetch = globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getFeaturedVideos",
|
|
async () => {
|
|
const url = `${PUBLIC_URL}/api/sexy/videos?featured=true&limit=${limit}`;
|
|
const response = await fetchFn(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch featured videos: ${response.statusText}`);
|
|
}
|
|
return (await response.json()) as Video[];
|
|
},
|
|
{ limit },
|
|
);
|
|
}
|
|
|
|
export async function getVideoBySlug(
|
|
slug: string,
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getVideoBySlug",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Video>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: `/sexy/videos/${slug}`,
|
|
}),
|
|
);
|
|
},
|
|
{ slug },
|
|
);
|
|
}
|
|
|
|
export async function getModels(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall("getModels", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Model[]>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: "/sexy/models",
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function getFeaturedModels(
|
|
limit = 3,
|
|
fetchFn: typeof globalThis.fetch = globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getFeaturedModels",
|
|
async () => {
|
|
const url = `${PUBLIC_URL}/api/sexy/models?featured=true&limit=${limit}`;
|
|
const response = await fetchFn(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch featured models: ${response.statusText}`);
|
|
}
|
|
return (await response.json()) as Model[];
|
|
},
|
|
{ limit },
|
|
);
|
|
}
|
|
|
|
export async function getModelBySlug(
|
|
slug: string,
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getModelBySlug",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Model>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: `/sexy/models/${slug}`,
|
|
}),
|
|
);
|
|
},
|
|
{ slug },
|
|
);
|
|
}
|
|
|
|
export async function updateProfile(user: Partial<User>) {
|
|
return loggedApiCall(
|
|
"updateProfile",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<User>(updateMe(user as never));
|
|
},
|
|
{ userId: user.id },
|
|
);
|
|
}
|
|
|
|
export async function getStats(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall("getStats", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Stats>(
|
|
customEndpoint({
|
|
path: "/sexy/stats",
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function getFolders(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall("getFolders", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(readFolders());
|
|
});
|
|
}
|
|
|
|
export async function removeFile(id: string) {
|
|
return loggedApiCall(
|
|
"removeFile",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(deleteFile(id));
|
|
},
|
|
{ fileId: id },
|
|
);
|
|
}
|
|
|
|
export async function uploadFile(data: FormData) {
|
|
return loggedApiCall("uploadFile", async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(uploadFiles(data));
|
|
});
|
|
}
|
|
|
|
export async function createCommentForVideo(item: string, comment: string) {
|
|
return loggedApiCall(
|
|
"createCommentForVideo",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(
|
|
createComment({
|
|
collection: "sexy_videos",
|
|
item,
|
|
comment,
|
|
}),
|
|
);
|
|
},
|
|
{ videoId: item, commentLength: comment.length },
|
|
);
|
|
}
|
|
|
|
export async function getCommentsForVideo(
|
|
item: string,
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getCommentsForVideo",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(
|
|
readComments({
|
|
fields: ["*", { user_created: ["*"] }],
|
|
filter: { collection: "sexy_videos", item },
|
|
sort: ["-date_created"],
|
|
}),
|
|
);
|
|
},
|
|
{ videoId: item },
|
|
);
|
|
}
|
|
|
|
export async function countCommentsForModel(
|
|
user_created: string,
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"countCommentsForModel",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus
|
|
.request<[{ count: number }]>(
|
|
aggregate("directus_comments", {
|
|
aggregate: {
|
|
count: "*",
|
|
},
|
|
query: {
|
|
filter: { user_created },
|
|
},
|
|
}),
|
|
)
|
|
.then((result) => result[0].count);
|
|
},
|
|
{ userId: user_created },
|
|
);
|
|
}
|
|
|
|
export async function getItemsByTag(
|
|
category: "video" | "article" | "model",
|
|
tag: string,
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"getItemsByTag",
|
|
async () => {
|
|
switch (category) {
|
|
case "video":
|
|
return getVideos(fetch);
|
|
case "model":
|
|
return getModels(fetch);
|
|
case "article":
|
|
return getArticles(fetch);
|
|
}
|
|
},
|
|
{ category, tag },
|
|
);
|
|
}
|
|
|
|
export async function getRecordings(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall(
|
|
"getRecordings",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
const response = await directus.request<Recording[]>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: "/sexy/recordings",
|
|
}),
|
|
);
|
|
return response;
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function createRecording(
|
|
recording: {
|
|
title: string;
|
|
description?: string;
|
|
duration: number;
|
|
events: unknown[];
|
|
device_info: unknown[];
|
|
tags?: string[];
|
|
status?: string;
|
|
},
|
|
fetch?: typeof globalThis.fetch,
|
|
) {
|
|
return loggedApiCall(
|
|
"createRecording",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
const response = await directus.request<Recording>(
|
|
customEndpoint({
|
|
method: "POST",
|
|
path: "/sexy/recordings",
|
|
body: JSON.stringify(recording),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
}),
|
|
);
|
|
return response;
|
|
},
|
|
{ title: recording.title, eventCount: recording.events.length },
|
|
);
|
|
}
|
|
|
|
export async function deleteRecording(id: string) {
|
|
return loggedApiCall(
|
|
"deleteRecording",
|
|
async () => {
|
|
const directus = getDirectusInstance();
|
|
await directus.request(
|
|
customEndpoint({
|
|
method: "DELETE",
|
|
path: `/sexy/recordings/${id}`,
|
|
}),
|
|
);
|
|
},
|
|
{ id },
|
|
);
|
|
}
|
|
|
|
export async function getRecording(id: string, fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall(
|
|
"getRecording",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
const response = await directus.request<Recording>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: `/sexy/recordings/${id}`,
|
|
}),
|
|
);
|
|
return response;
|
|
},
|
|
{ id },
|
|
);
|
|
}
|
|
|
|
export async function likeVideo(videoId: string) {
|
|
return loggedApiCall(
|
|
"likeVideo",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<VideoLikeResponse>(
|
|
customEndpoint({
|
|
method: "POST",
|
|
path: `/sexy/videos/${videoId}/like`,
|
|
})
|
|
);
|
|
},
|
|
{ videoId }
|
|
);
|
|
}
|
|
|
|
export async function unlikeVideo(videoId: string) {
|
|
return loggedApiCall(
|
|
"unlikeVideo",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<VideoLikeResponse>(
|
|
customEndpoint({
|
|
method: "DELETE",
|
|
path: `/sexy/videos/${videoId}/like`,
|
|
})
|
|
);
|
|
},
|
|
{ videoId }
|
|
);
|
|
}
|
|
|
|
export async function getVideoLikeStatus(videoId: string, fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall(
|
|
"getVideoLikeStatus",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<VideoLikeStatus>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: `/sexy/videos/${videoId}/like-status`,
|
|
})
|
|
);
|
|
},
|
|
{ videoId }
|
|
);
|
|
}
|
|
|
|
export async function recordVideoPlay(videoId: string, sessionId?: string) {
|
|
return loggedApiCall(
|
|
"recordVideoPlay",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<VideoPlayResponse>(
|
|
customEndpoint({
|
|
method: "POST",
|
|
path: `/sexy/videos/${videoId}/play`,
|
|
body: JSON.stringify({ session_id: sessionId }),
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
},
|
|
{ videoId }
|
|
);
|
|
}
|
|
|
|
export async function updateVideoPlay(
|
|
videoId: string,
|
|
playId: string,
|
|
durationWatched: number,
|
|
completed: boolean
|
|
) {
|
|
return loggedApiCall(
|
|
"updateVideoPlay",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request(
|
|
customEndpoint({
|
|
method: "PATCH",
|
|
path: `/sexy/videos/${videoId}/play/${playId}`,
|
|
body: JSON.stringify({
|
|
duration_watched: durationWatched,
|
|
completed,
|
|
}),
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
},
|
|
{ videoId, playId, durationWatched, completed }
|
|
);
|
|
}
|
|
|
|
export async function getAnalytics(fetch?: typeof globalThis.fetch) {
|
|
return loggedApiCall(
|
|
"getAnalytics",
|
|
async () => {
|
|
const directus = getDirectusInstance(fetch);
|
|
return directus.request<Analytics>(
|
|
customEndpoint({
|
|
method: "GET",
|
|
path: "/sexy/analytics",
|
|
})
|
|
);
|
|
},
|
|
{}
|
|
);
|
|
}
|