Refactor Study Desk application structure

This commit is contained in:
Elijah 2026-08-07 19:31:23 -07:00
parent faaccf8a7e
commit 089439ed90
145 changed files with 8087 additions and 3412 deletions

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { summarizeActivityBuckets, summarizeActivityRows } from "./activityService";
describe("summarizeActivityRows", () => {
it("keeps a 53-week display while preserving a longer current streak", () => {
const now = new Date("2026-08-07T18:00:00.000Z");
const activities = Array.from({ length: 400 }, (_, age) =>
Array.from({ length: 20 }, () => ({
type: "FLASHCARD",
occurredAt: new Date(now.getTime() - age * 24 * 60 * 60 * 1000),
}))
).flat();
const summary = summarizeActivityRows(activities, now);
expect(summary.days).toHaveLength(371);
expect(summary.currentStreak).toBe(400);
expect(summary.days.filter((day) => day.date <= summary.today).at(-1)?.date).toBe(summary.today);
});
it("preserves counts and long streaks from database-aggregated daily buckets", () => {
const now = new Date("2026-08-07T18:00:00.000Z");
const buckets = Array.from({ length: 400 }, (_, age) => ({
date: new Date(now.getTime() - 7 * 60 * 60 * 1000 - age * 86_400_000)
.toISOString()
.slice(0, 10),
type: "FLASHCARD",
count: 20,
}));
const summary = summarizeActivityBuckets(buckets, now);
expect(summary.currentStreak).toBe(400);
expect(summary.days).toHaveLength(371);
});
});

View file

@ -55,26 +55,49 @@ export async function recordActivity(type: StudyActivityType) {
return prisma.studyActivity.create({ data: { type } });
}
export async function getActivitySummary(now = new Date()): Promise<ActivitySummary> {
interface ActivityBucket {
date: string;
type: string;
count: number;
}
export function summarizeActivityRows(
activities: Array<{ type: string; occurredAt: Date }>,
now = new Date()
): ActivitySummary {
const counts = new Map<string, ActivityBucket>();
for (const activity of activities) {
const date = toArizonaDateKey(activity.occurredAt);
const key = `${date}\u0000${activity.type}`;
const existing = counts.get(key);
counts.set(key, {
date,
type: activity.type,
count: (existing?.count ?? 0) + 1,
});
}
return summarizeActivityBuckets([...counts.values()], now);
}
export function summarizeActivityBuckets(
activities: ActivityBucket[],
now = new Date()
): ActivitySummary {
const today = toArizonaDateKey(now);
const todayDate = dateKeyToUtcDate(today);
const dayOfWeek = todayDate.getUTCDay();
const startDate = addDays(today, -(dayOfWeek + (WEEKS_TO_DISPLAY - 1) * 7));
const endDate = addDays(startDate, DAYS_TO_DISPLAY - 1);
const activities = await prisma.studyActivity.findMany({
select: { type: true, occurredAt: true },
orderBy: { occurredAt: "asc" },
});
const totals = new Map<string, DailyActivity>();
for (const activity of activities) {
const date = toArizonaDateKey(activity.occurredAt);
const date = activity.date;
const day = totals.get(date) ?? emptyDay(date);
if (activity.type === "FLASHCARD") day.flashcards += 1;
if (activity.type === "QUIZ_QUESTION") day.questions += 1;
if (activity.type === "ARCADE_GROUP") day.arcade += 1;
day.total += 1;
if (activity.type === "FLASHCARD") day.flashcards += activity.count;
if (activity.type === "QUIZ_QUESTION") day.questions += activity.count;
if (activity.type === "ARCADE_GROUP") day.arcade += activity.count;
day.total += activity.count;
day.level = getLevel(day.total);
totals.set(date, day);
}
@ -107,3 +130,29 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
currentStreak,
};
}
export async function getActivitySummary(now = new Date()): Promise<ActivitySummary> {
const activities = await prisma.$queryRaw<
Array<{ date: string; type: string; count: bigint | number }>
>`
SELECT
CASE
WHEN typeof("occurredAt") IN ('integer', 'real')
THEN strftime('%Y-%m-%d', "occurredAt" / 1000, 'unixepoch', '-7 hours')
ELSE strftime('%Y-%m-%d', "occurredAt", '-7 hours')
END AS "date",
"type",
COUNT(*) AS "count"
FROM "StudyActivity"
GROUP BY "date", "type"
ORDER BY "date" ASC
`;
return summarizeActivityBuckets(
activities.map((activity) => ({
date: activity.date,
type: activity.type,
count: Number(activity.count),
})),
now
);
}

View file

@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import argon2 from "argon2";
import { prisma } from "@/lib/db";
import { getSetupStatus, login, requestPasswordReset } from "./authService";
const originalHash = process.env.ADMIN_PASSWORD_HASH;
beforeEach(async () => {
await prisma.setting.deleteMany({ where: { key: { in: ["admin_password_hash", "admin_password_reset", "auth_session_generation"] } } });
await prisma.authSecurity.deleteMany();
});
afterEach(async () => {
if (originalHash === undefined) delete process.env.ADMIN_PASSWORD_HASH;
else process.env.ADMIN_PASSWORD_HASH = originalHash;
await prisma.setting.deleteMany({ where: { key: { in: ["admin_password_hash", "admin_password_reset", "auth_session_generation"] } } });
await prisma.authSecurity.deleteMany();
});
describe("pre-provisioned administrator password", () => {
it("reports setup complete and rejects a different first password", async () => {
process.env.ADMIN_PASSWORD_HASH = await argon2.hash("correct-password");
await expect(getSetupStatus()).resolves.toMatchObject({ setupRequired: false });
await expect(login("different-password")).resolves.toMatchObject({ ok: false, status: 401 });
expect(await prisma.setting.findUnique({ where: { key: "admin_password_hash" } })).toMatchObject({
value: process.env.ADMIN_PASSWORD_HASH,
});
});
it("does not log or replace an unexpired password-reset bearer token", async () => {
process.env.ADMIN_PASSWORD_HASH = await argon2.hash("correct-password");
const info = vi.spyOn(console, "info").mockImplementation(() => {});
const first = await requestPasswordReset();
const stored = await prisma.setting.findUnique({
where: { key: "admin_password_reset" },
});
const second = await requestPasswordReset();
expect(first).toMatchObject({ status: "created" });
if (first.status === "created") expect(first.token).toHaveLength(32);
expect(second).toEqual({ status: "active-token" });
expect(await prisma.setting.findUnique({
where: { key: "admin_password_reset" },
})).toEqual(stored);
expect(info).not.toHaveBeenCalled();
info.mockRestore();
});
});

View file

