189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
|
|
import { UserDTO } from '../types/dto';
|
||
|
|
import { authService } from '../services/authService';
|
||
|
|
import {
|
||
|
|
getCurrentUserCache,
|
||
|
|
getUserCache,
|
||
|
|
saveCurrentUserCache,
|
||
|
|
saveUserCache,
|
||
|
|
} from '../services/database';
|
||
|
|
import { CacheBus, CacheEvent } from './cacheBus';
|
||
|
|
|
||
|
|
interface UserManagerSnapshot {
|
||
|
|
hasCurrentUser: boolean;
|
||
|
|
cachedUserIds: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
type UserManagerEvent =
|
||
|
|
| CacheEvent<{ user: UserDTO | null }>
|
||
|
|
| CacheEvent<{ userId: string; user: UserDTO | null }>
|
||
|
|
| CacheEvent<UserManagerSnapshot>
|
||
|
|
| CacheEvent<{ error: unknown; context: string }>;
|
||
|
|
|
||
|
|
const USER_TTL = 5 * 60 * 1000;
|
||
|
|
|
||
|
|
interface CacheEntry<T> {
|
||
|
|
data: T;
|
||
|
|
timestamp: number;
|
||
|
|
ttl: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
class UserManager extends CacheBus<UserManagerSnapshot, UserManagerEvent> {
|
||
|
|
private currentUserCache: CacheEntry<UserDTO | null> | null = null;
|
||
|
|
private userCache = new Map<string, CacheEntry<UserDTO | null>>();
|
||
|
|
private pendingRequests = new Map<string, Promise<any>>();
|
||
|
|
|
||
|
|
protected getSnapshot(): UserManagerSnapshot {
|
||
|
|
return {
|
||
|
|
hasCurrentUser: !!this.currentUserCache?.data,
|
||
|
|
cachedUserIds: [...this.userCache.keys()],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private isExpired(entry: CacheEntry<any>): boolean {
|
||
|
|
return Date.now() - entry.timestamp > entry.ttl;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||
|
|
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
||
|
|
if (pending) return pending;
|
||
|
|
const request = fetcher().finally(() => {
|
||
|
|
this.pendingRequests.delete(key);
|
||
|
|
});
|
||
|
|
this.pendingRequests.set(key, request);
|
||
|
|
return request;
|
||
|
|
}
|
||
|
|
|
||
|
|
async getCurrentUser(forceRefresh = false): Promise<UserDTO | null> {
|
||
|
|
if (!forceRefresh && this.currentUserCache && !this.isExpired(this.currentUserCache)) {
|
||
|
|
return this.currentUserCache.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!forceRefresh && this.currentUserCache && this.isExpired(this.currentUserCache)) {
|
||
|
|
this.refreshCurrentUserInBackground();
|
||
|
|
return this.currentUserCache.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!forceRefresh) {
|
||
|
|
const cachedFromDb = await getCurrentUserCache();
|
||
|
|
if (cachedFromDb) {
|
||
|
|
this.currentUserCache = { data: cachedFromDb, timestamp: Date.now(), ttl: USER_TTL };
|
||
|
|
this.notify({
|
||
|
|
type: 'detail_updated',
|
||
|
|
payload: { user: cachedFromDb },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
this.refreshCurrentUserInBackground();
|
||
|
|
return cachedFromDb;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.dedupe('users:current', async () => {
|
||
|
|
const user = await authService.fetchCurrentUserFromAPI();
|
||
|
|
this.currentUserCache = { data: user, timestamp: Date.now(), ttl: USER_TTL };
|
||
|
|
if (user) {
|
||
|
|
saveCurrentUserCache(user).catch(() => {});
|
||
|
|
saveUserCache(user).catch(() => {});
|
||
|
|
}
|
||
|
|
this.notify({
|
||
|
|
type: 'detail_updated',
|
||
|
|
payload: { user },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
return user;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private refreshCurrentUserInBackground(): void {
|
||
|
|
this.dedupe('users:current:bg', async () => {
|
||
|
|
const user = await authService.fetchCurrentUserFromAPI();
|
||
|
|
this.currentUserCache = { data: user, timestamp: Date.now(), ttl: USER_TTL };
|
||
|
|
if (user) {
|
||
|
|
saveCurrentUserCache(user).catch(() => {});
|
||
|
|
saveUserCache(user).catch(() => {});
|
||
|
|
}
|
||
|
|
this.notify({
|
||
|
|
type: 'detail_updated',
|
||
|
|
payload: { user },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
return user;
|
||
|
|
}).catch((error) => {
|
||
|
|
this.notify({
|
||
|
|
type: 'error',
|
||
|
|
payload: { error, context: 'refreshCurrentUserInBackground' },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async getUserById(userId: string, forceRefresh = false): Promise<UserDTO | null> {
|
||
|
|
const cached = this.userCache.get(userId);
|
||
|
|
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
||
|
|
return cached.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!forceRefresh && cached && this.isExpired(cached)) {
|
||
|
|
this.refreshUserByIdInBackground(userId);
|
||
|
|
return cached.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!forceRefresh) {
|
||
|
|
const cachedFromDb = await getUserCache(userId);
|
||
|
|
if (cachedFromDb) {
|
||
|
|
this.userCache.set(userId, { data: cachedFromDb, timestamp: Date.now(), ttl: USER_TTL });
|
||
|
|
this.notify({
|
||
|
|
type: 'detail_updated',
|
||
|
|
payload: { userId, user: cachedFromDb },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
this.refreshUserByIdInBackground(userId);
|
||
|
|
return cachedFromDb;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.dedupe(`users:detail:${userId}`, async () => {
|
||
|
|
const user = await authService.fetchUserByIdFromAPI(userId);
|
||
|
|
this.userCache.set(userId, { data: user, timestamp: Date.now(), ttl: USER_TTL });
|
||
|
|
if (user) {
|
||
|
|
saveUserCache(user).catch(() => {});
|
||
|
|
}
|
||
|
|
this.notify({
|
||
|
|
type: 'detail_updated',
|
||
|
|
payload: { userId, user },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
return user;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private refreshUserByIdInBackground(userId: string): void {
|
||
|
|
this.dedupe(`users:detail:bg:${userId}`, async () => {
|
||
|
|
const user = await authService.fetchUserByIdFromAPI(userId);
|
||
|
|
this.userCache.set(userId, { data: user, timestamp: Date.now(), ttl: USER_TTL });
|
||
|
|
if (user) {
|
||
|
|
saveUserCache(user).catch(() => {});
|
||
|
|
}
|
||
|
|
this.notify({
|
||
|
|
type: 'detail_updated',
|
||
|
|
payload: { userId, user },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
return user;
|
||
|
|
}).catch((error) => {
|
||
|
|
this.notify({
|
||
|
|
type: 'error',
|
||
|
|
payload: { error, context: 'refreshUserByIdInBackground' },
|
||
|
|
timestamp: Date.now(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
invalidateUsers(): void {
|
||
|
|
this.currentUserCache = null;
|
||
|
|
this.userCache.clear();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const userManager = new UserManager();
|
||
|
|
|