Fix all Server Actions imported from dynamic route paths

All admin and student Client Components were importing Server Actions
from paths with dynamic segments ([courseId], [moduleId], [lessonId], [slug]).
This caused "Cannot access toStringTag on the server" RSC crash.

Consolidated all Server Actions into static files under src/lib/actions/:
- course-actions.ts  (modules + enrollment)
- module-actions.ts  (lessons + reorder + move)
- user-actions.ts    (bulk grant / revoke)
- student-actions.ts (progress + homework + comments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 14:05:26 +05:00
parent 9eb21e3ab4
commit 5dfa79d357
11 changed files with 410 additions and 7 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
import { grantAccess, revokeAccess } from "@/lib/actions/course-actions";
interface Student {
id: string;
+1 -1
View File
@@ -25,7 +25,7 @@ import {
reorderLessons,
toggleLessonPublished,
moveLessonToModule,
} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
} from "@/lib/actions/module-actions";
interface Lesson {
id: string;
+1 -1
View File
@@ -17,7 +17,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Link from "next/link";
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
import { createModule, deleteModule, updateModule, reorderModules } from "@/lib/actions/course-actions";
interface Module {
id: string;
@@ -3,7 +3,7 @@
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { bulkGrantAccess, revokeUserAccess } from "@/app/admin/users/[userId]/actions";
import { bulkGrantAccess, revokeUserAccess } from "@/lib/actions/user-actions";
interface Course {
id: string;
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
import { submitHomework } from "@/lib/actions/student-actions";
import { AudioRecorder } from "@/components/curator/audio-recorder";
interface HWFile { name: string; url: string; size: number }
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import { addComment, deleteComment } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions";
import { addComment, deleteComment } from "@/lib/actions/student-actions";
type Comment = {
id: string;
@@ -2,7 +2,7 @@
import { useTransition } from "react";
import { Check } from "lucide-react";
import { toggleLessonProgress } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/actions";
import { toggleLessonProgress } from "@/lib/actions/student-actions";
export function LessonCompleteButton({
lessonId,
+103
View File
@@ -0,0 +1,103 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { sendCourseAccessEmail } from "@/lib/email";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
// ── Modules ──────────────────────────────────────────────────────────────────
export async function createModule(courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const count = await prisma.module.count({ where: { courseId } });
const mod = await prisma.module.create({ data: { courseId, title, order: count } });
revalidatePath(`/admin/courses/${courseId}`);
redirect(`/admin/courses/${courseId}/modules/${mod.id}`);
}
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const description = (formData.get("description") as string | null)?.trim() || null;
await prisma.module.update({ where: { id: moduleId }, data: { title, description } });
revalidatePath(`/admin/courses/${courseId}`);
}
export async function deleteModule(moduleId: string, courseId: string) {
await requireAdmin();
await prisma.module.delete({ where: { id: moduleId } });
revalidatePath(`/admin/courses/${courseId}`);
}
export async function reorderModules(courseId: string, orderedIds: string[]) {
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
prisma.module.update({ where: { id }, data: { order: index } })
)
);
revalidatePath(`/admin/courses/${courseId}`);
}
// ── Enrollment ───────────────────────────────────────────────────────────────
export async function grantAccess(
courseId: string,
userId: string,
expiresAt?: string | null,
note?: string
) {
const session = await requireAdmin();
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
create: { userId, courseId, expiresAt: expiresAt ? new Date(expiresAt) : null },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "manual",
grantedById: session.user.id,
note: note || null,
},
});
const [user, course] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
]);
if (user && course) {
await sendCourseAccessEmail(user.email, user.name, course.title);
}
revalidatePath(`/admin/courses/${courseId}`);
}
export async function revokeAccess(courseId: string, userId: string, note?: string) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "revoked",
method: "manual",
grantedById: session.user.id,
note: note || null,
},
});
revalidatePath(`/admin/courses/${courseId}`);
}
+89
View File
@@ -0,0 +1,89 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const kinescopeId = (formData.get("kinescopeId") as string | null)?.trim() || null;
const count = await prisma.lesson.count({ where: { moduleId } });
const lesson = await prisma.lesson.create({
data: { moduleId, title, kinescopeId, order: count },
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`);
}
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
await prisma.lesson.update({ where: { id: lessonId }, data: { title } });
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function deleteLesson(lessonId: string, courseId: string, moduleId: string) {
await requireAdmin();
await prisma.lesson.delete({ where: { id: lessonId } });
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function reorderLessons(moduleId: string, courseId: string, orderedIds: string[]) {
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
prisma.lesson.update({ where: { id }, data: { order: index } })
)
);
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function toggleLessonPublished(
lessonId: string,
courseId: string,
moduleId: string,
currentValue: boolean
) {
await requireAdmin();
await prisma.lesson.update({
where: { id: lessonId },
data: { published: !currentValue },
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
revalidatePath(`/admin/courses/${courseId}`);
}
export async function moveLessonToModule(
lessonId: string,
targetModuleId: string,
courseId: string,
sourceModuleId: string
) {
await requireAdmin();
const target = await prisma.module.findFirst({
where: { id: targetModuleId, courseId },
});
if (!target) throw new Error("Module not found");
const maxOrder = await prisma.lesson.aggregate({
where: { moduleId: targetModuleId },
_max: { order: true },
});
await prisma.lesson.update({
where: { id: lessonId },
data: { moduleId: targetModuleId, order: (maxOrder._max.order ?? -1) + 1 },
});
revalidatePath(`/admin/courses/${courseId}/modules/${sourceModuleId}`);
revalidatePath(`/admin/courses/${courseId}/modules/${targetModuleId}`);
revalidatePath(`/admin/courses/${courseId}`);
}
+152
View File
@@ -0,0 +1,152 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendHomeworkSubmittedEmail } from "@/lib/email";
// ── Lesson Progress ───────────────────────────────────────────────────────────
export async function toggleLessonProgress(lessonId: string, slug: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
});
if (existing) {
await prisma.lessonProgress.delete({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
});
} else {
await prisma.lessonProgress.create({
data: { userId: session.user.id, lessonId },
});
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
revalidatePath(`/courses/${slug}`);
revalidatePath("/dashboard");
}
// ── Homework ──────────────────────────────────────────────────────────────────
interface HomeworkFile {
name: string;
url: string;
size: number;
}
export async function submitHomework(
homeworkId: string,
slug: string,
lessonId: string,
text: string,
files: HomeworkFile[],
audioUrl?: string | null
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.homeworkSubmission.findFirst({
where: { homeworkId, userId: session.user.id },
include: { feedbacks: true },
});
if (existing?.feedbacks && existing.feedbacks.length > 0) {
throw new Error("Работа уже проверена");
}
let submissionId: string;
if (existing) {
const updated = await prisma.homeworkSubmission.update({
where: { id: existing.id },
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
});
submissionId = updated.id;
} else {
const created = await prisma.homeworkSubmission.create({
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
});
submissionId = created.id;
const [lesson, admins] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]);
if (lesson) {
await Promise.all(
admins.map((a) =>
sendHomeworkSubmittedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId)
)
);
}
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
// ── Comments ──────────────────────────────────────────────────────────────────
export async function addComment(lessonId: string, slug: string, text: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const trimmed = text.trim();
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
select: { module: { select: { course: { select: { id: true } } } } },
});
if (!lesson) throw new Error("Lesson not found");
const isAdmin = session.user.role === "admin";
if (!isAdmin) {
const enrollment = await prisma.courseEnrollment.findUnique({
where: {
userId_courseId: {
userId: session.user.id,
courseId: lesson.module.course.id,
},
},
});
if (!enrollment) throw new Error("Not enrolled");
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
}
await prisma.lessonComment.create({
data: { lessonId, userId: session.user.id, text: trimmed },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
export async function deleteComment(commentId: string, lessonId: string, slug: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Not found");
const canDelete =
comment.userId === session.user.id ||
session.user.role === "curator" ||
session.user.role === "admin";
if (!canDelete) throw new Error("Forbidden");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
export async function bulkGrantAccess(
userId: string,
courseIds: string[],
expiresAt?: string | null
) {
const session = await requireAdmin();
const expiry = expiresAt ? new Date(expiresAt) : null;
await Promise.all(
courseIds.map(async (courseId) => {
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt: expiry },
create: { userId, courseId, expiresAt: expiry },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "bulk",
grantedById: session.user.id,
},
});
})
);
revalidatePath(`/admin/users/${userId}`);
}
export async function revokeUserAccess(userId: string, courseId: string) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "revoked",
method: "manual",
grantedById: session.user.id,
},
});
revalidatePath(`/admin/users/${userId}`);
}