@ -20,7 +20,14 @@ interface ResetTokenRecord {
export type LoginResult =
| { ok: true }
| { ok: false; status: 401 | 423; error: string };
| { ok: false; status: 401 | 409 | 423; error: string };
export type PasswordResetRequestResult =
| { status: "created"; token: string }
| { status: "missing-password" }
| { status: "active-token" };
const ARGON2_HASH_PATTERN = /^\$argon2(?:id|i|d)\$/;
function getLockoutDuration(failedAttempts: number): number {
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000;
@ -74,6 +81,40 @@ async function getOrCreateSecurityRecord() {
});
}
function initialSetupAllowed() {
return (
process.env.NODE_ENV !== "production" ||
process.env.ALLOW_INITIAL_SETUP === "true"
);
}
async function getConfiguredPasswordSetting() {
const existing = await prisma.setting.findUnique({
where: { key: PASSWORD_HASH_KEY },
});
if (existing) return existing;
const provisionedHash = process.env.ADMIN_PASSWORD_HASH?.trim();
if (!provisionedHash) return null;
if (!ARGON2_HASH_PATTERN.test(provisionedHash)) {
throw new Error("ADMIN_PASSWORD_HASH must be a valid Argon2 encoded hash");
}
return prisma.setting.upsert({
where: { key: PASSWORD_HASH_KEY },
update: {},
create: { key: PASSWORD_HASH_KEY, value: provisionedHash },
});
}
export async function getSetupStatus() {
const passwordSetting = await getConfiguredPasswordSetting();
return {
setupRequired: !passwordSetting,
setupAllowed: initialSetupAllowed(),
};
}
export async function login(password: string): Promise<LoginResult> {
const security = await getOrCreateSecurityRecord();
@ -88,13 +129,19 @@ export async function login(password: string): Promise<LoginResult> {
};
}
const passwordSetting = await prisma.setting.findUnique({
where: { key: PASSWORD_HASH_KEY },
});
const passwordSetting = await getConfiguredPasswordSetting();
const argon2 = await import("argon2");
let isValid = false;
if (!passwordSetting) {
if (!initialSetupAllowed()) {
return {
ok: false,
status: 409,
error:
"Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP.",
};
}
await prisma.setting.create({
data: { key: PASSWORD_HASH_KEY, value: await argon2.hash(password) },
});
@ -129,11 +176,20 @@ export async function login(password: string): Promise<LoginResult> {
return { ok: true };
}
export async function requestPasswordReset(): Promise<boolean> {
const passwordSetting = await prisma.setting.findUnique({
where: { key: PASSWORD_HASH_KEY },
export async function requestPasswordReset(): Promise<PasswordResetRequestResult> {
const passwordSetting = await getConfiguredPasswordSetting();
if (!passwordSetting) return { status: "missing-password" };
const existingSetting = await prisma.setting.findUnique({
where: { key: RESET_TOKEN_KEY },
});
if (!passwordSetting) return false;
const existingRecord = parseResetTokenRecord(existingSetting?.value);
if (
existingRecord &&
new Date(existingRecord.expiresAt).getTime() > Date.now()
) {
return { status: "active-token" };
}
const token = randomBytes(24).toString("base64url");
const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString();
@ -149,8 +205,7 @@ export async function requestPasswordReset(): Promise<boolean> {
create: { key: RESET_TOKEN_KEY, value: JSON.stringify(record) },
});
console.info(`[password-reset] Token: ${token} (expires ${expiresAt})`);
return true;
return { status: "created", token };
}
export async function verifyPasswordResetToken(token: string): Promise<boolean> {

View file

@ -0,0 +1,27 @@
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { createClass, updateClass } from "./classService";
const createdIds: string[] = [];
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: createdIds.splice(0) } } });
});
describe("class slugs", () => {
it("creates unique navigable fallback slugs for punctuation-only names", async () => {
const first = await createClass("!!!");
const second = await createClass("!!!");
createdIds.push(first.id, second.id);
expect(first.slug).toMatch(/^class-[a-f0-9]{8}$/);
expect(second.slug).toMatch(/^class-[a-f0-9]{8}$/);
expect(second.slug).not.toBe(first.slug);
});
it("keeps the existing slug stable when a class is renamed", async () => {
const original = await createClass("Original name");
createdIds.push(original.id);
const renamed = await updateClass(original.id, { name: "Renamed" });
expect(renamed.slug).toBe(original.slug);
});
});

View file

