import { GraphQLError } from "graphql"; import { builder } from "../builder"; import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index"; import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index"; import { eq, and, lte, desc, inArray, count } from "drizzle-orm"; async function enrichVideo(db: any, video: any) { // Fetch models const modelRows = await db .select({ id: users.id, artist_name: users.artist_name, slug: users.slug, avatar: users.avatar, }) .from(video_models) .leftJoin(users, eq(video_models.user_id, users.id)) .where(eq(video_models.video_id, video.id)); // Fetch movie file let movieFile = null; if (video.movie) { const mf = await db.select().from(files).where(eq(files.id, video.movie)).limit(1); movieFile = mf[0] || null; } // Count likes const likesCount = await db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, video.id)); const playsCount = await db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, video.id)); return { ...video, models: modelRows, movie_file: movieFile, likes_count: likesCount[0]?.count || 0, plays_count: playsCount[0]?.count || 0, }; } builder.queryField("videos", (t) => t.field({ type: [VideoType], args: { modelId: t.arg.string(), featured: t.arg.boolean(), limit: t.arg.int(), }, resolve: async (_root, args, ctx) => { let query = ctx.db .select({ v: videos }) .from(videos) .where(lte(videos.upload_date, new Date())) .orderBy(desc(videos.upload_date)); if (args.modelId) { const videoIds = await ctx.db .select({ video_id: video_models.video_id }) .from(video_models) .where(eq(video_models.user_id, args.modelId)); if (videoIds.length === 0) return []; query = ctx.db .select({ v: videos }) .from(videos) .where(and( lte(videos.upload_date, new Date()), inArray(videos.id, videoIds.map((v: any) => v.video_id)), )) .orderBy(desc(videos.upload_date)); } if (args.featured !== null && args.featured !== undefined) { query = ctx.db .select({ v: videos }) .from(videos) .where(and( lte(videos.upload_date, new Date()), eq(videos.featured, args.featured), )) .orderBy(desc(videos.upload_date)); } if (args.limit) { query = (query as any).limit(args.limit); } const rows = await query; const videoList = rows.map((r: any) => r.v || r); return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v))); }, }), ); builder.queryField("video", (t) => t.field({ type: VideoType, nullable: true, args: { slug: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const video = await ctx.db .select() .from(videos) .where(and(eq(videos.slug, args.slug), lte(videos.upload_date, new Date()))) .limit(1); if (!video[0]) return null; return enrichVideo(ctx.db, video[0]); }, }), ); builder.queryField("videoLikeStatus", (t) => t.field({ type: VideoLikeStatusType, args: { videoId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { if (!ctx.currentUser) return { liked: false }; const existing = await ctx.db .select() .from(video_likes) .where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))) .limit(1); return { liked: existing.length > 0 }; }, }), ); builder.mutationField("likeVideo", (t) => t.field({ type: VideoLikeResponseType, args: { videoId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); const existing = await ctx.db .select() .from(video_likes) .where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))) .limit(1); if (existing.length > 0) throw new GraphQLError("Already liked"); await ctx.db.insert(video_likes).values({ video_id: args.videoId, user_id: ctx.currentUser.id, }); await ctx.db .update(videos) .set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 }) .where(eq(videos.id, args.videoId)); const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId)); return { liked: true, likes_count: likesCount[0]?.count || 1 }; }, }), ); builder.mutationField("unlikeVideo", (t) => t.field({ type: VideoLikeResponseType, args: { videoId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); const existing = await ctx.db .select() .from(video_likes) .where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))) .limit(1); if (existing.length === 0) throw new GraphQLError("Not liked"); await ctx.db .delete(video_likes) .where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id))); await ctx.db .update(videos) .set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) }) .where(eq(videos.id, args.videoId)); const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId)); return { liked: false, likes_count: likesCount[0]?.count || 0 }; }, }), ); builder.mutationField("recordVideoPlay", (t) => t.field({ type: VideoPlayResponseType, args: { videoId: t.arg.string({ required: true }), sessionId: t.arg.string(), }, resolve: async (_root, args, ctx) => { const play = await ctx.db.insert(video_plays).values({ video_id: args.videoId, user_id: ctx.currentUser?.id || null, session_id: args.sessionId || null, }).returning({ id: video_plays.id }); const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId)); await ctx.db .update(videos) .set({ plays_count: playsCount[0]?.count || 0 }) .where(eq(videos.id, args.videoId)); return { success: true, play_id: play[0].id, plays_count: playsCount[0]?.count || 0, }; }, }), ); builder.mutationField("updateVideoPlay", (t) => t.field({ type: "Boolean", args: { videoId: t.arg.string({ required: true }), playId: t.arg.string({ required: true }), durationWatched: t.arg.int({ required: true }), completed: t.arg.boolean({ required: true }), }, resolve: async (_root, args, ctx) => { await ctx.db .update(video_plays) .set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() }) .where(eq(video_plays.id, args.playId)); return true; }, }), ); builder.queryField("analytics", (t) => t.field({ type: "JSON", nullable: true, resolve: async (_root, _args, ctx) => { if (!ctx.currentUser || ctx.currentUser.role !== "model") { throw new GraphQLError("Unauthorized"); } const userId = ctx.currentUser.id; // Get all videos by this user (via video_models) const modelVideoIds = await ctx.db .select({ video_id: video_models.video_id }) .from(video_models) .where(eq(video_models.user_id, userId)); if (modelVideoIds.length === 0) { return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] }; } const videoIds = modelVideoIds.map((v: any) => v.video_id); const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds)); const plays = await ctx.db.select().from(video_plays).where(inArray(video_plays.video_id, videoIds)); const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds)); const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0); const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0); const playsByDate = plays.reduce((acc: any, play) => { const date = new Date(play.date_created).toISOString().split("T")[0]; if (!acc[date]) acc[date] = 0; acc[date]++; return acc; }, {}); const likesByDate = likes.reduce((acc: any, like) => { const date = new Date(like.date_created).toISOString().split("T")[0]; if (!acc[date]) acc[date] = 0; acc[date]++; return acc; }, {}); const videoAnalytics = videoList.map((video) => { const vPlays = plays.filter((p) => p.video_id === video.id); const completedPlays = vPlays.filter((p) => p.completed).length; const avgWatchTime = vPlays.length > 0 ? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.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), }; }); return { total_videos: videoList.length, total_likes: totalLikes, total_plays: totalPlays, plays_by_date: playsByDate, likes_by_date: likesByDate, videos: videoAnalytics, }; }, }), );