/**
* Utilities for fetching and caching user profile information
*/
import { getLogger } from "@utils/asyncLocalStorage";
import { redisUtils } from "../database/redis.client";
/**
* Enriches items that have an `id` field with user profile information (name, email, avatar).
* Commonly used for enriching authors/editors lists in filter metadata.
* @category Utils
* @template T
* @param {Array} items - Array of items with `id`, `name`, `email`, optional `avatar` fields
* @param {AuthGrpcService | undefined} grpcClient - gRPC client for fetching profiles (if undefined, returns items unchanged)
* @returns {Promise<IUserProfile>} Promise resolving to items with populated name, email, and avatar fields
*/
export async function enrichUserItems(items, grpcClient) {
const logger = getLogger();
if (!items.length || !grpcClient) {
return items;
}
try {
const profileCache = new Map();
return await Promise.all(items.map(async (item) => {
if (!item.id) {
return item;
}
try {
const profile = await fetchUserProfile(item.id, profileCache, grpcClient);
return {
...item,
name: profile.name,
email: profile.email,
avatar: profile.avatar,
};
}
catch (profileError) {
logger.warn({ profileError, userId: item.id }, "Failed to fetch user profile for item");
return item;
}
}));
}
catch (error) {
logger.warn({ error }, "Failed to enrich items with user profiles");
return items;
}
}
/**
* Fetches a user profile using a multi-layered caching strategy.
* @category Utils
* @param {string} userId - The ID of the user whose profile is being fetched.
* @param {Map<string, IUserProfile | Promise<IUserProfile>> | undefined} profileCache - Optional memoization cache to prevent duplicate fetches.
* @param {AuthGrpcService} grpcClient - The gRPC client used to fetch the user if cache misses.
* @returns {Promise<IUserProfile>} The resolved user profile with `name`, `avatar`, and `email` fields (nullable if unavailable).
*/
export async function fetchUserProfile(userId, profileCache, grpcClient) {
const logger = getLogger();
// Step 1: Check memoization cache if provided
if (profileCache?.has(userId)) {
const cached = profileCache.get(userId);
// If it's a Promise (race condition handling), await it
if (cached instanceof Promise) {
return await cached;
}
// Otherwise, it's the actual profile data
return cached;
}
// Create a promise for this fetch operation to handle race conditions
const fetchPromise = (async () => {
// Step 2: Check Redis cache
const redisKey = `user:${userId}`;
try {
const cached = await redisUtils.get(redisKey);
if (cached) {
const userData = JSON.parse(cached);
const profile = {
id: userData.id,
name: userData.name ?? null,
avatar: userData.avatar ?? null,
email: userData.email ?? null,
};
logger.debug({ userId }, "User profile fetched from Redis cache");
// Store actual value in memoization cache if provided
if (profileCache) {
profileCache.set(userId, profile);
}
return profile;
}
}
catch (error) {
logger.warn({ error, userId }, "Failed to read user from Redis, falling back to gRPC");
}
// Step 3: If Redis miss, try gRPC
try {
const user = (await grpcClient.getUserById(userId));
const profile = {
id: userId,
name: user?.name ?? null,
avatar: user?.avatar ?? null,
email: user?.email ?? null,
};
logger.debug({ userId }, "User profile fetched from gRPC service");
// Store actual value in memoization cache if provided
if (profileCache) {
profileCache.set(userId, profile);
}
return profile;
}
catch (error) {
logger.warn({ error, userId }, "Failed to fetch user profile from gRPC, using fallback");
// If gRPC fails, fallback to null values (user profile enrichment is best-effort)
const profile = { id: userId, name: null, avatar: null, email: null };
if (profileCache) {
profileCache.set(userId, profile);
}
return profile;
}
})();
// Store the promise in cache immediately to handle parallel requests
if (profileCache) {
profileCache.set(userId, fetchPromise);
}
return await fetchPromise;
}
/**
* Batch fetch multiple user profiles efficiently using a single gRPC call.
* Uses Redis mget for batch cache lookup, then fetches missing profiles via batch gRPC.
* @category Utils
* @param {string[]} userIds - Array of user IDs to fetch.
* @param {AuthGrpcService} grpcClient - The gRPC client used to fetch users.
* @returns {Promise<Map<string, IUserProfile>>} Map of userId to profile.
*/
export async function batchFetchUserProfiles(userIds, grpcClient) {
const logger = getLogger();
const result = new Map();
// Deduplicate and filter out empty IDs
const uniqueIds = [...new Set(userIds.filter((id) => Boolean(id)))];
if (uniqueIds.length === 0) {
return result;
}
// Step 1: Check Redis cache for all IDs using mget
const redisKeys = uniqueIds.map((id) => `user:${id}`);
let cachedValues = [];
try {
cachedValues = await redisUtils.mget(redisKeys);
}
catch (error) {
logger.warn({ error }, "Failed to batch read users from Redis, falling back to gRPC");
}
const missingIds = [];
// Process cached results
uniqueIds.forEach((id, index) => {
const cached = cachedValues[index];
if (cached) {
try {
const userData = JSON.parse(cached);
result.set(id, {
id: userData.id,
name: userData.name ?? null,
email: userData.email ?? null,
avatar: userData.avatar ?? null,
});
}
catch {
// JSON parse failed, add to missing
missingIds.push(id);
}
}
else {
missingIds.push(id);
}
});
// Step 2: Fetch missing IDs via batch gRPC call
if (missingIds.length > 0) {
try {
const users = await grpcClient.getUsersByIds(missingIds);
for (const user of users) {
const profile = {
id: user.id ?? null,
name: user.name ?? null,
email: user.email ?? null,
avatar: user.avatar ?? null,
};
if (user.id) {
result.set(user.id, profile);
}
}
// Add fallback profiles for IDs not returned (user not found)
for (const id of missingIds) {
if (!result.has(id)) {
result.set(id, { id, name: null, email: null, avatar: null });
}
}
}
catch (error) {
logger.warn({ error, missingIds }, "Failed to batch fetch user profiles from gRPC");
// Add fallback profiles for all missing IDs
for (const id of missingIds) {
if (!result.has(id)) {
result.set(id, { id, name: null, email: null, avatar: null });
}
}
}
}
return result;
}
Source