diff --git a/CLAUDE.md b/CLAUDE.md index 6874b6e2..ec6b5c31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project overview -RubricMaker is an **offline-first** rubric and grading application for teachers. All data lives in browser `localStorage`; an optional Supabase backend provides cloud sync and multi-device access. The app targets static hosting (no server required in offline mode) and can also be self-hosted with Docker. +RubricMaker is a rubric and grading application for teachers. It is **self-hostable with full functionality** (Supabase backend for persistence, sync, multi-device and multi-teacher features) and **offline-capable with reduced capabilities**: without a Supabase connection the app still runs from browser `localStorage`, but cloud-dependent features (collaboration, student portal, multi-device access) are unavailable. The app targets static hosting and can also be self-hosted with Docker. Key domains: - **Rubric Builder** — create/edit rubrics with criteria, levels, scoring modes @@ -38,7 +38,7 @@ npm run db:reset # Reset and re-apply all migrations | Build | Vite 8 | | Routing | React Router v7 (lazy-loaded pages) | | State | React Context (`AppContext`) + `useReducer` | -| Persistence | `localStorage` primary; Supabase optional sync | +| Persistence | Supabase primary when configured; `localStorage` offline-capable fallback | | Rich text | TipTap 3 (ProseMirror) | | Charts | Recharts | | Export | `docx`, `pdfjs-dist`, `file-saver` | @@ -59,9 +59,9 @@ User action → AppContext dispatch → useReducer → new state (always) `src/store/storage.ts` is the single write point for persistence. Never write to `localStorage` directly from components or hooks. -### Offline-first rule +### Storage rule (Supabase-primary, offline-capable) -The app must work completely without Supabase: the `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` env vars are optional, and if absent, all database sync is silently skipped and `localStorage` is the sole, permanent store (unchanged legacy behavior). +Supabase is the primary store whenever a connection is configured. The app must still start and run without Supabase — the `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` env vars are optional, and if absent, all database sync is silently skipped and `localStorage` is the sole, permanent store — but this local mode is a reduced-capability fallback, not the primary design target. When a Supabase connection is live, `localStorage` is no longer a permanent duplicate copy — it's only a temporary buffer: diff --git a/README.md b/README.md index 58350588..7f37bc4c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Rubric Maker -A comprehensive, offline-first rubric creation and grading tool built with React and TypeScript. Designed for educators who need to design complex rubrics, grade students efficiently, and analyse performance — including language proficiency tracking aligned to the Common European Framework of Reference (CEFR). +A comprehensive rubric creation and grading tool built with React and TypeScript — self-hostable with full functionality, and offline-capable (with reduced capabilities) when no backend is configured. Designed for educators who need to design complex rubrics, grade students efficiently, and analyse performance — including language proficiency tracking aligned to the Common European Framework of Reference (CEFR). ## Features @@ -92,12 +92,12 @@ A comprehensive, offline-first rubric creation and grading tool built with React ### 8. Installation -- **Installable PWA**: RubricMaker can be installed to a device's home screen or desktop (look for the install icon in the browser address bar) for an app-like, browser-chrome-free launch — useful for shared classroom devices. This only affects installability of the static app shell; it does not change the offline-first localStorage data model, and the service worker never caches Supabase API requests (`/rest/`, `/auth/`, `/realtime/`, `/storage/`, `/functions/` paths are always network-only). +- **Installable PWA**: RubricMaker can be installed to a device's home screen or desktop (look for the install icon in the browser address bar) for an app-like, browser-chrome-free launch — useful for shared classroom devices. This only affects installability of the static app shell; it does not change the storage model, and the service worker never caches Supabase API requests (`/rest/`, `/auth/`, `/realtime/`, `/storage/`, `/functions/` paths are always network-only). ### 9. Data Management -- **Offline-first**: All data lives in the browser's `localStorage`. No account required. -- **Cloud sync** (optional): Supabase backend for multi-device access and multi-teacher collaboration. Sync hydrates `localStorage` from Supabase on load and after reconnect; per-record conflicts resolve last-write-wins (newest `updatedAt` wins), and offline edits queued in the pending-sync queue are protected from being clobbered by stale cloud data until they are pushed (see `src/utils/syncMerge.ts`). +- **Offline-capable**: Without a configured backend, all data lives in the browser's `localStorage` and no account is required — with reduced capabilities (no collaboration, student portal, or multi-device access). +- **Cloud sync** (recommended): Supabase backend as the primary store for multi-device access and multi-teacher collaboration. Sync hydrates `localStorage` from Supabase on load, after reconnect, and in near-real-time whenever another device changes data (Postgres change events on every synced table, debounced into a single refresh — see `StorageSync.startRealtimeSync`); per-record conflicts resolve last-write-wins (newest `updatedAt` wins), and offline edits queued in the pending-sync queue are protected from being clobbered by stale cloud data until they are pushed (see `src/utils/syncMerge.ts`). - **Backup & restore**: Export the entire dataset to JSON; restore from any prior backup. - **Admin panel**: School-level management — user roles, onboarding, student anonymisation, data-retention policies. @@ -300,6 +300,16 @@ The script uses the Storage HTTP API — it does **not** delete rows directly fr On Supabase Cloud, schedule the `delete-old-attachments` edge function instead (see [Supabase Dashboard → Edge Functions](https://supabase.com/dashboard/project/_/functions)). +**Nightly cloud backup (recommended for Supabase Cloud and the official self-hosted stack):** + +`scripts/backup.sh` (see "Backup and restore" above) only works against **this repo's own `docker-compose.yml`** — it calls `docker-compose exec db pg_dump` and archives a hardcoded volume name (`rubricmaker_storage-data`), both specific to that bundled stack. It does nothing for Supabase Cloud or for a Supabase instance you're self-hosting separately (e.g. the [official self-hosted Supabase Docker stack](https://supabase.com/docs/guides/self-hosting/docker)) — different container/volume names, or no server access at all on Cloud. + +The `nightly-backup` edge function covers both of those cases instead: for every teacher/admin, it dumps their rows via `public.export_owner_backup()` and uploads a JSON snapshot to the private `backups` Storage bucket at `{userId}/{timestamp}.json`, keeping the 7 most recent per user. Like `set-student-password`, it needs a functions runtime — this repo's bundled `docker-compose.yml` doesn't have one, but the official self-hosted stack does (deploy by copying `index.ts` into that stack's `volumes/functions/nightly-backup/index.ts`, no separate deploy step), and so does Cloud. + +Schedule it nightly: on Cloud via [Supabase Dashboard → Edge Functions](https://supabase.com/dashboard/project/_/functions) or Cron Jobs; on the official self-hosted stack via a `pg_cron` + `pg_net` job or an external cron hitting the function URL. It authenticates the same way as `delete-old-attachments` — the scheduler must pass the project's service role key as a bearer token. + +This is a disaster-recovery snapshot of raw table rows, not a file you can feed back into the app's own JSON import (Settings → Backup & Restore) — restoring it means re-inserting the rows directly (e.g. via `psql` or the Supabase SQL editor), not through `importFullBackup()`. It's metadata only: rows that reference uploaded files (essay submissions, speaking-session recordings) store a Storage-bucket path, not the file itself, so back up the `essays`/`recordings`/`attachments` buckets separately if you need those files recoverable too. If you're self-hosting Supabase separately from this repo's stack, you may also want your own `pg_dump`-based backup of the whole instance — `export_owner_backup()` only covers the app's own tables, scoped per teacher/admin. + **Stress-test logging (optional):** Before a school-wide rollout, you can enable a diagnostic event stream to a diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index ac3bff56..a0b58866 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -33,6 +33,7 @@ import type { Test, StudentTest, TestAssignment, + UserTemplate, UserRole, } from '../types'; import { @@ -59,9 +60,11 @@ import { saveEssaySubmissions, saveEssayTemplates, saveGradingTasks, + saveUserTemplates, importFullBackup, loadPendingQueue, onStorageQuotaExceeded, + clearLocalData, } from '../store/storage'; import { mergeStoreData } from '../utils/syncMerge'; import { useTranslation } from 'react-i18next'; @@ -140,7 +143,9 @@ type Action = | { type: 'SAVE_ESSAY_TEMPLATE'; payload: EssayTemplate } | { type: 'DELETE_ESSAY_TEMPLATE'; id: string } | { type: 'ADD_GRADING_TASKS'; payload: GradingTask[] } - | { type: 'DELETE_GRADING_TASK'; id: string }; + | { type: 'DELETE_GRADING_TASK'; id: string } + | { type: 'SAVE_USER_TEMPLATE'; payload: UserTemplate } + | { type: 'DELETE_USER_TEMPLATE'; id: string }; // When a Supabase connection is live, the Supabase push is the durable write and the // local copy is redundant; only fall back to localStorage while genuinely offline. A @@ -551,21 +556,23 @@ function reducer(state: StoreData, action: Action): StoreData { } case 'ADD_ESSAY_ASSIGNMENTS': { const next = [...state.essayAssignments, ...action.payload]; - saveEssayAssignments(next); + if (isOffline()) saveEssayAssignments(next); return { ...state, essayAssignments: next }; } case 'UPDATE_ESSAY_GROUP': { const next = state.essayAssignments.map((a) => a.teacherKey === action.teacherKey ? { ...a, ...action.patch } : a ); - saveEssayAssignments(next); + if (isOffline()) saveEssayAssignments(next); return { ...state, essayAssignments: next }; } case 'DELETE_ESSAY_GROUP': { const nextAssignments = state.essayAssignments.filter((a) => a.teacherKey !== action.teacherKey); - saveEssayAssignments(nextAssignments); const nextSubmissions = state.essaySubmissions.filter((s) => s.teacherKey !== action.teacherKey); - saveEssaySubmissions(nextSubmissions); + if (isOffline()) { + saveEssayAssignments(nextAssignments); + saveEssaySubmissions(nextSubmissions); + } return { ...state, essayAssignments: nextAssignments, essaySubmissions: nextSubmissions }; } case 'ADD_ESSAY_SUBMISSION': { @@ -578,7 +585,7 @@ function reducer(state: StoreData, action: Action): StoreData { exists >= 0 ? state.essaySubmissions.map((s, i) => (i === exists ? action.payload : s)) : [...state.essaySubmissions, action.payload]; - saveEssaySubmissions(next); + if (isOffline()) saveEssaySubmissions(next); return { ...state, essaySubmissions: next }; } case 'SAVE_ESSAY_TEMPLATE': { @@ -607,6 +614,21 @@ function reducer(state: StoreData, action: Action): StoreData { if (isOffline()) saveGradingTasks(next); return { ...state, gradingTasks: next }; } + case 'SAVE_USER_TEMPLATE': { + // No cap here — this array is now the sync source of truth, diffed against + // Supabase (see the delta-sync effect below). Evicting an entry would look + // like a delete to that diff and get pushed as one, silently deleting the + // teacher's oldest saved template from the cloud and every other device. + const filtered = state.userTemplates.filter((ut) => ut.id !== action.payload.id); + const next = [action.payload, ...filtered]; + if (isOffline()) saveUserTemplates(next); + return { ...state, userTemplates: next }; + } + case 'DELETE_USER_TEMPLATE': { + const next = state.userTemplates.filter((ut) => ut.id !== action.id); + if (isOffline()) saveUserTemplates(next); + return { ...state, userTemplates: next }; + } default: return state; } @@ -711,6 +733,9 @@ interface AppContextValue extends StoreData { // Grading task assignment (batch-assign ungraded submissions to a teacher) addGradingTasks: (tasks: GradingTask[]) => void; deleteGradingTask: (id: string) => void; + // Saved rubric templates ("save as template") + saveUserTemplate: (t: UserTemplate) => void; + deleteUserTemplate: (id: string) => void; // Database sync connectDatabase: (config: DatabaseConfig) => Promise; disconnectDatabase: () => void; @@ -809,6 +834,9 @@ async function flushToLocalStorage(merged: StoreData) { saveStudentTests, saveEssayTemplates, saveGradingTasks, + saveEssayAssignments, + saveEssaySubmissions, + saveUserTemplates, } = await import('../store/storage'); saveRubrics(merged.rubrics); saveStudents(merged.students); @@ -829,6 +857,19 @@ async function flushToLocalStorage(merged: StoreData) { saveStudentTests(merged.studentTests); saveEssayTemplates(merged.essayTemplates); saveGradingTasks(merged.gradingTasks); + saveEssayAssignments(merged.essayAssignments); + saveEssaySubmissions(merged.essaySubmissions); + saveUserTemplates(merged.userTemplates); + + // Best-effort: a recording blob whose session was deleted on another device has no + // app-level delete call to clean it up locally, so sweep for orphans after every sync. + const { pruneOrphanedBlobs } = await import('../services/mediaStore'); + const referencedRecordingIds = new Set( + merged.speakingSessions.flatMap((ss) => ss.recordings?.map((r) => r.id) ?? []) + ); + pruneOrphanedBlobs(referencedRecordingIds).catch(() => { + // stray IndexedDB blob costs storage quota, not correctness — not worth surfacing + }); } export function AppProvider({ children }: { children: ReactNode }) { @@ -964,7 +1005,10 @@ export function AppProvider({ children }: { children: ReactNode }) { const { data: fresh, error: hydrateError } = await storageSync.hydrate(); if (hydrateError) showToast(t('toast.sync_load_failed'), 'warning'); if (fresh) { - const merged = mergeStoreData(initialStateRef.current, fresh, loadPendingQueue()); + // After an owner switch the in-memory state still holds the previous + // user's data — merge against the freshly wiped store instead. + const base = storageSync.didWipeLocalData() ? loadStore() : initialStateRef.current; + const merged = mergeStoreData(base, fresh, loadPendingQueue()); dispatch({ type: 'SET_ALL', payload: merged }); try { await flushToLocalStorage(merged); @@ -987,7 +1031,7 @@ export function AppProvider({ children }: { children: ReactNode }) { await configureAndEnter(config); // Show migration prompt once if local data exists and hasn't been migrated - if (localStorage.getItem(MIGRATION_DONE_KEY) !== 'true') { + if (localStorage.getItem(MIGRATION_DONE_KEY) !== 'true' && !storageSync.didWipeLocalData()) { const s = initialStateRef.current; if (s.rubrics.length > 0 || s.students.length > 0 || s.classes.length > 0) { setShowMigrationPrompt(true); @@ -1072,7 +1116,8 @@ export function AppProvider({ children }: { children: ReactNode }) { const { data: fresh, error: hydrateError } = await storageSync.hydrate(); if (hydrateError) showToast(t('toast.sync_load_failed'), 'warning'); if (fresh) { - const merged = mergeStoreData(initialStateRef.current, fresh, loadPendingQueue()); + const base = storageSync.didWipeLocalData() ? loadStore() : initialStateRef.current; + const merged = mergeStoreData(base, fresh, loadPendingQueue()); dispatch({ type: 'SET_ALL', payload: merged }); try { await flushToLocalStorage(merged); @@ -1102,7 +1147,11 @@ export function AppProvider({ children }: { children: ReactNode }) { if (!currMap.has(id)) storageSync.pushOne(entity, 'delete', null, id); } for (const [id, item] of currMap) { - if (prevMap.get(id) !== JSON.stringify(item)) storageSync.pushOne(entity, 'upsert', item); + // Always pass id (not just on delete) — entities like essayBatchAssignment + // have no `id`/`guid` field on the payload itself (they're keyed by a + // composite of other fields), so the pending-queue dedup/protection logic + // needs it explicitly rather than deriving it from the payload. + if (prevMap.get(id) !== JSON.stringify(item)) storageSync.pushOne(entity, 'upsert', item, id); } } @@ -1122,6 +1171,14 @@ export function AppProvider({ children }: { children: ReactNode }) { diff(prev.analysisResults, state.analysisResults, 'analysisResult', (ar) => ar.id); diff(prev.tests, state.tests, 'test', (t) => t.id); diff(prev.studentTests, state.studentTests, 'studentTest', (st) => st.id); + diff( + prev.essayAssignments, + state.essayAssignments, + 'essayBatchAssignment', + (a) => `${a.teacherKey}:${a.studentId}` + ); + diff(prev.essaySubmissions, state.essaySubmissions, 'essayOfflineSubmission', (s) => s.id); + diff(prev.userTemplates, state.userTemplates, 'userTemplate', (ut) => ut.id); if (JSON.stringify(prev.settings) !== JSON.stringify(state.settings)) { storageSync.pushOne('settings', 'upsert', state.settings); @@ -1442,6 +1499,14 @@ export function AppProvider({ children }: { children: ReactNode }) { storageSync.pushOne('gradingTask', 'delete', null, id); }, []); + // Pushed to Supabase via the delta-sync diff() effect below, like essayAssignments. + const saveUserTemplate = useCallback((t: UserTemplate) => { + dispatch({ type: 'SAVE_USER_TEMPLATE', payload: t }); + }, []); + const deleteUserTemplate = useCallback((id: string) => { + dispatch({ type: 'DELETE_USER_TEMPLATE', id }); + }, []); + // ─── Database sync ──────────────────────────────────────────────── const connectDatabase = useCallback( async (config: DatabaseConfig): Promise => { @@ -1455,7 +1520,8 @@ export function AppProvider({ children }: { children: ReactNode }) { const { data: fresh, error: hydrateError } = await storageSync.hydrate(); if (hydrateError) showToast(t('toast.sync_load_failed'), 'warning'); if (fresh) { - const merged = mergeStoreData(state, fresh, loadPendingQueue()); + const base = storageSync.didWipeLocalData() ? loadStore() : state; + const merged = mergeStoreData(base, fresh, loadPendingQueue()); dispatch({ type: 'SET_ALL', payload: merged }); try { await flushToLocalStorage(merged); @@ -1575,10 +1641,20 @@ export function AppProvider({ children }: { children: ReactNode }) { const signOutFromDatabase = useCallback(async () => { await storageSync.signOut(); clearAuditLogger(); + // Shared-device hygiene: wipe this account's data from localStorage so the + // next person to open the app on this browser doesn't see it. Only safe when + // everything has actually reached Supabase — a non-empty pending queue means + // wiping would lose edits that exist nowhere else yet. + if (loadPendingQueue().length === 0) { + clearLocalData(); + dispatch({ type: 'SET_ALL', payload: loadStore() }); + } else { + showToast(t('toast.signout_pending_writes'), 'warning'); + } if (localStorage.getItem(LOCAL_MODE_KEY) !== 'true') { setLandingState('show'); } - }, []); + }, [showToast, t]); // ─── Backup restore ───────────────────────────────────────────────────────── const importBackup = useCallback(async (json: string): Promise => { @@ -1714,6 +1790,8 @@ export function AppProvider({ children }: { children: ReactNode }) { deleteEssayTemplate, addGradingTasks, deleteGradingTask, + saveUserTemplate, + deleteUserTemplate, connectDatabase, disconnectDatabase, pushAllToDatabase, diff --git a/src/locales/de.json b/src/locales/de.json index b91cdc98..47939e21 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1088,7 +1088,8 @@ "sync_queued": "Offline — Änderungen werden bei Wiederverbindung synchronisiert.", "sync_flushed": "Wieder online — alle Änderungen synchronisiert.", "copy_paste_failed": "Kopieren/Einfügen fehlgeschlagen. Bitte versuchen Sie es erneut.", - "sync_offline_cache": "Sie sind offline — es werden lokal zwischengespeicherte Daten verwendet." + "sync_offline_cache": "Sie sind offline — es werden lokal zwischengespeicherte Daten verwendet.", + "signout_pending_writes": "Einige Änderungen wurden noch nicht synchronisiert — Ihre lokalen Daten wurden beibehalten, damit nichts verloren geht." }, "pwa": { "update_available_confirm": "Eine neue Version von RubricMaker ist verfügbar. Jetzt neu laden?" diff --git a/src/locales/en.json b/src/locales/en.json index 29240bdf..e6e58d13 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1088,7 +1088,8 @@ "sync_queued": "Offline — changes will sync when you reconnect.", "sync_flushed": "Back online — all changes synced.", "copy_paste_failed": "Copy/paste failed. Please try again.", - "sync_offline_cache": "You're offline — using locally cached data." + "sync_offline_cache": "You're offline — using locally cached data.", + "signout_pending_writes": "Some changes haven't synced yet — your local data was kept so nothing is lost." }, "pwa": { "update_available_confirm": "A new version of RubricMaker is available. Reload now?" diff --git a/src/locales/es.json b/src/locales/es.json index 1f25818c..b0b36a5e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1088,7 +1088,8 @@ "sync_queued": "Sin conexión — los cambios se sincronizarán cuando te vuelvas a conectar.", "sync_flushed": "De vuelta en línea — todos los cambios sincronizados.", "copy_paste_failed": "Error al copiar/pegar. Por favor, inténtalo de nuevo.", - "sync_offline_cache": "Estás sin conexión — usando datos almacenados en caché local." + "sync_offline_cache": "Estás sin conexión — usando datos almacenados en caché local.", + "signout_pending_writes": "Algunos cambios aún no se han sincronizado — tus datos locales se han conservado para no perder nada." }, "pwa": { "update_available_confirm": "Hay una nueva versión de RubricMaker disponible. ¿Recargar ahora?" diff --git a/src/locales/fr.json b/src/locales/fr.json index 11045b4e..e9a101f6 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1088,7 +1088,8 @@ "sync_queued": "Hors ligne — les modifications seront synchronisées à la reconnexion.", "sync_flushed": "De retour en ligne — toutes les modifications synchronisées.", "copy_paste_failed": "Copier/coller échoué. Veuillez réessayer.", - "sync_offline_cache": "Vous êtes hors ligne — utilisation des données en cache local." + "sync_offline_cache": "Vous êtes hors ligne — utilisation des données en cache local.", + "signout_pending_writes": "Certaines modifications ne sont pas encore synchronisées — vos données locales ont été conservées pour ne rien perdre." }, "pwa": { "update_available_confirm": "Une nouvelle version de RubricMaker est disponible. Recharger maintenant ?" diff --git a/src/locales/nl.json b/src/locales/nl.json index 1ffc1d69..eebe3c51 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -1088,7 +1088,8 @@ "sync_queued": "Offline — wijzigingen worden gesynchroniseerd wanneer je weer verbinding hebt.", "sync_flushed": "Weer online — alle wijzigingen gesynchroniseerd.", "copy_paste_failed": "Kopiëren/plakken mislukt. Probeer het opnieuw.", - "sync_offline_cache": "Je bent offline — lokaal gecachte gegevens worden gebruikt." + "sync_offline_cache": "Je bent offline — lokaal gecachte gegevens worden gebruikt.", + "signout_pending_writes": "Sommige wijzigingen zijn nog niet gesynchroniseerd — je lokale gegevens zijn bewaard zodat niets verloren gaat." }, "pwa": { "update_available_confirm": "Er is een nieuwe versie van RubricMaker beschikbaar. Nu opnieuw laden?" diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index f20a2f59..1c5922d6 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { BookOpen, @@ -18,8 +18,6 @@ import type { TFunction } from 'i18next'; import { useApp } from '../context/AppContext'; import { QUICK_START_TEMPLATES } from '../data/templates'; import { calcGradeSummary } from '../utils/gradeCalc'; -import { loadUserTemplates, saveUserTemplates } from '../store/storage'; -import type { UserTemplate } from '../types'; function dayKey(iso: string): string { return new Date(iso).toDateString(); @@ -43,16 +41,7 @@ function timeAgo(iso: string): string { export default function Dashboard() { const { t } = useTranslation(); const navigate = useNavigate(); - const { rubrics, students, studentRubrics, gradeScales, settings } = useApp(); - - const [userTemplates, setUserTemplates] = useState([]); - useEffect(() => { - try { - setUserTemplates(loadUserTemplates()); - } catch { - /* ignore */ - } - }, []); + const { rubrics, students, studentRubrics, gradeScales, settings, userTemplates, deleteUserTemplate } = useApp(); const scale = useMemo( () => gradeScales.find((g) => g.id === settings.defaultGradeScaleId) ?? gradeScales[0], @@ -488,9 +477,7 @@ export default function Dashboard() { title={t('dashboard.remove_template')} onClick={(e) => { e.stopPropagation(); - const updated = userTemplates.filter((ut) => ut.id !== tpl.id); - saveUserTemplates(updated); - setUserTemplates(updated); + deleteUserTemplate(tpl.id); }} > ✕ diff --git a/src/pages/GradeStudent.tsx b/src/pages/GradeStudent.tsx index 5aa85145..8d8f0c79 100644 --- a/src/pages/GradeStudent.tsx +++ b/src/pages/GradeStudent.tsx @@ -343,7 +343,7 @@ export default function GradeStudent() { }, [focusedCriterionIdx]); // Colleague picker for co-grading needs Supabase + a school; without either it - // falls back to the free-text input (see CLAUDE.md offline-first rule). + // falls back to the free-text input (see CLAUDE.md storage rule). React.useEffect(() => { if (!showCoGradeModal || !dbStatus.isConnected || !settings.schoolId) { setColleagues([]); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 7a9712b3..3972cbec 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -284,7 +284,7 @@ export default function LandingPage() { {[ { icon: CheckCircle, label: 'No student account needed' }, { icon: CheckCircle, label: 'CEFR proficiency tracking' }, - { icon: CheckCircle, label: 'Offline-first' }, + { icon: CheckCircle, label: 'Works offline' }, ].map(({ icon: Icon, label }) => ( r.id === id) : undefined; @@ -320,9 +321,7 @@ export default function RubricBuilder() { savedAt: new Date().toISOString(), }; try { - const existing = loadUserTemplates(); - const filtered = existing.filter((tpl) => tpl.id !== template.id); - saveUserTemplates([template, ...filtered].slice(0, 20)); + saveUserTemplate(template); showToast(t('rubricBuilder.save_as_template_success', `"${rubric.name}" saved as template`), 'success'); } catch { showToast(t('toast.export_error'), 'error'); diff --git a/src/pages/__tests__/pages.deepcoverage.test.tsx b/src/pages/__tests__/pages.deepcoverage.test.tsx index dce2507a..35aa2643 100644 --- a/src/pages/__tests__/pages.deepcoverage.test.tsx +++ b/src/pages/__tests__/pages.deepcoverage.test.tsx @@ -101,6 +101,7 @@ const mockUseApp = { analysisResults: [], essayAssignments: [], essaySubmissions: [], + userTemplates: [], dispatch: noop, addRubric: vi.fn(() => mockRubric), updateRubric: noop, @@ -147,6 +148,8 @@ const mockUseApp = { deleteVocabularyItem: noop, saveAnalysisResult: noop, deleteAnalysisResult: noop, + saveUserTemplate: noop, + deleteUserTemplate: noop, loginMicrosoft: vi.fn(), logoutMicrosoft: vi.fn(), syncToOneDrive: vi.fn(), diff --git a/src/services/__tests__/mediaStore.test.ts b/src/services/__tests__/mediaStore.test.ts index 9cbf6e49..dbee3fbd 100644 --- a/src/services/__tests__/mediaStore.test.ts +++ b/src/services/__tests__/mediaStore.test.ts @@ -1,6 +1,6 @@ import 'fake-indexeddb/auto'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { putBlob, getBlob, deleteBlob, listIds, estimateUsage } from '../mediaStore'; +import { putBlob, getBlob, deleteBlob, listIds, estimateUsage, pruneOrphanedBlobs } from '../mediaStore'; function makeBlob(content: string, type = 'audio/webm'): Blob { return new Blob([content], { type }); @@ -66,6 +66,37 @@ describe('mediaStore', () => { }); }); +describe('pruneOrphanedBlobs', () => { + beforeEach(async () => { + for (const id of await listIds()) { + await deleteBlob(id); + } + }); + + it('deletes blobs not present in the referenced set', async () => { + await putBlob('kept', makeBlob('1'), 'audio/webm'); + await putBlob('orphan', makeBlob('2'), 'audio/webm'); + + await pruneOrphanedBlobs(new Set(['kept'])); + + expect((await listIds()).sort()).toEqual(['kept']); + }); + + it('deletes nothing when every blob is referenced', async () => { + await putBlob('a', makeBlob('1'), 'audio/webm'); + await putBlob('b', makeBlob('2'), 'audio/webm'); + + await pruneOrphanedBlobs(new Set(['a', 'b'])); + + expect((await listIds()).sort()).toEqual(['a', 'b']); + }); + + it('is a no-op on an empty store', async () => { + await expect(pruneOrphanedBlobs(new Set())).resolves.toBeUndefined(); + expect(await listIds()).toEqual([]); + }); +}); + describe('estimateUsage', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/services/database/StorageSync.ts b/src/services/database/StorageSync.ts index 73883476..da6a846d 100644 --- a/src/services/database/StorageSync.ts +++ b/src/services/database/StorageSync.ts @@ -1,10 +1,17 @@ import i18n from 'i18next'; +import type { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseAdapter } from './SupabaseAdapter'; import { AttachmentSync } from './AttachmentSync'; import { RecordingSync } from './RecordingSync'; import type { DatabaseConfig, SyncStatus, SyncResult, DbUser } from './types'; import type { StoreData } from '../../store/storage'; -import { addToPendingQueue, loadPendingQueue, removePendingWrites, DEFAULT_SETTINGS } from '../../store/storage'; +import { + addToPendingQueue, + loadPendingQueue, + removePendingWrites, + clearLocalData, + DEFAULT_SETTINGS, +} from '../../store/storage'; import { logEvent } from '../logging/clientLogger'; import type { Rubric, @@ -21,15 +28,18 @@ import type { SpeakingSession, DocumentAnalysisResult, EssayAssignment, + EssaySubmission, EssayTemplate, GradingTask, Test, StudentTest, TestAssignment, + UserTemplate, } from '../../types'; const CONFIG_KEY = 'rm_supabase_config'; const LAST_SYNC_KEY = 'rm_last_sync_at'; +const OWNER_KEY = 'rm_owner_uid'; function normalizeSupabaseUrl(url: string): string { let normalized = url.trim(); @@ -92,6 +102,37 @@ class StorageSyncService { private reconnectListeners: Set<() => void> = new Set(); private networkListenerActive = false; private flushInProgress = false; + private realtimeChannel: ReturnType | null = null; + private realtimeDebounceTimer: ReturnType | null = null; + + // Every table backing a StoreData collection, with the RLS-scoping column to filter + // change events by so a client only receives events for rows it can already read. + // student_rubrics backs both studentRubrics and peerReviews (split by a boolean + // column) — one subscription covers both, since a refresh re-fetches the whole table. + private static readonly REALTIME_TABLES: Array<{ table: string; filterColumn: string }> = [ + { table: 'rubrics', filterColumn: 'owner_id' }, + { table: 'classes', filterColumn: 'owner_id' }, + { table: 'students', filterColumn: 'owner_id' }, + { table: 'student_rubrics', filterColumn: 'grader_id' }, + { table: 'attachments', filterColumn: 'owner_id' }, + { table: 'grade_scales', filterColumn: 'owner_id' }, + { table: 'comment_snippets', filterColumn: 'owner_id' }, + { table: 'comment_bank', filterColumn: 'owner_id' }, + { table: 'export_templates', filterColumn: 'owner_id' }, + { table: 'favorite_standards', filterColumn: 'owner_id' }, + { table: 'self_assessments', filterColumn: 'owner_id' }, + { table: 'speaking_sessions', filterColumn: 'owner_id' }, + { table: 'analysis_results', filterColumn: 'owner_id' }, + { table: 'tests', filterColumn: 'owner_id' }, + { table: 'student_tests', filterColumn: 'owner_id' }, + { table: 'essay_templates', filterColumn: 'owner_id' }, + { table: 'grading_tasks', filterColumn: 'owner_id' }, + { table: 'essay_batch_assignments', filterColumn: 'owner_id' }, + { table: 'essay_offline_submissions', filterColumn: 'owner_id' }, + { table: 'user_templates', filterColumn: 'owner_id' }, + { table: 'user_settings', filterColumn: 'user_id' }, + ]; + private static readonly REALTIME_DEBOUNCE_MS = 800; // ── Status ──────────────────────────────────────────────────────────────── @@ -212,12 +253,19 @@ class StorageSyncService { async configure(config: DatabaseConfig): Promise { const ok = await this.adapter.connect(config); if (ok) { + // startRealtimeSync() is a no-op while a channel already exists, so without + // this, configure() called twice without an intervening disconnect()/signOut() + // (e.g. an owner switch) would leave the realtime subscription scoped to the + // previous user's uid. + this.stopRealtimeSync(); + this.guardOwnerSwitch(); this.setStatus('idle'); this.adapter.setAuthChangeListener((user) => { this.notifyAuthChange(user); this.notifyListeners(); }); this.startNetworkListener(); + this.startRealtimeSync(); // Flush any writes that failed in a previous session this.flushPendingQueue().catch(console.warn); } else { @@ -226,13 +274,98 @@ class StorageSyncService { return ok; } + /** + * Subscribes to Postgres change events on every synced table (RLS + a per-row + * filter scope it to the current user) so edits made on another device show up + * without waiting for reconnect or next login. Bursts of changes are debounced + * into a single refresh — this is a "something changed, go refetch" signal, not + * a delta transport, so it reuses the already-correct hydrate()+mergeStoreData() + * pipeline (via the reconnect-listener callback) instead of hand-decoding ~20 + * different row shapes into app objects a second time. + */ + private startRealtimeSync(): void { + const client = this.adapter.getClient(); + const uid = this.adapter.getCurrentUserId(); + if (!client || !uid || this.realtimeChannel) return; + const channel = client.channel(`sync:${uid}`); + for (const { table, filterColumn } of StorageSyncService.REALTIME_TABLES) { + channel.on( + 'postgres_changes', + { event: '*', schema: 'public', table, filter: `${filterColumn}=eq.${uid}` }, + () => this.scheduleRealtimeRefresh() + ); + } + channel.subscribe(); + this.realtimeChannel = channel; + } + + private stopRealtimeSync(): void { + if (this.realtimeDebounceTimer) { + clearTimeout(this.realtimeDebounceTimer); + this.realtimeDebounceTimer = null; + } + if (this.realtimeChannel) { + this.adapter.getClient()?.removeChannel(this.realtimeChannel); + this.realtimeChannel = null; + } + } + + private scheduleRealtimeRefresh(): void { + if (this.realtimeDebounceTimer) clearTimeout(this.realtimeDebounceTimer); + this.realtimeDebounceTimer = setTimeout(() => { + this.realtimeDebounceTimer = null; + // AppContext's onNetworkReconnect handler already does exactly what a + // realtime change needs: hydrate, merge against pending writes, flush + // to localStorage. + this.notifyReconnect(); + }, StorageSyncService.REALTIME_DEBOUNCE_MS); + } + + private wipedLocalDataOnConfigure = false; + + /** True when the last configure() wiped local data because a different user signed in. */ + didWipeLocalData(): boolean { + return this.wipedLocalDataOnConfigure; + } + + // Local data (including the pending-sync queue) always belongs to exactly one + // account. If a different user signs in on this browser, wipe it BEFORE the + // pending queue is flushed so the previous user's edits are never pushed into + // the new user's account. + private guardOwnerSwitch(): void { + this.wipedLocalDataOnConfigure = false; + try { + const uid = this.adapter.getCurrentUserId(); + if (!uid) return; + // An anonymous session is a fallback identity (connect() calls + // signInAnonymously() whenever it can't read a real session — including + // a transient race right after a page reload, before Supabase-js has + // finished restoring the persisted session from storage), not a + // genuine different user. Never wipe — or overwrite the stored owner — + // because of one; a later reconnect with the real session should still + // compare against the last known real owner. + if (this.adapter.isAnonymousSession()) return; + const previousOwner = localStorage.getItem(OWNER_KEY); + if (previousOwner && previousOwner !== uid) { + clearLocalData(); + this.lastSyncAt = null; + this.wipedLocalDataOnConfigure = true; + } + localStorage.setItem(OWNER_KEY, uid); + } catch { + // storage unavailable — skip the guard + } + } + disconnect() { + this.stopRealtimeSync(); this.adapter.disconnect(); this.setStatus('offline'); clearSupabaseConfig(); } async signOut(): Promise { + this.stopRealtimeSync(); await this.adapter.signOut(); this.setStatus('offline'); clearSupabaseConfig(); @@ -399,6 +532,9 @@ class StorageSyncService { studentTests, essayTemplates, gradingTasks, + essayAssignments, + essaySubmissions, + userTemplates, attachments, settings, profile, @@ -420,6 +556,9 @@ class StorageSyncService { this.adapter.fetchStudentTests(), this.adapter.fetchEssayTemplates(), this.adapter.fetchGradingTasks(), + this.adapter.fetchEssayBatchAssignments(), + this.adapter.fetchEssayOfflineSubmissions(), + this.adapter.fetchUserTemplates(), this.attachmentSync.hydrateAttachments(), this.adapter.fetchSettings(), this.adapter.fetchMyProfile(), @@ -487,6 +626,9 @@ class StorageSyncService { studentTests, essayTemplates, gradingTasks, + essayAssignments, + essaySubmissions, + userTemplates, attachments, ...(mergedSettings ? { settings: mergedSettings as StoreData['settings'] } : {}), }; @@ -536,6 +678,11 @@ class StorageSyncService { ...state.studentTests.map((st) => this.adapter.upsertStudentTest(st)), ...state.essayTemplates.map((et) => this.adapter.upsertEssayTemplate(et)), ...state.gradingTasks.map((gt) => this.adapter.upsertGradingTask(gt)), + ...state.essayAssignments.map((a) => + this.adapter.upsertEssayBatchAssignment(`${a.teacherKey}:${a.studentId}`, a) + ), + ...state.essaySubmissions.map((s) => this.adapter.upsertEssayOfflineSubmission(s)), + ...state.userTemplates.map((ut) => this.adapter.upsertUserTemplate(ut)), this.adapter.saveSettings(state.settings), ]; await Promise.all(ups); @@ -657,6 +804,21 @@ class StorageSyncService { if (action === 'upsert') result = await this.adapter.upsertGradingTask(payload as GradingTask); else if (id) result = await this.adapter.deleteGradingTask(id); break; + case 'essayBatchAssignment': + if (action === 'upsert') { + const a = payload as EssayAssignment; + result = await this.adapter.upsertEssayBatchAssignment(`${a.teacherKey}:${a.studentId}`, a); + } else if (id) result = await this.adapter.deleteEssayBatchAssignment(id); + break; + case 'essayOfflineSubmission': + if (action === 'upsert') + result = await this.adapter.upsertEssayOfflineSubmission(payload as EssaySubmission); + else if (id) result = await this.adapter.deleteEssayOfflineSubmission(id); + break; + case 'userTemplate': + if (action === 'upsert') result = await this.adapter.upsertUserTemplate(payload as UserTemplate); + else if (id) result = await this.adapter.deleteUserTemplate(id); + break; case 'settings': if (action === 'upsert') result = await this.adapter.saveSettings(payload as import('../../types').AppSettings); diff --git a/src/services/database/SupabaseAdapter.ts b/src/services/database/SupabaseAdapter.ts index 6d18f851..3f32c045 100644 --- a/src/services/database/SupabaseAdapter.ts +++ b/src/services/database/SupabaseAdapter.ts @@ -16,6 +16,7 @@ import type { SessionRecording, DocumentAnalysisResult, EssayAssignment, + EssaySubmission, EssayTemplate, GradingTask, StudentEssayAssignmentSummary, @@ -23,6 +24,7 @@ import type { StudentTest, TestAssignment, StudentTestAssignmentSummary, + UserTemplate, MarketplaceListing, CefrLevel, } from '../../types'; @@ -32,6 +34,7 @@ import { nanoid } from '../../utils/nanoid'; export class SupabaseAdapter { private client: SupabaseClient | null = null; private userId: string | null = null; + private userIsAnonymous = false; private onAuthChange: ((user: DbUser | null) => void) | null = null; private activeUrl: string | null = null; private activeKey: string | null = null; @@ -120,12 +123,14 @@ export class SupabaseAdapter { } = await this.client.auth.getSession(); if (session) { this.userId = session.user.id; + this.userIsAnonymous = session.user.is_anonymous ?? false; return true; } // Session lost; sign in anonymously for backward compat const { data, error } = await this.client.auth.signInAnonymously(); if (error || !data.session) return false; this.userId = data.session.user.id; + this.userIsAnonymous = true; return true; } @@ -142,10 +147,12 @@ export class SupabaseAdapter { } = await this.client.auth.getSession(); if (session) { this.userId = session.user.id; + this.userIsAnonymous = session.user.is_anonymous ?? false; } else { const { data, error } = await this.client.auth.signInAnonymously(); if (error || !data.session) return false; this.userId = data.session.user.id; + this.userIsAnonymous = true; } this.registerAuthListener(); @@ -470,6 +477,11 @@ export class SupabaseAdapter { return this.userId; } + /** True when the current session is Supabase's anonymous-sign-in fallback, not a real logged-in user. */ + isAnonymousSession(): boolean { + return this.userIsAnonymous; + } + getClient(): SupabaseClient | null { return this.client; } @@ -1219,6 +1231,83 @@ export class SupabaseAdapter { return error ? { success: false, error: error.message } : { success: true }; } + // ── Essay batch assignments (class-assignment tracking, distinct from essay_assignments) ── + + async fetchEssayBatchAssignments(): Promise { + const { data, error } = await this.db().from('essay_batch_assignments').select('data'); + if (error) { + console.error('fetchEssayBatchAssignments', error); + return []; + } + return (data ?? []).map((r) => r.data as EssayAssignment); + } + + async upsertEssayBatchAssignment(id: string, a: EssayAssignment): Promise { + const { error } = await this.db() + .from('essay_batch_assignments') + .upsert({ id, owner_id: this.uid(), data: a }, { onConflict: 'id' }); + return error ? { success: false, error: error.message } : { success: true }; + } + + async deleteEssayBatchAssignment(id: string): Promise { + const { error } = await this.db() + .from('essay_batch_assignments') + .delete() + .eq('id', id) + .eq('owner_id', this.uid()); + return error ? { success: false, error: error.message } : { success: true }; + } + + // ── Essay offline submissions (share-code import, distinct from essay_submissions) ── + + async fetchEssayOfflineSubmissions(): Promise { + const { data, error } = await this.db().from('essay_offline_submissions').select('data'); + if (error) { + console.error('fetchEssayOfflineSubmissions', error); + return []; + } + return (data ?? []).map((r) => r.data as EssaySubmission); + } + + async upsertEssayOfflineSubmission(s: EssaySubmission): Promise { + const { error } = await this.db() + .from('essay_offline_submissions') + .upsert({ id: s.id, owner_id: this.uid(), data: s }, { onConflict: 'id' }); + return error ? { success: false, error: error.message } : { success: true }; + } + + async deleteEssayOfflineSubmission(id: string): Promise { + const { error } = await this.db() + .from('essay_offline_submissions') + .delete() + .eq('id', id) + .eq('owner_id', this.uid()); + return error ? { success: false, error: error.message } : { success: true }; + } + + // ── User templates (saved rubric templates) ────────────────────────────── + + async fetchUserTemplates(): Promise { + const { data, error } = await this.db().from('user_templates').select('data'); + if (error) { + console.error('fetchUserTemplates', error); + return []; + } + return (data ?? []).map((r) => r.data as UserTemplate); + } + + async upsertUserTemplate(t: UserTemplate): Promise { + const { error } = await this.db() + .from('user_templates') + .upsert({ id: t.id, owner_id: this.uid(), data: t }, { onConflict: 'id' }); + return error ? { success: false, error: error.message } : { success: true }; + } + + async deleteUserTemplate(id: string): Promise { + const { error } = await this.db().from('user_templates').delete().eq('id', id).eq('owner_id', this.uid()); + return error ? { success: false, error: error.message } : { success: true }; + } + async fetchGradingTasks(): Promise { const { data, error } = await this.db().from('grading_tasks').select('data'); if (error) { diff --git a/src/services/mediaStore.ts b/src/services/mediaStore.ts index 1e0835bf..cdd12afb 100644 --- a/src/services/mediaStore.ts +++ b/src/services/mediaStore.ts @@ -69,6 +69,20 @@ export async function listIds(): Promise { return keys.map(String); } +/** + * Deletes any stored blob whose id isn't in `referencedIds`. A recording's blob is + * normally deleted alongside its SessionRecording (RecordingSync.deleteRecording), but + * a remote hydrate/merge can drop a session's recordings without an app-level delete + * ever running (e.g. deleted on another device) — this sweeps those up after each sync. + */ +export async function pruneOrphanedBlobs(referencedIds: ReadonlySet): Promise { + const ids = await listIds(); + const orphaned = ids.filter((id) => !referencedIds.has(id)); + for (const id of orphaned) { + await deleteBlob(id); + } +} + export async function estimateUsage(): Promise { try { if (typeof navigator !== 'undefined' && navigator.storage?.estimate) { diff --git a/src/store/storage.test.ts b/src/store/storage.test.ts index 06b1b069..8d395798 100644 --- a/src/store/storage.test.ts +++ b/src/store/storage.test.ts @@ -28,6 +28,7 @@ import { saveTestTimer, clearTestTimer, onStorageQuotaExceeded, + clearLocalData, } from './storage'; import type { Rubric, Student, Class, AppSettings, RubricFormat } from '../types'; import { DEFAULT_FORMAT } from '../types'; @@ -506,6 +507,58 @@ describe('pending sync queue', () => { expect(queue).toHaveLength(1); expect((queue[0].payload as { theme: string }).theme).toBe('light'); }); + + it('drops the oldest entry when the queue is at capacity', () => { + for (let i = 0; i < 500; i++) { + addToPendingQueue({ entity: 'rubric', action: 'upsert', payload: { id: `r${i}` } }); + } + addToPendingQueue({ entity: 'rubric', action: 'upsert', payload: { id: 'overflow' } }); + const queue = loadPendingQueue(); + expect(queue).toHaveLength(500); + expect(queue.some((q) => (q.payload as { id: string }).id === 'r0')).toBe(false); + expect(queue.some((q) => (q.payload as { id: string }).id === 'overflow')).toBe(true); + }); + + it('fires the quota handler when the queue write hits a full localStorage', () => { + const handler = vi.fn(); + onStorageQuotaExceeded(handler); + const original = localStorage.setItem.bind(localStorage); + const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => { + if (key === 'rm_pending_sync') throw new DOMException('full', 'QuotaExceededError'); + original(key, value); + }); + try { + addToPendingQueue({ entity: 'rubric', action: 'upsert', payload: { id: 'r1' } }); + expect(handler).toHaveBeenCalledOnce(); + } finally { + spy.mockRestore(); + onStorageQuotaExceeded(() => {}); + } + }); +}); + +describe('clearLocalData', () => { + it('wipes rm_-prefixed data keys but preserves connection settings', () => { + localStorage.setItem('rm_rubrics', '[]'); + localStorage.setItem('rm_pending_sync', '[]'); + localStorage.setItem('rm_migration_done', 'true'); + localStorage.setItem('rm_last_sync_at', '2024-01-01'); + localStorage.setItem('rm_owner_uid', 'user-a'); + localStorage.setItem('rm_supabase_config', '{"supabaseUrl":"https://x"}'); + localStorage.setItem('rm_local_mode', 'true'); + localStorage.setItem('unrelated_key', 'kept'); + + clearLocalData(); + + expect(localStorage.getItem('rm_rubrics')).toBeNull(); + expect(localStorage.getItem('rm_pending_sync')).toBeNull(); + expect(localStorage.getItem('rm_migration_done')).toBeNull(); + expect(localStorage.getItem('rm_last_sync_at')).toBeNull(); + expect(localStorage.getItem('rm_owner_uid')).toBeNull(); + expect(localStorage.getItem('rm_supabase_config')).toBe('{"supabaseUrl":"https://x"}'); + expect(localStorage.getItem('rm_local_mode')).toBe('true'); + expect(localStorage.getItem('unrelated_key')).toBe('kept'); + }); }); describe('test timer storage', () => { diff --git a/src/store/storage.ts b/src/store/storage.ts index 82020d22..963ce5bc 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -382,6 +382,25 @@ export function saveGradingTasks(tasks: GradingTask[]) { save(KEYS.gradingTasks, tasks); } +// ─── Local data wipe (user switch / sign-out) ───────────────────────────────── + +// Connection settings survive a wipe; everything else under the rm_ prefix +// (entity data, pending queue, migration/tour flags, last-sync marker) goes. +const WIPE_PRESERVED_KEYS = new Set(['rm_supabase_config', 'rm_local_mode']); + +export function clearLocalData(): void { + try { + const doomed: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('rm_') && !WIPE_PRESERVED_KEYS.has(key)) doomed.push(key); + } + doomed.forEach((key) => localStorage.removeItem(key)); + } catch { + // storage unavailable — nothing to wipe + } +} + // ─── Full Backup / Restore ───────────────────────────────────────────────────── export function exportStore(state: StoreData): StoreData { @@ -592,7 +611,10 @@ export interface PendingWrite { } function pendingKey(op: Pick): string { - const eid = op.action === 'delete' ? op.entityId : (op.payload as Record | null)?.id; + // entityId is authoritative when present (every call site passes it, including upserts — + // required for entities like essayBatchAssignment whose payload has no id/guid field to + // fall back to). The payload.id fallback only serves callers that predate that change. + const eid = op.entityId ?? (op.payload as Record | null)?.id; return `${op.entity}:${eid ?? 'singleton'}`; } @@ -605,6 +627,11 @@ export function loadPendingQueue(): PendingWrite[] { } } +// One entry per entity:id (upserts are deduped), so the cap is only reached after +// ~500 distinct records are touched while pushes keep failing. Drop-oldest at the +// cap; consider a per-entity eviction policy if teachers ever hit it. +const MAX_PENDING_OPS = 500; + export function addToPendingQueue(op: Omit): void { try { const queue = loadPendingQueue(); @@ -618,11 +645,20 @@ export function addToPendingQueue(op: Omit): vo if (idx >= 0) { queue[idx] = entry; } else { + if (queue.length >= MAX_PENDING_OPS) { + console.warn('[storage] pending-sync queue full — dropping oldest entry', queue[0]); + queue.shift(); + // Same handler as a quota error — the dropped entry is unsynced data loss + // just like a failed write, and the user has no other way to know. + quotaExceededHandler?.(); + } queue.push(entry); } localStorage.setItem(PENDING_SYNC_KEY, JSON.stringify(queue)); - } catch { - // quota errors are non-fatal + } catch (e) { + // The queued edit now exists only in memory — surface it instead of losing it silently. + console.error('[storage] pending-sync queue write failed:', e); + if (isQuotaExceededError(e)) quotaExceededHandler?.(); } } diff --git a/src/utils/syncMerge.test.ts b/src/utils/syncMerge.test.ts index a33e366d..576ca8da 100644 --- a/src/utils/syncMerge.test.ts +++ b/src/utils/syncMerge.test.ts @@ -556,15 +556,16 @@ describe('mergeStoreData', () => { expect(result.favoriteStandards).toEqual([localOnly]); }); - it('userTemplates (not part of COLLECTIONS) always stays local, even if remote provides it', () => { - const localTemplates = [{ id: 't1', name: 'Local Template' }] as unknown as StoreData['userTemplates']; - const local = baseStoreData({ userTemplates: localTemplates }); - // Even if a caller mistakenly includes userTemplates on remote, mergeStoreData - // doesn't iterate it (not in COLLECTIONS) and settings/collections logic doesn't touch it. - const remote = { userTemplates: [{ id: 't2', name: 'Remote Template' }] } as unknown as Partial; + it('userTemplates: remote-only templates survive the merge and a pending upsert protects a local-only one', () => { + const localOnly = { id: 't1', name: 'Local Template' } as unknown as StoreData['userTemplates'][number]; + const remoteOnly = { id: 't2', name: 'Remote Template' } as unknown as StoreData['userTemplates'][number]; + const local = baseStoreData({ userTemplates: [localOnly] }); + const remote: Partial = { userTemplates: [remoteOnly] }; + const queue: PendingWrite[] = [pendingWrite({ entity: 'userTemplate', action: 'upsert', payload: localOnly })]; - const result = mergeStoreData(local, remote, []); - expect(result.userTemplates).toEqual(localTemplates); + const result = mergeStoreData(local, remote, queue); + expect(result.userTemplates).toEqual(expect.arrayContaining([localOnly, remoteOnly])); + expect(result.userTemplates).toHaveLength(2); }); it('does not crash on malformed pending queue entries missing entityId and payload', () => { @@ -596,4 +597,88 @@ describe('mergeStoreData', () => { const result = mergeStoreData(local, remote, queue); expect(result.rubrics).toEqual([localRubric]); }); + + it('remote-only essayTemplates survive the merge (cross-device visibility)', () => { + const remoteTemplate = { + id: 'et1', + rubricId: 'r1', + title: 'Remote', + } as unknown as StoreData['essayTemplates'][number]; + const local = baseStoreData({ essayTemplates: [] }); + const remote: Partial = { essayTemplates: [remoteTemplate] }; + + const result = mergeStoreData(local, remote, []); + expect(result.essayTemplates).toEqual([remoteTemplate]); + }); + + it('remote-only gradingTasks survive the merge and a pending delete is honored', () => { + const kept = { id: 'gt1', rubricId: 'r1', studentId: 's1' } as unknown as StoreData['gradingTasks'][number]; + const deleted = { id: 'gt2', rubricId: 'r1', studentId: 's2' } as unknown as StoreData['gradingTasks'][number]; + const local = baseStoreData({ gradingTasks: [] }); + const remote: Partial = { gradingTasks: [kept, deleted] }; + const queue: PendingWrite[] = [ + pendingWrite({ entity: 'gradingTask', action: 'delete', payload: null, entityId: 'gt2' }), + ]; + + const result = mergeStoreData(local, remote, queue); + expect(result.gradingTasks).toEqual([kept]); + }); + + it('essayAssignments are keyed by teacherKey:studentId — remote-only rows survive, a pending upsert for one student protects only that student', () => { + const localOnly = { + teacherKey: 'tk1', + studentId: 's-local', + title: 'Local only', + } as unknown as StoreData['essayAssignments'][number]; + const remoteOnly = { + teacherKey: 'tk1', + studentId: 's-remote', + title: 'Remote only', + } as unknown as StoreData['essayAssignments'][number]; + const local = baseStoreData({ essayAssignments: [localOnly] }); + const remote: Partial = { essayAssignments: [remoteOnly] }; + const queue: PendingWrite[] = [ + pendingWrite({ + entity: 'essayBatchAssignment', + action: 'upsert', + payload: localOnly, + entityId: 'tk1:s-local', + }), + ]; + + const result = mergeStoreData(local, remote, queue); + expect(result.essayAssignments).toEqual(expect.arrayContaining([localOnly, remoteOnly])); + expect(result.essayAssignments).toHaveLength(2); + }); + + it('two essayAssignments sharing a teacherKey but different studentIds do not collide during merge', () => { + const a = { + teacherKey: 'tk1', + studentId: 's1', + title: 'A', + } as unknown as StoreData['essayAssignments'][number]; + const b = { + teacherKey: 'tk1', + studentId: 's2', + title: 'B', + } as unknown as StoreData['essayAssignments'][number]; + const local = baseStoreData({ essayAssignments: [] }); + const remote: Partial = { essayAssignments: [a, b] }; + + const result = mergeStoreData(local, remote, []); + expect(result.essayAssignments).toEqual([a, b]); + }); + + it('remote-only essaySubmissions survive the merge and a pending delete is honored', () => { + const kept = { id: 'sub1', teacherKey: 'tk1' } as unknown as StoreData['essaySubmissions'][number]; + const deleted = { id: 'sub2', teacherKey: 'tk1' } as unknown as StoreData['essaySubmissions'][number]; + const local = baseStoreData({ essaySubmissions: [] }); + const remote: Partial = { essaySubmissions: [kept, deleted] }; + const queue: PendingWrite[] = [ + pendingWrite({ entity: 'essayOfflineSubmission', action: 'delete', payload: null, entityId: 'sub2' }), + ]; + + const result = mergeStoreData(local, remote, queue); + expect(result.essaySubmissions).toEqual([kept]); + }); }); diff --git a/src/utils/syncMerge.ts b/src/utils/syncMerge.ts index 334599ae..e9729bad 100644 --- a/src/utils/syncMerge.ts +++ b/src/utils/syncMerge.ts @@ -62,9 +62,12 @@ function pendingIdsFor(queue: PendingWrite[], entity: string): PendingIndex { const lastActionById = new Map(); for (const op of queue) { if (op.entity !== entity) continue; + // entityId is authoritative when present (every pushOne call site passes it now, + // including upserts) — required for entities like essayBatchAssignment whose + // payload has no id/guid field to fall back to. const payloadId = (op.payload as { id?: string; guid?: string } | null)?.id ?? (op.payload as { guid?: string } | null)?.guid; - const id = op.action === 'delete' ? op.entityId : payloadId; + const id = op.entityId ?? payloadId; if (!id) continue; lastActionById.set(id, op.action); } @@ -168,6 +171,15 @@ const COLLECTIONS: CollectionSpec[] = [ getId: (st: { id: string }) => st.id, getUpdatedAt: (st: { updatedAt?: string }) => st.updatedAt, }, + { key: 'essayTemplates', entity: 'essayTemplate', getId: (et: { id: string }) => et.id }, + { key: 'gradingTasks', entity: 'gradingTask', getId: (gt: { id: string }) => gt.id }, + { + key: 'essayAssignments', + entity: 'essayBatchAssignment', + getId: (a: { teacherKey: string; studentId: string }) => `${a.teacherKey}:${a.studentId}`, + }, + { key: 'essaySubmissions', entity: 'essayOfflineSubmission', getId: (s: { id: string }) => s.id }, + { key: 'userTemplates', entity: 'userTemplate', getId: (ut: { id: string }) => ut.id }, ] as CollectionSpec[]; export function mergeStoreData(local: StoreData, remote: Partial, pendingQueue: PendingWrite[]): StoreData { diff --git a/supabase/functions/nightly-backup/index.ts b/supabase/functions/nightly-backup/index.ts new file mode 100644 index 00000000..cccae80c --- /dev/null +++ b/supabase/functions/nightly-backup/index.ts @@ -0,0 +1,103 @@ +// Edge Function: nightly-backup +// Called nightly by Supabase Cron or by a self-hosted scheduler hitting this function URL +// (this repo's bundled docker-compose stack has no functions runtime, so it uses +// scripts/backup.sh instead — see README's "Nightly cloud backup" section). +// For every teacher/admin profile, dumps their rows via export_owner_backup() and +// uploads the JSON to the private 'backups' bucket at {userId}/{timestamp}.json, +// then prunes older snapshots beyond KEEP_COUNT for that user. + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const KEEP_COUNT = 7; +const PROFILE_PAGE_SIZE = 1000; + +serve(async (req) => { + const supabaseUrl = Deno.env.get('SUPABASE_URL'); + const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + if (!supabaseUrl || !serviceKey) { + return new Response(JSON.stringify({ error: 'Server misconfigured' }), { status: 500 }); + } + + // Supabase Cron passes the service role key as the bearer token. + const authHeader = req.headers.get('Authorization') ?? ''; + if (authHeader !== `Bearer ${serviceKey}`) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + } + + const admin = createClient( + supabaseUrl, + serviceKey, + { auth: { autoRefreshToken: false, persistSession: false } }, + ); + + let profiles: Array<{ id: string }>; + try { + profiles = await fetchAllOwnerProfiles(admin); + } catch (e) { + console.error('[nightly-backup] failed to list owner profiles', e); + return new Response(JSON.stringify({ error: 'Failed to list owner profiles' }), { status: 500 }); + } + + let backedUp = 0; + const errors: Array<{ userId: string; error: string }> = []; + + for (const profile of profiles) { + try { + const { data: snapshot, error: exportErr } = await admin.rpc('export_owner_backup', { + target_owner: profile.id, + }); + if (exportErr) throw new Error(exportErr.message); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const path = `${profile.id}/${timestamp}.json`; + const { error: uploadErr } = await admin.storage + .from('backups') + .upload(path, JSON.stringify(snapshot), { contentType: 'application/json' }); + if (uploadErr) throw new Error(uploadErr.message); + + await pruneOldBackups(admin, profile.id); + backedUp++; + } catch (e) { + errors.push({ userId: profile.id, error: e instanceof Error ? e.message : String(e) }); + } + } + + return new Response(JSON.stringify({ backedUp, errors }), { status: 200 }); +}); + +// The Supabase API caps a single response at 1000 rows, so a school with more than +// 1000 teacher/admin accounts would silently lose backup coverage for the rest +// without paging through the full result set. +async function fetchAllOwnerProfiles(admin: ReturnType): Promise> { + const all: Array<{ id: string }> = []; + let from = 0; + for (;;) { + const { data, error } = await admin + .from('profiles') + .select('id') + .in('role', ['admin', 'teacher']) + .order('id') + .range(from, from + PROFILE_PAGE_SIZE - 1); + if (error) throw new Error(error.message); + all.push(...(data ?? [])); + if (!data || data.length < PROFILE_PAGE_SIZE) break; + from += PROFILE_PAGE_SIZE; + } + return all; +} + +async function pruneOldBackups( + admin: ReturnType, + userId: string, +): Promise { + const { data: files, error } = await admin.storage.from('backups').list(userId, { + sortBy: { column: 'name', order: 'desc' }, + }); + if (error) throw new Error(`Failed to list backups: ${error.message}`); + if (!files) return; + const stale = files.slice(KEEP_COUNT); + if (stale.length === 0) return; + const { error: removeErr } = await admin.storage.from('backups').remove(stale.map((f) => `${userId}/${f.name}`)); + if (removeErr) throw new Error(`Failed to prune backups: ${removeErr.message}`); +} diff --git a/supabase/migrations/045_essay_local_tracking_sync.sql b/supabase/migrations/045_essay_local_tracking_sync.sql new file mode 100644 index 00000000..7f26460d --- /dev/null +++ b/supabase/migrations/045_essay_local_tracking_sync.sql @@ -0,0 +1,46 @@ +-- Multi-device sync for two local-only essay collections that are distinct from +-- the existing essay_assignments/essay_submissions tables: +-- +-- - essay_batch_assignments: a teacher's class-assignment bookkeeping (which +-- student was assigned which essay), used by the Activity Dashboard/EssayListPage. +-- Unrelated to essay_assignments, which is a single row per shareable link used +-- by the student-facing DB-submission flow (keyed by teacherKey alone). +-- - essay_offline_submissions: essays imported via a manually pasted share code +-- (the fully offline submission path, no student account), embedding full HTML +-- content — distinct from essay_submissions, which stores Storage-bucket paths +-- for the online student-portal flow. +-- +-- Same jsonb-document pattern as essay_templates (036_essay_templates.sql). +-- +-- Indexes below are plain (not CONCURRENTLY) intentionally: Supabase migrations run +-- inside a transaction, where CONCURRENTLY is not allowed. Both tables are created +-- immediately above in this same migration, so there's no existing data to lock. + +create table if not exists public.essay_batch_assignments ( + id text primary key, -- `${teacherKey}:${studentId}` + owner_id uuid not null references public.profiles(id) on delete cascade, + data jsonb not null +); +create index if not exists essay_batch_assignments_owner_id_idx on public.essay_batch_assignments(owner_id); + +create table if not exists public.essay_offline_submissions ( + id text primary key, + owner_id uuid not null references public.profiles(id) on delete cascade, + data jsonb not null +); +create index if not exists essay_offline_submissions_owner_id_idx on public.essay_offline_submissions(owner_id); + +alter table public.essay_batch_assignments enable row level security; +alter table public.essay_offline_submissions enable row level security; + +drop policy if exists "essay_batch_assignments_own" on public.essay_batch_assignments; +create policy "essay_batch_assignments_own" + on public.essay_batch_assignments for all + using ((select auth.uid()) = owner_id) + with check ((select auth.uid()) = owner_id); + +drop policy if exists "essay_offline_submissions_own" on public.essay_offline_submissions; +create policy "essay_offline_submissions_own" + on public.essay_offline_submissions for all + using ((select auth.uid()) = owner_id) + with check ((select auth.uid()) = owner_id); diff --git a/supabase/migrations/046_user_templates_sync.sql b/supabase/migrations/046_user_templates_sync.sql new file mode 100644 index 00000000..597e66e7 --- /dev/null +++ b/supabase/migrations/046_user_templates_sync.sql @@ -0,0 +1,22 @@ +-- Saved rubric templates ("save as template" on the Rubric Builder), previously +-- localStorage-only and lost on device change. Same jsonb-document pattern as +-- essay_templates (036_essay_templates.sql). +-- +-- The index below is plain (not CONCURRENTLY) intentionally: Supabase migrations run +-- inside a transaction, where CONCURRENTLY is not allowed. The table is created +-- immediately above in this same migration, so there's no existing data to lock. + +create table if not exists public.user_templates ( + id text primary key, + owner_id uuid not null references public.profiles(id) on delete cascade, + data jsonb not null +); +create index if not exists user_templates_owner_id_idx on public.user_templates(owner_id); + +alter table public.user_templates enable row level security; + +drop policy if exists "user_templates_own" on public.user_templates; +create policy "user_templates_own" + on public.user_templates for all + using ((select auth.uid()) = owner_id) + with check ((select auth.uid()) = owner_id); diff --git a/supabase/migrations/047_enable_realtime.sql b/supabase/migrations/047_enable_realtime.sql new file mode 100644 index 00000000..36c67e6e --- /dev/null +++ b/supabase/migrations/047_enable_realtime.sql @@ -0,0 +1,30 @@ +-- Adds every user-data table to the supabase_realtime publication so connected +-- clients get notified of changes made on other devices/sessions, instead of only +-- picking them up on next login or network reconnect (see StorageSync.startRealtimeSync). +-- RLS still applies to postgres_changes payloads, so this does not widen access — +-- a client only receives change events for rows it could already SELECT. +-- +-- Unlike CREATE TABLE/INDEX, `ALTER PUBLICATION ... ADD TABLE` has no IF NOT EXISTS +-- form and errors (not a no-op) if a table is already a member — so a retry or a +-- table added manually beforehand would abort this migration. Guard each one. + +DO $$ +DECLARE + tbl text; +BEGIN + FOREACH tbl IN ARRAY ARRAY[ + 'rubrics', 'classes', 'students', 'student_rubrics', 'attachments', + 'grade_scales', 'comment_snippets', 'comment_bank', 'export_templates', + 'favorite_standards', 'self_assessments', 'speaking_sessions', 'analysis_results', + 'tests', 'student_tests', 'essay_templates', 'grading_tasks', + 'essay_batch_assignments', 'essay_offline_submissions', 'user_templates', 'user_settings' + ] + LOOP + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = tbl + ) THEN + EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', tbl); + END IF; + END LOOP; +END $$; diff --git a/supabase/migrations/048_nightly_backup.sql b/supabase/migrations/048_nightly_backup.sql new file mode 100644 index 00000000..17f11a69 --- /dev/null +++ b/supabase/migrations/048_nightly_backup.sql @@ -0,0 +1,102 @@ +-- Automated backup for deployments with no server-side pg_dump access: Supabase Cloud, +-- and any Supabase instance self-hosted separately from this repo's own docker-compose.yml +-- (scripts/backup.sh only works against that bundled stack — it calls `docker-compose +-- exec db pg_dump` and archives a hardcoded volume name specific to it). +-- +-- export_owner_backup() dumps every row a teacher/admin owns as raw table rows (not the +-- app's camelCase StoreData shape) — a disaster-recovery snapshot restorable by a DBA +-- re-inserting rows, not a JSON file meant to round-trip through the app's own +-- importFullBackup(). Called once per teacher/admin profile by the nightly-backup edge +-- function (supabase/functions/nightly-backup), same auth pattern as +-- delete-old-attachments: service-role-only, scheduled via Supabase Dashboard Cron Jobs +-- (Cloud) or pg_cron/an external cron hitting the function URL (self-hosted). +-- +-- Metadata only: rows like essay_submissions/speaking_sessions store Storage-bucket +-- paths (essays/recordings buckets), not file contents — this function does not copy +-- the referenced objects. A restored row's storage_path will be broken if the bucket +-- itself isn't backed up separately (e.g. Supabase's own project-level backups, or a +-- storage sync job). Out of scope here to keep this a lightweight per-user DB snapshot. + +CREATE OR REPLACE FUNCTION public.export_owner_backup(target_owner uuid) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + result jsonb := '{}'::jsonb; +BEGIN + result := result || jsonb_build_object('rubrics', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.rubrics t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('classes', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.classes t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('students', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.students t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('student_rubrics', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.student_rubrics t WHERE t.grader_id = target_owner AND t.is_peer_review = false)); + result := result || jsonb_build_object('peer_reviews', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.student_rubrics t WHERE t.grader_id = target_owner AND t.is_peer_review = true)); + result := result || jsonb_build_object('attachments', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.attachments t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('grade_scales', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.grade_scales t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('comment_snippets', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.comment_snippets t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('comment_bank', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.comment_bank t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('export_templates', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.export_templates t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('favorite_standards', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.favorite_standards t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('self_assessments', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.self_assessments t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('speaking_sessions', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.speaking_sessions t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('analysis_results', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.analysis_results t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('tests', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.tests t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('student_tests', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.student_tests t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('essay_templates', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.essay_templates t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('grading_tasks', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.grading_tasks t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('essay_batch_assignments', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.essay_batch_assignments t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('essay_offline_submissions', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.essay_offline_submissions t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('user_templates', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.user_templates t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('user_settings', + (SELECT to_jsonb(t) FROM public.user_settings t WHERE t.user_id = target_owner)); + -- essay_assignments/essay_submissions have no localStorage mirror at all (unlike the + -- tables above, which are also cached client-side) — this is their only backup copy. + result := result || jsonb_build_object('essay_assignments', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.essay_assignments t WHERE t.owner_id = target_owner)); + result := result || jsonb_build_object('essay_submissions', + (SELECT COALESCE(jsonb_agg(to_jsonb(t)), '[]'::jsonb) FROM public.essay_submissions t + WHERE t.assignment_id IN (SELECT id FROM public.essay_assignments WHERE owner_id = target_owner))); + + RETURN result; +END; +$$; + +REVOKE ALL ON FUNCTION public.export_owner_backup(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.export_owner_backup(uuid) TO service_role; + +-- ── Storage bucket for the resulting JSON snapshots ──────────────────────────── + +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ('backups', 'backups', false, 104857600, ARRAY['application/json']) +ON CONFLICT (id) DO NOTHING; + +-- Owner can read/list their own backups ({userId}/{timestamp}.json); writes and +-- deletes are service-role only (the edge function uses the service key, which +-- bypasses RLS, so no write/delete policy is granted to authenticated users here). +CREATE POLICY "backups_storage_owner_read" + ON storage.objects FOR SELECT + USING ( + bucket_id = 'backups' + AND (storage.foldername(name))[1] = auth.uid()::text + );