diff --git a/src/components/admin/enrollment-manager.tsx b/src/components/admin/enrollment-manager.tsx index dc82aad..49d3417 100644 --- a/src/components/admin/enrollment-manager.tsx +++ b/src/components/admin/enrollment-manager.tsx @@ -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; diff --git a/src/components/admin/sortable-lessons.tsx b/src/components/admin/sortable-lessons.tsx index b72e504..4e14d01 100644 --- a/src/components/admin/sortable-lessons.tsx +++ b/src/components/admin/sortable-lessons.tsx @@ -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; diff --git a/src/components/admin/sortable-modules.tsx b/src/components/admin/sortable-modules.tsx index 07d23d4..c64fadb 100644 --- a/src/components/admin/sortable-modules.tsx +++ b/src/components/admin/sortable-modules.tsx @@ -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; diff --git a/src/components/admin/user-enrollment-manager.tsx b/src/components/admin/user-enrollment-manager.tsx index aa73085..ed5edd0 100644 --- a/src/components/admin/user-enrollment-manager.tsx +++ b/src/components/admin/user-enrollment-manager.tsx @@ -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; diff --git a/src/components/student/homework-section.tsx b/src/components/student/homework-section.tsx index c750d77..7eecf7c 100644 --- a/src/components/student/homework-section.tsx +++ b/src/components/student/homework-section.tsx @@ -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 } diff --git a/src/components/student/lesson-comments.tsx b/src/components/student/lesson-comments.tsx index 742749f..eb1229e 100644 --- a/src/components/student/lesson-comments.tsx +++ b/src/components/student/lesson-comments.tsx @@ -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; diff --git a/src/components/student/lesson-complete-button.tsx b/src/components/student/lesson-complete-button.tsx index 8bb48fe..5c820c0 100644 --- a/src/components/student/lesson-complete-button.tsx +++ b/src/components/student/lesson-complete-button.tsx @@ -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, diff --git a/src/lib/actions/course-actions.ts b/src/lib/actions/course-actions.ts new file mode 100644 index 0000000..34ec2fd --- /dev/null +++ b/src/lib/actions/course-actions.ts @@ -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}`); +} diff --git a/src/lib/actions/module-actions.ts b/src/lib/actions/module-actions.ts new file mode 100644 index 0000000..6ef6e32 --- /dev/null +++ b/src/lib/actions/module-actions.ts @@ -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}`); +} diff --git a/src/lib/actions/student-actions.ts b/src/lib/actions/student-actions.ts new file mode 100644 index 0000000..0770815 --- /dev/null +++ b/src/lib/actions/student-actions.ts @@ -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}`); +} diff --git a/src/lib/actions/user-actions.ts b/src/lib/actions/user-actions.ts new file mode 100644 index 0000000..b96a0fa --- /dev/null +++ b/src/lib/actions/user-actions.ts @@ -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}`); +}