The edit page loaders were calling adminListVideos/adminListArticles with the old pre-pagination signatures and filtering by ID client-side, which broke after pagination limited results to 50. Now fetches the single item by ID directly via new adminGetVideo and adminGetArticle backend queries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
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,
|
|
} from "drizzle-orm";
|
|
import { requireAdmin } from "../../lib/acl";
|
|
|
|
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: 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: any[] = [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: any) => 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: any) => r.v || r);
|
|
const items = await Promise.all(videoList.map((v: any) => 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: any) => 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: 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,
|
|
};
|
|
},
|
|
}),
|
|
);
|
|
|
|
// ─── 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: any[] = [];
|
|
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: any) => 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<string, unknown> = {};
|
|
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 any)
|
|
.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;
|
|
},
|
|
}),
|
|
);
|