@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
import { randomUUID } from "node:crypto";
function slugify(name: string): string {
return name
@ -45,7 +46,7 @@ export async function getClassBySlug(slug: string) {
}
export async function createClass(name: string) {
const baseSlug = slugify(name);
const baseSlug = slugify(name) || `class-${randomUUID().slice(0, 8)}`;
let slug = baseSlug;
let counter = 1;

View file

@ -1,10 +1,20 @@
import { prisma } from "@/lib/db";
import type { FlashcardImportData } from "@/lib/validation/importSchemas";
export class DeckValidationError extends Error {
constructor(message: string, public status: 400 | 404 | 409) {
super(message);
}
}
export async function listDecksByClass(classId: string) {
return prisma.deck.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
orderBy: [
{ sortOrder: "asc" },
{ createdAt: "asc" },
{ id: "asc" },
],
include: {
_count: { select: { cards: true } },
progress: {
@ -13,6 +23,8 @@ export async function listDecksByClass(classId: string) {
currentIndex: true,
orderJson: true,
cardResultsJson: true,
sessionId: true,
revision: true,
},
},
},
@ -36,6 +48,21 @@ export async function createDeckFromImport(
overrideName?: string,
groupId?: string | null
) {
const [classItem, group] = await Promise.all([
prisma.class.findUnique({ where: { id: classId }, select: { id: true } }),
groupId
? prisma.materialGroup.findUnique({
where: { id: groupId },
select: { classId: true, type: true },
})
: null,
]);
if (!classItem) throw new DeckValidationError("Class not found", 404);
if (groupId && !group) throw new DeckValidationError("Group not found", 404);
if (group && (group.classId !== classId || group.type !== "DECK")) {
throw new DeckValidationError("Group must be a deck group in the selected class", 400);
}
const maxOrder = await prisma.deck.aggregate({
where: { classId, groupId: groupId || null },
_max: { sortOrder: true },
@ -66,7 +93,7 @@ export async function createDeckFromImport(
export async function updateDeck(
id: string,
data: { name?: string; description?: string }
data: { name?: string; description?: string | null }
) {
return prisma.deck.update({
where: { id },

View file

@ -0,0 +1,8 @@
import { prisma } from "@/lib/db";
export async function checkApplicationHealth() {
await prisma.$transaction([
prisma.deck.findFirst({ select: { id: true, groupId: true } }),
prisma.materialGroup.count(),
]);
}

View file

@ -0,0 +1,40 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { deleteMaterialGroup } from "./materialGroupService";
const classIds: string[] = [];
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: classIds.splice(0) } } });
});
describe("deleteMaterialGroup", () => {
it("keeps content and deterministically renumbers Uncategorized", async () => {
const classId = randomUUID();
classIds.push(classId);
await prisma.class.create({
data: { id: classId, slug: `class-${classId}`, name: "Class" },
});
const group = await prisma.materialGroup.create({
data: { classId, name: "Group", type: "DECK" },
});
await prisma.deck.createMany({
data: [
{ id: randomUUID(), classId, groupId: null, name: "Existing", sortOrder: 5 },
{ id: randomUUID(), classId, groupId: group.id, name: "Moved A", sortOrder: 5 },
{ id: randomUUID(), classId, groupId: group.id, name: "Moved B", sortOrder: 5 },
],
});
expect(await deleteMaterialGroup(group.id)).toBe(true);
const decks = await prisma.deck.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
select: { groupId: true, sortOrder: true },
});
expect(decks).toHaveLength(3);
expect(decks.every((deck) => deck.groupId === null)).toBe(true);
expect(decks.map((deck) => deck.sortOrder)).toEqual([0, 1, 2]);
});
});

View file

@ -0,0 +1,66 @@
import { prisma } from "@/lib/db";
export async function listMaterialGroups(
classId: string,
type?: "DECK" | "QUIZ"
) {
return prisma.materialGroup.findMany({
where: { classId, ...(type ? { type } : {}) },
orderBy: [{ sortOrder: "desc" }, { createdAt: "desc" }, { id: "asc" }],
});
}
export async function createMaterialGroup(data: {
classId: string;
name: string;
type: "DECK" | "QUIZ";
}) {
const classExists = await prisma.class.findUnique({
where: { id: data.classId },
select: { id: true },
});
if (!classExists) return null;
const maximum = await prisma.materialGroup.aggregate({
where: { classId: data.classId, type: data.type },
_max: { sortOrder: true },
});
return prisma.materialGroup.create({
data: { ...data, sortOrder: (maximum._max.sortOrder ?? -1) + 1 },
});
}
export async function renameMaterialGroup(id: string, name: string) {
return prisma.materialGroup.update({ where: { id }, data: { name } });
}
export async function deleteMaterialGroup(id: string) {
return prisma.$transaction(async (transaction) => {
const group = await transaction.materialGroup.findUnique({ where: { id } });
if (!group) return false;
await transaction.materialGroup.delete({ where: { id } });
if (group.type === "DECK") {
const items = await transaction.deck.findMany({
where: { classId: group.classId, groupId: null },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: { id: true },
});
await Promise.all(
items.map((item, sortOrder) =>
transaction.deck.update({ where: { id: item.id }, data: { sortOrder } })
)
);
} else {
const items = await transaction.quizSet.findMany({
where: { classId: group.classId, groupId: null },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: { id: true },
});
await Promise.all(
items.map((item, sortOrder) =>
transaction.quizSet.update({ where: { id: item.id }, data: { sortOrder } })
)
);
}
return true;
});
}

View file

@ -0,0 +1,131 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
ProgressConflictError,
ProgressNotFoundError,
ProgressValidationError,
clearProgress,
saveProgress,
} from "./progressService";
const classIds: string[] = [];
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: classIds.splice(0) } } });
});
async function createDeck() {
const classId = randomUUID();
classIds.push(classId);
return prisma.deck.create({
data: {
name: "Deck",
class: {
create: { id: classId, slug: `class-${classId}`, name: "Class" },
},
cards: { create: [{ front: "Front", back: "Back" }] },
},
include: { cards: true },
});
}
function payload(deckId: string, sessionId: string, revision: number) {
return {
contentType: "DECK" as const,
contentId: deckId,
mode: "SEQUENTIAL" as const,
currentIndex: 0,
order: [] as string[],
cardResults: {},
sessionId,
revision,
};
}
describe("revisioned progress", () => {
it("retains revision 3 when revisions 1 and 2 arrive later", async () => {
const deck = await createDeck();
await saveProgress(payload(deck.id, "session-a", 3));
await expect(saveProgress(payload(deck.id, "session-a", 1))).rejects.toBeInstanceOf(
ProgressConflictError
);
await expect(saveProgress(payload(deck.id, "session-a", 2))).rejects.toBeInstanceOf(
ProgressConflictError
);
expect(
await prisma.studyProgress.findUnique({
where: { deckId_mode: { deckId: deck.id, mode: "SEQUENTIAL" } },
})
).toMatchObject({ revision: 3, sessionId: "session-a" });
});
it("does not let an old-session delete remove a new session", async () => {
const deck = await createDeck();
await saveProgress(payload(deck.id, "old-session", 1));
await clearProgress({
contentType: "DECK",
contentId: deck.id,
mode: "SEQUENTIAL",
sessionId: "old-session",
});
await saveProgress(payload(deck.id, "new-session", 1));
expect(
await clearProgress({
contentType: "DECK",
contentId: deck.id,
mode: "SEQUENTIAL",
sessionId: "old-session",
})
).toBe(false);
expect(
await prisma.studyProgress.findUnique({
where: { deckId_mode: { deckId: deck.id, mode: "SEQUENTIAL" } },
})
).toMatchObject({ sessionId: "new-session", revision: 1 });
});
it("rejects a different session overwrite and missing content", async () => {
const deck = await createDeck();
await saveProgress(payload(deck.id, "session-a", 1));
await expect(saveProgress(payload(deck.id, "session-b", 2))).rejects.toBeInstanceOf(
ProgressConflictError
);
await expect(
saveProgress(payload(randomUUID(), "session-a", 1))
).rejects.toBeInstanceOf(ProgressNotFoundError);
});
it("atomically keeps the highest concurrently delivered revision", async () => {
const deck = await createDeck();
await saveProgress(payload(deck.id, "session-a", 1));
const results = await Promise.allSettled([
saveProgress(payload(deck.id, "session-a", 2)),
saveProgress(payload(deck.id, "session-a", 3)),
]);
expect(results.some((result) => result.status === "fulfilled")).toBe(true);
expect(
await prisma.studyProgress.findUnique({
where: { deckId_mode: { deckId: deck.id, mode: "SEQUENTIAL" } },
})
).toMatchObject({ revision: 3, sessionId: "session-a" });
});
it("rejects foreign order and result IDs instead of persisting corrupt progress", async () => {
const deck = await createDeck();
await expect(
saveProgress({
...payload(deck.id, "session-a", 1),
order: ["foreign-card"],
})
).rejects.toBeInstanceOf(ProgressValidationError);
await expect(
saveProgress({
...payload(deck.id, "session-a", 1),
cardResults: { "foreign-card": "correct" },
})
).rejects.toBeInstanceOf(ProgressValidationError);
expect(await prisma.studyProgress.count({ where: { deckId: deck.id } })).toBe(0);
});
});

