Pending course, rest ready for launch
This commit is contained in:
@@ -1,53 +1,44 @@
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { db } from "@/lib/prisma";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { supabaseServer } from "@/lib/supabase/server";
|
||||
|
||||
export async function requireTeacher() {
|
||||
const supabase = await supabaseServer();
|
||||
if (!supabase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
|
||||
// 1. Get Supabase Session
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll() },
|
||||
setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch (error) {
|
||||
// This is expected in Server Components, but let's log it just in case
|
||||
logger.warn("Failed to set cookies in Server Component context (expected behavior)", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return null; // Let the caller handle the redirect
|
||||
}
|
||||
|
||||
// 2. Check Role in Database
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { id: user.id },
|
||||
}
|
||||
);
|
||||
console.log("AUTH_USER_ID:", user.id);
|
||||
console.log("DB_PROFILE:", profile);
|
||||
}).catch((error) => {
|
||||
logger.error("Failed to load profile in requireTeacher", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
if (!profile || (profile.role !== UserRole.TEACHER && profile.role !== UserRole.SUPER_ADMIN)) {
|
||||
// You can decide to return null or throw an error here
|
||||
if (
|
||||
!profile ||
|
||||
(profile.role !== UserRole.TEACHER && profile.role !== UserRole.SUPER_ADMIN)
|
||||
) {
|
||||
logger.info("User authenticated but not authorized as teacher", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: profile?.role ?? "none",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { db } from "@/lib/prisma";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
export async function requireUser() {
|
||||
const cookieStore = await cookies();
|
||||
@@ -13,6 +14,20 @@ export async function requireUser() {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return null;
|
||||
|
||||
const profile = await db.profile.findUnique({ where: { id: user.id } });
|
||||
return profile;
|
||||
}
|
||||
const profile = await db.profile.findUnique({ where: { id: user.id } }).catch((error) => {
|
||||
console.error("Failed to load profile for authenticated user.", error);
|
||||
return null;
|
||||
});
|
||||
if (profile) return profile;
|
||||
|
||||
// Keep authenticated flows working even if profile lookup fails.
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email ?? "unknown@acve.local",
|
||||
fullName: (user.user_metadata?.full_name as string | undefined) ?? null,
|
||||
avatarUrl: null,
|
||||
role: UserRole.LEARNER,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
const parseTeacherEmails = (source: string | undefined): string[] =>
|
||||
(source ?? "")
|
||||
.split(",")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
export const readTeacherEmailsServer = (): string[] => parseTeacherEmails(process.env.TEACHER_EMAILS);
|
||||
|
||||
export const readTeacherEmailsBrowser = (): string[] =>
|
||||
parseTeacherEmails(process.env.NEXT_PUBLIC_TEACHER_EMAILS);
|
||||
|
||||
export const isTeacherEmailAllowed = (email: string | null, allowed: string[]): boolean => {
|
||||
if (!email) return false;
|
||||
if (allowed.length === 0) return false;
|
||||
return allowed.includes(email.toLowerCase());
|
||||
};
|
||||
197
lib/certificates.ts
Normal file
197
lib/certificates.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { db } from "@/lib/prisma";
|
||||
import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
|
||||
|
||||
type CertificatePrismaClient = {
|
||||
certificate: {
|
||||
create: (args: object) => Promise<{ id: string; certificateNumber?: string }>;
|
||||
findFirst: (args: object) => Promise<{ id: string; certificateNumber?: string } | null>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CertificateIssueResult = {
|
||||
certificateId: string | null;
|
||||
certificateNumber: string | null;
|
||||
newlyIssued: boolean;
|
||||
};
|
||||
|
||||
function getText(value: unknown): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.en === "string") return record.en;
|
||||
if (typeof record.es === "string") return record.es;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapePdfText(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
function buildMinimalPdf(lines: string[]): Uint8Array {
|
||||
const contentLines = lines
|
||||
.map((line, index) => `BT /F1 14 Tf 72 ${730 - index * 24} Td (${escapePdfText(line)}) Tj ET`)
|
||||
.join("\n");
|
||||
const stream = `${contentLines}\n`;
|
||||
|
||||
const objects = [
|
||||
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj",
|
||||
"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj",
|
||||
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj",
|
||||
"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj",
|
||||
`5 0 obj << /Length ${stream.length} >> stream\n${stream}endstream endobj`,
|
||||
];
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [0];
|
||||
|
||||
for (const object of objects) {
|
||||
offsets.push(pdf.length);
|
||||
pdf += `${object}\n`;
|
||||
}
|
||||
|
||||
const xrefStart = pdf.length;
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += "0000000000 65535 f \n";
|
||||
for (let i = 1; i <= objects.length; i += 1) {
|
||||
pdf += `${offsets[i].toString().padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
|
||||
return new TextEncoder().encode(pdf);
|
||||
}
|
||||
|
||||
export async function issueCertificateIfEligible(userId: string, courseId: string): Promise<CertificateIssueResult> {
|
||||
const prismaAny = db as unknown as CertificatePrismaClient;
|
||||
try {
|
||||
const existing = await prismaAny.certificate.findFirst({
|
||||
where: { userId, courseId },
|
||||
select: { id: true, certificateNumber: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
certificateId: existing.id,
|
||||
certificateNumber: existing.certificateNumber ?? null,
|
||||
newlyIssued: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [course, completedCount, lessons] = await Promise.all([
|
||||
db.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, title: true, slug: true },
|
||||
}),
|
||||
db.userProgress.count({
|
||||
where: {
|
||||
userId,
|
||||
isCompleted: true,
|
||||
lesson: {
|
||||
module: {
|
||||
courseId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.lesson.findMany({
|
||||
where: {
|
||||
module: {
|
||||
courseId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
description: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalCount = lessons.length;
|
||||
if (!course || totalCount === 0 || completedCount < totalCount) {
|
||||
return { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||
}
|
||||
|
||||
const finalExamLessonIds = lessons
|
||||
.filter((lesson) => isFinalExam(parseLessonDescriptionMeta(lesson.description).contentType))
|
||||
.map((lesson) => lesson.id);
|
||||
|
||||
if (finalExamLessonIds.length > 0) {
|
||||
const completedFinalExams = await db.userProgress.count({
|
||||
where: {
|
||||
userId,
|
||||
isCompleted: true,
|
||||
lessonId: {
|
||||
in: finalExamLessonIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (completedFinalExams < finalExamLessonIds.length) {
|
||||
return { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||
}
|
||||
}
|
||||
|
||||
const membership = await db.membership.findFirst({
|
||||
where: { userId, isActive: true },
|
||||
select: { companyId: true },
|
||||
});
|
||||
|
||||
const certificateNumber = `ACVE-${new Date().getFullYear()}-${Math.floor(
|
||||
100000 + Math.random() * 900000,
|
||||
)}`;
|
||||
|
||||
const certificate = await prismaAny.certificate.create({
|
||||
data: {
|
||||
userId,
|
||||
courseId,
|
||||
companyId: membership?.companyId ?? null,
|
||||
certificateNumber,
|
||||
pdfVersion: 1,
|
||||
metadataSnapshot: {
|
||||
courseId: course.id,
|
||||
courseSlug: course.slug,
|
||||
courseTitle: getText(course.title) || "Untitled course",
|
||||
certificateNumber,
|
||||
completionPercent: 100,
|
||||
issuedAt: new Date().toISOString(),
|
||||
brandingVersion: "ACVE-2026-01",
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
certificateId: certificate.id,
|
||||
certificateNumber: certificate.certificateNumber ?? certificateNumber,
|
||||
newlyIssued: true,
|
||||
};
|
||||
} catch {
|
||||
return { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCertificatePdf(input: {
|
||||
certificateNumber: string;
|
||||
learnerName: string;
|
||||
learnerEmail: string;
|
||||
courseTitle: string;
|
||||
issuedAt: Date;
|
||||
}): Uint8Array {
|
||||
const date = new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(input.issuedAt);
|
||||
|
||||
return buildMinimalPdf([
|
||||
"ACVE - Certificate of Completion",
|
||||
"",
|
||||
`Certificate No: ${input.certificateNumber}`,
|
||||
"",
|
||||
"This certifies that",
|
||||
input.learnerName,
|
||||
`(${input.learnerEmail})`,
|
||||
"",
|
||||
"has successfully completed the course",
|
||||
input.courseTitle,
|
||||
"",
|
||||
`Issued on ${date}`,
|
||||
]);
|
||||
}
|
||||
108
lib/courses/lessonContent.ts
Normal file
108
lib/courses/lessonContent.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export const lessonContentTypes = ["VIDEO", "LECTURE", "ACTIVITY", "QUIZ", "FINAL_EXAM"] as const;
|
||||
|
||||
export type LessonContentType = (typeof lessonContentTypes)[number];
|
||||
|
||||
type LessonDescriptionMeta = {
|
||||
text: string;
|
||||
contentType: LessonContentType;
|
||||
materialUrl: string | null;
|
||||
};
|
||||
|
||||
const lessonTypeAliases: Record<string, LessonContentType> = {
|
||||
VIDEO: "VIDEO",
|
||||
LECTURE: "LECTURE",
|
||||
LECTURA: "LECTURE",
|
||||
READING: "LECTURE",
|
||||
ACTIVITY: "ACTIVITY",
|
||||
ACTIVIDAD: "ACTIVITY",
|
||||
QUIZ: "QUIZ",
|
||||
EVALUACION: "QUIZ",
|
||||
EVALUACIÓN: "QUIZ",
|
||||
FINAL_EXAM: "FINAL_EXAM",
|
||||
EXAMEN_FINAL: "FINAL_EXAM",
|
||||
EXAMENFINAL: "FINAL_EXAM",
|
||||
EVALUACION_FINAL: "FINAL_EXAM",
|
||||
EVALUACIÓN_FINAL: "FINAL_EXAM",
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeType(value: string): LessonContentType {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return lessonTypeAliases[normalized] ?? "VIDEO";
|
||||
}
|
||||
|
||||
function getDescriptionText(input: unknown): string {
|
||||
if (typeof input === "string") return input.trim();
|
||||
if (isRecord(input)) {
|
||||
const direct = asString(input.text);
|
||||
if (direct) return direct;
|
||||
const es = asString(input.es);
|
||||
if (es) return es;
|
||||
const en = asString(input.en);
|
||||
if (en) return en;
|
||||
const summary = asString(input.summary);
|
||||
if (summary) return summary;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
|
||||
if (!isRecord(description)) {
|
||||
return {
|
||||
text: getDescriptionText(description),
|
||||
contentType: "VIDEO",
|
||||
materialUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const contentTypeRaw = asString(description.contentType) || asString(description.kind) || asString(description.type);
|
||||
const materialUrl =
|
||||
asString(description.materialUrl) ||
|
||||
asString(description.resourceUrl) ||
|
||||
asString(description.pdfUrl) ||
|
||||
asString(description.attachmentUrl) ||
|
||||
"";
|
||||
|
||||
return {
|
||||
text: getDescriptionText(description),
|
||||
contentType: normalizeType(contentTypeRaw || "VIDEO"),
|
||||
materialUrl: materialUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLessonDescriptionMeta(input: {
|
||||
text: string;
|
||||
contentType: LessonContentType;
|
||||
materialUrl?: string | null;
|
||||
}): Record<string, string> {
|
||||
const payload: Record<string, string> = {
|
||||
contentType: input.contentType,
|
||||
};
|
||||
|
||||
const text = input.text.trim();
|
||||
if (text) payload.es = text;
|
||||
|
||||
const materialUrl = (input.materialUrl ?? "").trim();
|
||||
if (materialUrl) payload.materialUrl = materialUrl;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function getLessonContentTypeLabel(contentType: LessonContentType): string {
|
||||
if (contentType === "LECTURE") return "Lectura";
|
||||
if (contentType === "ACTIVITY") return "Actividad";
|
||||
if (contentType === "QUIZ") return "Quiz";
|
||||
if (contentType === "FINAL_EXAM") return "Evaluación final";
|
||||
return "Video";
|
||||
}
|
||||
|
||||
export function isFinalExam(contentType: LessonContentType): boolean {
|
||||
return contentType === "FINAL_EXAM";
|
||||
}
|
||||
254
lib/courses/presentation.ts
Normal file
254
lib/courses/presentation.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { ContentStatus, ProficiencyLevel } from "@prisma/client";
|
||||
|
||||
export type AcademicStageId = "base" | "consolidacion" | "especializacion";
|
||||
export type AvailabilityState = "published" | "upcoming" | "draft";
|
||||
|
||||
export type AcademicStageConfig = {
|
||||
id: AcademicStageId;
|
||||
tabLabel: string;
|
||||
sectionTitle: string;
|
||||
sectionDescription: string;
|
||||
levelLabel: string;
|
||||
};
|
||||
|
||||
export const ACADEMIC_STAGE_ORDER: AcademicStageId[] = ["base", "consolidacion", "especializacion"];
|
||||
|
||||
export const ACADEMIC_STAGE_CONFIG: Record<AcademicStageId, AcademicStageConfig> = {
|
||||
base: {
|
||||
id: "base",
|
||||
tabLabel: "Base",
|
||||
sectionTitle: "Programas Base",
|
||||
sectionDescription:
|
||||
"Trayectos introductorios para fortalecer fundamentos del inglés jurídico y lenguaje técnico aplicado.",
|
||||
levelLabel: "Base",
|
||||
},
|
||||
consolidacion: {
|
||||
id: "consolidacion",
|
||||
tabLabel: "Consolidación",
|
||||
sectionTitle: "Programas de Consolidación",
|
||||
sectionDescription:
|
||||
"Programas para consolidar precisión terminológica, comprensión de textos jurídicos y comunicación profesional.",
|
||||
levelLabel: "Consolidación",
|
||||
},
|
||||
especializacion: {
|
||||
id: "especializacion",
|
||||
tabLabel: "Especialización",
|
||||
sectionTitle: "Programas de Especialización",
|
||||
sectionDescription:
|
||||
"Itinerarios avanzados orientados a práctica profesional internacional, redacción especializada y análisis complejo.",
|
||||
levelLabel: "Especialización",
|
||||
},
|
||||
};
|
||||
|
||||
const STAGE_KEYWORDS: Record<AcademicStageId, string[]> = {
|
||||
base: ["base", "fundamentos", "fundamentals", "intro", "introduccion", "beginner", "inicial"],
|
||||
consolidacion: ["consolidacion", "consolidación", "consolidation", "intermedio", "intermediate"],
|
||||
especializacion: ["especializacion", "especialización", "specialization", "avanzado", "advanced", "expert", "experto"],
|
||||
};
|
||||
|
||||
export const PUBLIC_UPCOMING_TAGS = ["upcoming", "coming-soon", "proximamente", "próximamente", "public-upcoming"];
|
||||
|
||||
const IMAGE_KEYS = ["coverImageUrl", "coverImage", "thumbnailUrl", "thumbnail", "imageUrl", "image", "cover"];
|
||||
const SHORT_KEYS = ["shortEs", "shortEn", "short", "summary", "excerpt", "resumen"];
|
||||
const LONG_KEYS = ["longEs", "longEn", "long", "description", "descripcion"];
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function fromRecord(record: UnknownRecord, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = asString(record[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getLocalizedText(value: unknown): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (isRecord(value)) {
|
||||
const preferred = fromRecord(value, ["es", "en"]);
|
||||
if (preferred) return preferred;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getCourseDescriptions(description: unknown): { short: string; long: string } {
|
||||
if (!description) {
|
||||
return {
|
||||
short: "Programa en actualización académica.",
|
||||
long: "Programa en actualización académica.",
|
||||
};
|
||||
}
|
||||
|
||||
const localized = getLocalizedText(description);
|
||||
|
||||
if (!isRecord(description)) {
|
||||
const normalized = localized || "Programa en actualización académica.";
|
||||
return { short: truncateText(normalized, 170), long: normalized };
|
||||
}
|
||||
|
||||
const shortCandidate = fromRecord(description, SHORT_KEYS);
|
||||
const longCandidate = fromRecord(description, LONG_KEYS) || localized;
|
||||
const longText = longCandidate || "Programa en actualización académica.";
|
||||
const shortText = shortCandidate || truncateText(longText, 170);
|
||||
|
||||
return {
|
||||
short: shortText,
|
||||
long: longText,
|
||||
};
|
||||
}
|
||||
|
||||
function toTags(input: string[] | null | undefined): string[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
function getImageFromTags(tags: string[] | null | undefined): string {
|
||||
for (const rawTag of toTags(tags)) {
|
||||
const tag = rawTag.trim();
|
||||
if (!tag) continue;
|
||||
const lower = tag.toLowerCase();
|
||||
if (lower.startsWith("cover:") || lower.startsWith("cover-url:") || lower.startsWith("thumbnail:")) {
|
||||
const value = tag.slice(tag.indexOf(":") + 1).trim();
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getYouTubeThumbnail(url: string): string {
|
||||
if (!url) return "";
|
||||
const normalized = url.trim();
|
||||
if (!normalized) return "";
|
||||
|
||||
const patterns = [/v=([a-zA-Z0-9_-]{6,})/, /youtu\.be\/([a-zA-Z0-9_-]{6,})/, /embed\/([a-zA-Z0-9_-]{6,})/];
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
const videoId = match?.[1];
|
||||
if (videoId) return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getImageFromDescription(description: unknown): string {
|
||||
if (!isRecord(description)) return "";
|
||||
const image = fromRecord(description, IMAGE_KEYS);
|
||||
if (!image) return "";
|
||||
return image.startsWith("http://") || image.startsWith("https://") || image.startsWith("/") ? image : "";
|
||||
}
|
||||
|
||||
function getImageFromVideoUrl(url: string): string {
|
||||
if (!url) return "";
|
||||
const normalized = url.trim();
|
||||
if (!normalized) return "";
|
||||
if (/\.(png|jpe?g|webp|gif)$/i.test(normalized)) return normalized;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveThumbnailUrl(args: {
|
||||
description: unknown;
|
||||
tags: string[] | null | undefined;
|
||||
youtubeUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
}): string {
|
||||
const fromDescription = getImageFromDescription(args.description);
|
||||
if (fromDescription) return fromDescription;
|
||||
|
||||
const fromTags = getImageFromTags(args.tags);
|
||||
if (fromTags) return fromTags;
|
||||
|
||||
const fromYouTube = getYouTubeThumbnail(args.youtubeUrl ?? "");
|
||||
if (fromYouTube) return fromYouTube;
|
||||
|
||||
const fromVideo = getImageFromVideoUrl(args.videoUrl ?? "");
|
||||
if (fromVideo) return fromVideo;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeTag(tag: string): string {
|
||||
return tag
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
export function normalizeTags(tags: string[] | null | undefined): string[] {
|
||||
return toTags(tags).map(normalizeTag).filter(Boolean);
|
||||
}
|
||||
|
||||
function includesKeyword(tags: string[], stage: AcademicStageId): boolean {
|
||||
return tags.some((tag) => STAGE_KEYWORDS[stage].some((keyword) => tag.includes(keyword)));
|
||||
}
|
||||
|
||||
export function resolveAcademicStage(level: ProficiencyLevel, tags: string[] | null | undefined): AcademicStageConfig {
|
||||
const normalizedTags = normalizeTags(tags);
|
||||
|
||||
if (includesKeyword(normalizedTags, "base")) return ACADEMIC_STAGE_CONFIG.base;
|
||||
if (includesKeyword(normalizedTags, "consolidacion")) return ACADEMIC_STAGE_CONFIG.consolidacion;
|
||||
if (includesKeyword(normalizedTags, "especializacion")) return ACADEMIC_STAGE_CONFIG.especializacion;
|
||||
|
||||
if (level === "BEGINNER") return ACADEMIC_STAGE_CONFIG.base;
|
||||
if (level === "INTERMEDIATE") return ACADEMIC_STAGE_CONFIG.consolidacion;
|
||||
return ACADEMIC_STAGE_CONFIG.especializacion;
|
||||
}
|
||||
|
||||
export function resolveAvailability(
|
||||
status: ContentStatus,
|
||||
tags: string[] | null | undefined,
|
||||
): { state: AvailabilityState; label: string } {
|
||||
if (status === "PUBLISHED") return { state: "published", label: "Disponible" };
|
||||
|
||||
const normalizedTags = normalizeTags(tags);
|
||||
const hasUpcomingTag = normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
|
||||
if (hasUpcomingTag) return { state: "upcoming", label: "Próximamente" };
|
||||
|
||||
return { state: "draft", label: "Borrador" };
|
||||
}
|
||||
|
||||
export function isUpcomingTagPresent(tags: string[] | null | undefined): boolean {
|
||||
const normalizedTags = normalizeTags(tags);
|
||||
return normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
|
||||
}
|
||||
|
||||
export function getProficiencyLabel(level: ProficiencyLevel): string {
|
||||
if (level === "BEGINNER") return "Inicial";
|
||||
if (level === "INTERMEDIATE") return "Intermedio";
|
||||
if (level === "ADVANCED") return "Avanzado";
|
||||
return "Experto";
|
||||
}
|
||||
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "Duración por definir";
|
||||
|
||||
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) return `${totalMinutes} min`;
|
||||
if (minutes === 0) return `${hours} h`;
|
||||
return `${hours} h ${minutes} min`;
|
||||
}
|
||||
|
||||
export function formatMinutes(minutes: number): string {
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return "Sin duración";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes === 0) return `${hours} h`;
|
||||
return `${hours} h ${remainingMinutes} min`;
|
||||
}
|
||||
|
||||
export function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
||||
}
|
||||
581
lib/courses/publicCourses.ts
Normal file
581
lib/courses/publicCourses.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { db } from "@/lib/prisma";
|
||||
import {
|
||||
ACADEMIC_STAGE_CONFIG,
|
||||
ACADEMIC_STAGE_ORDER,
|
||||
PUBLIC_UPCOMING_TAGS,
|
||||
type AcademicStageConfig,
|
||||
type AcademicStageId,
|
||||
formatDuration,
|
||||
formatMinutes,
|
||||
getCourseDescriptions,
|
||||
getLocalizedText,
|
||||
getProficiencyLabel,
|
||||
resolveAcademicStage,
|
||||
resolveAvailability,
|
||||
resolveThumbnailUrl,
|
||||
truncateText,
|
||||
} from "@/lib/courses/presentation";
|
||||
import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
|
||||
|
||||
type CatalogCourseRow = Prisma.CourseGetPayload<{
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
fullName: true;
|
||||
};
|
||||
};
|
||||
modules: {
|
||||
orderBy: {
|
||||
orderIndex: "asc";
|
||||
};
|
||||
select: {
|
||||
lessons: {
|
||||
orderBy: {
|
||||
orderIndex: "asc";
|
||||
};
|
||||
select: {
|
||||
id: true;
|
||||
estimatedDuration: true;
|
||||
isFreePreview: true;
|
||||
youtubeUrl: true;
|
||||
videoUrl: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
_count: {
|
||||
select: {
|
||||
enrollments: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
type DetailCourseRow = Prisma.CourseGetPayload<{
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
fullName: true;
|
||||
};
|
||||
};
|
||||
modules: {
|
||||
orderBy: {
|
||||
orderIndex: "asc";
|
||||
};
|
||||
select: {
|
||||
id: true;
|
||||
title: true;
|
||||
orderIndex: true;
|
||||
lessons: {
|
||||
orderBy: {
|
||||
orderIndex: "asc";
|
||||
};
|
||||
select: {
|
||||
id: true;
|
||||
title: true;
|
||||
description: true;
|
||||
orderIndex: true;
|
||||
estimatedDuration: true;
|
||||
isFreePreview: true;
|
||||
youtubeUrl: true;
|
||||
videoUrl: true;
|
||||
_count: {
|
||||
select: {
|
||||
resources: true;
|
||||
exercises: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
_count: {
|
||||
select: {
|
||||
enrollments: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export type CatalogCourseCardView = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
longDescription: string;
|
||||
thumbnailUrl: string | null;
|
||||
stageId: AcademicStageId;
|
||||
stageLabel: string;
|
||||
proficiencyLabel: string;
|
||||
durationLabel: string;
|
||||
durationMinutes: number;
|
||||
lessonCount: number;
|
||||
instructor: string;
|
||||
availabilityLabel: string;
|
||||
availabilityState: "published" | "upcoming" | "draft";
|
||||
progressPercent: number;
|
||||
completedLessons: number;
|
||||
totalLessons: number;
|
||||
studentsCount: number;
|
||||
isEnrolled: boolean;
|
||||
hasPreview: boolean;
|
||||
};
|
||||
|
||||
export type CatalogSectionView = {
|
||||
id: AcademicStageId;
|
||||
anchorId: string;
|
||||
tabLabel: string;
|
||||
sectionTitle: string;
|
||||
sectionDescription: string;
|
||||
courses: CatalogCourseCardView[];
|
||||
};
|
||||
|
||||
export type CourseCatalogViewModel = {
|
||||
sections: CatalogSectionView[];
|
||||
totals: {
|
||||
totalCourses: number;
|
||||
totalLessons: number;
|
||||
instructorCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ProgramBadge = "Video" | "Lectura" | "Actividad" | "Evaluación";
|
||||
|
||||
export type CourseProgramItemView = {
|
||||
id: string;
|
||||
order: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
durationLabel: string | null;
|
||||
badges: ProgramBadge[];
|
||||
isPreview: boolean;
|
||||
isFinalExam: boolean;
|
||||
isLocked: boolean;
|
||||
isCompleted: boolean;
|
||||
isUpcoming: boolean;
|
||||
};
|
||||
|
||||
export type CourseProgramModuleView = {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
items: CourseProgramItemView[];
|
||||
};
|
||||
|
||||
export type CourseDetailViewModel = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
longDescription: string;
|
||||
thumbnailUrl: string | null;
|
||||
stage: AcademicStageConfig;
|
||||
proficiencyLabel: string;
|
||||
instructor: string;
|
||||
availabilityLabel: string;
|
||||
availabilityState: "published" | "upcoming" | "draft";
|
||||
studentsCount: number;
|
||||
lessonCount: number;
|
||||
durationLabel: string;
|
||||
durationMinutes: number;
|
||||
moduleCount: number;
|
||||
modules: CourseProgramModuleView[];
|
||||
progressPercent: number;
|
||||
completedLessons: number;
|
||||
totalLessons: number;
|
||||
isEnrolled: boolean;
|
||||
firstPreviewLessonId: string | null;
|
||||
price: number;
|
||||
};
|
||||
|
||||
function toMinutes(totalSeconds: number): number {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return 0;
|
||||
return Math.max(1, Math.ceil(totalSeconds / 60));
|
||||
}
|
||||
|
||||
function getPrimaryMedia(lessons: Array<{ youtubeUrl: string | null; videoUrl: string | null }>) {
|
||||
const withYoutube = lessons.find((lesson) => Boolean(lesson.youtubeUrl?.trim()));
|
||||
if (withYoutube) return { youtubeUrl: withYoutube.youtubeUrl, videoUrl: withYoutube.videoUrl };
|
||||
|
||||
const withVideo = lessons.find((lesson) => Boolean(lesson.videoUrl?.trim()));
|
||||
if (withVideo) return { youtubeUrl: withVideo.youtubeUrl, videoUrl: withVideo.videoUrl };
|
||||
|
||||
return { youtubeUrl: null, videoUrl: null };
|
||||
}
|
||||
|
||||
function getProgramBadges(lesson: DetailCourseRow["modules"][number]["lessons"][number]): ProgramBadge[] {
|
||||
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||
const badges: ProgramBadge[] = [];
|
||||
|
||||
if (lessonMeta.contentType === "VIDEO") badges.push("Video");
|
||||
if (lessonMeta.contentType === "LECTURE") badges.push("Lectura");
|
||||
if (lessonMeta.contentType === "ACTIVITY") badges.push("Actividad");
|
||||
if (lessonMeta.contentType === "QUIZ" || lessonMeta.contentType === "FINAL_EXAM") badges.push("Evaluación");
|
||||
if (lesson.youtubeUrl || lesson.videoUrl) {
|
||||
if (!badges.includes("Video")) badges.push("Video");
|
||||
}
|
||||
if (lesson._count.resources > 0 && !badges.includes("Lectura")) badges.push("Lectura");
|
||||
if (lesson._count.exercises > 0 && !badges.includes("Evaluación")) badges.push("Evaluación");
|
||||
if (badges.length === 0) badges.push("Actividad");
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
function getPublicCatalogWhere() {
|
||||
return {
|
||||
OR: [
|
||||
{ status: "PUBLISHED" as const },
|
||||
{
|
||||
status: "DRAFT" as const,
|
||||
tags: {
|
||||
hasSome: PUBLIC_UPCOMING_TAGS,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseCatalogViewModel(userId: string | null): Promise<CourseCatalogViewModel> {
|
||||
const courses = await db.course
|
||||
.findMany({
|
||||
where: getPublicCatalogWhere(),
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
fullName: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
orderBy: {
|
||||
orderIndex: "asc",
|
||||
},
|
||||
select: {
|
||||
lessons: {
|
||||
orderBy: {
|
||||
orderIndex: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
estimatedDuration: true,
|
||||
isFreePreview: true,
|
||||
youtubeUrl: true,
|
||||
videoUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
enrollments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
updatedAt: "desc",
|
||||
},
|
||||
],
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load public course catalog.", error);
|
||||
return [] as CatalogCourseRow[];
|
||||
});
|
||||
|
||||
const courseIds = courses.map((course) => course.id);
|
||||
const [enrollments, completedProgress] =
|
||||
userId && courseIds.length > 0
|
||||
? await Promise.all([
|
||||
db.enrollment
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
courseId: {
|
||||
in: courseIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
courseId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
db.userProgress
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isCompleted: true,
|
||||
lesson: {
|
||||
module: {
|
||||
courseId: {
|
||||
in: courseIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
lesson: {
|
||||
select: {
|
||||
module: {
|
||||
select: {
|
||||
courseId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const enrollmentSet = new Set(enrollments.map((entry) => entry.courseId));
|
||||
const completedByCourse = new Map<string, number>();
|
||||
for (const entry of completedProgress) {
|
||||
const courseId = entry.lesson.module.courseId;
|
||||
completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const grouped: Record<AcademicStageId, CatalogCourseCardView[]> = {
|
||||
base: [],
|
||||
consolidacion: [],
|
||||
especializacion: [],
|
||||
};
|
||||
|
||||
for (const course of courses) {
|
||||
const allLessons = course.modules.flatMap((module) => module.lessons);
|
||||
const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0);
|
||||
const totalLessons = allLessons.length;
|
||||
const completedLessons = completedByCourse.get(course.id) ?? 0;
|
||||
const isEnrolled = enrollmentSet.has(course.id);
|
||||
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
||||
const stage = resolveAcademicStage(course.level, course.tags);
|
||||
const availability = resolveAvailability(course.status, course.tags);
|
||||
const descriptions = getCourseDescriptions(course.description);
|
||||
const media = getPrimaryMedia(allLessons);
|
||||
const thumbnailUrl =
|
||||
resolveThumbnailUrl({
|
||||
description: course.description,
|
||||
tags: course.tags,
|
||||
youtubeUrl: media.youtubeUrl,
|
||||
videoUrl: media.videoUrl,
|
||||
}) || null;
|
||||
|
||||
grouped[stage.id].push({
|
||||
id: course.id,
|
||||
slug: course.slug,
|
||||
title: getLocalizedText(course.title) || "Programa académico ACVE",
|
||||
shortDescription: descriptions.short,
|
||||
longDescription: descriptions.long,
|
||||
thumbnailUrl,
|
||||
stageId: stage.id,
|
||||
stageLabel: stage.levelLabel,
|
||||
proficiencyLabel: getProficiencyLabel(course.level),
|
||||
durationLabel: formatDuration(totalSeconds),
|
||||
durationMinutes: toMinutes(totalSeconds),
|
||||
lessonCount: totalLessons,
|
||||
instructor: course.author.fullName || "Equipo académico ACVE",
|
||||
availabilityLabel: availability.label,
|
||||
availabilityState: availability.state,
|
||||
progressPercent,
|
||||
completedLessons,
|
||||
totalLessons,
|
||||
studentsCount: course._count.enrollments,
|
||||
isEnrolled,
|
||||
hasPreview: allLessons.some((lesson) => lesson.isFreePreview),
|
||||
});
|
||||
}
|
||||
|
||||
const sections: CatalogSectionView[] = ACADEMIC_STAGE_ORDER.map((stageId) => {
|
||||
const config = ACADEMIC_STAGE_CONFIG[stageId];
|
||||
return {
|
||||
id: stageId,
|
||||
anchorId: `programas-${stageId}`,
|
||||
tabLabel: config.tabLabel,
|
||||
sectionTitle: config.sectionTitle,
|
||||
sectionDescription: config.sectionDescription,
|
||||
courses: grouped[stageId],
|
||||
};
|
||||
});
|
||||
|
||||
const totalLessons = sections.reduce(
|
||||
(sum, section) => sum + section.courses.reduce((sectionSum, course) => sectionSum + course.lessonCount, 0),
|
||||
0,
|
||||
);
|
||||
const instructorCount = new Set(sections.flatMap((section) => section.courses.map((course) => course.instructor))).size;
|
||||
const totalCourses = sections.reduce((sum, section) => sum + section.courses.length, 0);
|
||||
|
||||
return {
|
||||
sections,
|
||||
totals: {
|
||||
totalCourses,
|
||||
totalLessons,
|
||||
instructorCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: Prisma.Decimal): number {
|
||||
const asNumber = Number(value);
|
||||
return Number.isFinite(asNumber) ? asNumber : 0;
|
||||
}
|
||||
|
||||
export async function getCourseDetailViewModel(
|
||||
slug: string,
|
||||
userId: string | null,
|
||||
): Promise<CourseDetailViewModel | null> {
|
||||
const course = await db.course.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
...getPublicCatalogWhere(),
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
fullName: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
orderBy: {
|
||||
orderIndex: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
orderIndex: true,
|
||||
lessons: {
|
||||
orderBy: {
|
||||
orderIndex: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
orderIndex: true,
|
||||
estimatedDuration: true,
|
||||
isFreePreview: true,
|
||||
youtubeUrl: true,
|
||||
videoUrl: true,
|
||||
_count: {
|
||||
select: {
|
||||
resources: true,
|
||||
exercises: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
enrollments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) return null;
|
||||
|
||||
const allLessons = course.modules.flatMap((module) => module.lessons);
|
||||
const lessonIds = allLessons.map((lesson) => lesson.id);
|
||||
const [enrollment, completedProgress] = userId
|
||||
? await Promise.all([
|
||||
db.enrollment.findUnique({
|
||||
where: {
|
||||
userId_courseId: {
|
||||
userId,
|
||||
courseId: course.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
lessonIds.length > 0
|
||||
? db.userProgress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isCompleted: true,
|
||||
lessonId: {
|
||||
in: lessonIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
lessonId: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([] as { lessonId: string }[]),
|
||||
])
|
||||
: [null, [] as { lessonId: string }[]];
|
||||
|
||||
const completedSet = new Set(completedProgress.map((entry) => entry.lessonId));
|
||||
const isEnrolled = Boolean(enrollment);
|
||||
const totalLessons = allLessons.length;
|
||||
const completedLessons = completedSet.size;
|
||||
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
||||
const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0);
|
||||
const stage = resolveAcademicStage(course.level, course.tags);
|
||||
const availability = resolveAvailability(course.status, course.tags);
|
||||
const descriptions = getCourseDescriptions(course.description);
|
||||
const media = getPrimaryMedia(allLessons);
|
||||
const thumbnailUrl =
|
||||
resolveThumbnailUrl({
|
||||
description: course.description,
|
||||
tags: course.tags,
|
||||
youtubeUrl: media.youtubeUrl,
|
||||
videoUrl: media.videoUrl,
|
||||
}) || null;
|
||||
|
||||
let runningOrder = 0;
|
||||
const modules: CourseProgramModuleView[] = course.modules.map((module) => ({
|
||||
id: module.id,
|
||||
title: getLocalizedText(module.title) || `Módulo ${module.orderIndex + 1}`,
|
||||
order: module.orderIndex + 1,
|
||||
items: module.lessons.map((lesson) => {
|
||||
runningOrder += 1;
|
||||
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||
const subtitleRaw = lessonMeta.text || getLocalizedText(lesson.description);
|
||||
const subtitle = subtitleRaw ? truncateText(subtitleRaw, 130) : "";
|
||||
const isUpcoming = availability.state !== "published";
|
||||
const isLocked = !isUpcoming && !isEnrolled && !lesson.isFreePreview;
|
||||
return {
|
||||
id: lesson.id,
|
||||
order: runningOrder,
|
||||
title: getLocalizedText(lesson.title) || `Lección ${runningOrder}`,
|
||||
subtitle,
|
||||
durationLabel: lesson.estimatedDuration > 0 ? formatMinutes(toMinutes(lesson.estimatedDuration)) : null,
|
||||
badges: getProgramBadges(lesson),
|
||||
isPreview: lesson.isFreePreview,
|
||||
isFinalExam: isFinalExam(lessonMeta.contentType),
|
||||
isLocked,
|
||||
isCompleted: completedSet.has(lesson.id),
|
||||
isUpcoming,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: course.id,
|
||||
slug: course.slug,
|
||||
title: getLocalizedText(course.title) || "Programa académico ACVE",
|
||||
shortDescription: descriptions.short,
|
||||
longDescription: descriptions.long,
|
||||
thumbnailUrl,
|
||||
stage,
|
||||
proficiencyLabel: getProficiencyLabel(course.level),
|
||||
instructor: course.author.fullName || "Equipo académico ACVE",
|
||||
availabilityLabel: availability.label,
|
||||
availabilityState: availability.state,
|
||||
studentsCount: course._count.enrollments,
|
||||
lessonCount: totalLessons,
|
||||
durationLabel: formatDuration(totalSeconds),
|
||||
durationMinutes: toMinutes(totalSeconds),
|
||||
moduleCount: modules.length,
|
||||
modules,
|
||||
progressPercent,
|
||||
completedLessons,
|
||||
totalLessons,
|
||||
isEnrolled,
|
||||
firstPreviewLessonId: allLessons.find((lesson) => lesson.isFreePreview)?.id ?? null,
|
||||
price: toNumber(course.price),
|
||||
};
|
||||
}
|
||||
@@ -2,34 +2,96 @@ import type { CaseStudy } from "@/types/caseStudy";
|
||||
|
||||
export const mockCaseStudies: CaseStudy[] = [
|
||||
{
|
||||
slug: "acme-v-zenith",
|
||||
title: "Acme v. Zenith: Non-Compete Enforcement",
|
||||
citation: "2021 App. Ct. 402",
|
||||
year: 2021,
|
||||
summary: "Dispute over enforceability of a cross-state non-compete with broad scope language.",
|
||||
level: "Intermediate",
|
||||
topic: "Employment",
|
||||
keyTerms: ["Reasonableness", "Geographic scope", "Public policy"],
|
||||
},
|
||||
{
|
||||
slug: "state-v-garcia",
|
||||
title: "State v. Garcia: Evidence Admissibility",
|
||||
citation: "2019 Sup. Ct. 88",
|
||||
year: 2019,
|
||||
summary: "Examines when digital communications meet admissibility and chain-of-custody standards.",
|
||||
level: "Advanced",
|
||||
topic: "Criminal Procedure",
|
||||
keyTerms: ["Authentication", "Hearsay exception", "Prejudice test"],
|
||||
},
|
||||
{
|
||||
slug: "harbor-bank-v-orchid",
|
||||
title: "Harbor Bank v. Orchid Labs",
|
||||
citation: "2023 Com. Ct. 110",
|
||||
year: 2023,
|
||||
summary: "Breach of financing covenants and acceleration remedies in a distressed credit event.",
|
||||
slug: "marbury-v-madison",
|
||||
title: "Marbury v. Madison",
|
||||
citation: "5 U.S. (1 Cranch) 137",
|
||||
year: 1803,
|
||||
summaryEs:
|
||||
"Un caso emblematico que establece el principio de judicial review. William Marbury solicito a la Corte Suprema que ordenara al Secretario de Estado entregarle su nombramiento como juez federal. Aunque la Corte reconocio que tenia derecho al cargo, declaro inconstitucional la norma que le otorgaba competencia para resolver el asunto.",
|
||||
legalOutcomeEs:
|
||||
"La Corte Suprema sostuvo que Marbury tenia derecho a su nombramiento y existia un remedio legal, pero la disposicion que otorgaba competencia originaria para emitir un writ of mandamus era inconstitucional.",
|
||||
level: "Beginner",
|
||||
topic: "Commercial",
|
||||
keyTerms: ["Default", "Covenant breach", "Acceleration"],
|
||||
category: "Constitucional",
|
||||
difficulty: "Base",
|
||||
keyTerms: [
|
||||
{
|
||||
term: "Judicial Review",
|
||||
definitionEs:
|
||||
"Facultad del poder judicial para declarar inconstitucional una ley del Congreso cuando contradice la Constitucion.",
|
||||
},
|
||||
{
|
||||
term: "Writ of Mandamus",
|
||||
definitionEs:
|
||||
"Orden judicial que instruye a un funcionario publico a cumplir con un deber legal especifico.",
|
||||
},
|
||||
{
|
||||
term: "Original Jurisdiction",
|
||||
definitionEs:
|
||||
"Competencia de un tribunal para conocer un asunto en primera instancia.",
|
||||
},
|
||||
],
|
||||
quizPrompt: "Ponte a prueba",
|
||||
},
|
||||
{
|
||||
slug: "miranda-v-arizona",
|
||||
title: "Miranda v. Arizona",
|
||||
citation: "384 U.S. 436",
|
||||
year: 1966,
|
||||
summaryEs:
|
||||
"Un caso emblematico que transformo el procedimiento penal en Estados Unidos. Ernesto Miranda fue detenido e interrogado sin ser informado de su derecho a guardar silencio ni a contar con un abogado. La Corte Suprema determino que esas declaraciones no podian utilizarse como prueba.",
|
||||
legalOutcomeEs:
|
||||
"La Corte sostuvo que toda persona detenida debe ser informada de sus derechos antes de un interrogatorio custodial; sin esas advertencias, las declaraciones son inadmisibles por la proteccion contra la autoincriminacion.",
|
||||
level: "Intermediate",
|
||||
category: "Constitucional",
|
||||
difficulty: "Base",
|
||||
keyTerms: [
|
||||
{
|
||||
term: "Custodial Interrogation",
|
||||
definitionEs:
|
||||
"Interrogatorio realizado por la policia cuando una persona se encuentra bajo custodia y privada de su libertad.",
|
||||
},
|
||||
{
|
||||
term: "Self-Incrimination",
|
||||
definitionEs:
|
||||
"Derecho constitucional a no declarar contra uno mismo, protegido por la Quinta Enmienda.",
|
||||
},
|
||||
{
|
||||
term: "Miranda Warnings",
|
||||
definitionEs:
|
||||
"Advertencias que la policia debe comunicar antes de interrogar a un detenido, incluyendo derecho a guardar silencio y abogado.",
|
||||
},
|
||||
],
|
||||
quizPrompt: "Ponte a prueba",
|
||||
},
|
||||
{
|
||||
slug: "brown-v-board-of-education",
|
||||
title: "Brown v. Board of Education",
|
||||
citation: "347 U.S. 483",
|
||||
year: 1953,
|
||||
summaryEs:
|
||||
"Un caso emblematico que transformo el derecho constitucional estadounidense en materia de igualdad. Varias familias afroamericanas impugnaron la segregacion racial en escuelas publicas. La Corte concluyo que la doctrina de separados pero iguales era incompatible con la igualdad ante la ley.",
|
||||
legalOutcomeEs:
|
||||
"La Corte Suprema sostuvo que la segregacion racial en escuelas publicas genera desigualdad inherente y viola la Equal Protection Clause de la Decimocuarta Enmienda.",
|
||||
level: "Advanced",
|
||||
category: "Constitucional",
|
||||
difficulty: "Base",
|
||||
keyTerms: [
|
||||
{
|
||||
term: "Equal Protection Clause",
|
||||
definitionEs:
|
||||
"Clausula de la Decimocuarta Enmienda que garantiza la igualdad de proteccion de las leyes para todas las personas.",
|
||||
},
|
||||
{
|
||||
term: "Segregation",
|
||||
definitionEs: "Separacion legal de personas con base en criterios raciales.",
|
||||
},
|
||||
{
|
||||
term: "Precedent",
|
||||
definitionEs:
|
||||
"Decision judicial previa que sirve como guia o autoridad obligatoria para casos posteriores.",
|
||||
},
|
||||
],
|
||||
quizPrompt: "Ponte a prueba",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const mockPracticeModules: PracticeModule[] = [
|
||||
title: "Legal Translation Challenge",
|
||||
description: "Translate legal terms accurately in context with timed multiple-choice questions.",
|
||||
isInteractive: true,
|
||||
difficulty: "Beginner",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
@@ -30,14 +31,86 @@ export const mockPracticeModules: PracticeModule[] = [
|
||||
{
|
||||
slug: "term-matching",
|
||||
title: "Term Matching Game",
|
||||
description: "Pair legal terms with practical definitions.",
|
||||
isInteractive: false,
|
||||
description: "Match core legal concepts with the most accurate definition in English.",
|
||||
isInteractive: true,
|
||||
difficulty: "Intermediate",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Match: consideration",
|
||||
choices: [
|
||||
"A legally binding command from the court",
|
||||
"A bargained-for exchange of value between parties",
|
||||
"A prior case that has no legal effect",
|
||||
"A statement made outside of court",
|
||||
],
|
||||
answerIndex: 1,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
prompt: "Match: injunction",
|
||||
choices: [
|
||||
"A court order requiring a party to do or stop doing something",
|
||||
"A clause that sets venue for disputes",
|
||||
"A witness statement under oath",
|
||||
"A mandatory arbitration waiver",
|
||||
],
|
||||
answerIndex: 0,
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
prompt: "Match: precedent",
|
||||
choices: [
|
||||
"A contractual deadline extension",
|
||||
"A final administrative regulation",
|
||||
"A prior judicial decision used as authority",
|
||||
"A private settlement term",
|
||||
],
|
||||
answerIndex: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "contract-clauses",
|
||||
title: "Contract Clause Practice",
|
||||
description: "Identify weak and risky clause drafting choices.",
|
||||
isInteractive: false,
|
||||
description: "Identify the strongest contract clause option for each scenario.",
|
||||
isInteractive: true,
|
||||
difficulty: "Advanced",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Choose the strongest force majeure clause element:",
|
||||
choices: [
|
||||
"No definition of triggering events",
|
||||
"Broad reference without notice obligations",
|
||||
"Defined events, notice timeline, and mitigation duty",
|
||||
"Automatic termination without limits",
|
||||
],
|
||||
answerIndex: 2,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
prompt: "Best limitation of liability drafting choice:",
|
||||
choices: [
|
||||
"Exclude all damages including willful misconduct",
|
||||
"Cap liability with carve-outs for fraud and gross negligence",
|
||||
"No cap and no exclusions",
|
||||
"Cap liability only for one party",
|
||||
],
|
||||
answerIndex: 1,
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
prompt: "Best dispute resolution clause for cross-border deals:",
|
||||
choices: [
|
||||
"No governing law or venue specified",
|
||||
"Unilateral right to sue in any court worldwide",
|
||||
"Clear governing law, venue, and arbitration seat",
|
||||
"Only internal escalation with no external remedy",
|
||||
],
|
||||
answerIndex: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
7
lib/i18n/clientLocale.ts
Normal file
7
lib/i18n/clientLocale.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ClientLocale = "en" | "es";
|
||||
|
||||
export function getClientLocale(): ClientLocale {
|
||||
if (typeof navigator === "undefined") return "en";
|
||||
const language = navigator.language?.toLowerCase() ?? "en";
|
||||
return language.startsWith("es") ? "es" : "en";
|
||||
}
|
||||
224
lib/recommendations.ts
Normal file
224
lib/recommendations.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { db } from "@/lib/prisma";
|
||||
|
||||
type RecommendedCourse = {
|
||||
courseId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
level: string;
|
||||
reason: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type RecommendationPrismaClient = {
|
||||
miniGameAttempt: {
|
||||
findMany: (args: object) => Promise<{ miniGameId: string; scorePercent: number }[]>;
|
||||
};
|
||||
studyRecommendation: {
|
||||
updateMany: (args: object) => Promise<unknown>;
|
||||
createMany: (args: object) => Promise<unknown>;
|
||||
findMany: (args: object) => Promise<
|
||||
{
|
||||
courseId: string;
|
||||
reason: string;
|
||||
priority: number;
|
||||
course: { title: unknown; slug: string; level: string };
|
||||
}[]
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
function getText(value: unknown): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.en === "string") return record.en;
|
||||
if (typeof record.es === "string") return record.es;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function targetLevelByGrade(grade: number): "BEGINNER" | "INTERMEDIATE" | "ADVANCED" {
|
||||
if (grade < 60) return "BEGINNER";
|
||||
if (grade < 80) return "INTERMEDIATE";
|
||||
return "ADVANCED";
|
||||
}
|
||||
|
||||
export async function getMiniGameGrade(userId: string): Promise<number> {
|
||||
let attempts: { miniGameId: string; scorePercent: number }[] = [];
|
||||
const prismaAny = db as unknown as RecommendationPrismaClient;
|
||||
try {
|
||||
attempts = await prismaAny.miniGameAttempt.findMany({
|
||||
where: { userId },
|
||||
orderBy: { completedAt: "desc" },
|
||||
select: { miniGameId: true, scorePercent: true },
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const latestByGame = new Map<string, number>();
|
||||
for (const attempt of attempts) {
|
||||
if (!latestByGame.has(attempt.miniGameId)) {
|
||||
latestByGame.set(attempt.miniGameId, attempt.scorePercent);
|
||||
}
|
||||
}
|
||||
|
||||
const latest = [...latestByGame.values()];
|
||||
if (latest.length === 0) return 0;
|
||||
return Math.round(latest.reduce((acc, value) => acc + value, 0) / latest.length);
|
||||
}
|
||||
|
||||
export async function refreshStudyRecommendations(userId: string) {
|
||||
const grade = await getMiniGameGrade(userId);
|
||||
const targetLevel = targetLevelByGrade(grade);
|
||||
|
||||
const [courses, enrollments] = await Promise.all([
|
||||
db.course.findMany({
|
||||
where: {
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
include: {
|
||||
modules: {
|
||||
include: {
|
||||
lessons: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.enrollment.findMany({
|
||||
where: { userId },
|
||||
select: { courseId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const enrolledSet = new Set(enrollments.map((enrollment) => enrollment.courseId));
|
||||
const completedProgress = await db.userProgress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isCompleted: true,
|
||||
lesson: {
|
||||
module: {
|
||||
courseId: {
|
||||
in: courses.map((course) => course.id),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
lesson: {
|
||||
select: {
|
||||
module: {
|
||||
select: { courseId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completedByCourse = new Map<string, number>();
|
||||
for (const item of completedProgress) {
|
||||
const courseId = item.lesson.module.courseId;
|
||||
completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const recommendations: RecommendedCourse[] = [];
|
||||
for (const course of courses) {
|
||||
const totalLessons = course.modules.reduce((acc, module) => acc + module.lessons.length, 0);
|
||||
const completedLessons = completedByCourse.get(course.id) ?? 0;
|
||||
const isCompleted = totalLessons > 0 && completedLessons >= totalLessons;
|
||||
if (isCompleted) continue;
|
||||
|
||||
const isEnrolled = enrolledSet.has(course.id);
|
||||
const levelMatch = course.level === targetLevel;
|
||||
|
||||
let priority = 20;
|
||||
let reason = `Aligned with your current level focus (${targetLevel.toLowerCase()}).`;
|
||||
|
||||
if (isEnrolled) {
|
||||
priority = 5;
|
||||
reason = "You already started this course and can keep progressing.";
|
||||
} else if (!levelMatch) {
|
||||
priority = 40;
|
||||
reason = "Useful as a secondary recommendation outside your current level target.";
|
||||
}
|
||||
|
||||
recommendations.push({
|
||||
courseId: course.id,
|
||||
slug: course.slug,
|
||||
title: getText(course.title) || "Untitled course",
|
||||
level: course.level,
|
||||
reason,
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = recommendations.sort((a, b) => a.priority - b.priority).slice(0, 5);
|
||||
|
||||
const prismaAny = db as unknown as RecommendationPrismaClient;
|
||||
try {
|
||||
await prismaAny.studyRecommendation.updateMany({
|
||||
where: { userId, isActive: true },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
if (sorted.length > 0) {
|
||||
await prismaAny.studyRecommendation.createMany({
|
||||
data: sorted.map((item) => ({
|
||||
userId,
|
||||
courseId: item.courseId,
|
||||
reason: item.reason,
|
||||
priority: item.priority,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export async function getActiveRecommendations(userId: string) {
|
||||
let existing:
|
||||
| {
|
||||
courseId: string;
|
||||
reason: string;
|
||||
priority: number;
|
||||
course: { title: unknown; slug: string; level: string };
|
||||
}[]
|
||||
| null = null;
|
||||
try {
|
||||
existing = await (db as unknown as RecommendationPrismaClient).studyRecommendation.findMany({
|
||||
where: { userId, isActive: true },
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
level: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { priority: "asc" },
|
||||
take: 5,
|
||||
});
|
||||
} catch {
|
||||
return refreshStudyRecommendations(userId);
|
||||
}
|
||||
|
||||
if (!existing || existing.length === 0) {
|
||||
return refreshStudyRecommendations(userId);
|
||||
}
|
||||
|
||||
return existing.map((item) => ({
|
||||
courseId: item.courseId,
|
||||
slug: item.course.slug,
|
||||
title: getText(item.course.title) || "Untitled course",
|
||||
level: item.course.level,
|
||||
reason: item.reason,
|
||||
priority: item.priority,
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { readSupabasePublicConfig } from "@/lib/supabase/config";
|
||||
|
||||
let browserClient: SupabaseClient | null = null;
|
||||
|
||||
/**
|
||||
* Browser Supabase client that uses cookies (via @supabase/ssr) so the session
|
||||
* is shared with the server/middleware and stays in sync after login.
|
||||
*/
|
||||
export const supabaseBrowser = (): SupabaseClient | null => {
|
||||
const config = readSupabasePublicConfig();
|
||||
if (!config) {
|
||||
@@ -10,7 +15,7 @@ export const supabaseBrowser = (): SupabaseClient | null => {
|
||||
}
|
||||
|
||||
if (!browserClient) {
|
||||
browserClient = createClient(config.url, config.anonKey);
|
||||
browserClient = createBrowserClient(config.url, config.anonKey);
|
||||
}
|
||||
|
||||
return browserClient;
|
||||
|
||||
Reference in New Issue
Block a user