feat: replace Directus with custom Node.js GraphQL backend
Removes Directus 11 and replaces it with a lean, purpose-built backend: - packages/backend/: Fastify v5 + GraphQL Yoga v5 + Pothos (code-first) with Drizzle ORM, Redis sessions (session_token cookie), argon2 auth, Nodemailer, fluent-ffmpeg, and full gamification system ported from bundle - Frontend: @directus/sdk replaced by graphql-request v7; services.ts fully rewritten with identical signatures; directus.ts now re-exports from api.ts - Cookie renamed directus_session_token → session_token - Dev proxy target updated 8055 → 4000 - compose.yml: Directus service removed, backend service added (port 4000) - Dockerfile.backend: new multi-stage image with ffmpeg - Dockerfile: bundle build step and ffmpeg removed from frontend image - data-migration.ts: one-time script to migrate all Directus/sexy_ tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
packages/backend/src/graphql/resolvers/articles.ts
Normal file
83
packages/backend/src/graphql/resolvers/articles.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { ArticleType } from "../types/index.js";
|
||||
import { articles, users } from "../../db/schema/index.js";
|
||||
import { eq, and, lte, desc } from "drizzle-orm";
|
||||
|
||||
builder.queryField("articles", (t) =>
|
||||
t.field({
|
||||
type: [ArticleType],
|
||||
args: {
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(lte(articles.publish_date, new Date()))
|
||||
.orderBy(desc(articles.publish_date));
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
}
|
||||
|
||||
const articleList = await query;
|
||||
|
||||
return Promise.all(
|
||||
articleList.map(async (article: any) => {
|
||||
let author = null;
|
||||
if (article.author) {
|
||||
const authorUser = await ctx.db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
return { ...article, author };
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("article", (t) =>
|
||||
t.field({
|
||||
type: ArticleType,
|
||||
nullable: true,
|
||||
args: {
|
||||
slug: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const article = await ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(and(eq(articles.slug, args.slug), lte(articles.publish_date, new Date())))
|
||||
.limit(1);
|
||||
|
||||
if (!article[0]) return null;
|
||||
|
||||
let author = null;
|
||||
if (article[0].author) {
|
||||
const authorUser = await ctx.db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article[0].author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
|
||||
return { ...article[0], author };
|
||||
},
|
||||
}),
|
||||
);
|
||||
226
packages/backend/src/graphql/resolvers/auth.ts
Normal file
226
packages/backend/src/graphql/resolvers/auth.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CurrentUserType } from "../types/index.js";
|
||||
import { users } from "../../db/schema/index.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hash, verify as verifyArgon } from "../../lib/argon.js";
|
||||
import { setSession, deleteSession } from "../../lib/auth.js";
|
||||
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
|
||||
import { slugify } from "../../lib/slugify.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
builder.mutationField("login", (t) =>
|
||||
t.field({
|
||||
type: CurrentUserType,
|
||||
args: {
|
||||
email: t.arg.string({ required: true }),
|
||||
password: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, args.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user[0]) throw new GraphQLError("Invalid credentials");
|
||||
|
||||
const valid = await verifyArgon(user[0].password_hash, args.password);
|
||||
if (!valid) throw new GraphQLError("Invalid credentials");
|
||||
|
||||
const token = nanoid(32);
|
||||
const sessionUser = {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
role: user[0].role,
|
||||
first_name: user[0].first_name,
|
||||
last_name: user[0].last_name,
|
||||
artist_name: user[0].artist_name,
|
||||
slug: user[0].slug,
|
||||
avatar: user[0].avatar,
|
||||
};
|
||||
|
||||
await setSession(token, sessionUser);
|
||||
|
||||
// Set session cookie
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
|
||||
// For graphql-yoga response
|
||||
if ((ctx as any).serverResponse) {
|
||||
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
|
||||
}
|
||||
|
||||
return user[0];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("logout", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
const cookieHeader = ctx.request.headers.get("cookie") || "";
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(";").map((c) => {
|
||||
const [k, ...v] = c.trim().split("=");
|
||||
return [k.trim(), v.join("=")];
|
||||
}),
|
||||
);
|
||||
const token = cookies["session_token"];
|
||||
if (token) {
|
||||
await deleteSession(token);
|
||||
}
|
||||
// Clear cookie
|
||||
const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0";
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("register", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
email: t.arg.string({ required: true }),
|
||||
password: t.arg.string({ required: true }),
|
||||
firstName: t.arg.string({ required: true }),
|
||||
lastName: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const existing = await ctx.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, args.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) throw new GraphQLError("Email already registered");
|
||||
|
||||
const passwordHash = await hash(args.password);
|
||||
const artistName = `${args.firstName} ${args.lastName}`;
|
||||
const baseSlug = slugify(artistName);
|
||||
const verifyToken = nanoid(32);
|
||||
|
||||
// Ensure unique slug
|
||||
let slug = baseSlug;
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const existing = await ctx.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.slug, slug))
|
||||
.limit(1);
|
||||
if (existing.length === 0) break;
|
||||
attempt++;
|
||||
slug = `${baseSlug}-${attempt}`;
|
||||
}
|
||||
|
||||
await ctx.db.insert(users).values({
|
||||
email: args.email.toLowerCase(),
|
||||
password_hash: passwordHash,
|
||||
first_name: args.firstName,
|
||||
last_name: args.lastName,
|
||||
artist_name: artistName,
|
||||
slug,
|
||||
role: "viewer",
|
||||
email_verify_token: verifyToken,
|
||||
email_verified: false,
|
||||
});
|
||||
|
||||
await sendVerification(args.email, verifyToken);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("verifyEmail", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
token: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email_verify_token, args.token))
|
||||
.limit(1);
|
||||
|
||||
if (!user[0]) throw new GraphQLError("Invalid verification token");
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ email_verified: true, email_verify_token: null })
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("requestPasswordReset", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
email: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, args.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
// Always return true to prevent email enumeration
|
||||
if (!user[0]) return true;
|
||||
|
||||
const token = nanoid(32);
|
||||
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ password_reset_token: token, password_reset_expiry: expiry })
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
await sendPasswordReset(args.email, token);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("resetPassword", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
token: t.arg.string({ required: true }),
|
||||
newPassword: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.password_reset_token, args.token))
|
||||
.limit(1);
|
||||
|
||||
if (!user[0]) throw new GraphQLError("Invalid or expired reset token");
|
||||
if (user[0].password_reset_expiry && user[0].password_reset_expiry < new Date()) {
|
||||
throw new GraphQLError("Reset token expired");
|
||||
}
|
||||
|
||||
const passwordHash = await hash(args.newPassword);
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password_hash: passwordHash,
|
||||
password_reset_token: null,
|
||||
password_reset_expiry: null,
|
||||
})
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
68
packages/backend/src/graphql/resolvers/comments.ts
Normal file
68
packages/backend/src/graphql/resolvers/comments.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CommentType } from "../types/index.js";
|
||||
import { comments, users } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
|
||||
|
||||
builder.queryField("commentsForVideo", (t) =>
|
||||
t.field({
|
||||
type: [CommentType],
|
||||
args: {
|
||||
videoId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const commentList = await ctx.db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.collection, "videos"), eq(comments.item_id, args.videoId)))
|
||||
.orderBy(desc(comments.date_created));
|
||||
|
||||
return Promise.all(
|
||||
commentList.map(async (c: any) => {
|
||||
const user = await ctx.db
|
||||
.select({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar })
|
||||
.from(users)
|
||||
.where(eq(users.id, c.user_id))
|
||||
.limit(1);
|
||||
return { ...c, user: user[0] || null };
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("createCommentForVideo", (t) =>
|
||||
t.field({
|
||||
type: CommentType,
|
||||
args: {
|
||||
videoId: t.arg.string({ required: true }),
|
||||
comment: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const newComment = await ctx.db
|
||||
.insert(comments)
|
||||
.values({
|
||||
collection: "videos",
|
||||
item_id: args.videoId,
|
||||
comment: args.comment,
|
||||
user_id: ctx.currentUser.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Gamification
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE");
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "social");
|
||||
|
||||
const user = await ctx.db
|
||||
.select({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar })
|
||||
.from(users)
|
||||
.where(eq(users.id, ctx.currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
return { ...newComment[0], user: user[0] || null };
|
||||
},
|
||||
}),
|
||||
);
|
||||
115
packages/backend/src/graphql/resolvers/gamification.ts
Normal file
115
packages/backend/src/graphql/resolvers/gamification.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index.js";
|
||||
import { user_stats, users, user_achievements, achievements, user_points } from "../../db/schema/index.js";
|
||||
import { eq, desc, gt, count, isNotNull } from "drizzle-orm";
|
||||
|
||||
builder.queryField("leaderboard", (t) =>
|
||||
t.field({
|
||||
type: [LeaderboardEntryType],
|
||||
args: {
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const limit = Math.min(args.limit || 100, 500);
|
||||
const offset = args.offset || 0;
|
||||
|
||||
const entries = await ctx.db
|
||||
.select({
|
||||
user_id: user_stats.user_id,
|
||||
display_name: users.artist_name,
|
||||
avatar: users.avatar,
|
||||
total_weighted_points: user_stats.total_weighted_points,
|
||||
total_raw_points: user_stats.total_raw_points,
|
||||
recordings_count: user_stats.recordings_count,
|
||||
playbacks_count: user_stats.playbacks_count,
|
||||
achievements_count: user_stats.achievements_count,
|
||||
})
|
||||
.from(user_stats)
|
||||
.leftJoin(users, eq(user_stats.user_id, users.id))
|
||||
.orderBy(desc(user_stats.total_weighted_points))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("userGamification", (t) =>
|
||||
t.field({
|
||||
type: UserGamificationType,
|
||||
nullable: true,
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const stats = await ctx.db
|
||||
.select()
|
||||
.from(user_stats)
|
||||
.where(eq(user_stats.user_id, args.userId))
|
||||
.limit(1);
|
||||
|
||||
let rank = 1;
|
||||
if (stats[0]) {
|
||||
const rankResult = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(user_stats)
|
||||
.where(gt(user_stats.total_weighted_points, stats[0].total_weighted_points || 0));
|
||||
rank = (rankResult[0]?.count || 0) + 1;
|
||||
}
|
||||
|
||||
const userAchievements = await ctx.db
|
||||
.select({
|
||||
id: achievements.id,
|
||||
code: achievements.code,
|
||||
name: achievements.name,
|
||||
description: achievements.description,
|
||||
icon: achievements.icon,
|
||||
category: achievements.category,
|
||||
date_unlocked: user_achievements.date_unlocked,
|
||||
progress: user_achievements.progress,
|
||||
required_count: achievements.required_count,
|
||||
})
|
||||
.from(user_achievements)
|
||||
.leftJoin(achievements, eq(user_achievements.achievement_id, achievements.id))
|
||||
.where(eq(user_achievements.user_id, args.userId))
|
||||
.where(isNotNull(user_achievements.date_unlocked))
|
||||
.orderBy(desc(user_achievements.date_unlocked));
|
||||
|
||||
const recentPoints = await ctx.db
|
||||
.select({
|
||||
action: user_points.action,
|
||||
points: user_points.points,
|
||||
date_created: user_points.date_created,
|
||||
recording_id: user_points.recording_id,
|
||||
})
|
||||
.from(user_points)
|
||||
.where(eq(user_points.user_id, args.userId))
|
||||
.orderBy(desc(user_points.date_created))
|
||||
.limit(10);
|
||||
|
||||
return {
|
||||
stats: stats[0] ? { ...stats[0], rank } : null,
|
||||
achievements: userAchievements.map((a: any) => ({
|
||||
...a,
|
||||
date_unlocked: a.date_unlocked!,
|
||||
})),
|
||||
recent_points: recentPoints,
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("achievements", (t) =>
|
||||
t.field({
|
||||
type: [AchievementType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(achievements)
|
||||
.where(eq(achievements.status, "published"))
|
||||
.orderBy(achievements.sort);
|
||||
},
|
||||
}),
|
||||
);
|
||||
63
packages/backend/src/graphql/resolvers/models.ts
Normal file
63
packages/backend/src/graphql/resolvers/models.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { ModelType } from "../types/index.js";
|
||||
import { users, user_photos, files } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
|
||||
async function enrichModel(db: any, user: any) {
|
||||
// Fetch photos
|
||||
const photoRows = await db
|
||||
.select({ id: files.id, filename: files.filename })
|
||||
.from(user_photos)
|
||||
.leftJoin(files, eq(user_photos.file_id, files.id))
|
||||
.where(eq(user_photos.user_id, user.id))
|
||||
.orderBy(user_photos.sort);
|
||||
|
||||
return {
|
||||
...user,
|
||||
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
|
||||
};
|
||||
}
|
||||
|
||||
builder.queryField("models", (t) =>
|
||||
t.field({
|
||||
type: [ModelType],
|
||||
args: {
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.role, "model"))
|
||||
.orderBy(desc(users.date_created));
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
}
|
||||
|
||||
const modelList = await query;
|
||||
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("model", (t) =>
|
||||
t.field({
|
||||
type: ModelType,
|
||||
nullable: true,
|
||||
args: {
|
||||
slug: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const model = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.slug, args.slug), eq(users.role, "model")))
|
||||
.limit(1);
|
||||
|
||||
if (!model[0]) return null;
|
||||
return enrichModel(ctx.db, model[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
333
packages/backend/src/graphql/resolvers/recordings.ts
Normal file
333
packages/backend/src/graphql/resolvers/recordings.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { RecordingType } from "../types/index.js";
|
||||
import { recordings, recording_plays } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { slugify } from "../../lib/slugify.js";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
|
||||
|
||||
builder.queryField("recordings", (t) =>
|
||||
t.field({
|
||||
type: [RecordingType],
|
||||
args: {
|
||||
status: t.arg.string(),
|
||||
tags: t.arg.string(),
|
||||
linkedVideoId: t.arg.string(),
|
||||
limit: t.arg.int(),
|
||||
page: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as any));
|
||||
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
||||
|
||||
const limit = args.limit || 50;
|
||||
const page = args.page || 1;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(recordings.date_created))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("recording", (t) =>
|
||||
t.field({
|
||||
type: RecordingType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const recording = await ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.limit(1);
|
||||
|
||||
if (!recording[0]) return null;
|
||||
|
||||
if (recording[0].user_id !== ctx.currentUser.id && !recording[0].public) {
|
||||
throw new GraphQLError("Forbidden");
|
||||
}
|
||||
|
||||
return recording[0];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("communityRecordings", (t) =>
|
||||
t.field({
|
||||
type: [RecordingType],
|
||||
args: {
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(and(eq(recordings.status, "published"), eq(recordings.public, true)))
|
||||
.orderBy(desc(recordings.date_created))
|
||||
.limit(args.limit || 50)
|
||||
.offset(args.offset || 0);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("createRecording", (t) =>
|
||||
t.field({
|
||||
type: RecordingType,
|
||||
args: {
|
||||
title: t.arg.string({ required: true }),
|
||||
description: t.arg.string(),
|
||||
duration: t.arg.int({ required: true }),
|
||||
events: t.arg({ type: "JSON", required: true }),
|
||||
deviceInfo: t.arg({ type: "JSON", required: true }),
|
||||
tags: t.arg.stringList(),
|
||||
status: t.arg.string(),
|
||||
linkedVideoId: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const slug = slugify(args.title);
|
||||
|
||||
const newRecording = await ctx.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
title: args.title,
|
||||
description: args.description || null,
|
||||
slug,
|
||||
duration: args.duration,
|
||||
events: (args.events as object[]) || [],
|
||||
device_info: (args.deviceInfo as object[]) || [],
|
||||
user_id: ctx.currentUser.id,
|
||||
tags: args.tags || [],
|
||||
linked_video: args.linkedVideoId || null,
|
||||
status: (args.status as any) || "draft",
|
||||
public: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const recording = newRecording[0];
|
||||
|
||||
// Gamification: award points if published
|
||||
if (recording.status === "published") {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
||||
}
|
||||
|
||||
return recording;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateRecording", (t) =>
|
||||
t.field({
|
||||
type: RecordingType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
title: t.arg.string(),
|
||||
description: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
status: t.arg.string(),
|
||||
public: t.arg.boolean(),
|
||||
linkedVideoId: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.title !== null && args.title !== undefined) {
|
||||
updates.title = args.title;
|
||||
updates.slug = slugify(args.title);
|
||||
}
|
||||
if (args.description !== null && args.description !== undefined) updates.description = args.description;
|
||||
if (args.tags !== null && args.tags !== undefined) updates.tags = args.tags;
|
||||
if (args.status !== null && args.status !== undefined) updates.status = args.status;
|
||||
if (args.public !== null && args.public !== undefined) updates.public = args.public;
|
||||
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined) updates.linked_video = args.linkedVideoId;
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(recordings)
|
||||
.set(updates as any)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.returning();
|
||||
|
||||
const recording = updated[0];
|
||||
|
||||
// Gamification: if newly published
|
||||
if (args.status === "published" && existing[0].status !== "published") {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
||||
}
|
||||
if (args.status === "published" && recording.featured && !existing[0].featured) {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
||||
}
|
||||
|
||||
return recording;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("deleteRecording", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||
|
||||
await ctx.db
|
||||
.update(recordings)
|
||||
.set({ status: "archived", date_updated: new Date() })
|
||||
.where(eq(recordings.id, args.id));
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("duplicateRecording", (t) =>
|
||||
t.field({
|
||||
type: RecordingType,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const original = await ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.limit(1);
|
||||
|
||||
if (!original[0]) throw new GraphQLError("Recording not found");
|
||||
if (original[0].status !== "published" || !original[0].public) {
|
||||
throw new GraphQLError("Recording is not publicly shared");
|
||||
}
|
||||
|
||||
const slug = `${slugify(original[0].title)}-copy-${Date.now()}`;
|
||||
|
||||
const duplicated = await ctx.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
title: `${original[0].title} (Copy)`,
|
||||
description: original[0].description,
|
||||
slug,
|
||||
duration: original[0].duration,
|
||||
events: original[0].events || [],
|
||||
device_info: original[0].device_info || [],
|
||||
user_id: ctx.currentUser.id,
|
||||
tags: original[0].tags || [],
|
||||
status: "draft",
|
||||
public: false,
|
||||
original_recording_id: original[0].id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return duplicated[0];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("recordRecordingPlay", (t) =>
|
||||
t.field({
|
||||
type: "JSON",
|
||||
args: {
|
||||
recordingId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const recording = await ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(eq(recordings.id, args.recordingId))
|
||||
.limit(1);
|
||||
|
||||
if (!recording[0]) throw new GraphQLError("Recording not found");
|
||||
|
||||
const play = await ctx.db
|
||||
.insert(recording_plays)
|
||||
.values({
|
||||
recording_id: args.recordingId,
|
||||
user_id: ctx.currentUser?.id || null,
|
||||
duration_played: 0,
|
||||
completed: false,
|
||||
})
|
||||
.returning({ id: recording_plays.id });
|
||||
|
||||
// Gamification
|
||||
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
|
||||
}
|
||||
|
||||
return { success: true, play_id: play[0].id };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateRecordingPlay", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
playId: t.arg.string({ required: true }),
|
||||
durationPlayed: t.arg.int({ required: true }),
|
||||
completed: t.arg.boolean({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(recording_plays)
|
||||
.where(eq(recording_plays.id, args.playId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing[0]) throw new GraphQLError("Play record not found");
|
||||
const wasCompleted = existing[0].completed;
|
||||
|
||||
await ctx.db
|
||||
.update(recording_plays)
|
||||
.set({ duration_played: args.durationPlayed, completed: args.completed, date_updated: new Date() })
|
||||
.where(eq(recording_plays.id, args.playId));
|
||||
|
||||
if (args.completed && !wasCompleted && ctx.currentUser) {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
29
packages/backend/src/graphql/resolvers/stats.ts
Normal file
29
packages/backend/src/graphql/resolvers/stats.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { StatsType } from "../types/index.js";
|
||||
import { users, videos } from "../../db/schema/index.js";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
|
||||
builder.queryField("stats", (t) =>
|
||||
t.field({
|
||||
type: StatsType,
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
const modelsCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.role, "model"));
|
||||
const viewersCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.role, "viewer"));
|
||||
const videosCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(videos);
|
||||
|
||||
return {
|
||||
models_count: modelsCount[0]?.count || 0,
|
||||
viewers_count: viewersCount[0]?.count || 0,
|
||||
videos_count: videosCount[0]?.count || 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
72
packages/backend/src/graphql/resolvers/users.ts
Normal file
72
packages/backend/src/graphql/resolvers/users.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CurrentUserType, UserType } from "../types/index.js";
|
||||
import { users } from "../../db/schema/index.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
builder.queryField("me", (t) =>
|
||||
t.field({
|
||||
type: CurrentUserType,
|
||||
nullable: true,
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
if (!ctx.currentUser) return null;
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, ctx.currentUser.id))
|
||||
.limit(1);
|
||||
return user[0] || null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("userProfile", (t) =>
|
||||
t.field({
|
||||
type: UserType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, args.id))
|
||||
.limit(1);
|
||||
return user[0] || null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateProfile", (t) =>
|
||||
t.field({
|
||||
type: CurrentUserType,
|
||||
nullable: true,
|
||||
args: {
|
||||
firstName: t.arg.string(),
|
||||
lastName: t.arg.string(),
|
||||
artistName: t.arg.string(),
|
||||
description: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.firstName !== undefined && args.firstName !== null) updates.first_name = args.firstName;
|
||||
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
|
||||
if (args.artistName !== undefined && args.artistName !== null) updates.artist_name = args.artistName;
|
||||
if (args.description !== undefined && args.description !== null) updates.description = args.description;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
|
||||
await ctx.db.update(users).set(updates as any).where(eq(users.id, ctx.currentUser.id));
|
||||
|
||||
const updated = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, ctx.currentUser.id))
|
||||
.limit(1);
|
||||
return updated[0] || null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
320
packages/backend/src/graphql/resolvers/videos.ts
Normal file
320
packages/backend/src/graphql/resolvers/videos.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index.js";
|
||||
import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index.js";
|
||||
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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user