/** * User-scoped localStorage utilities. * * All draft/answer data is keyed by the authenticated user's profile ID * so that Account A's unsynced edits can never be displayed or synced * for Account B. * * The raw auth token is NEVER used as a key component. */ import type { MarriageProfileResponse } from "@/hooks/marriage/types"; export const SCOPED_STORAGE_VERSION = 3; // ─── Types ─────────────────────────────────────────────────────────── export interface ScopedSectionDraft { version: typeof SCOPED_STORAGE_VERSION; ownerProfileId: number; slug: string; pending: Record; updatedAt: string; } export interface ScopedPendingField { key: string; label: string; type: string; value: unknown; option_id?: string | string[] | null; private?: boolean; } export interface ScopedAssessmentDraft { version: typeof SCOPED_STORAGE_VERSION; ownerProfileId: number; slug: string; answers: Record; currentIndex: number; totalQuestions: number; } // ─── Key Generators ────────────────────────────────────────────────── export function getScopedSectionDraftKey( profileId: number, slug: string, ): string { return `marriage:user:${profileId}:sections:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; } export function getScopedAssessmentDraftKey( profileId: number, slug: string, ): string { return `marriage:user:${profileId}:tests:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; } // ─── Ownership Verification ────────────────────────────────────────── export function isDraftOwnedBy( draft: { ownerProfileId?: number } | null | undefined, profileId: number, ): boolean { if (!draft || typeof draft !== "object") return false; return draft.ownerProfileId === profileId; } // ─── Reading ───────────────────────────────────────────────────────── export function readScopedSectionDraft( profileId: number, slug: string, ): ScopedSectionDraft | null { try { const key = getScopedSectionDraftKey(profileId, slug); const raw = window.localStorage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw); if ( !parsed || parsed.version !== SCOPED_STORAGE_VERSION || parsed.ownerProfileId !== profileId || parsed.slug !== slug ) { // Corrupted or mismatched — remove it window.localStorage.removeItem(key); return null; } return parsed as ScopedSectionDraft; } catch { return null; } } export function readScopedAssessmentDraft( profileId: number, slug: string, ): ScopedAssessmentDraft | null { try { const key = getScopedAssessmentDraftKey(profileId, slug); const raw = window.localStorage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw); if ( !parsed || parsed.version !== SCOPED_STORAGE_VERSION || parsed.ownerProfileId !== profileId ) { window.localStorage.removeItem(key); return null; } return parsed as ScopedAssessmentDraft; } catch { return null; } } // ─── Writing ───────────────────────────────────────────────────────── export function writeScopedSectionDraft( profileId: number, slug: string, pending: Record, ): void { try { const key = getScopedSectionDraftKey(profileId, slug); if (Object.keys(pending).length === 0) { window.localStorage.removeItem(key); return; } const draft: ScopedSectionDraft = { version: SCOPED_STORAGE_VERSION, ownerProfileId: profileId, slug, pending, updatedAt: new Date().toISOString(), }; window.localStorage.setItem(key, JSON.stringify(draft)); } catch { // localStorage can fail in private mode or when storage quota is exhausted. } } export function writeScopedAssessmentDraft( profileId: number, slug: string, answers: Record, currentIndex: number, totalQuestions: number, ): void { try { const key = getScopedAssessmentDraftKey(profileId, slug); if (Object.keys(answers).length === 0) { window.localStorage.removeItem(key); return; } const draft: ScopedAssessmentDraft = { version: SCOPED_STORAGE_VERSION, ownerProfileId: profileId, slug, answers, currentIndex, totalQuestions, }; window.localStorage.setItem(key, JSON.stringify(draft)); } catch { // localStorage can fail. } } export function removeScopedSectionDraft( profileId: number, slug: string, ): void { try { window.localStorage.removeItem( getScopedSectionDraftKey(profileId, slug), ); } catch {} } export function removeScopedAssessmentDraft( profileId: number, slug: string, ): void { try { window.localStorage.removeItem( getScopedAssessmentDraftKey(profileId, slug), ); } catch {} } // ─── Legacy Migration ──────────────────────────────────────────────── /** * Remove old V2 unscoped answer keys. Since they have no owner metadata, * ownership cannot be proven, so we do NOT migrate them. * We also do NOT call localStorage.clear() because the application stores * unrelated device/UI flags. */ export function migrateLegacyStorageKeys(): void { try { const keysToRemove: string[] = []; for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i); if (!key) continue; // Old V2 normal section keys: marriage:sections:{slug}:answers:v2 if (key.match(/^marriage:sections:[^:]+:answers:v2$/)) { keysToRemove.push(key); } // Old unscoped assessment draft keys: marriage:tests:{slug}:draft if (key.match(/^marriage:tests:[^:]+:draft$/) && !key.includes(":user:")) { keysToRemove.push(key); } // Old unscoped completion markers: marriage:sections:{slug}:answers // (used by assessments to store {completed: true}) if (key.match(/^marriage:sections:[^:]+:answers$/) && !key.includes(":user:")) { keysToRemove.push(key); } } for (const key of keysToRemove) { window.localStorage.removeItem(key); } // Mark migration as done so we don't re-scan every page load window.localStorage.setItem("marriage:storage-migration:v3", "done"); } catch { // Non-critical; will retry next page load. } } /** * Check if the V3 migration has already been performed. */ export function isLegacyMigrationDone(): boolean { try { return window.localStorage.getItem("marriage:storage-migration:v3") === "done"; } catch { return false; } } /** * Enumerate all scoped section draft keys for a given profile. */ export function getAllScopedSectionDraftKeys( profileId: number, ): string[] { const prefix = `marriage:user:${profileId}:sections:`; const suffix = `:draft:v${SCOPED_STORAGE_VERSION}`; const keys: string[] = []; try { for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i); if (key && key.startsWith(prefix) && key.endsWith(suffix)) { keys.push(key); } } } catch {} return keys; } /** * Extract the slug from a scoped section draft key. */ export function extractSlugFromScopedKey(key: string): string | null { const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/); return match ? match[1] : null; } // ─── Scoped Marriage Profile Cache ───────────────────────────────────── export interface ScopedMarriageProfileData { version: typeof SCOPED_STORAGE_VERSION; ownerProfileId: number; profile: MarriageProfileResponse; savedAt: number; } export function getScopedMarriageProfileKey(profileId: number): string { return `marriage:user:${profileId}:profile:v${SCOPED_STORAGE_VERSION}`; } export const LATEST_PROFILE_STORAGE_KEY = `marriage:profile:latest:v${SCOPED_STORAGE_VERSION}`; /** * Persist the latest full MarriageProfileResponse from the server. */ export function saveScopedMarriageProfile(profile: MarriageProfileResponse): void { if (typeof window === "undefined" || !profile?.id) return; try { const payload: ScopedMarriageProfileData = { version: SCOPED_STORAGE_VERSION, ownerProfileId: profile.id, profile, savedAt: Date.now(), }; const serialized = JSON.stringify(payload); window.localStorage.setItem(getScopedMarriageProfileKey(profile.id), serialized); window.localStorage.setItem(LATEST_PROFILE_STORAGE_KEY, serialized); } catch {} } /** * Retrieve the persisted MarriageProfileResponse if valid. */ export function getScopedMarriageProfile( expectedProfileId?: number, ): MarriageProfileResponse | null { if (typeof window === "undefined") return null; try { const key = expectedProfileId ? getScopedMarriageProfileKey(expectedProfileId) : LATEST_PROFILE_STORAGE_KEY; const raw = window.localStorage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw) as ScopedMarriageProfileData; if (!parsed || parsed.version !== SCOPED_STORAGE_VERSION || !parsed.profile) { return null; } if (expectedProfileId && parsed.ownerProfileId !== expectedProfileId) { return null; } return parsed.profile; } catch { return null; } } /** * Clear all persisted marriage profile caches (used on auth token/account change). */ export function clearScopedMarriageProfiles(): void { if (typeof window === "undefined") return; try { window.localStorage.removeItem(LATEST_PROFILE_STORAGE_KEY); const keysToRemove: string[] = []; for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i); if (key && key.startsWith("marriage:user:") && key.includes(":profile:")) { keysToRemove.push(key); } } for (const key of keysToRemove) { window.localStorage.removeItem(key); } } catch {} }