Refactor Study Desk application structure
This commit is contained in:
parent
faaccf8a7e
commit
089439ed90
145 changed files with 8087 additions and 3412 deletions
32
src/services/activityService.test.ts
Normal file
32
src/services/activityService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
48
src/services/authService.test.ts
Normal file
48
src/services/authService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
27
src/services/classService.test.ts
Normal file
27
src/services/classService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
8
src/services/healthService.ts
Normal file
8
src/services/healthService.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
40
src/services/materialGroupService.test.ts
Normal file
40
src/services/materialGroupService.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
66
src/services/materialGroupService.ts
Normal file
66
src/services/materialGroupService.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
131
src/services/progressService.test.ts
Normal file
131
src/services/progressService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
150
src/services/quizService.test.ts
Normal file
150
src/services/quizService.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
91
src/services/reorderService.test.ts
Normal file
91
src/services/reorderService.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/services/reorderService.ts
Normal file
101
src/services/reorderService.ts
Normal 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);
|
||||
}
|
||||
61
src/services/shareService.test.ts
Normal file
61
src/services/shareService.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
154
src/services/spacedRepetitionService.test.ts
Normal file
154
src/services/spacedRepetitionService.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue