Files
ACVE/lib/recommendations.ts
2026-03-15 13:52:11 +00:00

225 lines
5.9 KiB
TypeScript

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,
}));
}