View file

@ -1,85 +1,169 @@
import { prisma } from "@/lib/db";
import type {
ProgressDeleteInput,
ProgressPatchInput,
} from "@/lib/validation/progressSchemas";
import { isPrismaError } from "@/lib/validation/contentSchemas";
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 class ProgressNotFoundError extends Error {}
export class ProgressConflictError extends Error {}
export class ProgressValidationError extends Error {}
export async function upsertProgress(data: {
type ProgressTarget = {
contentType: "DECK" | "QUIZ";
contentId: string;
mode: "SEQUENTIAL" | "SHUFFLED";
currentIndex: number;
orderJson: string;
answersJson?: string;
cardResultsJson?: string;
}) {
const base = {
};
function progressWhere(target: ProgressTarget) {
return target.contentType === "DECK"
? { deckId_mode: { deckId: target.contentId, mode: target.mode } }
: { quizSetId_mode: { quizSetId: target.contentId, mode: target.mode } };
}
export async function getProgress(target: ProgressTarget) {
return prisma.studyProgress.findUnique({ where: progressWhere(target) });
}
export async function saveProgress(data: ProgressPatchInput) {
const deck =
data.contentType === "DECK"
? await prisma.deck.findUnique({
where: { id: data.contentId },
select: { cards: { select: { id: true } } },
})
: null;
const quiz =
data.contentType === "QUIZ"
? await prisma.quizSet.findUnique({
where: { id: data.contentId },
select: {
questions: {
select: {
id: true,
options: { select: { id: true } },
},
},
},
})
: null;
if (!deck && !quiz) throw new ProgressNotFoundError("Study content not found");
const liveIds = new Set(
deck
? deck.cards.map((card) => card.id)
: quiz!.questions.map((question) => question.id)
);
if (data.order.some((id) => !liveIds.has(id))) {
throw new ProgressValidationError(
"Progress order contains content that does not belong to this study item"
);
}
if (data.currentIndex > data.order.length) {
throw new ProgressValidationError("Progress index is beyond the saved order");
}
if (data.contentType === "DECK") {
if (Object.keys(data.cardResults).some((id) => !liveIds.has(id))) {
throw new ProgressValidationError(
"Card results contain a card that does not belong to this deck"
);
}
} else {
const optionsByQuestion = new Map(
quiz!.questions.map((question) => [
question.id,
new Set(question.options.map((option) => option.id)),
])
);
for (const [questionId, selectedIds] of Object.entries(data.answers)) {
if (!liveIds.has(questionId) || !data.order.includes(questionId)) {
throw new ProgressValidationError(
"Answers contain a question outside the saved quiz order"
);
}
const optionIds = optionsByQuestion.get(questionId);
if (selectedIds.some((id) => !optionIds?.has(id))) {
throw new ProgressValidationError(
"Answers contain an option that does not belong to its question"
);
}
}
}
const common = {
contentType: data.contentType,
mode: data.mode,
currentIndex: data.currentIndex,
orderJson: data.orderJson,
answersJson: data.answersJson ?? null,
cardResultsJson: data.cardResultsJson ?? null,
orderJson: JSON.stringify(data.order),
sessionId: data.sessionId,
revision: data.revision,
};
const content =
data.contentType === "DECK"
? {
answersJson: null,
cardResultsJson: JSON.stringify(data.cardResults),
}
: {
answersJson: JSON.stringify(data.answers),
cardResultsJson: null,
};
const storedData = { ...common, ...content };
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,
async function updateExisting(existingId: string) {
const updated = await prisma.studyProgress.updateMany({
where: {
id: existingId,
sessionId: data.sessionId,
revision: { lt: data.revision },
},
create: {
...base,
deckId: data.contentId,
data: storedData,
});
if (!updated.count) {
const current = await prisma.studyProgress.findUnique({
where: { id: existingId },
select: { sessionId: true },
});
throw new ProgressConflictError(
current && current.sessionId !== data.sessionId
? "A newer study session already owns this progress"
: "Progress revision is stale"
);
}
return prisma.studyProgress.findUniqueOrThrow({ where: { id: existingId } });
}
const where = progressWhere(data);
const existing = await prisma.studyProgress.findUnique({ where });
if (existing) return updateExisting(existing.id);
try {
return await prisma.studyProgress.create({
data: {
...storedData,
...(data.contentType === "DECK"
? { deckId: data.contentId }
: { quizSetId: data.contentId }),
},
});
} catch (error) {
if (!isPrismaError(error, "P2002")) throw error;
const collided = await prisma.studyProgress.findUnique({ where });
if (!collided) throw error;
return updateExisting(collided.id);
}
}
return prisma.studyProgress.upsert({
export async function clearProgress(data: ProgressDeleteInput) {
const result = await prisma.studyProgress.deleteMany({
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,
contentType: data.contentType,
mode: data.mode,
sessionId: data.sessionId,
...(data.contentType === "DECK"
? { deckId: data.contentId }
: { 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
}
return result.count > 0;
}

View file

@ -0,0 +1,150 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots";
import {
QuizAttemptValidationError,
submitQuizAttempt,
} from "./quizService";
const classIds: string[] = [];
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: classIds.splice(0) } } });
});
async function createQuiz(questionCount = 3) {
const classId = randomUUID();
classIds.push(classId);
return prisma.quizSet.create({
data: {
class: {
create: { id: classId, slug: `class-${classId}`, name: "Class" },
},
name: "Quiz",
questions: {
create: Array.from({ length: questionCount }, (_, index) => ({
type: "MULTIPLE_CHOICE",
prompt: `Original prompt ${index + 1}`,
rationale: `Original rationale ${index + 1}`,
category: "category",
sortOrder: index,
options: {
create: [
{ text: "Correct", isCorrect: true, sortOrder: 0 },
{ text: "Incorrect", isCorrect: false, sortOrder: 1 },
],
},
})),
},
},
include: {
questions: {
orderBy: { sortOrder: "asc" },
include: { options: { orderBy: { sortOrder: "asc" } } },
},
},
});
}
describe("submitQuizAttempt", () => {
it("scores the explicit full scope, snapshots unanswered questions, and clears full progress", async () => {
const quiz = await createQuiz();
await prisma.studyProgress.create({
data: {
contentType: "QUIZ",
quizSetId: quiz.id,
mode: "SEQUENTIAL",
currentIndex: 1,
orderJson: JSON.stringify(quiz.questions.map((question) => question.id)),
},
});
const questionIds = quiz.questions.map((question) => question.id);
const attempt = await submitQuizAttempt(quiz.id, {
questionIds,
answers: {
[questionIds[0]]: [quiz.questions[0].options[0].id],
[questionIds[1]]: [quiz.questions[1].options[1].id],
},
});
expect(attempt).toMatchObject({ score: 1, maxScore: 3, isPartialRetake: false });
expect(JSON.parse(attempt.answersJson)).toEqual({
[questionIds[0]]: [quiz.questions[0].options[0].id],
[questionIds[1]]: [quiz.questions[1].options[1].id],
[questionIds[2]]: [],
});
expect(parseQuizReviewSnapshot(attempt.reviewSnapshotJson)?.questions).toHaveLength(3);
expect(
await prisma.studyProgress.count({ where: { quizSetId: quiz.id } })
).toBe(0);
});
it("derives a partial retake and leaves full progress untouched", async () => {
const quiz = await createQuiz(3);
const questionIds = quiz.questions.slice(0, 2).map((question) => question.id);
await prisma.studyProgress.create({
data: {
contentType: "QUIZ",
quizSetId: quiz.id,
mode: "SEQUENTIAL",
currentIndex: 0,
orderJson: JSON.stringify(quiz.questions.map((question) => question.id)),
},
});
const attempt = await submitQuizAttempt(quiz.id, {
questionIds,
answers: {
[questionIds[0]]: [quiz.questions[0].options[0].id],
[questionIds[1]]: [quiz.questions[1].options[0].id],
},
});
expect(attempt).toMatchObject({ score: 2, maxScore: 2, isPartialRetake: true });
expect(
await prisma.studyProgress.count({ where: { quizSetId: quiz.id } })
).toBe(1);
});
it("rejects unknown questions, out-of-scope answers, and foreign options", async () => {
const quiz = await createQuiz(2);
const [first, second] = quiz.questions;
await expect(
submitQuizAttempt(quiz.id, { questionIds: ["unknown"], answers: {} })
).rejects.toBeInstanceOf(QuizAttemptValidationError);
await expect(
submitQuizAttempt(quiz.id, {
questionIds: [first.id],
answers: { [second.id]: [second.options[0].id] },
})
).rejects.toBeInstanceOf(QuizAttemptValidationError);
await expect(
submitQuizAttempt(quiz.id, {
questionIds: [first.id],
answers: { [first.id]: [second.options[0].id] },
})
).rejects.toBeInstanceOf(QuizAttemptValidationError);
expect(await prisma.quizAttempt.count({ where: { quizSetId: quiz.id } })).toBe(0);
});
it("keeps the review copy immutable after current content changes", async () => {
const quiz = await createQuiz(1);
const question = quiz.questions[0];
const attempt = await submitQuizAttempt(quiz.id, {
questionIds: [question.id],
answers: { [question.id]: [] },
});
await prisma.question.update({
where: { id: question.id },
data: { prompt: "Changed prompt", rationale: "Changed rationale" },
});
const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson);
expect(snapshot?.questions[0]).toMatchObject({
prompt: "Original prompt 1",
rationale: "Original rationale 1",
selections: [],
points: 0,
});
});
});

View file

@ -1,10 +1,25 @@
import { prisma } from "@/lib/db";
import type { QuizImportData } from "@/lib/validation/importSchemas";
import type { QuizAttemptInput } from "@/lib/validation/attemptSchemas";
import type { QuizReviewSnapshot } from "@/lib/quizSnapshots";
import { scoreQuiz } from "@/lib/scoring";
export class QuizAttemptValidationError extends Error {}
export class QuizNotFoundError extends Error {}
export class QuizContentValidationError extends Error {
constructor(message: string, public status: 400 | 404 | 409) {
super(message);
}
}
export async function listQuizSetsByClass(classId: string) {
return prisma.quizSet.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
orderBy: [
{ sortOrder: "asc" },
{ createdAt: "asc" },
{ id: "asc" },
],
include: {
_count: { select: { questions: true, attempts: true } },
progress: {
@ -13,6 +28,8 @@ export async function listQuizSetsByClass(classId: string) {
currentIndex: true,
orderJson: true,
answersJson: true,
sessionId: true,
revision: true,
},
},
},
@ -41,6 +58,21 @@ export async function createQuizSetFromImport(
overrideName?: string,
groupId?: string | null
) {
const [classItem, group] = await Promise.all([
prisma.class.findUnique({ where: { id: classId }, select: { id: true } }),
groupId
? prisma.materialGroup.findUnique({
where: { id: groupId },
select: { classId: true, type: true },
})
: null,
]);
if (!classItem) throw new QuizContentValidationError("Class not found", 404);
if (groupId && !group) throw new QuizContentValidationError("Group not found", 404);
if (group && (group.classId !== classId || group.type !== "QUIZ")) {
throw new QuizContentValidationError("Group must be a quiz group in the selected class", 400);
}
const maxOrder = await prisma.quizSet.aggregate({
where: { classId, groupId: groupId || null },
_max: { sortOrder: true },
@ -80,7 +112,7 @@ export async function createQuizSetFromImport(
export async function updateQuizSet(
id: string,
data: { name?: string; description?: string }
data: { name?: string; description?: string | null }
) {
return prisma.quizSet.update({
where: { id },
@ -94,14 +126,113 @@ export async function deleteQuizSet(id: string) {
});
}
export async function createQuizAttempt(data: {
quizSetId: string;
score: number;
maxScore: number;
answersJson: string;
isPartialRetake?: boolean;
}) {
return prisma.quizAttempt.create({ data });
export async function submitQuizAttempt(
quizSetId: string,
input: QuizAttemptInput
) {
return prisma.$transaction(async (transaction) => {
const quiz = await transaction.quizSet.findUnique({
where: { id: quizSetId },
include: {
questions: {
orderBy: { sortOrder: "asc" },
include: { options: { orderBy: { sortOrder: "asc" } } },
},
},
});
if (!quiz) throw new QuizNotFoundError("Quiz not found");
const questionsById = new Map(
quiz.questions.map((question) => [question.id, question])
);
const attemptedQuestions = input.questionIds.map((questionId) => {
const question = questionsById.get(questionId);
if (!question) {
throw new QuizAttemptValidationError(
`Question ${questionId} does not belong to this quiz`
);
}
return question;
});
const scope = new Set(input.questionIds);
for (const [questionId, selectedIds] of Object.entries(input.answers)) {
if (!scope.has(questionId)) {
throw new QuizAttemptValidationError(
`Answer for question ${questionId} is outside the attempted scope`
);
}
const question = questionsById.get(questionId);
if (!question) {
throw new QuizAttemptValidationError(`Unknown question ${questionId}`);
}
const optionIds = new Set(question.options.map((option) => option.id));
if (selectedIds.some((optionId) => !optionIds.has(optionId))) {
throw new QuizAttemptValidationError(
`An option does not belong to question ${questionId}`
);
}
if (question.type === "MULTIPLE_CHOICE" && selectedIds.length > 1) {
throw new QuizAttemptValidationError(
"Multiple-choice questions accept at most one option"
);
}
}
const canonicalAnswers = Object.fromEntries(
input.questionIds.map((questionId) => [
questionId,
input.answers[questionId] ?? [],
])
);
const formattedQuestions = attemptedQuestions.map((question) => ({
id: question.id,
type: question.type as "MULTIPLE_CHOICE" | "SATA",
options: question.options.map((option) => ({
id: option.id,
isCorrect: option.isCorrect,
})),
}));
const scored = scoreQuiz(formattedQuestions, canonicalAnswers);
const pointsByQuestion = new Map(
scored.perQuestion.map((item) => [item.questionId, item.points])
);
const snapshot: QuizReviewSnapshot = {
version: 1,
questions: attemptedQuestions.map((question) => ({
id: question.id,
type: question.type,
prompt: question.prompt,
rationale: question.rationale,
category: question.category,
options: question.options.map((option) => ({
id: option.id,
text: option.text,
isCorrect: option.isCorrect,
})),
selections: canonicalAnswers[question.id],
points: pointsByQuestion.get(question.id) ?? 0,
})),
};
const isPartialRetake = attemptedQuestions.length !== quiz.questions.length;
const attempt = await transaction.quizAttempt.create({
data: {
quizSetId,
score: scored.total,
maxScore: scored.maxScore,
answersJson: JSON.stringify(canonicalAnswers),
reviewSnapshotJson: JSON.stringify(snapshot),
isPartialRetake,
},
});
if (!isPartialRetake) {
await transaction.studyProgress.deleteMany({
where: { quizSetId, mode: "SEQUENTIAL" },
});
}
return attempt;
});
}
export async function listQuizAttempts(quizSetId: string) {

View file

@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
ReorderConflictError,
ReorderValidationError,
reorderContent,
} from "./reorderService";
const classIds: string[] = [];
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: classIds.splice(0) } } });
});
async function createClass(name: string) {
const id = randomUUID();
classIds.push(id);
return prisma.class.create({
data: { id, slug: `${name}-${id}`, name },
});
}
describe("reorderContent", () => {
it("validates completeness and computes contiguous order on the server", async () => {
const classItem = await createClass("class");
const [source, target] = await Promise.all([
prisma.materialGroup.create({
data: { classId: classItem.id, name: "Source", type: "DECK" },
}),
prisma.materialGroup.create({
data: { classId: classItem.id, name: "Target", type: "DECK" },
}),
]);
const first = await prisma.deck.create({
data: { classId: classItem.id, groupId: source.id, name: "First", sortOrder: 7 },
});
const second = await prisma.deck.create({
data: { classId: classItem.id, groupId: source.id, name: "Second", sortOrder: 7 },
});
await expect(
reorderContent("DECK", { items: [{ id: first.id, groupId: target.id }] })
).rejects.toBeInstanceOf(ReorderConflictError);
await reorderContent("DECK", {
items: [
{ id: second.id, groupId: source.id },
{ id: first.id, groupId: target.id },
],
});
expect(
await prisma.deck.findMany({
where: { classId: classItem.id },
orderBy: [{ groupId: "asc" }, { sortOrder: "asc" }],
select: { id: true, groupId: true, sortOrder: true },
})
).toEqual(
expect.arrayContaining([
{ id: second.id, groupId: source.id, sortOrder: 0 },
{ id: first.id, groupId: target.id, sortOrder: 0 },
])
);
});
it("rejects cross-class and cross-type group assignment without changes", async () => {
const ownClass = await createClass("own");
const foreignClass = await createClass("foreign");
const deck = await prisma.deck.create({
data: { classId: ownClass.id, name: "Deck" },
});
const [foreignGroup, quizGroup] = await Promise.all([
prisma.materialGroup.create({
data: { classId: foreignClass.id, name: "Foreign", type: "DECK" },
}),
prisma.materialGroup.create({
data: { classId: ownClass.id, name: "Quiz", type: "QUIZ" },
}),
]);
for (const groupId of [foreignGroup.id, quizGroup.id]) {
await expect(
reorderContent("DECK", { items: [{ id: deck.id, groupId }] })
).rejects.toBeInstanceOf(ReorderValidationError);
}
expect(await prisma.deck.findUnique({ where: { id: deck.id } })).toMatchObject({
groupId: null,
sortOrder: 0,
});
});
});

View file

@ -0,0 +1,101 @@
import { prisma } from "@/lib/db";
import type { ReorderInput } from "@/lib/validation/reorderSchemas";
export class ReorderValidationError extends Error {}
export class ReorderConflictError extends Error {}
type ContentType = "DECK" | "QUIZ";
export async function reorderContent(
contentType: ContentType,
input: ReorderInput
) {
const ids = input.items.map((item) => item.id);
const records =
contentType === "DECK"
? await prisma.deck.findMany({
where: { id: { in: ids } },
select: { id: true, classId: true, groupId: true },
})
: await prisma.quizSet.findMany({
where: { id: { in: ids } },
select: { id: true, classId: true, groupId: true },
});
if (records.length !== ids.length) {
throw new ReorderValidationError("Reorder contains unknown content IDs");
}
const classIds = new Set(records.map((record) => record.classId));
if (classIds.size !== 1) {
throw new ReorderValidationError("All reordered items must belong to one class");
}
const classId = records[0].classId;
const targetGroupIds = [
...new Set(
input.items
.map((item) => item.groupId)
.filter((groupId): groupId is string => groupId !== null)
),
];
const groups = await prisma.materialGroup.findMany({
where: { id: { in: targetGroupIds } },
select: { id: true, classId: true, type: true },
});
if (
groups.length !== targetGroupIds.length ||
groups.some(
(group) => group.classId !== classId || group.type !== contentType
)
) {
throw new ReorderValidationError(
"Target groups must match the content class and type"
);
}
const affectedGroupIds = [
...new Set([
...records.map((record) => record.groupId),
...input.items.map((item) => item.groupId),
]),
];
const completeRecords =
contentType === "DECK"
? await prisma.deck.findMany({
where: {
classId,
OR: affectedGroupIds.map((groupId) => ({ groupId })),
},
select: { id: true },
})
: await prisma.quizSet.findMany({
where: {
classId,
OR: affectedGroupIds.map((groupId) => ({ groupId })),
},
select: { id: true },
});
const submitted = new Set(ids);
if (
completeRecords.length !== submitted.size ||
completeRecords.some((record) => !submitted.has(record.id))
) {
throw new ReorderConflictError(
"Reorder must include every item in each affected group"
);
}
const nextOrder = new Map<string | null, number>();
const updates = input.items.map((item) => {
const sortOrder = nextOrder.get(item.groupId) ?? 0;
nextOrder.set(item.groupId, sortOrder + 1);
return contentType === "DECK"
? prisma.deck.update({
where: { id: item.id },
data: { groupId: item.groupId, sortOrder },
})
: prisma.quizSet.update({
where: { id: item.id },
data: { groupId: item.groupId, sortOrder },
});
});
await prisma.$transaction(updates);
}

View file

@ -0,0 +1,61 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
ShareTargetNotFoundError,
getShareLink,
getShareLinkMeta,
toggleShareLink,
} from "./shareService";
const classIds: string[] = [];
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: classIds.splice(0) } } });
});
async function createClass(label: string) {
const id = randomUUID();
classIds.push(id);
return prisma.class.create({
data: { id, slug: `${label}-${id}`, name: label },
});
}
describe("share invariants", () => {
it("creates exactly one typed target and rejects missing targets", async () => {
const classItem = await createClass("class");
const deck = await prisma.deck.create({
data: { classId: classItem.id, name: "Deck" },
});
const link = await toggleShareLink("DECK", deck.id);
expect(link).toMatchObject({
targetType: "DECK",
deckId: deck.id,
quizSetId: null,
groupId: null,
});
await expect(toggleShareLink("QUIZ", randomUUID())).rejects.toBeInstanceOf(
ShareTargetNotFoundError
);
});
it("filters a tampered foreign-class item from a group share", async () => {
const owner = await createClass("owner");
const foreign = await createClass("foreign");
const group = await prisma.materialGroup.create({
data: { classId: owner.id, name: "Group", type: "DECK" },
});
const validDeck = await prisma.deck.create({
data: { classId: owner.id, groupId: group.id, name: "Valid" },
});
await prisma.deck.create({
data: { classId: foreign.id, groupId: group.id, name: "Tampered" },
});
const link = await toggleShareLink("GROUP", group.id);
const loaded = await getShareLink(link!.id);
expect(loaded?.group?.decks.map((deck) => deck.id)).toEqual([validDeck.id]);
const metadata = await getShareLinkMeta(link!.id);
expect(metadata?.group?.decks.map((deck) => deck.id)).toEqual([validDeck.id]);
});
});

