a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Watch progress and unlocking: - Player samples the playhead and reports the ranges actually played, so skipped sections earn no credit and early plays are not lost - Server merges ranges into compact segment rows; progress never regresses - Single 80% unlock rule enforced server-side (lib/watch-progress.ts); the Unlock button and automatic unlock now agree - Lock state and progress bar update live from each progress save - Playlist is derived from the video, so unlocks work for shared playlists and for links from watch history and liked videos - First/previous video decided by playlist position, not index numbers Access control: - Video API and HLS/MP4 routes require a signed-in viewer who is enrolled and has the video unlocked; thumbnails require sign-in - Enrolment sync reads course codes from the allow-list, not the request - /api/admin/meta is admin-only - Account status, role and levels are re-checked from the database during a session, so deactivated students lose access within a minute Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
// 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";
|
|
import { checkVideoAccess } from "../../../../lib/video-access";
|
|
|
|
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 });
|
|
}
|
|
|
|
// Sequential locks apply to students even when they follow a direct link.
|
|
const role = (session.user as any).role;
|
|
const isAdmin = role === "admin" || role === "superadmin";
|
|
if (!isAdmin) {
|
|
const access = await checkVideoAccess({ id: user.id, isAdmin: false }, id);
|
|
if (!access.ok) {
|
|
return NextResponse.json({ error: access.error }, { status: access.status });
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
}
|