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
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 {}
|
|
}
|
|
|