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:
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
interface Student {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
reorderLessons,
|
reorderLessons,
|
||||||
toggleLessonPublished,
|
toggleLessonPublished,
|
||||||
moveLessonToModule,
|
moveLessonToModule,
|
||||||
} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
|
} from "@/lib/actions/module-actions";
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import Link from "next/link";
|
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 {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 {
|
interface Course {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
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";
|
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||||
|
|
||||||
interface HWFile { name: string; url: string; size: number }
|
interface HWFile { name: string; url: string; size: number }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
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 = {
|
type Comment = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Check } from "lucide-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({
|
export function LessonCompleteButton({
|
||||||
lessonId,
|
lessonId,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user