import { GraphQLError } from "graphql"; import { builder } from "../builder"; import { VideoType, VideoListType, AdminVideoListType, 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, asc, inArray, count, ilike, lt, gte, arrayContains, type SQL, } from "drizzle-orm"; import { requireAdmin } from "../../lib/acl"; import type { DB } from "../../db/connection"; async function enrichVideo(db: DB, video: typeof videos.$inferSelect) { // Fetch models const modelRows = await db .select({ id: users.id, artist_name: users.artist_name, slug: users.slug, avatar: users.avatar, description: users.description, }) .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)); const models = modelRows .filter((m) => m.id !== null) .map((m) => ({ id: m.id!, artist_name: m.artist_name, slug: m.slug, avatar: m.avatar, description: m.description, })); return { ...video, models, movie_file: movieFile, likes_count: likesCount[0]?.count || 0, plays_count: playsCount[0]?.count || 0, }; } builder.queryField("videos", (t) => t.field({ type: VideoListType, args: { modelId: t.arg.string(), featured: t.arg.boolean(), limit: t.arg.int(), search: t.arg.string(), offset: t.arg.int(), sortBy: t.arg.string(), duration: t.arg.string(), tag: t.arg.string(), }, resolve: async (_root, args, ctx) => { const pageSize = args.limit ?? 24; const offset = args.offset ?? 0; const conditions: SQL[] = [lte(videos.upload_date, new Date())]; if (!ctx.currentUser) conditions.push(eq(videos.premium, false)); if (args.featured !== null && args.featured !== undefined) { conditions.push(eq(videos.featured, args.featured)); } if (args.search) { conditions.push(ilike(videos.title, `%${args.search}%`)); } if (args.tag) { conditions.push(arrayContains(videos.tags, [args.tag])); } 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 { items: [], total: 0 }; conditions.push( inArray( videos.id, videoIds.map((v) => v.video_id), ), ); } const order = args.sortBy === "most_liked" ? desc(videos.likes_count) : args.sortBy === "most_played" ? desc(videos.plays_count) : args.sortBy === "name" ? asc(videos.title) : desc(videos.upload_date); const where = and(...conditions); // Duration filter requires JOIN to files table if (args.duration && args.duration !== "all") { const durationCond = args.duration === "short" ? lt(files.duration, 600) : args.duration === "medium" ? and(gte(files.duration, 600), lt(files.duration, 1200)) : gte(files.duration, 1200); const fullWhere = and(where, durationCond); const [rows, totalRows] = await Promise.all([ ctx.db .select({ v: videos }) .from(videos) .leftJoin(files, eq(videos.movie, files.id)) .where(fullWhere) .orderBy(order) .limit(pageSize) .offset(offset), ctx.db .select({ total: count() }) .from(videos) .leftJoin(files, eq(videos.movie, files.id)) .where(fullWhere), ]); const videoList = rows.map((r) => r.v); const items = await Promise.all(videoList.map((v) => enrichVideo(ctx.db, v))); return { items, total: totalRows[0]?.total ?? 0 }; } const [rows, totalRows] = await Promise.all([ ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset), ctx.db.select({ total: count() }).from(videos).where(where), ]); const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v))); return { items, total: totalRows[0]?.total ?? 0 }; }, }), ); 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; if (video[0].premium && !ctx.currentUser) { throw new GraphQLError("Unauthorized"); } return enrichVideo(ctx.db, video[0]); }, }), ); builder.queryField("adminGetVideo", (t) => t.field({ type: VideoType, nullable: true, args: { id: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); const video = await ctx.db.select().from(videos).where(eq(videos.id, args.id)).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) => { const play = await ctx.db .select() .from(video_plays) .where(eq(video_plays.id, args.playId)) .limit(1); if (!play[0]) return false; // If play belongs to a user, verify ownership if (play[0].user_id && (!ctx.currentUser || play[0].user_id !== ctx.currentUser.id)) { throw new GraphQLError("Forbidden"); } 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) => 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: Record, 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: Record, 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, }; }, }), ); // ─── Admin queries & mutations ──────────────────────────────────────────────── builder.queryField("adminListVideos", (t) => t.field({ type: AdminVideoListType, args: { search: t.arg.string(), premium: t.arg.boolean(), featured: t.arg.boolean(), limit: t.arg.int(), offset: t.arg.int(), }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); const limit = args.limit ?? 50; const offset = args.offset ?? 0; const conditions: SQL[] = []; if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`)); if (args.premium !== null && args.premium !== undefined) conditions.push(eq(videos.premium, args.premium)); if (args.featured !== null && args.featured !== undefined) conditions.push(eq(videos.featured, args.featured)); const where = conditions.length > 0 ? and(...conditions) : undefined; const [rows, totalRows] = await Promise.all([ ctx.db .select() .from(videos) .where(where) .orderBy(desc(videos.upload_date)) .limit(limit) .offset(offset), ctx.db.select({ total: count() }).from(videos).where(where), ]); const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v))); return { items, total: totalRows[0]?.total ?? 0 }; }, }), ); builder.mutationField("createVideo", (t) => t.field({ type: VideoType, args: { title: t.arg.string({ required: true }), slug: t.arg.string({ required: true }), description: t.arg.string(), imageId: t.arg.string(), movieId: t.arg.string(), tags: t.arg.stringList(), premium: t.arg.boolean(), featured: t.arg.boolean(), uploadDate: t.arg.string(), }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); const inserted = await ctx.db .insert(videos) .values({ title: args.title, slug: args.slug, description: args.description || null, image: args.imageId || null, movie: args.movieId || null, tags: args.tags || [], premium: args.premium ?? false, featured: args.featured ?? false, upload_date: args.uploadDate ? new Date(args.uploadDate) : new Date(), }) .returning(); return enrichVideo(ctx.db, inserted[0]); }, }), ); builder.mutationField("updateVideo", (t) => t.field({ type: VideoType, nullable: true, args: { id: t.arg.string({ required: true }), title: t.arg.string(), slug: t.arg.string(), description: t.arg.string(), imageId: t.arg.string(), movieId: t.arg.string(), tags: t.arg.stringList(), premium: t.arg.boolean(), featured: t.arg.boolean(), uploadDate: t.arg.string(), }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); const updates: Record = {}; if (args.title !== undefined && args.title !== null) updates.title = args.title; if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug; if (args.description !== undefined) updates.description = args.description; if (args.imageId !== undefined) updates.image = args.imageId; if (args.movieId !== undefined) updates.movie = args.movieId; if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags; if (args.premium !== undefined && args.premium !== null) updates.premium = args.premium; if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured; if (args.uploadDate !== undefined && args.uploadDate !== null) updates.upload_date = new Date(args.uploadDate); const updated = await ctx.db .update(videos) .set(updates as Partial) .where(eq(videos.id, args.id)) .returning(); if (!updated[0]) return null; return enrichVideo(ctx.db, updated[0]); }, }), ); builder.mutationField("deleteVideo", (t) => t.field({ type: "Boolean", args: { id: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); await ctx.db.delete(videos).where(eq(videos.id, args.id)); return true; }, }), ); builder.mutationField("setVideoModels", (t) => t.field({ type: "Boolean", args: { videoId: t.arg.string({ required: true }), userIds: t.arg.stringList({ required: true }), }, resolve: async (_root, args, ctx) => { requireAdmin(ctx); await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId)); if (args.userIds.length > 0) { await ctx.db.insert(video_models).values( args.userIds.map((userId) => ({ video_id: args.videoId, user_id: userId, })), ); } return true; }, }), );