diff --git a/FIXES.md b/FIXES.md new file mode 100644 index 0000000..ef423bc --- /dev/null +++ b/FIXES.md @@ -0,0 +1,46 @@ +# Hardening Fixes + +## Architectural Decisions + +### Canonical migration runner +`server/plugins/migrate.ts` is the canonical migration runner (Nitro plugin, runs on server start). `server/db/migrate.ts` is the standalone CLI script kept for manual use via `npm run db:migrate`, but the plugin is authoritative. + +### SQLite +Appropriate for single-user/small-team. For multi-user production, evaluate PostgreSQL with Drizzle's pg adapter. + +### Audio serving +Currently served from `/public/audio`. To complete the private audio migration: +1. Move audio generation output directory from `public/audio` to `private/audio` +2. Update all `audioPath` values stored in lesson content JSON — paths are stored as `/audio/...` and need to become `/api/audio/...` +3. This requires a one-time data migration script + +### inFlightCourses Set +In-process only — does not survive server restarts. For multi-process deployments, use a DB flag or Redis. + +### Per-topic mutex +Same limitation — in-process only. + +--- + +## .env git history +`git log --all -- .env` returned no output — `.env` has never been committed to this repository. No key rotation required. + +--- + +## Rate limiting +Not yet implemented. Marked for manual addition via a Nitro middleware using a simple Map-based token bucket. + +--- + +## Cost tracking +`costAI` and `costAudio` fields are estimated values based on API-reported costs. Reconcile against OpenRouter and TTS provider dashboards monthly. + +--- + +## Items that could NOT be fixed automatically + +- **Full audio path migration** (public → private/audio + updating stored JSON paths): requires a one-time data migration script +- **Drizzle migration for ON DELETE CASCADE on foreign keys**: SQLite doesn't support `ALTER TABLE ... ADD FOREIGN KEY`, so CASCADE would require recreating all tables. Recommend doing this on next schema version if needed. +- **Full composable extraction from `learn/[id]/index.vue`** (`useLessonState`, `useFocusMode`, `useBranchPoll`): architectural refactor deferred, not a correctness issue. +- **Prompt versioning** (`server/prompts/` directory): deferred, not a correctness issue. +- **Full consola migration**: deferred tech debt. diff --git a/app/pages/learn/[id]/index.vue b/app/pages/learn/[id]/index.vue index 6bd9903..336be2f 100644 --- a/app/pages/learn/[id]/index.vue +++ b/app/pages/learn/[id]/index.vue @@ -14,6 +14,9 @@ const lessonPending = ref(false); const lessonError = ref(null); const genState = ref(null); +const abortController = ref(null); +const generationFired = ref(false); + const GEN_TIPS = [ "Reading through your past papers...", "Crafting analogies just for this topic...", @@ -36,6 +39,7 @@ let genTipTimer: ReturnType | null = null; let genStageTimer: ReturnType | null = null; function startGenAnimations() { + if (genTipTimer) return; genTipTimer = setInterval(() => { genTipVisible.value = false; setTimeout(() => { @@ -55,7 +59,12 @@ function stopGenAnimations() { } async function loadLesson() { - lessonPending.value = true; + lesson.value = null; + + const pendingTimer = setTimeout(() => { lessonPending.value = true; }, 100); + + abortController.value = new AbortController(); + try { const data = await $fetch(`/api/topics/${topicId}/lesson`); lesson.value = data; @@ -68,11 +77,15 @@ async function loadLesson() { lessonError.value = err; } } finally { + clearTimeout(pendingTimer); lessonPending.value = false; } } async function triggerGeneration() { + if (generationFired.value) return; + generationFired.value = true; + genState.value = "loading"; startGenAnimations(); try { @@ -95,6 +108,7 @@ onMounted(loadLesson); onUnmounted(() => { stopGenAnimations(); + abortController.value?.abort(); }); // ── branch loading overlay ───────────────────────────────────────────────── @@ -103,8 +117,13 @@ const branchOverlayVisible = ref(false); const branchOverlayError = ref(false); let branchPollTimer: ReturnType | null = null; +const branchPollStart = ref(0); function startBranchPoll() { + if (branchPollTimer) return; + + branchPollStart.value = Date.now(); + branchPollTimer = setInterval(async () => { try { const data = await $fetch(`/api/topics/${topicId}/lesson`); @@ -117,6 +136,11 @@ function startBranchPoll() { branchOverlayError.value = true; } } catch { /* ignore poll errors */ } + + if (Date.now() - branchPollStart.value > 60_000) { + stopBranchPoll(); + branchOverlayError.value = true; + } }, 2000); } @@ -295,10 +319,12 @@ const showMainExplanation = computed(() => { const shuffledOptions = ref([]); -function fisherYates(arr: string[]): string[] { +function seededShuffle(arr: string[], seed: number): string[] { const a = [...arr]; + let s = seed; for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + s = (s * 1664525 + 1013904223) >>> 0; + const j = s % (i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; @@ -307,7 +333,8 @@ function fisherYates(arr: string[]): string[] { watch([() => stepKey.value, steps], () => { const s = displayStep.value; if (s?.type === "question" && s.options) { - shuffledOptions.value = fisherYates(s.options); + const seed = lessonState.stepIndex * 31 + (lessonState.branchOption ? lessonState.branchOption.charCodeAt(0) : 0); + shuffledOptions.value = seededShuffle(s.options, seed); } else { shuffledOptions.value = []; } @@ -478,19 +505,24 @@ function advance() { } const lessonScore = ref(0); -watch(selectedAnswers, (opts, prev) => { +watch(() => ({ ...selectedAnswers.value }), (opts, prev) => { const newKeys = Object.keys(opts).map(Number).filter(k => !(k in prev)); for (const k of newKeys) { const s = steps.value[k]; if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++; } -}, { deep: true }); +}); watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => { if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration(); }); +const celebrationFired = ref(false); + function fireCelebration() { + if (celebrationFired.value) return; + celebrationFired.value = true; + const count = 160; const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 }; const interval = setInterval(() => { @@ -502,13 +534,20 @@ function fireCelebration() { const lessonDone = ref(false); const completing = ref(false); +const completeError = ref(false); async function completeLesson() { if (completing.value || lessonDone.value) return; completing.value = true; + completeError.value = false; + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 10000); + try { await $fetch(`/api/topics/${topicId}/progress`, { method: "POST", + signal: ctrl.signal, body: { lessonComplete: true, tookBranches: branchesEntered.value > 0, @@ -516,12 +555,38 @@ async function completeLesson() { }, }); lessonDone.value = true; + } catch { + completing.value = false; + completeError.value = true; + return; } finally { + clearTimeout(timer); completing.value = false; } } -function goBack() { history.back(); } +const router = useRouter(); +function goBack() { + if (history.length <= 1) { router.push("/"); return; } + history.back(); +} + +function stepBack() { + stopAllAudio(); + if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") { + // exit branch back to main question + lessonState.mode = "main"; + lessonState.branchOption = ""; + lessonState.branchStepIndex = 0; + confirmSelectedAnswer.value = null; + return; + } + if (lessonState.stepIndex > 0) { + lessonState.stepIndex--; + // clear the answer for this step so they can re-answer + delete selectedAnswers.value[lessonState.stepIndex]; + } +} // ── FOCUS MODE ──────────────────────────────────────────────────────────── @@ -529,6 +594,7 @@ const focusMode = ref(false); const currentChunk = ref<{ text: string; start: number; end: number } | null>(null); const karaokeAudioEl = ref(null); const questionPlaying = ref(false); +const narratingOptionIndex = ref(null);; const focusEverActivated = ref(false); if (import.meta.client) { @@ -550,6 +616,7 @@ const showKaraoke = computed(() => { return !!(ds?.audioPath && ds?.audioChunks && (ds.audioChunks as any[]).length > 0); }); +// kept for compat — no longer drives a full overlay, just used to gate the continue button const showQuestionOverlay = computed(() => focusMode.value && (lessonState.mode === "main" || lessonState.mode === "confirm") && @@ -656,13 +723,8 @@ async function playSequence(urls: string[]): Promise { }); } -const questionVariants = ref>({}); - function getVariant(stepIndex: number): number { - if (!questionVariants.value[stepIndex]) { - questionVariants.value[stepIndex] = Math.floor(Math.random() * 4) + 1; - } - return questionVariants.value[stepIndex]; + return (Math.abs(stepIndex) % 4) + 1; } async function playQuestionLike( @@ -672,26 +734,31 @@ async function playQuestionLike( ) { stopQueue(); questionPlaying.value = true; + narratingOptionIndex.value = null; const variant = getVariant(variantKey); - const playlist: string[] = []; - - if (questionStep.questionAudioPath) playlist.push(questionStep.questionAudioPath); - - for (let oi = 0; oi < shuffledOptions.value.length; oi++) { - const label = LABELS[oi]; - playlist.push(`/audio/labels/${label}_${variant}.mp3`); - const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]); - const optAudio = questionStep.optionAudioPaths?.[origIdx]; - if (optAudio) playlist.push(optAudio); - } try { - await playSequence(playlist); + // play question stem first + if (questionStep.questionAudioPath) { + await playSequence([questionStep.questionAudioPath]); + } + + // play each option in order, highlighting it while it plays + for (let oi = 0; oi < shuffledOptions.value.length; oi++) { + narratingOptionIndex.value = oi; + const label = LABELS[oi]; + const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]); + const optAudio = questionStep.optionAudioPaths?.[origIdx]; + const clips: string[] = [`/audio/labels/${label}_${variant}.mp3`]; + if (optAudio) clips.push(optAudio); + await playSequence(clips); + } } catch { // aborted } + narratingOptionIndex.value = null; questionPlaying.value = false; } @@ -704,10 +771,9 @@ function startStepAudio() { if (s.type === "concept" || s.type === "example" || s.type === "summary") { if (!s.audioPath || !s.audioChunks || (s.audioChunks as any[]).length === 0) { - // no usable audio for this step — skip it automatically so focus mode stays alive + // no usable audio — advance state only, the watcher fires startStepAudio for the new step if (!isLastMainStep.value) { lessonState.stepIndex++; - nextTick(() => startStepAudio()); } else { completeLesson(); } @@ -720,7 +786,7 @@ function startStepAudio() { startKaraoke(audio, s.audioChunks, () => { if (!isLastMainStep.value) { lessonState.stepIndex++; - nextTick(() => startStepAudio()); + // watcher handles startStepAudio for the new step } else { completeLesson(); } @@ -862,7 +928,7 @@ onBeforeUnmount(() => {
- + +
-

{{ currentChunk?.text ?? '…' }}

- -
-
- - - -
-

Listen carefully…

- + +
@@ -956,7 +1026,7 @@ onBeforeUnmount(() => {
@@ -1002,7 +1072,8 @@ onBeforeUnmount(() => { :key="opt" class="option" :class="{ - 'option--hover-ready': !hasAnswered, + 'option--hover-ready': !hasAnswered && narratingOptionIndex === null, + 'option--narrating': !hasAnswered && narratingOptionIndex === i, 'option--correct': hasAnswered && opt === displayStep.answer, 'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer, 'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer, @@ -1077,13 +1148,15 @@ onBeforeUnmount(() => {
+ + @@ -1092,7 +1165,7 @@ onBeforeUnmount(() => {
-