Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
@@ -0,0 +1,156 @@
// app/api/admin/allowed-students/import/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
// Check if user is admin
async function checkAdmin(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return null;
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return null;
}
return session;
}
// POST import students from CSV
export async function POST(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Read file
const text = await file.text();
const lines = text.split('\n').filter((line) => line.trim().length > 0);
if (lines.length === 0) {
return NextResponse.json({ error: 'CSV file is empty' }, { status: 400 });
}
// Skip header row if it exists (assume first line is header if email doesn't look like email)
let startIndex = 0;
if (lines[0].toLowerCase().includes('email')) {
startIndex = 1;
}
const results = {
imported: 0,
updated: 0,
errors: [] as Array<{ row: number; email: string; error: string }>,
};
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const [email, levels] = line.split(',').map((v) => v.trim());
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || !levels) {
results.errors.push({
row: i + 1,
email: email || 'N/A',
error: 'Invalid format: expected "email,courseCodes"',
});
continue;
}
// Validate email format
if (!email.includes('@')) {
results.errors.push({
row: i + 1,
email,
error: 'Invalid email format',
});
continue;
}
// Validate levels are valid course codes
const levelArray = levels.split('|').map((l) => l.trim());
let invalidLevel = null;
for (const level of levelArray) {
const course = await prisma.course.findUnique({ where: { code: level } });
if (!course) {
invalidLevel = level;
break;
}
}
if (invalidLevel) {
results.errors.push({
row: i + 1,
email,
error: `Course code '${invalidLevel}' not found`,
});
continue;
}
try {
// Check if student already exists
const existing = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: 'insensitive',
},
},
});
if (existing) {
// Update existing
await prisma.allowedStudent.update({
where: { id: existing.id },
data: {
email: normalizedEmail,
levels: levelArray.join(','),
active: true,
},
});
results.updated++;
} else {
// Create new
await prisma.allowedStudent.create({
data: {
email: normalizedEmail,
levels: levelArray.join(','),
active: true,
},
});
results.imported++;
}
} catch (err) {
results.errors.push({
row: i + 1,
email,
error: `Database error: ${(err as any)?.message || 'Unknown error'}`,
});
}
}
return NextResponse.json(results);
} catch (err) {
console.error('POST /api/admin/allowed-students/import error', err);
return NextResponse.json(
{ error: 'Failed to import students' },
{ status: 500 }
);
}
}
+192
View File
@@ -0,0 +1,192 @@
// app/api/admin/allowed-students/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
// Check if user is admin
async function checkAdmin(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return null;
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return null;
}
return session;
}
// GET all allowed students
export async function GET(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const students = await prisma.allowedStudent.findMany({
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(students);
} catch (err) {
console.error('GET /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to fetch allowed students' },
{ status: 500 }
);
}
}
// POST create new allowed student
export async function POST(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { email, levels } = body;
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || !levels) {
return NextResponse.json(
{ error: 'Email and levels required' },
{ status: 400 }
);
}
// Validate levels are valid course codes
const levelArray = levels.split(',').map((l: string) => l.trim());
for (const level of levelArray) {
const course = await prisma.course.findUnique({ where: { code: level } });
if (!course) {
return NextResponse.json(
{ error: `Course code '${level}' not found` },
{ status: 400 }
);
}
}
// Check if student already exists
const existing = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: 'insensitive',
},
},
});
if (existing) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
const student = await prisma.allowedStudent.create({
data: {
email: normalizedEmail,
levels: levelArray.join(','),
active: true,
},
});
return NextResponse.json(student, { status: 201 });
} catch (err) {
console.error('POST /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to create allowed student' },
{ status: 500 }
);
}
}
// PUT update allowed student
export async function PUT(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { id, email, levels, active } = body;
const normalizedEmail = email ? normalizeEmail(email) : null;
if (!id) {
return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
}
if (email && !normalizedEmail) {
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
}
// Validate levels if provided
if (levels) {
const levelArray = levels.split(',').map((l: string) => l.trim());
for (const level of levelArray) {
const course = await prisma.course.findUnique({ where: { code: level } });
if (!course) {
return NextResponse.json(
{ error: `Course code '${level}' not found` },
{ status: 400 }
);
}
}
}
const student = await prisma.allowedStudent.update({
where: { id },
data: {
...(normalizedEmail && { email: normalizedEmail }),
...(levels && { levels }),
...(active !== undefined && { active }),
},
});
return NextResponse.json(student);
} catch (err) {
console.error('PUT /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to update allowed student' },
{ status: 500 }
);
}
}
// DELETE allowed student
export async function DELETE(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
}
await prisma.allowedStudent.delete({
where: { id },
});
return NextResponse.json({ message: 'Student removed' });
} catch (err) {
console.error('DELETE /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to delete allowed student' },
{ status: 500 }
);
}
}
+27
View File
@@ -0,0 +1,27 @@
// app/api/admin/create-course/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
}
export async function POST(req: Request) {
try {
await checkAdmin();
const body = await req.json();
const { title, code } = body;
if (!title) return NextResponse.json({ error: 'missing title' }, { status: 400 });
const course = await prisma.course.create({ data: { title, code } });
return NextResponse.json({ course });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
// app/api/admin/create-playlist/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
}
export async function POST(req: Request) {
try {
await checkAdmin();
const { title, courseId, additionalCourseIds } = await req.json();
if (!title || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
const playlist = await prisma.playlist.create({
data: {
title,
courseId,
// Create CoursePlaylist mappings for additional courses
courses: {
create: (additionalCourseIds || []).map((cid: string) => ({
courseId: cid,
})),
},
},
include: {
courses: {
include: { course: true },
},
},
});
return NextResponse.json({ playlist });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
// app/api/admin/delete-course/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { courseId } = body ?? {};
if (!courseId)
return NextResponse.json(
{ error: 'courseId required' },
{ status: 400 }
);
// Delete course (cascade will handle playlists and videos via DB constraints)
await prisma.course.delete({
where: { id: courseId },
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('delete course error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
// app/api/admin/delete-playlist/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { playlistId } = body ?? {};
if (!playlistId)
return NextResponse.json(
{ error: 'playlistId required' },
{ status: 400 }
);
// Delete playlist (cascade will handle videos via DB constraints)
await prisma.playlist.delete({
where: { id: playlistId },
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('delete playlist error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+37
View File
@@ -0,0 +1,37 @@
// app/api/admin/delete-video/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { videoId } = body ?? {};
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
// Delete video (cascade will handle progress records)
await prisma.video.delete({
where: { id: videoId },
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('delete video error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,108 @@
// app/api/admin/manage-playlist-courses/route.ts
// Assign or remove a playlist from additional courses
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
}
// GET: Get all courses a playlist is assigned to
export async function GET(req: Request) {
try {
await checkAdmin();
const { searchParams } = new URL(req.url);
const playlistId = searchParams.get('playlistId');
if (!playlistId) return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
include: {
course: true,
courses: {
include: { course: true },
},
},
});
if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
// Return primary course + all additional courses
const allCourses = [
playlist.course,
...playlist.courses.map(cp => cp.course),
];
return NextResponse.json({ playlist, allCourses });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
// POST: Assign playlist to an additional course
export async function POST(req: Request) {
try {
await checkAdmin();
const { playlistId, courseId } = await req.json();
if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
// Check if already assigned
const existing = await prisma.coursePlaylist.findFirst({
where: { playlistId, courseId },
});
if (existing) return NextResponse.json({ error: 'Already assigned' }, { status: 400 });
const assignment = await prisma.coursePlaylist.create({
data: { playlistId, courseId },
include: { course: true },
});
return NextResponse.json({ assignment });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
// DELETE: Remove playlist from a course
export async function DELETE(req: Request) {
try {
await checkAdmin();
const { searchParams } = new URL(req.url);
const playlistId = searchParams.get('playlistId');
const courseId = searchParams.get('courseId');
if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
// Don't allow deleting the primary course assignment
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
if (playlist.courseId === courseId) {
return NextResponse.json({ error: 'Cannot remove primary course' }, { status: 400 });
}
await prisma.coursePlaylist.delete({
where: {
courseId_playlistId: { playlistId, courseId },
},
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
// app/api/admin/meta/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
export async function GET() {
// Basic protection: ensure signed-in user is admin. Modify as needed.
// If you want stricter, get session from context or check roles.
// Here we skip session check to let dev access; but production should check.
try {
const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
return NextResponse.json({ courses, playlists });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'server' }, { status: 500 });
}
}
+168
View File
@@ -0,0 +1,168 @@
// app/api/admin/notifications/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Get current user
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get all videos uploaded by this admin or superadmin
const uploadedVideos = await prisma.video.findMany({
where: { userId: user.id },
select: { id: true, title: true },
});
const videoIds = uploadedVideos.map((v) => v.id);
if (videoIds.length === 0) {
return NextResponse.json({
likes: [],
comments: [],
total: 0,
});
}
// Fetch recent likes on uploaded videos
const likes = await prisma.videoLike.findMany({
where: { videoId: { in: videoIds } },
include: {
user: {
select: {
id: true,
name: true,
image: true,
email: true,
},
},
video: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Fetch recent comments on uploaded videos
const comments = await prisma.comment.findMany({
where: { videoId: { in: videoIds } },
include: {
user: {
select: {
id: true,
name: true,
image: true,
email: true,
},
},
video: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Fetch recently created courses by this user
const createdCourses = await prisma.course.findMany({
where: { userId: user.id },
select: {
id: true,
title: true,
code: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Fetch recently created playlists by this user
const createdPlaylists = await prisma.playlist.findMany({
where: { userId: user.id },
include: {
course: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Combine and sort by date
const allNotifications = [
...likes.map((like) => ({
id: like.id,
type: 'like' as const,
user: like.user,
video: like.video,
content: null,
createdAt: like.createdAt,
})),
...comments.map((comment) => ({
id: comment.id,
type: 'comment' as const,
user: comment.user,
video: comment.video,
content: comment.content,
createdAt: comment.createdAt,
})),
...createdCourses.map((course) => ({
id: course.id,
type: 'course_created' as const,
user: null,
course: { id: course.id, title: course.title, code: course.code },
content: null,
createdAt: course.createdAt,
})),
...createdPlaylists.map((playlist) => ({
id: playlist.id,
type: 'playlist_created' as const,
user: null,
playlist: { id: playlist.id, title: playlist.title, courseTitle: playlist.course.title },
content: null,
createdAt: playlist.createdAt,
})),
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return NextResponse.json({
likes,
comments,
notifications: allNotifications,
total: allNotifications.length,
});
} catch (err: any) {
console.error('get notifications error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+34
View File
@@ -0,0 +1,34 @@
// app/api/admin/playlist-videos/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const playlistId = searchParams.get('playlistId');
if (!playlistId)
return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const videos = await prisma.video.findMany({
where: { playlistId },
select: { id: true, title: true, thumbnail: true, index: true, durationSec: true },
orderBy: { index: 'asc' },
});
return NextResponse.json(videos);
} catch (err: any) {
console.error('playlist videos GET error', err);
return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
// app/api/admin/reorder-videos/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { playlistId, orderedIds } = body ?? {};
if (!playlistId || !Array.isArray(orderedIds))
return NextResponse.json({ error: 'playlistId and orderedIds required' }, { status: 400 });
// Validate that all video ids belong to the playlist (optional)
const vids = await prisma.video.findMany({ where: { playlistId }, select: { id: true } });
const validIds = new Set(vids.map((v) => v.id));
const invalid = orderedIds.find((id: string) => !validIds.has(id));
if (invalid)
return NextResponse.json({ error: 'Invalid video id in orderedIds' }, { status: 400 });
// Use two-phase update to avoid unique constraint violation:
// Phase 1: Set all indices to negative temporary values
// Phase 2: Set to final positive values
await prisma.$transaction([
// Phase 1: Set temporary negative indices to avoid conflicts
...orderedIds.map((id: string, idx: number) =>
prisma.video.update({
where: { id },
data: { index: -(idx + 1) }, // Use negative values: -1, -2, -3, etc.
})
),
// Phase 2: Set to final indices
...orderedIds.map((id: string, idx: number) =>
prisma.video.update({
where: { id },
data: { index: idx },
})
),
]);
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('reorder videos error', err);
return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
}
}
+106
View File
@@ -0,0 +1,106 @@
// app/api/admin/stats/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { Prisma } from '@prisma/client';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch all videos with aggregated stats using raw queries for better performance
const videos = await prisma.video.findMany({
include: {
playlist: {
select: {
title: true,
},
},
uploader: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
likes: true,
comments: true,
unlocks: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
// Fetch detailed stats for each video using raw queries for better performance
const statsData = await prisma.$queryRaw<
Array<{
videoId: string;
totalViewers: number;
totalSecondsWatched: bigint;
avgPercentWatched: number | null;
totalSegments: number;
completionCount: number;
}>
>`
SELECT
p."videoId",
COUNT(DISTINCT p."userId") as "totalViewers",
CAST(COALESCE(SUM(p."watchedSec"), 0) AS BIGINT) as "totalSecondsWatched",
ROUND(CAST(AVG(p."percent") AS NUMERIC), 2) as "avgPercentWatched",
(SELECT COUNT(*) FROM "VideoWatchSegment" ws WHERE ws."videoId" = p."videoId") as "totalSegments",
COUNT(CASE WHEN p."completed" = true THEN 1 END) as "completionCount"
FROM "VideoProgress" p
GROUP BY p."videoId"
`;
// Create a map for quick lookup
const statsMap = new Map(statsData.map(item => [item.videoId, item]));
// Combine video data with stats
const videosWithStats = videos.map(video => {
const stats = statsMap.get(video.id);
const views = video._count.unlocks || 0;
const totalViewers = stats ? Number(stats.totalViewers) : 0;
const completions = stats ? Number(stats.completionCount) : 0;
const engagement = (video._count.likes || 0) + (video._count.comments || 0);
const avgWatched = stats?.avgPercentWatched ? Number(stats.avgPercentWatched) : 0;
return {
id: video.id,
title: video.title,
playlistTitle: video.playlist.title,
uploaderName: video.uploader?.name || 'Unknown',
views,
totalViewers,
completions,
completionRate: totalViewers > 0 ? ((completions / totalViewers) * 100).toFixed(2) : '0.00',
likes: video._count.likes,
comments: video._count.comments,
engagement,
avgPercentWatched: avgWatched,
totalSecondsWatched: stats ? Number(stats.totalSecondsWatched) : 0,
totalSegments: stats ? Number(stats.totalSegments) : 0,
durationSec: video.durationSec,
createdAt: video.createdAt,
};
});
return NextResponse.json(videosWithStats);
} catch (err: any) {
console.error('get stats error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
// app/api/admin/update-playlist/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { playlistId, title } = await req.json();
if (!playlistId) {
return NextResponse.json(
{ error: 'playlistId required' },
{ status: 400 }
);
}
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return NextResponse.json(
{ error: 'title required and must be non-empty' },
{ status: 400 }
);
}
const updatedPlaylist = await prisma.playlist.update({
where: { id: playlistId },
data: { title: title.trim() },
});
return NextResponse.json(updatedPlaylist, { status: 200 });
} catch (err: any) {
console.error('update playlist error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+77
View File
@@ -0,0 +1,77 @@
// app/api/admin/update-user-role/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only superadmins can assign roles
const role = (session as any)?.user?.role ?? null;
if (role !== 'superadmin') {
return NextResponse.json(
{ error: 'Only superadmins can assign roles' },
{ status: 403 }
);
}
const body = await req.json();
const { userId, newRole } = body;
if (!userId || !newRole) {
return NextResponse.json(
{ error: 'userId and newRole required' },
{ status: 400 }
);
}
// Validate newRole
const validRoles = ['user', 'admin', 'superadmin'];
if (!validRoles.includes(newRole)) {
return NextResponse.json(
{ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` },
{ status: 400 }
);
}
// Prevent self-demotion from superadmin
const currentUser = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (currentUser?.id === userId && newRole !== 'superadmin') {
return NextResponse.json(
{ error: 'Cannot demote yourself from superadmin' },
{ status: 400 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { role: newRole },
select: {
id: true,
email: true,
name: true,
role: true,
},
});
return NextResponse.json({
message: 'User role updated successfully',
user: updatedUser,
});
} catch (err: any) {
console.error('update user role error:', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+141
View File
@@ -0,0 +1,141 @@
// app/api/admin/update-video/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
export async function PATCH(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { videoId, title } = await req.json();
if (!videoId) {
return NextResponse.json(
{ error: 'videoId required' },
{ status: 400 }
);
}
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return NextResponse.json(
{ error: 'title required and must be non-empty' },
{ status: 400 }
);
}
const updatedVideo = await prisma.video.update({
where: { id: videoId },
data: { title: title.trim() },
});
return NextResponse.json(updatedVideo, { status: 200 });
} catch (err: any) {
console.error('update video error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const formData = await req.formData().catch(() => null);
if (!formData) {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
}
const videoId = formData.get('videoId') as string;
const title = formData.get('title') as string;
const description = formData.get('description') as string | null;
const thumbnail = formData.get('thumbnail') as File | null;
const restricted = formData.get('restrictedCourseIds') as string | null;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
// Build update data
const updateData: any = {};
if (title) updateData.title = title;
if (description !== null) updateData.description = description;
let restrictedCourseIds: string[] | null = null;
if (restricted) {
try {
const parsed = JSON.parse(restricted);
if (Array.isArray(parsed)) {
restrictedCourseIds = parsed.filter((id) => typeof id === 'string' && id);
}
} catch (err) {
console.warn('Invalid restrictedCourseIds payload', err);
}
}
// Handle thumbnail upload if provided
if (thumbnail && thumbnail.size > 0) {
const bytes = await thumbnail.arrayBuffer();
const buffer = Buffer.from(bytes);
// Save to public/thumbnails
const uploadDir = join(process.cwd(), 'public', 'thumbnails');
await mkdir(uploadDir, { recursive: true });
const filename = `${videoId}-${Date.now()}.${thumbnail.type.split('/')[1] || 'jpg'}`;
const filepath = join(uploadDir, filename);
await writeFile(filepath, buffer);
updateData.thumbnail = `/thumbnails/${filename}`;
}
if (Object.keys(updateData).length === 0 && !thumbnail && restrictedCourseIds === null) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
const updated = await prisma.video.update({
where: { id: videoId },
data: updateData,
});
if (restrictedCourseIds !== null) {
await prisma.videoCourse.deleteMany({ where: { videoId } });
if (restrictedCourseIds.length > 0) {
await prisma.videoCourse.createMany({
data: restrictedCourseIds.map((courseId) => ({
videoId,
courseId,
exclusive: true,
})),
skipDuplicates: true,
});
}
}
return NextResponse.json({ success: true, video: updated });
} catch (err: any) {
console.error('update video error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+150
View File
@@ -0,0 +1,150 @@
// app/api/admin/upload/finalize/route.ts
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
async function getVideoDurationFromFile(filePath: string): Promise<number | null> {
return new Promise((resolve) => {
try {
const ffprobe = spawn("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
filePath,
]);
let output = "";
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
ffprobe.kill();
resolve(null);
}, 30000);
ffprobe.stdout.on("data", (data) => {
output += data.toString();
});
ffprobe.on("close", (code) => {
clearTimeout(timeoutId);
if (!timedOut && code === 0) {
const duration = parseFloat(output.trim());
if (!isNaN(duration) && isFinite(duration) && duration > 0) {
resolve(Math.round(duration));
} else {
resolve(null);
}
} else {
resolve(null);
}
});
ffprobe.on("error", () => {
clearTimeout(timeoutId);
resolve(null);
});
} catch (err) {
resolve(null);
}
});
}
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
const allowed = (process.env.ALLOWED_ADMINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email))
throw { status: 403, message: "Forbidden" };
return session;
}
export async function POST(req: Request) {
try {
console.log('[Upload/Finalize] Request received');
await checkAdmin();
const body = await req.json();
const { videoId } = body;
if (!videoId) {
return NextResponse.json({ error: "videoId required" }, { status: 400 });
}
console.log('[Upload/Finalize] Checking for video:', videoId);
// Check if video exists in DB
const video = await prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return NextResponse.json({ error: "video not found" }, { status: 404 });
}
// Check if file exists at the expected path
const videoPath = path.join(UPLOADS_DIR, "videos", `${videoId}.mp4`);
console.log('[Upload/Finalize] Checking file at:', videoPath);
try {
await fs.promises.access(videoPath, fs.constants.F_OK);
} catch {
return NextResponse.json(
{ error: `File not found at ${videoPath}. Please copy the file there first.` },
{ status: 404 }
);
}
console.log('[Upload/Finalize] File found, extracting duration');
// Extract duration if not already set
let durationSec = video.durationSec;
if (!durationSec) {
try {
const extractedDuration = await getVideoDurationFromFile(videoPath);
if (extractedDuration !== null) {
durationSec = extractedDuration;
console.log('[Upload/Finalize] Extracted duration:', durationSec);
} else {
console.warn('[Upload/Finalize] Could not extract duration');
}
} catch (err) {
console.warn('[Upload/Finalize] Duration extraction error:', err);
}
}
// Update video: set URL, duration, and transcoding status to trigger processing
const videoUrl = `/uploads/videos/${videoId}.mp4`;
const updatedVideo = await prisma.video.update({
where: { id: videoId },
data: {
url: videoUrl,
transcodingStatus: 'uploaded', // Mark as ready for transcoding
...(durationSec !== null && { durationSec }),
},
});
console.log('[Upload/Finalize] Video updated successfully with URL:', videoUrl);
return NextResponse.json({
success: true,
video: updatedVideo,
message: `Video finalized${durationSec ? ` (${durationSec}s)` : ''}. HLS transcoding will start shortly.`,
}, { status: 200 });
} catch (err: any) {
console.error("[Upload/Finalize] Error:", err);
const status = err?.status ?? 500;
const message = err?.message ?? "server error";
return NextResponse.json({ error: message }, { status });
}
}
+394
View File
@@ -0,0 +1,394 @@
// app/api/admin/upload/route.ts
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { IncomingMessage } from "http";
import { spawn } from "child_process";
import { pipeline } from "stream/promises";
import formidable, { File as FormidableFile } from "formidable";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
// For 2GB uploads over Tailscale (~10-50 Mbps), need 30-45 minutes
export const maxDuration = 2700; // 45 minutes for very large uploads over slow connections
// Use ConfigurableUPLOADS_DIR from environment or default to /uploads
const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
// Helper to extract video duration using FFprobe
async function getVideoDurationFromFile(filePath: string): Promise<number | null> {
return new Promise((resolve) => {
try {
const ffprobe = spawn("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
filePath,
]);
let output = "";
let timedOut = false;
// Large 2GB files may take longer to scan; timeout after 30s
const timeoutId = setTimeout(() => {
timedOut = true;
ffprobe.kill();
resolve(null); // Return null instead of blocking
}, 30000);
ffprobe.stdout.on("data", (data) => {
output += data.toString();
});
ffprobe.on("close", (code) => {
clearTimeout(timeoutId);
if (!timedOut && code === 0) {
const duration = parseFloat(output.trim());
if (!isNaN(duration) && isFinite(duration) && duration > 0) {
resolve(Math.round(duration));
} else {
resolve(null);
}
} else {
resolve(null);
}
});
ffprobe.on("error", () => {
clearTimeout(timeoutId);
resolve(null);
});
} catch (err) {
resolve(null);
}
});
}
function parseForm(req: IncomingMessage): Promise<{ fields: any; files: any }> {
const form = formidable({
multiples: false,
maxFileSize: 2.5 * 1024 * 1024 * 1024, // 2.5GB max file size
maxFieldsSize: 10 * 1024 * 1024, // 10MB for all fields combined
maxFields: 50,
keepExtensions: true,
});
return new Promise((resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
console.error('[Upload] Formidable parse error:', err.code, err.message);
reject(err);
}
else resolve({ fields, files });
});
});
}
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
const allowed = (process.env.ALLOWED_ADMINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email))
throw { status: 403, message: "Forbidden" };
return session;
}
export async function POST(req: Request) {
// track saved paths so we can cleanup on error
let savedVideoDest: string | undefined;
let savedThumbDest: string | undefined;
try {
console.log('[Upload] Request received, content-type:', req.headers.get("content-type"));
const nodeReq = (req as any).req ?? (globalThis as any).__NEXT_INIT?.req ?? null;
const session = await checkAdmin();
console.log('[Upload] Admin check passed for:', session.user?.email);
// helper to stream a File directly to disk (handles large files efficiently)
async function saveVideoStream(file: File, origName?: string) {
const filename = `${Date.now()}-${String(origName ?? "upload.mp4")}`;
const dest = path.join(UPLOADS_DIR, "videos", filename);
// Ensure directory exists
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
// Create write stream to destination
const writeStream = fs.createWriteStream(dest);
try {
// Stream the file directly to disk without buffering
// file.stream() returns a Web ReadableStream, convert to Node.js stream
const nodeStream = file.stream() as any;
await pipeline(nodeStream, writeStream);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
return { filename, dest };
} catch (err) {
// Clean up the partially written file if stream fails
try {
await fs.promises.unlink(dest);
} catch (_) {}
throw err;
}
}
// helper to save a thumbnail buffer to UPLOADS_DIR/thumbnails
async function saveThumbBuffer(buffer: Buffer, ext = "jpg") {
const filename = `${Date.now()}-thumb.${ext.replace(/^\./, "")}`;
const dest = path.join(UPLOADS_DIR, "thumbnails", filename);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.writeFile(dest, buffer);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
return { filename, dest };
}
let title: string | undefined;
let playlistId: string | undefined;
let durationSec: number | null = null;
let savedFilename: string | undefined;
// **IMPORTANT**: single thumbnailUrl used in both branches
let thumbnailUrl: string | null = null;
if (!nodeReq) {
// Request.formData() flow (some Next.js environments)
let formData: FormData | null = null;
try {
formData = await req.formData();
} catch (e: any) {
console.error("Failed to parse body as FormData.", e, "content-type=", req.headers.get("content-type"));
return NextResponse.json(
{ error: "Failed to parse body as FormData. Ensure request is sent with multipart/form-data." },
{ status: 400 }
);
}
const isManualCopy = formData.get("manualFileCopy") === "true";
const file = isManualCopy ? null : (formData.get("file") as Blob | null);
title = (formData.get("title") as string) || undefined;
playlistId = (formData.get("playlistId") as string) || undefined;
const durationField = formData.get("durationSec") as string | null;
if (durationField) {
const n = Number(durationField);
if (!isNaN(n)) durationSec = Math.round(n);
}
// thumbnail (optional)
const thumb = formData.get("thumbnail") as Blob | null;
if (thumb) {
try {
const thumbArrayBuffer = await thumb.arrayBuffer();
const thumbBuffer = Buffer.from(thumbArrayBuffer);
// try to infer extension from name, fallback to jpg
const fName = (thumb as any).name ?? "";
const extMatch = fName.match(/\.([a-z0-9]+)$/i);
const ext = extMatch ? extMatch[1] : "jpg";
const saved = await saveThumbBuffer(thumbBuffer, ext);
thumbnailUrl = `/api/thumbnails/${saved.filename}`;
savedThumbDest = saved.dest;
} catch (err) {
console.warn("thumbnail save failed (formData)", err);
// not fatal — we simply leave thumbnailUrl null
}
}
if (!isManualCopy) {
if (!file) return NextResponse.json({ error: "no file" }, { status: 400 });
// Stream the large file directly to disk without buffering
const origName = (file as any).name ?? `upload-${Date.now()}.mp4`;
const saved = await saveVideoStream(file as File, origName);
savedFilename = saved.filename;
savedVideoDest = saved.dest;
} else {
// Manual copy mode: don't save file, just mark for manual copy
console.log('[Upload] Manual file copy mode enabled');
savedFilename = undefined;
savedVideoDest = undefined;
}
} else {
// formidable flow (Node IncomingMessage available)
console.log('[Upload] Using formidable flow for file upload');
const { fields, files } = await parseForm(nodeReq as IncomingMessage);
console.log('[Upload] Formidable parsing complete, fields:', Object.keys(fields), 'files:', Object.keys(files));
const f: FormidableFile | undefined =
(files && (files.file as FormidableFile)) || (files && Object.values(files)[0]);
if (!f) return NextResponse.json({ error: "no file found" }, { status: 400 });
// handle thumbnail file if present in formidable files
const thumbFile = (files && (files.thumbnail as FormidableFile)) || undefined;
if (thumbFile) {
try {
const tPath = (thumbFile as any).filepath || (thumbFile as any).path;
const originalThumbName = (thumbFile as any).originalFilename || path.basename(tPath);
const tExt = path.extname(originalThumbName) || ".jpg";
const thumbFilename = `${Date.now()}-thumb${tExt}`;
const thumbDest = path.join(UPLOADS_DIR, "thumbnails", thumbFilename);
await fs.promises.mkdir(path.dirname(thumbDest), { recursive: true });
await fs.promises.copyFile(tPath, thumbDest);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(thumbDest, 0o644);
thumbnailUrl = `/api/thumbnails/${thumbFilename}`;
savedThumbDest = thumbDest;
} catch (err) {
console.warn("thumbnail save failed (formidable)", err);
}
}
// copy video file to UPLOADS_DIR/videos
const filePath = (f as any).filepath || (f as any).path;
const originalFilename = (f as any).originalFilename || (f as any).name || path.basename(filePath);
const filename = `${Date.now()}-${originalFilename}`;
const dest = path.join(UPLOADS_DIR, "videos", filename);
console.log('[Upload] Copying video file', { size: (f as any).size, path: filePath, dest });
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.copyFile(filePath, dest);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
console.log('[Upload] Video file copied successfully');
title = fields.title ?? originalFilename;
playlistId = fields.playlistId;
const durationField = fields.durationSec ?? fields.duration ?? null;
if (durationField) {
const n = Number(durationField);
if (!isNaN(n)) durationSec = Math.round(n);
}
savedFilename = filename;
savedVideoDest = dest;
}
// validate playlist: avoid FK errors
if (!playlistId) {
// cleanup if needed
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "playlistId required" }, { status: 400 });
}
const playlist = await prisma.playlist.findUnique({ where: { id: playlistId } });
if (!playlist) {
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "playlist not found" }, { status: 400 });
}
// Get current user
const user = await prisma.user.findUnique({
where: { email: session.user?.email ?? "" },
select: { id: true },
});
if (!user) {
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "user not found" }, { status: 400 });
}
// If duration not provided by client, try to extract it from the saved video file
if (durationSec === null && savedVideoDest) {
try {
const extractedDuration = await getVideoDurationFromFile(savedVideoDest);
if (extractedDuration !== null) {
durationSec = extractedDuration;
console.log(`[Upload] Extracted duration: ${durationSec}s from ${savedVideoDest}`);
} else {
console.warn(`[Upload] Could not extract duration from ${savedVideoDest}`);
}
} catch (err) {
console.warn(`[Upload] Duration extraction error:`, err);
}
}
// For manual copy mode, check if we have a file
const isManualMode = !savedVideoDest;
// Calculate index: find max index in playlist and add 1
const maxIndexVideo = await prisma.video.findFirst({
where: { playlistId },
orderBy: { index: 'desc' },
select: { index: true },
});
const desiredIndex = (maxIndexVideo?.index ?? -1) + 1;
// First, create the video record to get its ID
const video = await prisma.video.create({
data: {
title: title ?? savedFilename ?? 'Untitled',
url: '', // Will be set below or after rename
thumbnail: thumbnailUrl,
index: desiredIndex,
playlistId,
userId: user.id,
transcodingStatus: isManualMode ? 'pending_manual_file' : 'uploaded',
...(durationSec !== null && { durationSec }),
},
});
// If manual copy mode, set the URL and return
if (isManualMode) {
const videoUrl = `/uploads/videos/${video.id}.mp4`;
await prisma.video.update({
where: { id: video.id },
data: { url: videoUrl },
});
console.log('[Upload] Manual copy mode: video created with ID', video.id, 'URL:', videoUrl);
return NextResponse.json({ video: { ...video, url: videoUrl } }, { status: 201 });
}
// Now rename the uploaded file to use the video ID
const finalFilename = `${video.id}.mp4`;
const finalDest = path.join(UPLOADS_DIR, "videos", finalFilename);
try {
if (savedVideoDest) {
await fs.promises.rename(savedVideoDest, finalDest);
}
// Update the URL in the database to reflect the final filename
await prisma.video.update({
where: { id: video.id },
data: { url: `/uploads/videos/${finalFilename}` },
});
} catch (err) {
console.error("Error renaming video file:", err);
// If rename fails, cleanup and delete the record
await prisma.video.delete({ where: { id: video.id } });
throw err;
}
return NextResponse.json({ video: { ...video, url: `/uploads/videos/${finalFilename}` } }, { status: 201 });
} catch (err: any) {
console.error("upload error", {
code: err?.code,
message: err?.message,
errno: err?.errno,
}, err);
// cleanup saved files if something failed
if (savedVideoDest) {
try {
await fs.promises.unlink(savedVideoDest);
} catch (_) {}
}
if (savedThumbDest) {
try {
await fs.promises.unlink(savedThumbDest);
} catch (_) {}
}
const status = err?.status ?? 500;
const message = err?.message ?? (err?.toString ? err.toString() : "server error");
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(
req: Request,
{ params }: { params: Promise<{ userId: string; videoId: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check if user is admin
const admin = await prisma.user.findUnique({ where: { email: session.user.email } });
if (!admin || (admin.role !== 'admin' && admin.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { userId, videoId } = await params;
// Fetch all watch segments for the specified user+video
const segments = await prisma.videoWatchSegment.findMany({
where: { userId, videoId },
select: { startSec: true, endSec: true, watchedAt: true },
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({ segments });
} catch (err: any) {
console.error('admin segments GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+150
View File
@@ -0,0 +1,150 @@
// app/api/admin/users/[userId]/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(
req: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { userId } = await params;
// Fetch user with all related data
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
image: true,
role: true,
createdAt: true,
enrollments: {
select: {
course: {
select: {
id: true,
title: true,
},
},
},
},
progress: {
select: {
id: true,
videoId: true,
watchedSec: true,
lastPos: true,
percent: true,
completed: true,
durationSec: true,
updatedAt: true,
createdAt: true,
video: {
select: {
id: true,
title: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
},
comments: {
select: {
id: true,
content: true,
createdAt: true,
video: {
select: {
id: true,
title: true,
},
},
replies: {
select: {
id: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
} catch (err: any) {
console.error('fetch user detail error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { userId } = await params;
// Prevent deleting yourself
const currentUser = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (currentUser?.id === userId) {
return NextResponse.json(
{ error: 'Cannot delete your own account' },
{ status: 400 }
);
}
// Delete user with cascading deletes handled by Prisma schema
// The schema has onDelete: Cascade for most relations
const deletedUser = await prisma.user.delete({
where: { id: userId },
});
return NextResponse.json({
message: 'User deleted successfully',
deleted: deletedUser.email,
});
} catch (err: any) {
console.error('delete user error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+68
View File
@@ -0,0 +1,68 @@
// app/api/admin/users/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch all users with enrollments and last activity
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
image: true,
role: true,
createdAt: true,
enrollments: {
select: {
course: {
select: {
id: true,
title: true,
},
},
},
},
progress: {
select: {
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 1,
},
},
orderBy: {
createdAt: 'desc',
},
});
// Map to include last activity
const usersWithActivity = users.map((user) => ({
...user,
lastActivity: user.progress[0]?.updatedAt ?? null,
progress: undefined, // Remove the progress array
}));
return NextResponse.json(usersWithActivity);
} catch (err: any) {
console.error('fetch users error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,96 @@
// app/api/admin/videos/[videoId]/instant-access/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json().catch(() => ({}));
const { instantAccess } = body ?? {};
if (typeof instantAccess !== 'boolean')
return NextResponse.json(
{ error: 'instantAccess boolean value required' },
{ status: 400 }
);
// Update the video
const video = await prisma.video.update({
where: { id: videoId },
data: { instantAccess },
select: { id: true, title: true, instantAccess: true, locked: true },
});
return NextResponse.json({ ok: true, video });
} catch (err: any) {
console.error('admin instant-access PATCH error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function GET(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Get the video
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, title: true, instantAccess: true, locked: true },
});
if (!video)
return NextResponse.json({ error: 'video not found' }, { status: 404 });
return NextResponse.json({ video });
} catch (err: any) {
console.error('admin instant-access GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,96 @@
// app/api/admin/videos/[videoId]/locked/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json().catch(() => ({}));
const { locked } = body ?? {};
if (typeof locked !== 'boolean')
return NextResponse.json(
{ error: 'locked boolean value required' },
{ status: 400 }
);
// Update the video
const video = await prisma.video.update({
where: { id: videoId },
data: { locked },
select: { id: true, title: true, locked: true, instantAccess: true },
});
return NextResponse.json({ ok: true, video });
} catch (err: any) {
console.error('admin locked PATCH error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function GET(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Get the video
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, title: true, locked: true, instantAccess: true },
});
if (!video)
return NextResponse.json({ error: 'video not found' }, { status: 404 });
return NextResponse.json({ video });
} catch (err: any) {
console.error('admin locked GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
// app/api/admin/videos/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch all videos with their playlist and course info
const videos = await prisma.video.findMany({
include: {
playlist: {
include: {
course: {
select: {
id: true,
title: true,
},
},
},
},
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
videoCourses: {
include: { course: true },
},
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(videos);
} catch (err: any) {
console.error('get videos error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}