You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

367 lines
10 KiB

/**
* 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<string, ScopedPendingField>;
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<number, string | number>;
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<string, ScopedPendingField>,
): 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<number, string | number>,
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 {}
}