From 5d6c66080b906c51b887ac828b6ad5afe9bd5397 Mon Sep 17 00:00:00 2001 From: kinga Date: Sat, 25 Apr 2026 14:21:08 +0200 Subject: [PATCH 1/4] feat(profile): implement course and deck archiving functionality with corresponding API routes and UI components --- .../(student)/(shell)/courses/[slug]/page.tsx | 16 +- .../(shell)/dashboard/flashcards/page.tsx | 4 + .../app/(student)/(shell)/profile/page.tsx | 213 ++++++++++++++++++ .../app/actions/course-progress.ts | 2 +- .../app/api/practice/session/route.ts | 24 +- .../app/api/profile/archive/route.ts | 99 ++++++++ .../app/api/profile/unarchive/route.ts | 50 ++++ .../app/api/recommend/tasks/route.ts | 24 +- .../components/dashboard/course-carousel.tsx | 20 +- .../dashboard/flashcard-deck-carousel.tsx | 112 +++++++++ LearningPlatform/components/navbar.tsx | 4 +- .../components/profile/archive-actions.tsx | 117 ++++++++++ .../lib/flashcards-dashboard-summary.ts | 12 +- LearningPlatform/lib/started-courses.ts | 8 +- .../migration.sql | 12 + LearningPlatform/prisma/schema.prisma | 4 + 16 files changed, 706 insertions(+), 15 deletions(-) create mode 100644 LearningPlatform/app/(student)/(shell)/profile/page.tsx create mode 100644 LearningPlatform/app/api/profile/archive/route.ts create mode 100644 LearningPlatform/app/api/profile/unarchive/route.ts create mode 100644 LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx create mode 100644 LearningPlatform/components/profile/archive-actions.tsx create mode 100644 LearningPlatform/prisma/migrations/20260425122000_add_archive_flags/migration.sql diff --git a/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx b/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx index d6f59aa..14d3e39 100644 --- a/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx @@ -13,6 +13,7 @@ import { prisma } from '@/lib/prisma' import { CourseHeroTitle } from '@/components/courses/course-hero-title' import { studentGlassCard, studentGlassPill } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' +import { ArchiveCourseButton } from '@/components/profile/archive-actions' /** Same visual as dashboard `StatPill` in `flashcard-section.tsx` (server-safe duplicate). */ function CourseFlashcardStatPill({ label, count }: { label: string; count: number }) { @@ -147,10 +148,19 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s const courseId = String(course.id) const moduleIds = modulesWithLessons.map((m) => String(m.id)) - const mainDeck = await prisma.flashcardDeck.findFirst({ + let mainDeck = await prisma.flashcardDeck.findFirst({ where: { courseId, parentDeckId: null }, select: { id: true, name: true, slug: true }, }) + if (mainDeck && session?.user?.id) { + const archivedDeck = await prisma.userStandaloneFlashcardDeck.findUnique({ + where: { userId_deckId: { userId: session.user.id, deckId: mainDeck.id } }, + select: { archivedAt: true }, + }) + if (archivedDeck?.archivedAt) { + mainDeck = null + } + } const subdecks = mainDeck ? await prisma.flashcardDeck.findMany({ @@ -468,6 +478,10 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s )} + +
+ +
) } diff --git a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx index 146fd81..cf604db 100644 --- a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { studentGlassCard, studentGlassFooterNavButton, studentGlassPill } from '@/lib/student-glass-styles' import type { FlashcardDashboardSummary } from '@/lib/flashcards-dashboard-summary' +import { ArchiveDeckButton } from '@/components/profile/archive-actions' type Stats = { total: number; newCards: number; due: number } @@ -419,6 +420,9 @@ export default function StudentFlashcardDeckTreePage() { )} )} +
+ +
) diff --git a/LearningPlatform/app/(student)/(shell)/profile/page.tsx b/LearningPlatform/app/(student)/(shell)/profile/page.tsx new file mode 100644 index 0000000..4d147dd --- /dev/null +++ b/LearningPlatform/app/(student)/(shell)/profile/page.tsx @@ -0,0 +1,213 @@ +import { auth } from '@/auth' +import { getPayload } from 'payload' +import config from '@payload-config' +import { redirect } from 'next/navigation' +import { prisma } from '@/lib/prisma' +import { Card, CardContent } from '@/components/ui/card' +import { studentGlassCard } from '@/lib/student-glass-styles' +import { cn } from '@/lib/utils' +import { CourseCarousel, type CourseProgressSnapshot } from '@/components/dashboard/course-carousel' +import { FlashcardDeckCarousel } from '@/components/dashboard/flashcard-deck-carousel' +import { UnarchiveCourseButton, UnarchiveDeckButton } from '@/components/profile/archive-actions' + +export const dynamic = 'force-dynamic' + +type DeckStats = { total: number; newCards: number; due: number } + +export default async function ProfilePage() { + const session = await auth() + const userId = session?.user?.id + if (!userId) redirect('/login') + + const payload = await getPayload({ config }) + const [archivedCourses, archivedDeckEnrollments] = await Promise.all([ + prisma.courseProgress.findMany({ + where: { userId, archivedAt: { not: null } }, + orderBy: { archivedAt: 'desc' }, + select: { courseId: true, completedLessons: true, totalLessons: true, progressPercentage: true }, + }), + prisma.userStandaloneFlashcardDeck.findMany({ + where: { userId, archivedAt: { not: null } }, + orderBy: { archivedAt: 'desc' }, + select: { + deck: { + select: { + id: true, + name: true, + slug: true, + description: true, + subjectId: true, + courseId: true, + parentDeckId: true, + tags: { select: { name: true } }, + }, + }, + }, + }), + ]) + + const archivedCourseIds = archivedCourses.map((c) => c.courseId) + const courseDocs = archivedCourseIds.length + ? (await payload.find({ + collection: 'courses', + where: { id: { in: archivedCourseIds }, isPublished: { equals: true } }, + limit: archivedCourseIds.length, + depth: 1, + })).docs + : [] + const courseById = new Map(courseDocs.map((c) => [String(c.id), c])) + + const archivedCourseItems = archivedCourseIds + .map((id) => { + const doc = courseById.get(id) + if (!doc) return null + return { + id, + title: doc.title, + slug: doc.slug, + description: doc.description, + coverImage: typeof doc.coverImage === 'object' ? (doc.coverImage as { filename: string; alt?: string }) : null, + level: doc.level, + subject: doc.subject as string | { name?: string } | null, + } + }) + .filter((v): v is NonNullable => Boolean(v)) + + const archivedProgressByCourseId: Record = {} + for (const row of archivedCourses) { + archivedProgressByCourseId[row.courseId] = { + progressPercentage: row.progressPercentage, + completedLessons: row.completedLessons, + totalLessons: row.totalLessons, + hasStarted: row.totalLessons > 0 || row.completedLessons > 0, + } + } + + const rootDecks = archivedDeckEnrollments + .map((row) => row.deck) + .filter((d) => d && !d.parentDeckId) + const rootDeckIds = rootDecks.map((d) => d.id) + const allDeckIds = new Set(rootDeckIds) + let layer = [...rootDeckIds] + for (let depth = 0; depth < 16 && layer.length > 0; depth++) { + const kids = await prisma.flashcardDeck.findMany({ + where: { parentDeckId: { in: layer } }, + select: { id: true }, + }) + layer = [] + for (const k of kids) { + if (!allDeckIds.has(k.id)) { + allDeckIds.add(k.id) + layer.push(k.id) + } + } + } + const statsByRootId = new Map() + for (const id of rootDeckIds) statsByRootId.set(id, { total: 0, newCards: 0, due: 0 }) + if (allDeckIds.size > 0) { + const deckRows = await prisma.flashcardDeck.findMany({ + where: { id: { in: [...allDeckIds] } }, + select: { id: true, parentDeckId: true }, + }) + const parentById = new Map(deckRows.map((d) => [d.id, d.parentDeckId])) + const rootByDeckId = new Map() + for (const d of deckRows) { + let cursor: string | null = d.id + while (cursor) { + const parent = parentById.get(cursor) ?? null + if (!parent) { + rootByDeckId.set(d.id, cursor) + break + } + cursor = parent + } + } + const flashcards = await prisma.flashcard.findMany({ + where: { deckId: { in: [...allDeckIds] } }, + select: { + deckId: true, + userProgress: { where: { userId }, select: { state: true, nextReviewAt: true } }, + }, + }) + const now = new Date() + for (const row of flashcards) { + const rootId = rootByDeckId.get(row.deckId) + if (!rootId) continue + const stats = statsByRootId.get(rootId) + if (!stats) continue + stats.total += 1 + const progress = row.userProgress[0] + const state = progress?.state ?? 'NEW' + if (state === 'NEW') stats.newCards += 1 + else if (progress?.nextReviewAt && progress.nextReviewAt <= now) stats.due += 1 + } + } + + const archivedDeckRows = rootDecks.map((deck) => ({ + id: deck.id, + name: deck.name, + subtitle: + deck.courseId != null + ? 'Course-linked deck' + : deck.tags?.length + ? deck.tags.map((t) => t.name).join(' · ') + : deck.description ?? '', + openHref: + deck.courseId != null + ? '/dashboard/flashcards' + : `/dashboard/flashcards?standaloneDeckSlug=${encodeURIComponent(deck.slug)}`, + stats: statsByRootId.get(deck.id) ?? { total: 0, newCards: 0, due: 0 }, + })) + + return ( +
+
+
+

Your profile

+

+ Manage archived courses and flashcard decks. +

+
+ +
+

+ Archived courses +

+ {archivedCourseItems.length > 0 ? ( + [course.id, ]), + )} + /> + ) : ( + + No archived courses yet. + + )} +
+ +
+

