Source

utils/user-profile.utils.js

/**
 * 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;
}