import { gql, GraphQLClient } from "graphql-request"; import { apiUrl, getGraphQLClient } from "$lib/api"; import type { Analytics, Article, CurrentUser, Model, Recording, Stats, User, Video, VideoLikeStatus, VideoLikeResponse, VideoPlayResponse, } from "$lib/types"; import { logger } from "$lib/logger"; // Helper to log API calls async function loggedApiCall( operationName: string, operation: () => Promise, context?: Record, ): Promise { 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; } } // For server-side auth checks: forward cookie header manually function getAuthClient(token: string, fetchFn?: typeof globalThis.fetch) { return new GraphQLClient(`${apiUrl}/graphql`, { fetch: fetchFn || globalThis.fetch, headers: { cookie: `session_token=${token}` }, }); } // ─── Auth ──────────────────────────────────────────────────────────────────── const ME_QUERY = gql` query Me { me { id email first_name last_name artist_name slug description tags role is_admin avatar banner email_verified date_created } } `; export async function isAuthenticated(token: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "isAuthenticated", async () => { try { const client = getAuthClient(token, fetchFn); const data = await client.request<{ me: CurrentUser | null }>(ME_QUERY); if (data.me) { return { authenticated: true, user: data.me }; } return { authenticated: false }; } catch { return { authenticated: false }; } }, { hasToken: !!token }, ); } const LOGIN_MUTATION = gql` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { id email first_name last_name artist_name slug description tags role avatar banner email_verified date_created } } `; export async function login(email: string, password: string) { return loggedApiCall( "login", async () => { const data = await getGraphQLClient().request<{ login: CurrentUser }>(LOGIN_MUTATION, { email, password, }); return data.login; }, { email }, ); } const LOGOUT_MUTATION = gql` mutation Logout { logout } `; export async function logout() { return loggedApiCall("logout", async () => { await getGraphQLClient().request(LOGOUT_MUTATION); }); } const REGISTER_MUTATION = gql` mutation Register($email: String!, $password: String!, $firstName: String!, $lastName: String!) { register(email: $email, password: $password, firstName: $firstName, lastName: $lastName) } `; export async function register( email: string, password: string, firstName: string, lastName: string, ) { return loggedApiCall( "register", async () => { await getGraphQLClient().request(REGISTER_MUTATION, { email, password, firstName, lastName, }); }, { email, firstName, lastName }, ); } const VERIFY_EMAIL_MUTATION = gql` mutation VerifyEmail($token: String!) { verifyEmail(token: $token) } `; export async function verify(token: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "verify", async () => { await getGraphQLClient(fetchFn).request(VERIFY_EMAIL_MUTATION, { token }); }, { hasToken: !!token }, ); } const REQUEST_PASSWORD_MUTATION = gql` mutation RequestPasswordReset($email: String!) { requestPasswordReset(email: $email) } `; export async function requestPassword(email: string) { return loggedApiCall( "requestPassword", async () => { await getGraphQLClient().request(REQUEST_PASSWORD_MUTATION, { email }); }, { email }, ); } const RESET_PASSWORD_MUTATION = gql` mutation ResetPassword($token: String!, $newPassword: String!) { resetPassword(token: $token, newPassword: $newPassword) } `; export async function resetPassword(token: string, password: string) { return loggedApiCall( "resetPassword", async () => { await getGraphQLClient().request(RESET_PASSWORD_MUTATION, { token, newPassword: password, }); }, { hasToken: !!token }, ); } // ─── Articles ──────────────────────────────────────────────────────────────── const ARTICLES_QUERY = gql` query GetArticles( $search: String $category: String $sortBy: String $offset: Int $limit: Int $featured: Boolean $tag: String ) { articles( search: $search category: $category sortBy: $sortBy offset: $offset limit: $limit featured: $featured tag: $tag ) { items { id slug title excerpt content image tags publish_date category featured author { id artist_name slug avatar } } total } } `; export async function getArticles( params: { search?: string; category?: string; sortBy?: string; offset?: number; limit?: number; featured?: boolean; tag?: string; } = {}, fetchFn?: typeof globalThis.fetch, ): Promise<{ items: Article[]; total: number }> { return loggedApiCall("getArticles", async () => { const data = await getGraphQLClient(fetchFn).request<{ articles: { items: Article[]; total: number }; }>(ARTICLES_QUERY, params); return data.articles; }); } const ARTICLE_BY_SLUG_QUERY = gql` query GetArticleBySlug($slug: String!) { article(slug: $slug) { id slug title excerpt content image tags publish_date category featured author { id artist_name slug avatar } } } `; export async function getArticleBySlug(slug: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getArticleBySlug", async () => { const data = await getGraphQLClient(fetchFn).request<{ article: Article | null }>( ARTICLE_BY_SLUG_QUERY, { slug }, ); if (!data.article) throw new Error("Article not found"); return data.article; }, { slug }, ); } // ─── Videos ────────────────────────────────────────────────────────────────── const VIDEOS_QUERY = gql` query GetVideos( $modelId: String $featured: Boolean $limit: Int $search: String $offset: Int $sortBy: String $duration: String $tag: String ) { videos( modelId: $modelId featured: $featured limit: $limit search: $search offset: $offset sortBy: $sortBy duration: $duration tag: $tag ) { items { id slug title description image movie tags upload_date premium featured likes_count plays_count models { id artist_name slug avatar } movie_file { id filename mime_type duration } } total } } `; export async function getVideos( params: { search?: string; sortBy?: string; duration?: string; offset?: number; limit?: number; tag?: string; } = {}, fetchFn?: typeof globalThis.fetch, ): Promise<{ items: Video[]; total: number }> { return loggedApiCall("getVideos", async () => { const data = await getGraphQLClient(fetchFn).request<{ videos: { items: Video[]; total: number }; }>(VIDEOS_QUERY, params); return data.videos; }); } export async function getVideosForModel(id: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getVideosForModel", async () => { const data = await getGraphQLClient(fetchFn).request<{ videos: { items: Video[]; total: number }; }>(VIDEOS_QUERY, { modelId: id, limit: 10000 }); return data.videos.items; }, { modelId: id }, ); } export async function getFeaturedVideos( limit: number, fetchFn: typeof globalThis.fetch = globalThis.fetch, ) { return loggedApiCall( "getFeaturedVideos", async () => { const data = await getGraphQLClient(fetchFn).request<{ videos: { items: Video[]; total: number }; }>(VIDEOS_QUERY, { featured: true, limit }); return data.videos.items; }, { limit }, ); } const VIDEO_BY_SLUG_QUERY = gql` query GetVideoBySlug($slug: String!) { video(slug: $slug) { id slug title description image movie tags upload_date premium featured likes_count plays_count models { id artist_name slug avatar } movie_file { id filename mime_type duration } } } `; export async function getVideoBySlug(slug: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getVideoBySlug", async () => { const data = await getGraphQLClient(fetchFn).request<{ video: Video | null }>( VIDEO_BY_SLUG_QUERY, { slug }, ); if (!data.video) throw new Error("Video not found"); return data.video; }, { slug }, ); } // ─── Models ────────────────────────────────────────────────────────────────── const MODELS_QUERY = gql` query GetModels( $featured: Boolean $limit: Int $search: String $offset: Int $sortBy: String $tag: String ) { models( featured: $featured limit: $limit search: $search offset: $offset sortBy: $sortBy tag: $tag ) { items { id slug artist_name description avatar banner tags date_created photos { id filename } } total } } `; export async function getModels( params: { search?: string; sortBy?: string; offset?: number; limit?: number; tag?: string } = {}, fetchFn?: typeof globalThis.fetch, ): Promise<{ items: Model[]; total: number }> { return loggedApiCall("getModels", async () => { const data = await getGraphQLClient(fetchFn).request<{ models: { items: Model[]; total: number }; }>(MODELS_QUERY, params); return data.models; }); } export async function getFeaturedModels( limit = 3, fetchFn: typeof globalThis.fetch = globalThis.fetch, ) { return loggedApiCall( "getFeaturedModels", async () => { const data = await getGraphQLClient(fetchFn).request<{ models: { items: Model[]; total: number }; }>(MODELS_QUERY, { featured: true, limit }); return data.models.items; }, { limit }, ); } const MODEL_BY_SLUG_QUERY = gql` query GetModelBySlug($slug: String!) { model(slug: $slug) { id slug artist_name description avatar banner tags date_created photos { id filename } } } `; export async function getModelBySlug(slug: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getModelBySlug", async () => { const data = await getGraphQLClient(fetchFn).request<{ model: Model | null }>( MODEL_BY_SLUG_QUERY, { slug }, ); if (!data.model) throw new Error("Model not found"); return data.model; }, { slug }, ); } // ─── Profile ───────────────────────────────────────────────────────────────── const UPDATE_PROFILE_MUTATION = gql` mutation UpdateProfile( $firstName: String $lastName: String $artistName: String $description: String $tags: [String!] ) { updateProfile( firstName: $firstName lastName: $lastName artistName: $artistName description: $description tags: $tags ) { id email first_name last_name artist_name slug description tags role avatar banner date_created } } `; export async function updateProfile(user: Partial & { password?: string }) { return loggedApiCall( "updateProfile", async () => { const data = await getGraphQLClient().request<{ updateProfile: User }>( UPDATE_PROFILE_MUTATION, { firstName: user.first_name, lastName: user.last_name, artistName: user.artist_name, description: user.description, tags: user.tags, }, ); return data.updateProfile; }, { userId: user.id }, ); } // ─── Stats ─────────────────────────────────────────────────────────────────── const STATS_QUERY = gql` query GetStats { stats { videos_count models_count viewers_count } } `; export async function getStats(fetchFn?: typeof globalThis.fetch) { return loggedApiCall("getStats", async () => { const data = await getGraphQLClient(fetchFn).request<{ stats: Stats }>(STATS_QUERY); return data.stats; }); } // Stub — Directus folder concept dropped export async function getFolders(_fetchFn?: typeof globalThis.fetch) { return loggedApiCall("getFolders", async () => [] as { id: string; name: string }[]); } // ─── Files ─────────────────────────────────────────────────────────────────── export async function removeFile(id: string) { return loggedApiCall( "removeFile", async () => { // File deletion via REST DELETE /assets/:id (backend handles it) const response = await fetch(`${apiUrl}/assets/${id}`, { method: "DELETE", credentials: "include", }); if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`); }, { fileId: id }, ); } export async function uploadFile(data: FormData) { return loggedApiCall("uploadFile", async () => { const response = await fetch(`${apiUrl}/upload`, { method: "POST", body: data, credentials: "include", }); if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`); return response.json(); }); } // ─── Comments ──────────────────────────────────────────────────────────────── const COMMENTS_FOR_VIDEO_QUERY = gql` query CommentsForVideo($videoId: String!) { commentsForVideo(videoId: $videoId) { id comment item_id user_id date_created user { id first_name last_name avatar } } } `; export async function getCommentsForVideo(item: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getCommentsForVideo", async () => { const data = await getGraphQLClient(fetchFn).request<{ commentsForVideo: { id: number; comment: string; item_id: string; user_id: string; date_created: string; user: { id: string; first_name: string | null; last_name: string | null; artist_name: string | null; avatar: string | null; } | null; }[]; }>(COMMENTS_FOR_VIDEO_QUERY, { videoId: item }); return data.commentsForVideo; }, { videoId: item }, ); } const CREATE_COMMENT_MUTATION = gql` mutation CreateCommentForVideo($videoId: String!, $comment: String!) { createCommentForVideo(videoId: $videoId, comment: $comment) { id comment item_id user_id date_created } } `; export async function createCommentForVideo(item: string, comment: string) { return loggedApiCall( "createCommentForVideo", async () => { const data = await getGraphQLClient().request(CREATE_COMMENT_MUTATION, { videoId: item, comment, }); return data; }, { videoId: item, commentLength: comment.length }, ); } export async function countCommentsForModel( _user_created: string, _fetchFn?: typeof globalThis.fetch, ) { // Not directly available in new API, return 0 return 0; } // ─── Tags ──────────────────────────────────────────────────────────────────── export async function getItemsByTag( category: "video" | "article" | "model", tag: string, fetchFn?: typeof globalThis.fetch, ) { return loggedApiCall( "getItemsByTag", async () => { switch (category) { case "video": return getVideos({ tag, limit: 1000 }, fetchFn).then((r) => r.items); case "model": return getModels({ tag, limit: 1000 }, fetchFn).then((r) => r.items); case "article": return getArticles({ tag, limit: 1000 }, fetchFn).then((r) => r.items); } }, { category, tag }, ); } // ─── Recordings ────────────────────────────────────────────────────────────── const RECORDINGS_QUERY = gql` query GetRecordings( $status: String $tags: String $linkedVideoId: String $limit: Int $page: Int ) { recordings( status: $status tags: $tags linkedVideoId: $linkedVideoId limit: $limit page: $page ) { id title description slug duration events device_info user_id status tags linked_video featured public date_created date_updated } } `; export async function getRecordings(fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getRecordings", async () => { const data = await getGraphQLClient(fetchFn).request<{ recordings: Recording[] }>( RECORDINGS_QUERY, ); return data.recordings; }, {}, ); } const CREATE_RECORDING_MUTATION = gql` mutation CreateRecording( $title: String! $description: String $duration: Int! $events: JSON! $deviceInfo: JSON! $tags: [String!] $status: String $linkedVideoId: String ) { createRecording( title: $title description: $description duration: $duration events: $events deviceInfo: $deviceInfo tags: $tags status: $status linkedVideoId: $linkedVideoId ) { id title description slug duration events device_info user_id status tags linked_video featured public date_created date_updated } } `; export async function createRecording( recording: { title: string; description?: string; duration: number; events: unknown[]; device_info: unknown[]; tags?: string[]; status?: string; }, fetchFn?: typeof globalThis.fetch, ) { return loggedApiCall( "createRecording", async () => { const data = await getGraphQLClient(fetchFn).request<{ createRecording: Recording }>( CREATE_RECORDING_MUTATION, { title: recording.title, description: recording.description, duration: recording.duration, events: recording.events, deviceInfo: recording.device_info, tags: recording.tags, status: recording.status, }, ); return data.createRecording; }, { title: recording.title, eventCount: recording.events.length }, ); } const DELETE_RECORDING_MUTATION = gql` mutation DeleteRecording($id: String!) { deleteRecording(id: $id) } `; export async function deleteRecording(id: string) { return loggedApiCall( "deleteRecording", async () => { await getGraphQLClient().request(DELETE_RECORDING_MUTATION, { id }); }, { id }, ); } const RECORDING_QUERY = gql` query GetRecording($id: String!) { recording(id: $id) { id title description slug duration events device_info user_id status tags linked_video featured public date_created date_updated } } `; export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getRecording", async () => { const data = await getGraphQLClient(fetchFn).request<{ recording: Recording | null }>( RECORDING_QUERY, { id }, ); return data.recording; }, { id }, ); } // ─── Video likes & plays ───────────────────────────────────────────────────── const LIKE_VIDEO_MUTATION = gql` mutation LikeVideo($videoId: String!) { likeVideo(videoId: $videoId) { liked likes_count } } `; export async function likeVideo(videoId: string) { return loggedApiCall( "likeVideo", async () => { const data = await getGraphQLClient().request<{ likeVideo: VideoLikeResponse }>( LIKE_VIDEO_MUTATION, { videoId }, ); return data.likeVideo; }, { videoId }, ); } const UNLIKE_VIDEO_MUTATION = gql` mutation UnlikeVideo($videoId: String!) { unlikeVideo(videoId: $videoId) { liked likes_count } } `; export async function unlikeVideo(videoId: string) { return loggedApiCall( "unlikeVideo", async () => { const data = await getGraphQLClient().request<{ unlikeVideo: VideoLikeResponse }>( UNLIKE_VIDEO_MUTATION, { videoId }, ); return data.unlikeVideo; }, { videoId }, ); } const VIDEO_LIKE_STATUS_QUERY = gql` query VideoLikeStatus($videoId: String!) { videoLikeStatus(videoId: $videoId) { liked } } `; export async function getVideoLikeStatus(videoId: string, fetchFn?: typeof globalThis.fetch) { return loggedApiCall( "getVideoLikeStatus", async () => { const data = await getGraphQLClient(fetchFn).request<{ videoLikeStatus: VideoLikeStatus }>( VIDEO_LIKE_STATUS_QUERY, { videoId }, ); return data.videoLikeStatus; }, { videoId }, ); } const RECORD_VIDEO_PLAY_MUTATION = gql` mutation RecordVideoPlay($videoId: String!, $sessionId: String) { recordVideoPlay(videoId: $videoId, sessionId: $sessionId) { success play_id plays_count } } `; export async function recordVideoPlay(videoId: string, sessionId?: string) { return loggedApiCall( "recordVideoPlay", async () => { const data = await getGraphQLClient().request<{ recordVideoPlay: VideoPlayResponse }>( RECORD_VIDEO_PLAY_MUTATION, { videoId, sessionId }, ); return data.recordVideoPlay; }, { videoId }, ); } const UPDATE_VIDEO_PLAY_MUTATION = gql` mutation UpdateVideoPlay( $videoId: String! $playId: String! $durationWatched: Int! $completed: Boolean! ) { updateVideoPlay( videoId: $videoId playId: $playId durationWatched: $durationWatched completed: $completed ) } `; export async function updateVideoPlay( videoId: string, playId: string, durationWatched: number, completed: boolean, ) { return loggedApiCall( "updateVideoPlay", async () => { await getGraphQLClient().request(UPDATE_VIDEO_PLAY_MUTATION, { videoId, playId, durationWatched, completed, }); }, { videoId, playId, durationWatched, completed }, ); } // ─── Delete comment ────────────────────────────────────────────────────────── const DELETE_COMMENT_MUTATION = gql` mutation DeleteComment($id: Int!) { deleteComment(id: $id) } `; export async function deleteComment(id: number) { return loggedApiCall( "deleteComment", async () => { await getGraphQLClient().request(DELETE_COMMENT_MUTATION, { id }); }, { id }, ); } // ─── Admin: Users ───────────────────────────────────────────────────────────── const ADMIN_LIST_USERS_QUERY = gql` query AdminListUsers($role: String, $search: String, $limit: Int, $offset: Int) { adminListUsers(role: $role, search: $search, limit: $limit, offset: $offset) { total items { id email first_name last_name artist_name slug role is_admin avatar email_verified date_created } } } `; export async function adminListUsers( opts: { role?: string; search?: string; limit?: number; offset?: number } = {}, fetchFn?: typeof globalThis.fetch, token?: string, ) { return loggedApiCall( "adminListUsers", async () => { const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn); const data = await client.request<{ adminListUsers: { total: number; items: User[] }; }>(ADMIN_LIST_USERS_QUERY, opts); return data.adminListUsers; }, opts, ); } const ADMIN_UPDATE_USER_MUTATION = gql` mutation AdminUpdateUser( $userId: String! $role: String $isAdmin: Boolean $firstName: String $lastName: String $artistName: String $avatarId: String $bannerId: String ) { adminUpdateUser( userId: $userId role: $role isAdmin: $isAdmin firstName: $firstName lastName: $lastName artistName: $artistName avatarId: $avatarId bannerId: $bannerId ) { id email first_name last_name artist_name role is_admin avatar banner date_created } } `; export async function adminUpdateUser(input: { userId: string; role?: string; isAdmin?: boolean; firstName?: string; lastName?: string; artistName?: string; avatarId?: string; bannerId?: string; }) { return loggedApiCall( "adminUpdateUser", async () => { const data = await getGraphQLClient().request<{ adminUpdateUser: User | null }>( ADMIN_UPDATE_USER_MUTATION, input, ); return data.adminUpdateUser; }, { userId: input.userId }, ); } const ADMIN_DELETE_USER_MUTATION = gql` mutation AdminDeleteUser($userId: String!) { adminDeleteUser(userId: $userId) } `; export async function adminDeleteUser(userId: string) { return loggedApiCall( "adminDeleteUser", async () => { await getGraphQLClient().request(ADMIN_DELETE_USER_MUTATION, { userId }); }, { userId }, ); } const ADMIN_GET_USER_QUERY = gql` query AdminGetUser($userId: String!) { adminGetUser(userId: $userId) { id email first_name last_name artist_name slug role is_admin avatar banner description tags email_verified date_created photos { id filename } } } `; export async function adminGetUser(userId: string, token?: string) { return loggedApiCall( "adminGetUser", async () => { const client = token ? getAuthClient(token) : getGraphQLClient(); const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId }); return data.adminGetUser; }, { userId }, ); } const ADMIN_ADD_USER_PHOTO_MUTATION = gql` mutation AdminAddUserPhoto($userId: String!, $fileId: String!) { adminAddUserPhoto(userId: $userId, fileId: $fileId) } `; export async function adminAddUserPhoto(userId: string, fileId: string) { return loggedApiCall("adminAddUserPhoto", async () => { await getGraphQLClient().request(ADMIN_ADD_USER_PHOTO_MUTATION, { userId, fileId }); }); } const ADMIN_REMOVE_USER_PHOTO_MUTATION = gql` mutation AdminRemoveUserPhoto($userId: String!, $fileId: String!) { adminRemoveUserPhoto(userId: $userId, fileId: $fileId) } `; export async function adminRemoveUserPhoto(userId: string, fileId: string) { return loggedApiCall("adminRemoveUserPhoto", async () => { await getGraphQLClient().request(ADMIN_REMOVE_USER_PHOTO_MUTATION, { userId, fileId }); }); } // ─── Admin: Videos ──────────────────────────────────────────────────────────── const ADMIN_LIST_VIDEOS_QUERY = gql` query AdminListVideos( $search: String $premium: Boolean $featured: Boolean $limit: Int $offset: Int ) { adminListVideos( search: $search premium: $premium featured: $featured limit: $limit offset: $offset ) { items { id slug title description image movie tags upload_date premium featured likes_count plays_count models { id artist_name slug avatar } movie_file { id filename mime_type duration } } total } } `; export async function adminListVideos( opts: { search?: string; premium?: boolean; featured?: boolean; limit?: number; offset?: number; } = {}, fetchFn?: typeof globalThis.fetch, token?: string, ): Promise<{ items: Video[]; total: number }> { return loggedApiCall("adminListVideos", async () => { const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn); const data = await client.request<{ adminListVideos: { items: Video[]; total: number } }>( ADMIN_LIST_VIDEOS_QUERY, opts, ); return data.adminListVideos; }); } const ADMIN_GET_VIDEO_QUERY = gql` query AdminGetVideo($id: String!) { adminGetVideo(id: $id) { id slug title description image movie tags upload_date premium featured likes_count plays_count models { id artist_name slug avatar } movie_file { id filename mime_type duration } } } `; export async function adminGetVideo( id: string, fetchFn?: typeof globalThis.fetch, token?: string, ): Promise