Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
This commit is contained in:
parent
666ceb7325
commit
b7ce314f01
105 changed files with 35510 additions and 11 deletions
39
src/services/cardService.ts
Normal file
39
src/services/cardService.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function createCard(
|
||||
deckId: string,
|
||||
data: { front: string; back: string }
|
||||
) {
|
||||
const maxOrder = await prisma.flashcard.aggregate({
|
||||
where: { deckId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||
|
||||
return prisma.flashcard.create({
|
||||
data: {
|
||||
deckId,
|
||||
front: data.front,
|
||||
back: data.back,
|
||||
sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCard(
|
||||
id: string,
|
||||
data: { front?: string; back?: string }
|
||||
) {
|
||||
return prisma.flashcard.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCard(id: string) {
|
||||
// The card is deleted; StudyProgress.orderJson may reference this id.
|
||||
// On session resume, filterAndClampOrder() handles stale ids (see shuffle.ts).
|
||||
return prisma.flashcard.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
62
src/services/classService.ts
Normal file
62
src/services/classService.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
export async function listClasses() {
|
||||
return prisma.class.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { decks: true, quizSets: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getClassBySlug(slug: string) {
|
||||
return prisma.class.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
_count: {
|
||||
select: { decks: true, quizSets: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createClass(name: string) {
|
||||
const baseSlug = slugify(name);
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
// Ensure unique slug
|
||||
while (await prisma.class.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const maxOrder = await prisma.class.aggregate({ _max: { sortOrder: true } });
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||
|
||||
return prisma.class.create({
|
||||
data: { name, slug, sortOrder },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClass(id: string, data: { name?: string }) {
|
||||
return prisma.class.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteClass(id: string) {
|
||||
return prisma.class.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
79
src/services/deckService.ts
Normal file
79
src/services/deckService.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
import type { FlashcardImportData } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function listDecksByClass(classId: string) {
|
||||
return prisma.deck.findMany({
|
||||
where: { classId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: { select: { cards: true } },
|
||||
progress: {
|
||||
select: {
|
||||
mode: true,
|
||||
currentIndex: true,
|
||||
orderJson: true,
|
||||
cardResultsJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDeckWithCards(deckId: string) {
|
||||
return prisma.deck.findUnique({
|
||||
where: { id: deckId },
|
||||
include: {
|
||||
cards: { orderBy: { sortOrder: "asc" } },
|
||||
class: { select: { slug: true, name: true } },
|
||||
progress: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDeckFromImport(
|
||||
classId: string,
|
||||
data: FlashcardImportData,
|
||||
overrideName?: string
|
||||
) {
|
||||
const maxOrder = await prisma.deck.aggregate({
|
||||
where: { classId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||
|
||||
return prisma.deck.create({
|
||||
data: {
|
||||
classId,
|
||||
name: overrideName || data.deckName,
|
||||
description: data.description,
|
||||
sortOrder,
|
||||
cards: {
|
||||
create: data.cards.map((card, index) => ({
|
||||
front: card.front,
|
||||
back: card.back,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
cards: true,
|
||||
_count: { select: { cards: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDeck(
|
||||
id: string,
|
||||
data: { name?: string; description?: string }
|
||||
) {
|
||||
return prisma.deck.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDeck(id: string) {
|
||||
return prisma.deck.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
85
src/services/progressService.ts
Normal file
85
src/services/progressService.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function getProgress(
|
||||
contentType: "DECK" | "QUIZ",
|
||||
contentId: string,
|
||||
mode: "SEQUENTIAL" | "SHUFFLED"
|
||||
) {
|
||||
if (contentType === "DECK") {
|
||||
return prisma.studyProgress.findUnique({
|
||||
where: { deckId_mode: { deckId: contentId, mode } },
|
||||
});
|
||||
}
|
||||
return prisma.studyProgress.findUnique({
|
||||
where: { quizSetId_mode: { quizSetId: contentId, mode } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertProgress(data: {
|
||||
contentType: "DECK" | "QUIZ";
|
||||
contentId: string;
|
||||
mode: "SEQUENTIAL" | "SHUFFLED";
|
||||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson?: string;
|
||||
cardResultsJson?: string;
|
||||
}) {
|
||||
const base = {
|
||||
contentType: data.contentType,
|
||||
mode: data.mode,
|
||||
currentIndex: data.currentIndex,
|
||||
orderJson: data.orderJson,
|
||||
answersJson: data.answersJson ?? null,
|
||||
cardResultsJson: data.cardResultsJson ?? null,
|
||||
};
|
||||
|
||||
if (data.contentType === "DECK") {
|
||||
return prisma.studyProgress.upsert({
|
||||
where: { deckId_mode: { deckId: data.contentId, mode: data.mode } },
|
||||
update: {
|
||||
currentIndex: data.currentIndex,
|
||||
orderJson: data.orderJson,
|
||||
cardResultsJson: data.cardResultsJson ?? null,
|
||||
},
|
||||
create: {
|
||||
...base,
|
||||
deckId: data.contentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.studyProgress.upsert({
|
||||
where: {
|
||||
quizSetId_mode: { quizSetId: data.contentId, mode: data.mode },
|
||||
},
|
||||
update: {
|
||||
currentIndex: data.currentIndex,
|
||||
orderJson: data.orderJson,
|
||||
answersJson: data.answersJson ?? null,
|
||||
},
|
||||
create: {
|
||||
...base,
|
||||
quizSetId: data.contentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearProgress(
|
||||
contentType: "DECK" | "QUIZ",
|
||||
contentId: string,
|
||||
mode: "SEQUENTIAL" | "SHUFFLED"
|
||||
) {
|
||||
try {
|
||||
if (contentType === "DECK") {
|
||||
await prisma.studyProgress.delete({
|
||||
where: { deckId_mode: { deckId: contentId, mode } },
|
||||
});
|
||||
} else {
|
||||
await prisma.studyProgress.delete({
|
||||
where: { quizSetId_mode: { quizSetId: contentId, mode } },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Row may not exist — that's fine
|
||||
}
|
||||
}
|
||||
110
src/services/quizService.ts
Normal file
110
src/services/quizService.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
import type { QuizImportData } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function listQuizSetsByClass(classId: string) {
|
||||
return prisma.quizSet.findMany({
|
||||
where: { classId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: { select: { questions: true, attempts: true } },
|
||||
progress: {
|
||||
select: {
|
||||
mode: true,
|
||||
currentIndex: true,
|
||||
orderJson: true,
|
||||
answersJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getQuizSetWithQuestions(quizSetId: string) {
|
||||
return prisma.quizSet.findUnique({
|
||||
where: { id: quizSetId },
|
||||
include: {
|
||||
questions: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
},
|
||||
class: { select: { slug: true, name: true } },
|
||||
progress: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createQuizSetFromImport(
|
||||
classId: string,
|
||||
data: QuizImportData,
|
||||
overrideName?: string
|
||||
) {
|
||||
const maxOrder = await prisma.quizSet.aggregate({
|
||||
where: { classId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||
|
||||
return prisma.quizSet.create({
|
||||
data: {
|
||||
classId,
|
||||
name: overrideName || data.quizName,
|
||||
description: data.description,
|
||||
sortOrder,
|
||||
questions: {
|
||||
create: data.questions.map((q, qi) => ({
|
||||
type: q.type === "multiple_choice" ? "MULTIPLE_CHOICE" : "SATA",
|
||||
prompt: q.prompt,
|
||||
rationale: q.rationale,
|
||||
category: q.category.trim().toLowerCase(),
|
||||
sortOrder: qi,
|
||||
options: {
|
||||
create: q.options.map((opt, oi) => ({
|
||||
text: opt.text,
|
||||
isCorrect: opt.correct,
|
||||
sortOrder: oi,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
questions: { include: { options: true } },
|
||||
_count: { select: { questions: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateQuizSet(
|
||||
id: string,
|
||||
data: { name?: string; description?: string }
|
||||
) {
|
||||
return prisma.quizSet.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteQuizSet(id: string) {
|
||||
return prisma.quizSet.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createQuizAttempt(data: {
|
||||
quizSetId: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
answersJson: string;
|
||||
isPartialRetake?: boolean;
|
||||
}) {
|
||||
return prisma.quizAttempt.create({ data });
|
||||
}
|
||||
|
||||
export async function listQuizAttempts(quizSetId: string) {
|
||||
return prisma.quizAttempt.findMany({
|
||||
where: { quizSetId },
|
||||
orderBy: { completedAt: "desc" },
|
||||
});
|
||||
}
|
||||
81
src/services/settingsService.ts
Normal file
81
src/services/settingsService.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
|
||||
const DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS = `You are generating study materials in a strict JSON format for import into a
|
||||
personal study app. The overall output must be raw JSON with no wrapping
|
||||
markdown code fence and no commentary before or after. Markdown formatting
|
||||
INSIDE string values (lists, **bold**, tables) is fine, see the formatting
|
||||
rule below.
|
||||
|
||||
For flashcards, use this shape:
|
||||
{ "type": "flashcards", "deckName": "...", "description": "...", "cards": [{"front": "...", "back": "..."}] }
|
||||
|
||||
Rules:
|
||||
- CRITICAL: Output MUST be valid JSON. All strings must be wrapped in double quotes.
|
||||
- Use Markdown formatting (bullet lists, **bold**, tables) inside "back"
|
||||
text wherever it improves clarity. You MUST escape any newlines in the string as \n. Do not use raw newlines.
|
||||
- Base everything strictly on the material below. Do not add outside facts.
|
||||
|
||||
Material:
|
||||
[paste your notes or lecture content here]`;
|
||||
|
||||
const DEFAULT_LLM_INSTRUCTIONS_QUIZZES = `You are generating study materials in a strict JSON format for import into a
|
||||
personal study app. The overall output must be raw JSON with no wrapping
|
||||
markdown code fence and no commentary before or after. Markdown formatting
|
||||
INSIDE string values (lists, **bold**, tables) is fine, see the formatting
|
||||
rule below.
|
||||
|
||||
For quizzes, use this shape:
|
||||
{ "type": "quiz", "quizName": "...", "description": "...", "questions": [
|
||||
{"type": "multiple_choice" | "sata", "prompt": "...", "category": "...",
|
||||
"options": [{"text": "...", "correct": true|false}], "rationale": "..."}
|
||||
]}
|
||||
|
||||
Rules:
|
||||
- CRITICAL: Output MUST be valid JSON. All strings must be wrapped in double quotes.
|
||||
- "multiple_choice" questions must have exactly one option with correct: true.
|
||||
- "sata" questions must have two or more options with correct: true.
|
||||
- Every question needs a rationale explaining why the correct option(s) are
|
||||
correct, and briefly why the others are not.
|
||||
- Every question must include a "category" field: a short topic tag you
|
||||
choose based on the content (e.g. "thyroid", "limbic system"). Reuse the
|
||||
exact same label across questions on the same topic rather than inventing
|
||||
a new variant each time, and keep the total number of distinct categories
|
||||
small relative to the number of questions.
|
||||
- Use Markdown formatting (bullet lists, **bold**, tables) inside
|
||||
"rationale" text wherever it improves clarity. You MUST escape any newlines in the string as \n. Do not use raw newlines.
|
||||
- Base everything strictly on the material below. Do not add outside facts.
|
||||
|
||||
Material:
|
||||
[paste your notes or lecture content here]`;
|
||||
|
||||
export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promise<string> {
|
||||
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
|
||||
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
|
||||
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
return setting?.value ?? def;
|
||||
}
|
||||
|
||||
export async function updateLlmInstructions(type: "flashcards" | "quizzes", value: string) {
|
||||
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
|
||||
|
||||
return prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
|
||||
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
|
||||
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
|
||||
|
||||
return prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: def },
|
||||
create: { key, value: def },
|
||||
});
|
||||
}
|
||||
|
||||
52
src/services/shareService.ts
Normal file
52
src/services/shareService.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function getShareLink(token: string) {
|
||||
return prisma.shareLink.findUnique({
|
||||
where: { id: token },
|
||||
include: {
|
||||
deck: {
|
||||
include: {
|
||||
cards: { orderBy: { sortOrder: "asc" } },
|
||||
class: { select: { slug: true, name: true } },
|
||||
},
|
||||
},
|
||||
quizSet: {
|
||||
include: {
|
||||
questions: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
},
|
||||
class: { select: { slug: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getShareLinkForContent(targetType: "DECK" | "QUIZ", contentId: string) {
|
||||
if (targetType === "DECK") {
|
||||
return prisma.shareLink.findUnique({ where: { deckId: contentId } });
|
||||
}
|
||||
return prisma.shareLink.findUnique({ where: { quizSetId: contentId } });
|
||||
}
|
||||
|
||||
export async function toggleShareLink(targetType: "DECK" | "QUIZ", contentId: string) {
|
||||
const existing = await getShareLinkForContent(targetType, contentId);
|
||||
|
||||
if (existing) {
|
||||
// Disable sharing
|
||||
await prisma.shareLink.delete({ where: { id: existing.id } });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enable sharing
|
||||
return prisma.shareLink.create({
|
||||
data: {
|
||||
targetType,
|
||||
deckId: targetType === "DECK" ? contentId : null,
|
||||
quizSetId: targetType === "QUIZ" ? contentId : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue