Files
ACVE/app/(protected)/teacher/actions.ts
Marcelo 02afbd7cfb MVP
2026-03-20 04:54:08 +00:00

481 lines
15 KiB
TypeScript

"use server";
import { db } from "@/lib/prisma";
import { requireTeacher } from "@/lib/auth/requireTeacher";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { ContentStatus, Prisma, ProficiencyLevel } from "@prisma/client";
import {
buildLessonDescriptionMeta,
parseLessonDescriptionMeta,
type LessonActivityMeta,
type LessonContentType,
} from "@/lib/courses/lessonContent";
// --- VALIDATION SCHEMAS (Zod) ---
const createCourseSchema = z.object({
title: z.string().min(3, "El título debe tener al menos 3 caracteres"),
});
// --- ACTIONS ---
/**
* 1. LIST COURSES
* Used by: app/(protected)/teacher/page.tsx (Dashboard)
*/
export async function getTeacherCourses() {
const user = await requireTeacher();
if (!user) {
return { success: false, error: "No autorizado" };
}
try {
const courses = await db.course.findMany({
where: {
authorId: user.id,
},
orderBy: {
updatedAt: "desc",
},
select: {
id: true,
title: true,
slug: true,
price: true,
status: true,
level: true,
_count: {
select: {
modules: true,
enrollments: true,
}
}
}
});
return { success: true, data: courses };
} catch (error) {
console.error("Error fetching courses:", error);
return { success: false, error: "Error al cargar los cursos" };
}
}
/**
* 2. CREATE COURSE (Draft) */
export async function createCourse(formData: FormData) {
const user = await requireTeacher();
if (!user) {
return { success: false, error: "No autorizado" };
}
// 1. Extract & Validate
const rawTitle = formData.get("title");
const validated = createCourseSchema.safeParse({ title: rawTitle });
if (!validated.success) {
return { success: false, error: validated.error.flatten().fieldErrors.title?.[0] || "Error en el título" };
}
const title = validated.data.title;
// 2. Generate basic slug
const slug = `${title.toLowerCase().replace(/ /g, "-").replace(/[^\w-]/g, "")}-${Math.floor(Math.random() * 10000)}`;
try {
// 3. Create DB Record
const course = await db.course.create({
data: {
title: title,
slug: slug,
authorId: user.id,
description: "Descripción pendiente...",
status: "DRAFT",
},
});
// 4. Revalidate & Return
revalidatePath("/teacher/courses");
return { success: true, data: course };
} catch (error) {
console.error("Create course error:", error);
return { success: false, error: "No se pudo crear el curso." };
}
}
/**
* 3. DELETE COURSE */
export async function deleteCourse(courseId: string) {
const user = await requireTeacher();
if (!user) return { success: false, error: "No autorizado" };
try {
const course = await db.course.findUnique({
where: { id: courseId },
select: { authorId: true }
});
if (!course || course.authorId !== user.id) {
return { success: false, error: "No tienes permiso para eliminar este curso." };
}
await db.course.delete({
where: { id: courseId },
});
revalidatePath("/teacher/courses");
return { success: true, data: "Curso eliminado" };
} catch {
return { success: false, error: "Error al eliminar el curso" };
}
}
function parseLearningOutcomes(raw: FormDataEntryValue | null): string[] {
if (raw == null || typeof raw !== "string") return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === "string");
} catch {
return [];
}
}
export async function updateCourse(courseId: string, courseSlug: string, formData: FormData) {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
try {
const title = formData.get("title") as string;
const summary = formData.get("summary") as string;
// Direct cast is okay if you trust the select/input values
const level = formData.get("level") as ProficiencyLevel;
const status = formData.get("status") as ContentStatus;
const price = parseFloat(formData.get("price") as string) || 0;
const learningOutcomes = parseLearningOutcomes(formData.get("learningOutcomes"));
await db.course.update({
where: { id: courseId, authorId: user.id },
data: {
title,
description: summary,
level,
status,
price,
learningOutcomes:
learningOutcomes.length > 0 ? (learningOutcomes as Prisma.InputJsonValue) : Prisma.JsonNull,
} as Prisma.CourseUpdateInput,
});
// Revalidate teacher list, editor page + layout (so router.refresh() gets fresh data), and public catalog
revalidatePath("/teacher/courses");
revalidatePath(`/teacher/courses/${courseSlug}/edit`, "page");
revalidatePath(`/teacher/courses/${courseSlug}/edit`, "layout");
revalidatePath("/courses");
return { success: true };
} catch {
return { success: false, error: "Failed to update" };
}
}
export async function updateLesson(lessonId: string, data: {
title?: string;
description?: string;
videoUrl?: string;
youtubeUrl?: string | null;
materialUrl?: string;
contentType?: LessonContentType;
lectureContent?: string;
activity?: LessonActivityMeta | null;
estimatedDurationMinutes?: number;
isPreview?: boolean; // maps to DB field isFreePreview
isPublished?: boolean; // optional: for later
}) {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
try {
const updateData: Prisma.LessonUpdateInput = { updatedAt: new Date() };
if (data.title !== undefined) updateData.title = data.title;
if (data.videoUrl !== undefined) updateData.videoUrl = data.videoUrl;
if (data.youtubeUrl !== undefined) updateData.youtubeUrl = data.youtubeUrl?.trim() || null;
if (data.estimatedDurationMinutes !== undefined) {
const minutes = Math.max(0, Math.round(data.estimatedDurationMinutes));
updateData.estimatedDuration = minutes * 60;
}
if (data.isPreview !== undefined) updateData.isFreePreview = data.isPreview;
const shouldUpdateMeta =
data.description !== undefined ||
data.contentType !== undefined ||
data.materialUrl !== undefined ||
data.lectureContent !== undefined ||
data.activity !== undefined;
if (shouldUpdateMeta) {
const lesson = await db.lesson.findUnique({
where: { id: lessonId },
select: { description: true },
});
const existingMeta = parseLessonDescriptionMeta(lesson?.description);
updateData.description = buildLessonDescriptionMeta({
text: data.description ?? existingMeta.text,
contentType: data.contentType ?? existingMeta.contentType,
materialUrl: data.materialUrl ?? existingMeta.materialUrl,
lectureContent: data.lectureContent ?? existingMeta.lectureContent,
activity: data.activity ?? existingMeta.activity,
}) as Prisma.InputJsonValue;
}
await db.lesson.update({
where: { id: lessonId },
data: updateData,
});
// 2. Revalidate to show changes immediately
revalidatePath("/teacher/courses");
return { success: true };
} catch (error) {
console.error("Update Lesson Error:", error);
return { success: false, error: "Failed to update lesson" };
}
}
export async function createModule(courseId: string) {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
try {
// Find the highest orderIndex so we can put the new one at the end
const lastModule = await db.module.findFirst({
where: { courseId },
orderBy: { orderIndex: "desc" },
});
const newOrder = lastModule ? lastModule.orderIndex + 1 : 0;
await db.module.create({
data: {
courseId,
title: "Nuevo Módulo", // Default title
orderIndex: newOrder,
},
});
revalidatePath("/teacher/courses");
return { success: true };
} catch (error) {
console.error("Create Module Error:", error);
return { success: false, error: "Failed to create module" };
}
}
/**
* UPDATE MODULE TITLE
*/
export async function updateModuleTitle(moduleId: string, title: string) {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
const trimmed = title?.trim() || "";
if (!trimmed) return { success: false, error: "El título no puede estar vacío" };
try {
const moduleRow = await db.module.findFirst({
where: { id: moduleId, course: { authorId: user.id } },
select: { id: true, course: { select: { slug: true } } },
});
if (!moduleRow) return { success: false, error: "Módulo no encontrado" };
await db.module.update({
where: { id: moduleId },
data: { title: trimmed },
});
revalidatePath("/teacher/courses");
revalidatePath(`/teacher/courses/${moduleRow.course.slug}/edit`, "page");
revalidatePath(`/teacher/courses/${moduleRow.course.slug}/edit`, "layout");
return { success: true };
} catch (error) {
console.error("Update Module Title Error:", error);
return { success: false, error: "No se pudo actualizar el título" };
}
}
// 2. CREATE LESSON
export async function createLesson(moduleId: string, contentType: LessonContentType = "VIDEO") {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
try {
// Find the highest orderIndex for lessons in this module
const lastLesson = await db.lesson.findFirst({
where: { moduleId },
orderBy: { orderIndex: "desc" },
});
const newOrder = lastLesson ? lastLesson.orderIndex + 1 : 0;
// Create the lesson with default values
const lesson = await db.lesson.create({
data: {
moduleId,
title: "Nueva Lección",
description: buildLessonDescriptionMeta({
text: "",
contentType,
lectureContent: "",
}) as Prisma.InputJsonValue,
orderIndex: newOrder,
estimatedDuration: 0,
version: 1,
// The type field is required in your schema (based on previous context)
// If you have a 'type' enum, set a default like 'VIDEO' or 'TEXT'
// type: "VIDEO",
},
});
revalidatePath(`/teacher/courses/${moduleId}/edit`, "page");
return { success: true, lessonId: lesson.id };
} catch (error) {
console.error("Create Lesson Error:", error);
return { success: false, error: "Failed to create lesson" };
}
}
/**
* DELETE MODULE
* Also deletes all lessons inside it due to Prisma cascade or manual cleanup
*/
export async function deleteModule(moduleId: string) {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
try {
await db.module.delete({
where: {
id: moduleId,
course: { authorId: user.id } // Security: ownership check
},
});
revalidatePath("/teacher/courses");
return { success: true };
} catch {
return { success: false, error: "No se pudo eliminar el módulo" };
}
}
/**
* DELETE LESSON
*/
export async function deleteLesson(lessonId: string) {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
try {
await db.lesson.delete({
where: {
id: lessonId,
module: { course: { authorId: user.id } } // Deep ownership check
},
});
revalidatePath("/teacher/courses");
return { success: true };
} catch {
return { success: false, error: "No se pudo eliminar la lección" };
}
}
export async function reorderModules(moduleId: string, direction: 'up' | 'down') {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
const currentModule = await db.module.findUnique({
where: { id: moduleId },
include: { course: true }
});
if (!currentModule || currentModule.course.authorId !== user.id) {
return { success: false, error: "Module not found" };
}
const siblingModule = await db.module.findFirst({
where: {
courseId: currentModule.courseId,
orderIndex: direction === 'up'
? { lt: currentModule.orderIndex }
: { gt: currentModule.orderIndex }
},
orderBy: { orderIndex: direction === 'up' ? 'desc' : 'asc' }
});
if (!siblingModule) return { success: true }; // Already at the top/bottom
// Swap orderIndex values
await db.$transaction([
db.module.update({
where: { id: currentModule.id },
data: { orderIndex: siblingModule.orderIndex }
}),
db.module.update({
where: { id: siblingModule.id },
data: { orderIndex: currentModule.orderIndex }
})
]);
revalidatePath(`/teacher/courses/${currentModule.course.slug}/edit`, "page");
return { success: true };
}
/**
* Reorder lessons within the same module (swap orderIndex with sibling)
*/
export async function reorderLessons(lessonId: string, direction: "up" | "down") {
const user = await requireTeacher();
if (!user) return { success: false, error: "Unauthorized" };
const currentLesson = await db.lesson.findUnique({
where: { id: lessonId },
include: { module: { include: { course: true } } },
});
if (!currentLesson || currentLesson.module.course.authorId !== user.id) {
return { success: false, error: "Lesson not found" };
}
const siblingLesson = await db.lesson.findFirst({
where: {
moduleId: currentLesson.moduleId,
orderIndex:
direction === "up"
? { lt: currentLesson.orderIndex }
: { gt: currentLesson.orderIndex },
},
orderBy: { orderIndex: direction === "up" ? "desc" : "asc" },
});
if (!siblingLesson) return { success: true }; // Already at top/bottom
await db.$transaction([
db.lesson.update({
where: { id: currentLesson.id },
data: { orderIndex: siblingLesson.orderIndex },
}),
db.lesson.update({
where: { id: siblingLesson.id },
data: { orderIndex: currentLesson.orderIndex },
}),
]);
revalidatePath(`/teacher/courses/${currentLesson.module.course.slug}/edit`, "page");
return { success: true };
}