225 lines
5.9 KiB
TypeScript
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,
|
|
}));
|
|
}
|