refactor: simplify form draft key generation

This commit is contained in:
Firman Ramdhani
2026-07-27 21:58:18 +07:00
parent a01b0c1339
commit b55606817c
4 changed files with 139 additions and 53 deletions
+36 -6
View File
@@ -5,7 +5,7 @@ export const AppStorageKey = {
THEME: 'app_theme', THEME: 'app_theme',
ACCESS_TOKEN: 'access_token', ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token', REFRESH_TOKEN: 'refresh_token',
USER_ID: 'u_id', USER_ID: 'uid',
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus', SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
} as const; } as const;
@@ -21,18 +21,43 @@ export const AppDatabaseKey = {
export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey]; export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey];
function getUserId(userIdKey: string) {
const rawId = localStorage.getItem(userIdKey);
if (!rawId) return null;
try {
return JSON.parse(rawId);
} catch (error) {
console.error('Failed to parse User ID:', error);
return null;
}
}
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([ export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_ID,
AppStorageKey.ACCESS_TOKEN, AppStorageKey.ACCESS_TOKEN,
AppStorageKey.REFRESH_TOKEN, AppStorageKey.REFRESH_TOKEN,
]); ]);
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([ export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_ID,
AppStorageKey.LANGUAGE, AppStorageKey.LANGUAGE,
AppStorageKey.THEME, AppStorageKey.THEME,
AppStorageKey.SIDEBAR_OPEN_MENUS, AppStorageKey.SIDEBAR_OPEN_MENUS,
]); ]);
export const APP_STORAGE_PERSONALIZED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LANGUAGE,
AppStorageKey.THEME,
AppStorageKey.SIDEBAR_OPEN_MENUS,
]);
export const appStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS,
plainTextKeys: APP_STORAGE_PLAIN_KEYS,
personalizedKeys: APP_STORAGE_PERSONALIZED_KEYS,
getUserId: () => getUserId(AppStorageKey.USER_ID) || null,
});
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]); export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([ export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
@@ -43,14 +68,19 @@ export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.BOOKMARK_PAGE, AppDatabaseKey.BOOKMARK_PAGE,
]); ]);
export const appStorage = createLocalStorage<AppStorageKeyValue>({ export const APP_DATABASE_PERSONALIZED_KEYS = new Set<AppDatabaseKeyValue>([
encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS, AppDatabaseKey.USER_PROFILE,
plainTextKeys: APP_STORAGE_PLAIN_KEYS, AppDatabaseKey.OFFLINE_DRAFT,
}); AppDatabaseKey.SYSTEM_SETTINGS,
AppDatabaseKey.HISTORY_PAGE,
AppDatabaseKey.BOOKMARK_PAGE,
]);
export const appDatabase = createIndexedDB<AppDatabaseKeyValue>({ export const appDatabase = createIndexedDB<AppDatabaseKeyValue>({
dbName: 'e_apps_db', dbName: 'e_apps_db',
storeName: 'web_store', storeName: 'web_store',
encryptedKeys: APP_DATABASE_ENCRYPTED_KEYS, encryptedKeys: APP_DATABASE_ENCRYPTED_KEYS,
plainTextKeys: APP_DATABASE_PLAIN_KEYS, plainTextKeys: APP_DATABASE_PLAIN_KEYS,
personalizedKeys: APP_DATABASE_PERSONALIZED_KEYS,
getUserId: () => getUserId(AppStorageKey.USER_ID) || null,
}); });
@@ -37,7 +37,7 @@ function openDatabase(dbName: string, storeName: string, version: number): Promi
/** /**
* Execute a single IndexedDB transaction and return the result. * Execute a single IndexedDB transaction and return the result.
* Handles open → transaction → request → close lifecycle cleanly. * Resolves only when the entire transaction is completed to prevent silent failures.
*/ */
function withTransaction<R>( function withTransaction<R>(
db: IDBDatabase, db: IDBDatabase,
@@ -48,10 +48,20 @@ function withTransaction<R>(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode); const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName); const store = tx.objectStore(storeName);
let requestResult: R;
const request = operation(store); const request = operation(store);
request.onsuccess = () => resolve(request.result); request.onsuccess = () => {
requestResult = request.result;
};
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
// Resolve promise ONLY after transaction is successfully committed
tx.oncomplete = () => resolve(requestResult);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
}); });
} }
@@ -67,6 +77,8 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
private readonly version: number; private readonly version: number;
private readonly encryptedKeys: Set<TKey>; private readonly encryptedKeys: Set<TKey>;
private readonly plainTextKeys: Set<TKey>; private readonly plainTextKeys: Set<TKey>;
private readonly personalizedKeys?: Set<TKey>;
private readonly getUserId?: () => string | null | undefined;
private dbPromise: Promise<IDBDatabase> | null = null; private dbPromise: Promise<IDBDatabase> | null = null;
constructor(config?: IndexedDBConfig<TKey>, encryptionUtils?: EncryptionUtils) { constructor(config?: IndexedDBConfig<TKey>, encryptionUtils?: EncryptionUtils) {
@@ -76,6 +88,8 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
this.version = config?.version ?? 1; this.version = config?.version ?? 1;
this.encryptedKeys = config?.encryptedKeys ?? new Set(); this.encryptedKeys = config?.encryptedKeys ?? new Set();
this.plainTextKeys = config?.plainTextKeys ?? new Set(); this.plainTextKeys = config?.plainTextKeys ?? new Set();
this.personalizedKeys = config?.personalizedKeys;
this.getUserId = config?.getUserId;
} }
/** Lazy-open the database connection (cached). */ /** Lazy-open the database connection (cached). */
@@ -96,37 +110,46 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
return this.encryptedKeys.has(key); return this.encryptedKeys.has(key);
} }
private resolveKey(key: TKey): string {
if (this.personalizedKeys?.has(key)) {
const userId = this.getUserId?.() || 'guest';
return `${String(key)}_${userId}`;
}
return String(key);
}
async setItem<T>(key: TKey, value: T): Promise<void> { async setItem<T>(key: TKey, value: T): Promise<void> {
this.validateKey(key); this.validateKey(key);
const db = await this.getDB(); const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
const serialized = JSON.stringify(value); const serialized = JSON.stringify(value);
const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized; const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized;
await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key as string)); await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, resolvedKey));
} }
async getItem<T>(key: TKey): Promise<T | null> { async getItem<T>(key: TKey): Promise<T | null> {
this.validateKey(key); this.validateKey(key);
const db = await this.getDB(); const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
const raw = await withTransaction<string | undefined>( const raw = await withTransaction<string | undefined>(
db, db,
this.storeName, this.storeName,
'readonly', 'readonly',
(store) => store.get(key as string) as IDBRequest<string | undefined>, (store) => store.get(resolvedKey) as IDBRequest<string | undefined>,
); );
if (raw === undefined || raw === null) return null; if (raw === undefined || raw === null) return null;
try { try {
if (this.shouldEncrypt(key)) { const decrypted = this.shouldEncrypt(key) ? this.encryption.decrypt(raw) : raw;
const decrypted = this.encryption.decrypt(raw);
if (!decrypted) return null; if (!decrypted) return null;
return JSON.parse(decrypted) as T; return JSON.parse(decrypted) as T;
}
return JSON.parse(raw) as T;
} catch { } catch {
console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`); console.warn(`[core-storage] Failed to parse IndexedDB key "${String(key)}". Removing corrupt entry.`);
await this.removeItem(key); await this.removeItem(key);
return null; return null;
} }
@@ -135,12 +158,16 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
async removeItem(key: TKey): Promise<void> { async removeItem(key: TKey): Promise<void> {
this.validateKey(key); this.validateKey(key);
const db = await this.getDB(); const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key as string)); const resolvedKey = this.resolveKey(key);
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(resolvedKey));
} }
async clear(): Promise<void> { async clear(): Promise<void> {
const db = await this.getDB(); const allValidKeys = await this.keys();
await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear()); for (const key of allValidKeys) {
await this.removeItem(key);
}
} }
async hasItem(key: TKey): Promise<boolean> { async hasItem(key: TKey): Promise<boolean> {
@@ -150,13 +177,34 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
async keys(): Promise<TKey[]> { async keys(): Promise<TKey[]> {
const db = await this.getDB(); const db = await this.getDB();
const allKeys = await withTransaction<string[]>( const rawKeys = await withTransaction<string[]>(
db, db,
this.storeName, this.storeName,
'readonly', 'readonly',
(store) => store.getAllKeys() as IDBRequest<string[]>, (store) => store.getAllKeys() as IDBRequest<string[]>,
); );
return allKeys as TKey[];
const resultSet = new Set<TKey>();
const currentUserId = this.getUserId?.() || 'guest';
for (const rawKey of rawKeys) {
const isGlobalKey = this.encryptedKeys.has(rawKey as TKey) || this.plainTextKeys.has(rawKey as TKey);
if (isGlobalKey && !this.personalizedKeys?.has(rawKey as TKey)) {
resultSet.add(rawKey as TKey);
}
if (this.personalizedKeys) {
for (const pKey of this.personalizedKeys) {
if (rawKey === `${String(pKey)}_${currentUserId}`) {
resultSet.add(pKey);
break;
}
}
}
}
return Array.from(resultSet);
} }
} }
@@ -4,6 +4,8 @@ import type { IStorageService } from '../types/storage.interface';
export interface StorageOptions<TKey extends string> { export interface StorageOptions<TKey extends string> {
encryptedKeys?: Set<TKey>; encryptedKeys?: Set<TKey>;
plainTextKeys?: Set<TKey>; plainTextKeys?: Set<TKey>;
personalizedKeys?: Set<TKey>;
getUserId?: () => string | null | undefined;
} }
/** /**
@@ -13,11 +15,15 @@ export class LocalStorageService<TKey extends string> implements IStorageService
private readonly encryption: EncryptionUtils; private readonly encryption: EncryptionUtils;
private readonly encryptedKeys: Set<TKey>; private readonly encryptedKeys: Set<TKey>;
private readonly plainTextKeys: Set<TKey>; private readonly plainTextKeys: Set<TKey>;
private readonly personalizedKeys?: Set<TKey>;
private readonly getUserId?: () => string | null | undefined;
constructor(options?: StorageOptions<TKey>, encryptionUtils?: EncryptionUtils) { constructor(options?: StorageOptions<TKey>, encryptionUtils?: EncryptionUtils) {
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance(); this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
this.encryptedKeys = options?.encryptedKeys ?? new Set(); this.encryptedKeys = options?.encryptedKeys ?? new Set();
this.plainTextKeys = options?.plainTextKeys ?? new Set(); this.plainTextKeys = options?.plainTextKeys ?? new Set();
this.personalizedKeys = options?.personalizedKeys;
this.getUserId = options?.getUserId;
} }
private validateKey(key: TKey): void { private validateKey(key: TKey): void {
@@ -26,25 +32,35 @@ export class LocalStorageService<TKey extends string> implements IStorageService
} }
} }
private resolveKey(key: TKey): string {
if (this.personalizedKeys?.has(key)) {
const userId = this.getUserId?.() || 'guest';
return `${String(key)}_${userId}`;
}
return String(key);
}
private shouldEncrypt(key: TKey): boolean { private shouldEncrypt(key: TKey): boolean {
return this.encryptedKeys.has(key); return this.encryptedKeys.has(key);
} }
async setItem<T>(key: TKey, value: T): Promise<void> { async setItem<T>(key: TKey, value: T): Promise<void> {
this.validateKey(key); this.validateKey(key);
const resolvedKey = this.resolveKey(key);
const serialized = JSON.stringify(value); const serialized = JSON.stringify(value);
if (this.shouldEncrypt(key)) { if (this.shouldEncrypt(key)) {
const encrypted = this.encryption.encrypt(serialized); const encrypted = this.encryption.encrypt(serialized);
localStorage.setItem(key as string, encrypted); localStorage.setItem(resolvedKey, encrypted);
} else { } else {
localStorage.setItem(key as string, serialized); localStorage.setItem(resolvedKey, serialized);
} }
} }
async getItem<T>(key: TKey): Promise<T | null> { async getItem<T>(key: TKey): Promise<T | null> {
this.validateKey(key); this.validateKey(key);
const raw = localStorage.getItem(key as string); const resolvedKey = this.resolveKey(key);
const raw = localStorage.getItem(resolvedKey);
if (raw === null) return null; if (raw === null) return null;
try { try {
@@ -55,15 +71,16 @@ export class LocalStorageService<TKey extends string> implements IStorageService
} }
return JSON.parse(raw) as T; return JSON.parse(raw) as T;
} catch { } catch {
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`); console.warn(`[core-storage] Failed to parse key "${String(key)}". Removing corrupt entry.`);
localStorage.removeItem(key as string); localStorage.removeItem(resolvedKey);
return null; return null;
} }
} }
async removeItem(key: TKey): Promise<void> { async removeItem(key: TKey): Promise<void> {
this.validateKey(key); this.validateKey(key);
localStorage.removeItem(key as string); const resolvedKey = this.resolveKey(key);
localStorage.removeItem(resolvedKey);
} }
async clear(): Promise<void> { async clear(): Promise<void> {
@@ -71,7 +88,8 @@ export class LocalStorageService<TKey extends string> implements IStorageService
} }
async hasItem(key: TKey): Promise<boolean> { async hasItem(key: TKey): Promise<boolean> {
return localStorage.getItem(key as string) !== null; const resolvedKey = this.resolveKey(key);
return localStorage.getItem(resolvedKey) !== null;
} }
async keys(): Promise<TKey[]> { async keys(): Promise<TKey[]> {
@@ -3,8 +3,6 @@ import { useState, useEffect, useCallback } from 'react';
import { useEnterpriseModuleConfigContext } from './use-module.context'; import { useEnterpriseModuleConfigContext } from './use-module.context';
import { DraftConfig, FormPageType } from '../entities/entity'; import { DraftConfig, FormPageType } from '../entities/entity';
import { import {
appStorage,
AppStorageKey,
appDatabase, appDatabase,
AppDatabaseKey, AppDatabaseKey,
} from '../../../../../../apps/web/src/core/storage/local'; } from '../../../../../../apps/web/src/core/storage/local';
@@ -19,36 +17,28 @@ export function useFormDraftContext({ config }: FormDraftContextProps) {
const { config: moduleConfig } = useEnterpriseModuleConfigContext(); const { config: moduleConfig } = useEnterpriseModuleConfigContext();
const [hasDraft, setHasDraft] = useState(false); const [hasDraft, setHasDraft] = useState(false);
const [draftData, setDraftData] = useState<any>(null); const [draftData, setDraftData] = useState<any>(null);
const [userID, setUserID] = useState<string>('UNRESOLVED_PRINCIPAL');
const isEnabled = config?.enableDraft === true; const isEnabled = config?.enableDraft === true;
// Use a generic 'CREATE' key for both CREATE and DUPLICATE so that drafts saved // Use a generic 'CREATE' key for both CREATE and DUPLICATE so that drafts saved
// during DUPLICATE can be restored when entering a new CREATE form. // during DUPLICATE can be restored when entering a new CREATE form.
const draftKey = `${userID}:draft:${moduleConfig.moduleKey}:CREATE`; const draftKey = `draft:${moduleConfig.moduleKey}:CREATE`;
// Check for existing draft on mount // Check for existing draft on mount
useEffect(() => { useEffect(() => {
if (!isEnabled) return; if (!isEnabled) return;
// Fetch user ID asynchronously using appStorage
appStorage.getItem(AppStorageKey.USER_ID).then((id: any) => {
const resolvedUserId = id || 'UNRESOLVED_PRINCIPAL';
setUserID(resolvedUserId);
const resolvedDraftKey = `${resolvedUserId}:draft:${moduleConfig.moduleKey}:CREATE`;
appDatabase appDatabase
.getItem(AppDatabaseKey.OFFLINE_DRAFT) .getItem(AppDatabaseKey.OFFLINE_DRAFT)
.then((allDrafts: any) => { .then((allDrafts: any) => {
const drafts = allDrafts || {}; const drafts = allDrafts || {};
if (drafts[resolvedDraftKey]) { if (drafts[draftKey]) {
setDraftData(drafts[resolvedDraftKey]); setDraftData(drafts[draftKey]);
setHasDraft(true); setHasDraft(true);
} }
}) })
.catch((err) => { .catch((err) => {
console.error('[Draft Recovery] Failed to read from appDatabase:', err); console.error('[Draft Recovery] Failed to read from appDatabase:', err);
}); });
}); }, [isEnabled, draftKey]);
}, [isEnabled, moduleConfig.moduleKey]);
const saveDraft = useCallback( const saveDraft = useCallback(
(data: any) => { (data: any) => {