Files
lms-sb/src/lib/actions/student-actions.ts
T
admins 7c8e72cd94 Add email notification on new lesson comment
Notify admins/curators (or configured notificationEmails) when a student
leaves a comment under a lesson. New setting notifyOnComment (default on) +
toggle in admin settings. Fires only for student comments, not admin/curator
replies; comment text is HTML-escaped in the email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:02:07 +05:00

231 lines
8.3 KiB
TypeScript

"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendHomeworkSubmittedEmail, sendCommentNotificationEmail } from "@/lib/email";
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
// ── 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) || existing?.status === "APPROVED") {
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, settings] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
getSettings(),
]);
if (lesson && asBool(settings.notifyOnHomework)) {
const configuredEmails = parseNotificationEmails(settings.notificationEmails);
const recipients = configuredEmails.length > 0
? configuredEmails.map((email) => ({ email, name: "" }))
: await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
await Promise.all(
recipients.map((r) =>
sendHomeworkSubmittedEmail(r.email, r.name, session.user.name, lesson.lesson.title, submissionId)
)
);
}
}
// Отметить урок пройденным по факту сдачи ДЗ (идемпотентно)
await prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
create: { userId: session.user.id, lessonId },
update: {},
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
revalidatePath(`/courses/${slug}`);
revalidatePath("/dashboard");
}
// ── Quiz ──────────────────────────────────────────────────────────────────────
export async function submitQuizAttempt(
quizId: string,
lessonId: string,
slug: string,
answers: Record<string, string>
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.quizAttempt.findFirst({
where: { quizId, userId: session.user.id },
});
if (existing) return;
await prisma.$transaction(async (tx) => {
await tx.quizAttempt.create({
data: {
userId: session.user.id,
quizId,
score: 0,
answers: answers as object,
},
});
await tx.lessonProgress.upsert({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
create: { userId: session.user.id, lessonId },
update: {},
});
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
revalidatePath(`/courses/${slug}`);
revalidatePath("/dashboard");
}
// ── Comments ──────────────────────────────────────────────────────────────────
export async function addComment(lessonId: string, slug: string, text: string, parentId?: 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: { title: true, module: { select: { course: { select: { id: true, slug: true } } } } },
});
if (!lesson) throw new Error("Lesson not found");
const isAdmin = session.user.role === "admin";
const isCurator = session.user.role === "curator";
if (!isAdmin && !isCurator) {
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");
}
if (parentId) {
if (!isAdmin && !isCurator) throw new Error("Forbidden");
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
}
await prisma.lessonComment.create({
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
});
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
if (!isAdmin && !isCurator) {
const settings = await getSettings();
if (asBool(settings.notifyOnComment)) {
const configured = parseNotificationEmails(settings.notificationEmails);
const recipients = configured.length > 0
? configured.map((email) => ({ email, name: "" }))
: await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
await Promise.all(
recipients.map((r) =>
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
)
);
}
}
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}`);
}