7c8e72cd94
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>
231 lines
8.3 KiB
TypeScript
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}`);
|
|
}
|