+ Archived flashcards +

+ {archivedDeckRows.length > 0 ? ( + [row.id, ]), + )} + /> + ) : ( + + No archived flashcard decks yet. + + )} +
+
+
+ ) +} diff --git a/LearningPlatform/app/actions/course-progress.ts b/LearningPlatform/app/actions/course-progress.ts index 7dd2202..2e5ffd2 100644 --- a/LearningPlatform/app/actions/course-progress.ts +++ b/LearningPlatform/app/actions/course-progress.ts @@ -29,7 +29,7 @@ export async function getAllCourseProgress() { } return prisma.courseProgress.findMany({ - where: { userId: session.user.id }, + where: { userId: session.user.id, archivedAt: null }, orderBy: { lastActivityAt: 'desc' }, }) } diff --git a/LearningPlatform/app/api/practice/session/route.ts b/LearningPlatform/app/api/practice/session/route.ts index ec6e70d..ee75b55 100644 --- a/LearningPlatform/app/api/practice/session/route.ts +++ b/LearningPlatform/app/api/practice/session/route.ts @@ -36,6 +36,15 @@ const MAX_LIMIT = 50 // ─── Helpers ───────────────────────────────────────────────────────────────── const normalise = (s: string) => s.toLowerCase().trim() +const courseIdFromTask = (task: any): string | null => { + const lesson = task?.lesson + if (!lesson || typeof lesson !== 'object') return null + const course = (lesson as { course?: unknown }).course + if (!course) return null + if (typeof course === 'string' || typeof course === 'number') return String(course) + if (typeof course === 'object' && 'id' in course) return String((course as { id: string | number }).id) + return null +} function payloadTaskTags(task: any): string[] { const tagObjects: Array> = task.tags ?? [] @@ -118,6 +127,15 @@ export async function GET(req: Request) { limit: CANDIDATE_LIMIT, depth: 1, }) + const archivedCourseRows = await prisma.courseProgress.findMany({ + where: { userId: user.id, archivedAt: { not: null } }, + select: { courseId: true }, + }) + const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId)) + const candidateTasks = (allTasks as any[]).filter((task) => { + const taskCourseId = courseIdFromTask(task) + return !taskCourseId || !archivedCourseIds.has(taskCourseId) + }) // Normalised task shape interface NTask { id: string; question: string; tags: string[] } @@ -134,9 +152,9 @@ export async function GET(req: Request) { return unsolved.length > 0 ? unsolved : pool } - const weakPool = preferUnsolved(shuffle((allTasks as any[]).map(toNTask).filter((t) => t.tags.some((tag) => weakTagSet.has(tag))))) - const mediumPool = preferUnsolved(shuffle((allTasks as any[]).map(toNTask).filter((t) => t.tags.some((tag) => mediumTagSet.has(tag))))) - const randomPool = preferUnsolved(shuffle((allTasks as any[]).map(toNTask))) + const weakPool = preferUnsolved(shuffle(candidateTasks.map(toNTask).filter((t) => t.tags.some((tag) => weakTagSet.has(tag))))) + const mediumPool = preferUnsolved(shuffle(candidateTasks.map(toNTask).filter((t) => t.tags.some((tag) => mediumTagSet.has(tag))))) + const randomPool = preferUnsolved(shuffle(candidateTasks.map(toNTask))) // ── Step 4: compose session ─────────────────────────────────────────────── // Target distribution: 40% weak, 30% medium, 30% random diff --git a/LearningPlatform/app/api/profile/archive/route.ts b/LearningPlatform/app/api/profile/archive/route.ts new file mode 100644 index 0000000..5a2461d --- /dev/null +++ b/LearningPlatform/app/api/profile/archive/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { requireAuth } from '@/lib/auth-helpers' + +const archiveBodySchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('course'), + courseSlug: z.string().min(1, 'courseSlug is required'), + archiveLinkedDeck: z.boolean().optional(), + }), + z.object({ + type: z.literal('deck'), + deckId: z.string().min(1, 'deckId is required'), + archiveLinkedCourse: z.boolean().optional(), + }), +]) + +/** POST /api/profile/archive — archive a course or main deck for the current user. */ +export async function POST(req: Request) { + try { + const user = await requireAuth() + const payload = await req.json().catch(() => null) + const parsed = archiveBodySchema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', issues: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ) + } + + const now = new Date() + if (parsed.data.type === 'course') { + const courseSlug = parsed.data.courseSlug.trim() + const courseRows = await prisma.$queryRaw>` + SELECT c.id::text AS id + FROM payload.courses c + WHERE c.slug = ${courseSlug} + AND c.is_published = TRUE + LIMIT 1 + ` + if (courseRows.length === 0) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + const courseId = courseRows[0].id + + await prisma.courseProgress.upsert({ + where: { userId_courseId: { userId: user.id, courseId } }, + create: { userId: user.id, courseId, archivedAt: now }, + update: { archivedAt: now, lastActivityAt: now }, + }) + + if (parsed.data.archiveLinkedDeck) { + const deck = await prisma.flashcardDeck.findFirst({ + where: { courseId, parentDeckId: null }, + select: { id: true }, + }) + if (deck) { + await prisma.userStandaloneFlashcardDeck.upsert({ + where: { userId_deckId: { userId: user.id, deckId: deck.id } }, + create: { userId: user.id, deckId: deck.id, archivedAt: now }, + update: { archivedAt: now }, + }) + } + } + return NextResponse.json({ ok: true }) + } + + const deck = await prisma.flashcardDeck.findUnique({ + where: { id: parsed.data.deckId }, + select: { id: true, courseId: true, parentDeckId: true }, + }) + if (!deck || deck.parentDeckId) { + return NextResponse.json({ error: 'Main deck not found' }, { status: 404 }) + } + + await prisma.userStandaloneFlashcardDeck.upsert({ + where: { userId_deckId: { userId: user.id, deckId: deck.id } }, + create: { userId: user.id, deckId: deck.id, archivedAt: now }, + update: { archivedAt: now }, + }) + + if (parsed.data.archiveLinkedCourse && deck.courseId) { + await prisma.courseProgress.upsert({ + where: { userId_courseId: { userId: user.id, courseId: deck.courseId } }, + create: { userId: user.id, courseId: deck.courseId, archivedAt: now }, + update: { archivedAt: now, lastActivityAt: now }, + }) + } + + return NextResponse.json({ ok: true }) + } catch (error) { + if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) { + return NextResponse.json({ error: error.message }, { status: error.message === 'Unauthorized' ? 401 : 403 }) + } + console.error('[POST /api/profile/archive]', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/LearningPlatform/app/api/profile/unarchive/route.ts b/LearningPlatform/app/api/profile/unarchive/route.ts new file mode 100644 index 0000000..bf27d8a --- /dev/null +++ b/LearningPlatform/app/api/profile/unarchive/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { requireAuth } from '@/lib/auth-helpers' + +const unarchiveBodySchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('course'), + courseId: z.string().min(1, 'courseId is required'), + }), + z.object({ + type: z.literal('deck'), + deckId: z.string().min(1, 'deckId is required'), + }), +]) + +/** POST /api/profile/unarchive — unarchive a course or deck for the current user. */ +export async function POST(req: Request) { + try { + const user = await requireAuth() + const payload = await req.json().catch(() => null) + const parsed = unarchiveBodySchema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', issues: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ) + } + + if (parsed.data.type === 'course') { + await prisma.courseProgress.updateMany({ + where: { userId: user.id, courseId: parsed.data.courseId }, + data: { archivedAt: null }, + }) + return NextResponse.json({ ok: true }) + } + + await prisma.userStandaloneFlashcardDeck.updateMany({ + where: { userId: user.id, deckId: parsed.data.deckId }, + data: { archivedAt: null }, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) { + return NextResponse.json({ error: error.message }, { status: error.message === 'Unauthorized' ? 401 : 403 }) + } + console.error('[POST /api/profile/unarchive]', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/LearningPlatform/app/api/recommend/tasks/route.ts b/LearningPlatform/app/api/recommend/tasks/route.ts index 54cafa1..65a3362 100644 --- a/LearningPlatform/app/api/recommend/tasks/route.ts +++ b/LearningPlatform/app/api/recommend/tasks/route.ts @@ -46,6 +46,15 @@ const BONUS_STALE = 0.2 // ─── Helpers ───────────────────────────────────────────────────────────────── const normalise = (s: string) => s.toLowerCase().trim() +const courseIdFromTask = (task: any): string | null => { + const lesson = task?.lesson + if (!lesson || typeof lesson !== 'object') return null + const course = (lesson as { course?: unknown }).course + if (!course) return null + if (typeof course === 'string' || typeof course === 'number') return String(course) + if (typeof course === 'object' && 'id' in course) return String((course as { id: string | number }).id) + return null +} function payloadTaskTags(task: any): string[] { const tagObjects: Array> = task.tags ?? [] @@ -260,15 +269,24 @@ export async function GET(req: Request) { limit: CANDIDATE_LIMIT, depth: 1, }) + const archivedCourseRows = await prisma.courseProgress.findMany({ + where: { userId: user.id, archivedAt: { not: null } }, + select: { courseId: true }, + }) + const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId)) + const candidateTasks = (allTasks as any[]).filter((task) => { + const taskCourseId = courseIdFromTask(task) + return !taskCourseId || !archivedCourseIds.has(taskCourseId) + }) let result: { tasks: ScoredTask[]; explanation: string } if (mode === 'review') { - result = await reviewMode(user.id, allTasks as any[], limit) + result = await reviewMode(user.id, candidateTasks, limit) } else if (mode === 'mixed') { - result = await mixedMode(user.id, allTasks as any[], limit) + result = await mixedMode(user.id, candidateTasks, limit) } else { - result = await weakMode(user.id, allTasks as any[], limit) + result = await weakMode(user.id, candidateTasks, limit) } return NextResponse.json({ ...result, mode }) diff --git a/LearningPlatform/components/dashboard/course-carousel.tsx b/LearningPlatform/components/dashboard/course-carousel.tsx index 28d7c1f..ea7ba14 100644 --- a/LearningPlatform/components/dashboard/course-carousel.tsx +++ b/LearningPlatform/components/dashboard/course-carousel.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { DashboardHorizontalScroll } from '@/components/dashboard/dashboard-horizontal-scroll' import { studentGlassCard, studentGlassPill } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' +import type { ReactNode } from 'react' export type CourseCarouselItem = { id: string | number @@ -35,9 +36,13 @@ const defaultProgress: CourseProgressSnapshot = { function CourseCarouselCard({ course, progress, + compact = false, + footerAction, }: { course: CourseCarouselItem progress: CourseProgressSnapshot + compact?: boolean + footerAction?: ReactNode }) { const { progressPercentage, completedLessons, totalLessons, hasStarted } = progress @@ -50,6 +55,7 @@ function CourseCarouselCard({ @@ -116,6 +122,7 @@ function CourseCarouselCard({ {hasStarted ? 'Continue learning' : 'Start course'} + {footerAction ?
{footerAction}
: null}
) @@ -125,11 +132,15 @@ export function CourseCarousel({ courses, progressByCourseId, scrollAriaLabel = 'Your courses', + compact = false, + footerActionByCourseId, }: { courses: CourseCarouselItem[] progressByCourseId: Record /** Passed to the horizontal scroller for accessibility. */ scrollAriaLabel?: string + compact?: boolean + footerActionByCourseId?: Record }) { if (courses.length === 0) return null @@ -142,7 +153,12 @@ export function CourseCarousel({ return (
- +
) @@ -159,6 +175,8 @@ export function CourseCarousel({ key={course.id} course={course} progress={progressFor(course)} + compact={compact} + footerAction={footerActionByCourseId?.[String(course.id)]} /> ))} diff --git a/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx b/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx new file mode 100644 index 0000000..552c43e --- /dev/null +++ b/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx @@ -0,0 +1,112 @@ +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { DashboardHorizontalScroll } from '@/components/dashboard/dashboard-horizontal-scroll' +import { Brain } from 'lucide-react' +import type { ReactNode } from 'react' +import { studentGlassCard } from '@/lib/student-glass-styles' +import { cn } from '@/lib/utils' + +export type FlashcardDeckCarouselRow = { + id: string + name: string + subtitle: string + openHref: string + stats: { total: number; newCards: number; due: number } +} + +function StatPill({ label, count }: { label: string; count: number }) { + return ( +
+ {count} + + {label} + +
+ ) +} + +function DeckCard({ + row, + compact, + action, +}: { + row: FlashcardDeckCarouselRow + compact?: boolean + action?: ReactNode +}) { + const canOpen = row.stats.total > 0 + return ( + + +
+

{row.name}

+ {row.subtitle ?

{row.subtitle}

: null} +
+
+ + + +
+
+ + + + {action ?
{action}
: null} +
+
+
+ ) +} + +export function FlashcardDeckCarousel({ + rows, + compact = false, + actionByDeckId, +}: { + rows: FlashcardDeckCarouselRow[] + compact?: boolean + actionByDeckId?: Record +}) { + if (rows.length === 0) return null + if (rows.length === 1) { + const row = rows[0] + return ( +
+
+ +
+
+ ) + } + return ( + + {rows.map((row) => ( + + ))} + + ) +} diff --git a/LearningPlatform/components/navbar.tsx b/LearningPlatform/components/navbar.tsx index 561447b..76b3fca 100644 --- a/LearningPlatform/components/navbar.tsx +++ b/LearningPlatform/components/navbar.tsx @@ -22,7 +22,7 @@ export async function Navbar() { {session ? ( <> {/* Order: user → theme → settings → (admin) → sign out */} -
+
{displayName ?? 'Account'} -
+ + ) +} + +export function ArchiveDeckButton({ deckId }: { deckId: string }) { + const router = useRouter() + const [pending, startTransition] = useTransition() + + return ( + + ) +} + +export function UnarchiveCourseButton({ courseId }: { courseId: string }) { + const router = useRouter() + const [pending, startTransition] = useTransition() + return ( + + ) +} + +export function UnarchiveDeckButton({ deckId }: { deckId: string }) { + const router = useRouter() + const [pending, startTransition] = useTransition() + return ( + + ) +} diff --git a/LearningPlatform/lib/flashcards-dashboard-summary.ts b/LearningPlatform/lib/flashcards-dashboard-summary.ts index b354597..1f637de 100644 --- a/LearningPlatform/lib/flashcards-dashboard-summary.ts +++ b/LearningPlatform/lib/flashcards-dashboard-summary.ts @@ -86,14 +86,19 @@ export async function getFlashcardDashboardSummary( const byDeckId = new Map() const startedRows = await prisma.courseProgress.findMany({ - where: { userId }, + where: { userId, archivedAt: null }, select: { courseId: true }, }) /** Table may be missing until migrations are applied — keep course summaries working. */ let enrollRows: { deckId: string }[] = [] + let deckArchiveRows: Array<{ deckId: string; archivedAt: Date | null }> = [] try { - enrollRows = await prisma.userStandaloneFlashcardDeck.findMany({ + deckArchiveRows = await prisma.userStandaloneFlashcardDeck.findMany({ where: { userId }, + select: { deckId: true, archivedAt: true }, + }) + enrollRows = await prisma.userStandaloneFlashcardDeck.findMany({ + where: { userId, archivedAt: null }, select: { deckId: true }, }) } catch (err) { @@ -102,6 +107,7 @@ export async function getFlashcardDashboardSummary( const startedCourseIds = [...new Set(startedRows.map((x) => x.courseId))] const enrolledMainDeckIds = [...new Set(enrollRows.map((e) => e.deckId))] + const archivedDeckIdSet = new Set(deckArchiveRows.filter((r) => r.archivedAt != null).map((r) => r.deckId)) const courseRows = startedCourseIds.length > 0 @@ -255,7 +261,7 @@ export async function getFlashcardDashboardSummary( childByParentId.set(deck.parentDeckId, list) } - const mainCourseDecks = allCourseDecks.filter((deck) => !deck.parentDeckId) + const mainCourseDecks = allCourseDecks.filter((deck) => !deck.parentDeckId && !archivedDeckIdSet.has(deck.id)) for (const mainDeck of mainCourseDecks) { const courseId = mainDeck.courseId ?? '' const course = publishedById.get(courseId) diff --git a/LearningPlatform/lib/started-courses.ts b/LearningPlatform/lib/started-courses.ts index bf04c1b..17a15a4 100644 --- a/LearningPlatform/lib/started-courses.ts +++ b/LearningPlatform/lib/started-courses.ts @@ -17,6 +17,12 @@ function courseIdFromLessonField(lesson: { id: string | number; course: unknown * with lesson progress IN_PROGRESS or COMPLETED. Ordered by most recent lesson activity. */ export async function getOrderedStartedCourseIds(userId: string, payload: Payload): Promise { + const archivedCourseRows = await prisma.courseProgress.findMany({ + where: { userId, archivedAt: { not: null } }, + select: { courseId: true }, + }) + const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId)) + const progressRows = await prisma.lessonProgress.findMany({ where: { userId, @@ -68,7 +74,7 @@ export async function getOrderedStartedCourseIds(userId: string, payload: Payloa const publishedSet = new Set(publishedCourses.map((c) => String(c.id))) return courseIds - .filter((id) => publishedSet.has(id)) + .filter((id) => publishedSet.has(id) && !archivedCourseIds.has(id)) .sort((a, b) => (courseLast.get(b)! - courseLast.get(a)!)) } diff --git a/LearningPlatform/prisma/migrations/20260425122000_add_archive_flags/migration.sql b/LearningPlatform/prisma/migrations/20260425122000_add_archive_flags/migration.sql new file mode 100644 index 0000000..b63109d --- /dev/null +++ b/LearningPlatform/prisma/migrations/20260425122000_add_archive_flags/migration.sql @@ -0,0 +1,12 @@ +-- Archive support for user course/deck visibility. +ALTER TABLE "course_progress" + ADD COLUMN "archivedAt" TIMESTAMP(3); + +ALTER TABLE "user_standalone_flashcard_decks" + ADD COLUMN "archivedAt" TIMESTAMP(3); + +CREATE INDEX "course_progress_userId_archivedAt_idx" + ON "course_progress"("userId", "archivedAt"); + +CREATE INDEX "user_standalone_flashcard_decks_userId_archivedAt_idx" + ON "user_standalone_flashcard_decks"("userId", "archivedAt"); diff --git a/LearningPlatform/prisma/schema.prisma b/LearningPlatform/prisma/schema.prisma index d87bf32..ee126dd 100644 --- a/LearningPlatform/prisma/schema.prisma +++ b/LearningPlatform/prisma/schema.prisma @@ -207,11 +207,13 @@ model CourseProgress { earnedPoints Int @default(0) enrolledAt DateTime @default(now()) lastActivityAt DateTime @default(now()) + archivedAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, courseId]) @@index([userId]) @@index([courseId]) + @@index([userId, archivedAt]) @@map("course_progress") } @@ -263,12 +265,14 @@ model UserStandaloneFlashcardDeck { userId String deckId String createdAt DateTime @default(now()) + archivedAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) deck FlashcardDeck @relation(fields: [deckId], references: [id], onDelete: Cascade) @@id([userId, deckId]) @@index([userId]) + @@index([userId, archivedAt]) @@map("user_standalone_flashcard_decks") } From 251a546a0db16136ddd3c97cbf9a7d4849fb59ad Mon Sep 17 00:00:00 2001 From: kinga Date: Sat, 25 Apr 2026 15:07:34 +0200 Subject: [PATCH 2/4] fix(Dockerfile): enhance npm ci command with retry logic for improved reliability during installation --- LearningPlatform/Dockerfile | 22 ++++++++++++++++++- .../app/(student)/(shell)/profile/page.tsx | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/LearningPlatform/Dockerfile b/LearningPlatform/Dockerfile index 0822153..dc06098 100644 --- a/LearningPlatform/Dockerfile +++ b/LearningPlatform/Dockerfile @@ -26,7 +26,27 @@ COPY prisma.config.ts ./ # This avoids running `prisma generate` before the full source (including schema) # is copied into the image. We'll run `prisma generate` explicitly later when # the schema file is present. -RUN npm ci --no-audit --no-fund --ignore-scripts +RUN set -eux; \ + RETRIES=5; \ + COUNT=0; \ + until [ "$COUNT" -ge "$RETRIES" ]; do \ + npm ci \ + --no-audit \ + --no-fund \ + --ignore-scripts \ + --fetch-retries=5 \ + --fetch-retry-factor=2 \ + --fetch-retry-mintimeout=10000 \ + --fetch-retry-maxtimeout=120000 && break || true; \ + COUNT=$((COUNT+1)); \ + echo "npm ci failed, retrying ($COUNT/$RETRIES)"; \ + npm cache verify || true; \ + sleep 5; \ + done; \ + if [ "$COUNT" -ge "$RETRIES" ]; then \ + echo "npm ci failed after $RETRIES attempts"; \ + exit 1; \ + fi # Copy rest of source and run build COPY . . diff --git a/LearningPlatform/app/(student)/(shell)/profile/page.tsx b/LearningPlatform/app/(student)/(shell)/profile/page.tsx index 4d147dd..27bc073 100644 --- a/LearningPlatform/app/(student)/(shell)/profile/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/profile/page.tsx @@ -114,7 +114,7 @@ export default async function ProfilePage() { for (const d of deckRows) { let cursor: string | null = d.id while (cursor) { - const parent = parentById.get(cursor) ?? null + const parent: string | null = parentById.get(cursor) ?? null if (!parent) { rootByDeckId.set(d.id, cursor) break From f4655f9781ac499e5e92826d0db669e09da6c7e8 Mon Sep 17 00:00:00 2001 From: kinga Date: Sat, 25 Apr 2026 15:51:43 +0200 Subject: [PATCH 3/4] refactor(flashcards): update documentation and import logic to use courseSlug and moduleSlug instead of courseId and moduleId for improved clarity and consistency --- .../documentation/prompts/creation_prompt.MD | 37 +++++++----- .../prompts/standalone_flashcards_prompt.MD | 6 +- .../imports/helpers/flashcard-import.js | 15 ++++- .../imports/helpers/next-env-default-shim.cjs | 10 ++++ .../scripts/imports/helpers/payload-client.js | 59 +++++++------------ 5 files changed, 70 insertions(+), 57 deletions(-) create mode 100644 LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs diff --git a/LearningPlatform/documentation/prompts/creation_prompt.MD b/LearningPlatform/documentation/prompts/creation_prompt.MD index 463987b..8cd2d38 100644 --- a/LearningPlatform/documentation/prompts/creation_prompt.MD +++ b/LearningPlatform/documentation/prompts/creation_prompt.MD @@ -360,11 +360,11 @@ Tasks go in the `tasks` array of each lesson. **Location:** `/data/flashcards/` **When:** Only if the user answered **Yes** to flashcards in discovery; otherwise skip all flashcard files. -**Platform rule:** For a **course** tree, the **main** deck row is structural only — use **`cards: []`**. Every study card lives on a **module subdeck** (`parentDeckSlug` + same `courseId` as main + **`moduleId`** for that module). The importer accepts **`module.exports = [{ deck, cards }, …]`** in one `.js` file (one object per module in that chunk). +**Platform rule:** For a **course** tree, the **main** deck row is structural only - use **`cards: []`**. Every study card lives on a **module subdeck** (`parentDeckSlug` + same `courseSlug` as main + **`moduleSlug`** or `moduleOrder` for that module). The importer accepts **`module.exports = [{ deck, cards }, …]`** in one `.js` file (one object per module in that chunk). **Files to create (aligned with course chunks):** -1. **`[topic]-main-deck.js`** — single object `{ deck, cards }`: main has `courseId: '__COURSE_ID__'`, same `slug` you use as every subdeck’s `parentDeckSlug`, **`cards: []`**. -2. **`[topic]-subdecks-ptN.js`** — **array** of `{ deck, cards }`, one entry per **module** in that course chunk; each `deck` has `parentDeckSlug`, `courseId`, **`moduleId`** matching the module from the matching course file. +1. **`[topic]-main-deck.js`** - single object `{ deck, cards }`: main has `courseSlug: '[course-slug]'`, same `slug` you use as every subdeck’s `parentDeckSlug`, **`cards: []`**. +2. **`[topic]-subdecks-ptN.js`** - **array** of `{ deck, cards }`, one entry per **module** in that course chunk; each `deck` has `parentDeckSlug`, `courseSlug`, and **`moduleSlug`** (preferred) or `moduleOrder` matching the module from the matching course file. **Import order:** Tags → courses → modules → flashcards. Name the main deck file so it **sorts before** `subdecks-pt*.js` (e.g. `docker-main-deck.js` before `docker-subdecks-pt1.js`) so the parent exists when subdecks import in one run. @@ -376,7 +376,7 @@ module.exports = { name: 'Docker — course deck', description: 'Parent for module subdecks.', tagSlugs: ['docker', 'beginner'], - courseId: '__COURSE_ID__', + courseSlug: 'docker-fundamentals', }, cards: [], } @@ -390,8 +390,8 @@ module.exports = [ slug: 'docker-module-1-subdeck', name: 'Module 1 — Introduction', parentDeckSlug: 'docker-main-deck', - courseId: '__COURSE_ID__', - moduleId: '__MODULE_1_ID__', + courseSlug: 'docker-fundamentals', + moduleSlug: 'docker-module-1-introduction', tagSlugs: ['docker', 'beginner'], }, cards: [ @@ -407,8 +407,8 @@ module.exports = [ slug: 'docker-module-2-subdeck', name: 'Module 2 — Images and containers', parentDeckSlug: 'docker-main-deck', - courseId: '__COURSE_ID__', - moduleId: '__MODULE_2_ID__', + courseSlug: 'docker-fundamentals', + moduleSlug: 'docker-module-2-images-and-containers', tagSlugs: ['docker', 'images'], }, cards: [ @@ -422,7 +422,9 @@ module.exports = [ ] ``` -**`deck` fields (course-linked):** `slug`, `name`, optional `description`, optional `tagSlugs`, **`courseId`** (Payload course id). On subdecks only: **`parentDeckSlug`** (main slug), **`moduleId`** (Payload module id for that subdeck). +**`deck` fields (course-linked):** `slug`, `name`, optional `description`, optional `tagSlugs`, **`courseSlug`**. On subdecks only: **`parentDeckSlug`** (main slug), plus **`moduleSlug`** (preferred) or `moduleOrder`. + +**Hard rule (critical):** Never generate or invent database UUIDs for `courseId` / `moduleId`, and do not use placeholders like `__COURSE_ID__` / `__MODULE_1_ID__`. **`cards` fields:** `question`, `answer`, `tagSlugs` (2–4 slugs; import tags first). @@ -514,9 +516,9 @@ Based on discovery answers, create a **detailed outline** showing: ... ### Flashcards *(omit this whole subsection if user chose No flashcards)*: -- **Main deck file:** `scripts/imports/data/flashcards/[topic]-main-deck.js` — `{ deck, cards }` with **`courseId`**, **`cards: []`** +- **Main deck file:** `scripts/imports/data/flashcards/[topic]-main-deck.js` - `{ deck, cards }` with **`courseSlug`**, **`cards: []`** - **Chunk subdeck files:** `scripts/imports/data/flashcards/[topic]-subdecks-pt1.js`, `...-pt2.js`, etc. — **`module.exports = [{ deck, cards }, …]`** (one block per module) -- **Rule:** one subdeck per module; every subdeck uses **`parentDeckSlug`** = main deck slug, plus **`courseId`** and **`moduleId`** +- **Rule:** one subdeck per module; every subdeck uses **`parentDeckSlug`** = main deck slug, plus **`courseSlug`** and **`moduleSlug`** (or `moduleOrder`) ``` **Get user approval** on this structure before generating files! @@ -974,7 +976,7 @@ module.exports = { name: 'Docker - Main Course Deck', description: 'Parent deck for module subdecks.', tagSlugs: ['docker', 'containerization', 'beginner'], - courseId: '__COURSE_ID__', + courseSlug: 'docker-fundamentals', }, cards: [], } @@ -1052,7 +1054,7 @@ Before showing any file to the user, verify: - [ ] Exported as object with `module.exports` ## Flashcards Files *(skip whole checklist if user chose No flashcards)* -- [ ] Main deck file exists with stable `deck.slug` and **`courseId`**; **`cards: []`** +- [ ] Main deck file exists with stable `deck.slug` and **`courseSlug`**; **`cards: []`** - [ ] Subdeck chunk file exists for each generated module chunk - [ ] Exactly one subdeck per module in each chunk - [ ] Every subdeck has `parentDeckSlug` pointing to the same main deck @@ -1121,8 +1123,13 @@ Before showing any file to the user, verify: - For each generated module chunk, ensure a matching subdeck chunk file exists 13. **Don't put flashcard questions on the course main deck** - - Main file must use **`cards: []`** and set **`courseId`** - - All cards belong on **subdeck** entries (`moduleId` + `parentDeckSlug` + `courseId`) + - Main file must use **`cards: []`** and set **`courseSlug`** + - All cards belong on **subdeck** entries (`moduleSlug` or `moduleOrder` + `parentDeckSlug` + `courseSlug`) + +14. **Don't use DB UUID fields in generated flashcard files** + - Do not generate `courseId` / `moduleId` values + - Do not leave placeholders like `__COURSE_ID__` / `__MODULE_X_ID__` + - Always use `courseSlug` + `moduleSlug` (or `moduleOrder`) # PART 8: RESPONSE FORMAT diff --git a/LearningPlatform/documentation/prompts/standalone_flashcards_prompt.MD b/LearningPlatform/documentation/prompts/standalone_flashcards_prompt.MD index c0da160..692ccd8 100644 --- a/LearningPlatform/documentation/prompts/standalone_flashcards_prompt.MD +++ b/LearningPlatform/documentation/prompts/standalone_flashcards_prompt.MD @@ -12,11 +12,13 @@ You output **only** CommonJS files: `module.exports = …`. No HTML, React, cour - Required: `slug` (kebab-case, unique), `name`. - Optional: `description`, `tagSlugs` (array of existing tag slugs). - Optional: `subjectId` — string id for the catalog subject field; if you do not have one, omit or use `__SUBJECT_ID__` and leave a one-line note for the human to replace it. -- **Do not set:** `courseId`, `parentDeckSlug`, or `moduleId` on the root deck. +- **Do not set:** `courseId`, `moduleId` on generated output. +- If deck is linked to a course tree, use `courseSlug` (not `courseId`). **Deck `deck` object — child deck (optional split)** - Same as root, plus **`parentDeckSlug`** = the root deck’s `slug`. +- For course-linked subdecks, use `courseSlug` plus `moduleSlug` (preferred) or `moduleOrder`. - Still **do not set** `courseId` or `moduleId`. **Cards** @@ -75,3 +77,5 @@ module.exports = [ **Before you write:** if the user did not give topic, approximate card count, or whether to use one file vs root+chunk, ask in one short message. **Before you finish:** every `slug` is unique across your files; every `tagSlugs` value exists in your tags file or was already valid; every file ends with a single `module.exports`. + +**Hard safety rule:** Never invent DB UUIDs and never emit placeholder IDs such as `__COURSE_ID__` or `__MODULE_1_ID__`. Use slugs and module order keys only. diff --git a/LearningPlatform/scripts/imports/helpers/flashcard-import.js b/LearningPlatform/scripts/imports/helpers/flashcard-import.js index 660ba1f..8883655 100644 --- a/LearningPlatform/scripts/imports/helpers/flashcard-import.js +++ b/LearningPlatform/scripts/imports/helpers/flashcard-import.js @@ -73,10 +73,20 @@ async function upsertFlashcardDeck(prisma, spec, { dryRun }) { parentDeckId = parent.id } - const existing = await prisma.flashcardDeck.findUnique({ + const existingBySlug = await prisma.flashcardDeck.findUnique({ where: { slug }, include: { tags: true }, }) + const existingByModule = + moduleId != null + ? await prisma.flashcardDeck.findUnique({ + where: { moduleId }, + include: { tags: true }, + }) + : null + const existing = + existingBySlug ?? + (existingByModule && existingByModule.parentDeckId === parentDeckId ? existingByModule : null) if (dryRun) { // Per-slug placeholder so parallel dry-run decks do not share one id (dedupe keys stay distinct) @@ -102,8 +112,9 @@ async function upsertFlashcardDeck(prisma, spec, { dryRun }) { } await prisma.flashcardDeck.update({ - where: { slug }, + where: { id: existing.id }, data: { + slug, name, description, subjectId, diff --git a/LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs b/LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs new file mode 100644 index 0000000..54a401f --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs @@ -0,0 +1,10 @@ +// Shim for environments where transpiled code expects `@next/env` default export. +try { + // eslint-disable-next-line global-require + const nextEnv = require('@next/env') + if (nextEnv && nextEnv.default == null) { + nextEnv.default = nextEnv + } +} catch { + // noop +} diff --git a/LearningPlatform/scripts/imports/helpers/payload-client.js b/LearningPlatform/scripts/imports/helpers/payload-client.js index 415d89c..2042d92 100644 --- a/LearningPlatform/scripts/imports/helpers/payload-client.js +++ b/LearningPlatform/scripts/imports/helpers/payload-client.js @@ -1,29 +1,6 @@ -const fs = require('fs') const path = require('path') const { pathToFileURL } = require('url') -function ensureScriptsTsconfigEnv() { - const scriptsTsconfig = path.resolve(__dirname, '../../../tsconfig.scripts.json') - if (!process.env.TSX_TSCONFIG_PATH) { - process.env.TSX_TSCONFIG_PATH = scriptsTsconfig - } -} - -/** - * Plain `node` does not execute TypeScript on `import()`. Use tsx's scoped require so - * `payload.config.ts` and `@payload-config` resolve like `tsx --tsconfig tsconfig.scripts.json`. - */ -function tryLoadPayloadConfigWithTsxRequire() { - ensureScriptsTsconfigEnv() - const { require: tsxRequire } = require('tsx/cjs/api') - const base = path.join(__dirname, '../../../src/payload/payload.config') - const tsPath = `${base}.ts` - if (fs.existsSync(tsPath)) { - return tsxRequire(tsPath, __filename) - } - return tsxRequire('@payload-config', __filename) -} - async function unwrapConfig(candidate) { let value = candidate @@ -47,25 +24,15 @@ async function unwrapConfig(candidate) { } async function loadPayloadConfig() { - const base = path.join(__dirname, '../../../src/payload/payload.config') - const candidates = [`${base}.ts`, `${base}.js`].map((p) => pathToFileURL(p).href) - - for (const href of candidates) { - try { - const mod = await import(href) - return await unwrapConfig(mod) - } catch { - // try next - } - } - + const configJsPath = path.join(__dirname, '../../../src/payload/payload.config.js') try { - const mod = tryLoadPayloadConfigWithTsxRequire() + const mod = await import(pathToFileURL(configJsPath).href) return await unwrapConfig(mod) } catch (err) { + const errDetails = err?.stack || err?.message || String(err) throw new Error( - 'Could not load Payload config. Install devDependency `tsx` and use tsconfig.scripts.json (see documentation/CONTENT_IMPORTS.md). ' + - `Last error: ${err?.message || err}`, + 'Could not load Payload config via src/payload/payload.config.js import path. ' + + `Last error: ${errDetails}`, ) } } @@ -83,7 +50,21 @@ async function initPayloadClient(payloadSecret) { process.env.PAYLOAD_MIGRATING = 'true' } - const { getPayload } = await import('payload') + // Payload's CJS loadEnv helper expects `require('@next/env').default`. + // Newer @next/env exports only named symbols, so we add a compatible default alias. + try { + // eslint-disable-next-line global-require + const nextEnv = require('@next/env') + if (nextEnv && nextEnv.default == null) { + // eslint-disable-next-line no-param-reassign + nextEnv.default = nextEnv + } + } catch { + // no-op: importer will fail later with a clearer message if @next/env is missing + } + + // eslint-disable-next-line global-require + const { getPayload } = require('payload') const config = await loadPayloadConfig() const effectiveConfig = config?.secret ? config : { ...config, secret: payloadSecret } From 60f777078dea045105b8cc69a2577b0c792339ee Mon Sep 17 00:00:00 2001 From: kinga Date: Sat, 25 Apr 2026 16:22:25 +0200 Subject: [PATCH 4/4] feat(course-progress): implement auto-unarchive functionality for courses and decks when user resumes learning, enhancing user experience --- .../[slug]/lessons/[lessonId]/page.tsx | 9 + .../app/api/flashcards/study/route.ts | 51 +++++ .../app/api/practice/session/route.ts | 12 +- .../app/api/recommend/tasks/route.ts | 12 +- .../admin/assign-to-lesson-dialog.tsx | 6 +- .../dashboard/flashcard-deck-carousel.tsx | 19 +- .../components/profile/archive-actions.tsx | 174 +++++++++++------- .../lib/flashcards-dashboard-summary.ts | 17 +- LearningPlatform/lib/payload-task-helpers.ts | 15 ++ LearningPlatform/lib/started-courses.ts | 11 +- .../imports/helpers/next-env-default-shim.cjs | 10 - .../scripts/imports/helpers/payload-client.js | 30 ++- 12 files changed, 248 insertions(+), 118 deletions(-) create mode 100644 LearningPlatform/lib/payload-task-helpers.ts delete mode 100644 LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs diff --git a/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx b/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx index 775be82..a13b957 100644 --- a/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -116,6 +116,15 @@ export default async function LessonPage({ prisma.lessonProgress.findUnique({ where: { userId_lessonId: { userId: session.user.id, lessonId } }, }), + // If the user starts learning this course again, restore it to active. + prisma.courseProgress.updateMany({ + where: { + userId: session.user.id, + courseId: String(courseId), + archivedAt: { not: null }, + }, + data: { archivedAt: null }, + }), ]) taskProgressRecords.forEach((tp) => taskProgressMap.set(tp.taskId, tp)) lessonProgress = lessonProgressRecord diff --git a/LearningPlatform/app/api/flashcards/study/route.ts b/LearningPlatform/app/api/flashcards/study/route.ts index 5b01631..3d69803 100644 --- a/LearningPlatform/app/api/flashcards/study/route.ts +++ b/LearningPlatform/app/api/flashcards/study/route.ts @@ -129,6 +129,7 @@ export async function GET(req: Request) { whereFilter = whereFilter ? { AND: [whereFilter, mainDeckPart] } : mainDeckPart } + let validatedMainDeckId: string | null = null if (mainDeckSlugQ && !deckFilterSlug) { // Ensure main deck exists and is not itself a subdeck, otherwise we might silently return unrelated data. const mainDeck = await prisma.flashcardDeck.findUnique({ @@ -141,6 +142,7 @@ export async function GET(req: Request) { { status: 400 }, ) } + validatedMainDeckId = mainDeck.id } if (deckFilterSlug || mainDeckSlugQ) { @@ -154,6 +156,55 @@ export async function GET(req: Request) { { status: 403 }, ) } + + // Auto-unarchive when the user starts studying this deck scope again. + let rootDeck: { id: string; courseId: string | null } | null = null + if (validatedMainDeckId) { + rootDeck = await prisma.flashcardDeck.findUnique({ + where: { id: validatedMainDeckId }, + select: { id: true, courseId: true }, + }) + } else if (deckFilterSlug) { + const deck = await prisma.flashcardDeck.findUnique({ + where: { slug: deckFilterSlug }, + select: { id: true, parentDeckId: true, courseId: true }, + }) + if (deck) { + if (deck.parentDeckId) { + rootDeck = await prisma.flashcardDeck.findUnique({ + where: { id: deck.parentDeckId }, + select: { id: true, courseId: true }, + }) + } else { + rootDeck = { id: deck.id, courseId: deck.courseId } + } + } + } + + if (rootDeck) { + if (typeof prisma.userStandaloneFlashcardDeck?.updateMany === 'function') { + await prisma.userStandaloneFlashcardDeck.updateMany({ + where: { + userId: user.id, + deckId: rootDeck.id, + archivedAt: { not: null }, + }, + data: { archivedAt: null }, + }) + } + if (rootDeck.courseId) { + if (typeof prisma.courseProgress?.updateMany === 'function') { + await prisma.courseProgress.updateMany({ + where: { + userId: user.id, + courseId: rootDeck.courseId, + archivedAt: { not: null }, + }, + data: { archivedAt: null }, + }) + } + } + } } const flashcards = await prisma.flashcard.findMany({ diff --git a/LearningPlatform/app/api/practice/session/route.ts b/LearningPlatform/app/api/practice/session/route.ts index ee75b55..0428237 100644 --- a/LearningPlatform/app/api/practice/session/route.ts +++ b/LearningPlatform/app/api/practice/session/route.ts @@ -6,6 +6,7 @@ import { getUserTagStats, getUserWeakTags } from '@/lib/analytics' import { prisma } from '@/lib/prisma' import { randomUUID } from 'crypto' import { extractText } from '@/lib/lexical' +import { courseIdFromPayloadTask } from '@/lib/payload-task-helpers' /** * GET /api/practice/session?limit=10 @@ -36,15 +37,6 @@ const MAX_LIMIT = 50 // ─── Helpers ───────────────────────────────────────────────────────────────── const normalise = (s: string) => s.toLowerCase().trim() -const courseIdFromTask = (task: any): string | null => { - const lesson = task?.lesson - if (!lesson || typeof lesson !== 'object') return null - const course = (lesson as { course?: unknown }).course - if (!course) return null - if (typeof course === 'string' || typeof course === 'number') return String(course) - if (typeof course === 'object' && 'id' in course) return String((course as { id: string | number }).id) - return null -} function payloadTaskTags(task: any): string[] { const tagObjects: Array> = task.tags ?? [] @@ -133,7 +125,7 @@ export async function GET(req: Request) { }) const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId)) const candidateTasks = (allTasks as any[]).filter((task) => { - const taskCourseId = courseIdFromTask(task) + const taskCourseId = courseIdFromPayloadTask(task) return !taskCourseId || !archivedCourseIds.has(taskCourseId) }) diff --git a/LearningPlatform/app/api/recommend/tasks/route.ts b/LearningPlatform/app/api/recommend/tasks/route.ts index 65a3362..db3e549 100644 --- a/LearningPlatform/app/api/recommend/tasks/route.ts +++ b/LearningPlatform/app/api/recommend/tasks/route.ts @@ -5,6 +5,7 @@ import { requireAuth } from '@/lib/auth-helpers' import { getUserWeakTags } from '@/lib/analytics' import { prisma } from '@/lib/prisma' import { extractText } from '@/lib/lexical' +import { courseIdFromPayloadTask } from '@/lib/payload-task-helpers' /** * GET /api/recommend/tasks?limit=5&mode=weak|review|mixed @@ -46,15 +47,6 @@ const BONUS_STALE = 0.2 // ─── Helpers ───────────────────────────────────────────────────────────────── const normalise = (s: string) => s.toLowerCase().trim() -const courseIdFromTask = (task: any): string | null => { - const lesson = task?.lesson - if (!lesson || typeof lesson !== 'object') return null - const course = (lesson as { course?: unknown }).course - if (!course) return null - if (typeof course === 'string' || typeof course === 'number') return String(course) - if (typeof course === 'object' && 'id' in course) return String((course as { id: string | number }).id) - return null -} function payloadTaskTags(task: any): string[] { const tagObjects: Array> = task.tags ?? [] @@ -275,7 +267,7 @@ export async function GET(req: Request) { }) const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId)) const candidateTasks = (allTasks as any[]).filter((task) => { - const taskCourseId = courseIdFromTask(task) + const taskCourseId = courseIdFromPayloadTask(task) return !taskCourseId || !archivedCourseIds.has(taskCourseId) }) diff --git a/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx b/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx index 5647f7f..16e044f 100644 --- a/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx +++ b/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx @@ -70,7 +70,11 @@ export function AssignToLessonDialog({ const now = Date.now() // During tests, skip the module cache so tests that stub `fetch` observe // the mocked responses instead of stale cached data from other tests. - const cacheValid = _cache && now - _cache.ts < CACHE_TTL_MS && process.env.NODE_ENV !== 'test' + const cacheValid = + _cache && + now - _cache.ts < CACHE_TTL_MS && + process.env.NODE_ENV !== 'test' && + process.env.VITEST !== 'true' function applyExpansion(courseList: CourseItem[]) { if (currentLessonIds.length > 0) { diff --git a/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx b/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx index 552c43e..d4ac6e3 100644 --- a/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx +++ b/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx @@ -1,3 +1,5 @@ +'use client' + import Link from 'next/link' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -60,17 +62,28 @@ function DeckCard({
- + {canOpen ? ( + + + + ) : ( - + )} {action ?
{action}
: null}
diff --git a/LearningPlatform/components/profile/archive-actions.tsx b/LearningPlatform/components/profile/archive-actions.tsx index d20c517..8e5c8cb 100644 --- a/LearningPlatform/components/profile/archive-actions.tsx +++ b/LearningPlatform/components/profile/archive-actions.tsx @@ -1,6 +1,6 @@ 'use client' -import { useTransition } from 'react' +import { useState, useTransition } from 'react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' @@ -19,99 +19,135 @@ async function postJson(url: string, body: unknown) { export function ArchiveCourseButton({ courseSlug }: { courseSlug: string }) { const router = useRouter() const [pending, startTransition] = useTransition() + const [error, setError] = useState(null) return ( - + }} + > + {pending ? 'Archiving...' : 'Archive course'} + + {error ?

{error}

: null} + ) } export function ArchiveDeckButton({ deckId }: { deckId: string }) { const router = useRouter() const [pending, startTransition] = useTransition() + const [error, setError] = useState(null) return ( - + }} + > + {pending ? 'Archiving...' : 'Archive deck'} + + {error ?

{error}

: null} + ) } export function UnarchiveCourseButton({ courseId }: { courseId: string }) { const router = useRouter() const [pending, startTransition] = useTransition() + const [error, setError] = useState(null) return ( - +
+ + {error ?

{error}

: null} +
) } export function UnarchiveDeckButton({ deckId }: { deckId: string }) { const router = useRouter() const [pending, startTransition] = useTransition() + const [error, setError] = useState(null) return ( - +
+ + {error ?

{error}

: null} +
) } diff --git a/LearningPlatform/lib/flashcards-dashboard-summary.ts b/LearningPlatform/lib/flashcards-dashboard-summary.ts index 1f637de..94e2cda 100644 --- a/LearningPlatform/lib/flashcards-dashboard-summary.ts +++ b/LearningPlatform/lib/flashcards-dashboard-summary.ts @@ -85,10 +85,19 @@ export async function getFlashcardDashboardSummary( const all: FlashcardDashboardStats = { total: 0, newCards: 0, due: 0 } const byDeckId = new Map() - const startedRows = await prisma.courseProgress.findMany({ - where: { userId, archivedAt: null }, - select: { courseId: true }, - }) + let startedRows: { courseId: string }[] = [] + try { + startedRows = await prisma.courseProgress.findMany({ + where: { userId, archivedAt: null }, + select: { courseId: true }, + }) + } catch (err) { + console.warn('[getFlashcardDashboardSummary] archived course filter unavailable, using fallback query', err) + startedRows = await prisma.courseProgress.findMany({ + where: { userId }, + select: { courseId: true }, + }) + } /** Table may be missing until migrations are applied — keep course summaries working. */ let enrollRows: { deckId: string }[] = [] let deckArchiveRows: Array<{ deckId: string; archivedAt: Date | null }> = [] diff --git a/LearningPlatform/lib/payload-task-helpers.ts b/LearningPlatform/lib/payload-task-helpers.ts new file mode 100644 index 0000000..1dd64e1 --- /dev/null +++ b/LearningPlatform/lib/payload-task-helpers.ts @@ -0,0 +1,15 @@ +export function courseIdFromPayloadTask(task: unknown): string | null { + const lesson = (task as { lesson?: unknown } | null)?.lesson + if (!lesson || typeof lesson !== 'object') return null + + const course = (lesson as { course?: unknown }).course + if (!course) return null + + if (typeof course === 'string' || typeof course === 'number') { + return String(course) + } + if (typeof course === 'object' && 'id' in course) { + return String((course as { id: string | number }).id) + } + return null +} diff --git a/LearningPlatform/lib/started-courses.ts b/LearningPlatform/lib/started-courses.ts index 17a15a4..f69f255 100644 --- a/LearningPlatform/lib/started-courses.ts +++ b/LearningPlatform/lib/started-courses.ts @@ -17,10 +17,13 @@ function courseIdFromLessonField(lesson: { id: string | number; course: unknown * with lesson progress IN_PROGRESS or COMPLETED. Ordered by most recent lesson activity. */ export async function getOrderedStartedCourseIds(userId: string, payload: Payload): Promise { - const archivedCourseRows = await prisma.courseProgress.findMany({ - where: { userId, archivedAt: { not: null } }, - select: { courseId: true }, - }) + const archivedCourseRows = + typeof prisma.courseProgress?.findMany === 'function' + ? await prisma.courseProgress.findMany({ + where: { userId, archivedAt: { not: null } }, + select: { courseId: true }, + }) + : [] const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId)) const progressRows = await prisma.lessonProgress.findMany({ diff --git a/LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs b/LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs deleted file mode 100644 index 54a401f..0000000 --- a/LearningPlatform/scripts/imports/helpers/next-env-default-shim.cjs +++ /dev/null @@ -1,10 +0,0 @@ -// Shim for environments where transpiled code expects `@next/env` default export. -try { - // eslint-disable-next-line global-require - const nextEnv = require('@next/env') - if (nextEnv && nextEnv.default == null) { - nextEnv.default = nextEnv - } -} catch { - // noop -} diff --git a/LearningPlatform/scripts/imports/helpers/payload-client.js b/LearningPlatform/scripts/imports/helpers/payload-client.js index 2042d92..80d12f5 100644 --- a/LearningPlatform/scripts/imports/helpers/payload-client.js +++ b/LearningPlatform/scripts/imports/helpers/payload-client.js @@ -1,5 +1,6 @@ const path = require('path') const { pathToFileURL } = require('url') +const { createRequire } = require('module') async function unwrapConfig(candidate) { let value = candidate @@ -24,17 +25,32 @@ async function unwrapConfig(candidate) { } async function loadPayloadConfig() { - const configJsPath = path.join(__dirname, '../../../src/payload/payload.config.js') + const base = path.join(__dirname, '../../../src/payload/payload.config') + const candidates = [`${base}.ts`, `${base}.js`] + const errors = [] + + for (const candidatePath of candidates) { + try { + const mod = await import(pathToFileURL(candidatePath).href) + return await unwrapConfig(mod) + } catch (err) { + errors.push(`${candidatePath}: ${err?.message || String(err)}`) + } + } + + // Fallback used by Next path alias in this repo. try { - const mod = await import(pathToFileURL(configJsPath).href) + const req = createRequire(__filename) + const mod = req('@payload-config') return await unwrapConfig(mod) } catch (err) { - const errDetails = err?.stack || err?.message || String(err) - throw new Error( - 'Could not load Payload config via src/payload/payload.config.js import path. ' + - `Last error: ${errDetails}`, - ) + errors.push(`@payload-config: ${err?.message || String(err)}`) } + + throw new Error( + 'Could not load Payload config. Install devDependency `tsx` and use tsconfig.scripts.json (see documentation/CONTENT_IMPORTS.md). ' + + `Tried: ${errors.join(' | ')}`, + ) } async function initPayloadClient(payloadSecret) {