View file

@ -1,7 +1,7 @@
import { prisma } from "@/lib/db";
export async function getShareLink(token: string) {
return prisma.shareLink.findUnique({
const link = await prisma.shareLink.findUnique({
where: { id: token },
include: {
deck: {
@ -47,6 +47,30 @@ export async function getShareLink(token: string) {
}
},
});
if (!link) return null;
const targets = [link.deck, link.quizSet, link.group].filter(Boolean);
const agrees =
(link.targetType === "DECK" && Boolean(link.deck)) ||
(link.targetType === "QUIZ" && Boolean(link.quizSet)) ||
(link.targetType === "GROUP" && Boolean(link.group));
if (targets.length !== 1 || !agrees) return null;
if (!link.group) return link;
return {
...link,
group: {
...link.group,
decks: link.group.decks.filter(
(deck) =>
link.group?.type === "DECK" &&
deck.class.slug === link.group.class.slug
),
quizSets: link.group.quizSets.filter(
(quiz) =>
link.group?.type === "QUIZ" &&
quiz.class.slug === link.group.class.slug
),
},
};
}
/**
@ -54,9 +78,10 @@ export async function getShareLink(token: string) {
* metadata without loading every card, question and option.
*/
export async function getShareLinkMeta(token: string) {
return prisma.shareLink.findUnique({
const link = await prisma.shareLink.findUnique({
where: { id: token },
select: {
targetType: true,
deck: {
select: {
name: true,
@ -79,17 +104,53 @@ export async function getShareLinkMeta(token: string) {
type: true,
class: { select: { slug: true, name: true } },
decks: {
select: { id: true, name: true, description: true, _count: { select: { cards: true } } },
select: {
id: true,
name: true,
description: true,
class: { select: { slug: true, name: true } },
_count: { select: { cards: true } },
},
orderBy: { sortOrder: "asc" },
},
quizSets: {
select: { id: true, name: true, description: true, _count: { select: { questions: true } } },
select: {
id: true,
name: true,
description: true,
class: { select: { slug: true, name: true } },
_count: { select: { questions: true } },
},
orderBy: { sortOrder: "asc" },
},
},
},
},
});
if (!link) return null;
const targets = [link.deck, link.quizSet, link.group].filter(Boolean);
const agrees =
(link.targetType === "DECK" && Boolean(link.deck)) ||
(link.targetType === "QUIZ" && Boolean(link.quizSet)) ||
(link.targetType === "GROUP" && Boolean(link.group));
if (targets.length !== 1 || !agrees) return null;
if (!link.group) return link;
return {
...link,
group: {
...link.group,
decks: link.group.decks.filter(
(deck) =>
link.group?.type === "DECK" &&
deck.class.slug === link.group.class.slug
),
quizSets: link.group.quizSets.filter(
(quiz) =>
link.group?.type === "QUIZ" &&
quiz.class.slug === link.group.class.slug
),
},
};
}
export async function getShareLinkForContent(targetType: "DECK" | "QUIZ" | "GROUP", contentId: string) {
@ -103,25 +164,47 @@ export async function getShareLinkForContent(targetType: "DECK" | "QUIZ" | "GROU
}
export async function toggleShareLink(targetType: "DECK" | "QUIZ" | "GROUP", 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,
groupId: targetType === "GROUP" ? contentId : null,
},
return prisma.$transaction(async (transaction) => {
const target =
targetType === "DECK"
? await transaction.deck.findUnique({
where: { id: contentId },
select: { id: true },
})
: targetType === "QUIZ"
? await transaction.quizSet.findUnique({
where: { id: contentId },
select: { id: true },
})
: await transaction.materialGroup.findUnique({
where: { id: contentId },
select: { id: true },
});
if (!target) throw new ShareTargetNotFoundError("Share target not found");
const existing =
targetType === "DECK"
? await transaction.shareLink.findUnique({ where: { deckId: contentId } })
: targetType === "QUIZ"
? await transaction.shareLink.findUnique({ where: { quizSetId: contentId } })
: await transaction.shareLink.findUnique({ where: { groupId: contentId } });
if (existing) {
await transaction.shareLink.delete({ where: { id: existing.id } });
return null;
}
return transaction.shareLink.create({
data: {
targetType,
deckId: targetType === "DECK" ? contentId : null,
quizSetId: targetType === "QUIZ" ? contentId : null,
groupId: targetType === "GROUP" ? contentId : null,
},
});
});
}
export class ShareTargetNotFoundError extends Error {}
export async function isContentSharedViaGroup(targetType: "DECK" | "QUIZ", contentId: string) {
let groupId: string | null = null;

View file

@ -0,0 +1,154 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
getStudyState,
reviewCard,
SpacedRepetitionServiceError,
} from "./spacedRepetitionService";
const classIds: string[] = [];
const now = new Date("2026-08-07T18:00:00.000Z");
afterEach(async () => {
await prisma.class.deleteMany({ where: { id: { in: classIds.splice(0) } } });
});
async function fixture(newCardsPerDay = 30) {
const id = randomUUID();
classIds.push(id);
const classItem = await prisma.class.create({
data: { id, name: "SRS", slug: `srs-${id}` },
});
const deck = await prisma.deck.create({
data: {
classId: classItem.id,
name: "Deck",
cards: {
create: [
{ front: "First", back: "One", sortOrder: 0 },
{ front: "Second", back: "Two", sortOrder: 1 },
],
},
},
include: { cards: { orderBy: { sortOrder: "asc" } } },
});
const set = await prisma.spacedRepetitionSet.create({
data: {
classId: classItem.id,
name: "Set",
newCardsPerDay,
decks: { create: { deckId: deck.id } },
},
});
return { set, cards: deck.cards };
}
function stateData(due: Date, state = 2) {
return {
due,
stability: 1,
difficulty: 5,
elapsedDays: 0,
scheduledDays: 0,
learningSteps: 0,
reps: 1,
lapses: 0,
state,
firstReviewedAt: new Date("2026-08-06T18:00:00.000Z"),
lastReview: new Date("2026-08-06T18:00:00.000Z"),
};
}
describe("reviewCard queue authorization", () => {
it("rejects unseen cards when the new-card allowance is exhausted", async () => {
const { set, cards } = await fixture(0);
await expect(reviewCard({
setId: set.id,
flashcardId: cards[0].id,
rating: "GOOD",
expectedStateVersion: null,
now,
})).rejects.toMatchObject({ status: 409 });
});
it("accepts only the selected card and rejects a future non-learning card", async () => {
const { set, cards } = await fixture();
await expect(reviewCard({
setId: set.id,
flashcardId: cards[1].id,
rating: "GOOD",
expectedStateVersion: null,
now,
})).rejects.toMatchObject({ status: 409 });
const future = await prisma.spacedRepetitionCardState.create({
data: {
setId: set.id,
flashcardId: cards[0].id,
...stateData(new Date(now.getTime() + 60 * 60 * 1000)),
},
});
await expect(reviewCard({
setId: set.id,
flashcardId: cards[0].id,
rating: "GOOD",
expectedStateVersion: future.updatedAt.toISOString(),
now,
})).rejects.toMatchObject({ status: 409 });
});
it("accepts valid due and deliberate learn-ahead reviews", async () => {
const dueFixture = await fixture(0);
const due = await prisma.spacedRepetitionCardState.create({
data: {
setId: dueFixture.set.id,
flashcardId: dueFixture.cards[0].id,
...stateData(new Date(now.getTime() - 1_000)),
},
});
await expect(reviewCard({
setId: dueFixture.set.id,
flashcardId: dueFixture.cards[0].id,
rating: "GOOD",
expectedStateVersion: due.updatedAt.toISOString(),
now,
})).resolves.toBeDefined();
const aheadFixture = await fixture(0);
const ahead = await prisma.spacedRepetitionCardState.create({
data: {
setId: aheadFixture.set.id,
flashcardId: aheadFixture.cards[0].id,
...stateData(new Date(now.getTime() + 10 * 60 * 1000), 1),
},
});
await expect(reviewCard({
setId: aheadFixture.set.id,
flashcardId: aheadFixture.cards[0].id,
rating: "GOOD",
expectedStateVersion: ahead.updatedAt.toISOString(),
now,
})).resolves.toBeDefined();
});
it("turns a concurrent first review into one success and one handled conflict", async () => {
const { set } = await fixture();
const initial = await getStudyState(set.id, now);
const input = {
setId: set.id,
flashcardId: initial.currentCard!.id,
rating: "GOOD" as const,
expectedStateVersion: null,
now,
};
const results = await Promise.allSettled([reviewCard(input), reviewCard(input)]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toBeDefined();
if (rejected?.status === "rejected") {
expect(rejected.reason).toBeInstanceOf(SpacedRepetitionServiceError);
expect(rejected.reason).toMatchObject({ status: 409 });
}
});
});

View file

@ -14,6 +14,7 @@ import type {
CreateSpacedRepetitionSetInput,
UpdateSpacedRepetitionSetInput,
} from "@/lib/validation/spacedRepetitionSchemas";
import { isPrismaError } from "@/lib/validation/contentSchemas";
export class SpacedRepetitionServiceError extends Error {
constructor(message: string, public status: 400 | 404 | 409) {
@ -428,21 +429,44 @@ export async function reviewCard(data: {
throw new SpacedRepetitionServiceError("This card was already reviewed. Refreshing the queue is required.", 409);
}
const summary = await getSetSummary(data.setId, now);
const eligible = await findNextCard(
data.setId,
now,
summary.stats.newCardsAvailable > 0
);
if (!eligible || eligible.flashcard.id !== data.flashcardId) {
throw new SpacedRepetitionServiceError(
"This card is not currently eligible for review. Refreshing the queue is required.",
409
);
}
const next = scheduleRating(current ? asStoredState(current) : null, data.rating, now);
await prisma.$transaction(async (tx) => {
if (current) {
const updated = await tx.spacedRepetitionCardState.updateMany({
where: { id: current.id, updatedAt: current.updatedAt },
data: next,
});
if (!updated.count) throw new SpacedRepetitionServiceError("Card state changed", 409);
} else {
await tx.spacedRepetitionCardState.create({
data: { setId: data.setId, flashcardId: data.flashcardId, ...next, firstReviewedAt: now },
});
try {
await prisma.$transaction(async (tx) => {
if (current) {
const updated = await tx.spacedRepetitionCardState.updateMany({
where: { id: current.id, updatedAt: current.updatedAt },
data: next,
});
if (!updated.count) throw new SpacedRepetitionServiceError("Card state changed", 409);
} else {
await tx.spacedRepetitionCardState.create({
data: { setId: data.setId, flashcardId: data.flashcardId, ...next, firstReviewedAt: now },
});
}
await tx.studyActivity.create({ data: { type: "FLASHCARD", occurredAt: now } });
});
} catch (error) {
if (isPrismaError(error, "P2002")) {
throw new SpacedRepetitionServiceError(
"This card was already reviewed. Refreshing the queue is required.",
409
);
}
await tx.studyActivity.create({ data: { type: "FLASHCARD", occurredAt: now } });
});
throw error;
}
return getStudyState(data.setId, now);
}