diff --git a/.env.example b/.env.example
index 22361b8..bd3a815 100644
--- a/.env.example
+++ b/.env.example
@@ -22,7 +22,6 @@ NEXTAUTH_SECRET="" # auto-filled by `npm run setup` (or: o
# --- Admin & Security -----------------------------------------
ADMIN_USER_IDS="" # comma-separated user UUIDs allowed to access /admin
-CRON_SECRET="" # auto-filled by `npm run setup` — shared secret for cron/internal endpoints
# WARNING: ALLOW_TEST_AUTH makes the app trust the X-User-Id header
# as the authenticated identity. It exists ONLY for automated tests.
@@ -64,6 +63,7 @@ AI_CACHE_TTL_SECONDS="3600"
AI_DAILY_COST_CAP_USD="1.0" # max spend per user per day (default $1)
AI_MONTHLY_COST_CAP_USD="10.0" # max spend per user per month (default $10)
AI_DISABLED="false" # emergency kill switch — set "true" to block all AI calls
+# MOCK_AI_MALFORMED="1" # test-only: makes the mock provider emit malformed output (never set outside tests)
# --- Background Worker ----------------------------------------
JOB_WORKER_CONCURRENCY="2"
diff --git a/README.md b/README.md
index 85711d5..61c2611 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ Then open http://localhost:3000. The defaults need no API keys or external accou
To be blunt about the default: mock mode exists for tests. With `AI_PROVIDER` set to `mock`, sessions get template questions and canned feedback, which exercises the machinery but teaches you nothing. To actually study, set `AI_PROVIDER` to `openai` and add an `OPENAI_API_KEY` in `.env`.
-What `npm run setup` does: copies `.env.example` to `.env`, generates `NEXTAUTH_SECRET`, `TOKEN_ENC_KEY`, and `CRON_SECRET`, starts PostgreSQL through Docker, and applies all migrations. It is idempotent and never overwrites a value you set yourself.
+What `npm run setup` does: copies `.env.example` to `.env`, generates `NEXTAUTH_SECRET` and `TOKEN_ENC_KEY`, starts PostgreSQL through Docker, and applies all migrations. It is idempotent and never overwrites a value you set yourself.
Notes:
diff --git a/docker-compose.yml b/docker-compose.yml
index b212af6..ecf1955 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,7 +26,6 @@ services:
BASE_URL: ${BASE_URL:-http://localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET in .env or environment}
- CRON_SECRET: ${CRON_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
GOOGLE_PROVIDER: ${GOOGLE_PROVIDER:-fake}
AI_PROVIDER: ${AI_PROVIDER:-mock}
diff --git a/next.config.js b/next.config.js
index 7a9db54..e1da177 100644
--- a/next.config.js
+++ b/next.config.js
@@ -27,13 +27,6 @@ const nextConfig = {
},
],
},
- {
- source: "/sw.js",
- headers: [
- { key: "Service-Worker-Allowed", value: "/" },
- { key: "Cache-Control", value: "no-cache" },
- ],
- },
],
}
diff --git a/package-lock.json b/package-lock.json
index 4b4b4ed..93df98e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,6 @@
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
- "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.4.2",
"eslint": "^8.57.1",
@@ -1903,16 +1902,6 @@
"csstype": "^3.2.2"
}
},
- "node_modules/@types/web-push": {
- "version": "3.6.4",
- "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
- "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
diff --git a/package.json b/package.json
index eee670b..a07101b 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,6 @@
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
- "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.4.2",
"eslint": "^8.57.1",
diff --git a/prisma/migrations/20260716120000_drop_notification_prefs_and_dead_columns/migration.sql b/prisma/migrations/20260716120000_drop_notification_prefs_and_dead_columns/migration.sql
new file mode 100644
index 0000000..a404896
--- /dev/null
+++ b/prisma/migrations/20260716120000_drop_notification_prefs_and_dead_columns/migration.sql
@@ -0,0 +1,6 @@
+-- Drop the unused notification preferences table (feature was never shipped)
+DROP TABLE "notification_preferences";
+
+-- Drop dead columns from user_game_state
+ALTER TABLE "user_game_state" DROP COLUMN "display_name";
+ALTER TABLE "user_game_state" DROP COLUMN "leaderboard_visible";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cb2a95e..fcd6cda 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -589,7 +589,6 @@ model Achievement {
model UserGameState {
id String @id @default(uuid())
userId String @unique @map("user_id")
- displayName String? @map("display_name")
dailyXpGoal Int @default(50) @map("daily_xp_goal")
streakFreezes Int @default(0) @map("streak_freezes")
streakFreezeUsedDate String? @map("streak_freeze_used_date") // YYYY-MM-DD
@@ -597,7 +596,6 @@ model UserGameState {
studyEndTime String @default("17:00") @map("study_end_time")
dailyStudyCap Int @default(180) @map("daily_study_cap")
onboardingComplete Boolean @default(false) @map("onboarding_complete")
- leaderboardVisible Boolean @default(true) @map("leaderboard_visible")
timezone String? // IANA timezone for streak day boundaries; null = UTC
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -617,9 +615,8 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
- notificationPreference NotificationPreference?
- chatMessages ChatMessage[]
- verificationTokens VerificationToken[]
+ chatMessages ChatMessage[]
+ verificationTokens VerificationToken[]
@@map("users")
}
@@ -641,25 +638,6 @@ model ChatMessage {
@@map("chat_messages")
}
-// ── Notification Preferences ────────────────────────────────────
-
-model NotificationPreference {
- id String @id @default(uuid())
- userId String @unique @map("user_id")
- emailReminders Boolean @default(true) @map("email_reminders")
- pushReminders Boolean @default(true) @map("push_reminders")
- reminderTime String @default("09:00") @map("reminder_time") // HH:MM
- streakReminders Boolean @default(true) @map("streak_reminders")
- weeklyDigest Boolean @default(true) @map("weekly_digest")
- emailAddress String? @map("email_address") // override if different from User.email
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
- @@map("notification_preferences")
-}
-
// ── Verification Tokens (email verify, password reset) ──────────
model VerificationToken {
diff --git a/scripts/setup.mjs b/scripts/setup.mjs
index 5bbd201..dab1dc5 100644
--- a/scripts/setup.mjs
+++ b/scripts/setup.mjs
@@ -6,7 +6,7 @@
*
* What it does:
* 1. Copies .env.example -> .env (never overwrites an existing .env).
- * 2. Generates NEXTAUTH_SECRET, TOKEN_ENC_KEY and CRON_SECRET where they
+ * 2. Generates NEXTAUTH_SECRET and TOKEN_ENC_KEY where they
* are empty (user-set values are never touched).
* 3. Starts the Postgres container via Docker Compose (if Docker is
* available) and waits for its healthcheck.
@@ -26,7 +26,7 @@ const envPath = join(root, ".env");
const examplePath = join(root, ".env.example");
// ---------------------------------------------------------------------------
-// 0. Node version check (Next.js 15 + this script need Node 20.19+)
+// 0. Node version check (Next.js 14 + this script need Node 20.19+)
// ---------------------------------------------------------------------------
const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 19)) {
@@ -81,7 +81,6 @@ if (existsSync(envPath)) {
const SECRET_GENERATORS = {
NEXTAUTH_SECRET: () => randomBytes(32).toString("base64"),
TOKEN_ENC_KEY: () => randomBytes(32).toString("hex"),
- CRON_SECRET: () => randomBytes(16).toString("hex"),
};
let envText = readFileSync(envPath, "utf8");
diff --git a/src/__tests__/content-prompts-validation.test.ts b/src/__tests__/content-prompts-validation.test.ts
index 94d558a..9ae71ad 100644
--- a/src/__tests__/content-prompts-validation.test.ts
+++ b/src/__tests__/content-prompts-validation.test.ts
@@ -206,6 +206,32 @@ describe("generateContentAwarePrompts output validation", () => {
expect(mcq.meta?.key_points).toEqual(["point one", "point two"]);
});
+ it("sanitizes difficulty: rounds floats, clamps to 1..5, and defaults non-finite/non-number values to 1", async () => {
+ mockRunTaskRaw({
+ prompts: [
+ { ...validPrompt(1), difficulty: 3.6 }, // float → rounded
+ { ...validPrompt(2), difficulty: NaN }, // NaN → 1
+ { ...validPrompt(3), difficulty: Infinity }, // non-finite → 1
+ { ...validPrompt(4), difficulty: "4" }, // string → 1 (not coerced)
+ { ...validPrompt(5), difficulty: 99 }, // above range → 5
+ { ...validPrompt(6), difficulty: -2 }, // below range → 1
+ { ...validPrompt(7), difficulty: undefined }, // missing → 1
+ { ...validPrompt(8), difficulty: 0.4 }, // rounds to 0 → clamped to 1
+ ],
+ });
+
+ const result = await generateContentAwarePrompts(DEFAULT_PARAMS);
+
+ expect(result).not.toBeNull();
+ expect(result!.map((p) => p.difficulty)).toEqual([4, 1, 1, 1, 5, 1, 1, 1]);
+ // Every persisted difficulty is a valid Int in range for the Prisma column
+ for (const p of result!) {
+ expect(Number.isInteger(p.difficulty)).toBe(true);
+ expect(p.difficulty).toBeGreaterThanOrEqual(1);
+ expect(p.difficulty).toBeLessThanOrEqual(5);
+ }
+ });
+
it("returns null when runTask throws", async () => {
runTaskMock.mockRejectedValue(new Error("provider down"));
diff --git a/src/__tests__/feedback-persistence.test.ts b/src/__tests__/feedback-persistence.test.ts
index 8747041..95a6597 100644
--- a/src/__tests__/feedback-persistence.test.ts
+++ b/src/__tests__/feedback-persistence.test.ts
@@ -227,14 +227,25 @@ describe("feedback persistence and eager generation", () => {
expect(runTaskMock).not.toHaveBeenCalled();
});
- it("persists the empty-results response when search finds nothing", async () => {
+ it("persists an explicit no-sources terminal state when search finds nothing", async () => {
searchChunksMock.mockResolvedValue([]);
const result = await generateFeedback("user-1", "attempt-1");
- expect(result).toEqual({ status: "OK", excerpts: [] });
+ // no_sources marks the empty result as terminal — clients stop polling
+ // and render an honest fallback instead of an eternal spinner.
+ expect(result).toEqual({ status: "OK", excerpts: [], no_sources: true });
expect(store.row?.feedbackStatus).toBe("READY");
expect(store.row?.feedbackJson).toEqual(result);
+
+ // Refetch returns the persisted terminal state verbatim — no
+ // regeneration, no renewed searching.
+ searchChunksMock.mockClear();
+ runTaskMock.mockClear();
+ const second = await generateFeedback("user-1", "attempt-1");
+ expect(second).toEqual(result);
+ expect(searchChunksMock).not.toHaveBeenCalled();
+ expect(runTaskMock).not.toHaveBeenCalled();
});
it("does not persist or claim for unscored (EXAM-phase) attempts", async () => {
diff --git a/src/__tests__/feedback-polling.test.ts b/src/__tests__/feedback-polling.test.ts
new file mode 100644
index 0000000..6cc03f9
--- /dev/null
+++ b/src/__tests__/feedback-polling.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Unit tests for the client-side feedback poll loop (pollFeedback).
+ *
+ * The live-audit bug: an empty/failed feedback generation left the review
+ * panel spinning forever. The contract under test: PENDING is the ONLY
+ * non-terminal status — any other result (OK with content, OK + no_sources,
+ * UNAVAILABLE) resolves the poll immediately, the poll budget yields null
+ * instead of spinning, and cancellation stops the loop without a result.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { pollFeedback } from "@/app/s/[sessionId]/feedback-poll";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock);
+
+function jsonResponse(body: unknown, ok = true) {
+ return { ok, json: async () => body };
+}
+
+const notCancelled = () => false;
+
+describe("pollFeedback terminal semantics", () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ });
+
+ it("resolves on the first non-PENDING result (single fetch)", async () => {
+ const payload = { status: "OK", excerpts: [], explanation: "because osmosis" };
+ fetchMock.mockResolvedValue(jsonResponse(payload));
+
+ const result = await pollFeedback("attempt-1", notCancelled, 5, 1);
+
+ expect(result).toEqual(payload);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("treats the explicit no-sources state as terminal — no further polling", async () => {
+ const payload = { status: "OK", excerpts: [], no_sources: true };
+ fetchMock.mockResolvedValue(jsonResponse(payload));
+
+ const result = await pollFeedback("attempt-1", notCancelled, 5, 1);
+
+ expect(result).toEqual(payload);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("treats UNAVAILABLE (generation failure) as terminal", async () => {
+ const payload = { status: "UNAVAILABLE", excerpts: [] };
+ fetchMock.mockResolvedValue(jsonResponse(payload));
+
+ const result = await pollFeedback("attempt-1", notCancelled, 5, 1);
+
+ expect(result).toEqual(payload);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps polling through PENDING, then returns the terminal result", async () => {
+ const terminal = { status: "OK", excerpts: [], no_sources: true };
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ status: "PENDING", excerpts: [] }))
+ .mockResolvedValueOnce(jsonResponse({ status: "PENDING", excerpts: [] }))
+ .mockResolvedValueOnce(jsonResponse(terminal));
+
+ const result = await pollFeedback("attempt-1", notCancelled, 5, 1);
+
+ expect(result).toEqual(terminal);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("returns null (not an infinite spin) when the poll budget is exhausted", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ status: "PENDING", excerpts: [] }));
+
+ const result = await pollFeedback("attempt-1", notCancelled, 3, 1);
+
+ expect(result).toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("returns null without fetching when already cancelled", async () => {
+ const result = await pollFeedback("attempt-1", () => true, 5, 1);
+
+ expect(result).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("stops mid-loop when cancellation flips during a PENDING stretch", async () => {
+ let cancelled = false;
+ fetchMock.mockImplementation(async () => {
+ cancelled = true; // cancel after the first response comes back
+ return jsonResponse({ status: "PENDING", excerpts: [] });
+ });
+
+ const result = await pollFeedback("attempt-1", () => cancelled, 5, 1);
+
+ expect(result).toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("propagates a fetch error so the caller can settle into a fallback", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ error: "Internal server error" }, false));
+
+ await expect(pollFeedback("attempt-1", notCancelled, 5, 1)).rejects.toThrow(
+ "Internal server error",
+ );
+ });
+});
diff --git a/src/__tests__/followups.test.ts b/src/__tests__/followups.test.ts
new file mode 100644
index 0000000..55f3c2f
--- /dev/null
+++ b/src/__tests__/followups.test.ts
@@ -0,0 +1,143 @@
+import { describe, it, expect } from "vitest";
+import { computeFollowupSchedule } from "@/services/followups";
+
+/**
+ * Unit tests for the pure follow-up scheduling core: deterministic mapping
+ * from a completed run's results onto concrete plan dates, timezone-aware,
+ * exam-capped. (DB insertion + idempotency are covered by the integration
+ * suite in src/__tests__/integration/followups.test.ts.)
+ */
+
+const AVAILABILITY = Array.from({ length: 7 }, () => ({
+ start: "09:00",
+ end: "17:00",
+}));
+
+// 2026-07-16 15:00:00 UTC = 11:00 in America/New_York (EDT)
+const ENDED_AT = new Date("2026-07-16T15:00:00Z");
+
+function baseInput(overrides: Partial[0]> = {}) {
+ return {
+ accuracy: 0.75,
+ endedAt: ENDED_AT,
+ timezone: "America/New_York",
+ examDate: "2026-08-15",
+ planStartDate: "2026-07-16",
+ availability: AVAILABILITY,
+ ...overrides,
+ };
+}
+
+describe("computeFollowupSchedule", () => {
+ it("is a deterministic function of the run's results", () => {
+ const a = computeFollowupSchedule(baseInput());
+ const b = computeFollowupSchedule(baseInput());
+ expect(a).toEqual(b);
+ });
+
+ it("uses the spaced-repetition ladder offsets (accuracy 0.75 -> 2 and 4 days)", () => {
+ const { planned, skipped } = computeFollowupSchedule(baseInput());
+ expect(skipped).toEqual([]);
+ expect(planned.map((p) => p.days_from_now)).toEqual([2, 4]);
+ expect(planned.map((p) => p.date)).toEqual(["2026-07-18", "2026-07-20"]);
+ });
+
+ it("uses shorter intervals for low accuracy and longer for high accuracy", () => {
+ const low = computeFollowupSchedule(baseInput({ accuracy: 0.5 }));
+ const high = computeFollowupSchedule(baseInput({ accuracy: 0.9 }));
+ expect(low.planned.map((p) => p.days_from_now)).toEqual([1, 2]);
+ expect(high.planned.map((p) => p.days_from_now)).toEqual([3, 6]);
+ });
+
+ it("computes calendar dates in the plan's timezone, not the server's", () => {
+ // 03:00 UTC on the 16th is still 23:00 on the 15th in New York, so
+ // "+2 days" lands on the 17th there.
+ const { planned } = computeFollowupSchedule(
+ baseInput({ endedAt: new Date("2026-07-16T03:00:00Z") }),
+ );
+ expect(planned.map((p) => p.date)).toEqual(["2026-07-17", "2026-07-19"]);
+ });
+
+ it("schedules at the availability window start in the plan's timezone", () => {
+ const { planned } = computeFollowupSchedule(baseInput());
+ // 09:00 America/New_York (EDT, UTC-4) = 13:00 UTC
+ expect(planned[0].start_time.toISOString()).toBe("2026-07-18T13:00:00.000Z");
+ expect(planned[0].day_index).toBe(2);
+ });
+
+ it("gives every follow-up a ~30 minute duration", () => {
+ const { planned } = computeFollowupSchedule(baseInput());
+ for (const p of planned) {
+ expect(p.end_time.getTime() - p.start_time.getTime()).toBe(30 * 60000);
+ }
+ });
+
+ it("skips dates past the exam", () => {
+ const { planned, skipped } = computeFollowupSchedule(
+ baseInput({ examDate: "2026-07-18" }),
+ );
+ expect(planned.map((p) => p.days_from_now)).toEqual([2]);
+ expect(skipped).toEqual([
+ { date: "2026-07-20", days_from_now: 4, reason: "past_exam" },
+ ]);
+ });
+
+ it("skips everything when the exam is tomorrow and accuracy is high", () => {
+ const { planned, skipped } = computeFollowupSchedule(
+ baseInput({ accuracy: 0.95, examDate: "2026-07-17" }),
+ );
+ expect(planned).toEqual([]);
+ expect(skipped).toHaveLength(2);
+ expect(skipped.every((s) => s.reason === "past_exam")).toBe(true);
+ });
+
+ it("allows a follow-up on the exam day itself", () => {
+ const { planned } = computeFollowupSchedule(baseInput({ examDate: "2026-07-18" }));
+ expect(planned.some((p) => p.date === "2026-07-18")).toBe(true);
+ });
+
+ it("wraps availability by weekday for days beyond the plan window", () => {
+ const availability = AVAILABILITY.map((a, i) =>
+ i === 1 ? { start: "14:30", end: "18:00" } : a,
+ );
+ // Plan started 7-16; run ends 7-20 (day 4); +4 days = day 8 -> wraps to
+ // availability[1] (14:30).
+ const { planned } = computeFollowupSchedule(
+ baseInput({
+ endedAt: new Date("2026-07-20T15:00:00Z"),
+ availability,
+ }),
+ );
+ const late = planned.find((p) => p.days_from_now === 4)!;
+ expect(late.day_index).toBe(8);
+ // 14:30 America/New_York (EDT) = 18:30 UTC
+ expect(late.start_time.toISOString()).toBe("2026-07-24T18:30:00.000Z");
+ });
+
+ it("falls back to 09:00 when no availability windows exist", () => {
+ const { planned } = computeFollowupSchedule(baseInput({ availability: null }));
+ expect(planned[0].start_time.toISOString()).toBe("2026-07-18T13:00:00.000Z");
+ });
+
+ it("handles DST transitions (spring forward) without drifting", () => {
+ // 2026-03-06 ends; +2 days = 2026-03-08, the US spring-forward day.
+ const { planned } = computeFollowupSchedule(
+ baseInput({
+ endedAt: new Date("2026-03-06T15:00:00Z"),
+ planStartDate: "2026-03-06",
+ examDate: "2026-04-01",
+ }),
+ );
+ // 09:00 America/New_York on 3/8 is EDT (UTC-4) = 13:00 UTC
+ expect(planned[0].date).toBe("2026-03-08");
+ expect(planned[0].start_time.toISOString()).toBe("2026-03-08T13:00:00.000Z");
+ });
+
+ it("falls back to server-local wall time for an invalid timezone", () => {
+ const { planned } = computeFollowupSchedule(baseInput({ timezone: "Not/AZone" }));
+ expect(planned).toHaveLength(2);
+ const d = planned[0].start_time;
+ expect(d.getHours()).toBe(9);
+ expect(d.getMinutes()).toBe(0);
+ });
+});
diff --git a/src/__tests__/integration/followups.test.ts b/src/__tests__/integration/followups.test.ts
new file mode 100644
index 0000000..0634c08
--- /dev/null
+++ b/src/__tests__/integration/followups.test.ts
@@ -0,0 +1,265 @@
+/**
+ * Integration tests for the follow-up scheduler.
+ *
+ * Tests: scheduleFollowups inserts RETRIEVAL plan items on the active plan,
+ * is idempotent per run, and reports "no_plan" when the user has no active
+ * study plan.
+ *
+ * Requires a running PostgreSQL database. Set DATABASE_URL before running.
+ * Run: DATABASE_URL= npm run test:integration
+ */
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+const hasDb = !!process.env.DATABASE_URL;
+
+let prisma: any;
+let createPlan: any;
+let scheduleFollowups: any;
+
+const userId = "test_user_followups";
+const noPlanUserId = "test_user_followups_noplan";
+
+async function cleanup(uid: string) {
+ await prisma.studyPlanItem.deleteMany({ where: { plan: { userId: uid } } });
+ await prisma.studyPlan.deleteMany({ where: { userId: uid } });
+ await prisma.sessionRun.deleteMany({ where: { userId: uid } });
+ await prisma.session.deleteMany({ where: { userId: uid } });
+}
+
+/** Create a completed RETRIEVAL session + run for the given user/course. */
+async function createCompletedRun(
+ uid: string,
+ courseName: string,
+ accuracy: number,
+ suffix: string,
+) {
+ const sessionId = `fu_session_${suffix}`;
+ const runId = `fu_run_${suffix}`;
+ await prisma.session.create({
+ data: {
+ sessionId,
+ userId: uid,
+ courseId: "",
+ courseName,
+ examId: "",
+ examName: "Midterm",
+ mode: "RETRIEVAL",
+ topicScope: "Data structures, Algorithms",
+ objectives: [
+ { id: "obj_1", title: "Data structures" },
+ { id: "obj_2", title: "Algorithms" },
+ ],
+ targetOutcome: { type: "accuracy", target_accuracy: 0.8 },
+ breakProtocol: { type: "50_10", cycles: 1 },
+ plannedMinutes: 30,
+ },
+ });
+ await prisma.sessionRun.create({
+ data: {
+ runId,
+ sessionId,
+ userId: uid,
+ mode: "RETRIEVAL",
+ phase: "COMPLETE",
+ status: "COMPLETED",
+ startedAt: new Date(Date.now() - 30 * 60 * 1000),
+ endedAt: new Date(),
+ prompts: [],
+ metrics: {
+ attempts_count: 8,
+ correct_count: 6,
+ partial_count: 1,
+ incorrect_count: 1,
+ accuracy,
+ time_spent_seconds: 1200,
+ },
+ },
+ });
+ return { sessionId, runId };
+}
+
+describe.skipIf(!hasDb)("Integration: follow-up scheduling", () => {
+ let planId: string;
+ let runId: string;
+ let baselineItemCount: number;
+
+ const futureExam = new Date();
+ futureExam.setDate(futureExam.getDate() + 30);
+ const examDateStr = futureExam.toISOString().split("T")[0];
+
+ beforeAll(async () => {
+ const dbModule = await import("@/lib/db");
+ prisma = dbModule.prisma;
+ const planService = await import("@/services/plan");
+ createPlan = planService.createPlan;
+ const followupService = await import("@/services/followups");
+ scheduleFollowups = followupService.scheduleFollowups;
+
+ await cleanup(userId);
+ await cleanup(noPlanUserId);
+
+ const plan = await createPlan(userId, {
+ course_name: "FOLLOWUP 301",
+ exam_name: "Midterm",
+ exam_date: examDateStr,
+ objectives: ["Data structures", "Algorithms", "Graph theory"],
+ availability: Array.from({ length: 7 }, () => ({
+ start: "09:00",
+ end: "17:00",
+ })),
+ daily_study_cap_minutes: 180,
+ break_protocol_default: "50_10",
+ });
+ planId = plan.plan_id;
+ baselineItemCount = plan.items.length;
+
+ const run = await createCompletedRun(userId, "FOLLOWUP 301", 0.75, "main");
+ runId = run.runId;
+ });
+
+ afterAll(async () => {
+ if (!prisma) return;
+ await cleanup(userId);
+ await cleanup(noPlanUserId);
+ await prisma.$disconnect();
+ });
+
+ it("schedules the recommended follow-ups on the active plan", async () => {
+ const result = await scheduleFollowups(userId, runId);
+ expect("data" in result).toBe(true);
+ expect(result.data.status).toBe("scheduled");
+ expect(result.data.plan_id).toBe(planId);
+ // Accuracy 0.75 -> +2 and +4 days, both well before the exam
+ expect(result.data.scheduled).toHaveLength(2);
+ expect(result.data.scheduled.map((s: any) => s.days_from_now)).toEqual([2, 4]);
+ expect(result.data.skipped).toEqual([]);
+ for (const s of result.data.scheduled) {
+ expect(s.session_id).toBeDefined();
+ expect(s.session_url).toContain(`/s/${s.session_id}`);
+ expect(s.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(s.date <= examDateStr).toBe(true);
+ }
+ });
+
+ it("created RETRIEVAL sessions carrying the run marker and the objectives", async () => {
+ const sessions = await prisma.session.findMany({
+ where: {
+ userId,
+ resources: { path: ["followup_of_run_id"], equals: runId },
+ },
+ });
+ expect(sessions).toHaveLength(2);
+ for (const s of sessions) {
+ expect(s.mode).toBe("RETRIEVAL");
+ expect(s.plannedMinutes).toBe(30);
+ expect(s.courseName).toBe("FOLLOWUP 301");
+ expect(s.topicScope).toContain("Data structures");
+ expect(s.objectives).toEqual([
+ { id: "obj_1", title: "Data structures" },
+ { id: "obj_2", title: "Algorithms" },
+ ]);
+ }
+ });
+
+ it("created plan items for the follow-up sessions", async () => {
+ const plan = await prisma.studyPlan.findUnique({
+ where: { planId },
+ include: { items: true },
+ });
+ expect(plan.items).toHaveLength(baselineItemCount + 2);
+
+ const sessions = await prisma.session.findMany({
+ where: { userId, resources: { path: ["followup_of_run_id"], equals: runId } },
+ select: { sessionId: true },
+ });
+ for (const s of sessions) {
+ const item = plan.items.find((i: any) => i.sessionId === s.sessionId);
+ expect(item, `plan item for ${s.sessionId} missing`).toBeDefined();
+ expect(item.status).toBe("SCHEDULED");
+ expect(item.endTime.getTime() - item.startTime.getTime()).toBe(30 * 60000);
+ }
+ });
+
+ it("is idempotent: a second call does not double-insert", async () => {
+ const result = await scheduleFollowups(userId, runId);
+ expect("data" in result).toBe(true);
+ expect(result.data.status).toBe("already_scheduled");
+ expect(result.data.scheduled).toHaveLength(2);
+
+ const sessions = await prisma.session.findMany({
+ where: { userId, resources: { path: ["followup_of_run_id"], equals: runId } },
+ });
+ expect(sessions).toHaveLength(2);
+
+ const plan = await prisma.studyPlan.findUnique({
+ where: { planId },
+ include: { items: true },
+ });
+ expect(plan.items).toHaveLength(baselineItemCount + 2);
+ });
+
+ it("returns no_plan when the user has no active study plan", async () => {
+ const { runId: orphanRunId } = await createCompletedRun(
+ noPlanUserId,
+ "NOPLAN 101",
+ 0.75,
+ "noplan",
+ );
+ const result = await scheduleFollowups(noPlanUserId, orphanRunId);
+ expect("data" in result).toBe(true);
+ expect(result.data.status).toBe("no_plan");
+
+ // Nothing was inserted
+ const sessions = await prisma.session.findMany({
+ where: {
+ userId: noPlanUserId,
+ resources: { path: ["followup_of_run_id"], equals: orphanRunId },
+ },
+ });
+ expect(sessions).toHaveLength(0);
+ });
+
+ it("rejects runs owned by another user", async () => {
+ const result = await scheduleFollowups("intruder", runId);
+ expect("error" in result).toBe(true);
+ expect(result.error).toBe("forbidden");
+ });
+
+ it("rejects unknown runs", async () => {
+ const result = await scheduleFollowups(userId, "does_not_exist");
+ expect("error" in result).toBe(true);
+ expect(result.error).toBe("not_found");
+ });
+
+ it("rejects runs that are not completed", async () => {
+ const sessionId = "fu_session_active";
+ const activeRunId = "fu_run_active";
+ await prisma.session.create({
+ data: {
+ sessionId,
+ userId,
+ courseId: "",
+ courseName: "FOLLOWUP 301",
+ examId: "",
+ examName: "Midterm",
+ mode: "RETRIEVAL",
+ topicScope: "Graph theory",
+ plannedMinutes: 30,
+ },
+ });
+ await prisma.sessionRun.create({
+ data: {
+ runId: activeRunId,
+ sessionId,
+ userId,
+ mode: "RETRIEVAL",
+ status: "ACTIVE",
+ prompts: [],
+ metrics: { attempts_count: 0, accuracy: 0 },
+ },
+ });
+ const result = await scheduleFollowups(userId, activeRunId);
+ expect("error" in result).toBe(true);
+ expect(result.error).toBe("run_not_completed");
+ });
+});
diff --git a/src/__tests__/integration/mode-parity.test.ts b/src/__tests__/integration/mode-parity.test.ts
index ed24db8..af19b62 100644
--- a/src/__tests__/integration/mode-parity.test.ts
+++ b/src/__tests__/integration/mode-parity.test.ts
@@ -450,8 +450,9 @@ describe.skipIf(!hasDb)("Integration: ERROR_REPAIR mode", () => {
data: { lastCorrectAt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
});
+ // Same course as the original error — ERROR_REPAIR decks are course-scoped
const result = await createSession(userId, {
- course_name: "REPAIR TEST DAY2",
+ course_name: "REPAIR TEST",
exam_name: "Quiz 1",
mode: "ERROR_REPAIR",
topic_scope: "Errors",
@@ -480,9 +481,10 @@ describe.skipIf(!hasDb)("Integration: ERROR_REPAIR mode", () => {
});
it("resolved errors are not included in new ERROR_REPAIR decks", async () => {
- // Create another repair session
+ // Create another repair session in the SAME course (a different course
+ // would exclude the error via course scoping, not via resolution)
const result = await createSession(userId, {
- course_name: "REPAIR TEST 2",
+ course_name: "REPAIR TEST",
exam_name: "Quiz 2",
mode: "ERROR_REPAIR",
topic_scope: "Errors",
@@ -502,6 +504,219 @@ describe.skipIf(!hasDb)("Integration: ERROR_REPAIR mode", () => {
});
});
+// ============================================================
+// ERROR_REPAIR course scoping
+// ============================================================
+
+describe.skipIf(!hasDb)("Integration: ERROR_REPAIR decks are course-scoped", () => {
+ const userId = "test_errorrepair_scope_user";
+ let getDeckPreview: any;
+ let courseAErrorId: string;
+ let courseBErrorId: string;
+
+ beforeAll(async () => {
+ const dbModule = await import("@/lib/db");
+ prisma = dbModule.prisma;
+ const sessionService = await import("@/services/session");
+ createSession = sessionService.createSession;
+ const runService = await import("@/services/run");
+ startOrResumeRun = runService.startOrResumeRun;
+ getDeckPreview = runService.getDeckPreview;
+
+ // Seed one unresolved error in each of two courses. Runs and error logs
+ // are inserted directly — the deck builder only cares about the rows,
+ // not how they got there.
+ const seedError = async (courseName: string, correctionRule: string) => {
+ const created = await createSession(userId, {
+ course_name: courseName,
+ exam_name: "Quiz 1",
+ mode: "RETRIEVAL",
+ topic_scope: "Errors",
+ planned_minutes: 15,
+ objectives: [{ id: "obj_1", title: "Topic" }],
+ target_outcome: { prompt_count: 2 },
+ break_protocol: { type: "TEST_3_2", cycles: 1 },
+ });
+ const run = await prisma.sessionRun.create({
+ data: {
+ runId: `run_scope_${courseName.replace(/\W/g, "_").toLowerCase()}`,
+ sessionId: created.session_id,
+ userId,
+ status: "COMPLETED",
+ prompts: [],
+ },
+ });
+ const log = await prisma.sessionErrorLog.create({
+ data: {
+ runId: run.runId,
+ userId,
+ promptIndex: 0,
+ errorType: "MISCONCEPTION",
+ correctionRule: correctionRule,
+ variantQuestion: `Variant for ${courseName}`,
+ },
+ });
+ return log.id;
+ };
+
+ courseAErrorId = await seedError("SCOPE COURSE A", "Rule for course A");
+ courseBErrorId = await seedError("SCOPE COURSE B", "Rule for course B");
+ });
+
+ afterAll(async () => {
+ if (!prisma) return;
+ await prisma.sessionErrorLog.deleteMany({ where: { run: { userId } } });
+ await prisma.sessionAttempt.deleteMany({ where: { run: { userId } } });
+ await prisma.sessionRun.deleteMany({ where: { userId } });
+ await prisma.session.deleteMany({ where: { userId } });
+ });
+
+ it("an ERROR_REPAIR deck contains only the current course's unresolved errors", async () => {
+ const result = await createSession(userId, {
+ course_name: "SCOPE COURSE A",
+ exam_name: "Quiz 1",
+ mode: "ERROR_REPAIR",
+ topic_scope: "Errors",
+ planned_minutes: 15,
+ target_outcome: { prompt_count: 5 },
+ break_protocol: { type: "TEST_3_2", cycles: 1 },
+ });
+
+ const startResult = await startOrResumeRun(userId, result.session_id);
+ const d = startResult.data!;
+ expect(d.mode).toBe("ERROR_REPAIR");
+ const prompts = d.prompts as any[];
+ const sourceIds = prompts
+ .map((p) => p.meta?.source_error_log_id)
+ .filter(Boolean);
+
+ // Course A's error is repaired; course B's error never leaks in
+ expect(sourceIds).toContain(courseAErrorId);
+ expect(sourceIds).not.toContain(courseBErrorId);
+ });
+
+ it("getDeckPreview counts only the current course's unresolved errors", async () => {
+ // Course B still has its own unresolved error (untouched above)
+ const previewB = await getDeckPreview(userId, {
+ courseName: "SCOPE COURSE B",
+ mode: "ERROR_REPAIR",
+ objectives: null,
+ promptCount: 5,
+ });
+ expect(previewB.repair_count).toBe(1);
+ expect(previewB.total).toBe(1);
+
+ // A course with no errors at all falls back to a small retrieval deck
+ const previewEmpty = await getDeckPreview(userId, {
+ courseName: "SCOPE COURSE EMPTY",
+ mode: "ERROR_REPAIR",
+ objectives: null,
+ promptCount: 5,
+ });
+ expect(previewEmpty.repair_count).toBe(0);
+ expect(previewEmpty.new_count).toBe(3);
+ });
+});
+
+// ============================================================
+// Completion race: exactly one caller runs post-completion effects
+// ============================================================
+
+describe.skipIf(!hasDb)("Integration: concurrent final attempt + completeRun", () => {
+ const userId = "test_completion_race_user";
+ let submitAttemptFn: any;
+ let completeRunFn: any;
+
+ beforeAll(async () => {
+ const dbModule = await import("@/lib/db");
+ prisma = dbModule.prisma;
+ const sessionService = await import("@/services/session");
+ createSession = sessionService.createSession;
+ const runService = await import("@/services/run");
+ startOrResumeRun = runService.startOrResumeRun;
+ submitAttemptFn = runService.submitAttempt;
+ completeRunFn = runService.completeRun;
+
+ // Suppress pre-test prepends so the deck is exactly the requested prompts
+ await prisma.objectiveMastery.createMany({
+ data: [{ userId, courseName: "RACE 101", objectiveKey: "obj_race" }],
+ skipDuplicates: true,
+ });
+ });
+
+ afterAll(async () => {
+ if (!prisma) return;
+ await prisma.xpEvent.deleteMany({ where: { userId } });
+ await prisma.sessionErrorLog.deleteMany({ where: { run: { userId } } });
+ await prisma.sessionAttempt.deleteMany({ where: { run: { userId } } });
+ await prisma.sessionRun.deleteMany({ where: { userId } });
+ await prisma.session.deleteMany({ where: { userId } });
+ await prisma.objectiveMastery.deleteMany({ where: { userId } });
+ });
+
+ it("racing the final attempt against completeRun awards SESSION_COMPLETED XP exactly once", async () => {
+ const created = await createSession(userId, {
+ course_name: "RACE 101",
+ exam_name: "Midterm",
+ mode: "RETRIEVAL",
+ topic_scope: "Racing",
+ planned_minutes: 15,
+ objectives: [{ id: "obj_race", title: "Race conditions" }],
+ target_outcome: { prompt_count: 2 },
+ break_protocol: { type: "TEST_3_2", cycles: 1 },
+ });
+ const startResult = await startOrResumeRun(userId, created.session_id);
+ const runId = startResult.data!.run_id as string;
+
+ // Answer everything except the last prompt
+ const promptCount = startResult.data!.prompt_count as number;
+ for (let i = 0; i < promptCount - 1; i++) {
+ const res = await submitAttemptFn(userId, runId, {
+ prompt_index: i,
+ user_answer: `Answer ${i}`,
+ self_score: "CORRECT",
+ time_to_answer_seconds: 5,
+ });
+ expect("data" in res).toBe(true);
+ }
+
+ // Race the final attempt against a manual completeRun. Exactly one path
+ // may claim the ACTIVE -> COMPLETED transition and run post-completion
+ // effects; the loser must observe the completed run (as data or a
+ // run_completed error), never resurrect it or re-run effects.
+ const [attemptRes, completeRes] = await Promise.all([
+ submitAttemptFn(userId, runId, {
+ prompt_index: promptCount - 1,
+ user_answer: "Final answer",
+ self_score: "CORRECT",
+ time_to_answer_seconds: 5,
+ }),
+ completeRunFn(userId, runId),
+ ]);
+
+ // completeRun is idempotent — it always reports COMPLETED
+ expect("data" in completeRes).toBe(true);
+ expect(completeRes.data!.status).toBe("COMPLETED");
+ // The attempt either landed (winner or pre-completion) or was rejected
+ // because completeRun won the transition first
+ if ("error" in attemptRes) {
+ expect(attemptRes.error).toBe("run_completed");
+ } else {
+ expect(attemptRes.data!.status).toBe("COMPLETED");
+ }
+
+ // The run is completed exactly once
+ const run = await prisma.sessionRun.findUnique({ where: { runId } });
+ expect(run.status).toBe("COMPLETED");
+
+ // Post-completion effects ran exactly once: one SESSION_COMPLETED XP event
+ const xpEvents = await prisma.xpEvent.findMany({
+ where: { userId, action: "SESSION_COMPLETED", sourceId: runId },
+ });
+ expect(xpEvents).toHaveLength(1);
+ });
+});
+
// ============================================================
// Phase enforcement across modes
// ============================================================
diff --git a/src/__tests__/plan-generator.test.ts b/src/__tests__/plan-generator.test.ts
index 451486f..1bebbb9 100644
--- a/src/__tests__/plan-generator.test.ts
+++ b/src/__tests__/plan-generator.test.ts
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
-import { generatePlan, PlanBlock } from "@/lib/plan-generator";
+import {
+ generatePlan,
+ PlanBlock,
+ slugifyObjectiveTitle,
+ buildObjectiveIdMap,
+} from "@/lib/plan-generator";
// ---------------------------------------------------------------------------
// Helpers
@@ -211,6 +216,59 @@ describe("plan-generator: determinism", () => {
});
});
+// ---------------------------------------------------------------------------
+// C2) Stable objective ids
+// ---------------------------------------------------------------------------
+
+describe("plan-generator: stable objective ids", () => {
+ it("slugifies titles: lowercase, non-alphanumeric runs to single underscore, trimmed", () => {
+ expect(slugifyObjectiveTitle("Photosynthesis: Light Reactions!")).toBe(
+ "photosynthesis_light_reactions",
+ );
+ expect(slugifyObjectiveTitle(" --Weird spacing-- ")).toBe("weird_spacing");
+ expect(slugifyObjectiveTitle("Objective 1")).toBe("objective_1");
+ });
+
+ it("caps slugs at 60 chars without a trailing underscore", () => {
+ const long = "A ".repeat(80); // slugs to a_a_a_... far beyond 60
+ const slug = slugifyObjectiveTitle(long);
+ expect(slug.length).toBeLessThanOrEqual(60);
+ expect(slug.endsWith("_")).toBe(false);
+ });
+
+ it("falls back to a non-empty id for titles with no alphanumerics", () => {
+ expect(slugifyObjectiveTitle("!!!")).toBe("objective");
+ });
+
+ it("disambiguates colliding slugs deterministically in first-seen order", () => {
+ const map = buildObjectiveIdMap(["Loops!", "Loops?", "Loops."]);
+ expect(map.get("Loops!")).toBe("loops");
+ expect(map.get("Loops?")).toBe("loops_2");
+ expect(map.get("Loops.")).toBe("loops_3");
+ });
+
+ it("gives the same objective the same id in every block it appears in", () => {
+ // 10 objectives → packs split; interleaved and full-scope blocks would
+ // previously remint per-block obj_N ids that collided across blocks.
+ const blocks = generatePlan(defaultInput({ objectives: makeObjectives(10) }));
+ const idByTitle = new Map();
+ const titleById = new Map();
+ for (const b of blocks) {
+ for (const o of b.objectives) {
+ const priorId = idByTitle.get(o.title);
+ expect(priorId ?? o.id, `"${o.title}" changed id across blocks`).toBe(o.id);
+ idByTitle.set(o.title, o.id);
+ const priorTitle = titleById.get(o.id);
+ expect(priorTitle ?? o.title, `id "${o.id}" reused for a different title`).toBe(o.title);
+ titleById.set(o.id, o.title);
+ }
+ }
+ // Ids derive from titles, not positions
+ expect(idByTitle.get("Objective 1")).toBe("objective_1");
+ expect(idByTitle.get("Objective 10")).toBe("objective_10");
+ });
+});
+
// ---------------------------------------------------------------------------
// D) Edge cases
// ---------------------------------------------------------------------------
diff --git a/src/__tests__/research-plan-generator.test.ts b/src/__tests__/research-plan-generator.test.ts
index 1b3e787..2bc7fc7 100644
--- a/src/__tests__/research-plan-generator.test.ts
+++ b/src/__tests__/research-plan-generator.test.ts
@@ -147,6 +147,22 @@ describe("generatePlanWithResearch", () => {
expect(result.blocks[2].mode).toBe("INTERLEAVED_PRACTICE");
expect(result.blocks[3].mode).toBe("EXAM_SIM");
+
+ // Objective ids are stable slugs of the titles: the same objective
+ // carries the same id in every block it appears in.
+ expect(result.blocks[0].objectives).toEqual([
+ { id: "algebra_basics", title: "Algebra basics" },
+ { id: "linear_equations", title: "Linear equations" },
+ ]);
+ const idsByTitle = new Map();
+ for (const block of result.blocks) {
+ for (const o of block.objectives) {
+ const prior = idsByTitle.get(o.title);
+ expect(prior ?? o.id).toBe(o.id);
+ idsByTitle.set(o.title, o.id);
+ }
+ }
+ expect(idsByTitle.get("Word problems")).toBe("word_problems");
});
it("filters out blocks with invalid modes", async () => {
diff --git a/src/__tests__/reveal-route.test.ts b/src/__tests__/reveal-route.test.ts
new file mode 100644
index 0000000..10c29dc
--- /dev/null
+++ b/src/__tests__/reveal-route.test.ts
@@ -0,0 +1,110 @@
+/**
+ * Unit tests for GET /api/runs/[runId]/reveal — the answer-standard reveal
+ * used at self-score time for free-recall prompts.
+ *
+ * Contract: authenticated owner only, index validated, service errors map to
+ * 404/403/409, and the model_answer/key_points payload passes through for
+ * the happy path (the MCQ pre-answer guard lives in the service and returns
+ * a null payload — never the answer key).
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { NextRequest } from "next/server";
+
+vi.mock("@/lib/auth", () => ({
+ getUserId: vi.fn(async (request: Request) => request.headers.get("x-user-id")),
+}));
+
+vi.mock("@/services/run", () => ({
+ getAnswerReveal: vi.fn(),
+}));
+
+import { GET } from "@/app/api/runs/[runId]/reveal/route";
+import { getAnswerReveal } from "@/services/run";
+
+const getAnswerRevealMock = vi.mocked(getAnswerReveal);
+
+function makeRequest(index: string | null = "0", userId: string | null = "user-1"): NextRequest {
+ const headers: Record = {};
+ if (userId) headers["x-user-id"] = userId;
+ const qs = index === null ? "" : `?index=${index}`;
+ return new Request(`http://localhost/api/runs/run-1/reveal${qs}`, {
+ headers,
+ }) as unknown as NextRequest;
+}
+
+const PARAMS = { params: { runId: "run-1" } };
+
+describe("GET /api/runs/[runId]/reveal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns 401 without a user", async () => {
+ const res = await GET(makeRequest("0", null), PARAMS);
+ expect(res.status).toBe(401);
+ expect(getAnswerRevealMock).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 for a missing or negative index", async () => {
+ expect((await GET(makeRequest(null), PARAMS)).status).toBe(400);
+ expect((await GET(makeRequest("-1"), PARAMS)).status).toBe(400);
+ expect((await GET(makeRequest("abc"), PARAMS)).status).toBe(400);
+ expect(getAnswerRevealMock).not.toHaveBeenCalled();
+ });
+
+ it("maps not_found to 404 and forbidden to 403", async () => {
+ getAnswerRevealMock.mockResolvedValue({ error: "not_found" });
+ expect((await GET(makeRequest(), PARAMS)).status).toBe(404);
+
+ getAnswerRevealMock.mockResolvedValue({ error: "forbidden" });
+ expect((await GET(makeRequest(), PARAMS)).status).toBe(403);
+ });
+
+ it("maps wrong_phase (EXAM) and wrong_index to 409", async () => {
+ getAnswerRevealMock.mockResolvedValue({
+ error: "wrong_phase",
+ message: "Answers are revealed after the exam phase",
+ });
+ expect((await GET(makeRequest(), PARAMS)).status).toBe(409);
+
+ getAnswerRevealMock.mockResolvedValue({ error: "wrong_index", expected: 2 });
+ const res = await GET(makeRequest("5"), PARAMS);
+ expect(res.status).toBe(409);
+ expect(await res.json()).toEqual({ error: "Wrong prompt index", expected: 2 });
+ });
+
+ it("returns model_answer and key_points for the authenticated owner", async () => {
+ getAnswerRevealMock.mockResolvedValue({
+ data: { model_answer: "Water moves toward solute.", key_points: ["gradient", "membrane"] },
+ });
+
+ const res = await GET(makeRequest(), PARAMS);
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ model_answer: "Water moves toward solute.",
+ key_points: ["gradient", "membrane"],
+ });
+ expect(getAnswerRevealMock).toHaveBeenCalledWith("user-1", "run-1", 0);
+ });
+
+ it("passes through a graceful-absence payload (prompt without key points)", async () => {
+ getAnswerRevealMock.mockResolvedValue({
+ data: { model_answer: null, key_points: null },
+ });
+
+ const res = await GET(makeRequest(), PARAMS);
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ model_answer: null, key_points: null });
+ });
+
+ it("returns 500 when the service throws", async () => {
+ getAnswerRevealMock.mockRejectedValue(new Error("boom"));
+
+ const res = await GET(makeRequest(), PARAMS);
+
+ expect(res.status).toBe(500);
+ expect(await res.json()).toEqual({ error: "Internal server error" });
+ });
+});
diff --git a/src/__tests__/validation-content.test.ts b/src/__tests__/validation-content.test.ts
index 79ee161..1dbf17f 100644
--- a/src/__tests__/validation-content.test.ts
+++ b/src/__tests__/validation-content.test.ts
@@ -2,10 +2,6 @@ import { describe, it, expect } from "vitest";
import {
uploadDocumentSchema,
searchContentSchema,
- createPracticeSetSchema,
- importQuestionsSchema,
- createEvidencePaperSchema,
- createEvidenceCardSchema,
} from "@/lib/validation-content";
describe("uploadDocumentSchema", () => {
@@ -74,85 +70,3 @@ describe("searchContentSchema", () => {
expect(result.success).toBe(false);
});
});
-
-describe("createPracticeSetSchema", () => {
- it("accepts valid input", () => {
- const result = createPracticeSetSchema.safeParse({
- course_name: "CS 101",
- title: "Midterm Prep",
- });
- expect(result.success).toBe(true);
- });
-
- it("rejects empty title", () => {
- const result = createPracticeSetSchema.safeParse({
- course_name: "CS 101",
- title: "",
- });
- expect(result.success).toBe(false);
- });
-});
-
-describe("importQuestionsSchema", () => {
- it("accepts valid question array", () => {
- const result = importQuestionsSchema.safeParse({
- questions: [
- { kind: "SHORT_ANSWER", prompt_text: "What is X?" },
- { kind: "MCQ", prompt_text: "Choose A/B/C", answer_key: "A" },
- ],
- });
- expect(result.success).toBe(true);
- });
-
- it("rejects empty questions array", () => {
- const result = importQuestionsSchema.safeParse({
- questions: [],
- });
- expect(result.success).toBe(false);
- });
-
- it("rejects invalid kind", () => {
- const result = importQuestionsSchema.safeParse({
- questions: [{ kind: "ESSAY", prompt_text: "Write about X" }],
- });
- expect(result.success).toBe(false);
- });
-});
-
-describe("createEvidencePaperSchema", () => {
- it("accepts valid paper", () => {
- const result = createEvidencePaperSchema.safeParse({
- title: "Testing Effect",
- document_id: "doc_123",
- tags: ["retrieval_practice"],
- });
- expect(result.success).toBe(true);
- });
-
- it("rejects missing document_id", () => {
- const result = createEvidencePaperSchema.safeParse({
- title: "Paper",
- });
- expect(result.success).toBe(false);
- });
-});
-
-describe("createEvidenceCardSchema", () => {
- it("accepts valid card", () => {
- const result = createEvidenceCardSchema.safeParse({
- claim: "Testing enhances learning",
- recommendation: "Use retrieval practice",
- strength: "STRONG",
- });
- expect(result.success).toBe(true);
- });
-
- it("rejects invalid strength", () => {
- const result = createEvidenceCardSchema.safeParse({
- claim: "X",
- recommendation: "Y",
- strength: "VERY_STRONG",
- });
- expect(result.success).toBe(false);
- });
-});
diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx
index c740c07..8ffea60 100644
--- a/src/app/achievements/page.tsx
+++ b/src/app/achievements/page.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
-import { getOrCreateUserId } from "@/lib/client-utils";
+import { apiGet } from "@/lib/client-api";
import { ALL_BADGES, TOTAL_BADGES, CATEGORY_LABELS } from "@/lib/badge-data";
interface GameState {
@@ -21,9 +21,7 @@ export default function AchievementsPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
- const userId = getOrCreateUserId();
- fetch("/api/stats/game", { headers: { "X-User-Id": userId } })
- .then((r) => r.json())
+ apiGet("/api/stats/game")
.then((state) => setGameState(state))
.catch(() => {})
.finally(() => setLoading(false));
diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts
index 0c1d159..89e6859 100644
--- a/src/app/api/account/delete/route.ts
+++ b/src/app/api/account/delete/route.ts
@@ -50,7 +50,6 @@ export async function DELETE(request: NextRequest) {
tx.objectiveAnchor.deleteMany({ where: { userId } }),
tx.sessionErrorLog.deleteMany({ where: { userId } }),
tx.chatMessage.deleteMany({ where: { userId } }),
- tx.notificationPreference.deleteMany({ where: { userId } }),
tx.verificationToken.deleteMany({ where: { userId } }),
]);
diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts
index decca62..422d57f 100644
--- a/src/app/api/account/export/route.ts
+++ b/src/app/api/account/export/route.ts
@@ -24,7 +24,6 @@ export async function GET(request: NextRequest) {
sessions,
flashcardDecks,
chatMessages,
- notificationPreference,
userGameState,
aiCallLogs,
contentDocuments,
@@ -66,10 +65,6 @@ export async function GET(request: NextRequest) {
orderBy: { createdAt: "asc" },
}),
- prisma.notificationPreference.findUnique({
- where: { userId },
- }),
-
prisma.userGameState.findUnique({
where: { userId },
}),
@@ -161,7 +156,6 @@ export async function GET(request: NextRequest) {
planCalendarPublications,
planItemExternalEvents,
googleIntegration,
- notificationPreference,
};
logger.info("account.export.success", { userId });
diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts
index 81cacab..257f9f6 100644
--- a/src/app/api/auth/signup/route.ts
+++ b/src/app/api/auth/signup/route.ts
@@ -48,14 +48,9 @@ export async function POST(request: Request) {
},
});
- await tx.notificationPreference.create({
- data: { userId: u.id },
- });
-
await tx.userGameState.create({
data: {
userId: u.id,
- displayName: name,
onboardingComplete: false,
},
});
diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts
new file mode 100644
index 0000000..177e3b5
--- /dev/null
+++ b/src/app/api/config/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+
+// Env-derived flags must be read at request time, not baked in at build.
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/config — public, non-secret runtime flags for the UI.
+ *
+ * - verification_required: whether email verification gates sign-in
+ * (opt-in via REQUIRE_EMAIL_VERIFICATION, see src/lib/auth-options.ts).
+ * - ai_mock: whether the AI provider factory resolves to the mock provider
+ * (AI_PROVIDER unset or "mock", see src/lib/ai/provider-factory.ts).
+ */
+export async function GET() {
+ return NextResponse.json({
+ verification_required: process.env.REQUIRE_EMAIL_VERIFICATION === "true",
+ ai_mock: (process.env.AI_PROVIDER || "mock") === "mock",
+ });
+}
diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts
deleted file mode 100644
index 0bfaa97..0000000
--- a/src/app/api/notifications/preferences/route.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { NextResponse } from "next/server";
-import { prisma } from "@/lib/db";
-import { getUserId } from "@/lib/auth";
-import { z } from "zod/v4";
-
-const prefsSchema = z.object({
- emailReminders: z.boolean().optional(),
- pushReminders: z.boolean().optional(),
- reminderTime: z
- .string()
- .regex(/^\d{2}:\d{2}$/, "Must be HH:MM format")
- .refine((v) => {
- const [h, m] = v.split(":").map(Number);
- return h >= 0 && h <= 23 && m >= 0 && m <= 59;
- }, "Invalid time")
- .optional(),
- streakReminders: z.boolean().optional(),
- weeklyDigest: z.boolean().optional(),
- emailAddress: z.string().email().nullable().optional(),
-});
-
-export async function GET(request: Request) {
- const userId = await getUserId(request);
- if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const prefs = await prisma.notificationPreference.findUnique({
- where: { userId },
- });
-
- if (!prefs) {
- // Return defaults
- return NextResponse.json({
- emailReminders: true,
- pushReminders: true,
- reminderTime: "09:00",
- streakReminders: true,
- weeklyDigest: true,
- emailAddress: null,
- });
- }
-
- return NextResponse.json({
- emailReminders: prefs.emailReminders,
- pushReminders: prefs.pushReminders,
- reminderTime: prefs.reminderTime,
- streakReminders: prefs.streakReminders,
- weeklyDigest: prefs.weeklyDigest,
- emailAddress: prefs.emailAddress,
- });
-}
-
-export async function PUT(request: Request) {
- const userId = await getUserId(request);
- if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- let body: z.infer;
- try {
- body = prefsSchema.parse(await request.json());
- } catch (err) {
- if (err instanceof z.ZodError) {
- return NextResponse.json({ error: "Validation error", details: err.issues }, { status: 400 });
- }
- return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
- }
-
- const prefs = await prisma.notificationPreference.upsert({
- where: { userId },
- create: {
- userId,
- emailReminders: body.emailReminders ?? true,
- pushReminders: body.pushReminders ?? true,
- reminderTime: body.reminderTime ?? "09:00",
- streakReminders: body.streakReminders ?? true,
- weeklyDigest: body.weeklyDigest ?? true,
- emailAddress: body.emailAddress ?? null,
- },
- update: {
- ...(body.emailReminders !== undefined && { emailReminders: body.emailReminders }),
- ...(body.pushReminders !== undefined && { pushReminders: body.pushReminders }),
- ...(body.reminderTime !== undefined && { reminderTime: body.reminderTime }),
- ...(body.streakReminders !== undefined && { streakReminders: body.streakReminders }),
- ...(body.weeklyDigest !== undefined && { weeklyDigest: body.weeklyDigest }),
- ...(body.emailAddress !== undefined && { emailAddress: body.emailAddress }),
- },
- });
-
- return NextResponse.json({
- emailReminders: prefs.emailReminders,
- pushReminders: prefs.pushReminders,
- reminderTime: prefs.reminderTime,
- streakReminders: prefs.streakReminders,
- weeklyDigest: prefs.weeklyDigest,
- emailAddress: prefs.emailAddress,
- });
-}
diff --git a/src/app/api/plans/followups/route.ts b/src/app/api/plans/followups/route.ts
new file mode 100644
index 0000000..738f4a9
--- /dev/null
+++ b/src/app/api/plans/followups/route.ts
@@ -0,0 +1,52 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getUserId } from "@/lib/auth";
+import { scheduleFollowups } from "@/services/followups";
+import { logger } from "@/lib/logger";
+import { generalLimiter, tooManyRequests } from "@/lib/rate-limit";
+import { z } from "zod/v4";
+
+const bodySchema = z.object({ run_id: z.string().min(1) });
+
+export async function POST(request: NextRequest) {
+ const userId = await getUserId(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const rl = generalLimiter.check(userId);
+ if (!rl.allowed) return tooManyRequests(rl.retryAfterMs);
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const parsed = bodySchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Validation failed", issues: parsed.error.issues },
+ { status: 400 },
+ );
+ }
+
+ try {
+ const result = await scheduleFollowups(userId, parsed.data.run_id);
+
+ if ("error" in result) {
+ if (result.error === "not_found") {
+ return NextResponse.json({ error: "Run not found" }, { status: 404 });
+ }
+ if (result.error === "forbidden") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ return NextResponse.json({ error: result.error }, { status: 409 });
+ }
+
+ return NextResponse.json(result.data);
+ } catch (err) {
+ logger.error("followups_schedule_failed", { error: String(err) });
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx
index 074adfa..180c84d 100644
--- a/src/app/auth/signup/page.tsx
+++ b/src/app/auth/signup/page.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
+import { signIn } from "next-auth/react";
import Link from "next/link";
import {
containerStyle,
@@ -54,6 +55,37 @@ export default function SignUpPage() {
return;
}
+ // When email verification is not required (the default), sign the new
+ // user in with the credentials they just chose and land them on the
+ // dashboard, which shows onboarding for fresh accounts. Only send them
+ // to "Check Your Email" when this server actually requires verification.
+ let verificationRequired = true;
+ try {
+ const configRes = await fetch("/api/config");
+ if (configRes.ok) {
+ const config = await configRes.json();
+ verificationRequired = config.verification_required === true;
+ }
+ } catch {
+ // Config unavailable — fall through to the verify-email page, which
+ // explains that verification may be optional.
+ }
+
+ if (!verificationRequired) {
+ const result = await signIn("credentials", {
+ email,
+ password,
+ redirect: false,
+ });
+ if (!result?.error) {
+ window.location.href = "/";
+ return;
+ }
+ // Auto sign-in failed unexpectedly; let the user sign in manually.
+ router.push("/auth/signin");
+ return;
+ }
+
// Redirect to verify-email page with email for resend
router.push(`/auth/verify-email?email=${encodeURIComponent(email)}`);
} catch {
diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx
index 04d5313..490ffb3 100644
--- a/src/app/auth/verify-email/page.tsx
+++ b/src/app/auth/verify-email/page.tsx
@@ -62,10 +62,10 @@ function VerifyEmailContent() {
The link expires in 24 hours. If you don't see it, check your spam folder.
- Running locally with the default console email provider? The verification link is
- printed in the terminal where npm run dev is running. And unless
- REQUIRE_EMAIL_VERIFICATION is set, verification is optional and you can just sign
- in directly.
+ Seeing this page means this server has REQUIRE_EMAIL_VERIFICATION enabled
+ (verification is off by default). Running locally with the default console email
+ provider? The verification link is printed in the terminal where{" "}
+ npm run dev is running.
{error &&
{error}
}
diff --git a/src/app/globals.css b/src/app/globals.css
index f061b5f..c979716 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -380,12 +380,6 @@ input[type="number"] {
font-size: 1rem;
}
- /* -- Leaderboard entries: more compact -- */
- .leaderboard-entry {
- padding: 0.4rem 0.6rem;
- font-size: 0.8rem;
- }
-
/* -- Rating buttons in flashcards -- */
div[style*="display: \"flex\""][style*="gap: \"0.4rem\""] {
flex-wrap: wrap;
diff --git a/src/app/icon.svg b/src/app/icon.svg
new file mode 100644
index 0000000..8bad40b
--- /dev/null
+++ b/src/app/icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx
index 11db04f..c12b03a 100644
--- a/src/app/learn/page.tsx
+++ b/src/app/learn/page.tsx
@@ -2,9 +2,17 @@
import { useState, useEffect } from "react";
import Link from "next/link";
-import { getOrCreateUserId, getActiveCourse, setActiveCourse } from "@/lib/client-utils";
+import { getActiveCourse, setActiveCourse } from "@/lib/client-utils";
import { apiGet } from "@/lib/client-api";
+/** Render a mastery key / objective slug (e.g. "photosynthesis_light_reactions") as a clean label. */
+function prettifyObjectiveKey(key: string): string {
+ return key
+ .replace(/_/g, " ")
+ .trim()
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
interface CourseData {
courseName: string;
examNames: string[];
@@ -66,7 +74,7 @@ export default function LearnPage() {
topic_scope: recs.next_session.topic_scope || activeCourse.courseName,
planned_minutes: 30,
objectives: recs.next_session.objectives.length > 0
- ? recs.next_session.objectives.map((key) => ({ id: key, title: key.replace(/_/g, " ") }))
+ ? recs.next_session.objectives.map((key) => ({ id: key, title: prettifyObjectiveKey(key) }))
: undefined,
target_outcome: { prompt_count: 10 },
break_protocol: { type: "25_5", cycles: 1 },
@@ -83,9 +91,7 @@ export default function LearnPage() {
const setSelectedCourse = (v: string | null) => { setSelectedCourseRaw(v); if (v) setActiveCourse(v); };
useEffect(() => {
- const userId = getOrCreateUserId();
- fetch("/api/learn", { headers: { "X-User-Id": userId } })
- .then((r) => r.json())
+ apiGet("/api/learn")
.then((d) => {
setData(d);
if (d.courses?.length > 0) {
@@ -263,8 +269,9 @@ export default function LearnPage() {
)}
- {/* Due cards alert */}
- {course.dueCardCount > 0 && (
+ {/* Due cards alert — only meaningful when the course actually has decks
+ to review; without decks there is no due queue to send anyone to. */}
+ {course.deckCount > 0 && course.dueCardCount > 0 && (
!
@@ -282,7 +289,7 @@ export default function LearnPage() {