diff --git a/.github/workflows/pull_request_template.md b/.github/workflows/pull_request_template.md index e69de29..0f23d72 100644 --- a/.github/workflows/pull_request_template.md +++ b/.github/workflows/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + +Describe the purpose of this PR. + +## Changes + +- +- +- + +## Verification + +- [ ] pnpm lint +- [ ] pnpm typecheck +- [ ] pnpm build + +## Manual Smoke Tests + +- [ ] auth flow +- [ ] onboarding flow +- [ ] dashboard flow diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 158fc34..5f2c018 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,18 +1,18 @@ -# Code of Conduct - -## Our Standards - -Contributors should: - -* be respectful -* provide constructive feedback -* collaborate professionally -* avoid harassment or discrimination - -## Expectations - -This repository is intended for professional startup-grade software development. - -## Enforcement - -Project maintainers may remove comments, pull requests, or contributions that violate these expectations. +# Code of Conduct + +## Our Standards + +Contributors should: + +- be respectful +- provide constructive feedback +- collaborate professionally +- avoid harassment or discrimination + +## Expectations + +This repository is intended for professional startup-grade software development. + +## Enforcement + +Project maintainers may remove comments, pull requests, or contributions that violate these expectations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17ae56e..9e1bc46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,47 +1,47 @@ -# Contributing - -Thank you for contributing to LearnDojoWorld. - -## Workflow - -1. Create a branch from `main` -2. Make focused changes -3. Run quality checks -4. Open a pull request -5. Wait for CI checks before merge - -## Branch Naming - -* `feature/*` -* `fix/*` -* `chore/*` -* `docs/*` - -## Required Checks - -Before pushing: - -```bash -pnpm lint -pnpm typecheck -pnpm build -``` - -## Pull Requests - -PRs should: - -* remain focused -* avoid unrelated changes -* include smoke-test notes -* pass CI checks - -## Security - -Never commit: - -* `.env` -* API keys -* tokens -* passwords -* production secrets +# Contributing + +Thank you for contributing to LearnDojoWorld. + +## Workflow + +1. Create a branch from `main` +2. Make focused changes +3. Run quality checks +4. Open a pull request +5. Wait for CI checks before merge + +## Branch Naming + +- `feature/*` +- `fix/*` +- `chore/*` +- `docs/*` + +## Required Checks + +Before pushing: + +```bash +pnpm lint +pnpm typecheck +pnpm build +``` + +## Pull Requests + +PRs should: + +- remain focused +- avoid unrelated changes +- include smoke-test notes +- pass CI checks + +## Security + +Never commit: + +- `.env` +- API keys +- tokens +- passwords +- production secrets diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 21d0711..006ea0a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { CoursesModule } from "./modules/courses/courses.module"; import { DashboardModule } from "./modules/dashboard/dashboard.module"; import { HealthModule } from "./modules/health/health.module"; import { LearningModule } from "./modules/learning/learning.module"; +import { MemoryModule } from "./modules/memory/memory.module"; import { OnboardingModule } from "./modules/onboarding/onboarding.module"; import { ProfilesModule } from "./modules/profiles/profiles.module"; import { UsersModule } from "./modules/users/users.module"; @@ -26,6 +27,7 @@ import { UsersModule } from "./modules/users/users.module"; DashboardModule, HealthModule, LearningModule, + MemoryModule, OnboardingModule, ProfilesModule, UsersModule, diff --git a/apps/api/src/modules/learning/learning.controller.ts b/apps/api/src/modules/learning/learning.controller.ts index 384da29..ddf90f4 100644 --- a/apps/api/src/modules/learning/learning.controller.ts +++ b/apps/api/src/modules/learning/learning.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, UseGuards } from "@nestjs/common"; +import { Controller, Get, UseGuards } from "@nestjs/common"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; diff --git a/apps/api/src/modules/learning/learning.service.ts b/apps/api/src/modules/learning/learning.service.ts index da7b3d9..4f91275 100644 --- a/apps/api/src/modules/learning/learning.service.ts +++ b/apps/api/src/modules/learning/learning.service.ts @@ -1,9 +1,5 @@ -import { - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from "@nestjs/common"; +import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "../../lib/prisma/prisma.service"; @@ -46,10 +42,6 @@ export class LearningService { async startLesson(userId: string, lessonId: string) { const lesson = await this.ensureLessonAccess(userId, lessonId); - const existing = await this.prisma.lessonProgress.findUnique({ - where: { userId_lessonId: { lessonId, userId } }, - }); - const progress = await this.prisma.lessonProgress.upsert({ create: { courseId: lesson.module.courseId, @@ -81,7 +73,8 @@ export class LearningService { const safeWatchedSec = Math.max(0, watchedSec); const duration = lesson.durationSec ?? 0; - const computedProgress = duration > 0 ? Math.min(100, Math.round((safeWatchedSec / duration) * 100)) : 0; + const computedProgress = + duration > 0 ? Math.min(100, Math.round((safeWatchedSec / duration) * 100)) : 0; const progress = await this.prisma.lessonProgress.upsert({ create: { @@ -93,9 +86,13 @@ export class LearningService { }, update: { lastActivityAt: new Date(), - status: completed ? "COMPLETED" : current?.status === "COMPLETED" ? "COMPLETED" : "IN_PROGRESS", + status: completed + ? "COMPLETED" + : current?.status === "COMPLETED" + ? "COMPLETED" + : "IN_PROGRESS", watchedSec: Math.max(current?.watchedSec ?? 0, safeWatchedSec), - completedAt: completed ? new Date() : current?.completedAt ?? null, + completedAt: completed ? new Date() : (current?.completedAt ?? null), }, where: { userId_lessonId: { lessonId, userId } }, }); @@ -181,7 +178,8 @@ export class LearningService { where: { courseId, userId }, }); - const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; + const progressPercent = + totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; return { completedLessons, @@ -212,11 +210,16 @@ export class LearningService { const totalLessons = await this.prisma.lesson.count({ where: { module: { courseId: latest.courseId } }, }); - const progressPercent = totalLessons > 0 - ? Math.round((await this.prisma.lessonProgress.count({ - where: { courseId: latest.courseId, status: "COMPLETED", userId }, - }) / totalLessons) * 100) - : 0; + const progressPercent = + totalLessons > 0 + ? Math.round( + ((await this.prisma.lessonProgress.count({ + where: { courseId: latest.courseId, status: "COMPLETED", userId }, + })) / + totalLessons) * + 100, + ) + : 0; return { courseId: latest.courseId, @@ -270,7 +273,8 @@ export class LearningService { where: { courseId, status: "COMPLETED", userId }, }); - const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; + const progressPercent = + totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; await this.prisma.enrollment.updateMany({ data: { progressPercent }, @@ -295,7 +299,7 @@ export class LearningService { await this.prisma.learningActivity.create({ data: { lessonId, - metadata, + metadata: (metadata ?? null) as Prisma.InputJsonValue, type, userId, courseId, diff --git a/apps/api/src/modules/learning/progress.controller.ts b/apps/api/src/modules/learning/progress.controller.ts index 4990c20..ebc2d09 100644 --- a/apps/api/src/modules/learning/progress.controller.ts +++ b/apps/api/src/modules/learning/progress.controller.ts @@ -38,10 +38,7 @@ export class ProgressController { @UseGuards(JwtAuthGuard) @Get("courses/:courseId") - getCourseProgress( - @CurrentUser() user: AuthenticatedUser, - @Param("courseId") courseId: string, - ) { + getCourseProgress(@CurrentUser() user: AuthenticatedUser, @Param("courseId") courseId: string) { return this.learningService.getCourseProgress(user.id, courseId); } } diff --git a/apps/api/src/modules/memory/memory.controller.ts b/apps/api/src/modules/memory/memory.controller.ts new file mode 100644 index 0000000..7407839 --- /dev/null +++ b/apps/api/src/modules/memory/memory.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from "@nestjs/common"; + +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; +import type { AuthenticatedUser } from "../auth/types/authenticated-user.type"; +import { MemoryService } from "./memory.service"; + +@Controller("") +export class MemoryController { + constructor(private readonly memoryService: MemoryService) {} + + @UseGuards(JwtAuthGuard) + @Get("quizzes") + getQuizzes() { + return this.memoryService.getQuizzes(); + } + + @UseGuards(JwtAuthGuard) + @Get("quizzes/:id") + getQuiz(@Param("id") id: string) { + return this.memoryService.getQuiz(id); + } + + @UseGuards(JwtAuthGuard) + @Post("quizzes/:id/attempts") + submitAttempt( + @CurrentUser() user: AuthenticatedUser, + @Param("id") id: string, + @Body("answers") answers: Record, + ) { + return this.memoryService.submitAttempt(user.id, id, answers ?? {}); + } + + @UseGuards(JwtAuthGuard) + @Get("quizzes/:id/results") + getResults(@CurrentUser() user: AuthenticatedUser, @Param("id") id: string) { + return this.memoryService.getResults(user.id, id); + } + + @UseGuards(JwtAuthGuard) + @Get("flashcards/me") + getMyFlashcards(@CurrentUser() user: AuthenticatedUser) { + return this.memoryService.getMyFlashcards(user.id); + } + + @UseGuards(JwtAuthGuard) + @Post("flashcards") + createFlashcard( + @CurrentUser() user: AuthenticatedUser, + @Body() + body: { front: string; back: string; tags?: string[]; lessonId?: string; courseId?: string }, + ) { + return this.memoryService.createFlashcard(user.id, body); + } + + @UseGuards(JwtAuthGuard) + @Post("flashcards/:id/review") + reviewFlashcard( + @CurrentUser() user: AuthenticatedUser, + @Param("id") id: string, + @Body("difficulty") difficulty: "FORGOT" | "HARD" | "GOOD" | "EASY", + ) { + return this.memoryService.reviewFlashcard(user.id, id, difficulty); + } + + @UseGuards(JwtAuthGuard) + @Get("flashcards/review-due") + getReviewDue(@CurrentUser() user: AuthenticatedUser, @Query("limit") limit?: string) { + return this.memoryService.getReviewDue(user.id, Number(limit ?? 10)); + } + + @UseGuards(JwtAuthGuard) + @Get("revision/dashboard") + getRevisionDashboard(@CurrentUser() user: AuthenticatedUser) { + return this.memoryService.getRevisionDashboard(user.id); + } +} diff --git a/apps/api/src/modules/memory/memory.module.ts b/apps/api/src/modules/memory/memory.module.ts new file mode 100644 index 0000000..97adf5a --- /dev/null +++ b/apps/api/src/modules/memory/memory.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; + +import { PrismaModule } from "../../lib/prisma/prisma.module"; +import { MemoryController } from "./memory.controller"; +import { MemoryService } from "./memory.service"; + +@Module({ + imports: [PrismaModule], + controllers: [MemoryController], + providers: [MemoryService], +}) +export class MemoryModule {} diff --git a/apps/api/src/modules/memory/memory.service.ts b/apps/api/src/modules/memory/memory.service.ts new file mode 100644 index 0000000..b8a681f --- /dev/null +++ b/apps/api/src/modules/memory/memory.service.ts @@ -0,0 +1,274 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { FlashcardDifficulty, Prisma } from "@prisma/client"; + +import { PrismaService } from "../../lib/prisma/prisma.service"; + +const SPACED_REPETITION_DAYS: Record = { + FORGOT: 1, + HARD: 3, + GOOD: 7, + EASY: 14, +}; + +@Injectable() +export class MemoryService { + constructor(private readonly prisma: PrismaService) {} + + async getQuizzes() { + return this.prisma.quiz.findMany({ + orderBy: { createdAt: "desc" }, + take: 10, + }); + } + + async getQuiz(id: string) { + const quiz = await this.prisma.quiz.findUnique({ + include: { questions: true }, + where: { id }, + }); + + if (!quiz) { + throw new NotFoundException("Quiz not found"); + } + + return quiz; + } + + async submitAttempt(userId: string, quizId: string, answers: Record) { + const quiz = await this.prisma.quiz.findUnique({ + include: { questions: true }, + where: { id: quizId }, + }); + + if (!quiz) { + throw new NotFoundException("Quiz not found"); + } + + let score = 0; + const explanations: string[] = []; + const weakTopics = new Set(); + + for (const question of quiz.questions) { + const userAnswer = this.normalizeAnswer(question.id, answers); + const expected = this.normalizeAnswer(question.id, question.answer); + const isCorrect = this.isAnswerCorrect(userAnswer, expected); + + if (isCorrect) { + score += question.points; + } else { + explanations.push(question.explanation ?? "Review this concept and try again."); + weakTopics.add(question.question.slice(0, 40)); + } + } + + const totalPoints = quiz.questions.reduce((sum, item) => sum + item.points, 0) || 1; + const percentage = Math.round((score / totalPoints) * 100); + const passed = percentage >= quiz.passScore; + + const attempt = await this.prisma.quizAttempt.create({ + data: { + answers: answers as Prisma.InputJsonValue, + passed, + quizId, + score: percentage, + userId, + }, + }); + + return { + attempt, + explanations, + passed, + score: percentage, + weakTopics: Array.from(weakTopics).slice(0, 4), + }; + } + + async getResults(userId: string, quizId: string) { + const attempt = await this.prisma.quizAttempt.findFirst({ + orderBy: { createdAt: "desc" }, + where: { quizId, userId }, + }); + + if (!attempt) { + throw new NotFoundException("No quiz attempt found"); + } + + const quiz = await this.prisma.quiz.findUnique({ + include: { questions: true }, + where: { id: quizId }, + }); + + if (!quiz) { + throw new NotFoundException("Quiz not found"); + } + + return { + attempt, + explanations: quiz.questions.map((question) => ({ + explanation: question.explanation ?? "Review this concept and try again.", + question: question.question, + })), + passed: attempt.passed, + score: attempt.score, + weakTopics: quiz.questions + .filter( + (question) => + !this.isAnswerCorrect( + this.normalizeAnswer(question.id, attempt.answers), + this.normalizeAnswer(question.id, question.answer), + ), + ) + .map((question) => question.question.slice(0, 40)) + .slice(0, 4), + }; + } + + async getMyFlashcards(userId: string) { + return this.prisma.flashcard.findMany({ + orderBy: { createdAt: "desc" }, + where: { userId }, + }); + } + + async createFlashcard( + userId: string, + payload: { front: string; back: string; tags?: string[]; lessonId?: string; courseId?: string }, + ) { + return this.prisma.flashcard.create({ + data: { + back: payload.back, + courseId: payload.courseId ?? null, + front: payload.front, + lessonId: payload.lessonId ?? null, + tags: payload.tags ?? [], + userId, + }, + }); + } + + async reviewFlashcard( + userId: string, + flashcardId: string, + difficulty: keyof typeof SPACED_REPETITION_DAYS, + ) { + const flashcard = await this.prisma.flashcard.findUnique({ where: { id: flashcardId } }); + + if (!flashcard) { + throw new NotFoundException("Flashcard not found"); + } + + const nextReviewAt = new Date(); + nextReviewAt.setDate(nextReviewAt.getDate() + SPACED_REPETITION_DAYS[difficulty]); + + await this.prisma.flashcardReview.create({ + data: { + difficulty, + flashcardId, + nextReviewAt, + reviewedAt: new Date(), + userId, + }, + }); + + await this.prisma.flashcard.update({ + data: { updatedAt: new Date() }, + where: { id: flashcardId }, + }); + + return { difficulty, flashcardId, nextReviewAt }; + } + + async getReviewDue(userId: string, limit: number) { + const now = new Date(); + + return this.prisma.flashcard.findMany({ + include: { reviews: { orderBy: { reviewedAt: "desc" }, take: 1 } }, + orderBy: { createdAt: "desc" }, + take: Math.max(1, limit), + where: { + userId, + OR: [ + { reviews: { some: { userId, nextReviewAt: { lte: now } } } }, + { reviews: { none: {} } }, + ], + }, + }); + } + + async getRevisionDashboard(userId: string) { + const dueCards = await this.prisma.flashcard.findMany({ + include: { reviews: { orderBy: { reviewedAt: "desc" }, take: 1 } }, + where: { userId }, + }); + + const dueToday = dueCards.filter( + (item) => (item.reviews[0]?.nextReviewAt ?? new Date(0)) <= new Date(), + ).length; + const quizAttempts = await this.prisma.quizAttempt.findMany({ + orderBy: { createdAt: "desc" }, + where: { userId }, + }); + + const averageScore = + quizAttempts.length > 0 + ? Math.round(quizAttempts.reduce((sum, item) => sum + item.score, 0) / quizAttempts.length) + : 0; + + return { + averageScore, + dueToday, + quizAttempts: quizAttempts.slice(0, 5), + totalFlashcards: dueCards.length, + upcomingReviews: dueCards.filter( + (item) => (item.reviews[0]?.nextReviewAt ?? new Date(0)) > new Date(), + ).length, + weakTopics: quizAttempts + .slice(0, 3) + .map((attempt) => `Quiz attempt ${attempt.id.slice(0, 6)} (${attempt.score}%)`), + }; + } + + private normalizeAnswer(questionId: string, value: unknown): string[] { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value.map((item) => String(item)); + } + + if (typeof value === "object") { + const record = value as Record; + if ("selected" in record) { + return this.normalizeAnswer(questionId, record.selected); + } + if ("answer" in record) { + return this.normalizeAnswer(questionId, record.answer); + } + if ("correctIndex" in record) { + return [String(record.correctIndex)]; + } + if ("correctIndices" in record) { + return this.normalizeAnswer(questionId, record.correctIndices); + } + if (questionId in record) { + return this.normalizeAnswer(questionId, record[questionId]); + } + + return []; + } + + const primitiveValue = value as string | number | boolean; + return [String(primitiveValue)]; + } + + private isAnswerCorrect(userAnswer: string[], expected: string[]) { + const normalizedUser = userAnswer.map((item) => item.trim().toLowerCase()); + const normalizedExpected = expected.map((item) => item.trim().toLowerCase()); + + return ( + normalizedUser.length > 0 && normalizedUser.every((item) => normalizedExpected.includes(item)) + ); + } +} diff --git a/apps/web/src/app/(learner)/dashboard/page.tsx b/apps/web/src/app/(learner)/dashboard/page.tsx index d067d45..3f619b7 100644 --- a/apps/web/src/app/(learner)/dashboard/page.tsx +++ b/apps/web/src/app/(learner)/dashboard/page.tsx @@ -185,6 +185,57 @@ export default function DashboardPage() { + + +

Revision due

+

Spaced repetition

+
+ +

+ Open your revision hub to review cards due today and keep your learning loop moving. +

+ +
+
+ + + +

Quiz performance

+

Memory checkpoints

+
+ +

+ Start a real quiz, review explanations, and see the weak areas that need another + pass. +

+ +
+
+ + + +

Flashcards due

+

Daily recall

+
+ +

+ Review your flashcard deck with FORGOT/HARD/GOOD/EASY scoring to build long-term + memory. +

+ +
+
+

Roadmap

diff --git a/apps/web/src/app/(learner)/flashcards/page.tsx b/apps/web/src/app/(learner)/flashcards/page.tsx new file mode 100644 index 0000000..9cb18cb --- /dev/null +++ b/apps/web/src/app/(learner)/flashcards/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useSession } from "@/hooks/use-session"; +import { FlashcardDeck } from "@/features/flashcards/components/flashcard-deck"; + +export default function FlashcardsPage() { + const { user, isLoading } = useSession(); + + if (isLoading) return

Loading…

; + if (!user) return

Please sign in.

; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(learner)/quiz/[id]/page.tsx b/apps/web/src/app/(learner)/quiz/[id]/page.tsx new file mode 100644 index 0000000..f1a2855 --- /dev/null +++ b/apps/web/src/app/(learner)/quiz/[id]/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import { useSession } from "@/hooks/use-session"; +import { QuizPlayer } from "@/features/quizzes/components/quiz-player"; + +export default function QuizDetailPage() { + const params = useParams<{ id: string }>(); + const { user, isLoading } = useSession(); + + if (isLoading) return

Loading…

; + if (!user) return

Please sign in.

; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(learner)/quizzes/page.tsx b/apps/web/src/app/(learner)/quizzes/page.tsx new file mode 100644 index 0000000..5805717 --- /dev/null +++ b/apps/web/src/app/(learner)/quizzes/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; + +import { useEffect, useState } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { useSession } from "@/hooks/use-session"; +import { getQuizzes } from "@/services/memory.api"; + +export default function QuizzesPage() { + const { user, isLoading } = useSession(); + const [quizzes, setQuizzes] = useState>( + [], + ); + + useEffect(() => { + void (async () => { + const response = await getQuizzes(); + if (response.success) { + setQuizzes(response.data); + } + })(); + }, []); + + if (isLoading) return

Loading…

; + if (!user) return

Please sign in.

; + + return ( +
+
+ + +

Memory engine

+

Quizzes

+

+ Starter developer quizzes to reinforce what you just learned. +

+
+ + {quizzes.map((quiz) => ( + +

{quiz.title}

+

Pass score {quiz.passScore}%

+ + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(learner)/revision/page.tsx b/apps/web/src/app/(learner)/revision/page.tsx new file mode 100644 index 0000000..9c738c0 --- /dev/null +++ b/apps/web/src/app/(learner)/revision/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useSession } from "@/hooks/use-session"; +import { RevisionSummary } from "@/features/revision/components/revision-summary"; + +export default function RevisionPage() { + const { user, isLoading } = useSession(); + + if (isLoading) return

Loading…

; + if (!user) return

Please sign in.

; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/features/flashcards/components/flashcard-deck.tsx b/apps/web/src/features/flashcards/components/flashcard-deck.tsx new file mode 100644 index 0000000..fbdc14e --- /dev/null +++ b/apps/web/src/features/flashcards/components/flashcard-deck.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { getMyFlashcards, getReviewDue, reviewFlashcard } from "@/services/memory.api"; + +export function FlashcardDeck() { + const [flashcards, setFlashcards] = useState< + Array<{ id: string; front: string; back: string; tags: string[] }> + >([]); + const [dueCards, setDueCards] = useState< + Array<{ id: string; front: string; back: string; tags: string[] }> + >([]); + const [flipped, setFlipped] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + void (async () => { + const [mine, due] = await Promise.all([getMyFlashcards(), getReviewDue(5)]); + if (mine.success) setFlashcards(mine.data); + if (due.success) setDueCards(due.data); + })(); + }, []); + + const current = dueCards[activeIndex] ?? flashcards[activeIndex]; + + const markDifficulty = async (difficulty: "FORGOT" | "HARD" | "GOOD" | "EASY") => { + if (!current) return; + await reviewFlashcard(current.id, difficulty); + setDueCards((prev) => prev.filter((item) => item.id !== current.id)); + setActiveIndex(0); + setFlipped(false); + }; + + return ( +
+ + +

Flashcards

+

Review session

+
+ + {current ? ( + <> +
+ + setFlipped((prev) => !prev)} + style={{ transformStyle: "preserve-3d" }} + transition={{ duration: 0.25 }} + > + + {current.front} + + + {current.back} + + + +
+
+ {current.tags.map((tag) => ( + + {tag} + + ))} +
+
+ {(["FORGOT", "HARD", "GOOD", "EASY"] as const).map((difficulty) => ( + + ))} +
+ + ) : ( +

+ No cards due for review yet. Create a new flashcard to start your spaced repetition + cycle. +

+ )} +
+
+ + + +

Deck

+

Your flashcards

+
+ + {flashcards.length === 0 ? ( +

No personal flashcards yet.

+ ) : ( + flashcards.map((card) => ( +
+

{card.front}

+

{card.back}

+
+ )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/features/quizzes/components/quiz-player.tsx b/apps/web/src/features/quizzes/components/quiz-player.tsx new file mode 100644 index 0000000..8316324 --- /dev/null +++ b/apps/web/src/features/quizzes/components/quiz-player.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { getQuiz, submitQuizAttempt } from "@/services/memory.api"; + +export function QuizPlayer({ quizId }: { quizId: string }) { + const [quiz, setQuiz] = useState>["data"] | null>(null); + const [answers, setAnswers] = useState>({}); + const [result, setResult] = useState< + Awaited>["data"] | null + >(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + void (async () => { + const response = await getQuiz(quizId); + if (response.success) { + setQuiz(response.data); + } + setLoading(false); + })(); + }, [quizId]); + + const handleSelect = (questionId: string, option: string) => { + setAnswers((prev) => ({ + ...prev, + [questionId]: prev[questionId]?.includes(option) + ? prev[questionId].filter((item) => item !== option) + : [...(prev[questionId] ?? []), option], + })); + }; + + const handleSubmit = async () => { + const response = await submitQuizAttempt(quizId, answers); + if (response.success) { + setResult(response.data); + } + }; + + if (loading) { + return

Loading quiz…

; + } + + if (!quiz) { + return

Quiz not available yet.

; + } + + return ( +
+ + +

Quiz

+

{quiz.title}

+

Passing score: {quiz.passScore}%

+
+ + {quiz.questions.map((question, index) => ( +
+

+ {index + 1}. {question.question} +

+
+ {(Array.isArray(question.options) ? question.options : []).map((option) => ( + + ))} +
+
+ ))} + +
+
+ + {result ? ( + + +

Result

+

Score: {result.score}%

+
+ +

+ {result.passed + ? "You passed this checkpoint." + : "Review the explanations below and try again."} +

+ {result.weakTopics.length > 0 ? ( +

Weak areas: {result.weakTopics.join(", ")}

+ ) : null} +
    + {result.explanations.map((item) => ( +
  • {item}
  • + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/features/revision/components/revision-summary.tsx b/apps/web/src/features/revision/components/revision-summary.tsx new file mode 100644 index 0000000..205643b --- /dev/null +++ b/apps/web/src/features/revision/components/revision-summary.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { getRevisionDashboard } from "@/services/memory.api"; + +export function RevisionSummary() { + const [data, setData] = useState>["data"] | null>( + null, + ); + + useEffect(() => { + void (async () => { + const response = await getRevisionDashboard(); + if (response.success) setData(response.data); + })(); + }, []); + + return ( +
+ {[ + { label: "Due today", value: data?.dueToday ?? 0 }, + { label: "Upcoming reviews", value: data?.upcomingReviews ?? 0 }, + { label: "Average quiz score", value: `${data?.averageScore ?? 0}%` }, + { label: "Flashcards due", value: data?.totalFlashcards ?? 0 }, + ].map((item) => ( + + +

Revision

+

{item.value}

+
+ +

{item.label}

+
+
+ ))} +
+ ); +} diff --git a/apps/web/src/services/memory.api.ts b/apps/web/src/services/memory.api.ts new file mode 100644 index 0000000..65dc894 --- /dev/null +++ b/apps/web/src/services/memory.api.ts @@ -0,0 +1,88 @@ +import { apiClient, type ApiResponse } from "@/services/api-client"; + +export type Quiz = { + id: string; + title: string; + passScore: number; + questions: Array<{ + id: string; + question: string; + options: string[]; + explanation?: string | null; + points: number; + }>; +}; + +export type QuizAttemptResult = { + attempt: { id: string; score: number; passed: boolean }; + explanations: Array<{ explanation: string; question: string }>; + passed: boolean; + score: number; + weakTopics: string[]; +}; + +export function getQuizzes(): Promise< + ApiResponse> +> { + return apiClient>("/quizzes"); +} + +export async function getQuiz(id: string): Promise> { + return apiClient(`/quizzes/${id}`); +} + +export async function submitQuizAttempt(id: string, answers: Record) { + return apiClient<{ + attempt: { id: string; score: number; passed: boolean }; + score: number; + passed: boolean; + weakTopics: string[]; + explanations: string[]; + }>(`/quizzes/${id}/attempts`, { body: JSON.stringify({ answers }), method: "POST" }); +} + +export async function getQuizResults(id: string) { + return apiClient(`/quizzes/${id}/results`); +} + +export async function getMyFlashcards() { + return apiClient>( + "/flashcards/me", + ); +} + +export async function createFlashcard(payload: { + front: string; + back: string; + tags?: string[]; + lessonId?: string; + courseId?: string; +}) { + return apiClient<{ id: string }>("/flashcards", { + body: JSON.stringify(payload), + method: "POST", + }); +} + +export async function reviewFlashcard(id: string, difficulty: "FORGOT" | "HARD" | "GOOD" | "EASY") { + return apiClient<{ difficulty: string; nextReviewAt: string }>(`/flashcards/${id}/review`, { + body: JSON.stringify({ difficulty }), + method: "POST", + }); +} + +export async function getReviewDue(limit = 10) { + return apiClient>( + `/flashcards/review-due?limit=${limit}`, + ); +} + +export async function getRevisionDashboard() { + return apiClient<{ + averageScore: number; + dueToday: number; + totalFlashcards: number; + upcomingReviews: number; + weakTopics: string[]; + }>("/revision/dashboard"); +} diff --git a/prisma/migrations/20260529121440_memory_engine_mvp/migration.sql b/prisma/migrations/20260529121440_memory_engine_mvp/migration.sql new file mode 100644 index 0000000..0384ef5 --- /dev/null +++ b/prisma/migrations/20260529121440_memory_engine_mvp/migration.sql @@ -0,0 +1,194 @@ +-- CreateEnum +CREATE TYPE "FlashcardDifficulty" AS ENUM ('FORGOT', 'HARD', 'GOOD', 'EASY'); + +-- CreateTable +CREATE TABLE "LessonProgress" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "lessonId" UUID NOT NULL, + "courseId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "watchedSec" INTEGER NOT NULL DEFAULT 0, + "completedAt" TIMESTAMP(3), + "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LessonProgress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LearningActivity" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "lessonId" UUID, + "courseId" UUID, + "type" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LearningActivity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Quiz" ( + "id" UUID NOT NULL, + "lessonId" UUID, + "courseId" UUID, + "title" TEXT NOT NULL, + "passScore" INTEGER NOT NULL DEFAULT 70, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Quiz_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Question" ( + "id" UUID NOT NULL, + "quizId" UUID NOT NULL, + "question" TEXT NOT NULL, + "options" JSONB NOT NULL, + "answer" JSONB NOT NULL, + "explanation" TEXT, + "points" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Question_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuizAttempt" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "quizId" UUID NOT NULL, + "score" INTEGER NOT NULL DEFAULT 0, + "passed" BOOLEAN NOT NULL DEFAULT false, + "answers" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "QuizAttempt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Flashcard" ( + "id" UUID NOT NULL, + "userId" UUID, + "lessonId" UUID, + "courseId" UUID, + "front" TEXT NOT NULL, + "back" TEXT NOT NULL, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "aiGenerated" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Flashcard_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FlashcardReview" ( + "id" UUID NOT NULL, + "flashcardId" UUID NOT NULL, + "userId" UUID NOT NULL, + "difficulty" "FlashcardDifficulty" NOT NULL, + "reviewedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "nextReviewAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FlashcardReview_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LessonProgress_userId_courseId_idx" ON "LessonProgress"("userId", "courseId"); + +-- CreateIndex +CREATE INDEX "LessonProgress_courseId_status_idx" ON "LessonProgress"("courseId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "LessonProgress_userId_lessonId_key" ON "LessonProgress"("userId", "lessonId"); + +-- CreateIndex +CREATE INDEX "LearningActivity_userId_createdAt_idx" ON "LearningActivity"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "LearningActivity_courseId_createdAt_idx" ON "LearningActivity"("courseId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Quiz_lessonId_idx" ON "Quiz"("lessonId"); + +-- CreateIndex +CREATE INDEX "Quiz_courseId_idx" ON "Quiz"("courseId"); + +-- CreateIndex +CREATE INDEX "Question_quizId_idx" ON "Question"("quizId"); + +-- CreateIndex +CREATE INDEX "QuizAttempt_userId_idx" ON "QuizAttempt"("userId"); + +-- CreateIndex +CREATE INDEX "QuizAttempt_quizId_idx" ON "QuizAttempt"("quizId"); + +-- CreateIndex +CREATE INDEX "Flashcard_userId_idx" ON "Flashcard"("userId"); + +-- CreateIndex +CREATE INDEX "Flashcard_lessonId_idx" ON "Flashcard"("lessonId"); + +-- CreateIndex +CREATE INDEX "Flashcard_courseId_idx" ON "Flashcard"("courseId"); + +-- CreateIndex +CREATE INDEX "FlashcardReview_userId_nextReviewAt_idx" ON "FlashcardReview"("userId", "nextReviewAt"); + +-- CreateIndex +CREATE INDEX "FlashcardReview_flashcardId_idx" ON "FlashcardReview"("flashcardId"); + +-- AddForeignKey +ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LearningActivity" ADD CONSTRAINT "LearningActivity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LearningActivity" ADD CONSTRAINT "LearningActivity_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LearningActivity" ADD CONSTRAINT "LearningActivity_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Quiz" ADD CONSTRAINT "Quiz_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Quiz" ADD CONSTRAINT "Quiz_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_quizId_fkey" FOREIGN KEY ("quizId") REFERENCES "Quiz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuizAttempt" ADD CONSTRAINT "QuizAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuizAttempt" ADD CONSTRAINT "QuizAttempt_quizId_fkey" FOREIGN KEY ("quizId") REFERENCES "Quiz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Flashcard" ADD CONSTRAINT "Flashcard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Flashcard" ADD CONSTRAINT "Flashcard_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Flashcard" ADD CONSTRAINT "Flashcard_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FlashcardReview" ADD CONSTRAINT "FlashcardReview_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FlashcardReview" ADD CONSTRAINT "FlashcardReview_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4d0b807..89113a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,23 +31,35 @@ enum LessonType { EXERCISE } +enum FlashcardDifficulty { + FORGOT + HARD + GOOD + EASY +} + model User { - id String @id @default(uuid()) @db.Uuid - email String @unique - username String @unique - name String? - passwordHash String - refreshTokenHash String? - role UserRole @default(LEARNER) - isActive Boolean @default(true) - isSuspended Boolean @default(false) - lastLoginAt DateTime? - profile Profile? - creatorProfile CreatorProfile? - enrollments Enrollment[] - auditLogs AuditLog[] @relation("AuditActor") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) @db.Uuid + email String @unique + username String @unique + name String? + passwordHash String + refreshTokenHash String? + role UserRole @default(LEARNER) + isActive Boolean @default(true) + isSuspended Boolean @default(false) + lastLoginAt DateTime? + profile Profile? + creatorProfile CreatorProfile? + enrollments Enrollment[] + lessonProgresses LessonProgress[] + learningActivities LearningActivity[] + quizAttempts QuizAttempt[] + flashcards Flashcard[] + flashcardReviews FlashcardReview[] + auditLogs AuditLog[] @relation("AuditActor") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([role]) @@index([isActive]) @@ -56,21 +68,21 @@ model User { } model Profile { - id String @id @default(uuid()) @db.Uuid - userId String @unique @db.Uuid - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) @db.Uuid + userId String @unique @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) displayName String? headline String? - goals String[] @default([]) - topics String[] @default([]) - learningStyle String[] @default([]) + goals String[] @default([]) + topics String[] @default([]) + learningStyle String[] @default([]) skillLevel Difficulty? - preferredDifficulty Difficulty @default(BEGINNER) - dailyGoalMin Int @default(30) - onboardingCompleted Boolean @default(false) + preferredDifficulty Difficulty @default(BEGINNER) + dailyGoalMin Int @default(30) + onboardingCompleted Boolean @default(false) onboardingCompletedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([preferredDifficulty]) } @@ -101,25 +113,29 @@ model Category { } model Course { - id String @id @default(uuid()) @db.Uuid - categoryId String? @db.Uuid - category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - title String - slug String @unique - subtitle String? - description String - thumbnailUrl String? - difficulty Difficulty @default(BEGINNER) - language String @default("English") - status CourseStatus @default(DRAFT) - isFree Boolean @default(true) - price Decimal? @db.Decimal(10, 2) - currency String @default("USD") - publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - modules CourseModule[] - enrollments Enrollment[] + id String @id @default(uuid()) @db.Uuid + categoryId String? @db.Uuid + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + title String + slug String @unique + subtitle String? + description String + thumbnailUrl String? + difficulty Difficulty @default(BEGINNER) + language String @default("English") + status CourseStatus @default(DRAFT) + isFree Boolean @default(true) + price Decimal? @db.Decimal(10, 2) + currency String @default("USD") + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + modules CourseModule[] + enrollments Enrollment[] + lessonProgresses LessonProgress[] + learningActivities LearningActivity[] + quizzes Quiz[] + flashcards Flashcard[] @@index([status]) @@index([difficulty]) @@ -142,21 +158,23 @@ model CourseModule { } model Lesson { - id String @id @default(uuid()) @db.Uuid - moduleId String @db.Uuid - module CourseModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) - title String - slug String - type LessonType @default(ARTICLE) - order Int - content String - videoUrl String? - durationSec Int? - isPreview Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lessonProgresses LessonProgress[] + id String @id @default(uuid()) @db.Uuid + moduleId String @db.Uuid + module CourseModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) + title String + slug String + type LessonType @default(ARTICLE) + order Int + content String + videoUrl String? + durationSec Int? + isPreview Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lessonProgresses LessonProgress[] learningActivities LearningActivity[] + quizzes Quiz[] + flashcards Flashcard[] @@unique([moduleId, slug]) @@index([moduleId, order]) @@ -198,16 +216,99 @@ model LearningActivity { @@index([courseId, createdAt]) } +model Quiz { + id String @id @default(uuid()) @db.Uuid + lessonId String? @db.Uuid + lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull) + courseId String? @db.Uuid + course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) + title String + passScore Int @default(70) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + questions Question[] + attempts QuizAttempt[] + + @@index([lessonId]) + @@index([courseId]) +} + +model Question { + id String @id @default(uuid()) @db.Uuid + quizId String @db.Uuid + quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade) + question String + options Json + answer Json + explanation String? + points Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([quizId]) +} + +model QuizAttempt { + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + quizId String @db.Uuid + quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade) + score Int @default(0) + passed Boolean @default(false) + answers Json @default("{}") + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([quizId]) +} + +model Flashcard { + id String @id @default(uuid()) @db.Uuid + userId String? @db.Uuid + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + lessonId String? @db.Uuid + lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull) + courseId String? @db.Uuid + course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) + front String + back String + tags String[] @default([]) + aiGenerated Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + reviews FlashcardReview[] + + @@index([userId]) + @@index([lessonId]) + @@index([courseId]) +} + +model FlashcardReview { + id String @id @default(uuid()) @db.Uuid + flashcardId String @db.Uuid + flashcard Flashcard @relation(fields: [flashcardId], references: [id], onDelete: Cascade) + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + difficulty FlashcardDifficulty + reviewedAt DateTime @default(now()) + nextReviewAt DateTime + createdAt DateTime @default(now()) + + @@index([userId, nextReviewAt]) + @@index([flashcardId]) +} + model Enrollment { - id String @id @default(uuid()) @db.Uuid - userId String @db.Uuid - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - courseId String @db.Uuid - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - progressPercent Float @default(0) - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + courseId String @db.Uuid + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + progressPercent Float @default(0) + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([userId, courseId]) @@index([userId]) diff --git a/prisma/seed.ts b/prisma/seed.ts index 74f03c9..f5ac408 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,8 +1,24 @@ import { PrismaClient } from "@prisma/client"; +import * as bcrypt from "bcrypt"; const prisma = new PrismaClient(); async function main() { + const bcryptModule = bcrypt as unknown as { hashSync: (value: string, salt: number) => string }; + const demoPasswordHash = bcryptModule.hashSync("learn123", 10); + + const demoUser = await prisma.user.upsert({ + create: { + email: "demo@learndojoworld.com", + name: "Demo Learner", + passwordHash: demoPasswordHash, + role: "LEARNER", + username: "demo-learner", + }, + update: {}, + where: { email: "demo@learndojoworld.com" }, + }); + const categories = await Promise.all([ prisma.category.upsert({ create: { @@ -241,7 +257,75 @@ async function main() { } } - console.log("Seeded 3 published starter courses with modules and lessons."); + const courseRecords = await prisma.course.findMany({ + include: { modules: { include: { lessons: true } } }, + where: { status: "PUBLISHED" }, + }); + + for (const course of courseRecords) { + const lesson = course.modules.flatMap((module) => module.lessons)[0]; + + if (!lesson) { + continue; + } + + const existingQuiz = await prisma.quiz.findFirst({ + where: { courseId: course.id, lessonId: lesson.id }, + }); + + if (existingQuiz) { + continue; + } + + const quiz = await prisma.quiz.create({ + data: { + courseId: course.id, + lessonId: lesson.id, + passScore: 70, + title: `${course.title} memory check`, + }, + }); + + await prisma.question.createMany({ + data: [ + { + answer: { correctIndex: 1 }, + explanation: "React state updates via setter functions to keep the UI in sync.", + options: ["Props", "State", "Routes", "Styles"], + points: 1, + question: "What drives component re-rendering when user input changes?", + quizId: quiz.id, + }, + { + answer: { correctIndex: 0 }, + explanation: "Async functions make asynchronous code read like synchronous steps.", + options: ["async/await", "forEach", "console.log", "setTimeout"], + points: 1, + question: "Which pattern makes promise-based code easier to read?", + quizId: quiz.id, + }, + ], + skipDuplicates: true, + }); + + await prisma.flashcard.createMany({ + data: [ + { + back: "Use state to store values that change over time and update the UI.", + courseId: course.id, + front: "What is state in React?", + lessonId: lesson.id, + tags: [course.title, "React"], + userId: demoUser.id, + }, + ], + skipDuplicates: true, + }); + } + + console.log( + "Seeded 3 published starter courses, quizzes, flashcards, and a demo learner account.", + ); } main()