Initial commit
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// app/api/auth/[...nextauth]/route.ts
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,57 @@
|
||||
// app/api/auth/check-student.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { normalizeEmail } from '@/lib/normalize-email';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const email = body?.email;
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
|
||||
if (!normalizedEmail) {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedStudent = await prisma.allowedStudent.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: normalizedEmail,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!allowedStudent || !allowedStudent.active) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
allowed: false,
|
||||
message: 'Email not registered for access'
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse course levels
|
||||
const levels = allowedStudent.levels
|
||||
.split(',')
|
||||
.map((level) => level.trim())
|
||||
.filter((level) => level.length > 0);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
allowed: true,
|
||||
levels,
|
||||
studentId: allowedStudent.id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/check-student error', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check student status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ commentId: string }> }
|
||||
) {
|
||||
const { commentId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { content } = await request.json();
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Non-empty content is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify comment exists
|
||||
const comment = await prisma.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
return NextResponse.json({ error: 'Comment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const reply = await prisma.commentReply.create({
|
||||
data: {
|
||||
commentId,
|
||||
userId: (session.user as any).id,
|
||||
content: content.trim(),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(reply, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create reply:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create reply' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ commentId: string }> }
|
||||
) {
|
||||
const { commentId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = (session.user as any).role;
|
||||
if (role !== 'admin' && role !== 'superadmin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the comment and its replies (cascade via Prisma)
|
||||
const comment = await prisma.comment.delete({
|
||||
where: { id: commentId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete comment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ replyId: string }> }
|
||||
) {
|
||||
const { replyId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = (session.user as any).role;
|
||||
if (role !== 'admin' && role !== 'superadmin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = await prisma.commentReply.delete({
|
||||
where: { id: replyId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete reply:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete reply' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const videoId = request.nextUrl.searchParams.get('videoId');
|
||||
|
||||
if (!videoId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'videoId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { videoId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(comments);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch comments' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { videoId, content } = await request.json();
|
||||
|
||||
if (!videoId || !content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'videoId and non-empty content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify video exists
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { id: videoId },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const comment = await prisma.comment.create({
|
||||
data: {
|
||||
videoId,
|
||||
userId: (session.user as any).id,
|
||||
content: content.trim(),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(comment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create comment:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create comment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// app/api/users/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const courses = await prisma.course.findMany({ select: { id: true, title: true, code: true } });
|
||||
return NextResponse.json(courses);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// /app/api/enrollments/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// --- GET --------------------------------------------------------------------
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const enrollments = await prisma.enrollment.findMany({
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
course: { select: { id: true, title: true, code: true, } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return NextResponse.json(enrollments);
|
||||
} catch (err) {
|
||||
console.error('GET /api/enrollments error', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch enrollments' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST --------------------------------------------------------------------
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
// incoming payload example:
|
||||
// { userId: "cmicsu2030000uaec0sp56gso", courseId: "cmicrx1b70000uahwilu0gh9u", role: "student" }
|
||||
const userId: string | undefined = body.userId;
|
||||
const courseId: string | undefined = body.courseId;
|
||||
// const role: string = body.role ?? 'student';
|
||||
|
||||
if (!userId || !courseId) {
|
||||
return NextResponse.json({ error: 'Missing userId or courseId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Optional: Prevent duplicate enrolments
|
||||
const existing = await prisma.enrollment.findFirst({
|
||||
where: { userId: userId, courseId: courseId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enrollment already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create enrollment using STRING IDs
|
||||
const enrollment = await prisma.enrollment.create({
|
||||
data: {
|
||||
user: { connect: { id: userId } },
|
||||
course: { connect: { id: courseId } },
|
||||
// role: role,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
course: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(enrollment, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error('POST /api/enrollments error', err);
|
||||
return NextResponse.json({ error: 'Failed to create enrollment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE --------------------------------------------------------------------
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const id = body?.id;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.enrollment.delete({
|
||||
where: { id: id }, // STRING — not Number(id)
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/enrollments error', err);
|
||||
return NextResponse.json({ error: 'Failed to delete enrollment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// app/api/enrollments/sync.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';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
|
||||
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userEmail = session.user.email;
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
|
||||
|
||||
const body = await req.json();
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
|
||||
|
||||
const levels: string[] = body?.levels || [];
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
|
||||
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
|
||||
|
||||
if (!Array.isArray(levels) || levels.length === 0) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
|
||||
return NextResponse.json(
|
||||
{ error: 'Levels array required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
});
|
||||
if (!user) {
|
||||
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
|
||||
|
||||
const createdEnrollments = [];
|
||||
|
||||
// For each course level, find the course and create enrollment
|
||||
for (const level of levels) {
|
||||
try {
|
||||
console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { code: level },
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
|
||||
|
||||
// Check if enrollment already exists
|
||||
const existing = await prisma.enrollment.findFirst({
|
||||
where: { userId: user.id, courseId: course.id },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
|
||||
} else {
|
||||
const enrollment = await prisma.enrollment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
courseId: course.id,
|
||||
},
|
||||
});
|
||||
console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
|
||||
createdEnrollments.push({
|
||||
courseCode: level,
|
||||
courseId: course.id,
|
||||
enrollmentId: enrollment.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
|
||||
console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Enrollments synced',
|
||||
created: createdEnrollments,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
|
||||
console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to sync enrollments' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// app/api/enrollments/sync/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';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
|
||||
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userEmail = session.user.email;
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
|
||||
|
||||
const body = await req.json();
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
|
||||
|
||||
const levels: string[] = body?.levels || [];
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
|
||||
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
|
||||
|
||||
if (!Array.isArray(levels) || levels.length === 0) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
|
||||
return NextResponse.json(
|
||||
{ error: 'Levels array required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
});
|
||||
if (!user) {
|
||||
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
|
||||
|
||||
const createdEnrollments = [];
|
||||
|
||||
// For each course level, find the course and create enrollment
|
||||
for (const level of levels) {
|
||||
try {
|
||||
console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { code: level },
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
|
||||
|
||||
// Check if enrollment already exists
|
||||
const existing = await prisma.enrollment.findFirst({
|
||||
where: { userId: user.id, courseId: course.id },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
|
||||
} else {
|
||||
const enrollment = await prisma.enrollment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
courseId: course.id,
|
||||
},
|
||||
});
|
||||
console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
|
||||
createdEnrollments.push({
|
||||
courseCode: level,
|
||||
courseId: course.id,
|
||||
enrollmentId: enrollment.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
|
||||
console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Enrollments synced',
|
||||
created: createdEnrollments,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
|
||||
console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to sync enrollments' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// app/api/likes/all/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 user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Fetch all videos liked by user, sorted by most recent
|
||||
const likes = await prisma.videoLike.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
video: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
thumbnail: true,
|
||||
durationSec: true,
|
||||
url: true,
|
||||
playlist: {
|
||||
select: {
|
||||
title: true,
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(likes);
|
||||
} catch (err: any) {
|
||||
console.error('get liked videos error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// app/api/likes/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 body = await req.json().catch(() => ({}));
|
||||
const { videoId, isLiked } = body ?? {};
|
||||
|
||||
if (!videoId)
|
||||
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
if (isLiked) {
|
||||
// Add like
|
||||
const existingLike = await prisma.videoLike.findUnique({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
return NextResponse.json({ success: true, message: 'Already liked' });
|
||||
}
|
||||
|
||||
await prisma.videoLike.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
videoId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Video liked' });
|
||||
} else {
|
||||
// Remove like
|
||||
await prisma.videoLike.deleteMany({
|
||||
where: { userId: user.id, videoId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Video unliked' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('like toggle error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 videoId = searchParams.get('videoId');
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
if (videoId) {
|
||||
// Check if a specific video is liked
|
||||
const like = await prisma.videoLike.findUnique({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
});
|
||||
return NextResponse.json({ isLiked: !!like });
|
||||
} else {
|
||||
// Get all liked videos for user
|
||||
return NextResponse.json({ error: 'videoId required for check' }, { status: 400 });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('like check error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// app/api/playlists/[id]/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(req: Request, context: any) {
|
||||
try {
|
||||
// Unwrap params (Next may provide a Promise)
|
||||
let params = context?.params;
|
||||
if (typeof params?.then === "function") params = await params;
|
||||
|
||||
const id = params?.id;
|
||||
if (!id) return NextResponse.json({ error: "Missing playlist id" }, { status: 400 });
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const playlist = await prisma.playlist.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
videos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
course: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const enrolled = await prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
|
||||
});
|
||||
if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/playlists/[id] error:", err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// app/api/playlists/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type VideoWithCourses = {
|
||||
videoCourses?: Array<{ courseId: string; exclusive: boolean }>
|
||||
};
|
||||
|
||||
function mapRestrictedCourseIds(video: VideoWithCourses) {
|
||||
if (!video?.videoCourses?.length) return [];
|
||||
return video.videoCourses
|
||||
.filter((assignment) => assignment.exclusive)
|
||||
.map((assignment) => assignment.courseId);
|
||||
}
|
||||
|
||||
function filterRestrictedVideos(videos: any[], userCourseIds: string[]) {
|
||||
return videos
|
||||
.filter((video) => {
|
||||
const restrictedCourseIds = mapRestrictedCourseIds(video);
|
||||
if (restrictedCourseIds.length === 0) return true;
|
||||
return restrictedCourseIds.some((courseId: string) =>
|
||||
userCourseIds.includes(courseId)
|
||||
);
|
||||
})
|
||||
.map((video) => ({
|
||||
...video,
|
||||
restrictedCourseIds: mapRestrictedCourseIds(video),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: {
|
||||
enrollments: {
|
||||
include: { course: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const courseIds = user?.enrollments.map((e) => e.courseId) ?? [];
|
||||
|
||||
// Get playlists directly assigned to enrolled courses
|
||||
const coursesWithPlaylists = await prisma.course.findMany({
|
||||
where: { id: { in: courseIds } },
|
||||
include: {
|
||||
playlists: {
|
||||
include: {
|
||||
videos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
videoCourses: true,
|
||||
},
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
courses: {
|
||||
include: { course: true },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { title: "asc" },
|
||||
});
|
||||
|
||||
// Also get playlists assigned to courses via CoursePlaylist mapping
|
||||
const additionalPlaylists = await prisma.coursePlaylist.findMany({
|
||||
where: { courseId: { in: courseIds } },
|
||||
include: {
|
||||
playlist: {
|
||||
include: {
|
||||
videos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
videoCourses: true,
|
||||
},
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
courses: {
|
||||
include: { course: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
course: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Merge results: add additional playlists to their respective courses
|
||||
const playlistMap = new Map();
|
||||
additionalPlaylists.forEach(({ course, playlist }) => {
|
||||
if (!playlistMap.has(course.id)) {
|
||||
playlistMap.set(course.id, []);
|
||||
}
|
||||
playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
|
||||
playlistMap.get(course.id).push(playlist);
|
||||
});
|
||||
|
||||
coursesWithPlaylists.forEach((course) => {
|
||||
course.playlists.forEach((playlist) => {
|
||||
playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
|
||||
});
|
||||
|
||||
const additional = playlistMap.get(course.id) || [];
|
||||
const existingIds = new Set(course.playlists.map((p) => p.id));
|
||||
const newPlaylists = additional.filter((p: any) => !existingIds.has(p.id));
|
||||
newPlaylists.forEach((playlist: any) => {
|
||||
playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
|
||||
});
|
||||
course.playlists.push(...newPlaylists);
|
||||
course.playlists.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
});
|
||||
|
||||
return NextResponse.json({ subjects: coursesWithPlaylists });
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// app/api/progress/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 userEmail = session.user.email;
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
if (!videoId)
|
||||
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: userEmail } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Validate that the video exists
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const progress = await prisma.videoProgress.findUnique({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
select: { percent: true, lastPos: true, watchedSec: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
percent: progress?.percent ?? 0,
|
||||
lastPos: progress?.lastPos ?? 0,
|
||||
watchedSec: progress?.watchedSec ?? 0,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('progress GET error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to calculate unique watched seconds from segments
|
||||
function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
|
||||
if (segments.length === 0) return 0;
|
||||
|
||||
// Sort and merge overlapping ranges
|
||||
const sorted = segments.sort((a, b) => a.startSec - b.startSec);
|
||||
const merged: Array<[number, number]> = [];
|
||||
|
||||
for (const seg of sorted) {
|
||||
if (merged.length === 0) {
|
||||
merged.push([seg.startSec, seg.endSec]);
|
||||
} else {
|
||||
const last = merged[merged.length - 1];
|
||||
if (seg.startSec <= last[1] + 0.5) {
|
||||
// Overlapping or adjacent, merge
|
||||
last[1] = Math.max(last[1], seg.endSec);
|
||||
} else {
|
||||
// Gap, new range
|
||||
merged.push([seg.startSec, seg.endSec]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const [start, end] of merged) {
|
||||
total += Math.max(0, end - start);
|
||||
}
|
||||
return Math.round(total);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const userEmail = session.user.email;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
|
||||
|
||||
if (!videoId)
|
||||
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
||||
if (!duration || Number(duration) <= 0)
|
||||
return NextResponse.json({ error: 'duration required' }, { status: 400 });
|
||||
|
||||
const watched = Number(watchedSec ?? 0);
|
||||
const lastPosition = Number(lastPos ?? 0);
|
||||
const dur = Number(duration);
|
||||
|
||||
// look up user id
|
||||
const user = await prisma.user.findUnique({ where: { email: userEmail } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Validate that the video exists
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
// Log at debug level since this is expected when users have old cached references
|
||||
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ensure VideoProgress exists or create it
|
||||
let videoProgress = await prisma.videoProgress.findUnique({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
});
|
||||
|
||||
if (!videoProgress) {
|
||||
videoProgress = await prisma.videoProgress.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
videoId,
|
||||
watchedSec: 0,
|
||||
lastPos: 0,
|
||||
percent: 0,
|
||||
durationSec: dur,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Record the watch segment if watched seconds > 0
|
||||
let newSegment = null;
|
||||
if (watched > 0) {
|
||||
newSegment = await prisma.videoWatchSegment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
videoId,
|
||||
startSec: Math.max(0, lastPosition - watched),
|
||||
endSec: lastPosition,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all segments for this user+video to recalculate totals
|
||||
const allSegments = await prisma.videoWatchSegment.findMany({
|
||||
where: { userId: user.id, videoId },
|
||||
select: { startSec: true, endSec: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Calculate total unique watched seconds
|
||||
const totalWatchedSec = calculateWatchedSeconds(allSegments);
|
||||
|
||||
// Calculate completion percentage
|
||||
const ratio = dur > 0 ? totalWatchedSec / dur : 0;
|
||||
const percentInt = Math.min(100, Math.round(ratio * 100));
|
||||
const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
|
||||
|
||||
// Update VideoProgress with recalculated values
|
||||
const upserted = await prisma.videoProgress.update({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
data: {
|
||||
watchedSec: totalWatchedSec,
|
||||
lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
|
||||
percent: percentInt,
|
||||
durationSec: dur,
|
||||
completed,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// If 80% watched, unlock next video in playlist for this user
|
||||
let unlockedNext = null;
|
||||
if (completed && playlistId) {
|
||||
// find current video
|
||||
const current = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (current && current.playlistId === playlistId) {
|
||||
// Find next video that is NOT instant access and NOT globally locked
|
||||
// Skip any instant access videos in the sequence
|
||||
const nextVideos = await prisma.video.findMany({
|
||||
where: {
|
||||
playlistId,
|
||||
index: { gt: current.index },
|
||||
locked: false,
|
||||
instantAccess: false,
|
||||
},
|
||||
orderBy: { index: 'asc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (nextVideos.length > 0) {
|
||||
const next = nextVideos[0];
|
||||
// Create a VideoUnlock record for this user (per-user unlock tracking)
|
||||
const unlock = await prisma.videoUnlock.upsert({
|
||||
where: { userId_videoId: { userId: user.id, videoId: next.id } },
|
||||
update: {}, // if already exists, do nothing
|
||||
create: { userId: user.id, videoId: next.id },
|
||||
});
|
||||
unlockedNext = { id: next.id, title: next.title };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
|
||||
} catch (err: any) {
|
||||
console.error('progress error', err);
|
||||
|
||||
// Handle foreign key constraint violations
|
||||
if (err.code === 'P2003') {
|
||||
const constraint = err.meta?.constraint_name;
|
||||
if (constraint?.includes('videoId')) {
|
||||
console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid video reference' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 userEmail = session.user.email;
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
if (!videoId)
|
||||
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: userEmail } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Validate that the video exists
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
console.debug(`[PROGRESS] Video not found for segments: ${videoId} (likely deleted video with cached browser reference)`);
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch all watch segments for this user+video
|
||||
const segments = await prisma.videoWatchSegment.findMany({
|
||||
where: { userId: user.id, videoId },
|
||||
select: { startSec: true, endSec: true, watchedAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ segments });
|
||||
} catch (err: any) {
|
||||
console.error('segments GET error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// /api/thumbnails/[...path]/route.ts
|
||||
// This endpoint serves thumbnails from the thumbnails directory
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
|
||||
const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
const pathSegments = params?.path || [];
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid thumbnail request' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the file path and validate it
|
||||
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(requestedPath)) {
|
||||
console.log(`[Thumbnails] File not found: ${requestedPath}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileContent = fs.readFileSync(requestedPath);
|
||||
|
||||
// Determine content type based on file extension
|
||||
let contentType = 'application/octet-stream';
|
||||
|
||||
if (requestedPath.endsWith('.jpg') || requestedPath.endsWith('.jpeg')) {
|
||||
contentType = 'image/jpeg';
|
||||
} else if (requestedPath.endsWith('.png')) {
|
||||
contentType = 'image/png';
|
||||
} else if (requestedPath.endsWith('.webp')) {
|
||||
contentType = 'image/webp';
|
||||
} else if (requestedPath.endsWith('.gif')) {
|
||||
contentType = 'image/gif';
|
||||
}
|
||||
|
||||
// Return the file with appropriate headers
|
||||
return new NextResponse(fileContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
|
||||
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Thumbnails] Error serving thumbnail file:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HEAD request support for thumbnail validation
|
||||
export async function HEAD(
|
||||
request: Request,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
const pathSegments = params?.path || [];
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
|
||||
return new NextResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(requestedPath)) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Return headers only
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg', // Default for HEAD requests
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Thumbnails] Error in HEAD request:', error);
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// app/api/transcoder/claim/route.ts
|
||||
// Atomically claims the next available transcoding job.
|
||||
// Uses SELECT … FOR UPDATE SKIP LOCKED so multiple workers never race on the
|
||||
// same video.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!verifyTranscoderToken(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const video = await prisma.$transaction(async (tx) => {
|
||||
// Atomically lock the oldest uploaded video, skipping rows already
|
||||
// locked by concurrent workers.
|
||||
const rows = await tx.$queryRaw<{ id: string }[]>(
|
||||
Prisma.sql`
|
||||
SELECT id
|
||||
FROM "Video"
|
||||
WHERE "transcodingStatus" = 'uploaded'
|
||||
ORDER BY "createdAt" ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return tx.video.update({
|
||||
where: { id: rows[0].id },
|
||||
data: { transcodingStatus: "processing" },
|
||||
});
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
// No work available – return null body so the worker knows to stop.
|
||||
return NextResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
// Build the download URL from the public CMS base URL.
|
||||
const cmsBase =
|
||||
process.env.NEXTAUTH_URL?.replace(/\/$/, "") ??
|
||||
process.env.CMS_PUBLIC_URL?.replace(/\/$/, "") ??
|
||||
"";
|
||||
|
||||
return NextResponse.json({
|
||||
videoId: video.id,
|
||||
downloadUrl: `${cmsBase}/api/transcoder/download/${video.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Transcoder Claim] Error:", err);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// app/api/transcoder/download/[videoId]/route.ts
|
||||
// Streams the original MP4 directly to the remote transcoder worker.
|
||||
// Never buffers the file in memory.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import * as fssync from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
|
||||
|
||||
// Allow up to 45 minutes for large file transfers.
|
||||
export const maxDuration = 2700;
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
|
||||
const ORIGINALS_DIR = path.join(UPLOADS_DIR, "videos");
|
||||
|
||||
// Narrow character set – CUIDs are alphanumeric plus underscore/dash.
|
||||
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ videoId: string }> }
|
||||
) {
|
||||
if (!verifyTranscoderToken(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { videoId } = await context.params;
|
||||
|
||||
if (!SAFE_ID.test(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow download while the job is actively claimed.
|
||||
if (video.transcodingStatus !== "processing") {
|
||||
return NextResponse.json(
|
||||
{ error: "Video is not in processing state" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(ORIGINALS_DIR, `${videoId}.mp4`);
|
||||
|
||||
// Security: ensure the resolved path stays within ORIGINALS_DIR.
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(ORIGINALS_DIR))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolved);
|
||||
const nodeStream = fssync.createReadStream(resolved);
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
|
||||
|
||||
return new Response(webStream, {
|
||||
headers: {
|
||||
"Content-Type": "video/mp4",
|
||||
"Content-Length": stat.size.toString(),
|
||||
"Content-Disposition": `attachment; filename="${videoId}.mp4"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// app/api/transcoder/fail/[videoId]/route.ts
|
||||
// Marks a video job as failed. Called by the remote worker when transcoding
|
||||
// or upload encounters an unrecoverable error.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
|
||||
|
||||
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ videoId: string }> }
|
||||
) {
|
||||
if (!verifyTranscoderToken(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { videoId } = await context.params;
|
||||
|
||||
if (!SAFE_ID.test(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.video.update({
|
||||
where: { id: videoId },
|
||||
data: { transcodingStatus: "failed" },
|
||||
});
|
||||
|
||||
// Log the error message from the worker if provided.
|
||||
let workerError = "";
|
||||
try {
|
||||
const body = await request.json();
|
||||
workerError = typeof body?.error === "string" ? body.error : "";
|
||||
} catch {
|
||||
// Body may be empty – that's fine.
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[Transcoder Fail] ${videoId}${workerError ? ` – ${workerError}` : ""}`
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error(`[Transcoder Fail] DB error for ${videoId}:`, err);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// app/api/transcoder/upload/[videoId]/route.ts
|
||||
// Receives a ZIP archive of the completed HLS package from the remote worker,
|
||||
// extracts it to disk, validates it, renames the temp dir to its final name,
|
||||
// and marks the video as transcoded.
|
||||
//
|
||||
// The request body must be raw application/zip (no multipart wrapper).
|
||||
// The file is streamed to disk before extraction – never fully buffered in RAM.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import * as fssync from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import * as unzipper from "unzipper";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
|
||||
|
||||
// Allow up to 45 minutes for very large uploads.
|
||||
export const maxDuration = 2700;
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
|
||||
const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
|
||||
|
||||
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ videoId: string }> }
|
||||
) {
|
||||
if (!verifyTranscoderToken(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { videoId } = await context.params;
|
||||
|
||||
if (!SAFE_ID.test(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (video.transcodingStatus !== "processing") {
|
||||
return NextResponse.json(
|
||||
{ error: "Video is not in processing state" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await fs.mkdir(HLS_ROOT, { recursive: true });
|
||||
|
||||
const tempZipPath = path.join(HLS_ROOT, `${videoId}.incoming.zip`);
|
||||
const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
|
||||
const finalDir = path.join(HLS_ROOT, videoId);
|
||||
|
||||
// Security: path traversal guard.
|
||||
for (const p of [tempZipPath, tempDir, finalDir]) {
|
||||
if (!path.resolve(p).startsWith(path.resolve(HLS_ROOT))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!request.body) {
|
||||
return NextResponse.json({ error: "Empty request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Stream upload body to a temp zip file on disk.
|
||||
const nodeReadable = Readable.fromWeb(
|
||||
request.body as ReadableStream<Uint8Array>
|
||||
);
|
||||
const writeStream = fssync.createWriteStream(tempZipPath);
|
||||
await pipeline(nodeReadable, writeStream);
|
||||
|
||||
// 2. Prepare extraction directory.
|
||||
if (fssync.existsSync(tempDir)) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// 3. Stream-extract the zip.
|
||||
await fssync
|
||||
.createReadStream(tempZipPath)
|
||||
.pipe(unzipper.Extract({ path: tempDir }))
|
||||
.promise();
|
||||
|
||||
// 4. Remove temp zip.
|
||||
await fs.unlink(tempZipPath).catch(() => {});
|
||||
|
||||
// 5. Validate contents.
|
||||
const masterPath = path.join(tempDir, "master.m3u8");
|
||||
if (!fssync.existsSync(masterPath)) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
return NextResponse.json(
|
||||
{ error: "master.m3u8 not found in archive" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const files = await fs.readdir(tempDir);
|
||||
const hasVariant = files.some(
|
||||
(f) => f.endsWith(".m3u8") && f !== "master.m3u8"
|
||||
);
|
||||
if (!hasVariant) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
return NextResponse.json(
|
||||
{ error: "No variant playlist found in archive" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Remove any stale final directory and atomically rename.
|
||||
if (fssync.existsSync(finalDir)) {
|
||||
await fs.rm(finalDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rename(tempDir, finalDir);
|
||||
|
||||
// 7. Mark as transcoded in the database.
|
||||
await prisma.video.update({
|
||||
where: { id: videoId },
|
||||
data: { transcodingStatus: "transcoded" },
|
||||
});
|
||||
|
||||
console.log(`[Transcoder Upload] ${videoId} – success (${files.length} files)`);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error(`[Transcoder Upload] Error for ${videoId}:`, err);
|
||||
|
||||
// Best-effort cleanup.
|
||||
await fs.unlink(tempZipPath).catch(() => {});
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Upload processing failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// app/api/user/unlocks/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 userEmail = session.user.email;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Fetch all VideoUnlock records for this user
|
||||
const unlocks = await prisma.videoUnlock.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, videoId: true, unlockedAt: true, createdAt: true },
|
||||
orderBy: { unlockedAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ unlocks });
|
||||
} catch (err: any) {
|
||||
console.error('user unlocks GET 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 userEmail = session.user.email;
|
||||
const body = await req.json();
|
||||
const { videoId } = body;
|
||||
|
||||
if (!videoId) {
|
||||
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Verify video exists
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { id: videoId },
|
||||
select: { id: true, locked: true, instantAccess: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if already unlocked
|
||||
const existingUnlock = await prisma.videoUnlock.findUnique({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
});
|
||||
|
||||
if (existingUnlock) {
|
||||
return NextResponse.json({
|
||||
message: 'Video already unlocked',
|
||||
unlock: existingUnlock
|
||||
});
|
||||
}
|
||||
|
||||
// Create the unlock record
|
||||
const unlock = await prisma.videoUnlock.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
videoId: videoId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Video unlocked successfully',
|
||||
unlock
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('user unlocks POST error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// app/api/users/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
|
||||
export async function GET() {
|
||||
const users = await prisma.user.findMany({ select: { id: true, name: true, email: true } });
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// app/api/videos/[id]/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth-options";
|
||||
import { getVideoUrls } from "../../../../lib/video-urls";
|
||||
|
||||
export async function GET(req: Request, context: any) {
|
||||
try {
|
||||
// unwrap params
|
||||
let params = context?.params;
|
||||
if (typeof params?.then === "function") params = await params;
|
||||
|
||||
const id = params?.id;
|
||||
if (!id) return NextResponse.json({ error: "Missing video id" }, { status: 400 });
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: {
|
||||
enrollments: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
playlist: {
|
||||
include: {
|
||||
course: true,
|
||||
courses: true,
|
||||
},
|
||||
},
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
videoCourses: true,
|
||||
},
|
||||
});
|
||||
if (!video) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const userCourseIds = user.enrollments.map((enrollment) => enrollment.courseId);
|
||||
|
||||
const playlistCourseIds = new Set<string>([video.playlist.courseId]);
|
||||
(video.playlist.courses || []).forEach((mapping) => {
|
||||
playlistCourseIds.add(mapping.courseId);
|
||||
});
|
||||
|
||||
const hasPlaylistAccess = [...playlistCourseIds].some((courseId) =>
|
||||
userCourseIds.includes(courseId)
|
||||
);
|
||||
if (!hasPlaylistAccess) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const restrictedCourseIds = (video.videoCourses || [])
|
||||
.filter((assignment) => assignment.exclusive)
|
||||
.map((assignment) => assignment.courseId);
|
||||
if (
|
||||
restrictedCourseIds.length > 0 &&
|
||||
!restrictedCourseIds.some((courseId) => userCourseIds.includes(courseId))
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the next video (index = current.index + 1)
|
||||
const next = await prisma.video.findFirst({
|
||||
where: {
|
||||
playlistId: video.playlistId,
|
||||
index: video.index + 1,
|
||||
},
|
||||
include: {
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add video URLs with HLS support
|
||||
const videoUrls = getVideoUrls(video.id, video.url, video.transcodingStatus as any);
|
||||
const responseVideo = {
|
||||
...video,
|
||||
videoUrls,
|
||||
transcodingStatus: video.transcodingStatus,
|
||||
restrictedCourseIds,
|
||||
};
|
||||
|
||||
// Ensure we return the URL field explicitly (player expects `video.url`).
|
||||
return NextResponse.json({ video: responseVideo, next });
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/videos/[id] error:", err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// /api/videos/hls/[...path]/route.ts
|
||||
// This endpoint serves HLS playlists and segments from the HLS directory
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Path to HLS directory (should match UPLOADS_DIR/hls)
|
||||
const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
const pathSegments = params?.path || [];
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid HLS request' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the file path and validate it
|
||||
const requestedPath = path.join(HLS_DIR, ...pathSegments);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
if (!requestedPath.startsWith(HLS_DIR)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(requestedPath)) {
|
||||
console.log(`[HLS] File not found: ${requestedPath}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileContent = fs.readFileSync(requestedPath);
|
||||
|
||||
// Determine content type based on file extension
|
||||
let contentType = 'application/octet-stream';
|
||||
|
||||
if (requestedPath.endsWith('.m3u8')) {
|
||||
contentType = 'application/vnd.apple.mpegurl';
|
||||
} else if (requestedPath.endsWith('.ts')) {
|
||||
contentType = 'video/mp2t';
|
||||
} else if (requestedPath.endsWith('.mp4')) {
|
||||
contentType = 'video/mp4';
|
||||
}
|
||||
|
||||
// Return the file with appropriate headers
|
||||
return new NextResponse(fileContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[HLS] Error serving HLS file:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HEAD request support for playlist validation
|
||||
export async function HEAD(
|
||||
request: Request,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
const pathSegments = params?.path || [];
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
const requestedPath = path.join(HLS_DIR, ...pathSegments);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
if (!requestedPath.startsWith(HLS_DIR)) {
|
||||
return new NextResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(requestedPath)) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('[HLS] Error in HEAD request:', error);
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// app/api/videos/latest/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() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get all courses the user is enrolled in
|
||||
const enrolledCourses = await prisma.enrollment.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { courseId: true },
|
||||
});
|
||||
|
||||
const enrolledCourseIds = enrolledCourses.map((e) => e.courseId);
|
||||
|
||||
if (enrolledCourseIds.length === 0) {
|
||||
return NextResponse.json({ videos: [] });
|
||||
}
|
||||
|
||||
// Get the latest 10 videos from those courses
|
||||
// Videos can be in playlists that belong to enrolled courses
|
||||
const videos = await prisma.video.findMany({
|
||||
where: {
|
||||
playlist: {
|
||||
OR: [
|
||||
{ courseId: { in: enrolledCourseIds } },
|
||||
{
|
||||
courses: {
|
||||
some: { courseId: { in: enrolledCourseIds } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
durationSec: true,
|
||||
thumbnail: true,
|
||||
createdAt: true,
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
playlist: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return NextResponse.json({ videos });
|
||||
} catch (err: any) {
|
||||
console.error('GET /api/videos/latest error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'Server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// app/api/watch-history/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 userEmail = session.user.email;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: userEmail } });
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Fetch all videos watched by user, sorted by most recently updated
|
||||
const watchHistory = await prisma.videoProgress.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
video: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
thumbnail: true,
|
||||
durationSec: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(watchHistory);
|
||||
} catch (err: any) {
|
||||
console.error('watch history GET error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user