481 lines
15 KiB
TypeScript
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 };
|
|
}
|