/** * Tracks the entities each user has recently opened so the global search * dropdown can surface "Recently viewed" suggestions before the user types. * * Storage: Redis sorted set per (user, port). Key is * `recent-views::`, score is the unix epoch of the view, * member is `:`. We trim the set to RECENT_VIEW_MAX * after every write so stale ids age out. * * The companion API route hydrates the typed/labelled rows on read by * looking up the underlying tables; this service only deals in (type, id) * pairs to stay schema-free and cheap. */ import { redis } from '@/lib/redis'; export type RecentlyViewedType = | 'client' | 'residential-client' | 'yacht' | 'company' | 'interest' | 'residential-interest' | 'berth' | 'invoice' | 'expense' | 'document'; export interface RecentlyViewedEntry { type: RecentlyViewedType; id: string; /** Unix milliseconds of the most recent view. */ viewedAt: number; } const RECENT_VIEW_TTL = 60 * 60 * 24 * 30; // 30 days const RECENT_VIEW_MAX = 20; function key(userId: string, portId: string): string { return `recent-views:${userId}:${portId}`; } function encode(type: RecentlyViewedType, id: string): string { return `${type}:${id}`; } function decode(member: string): { type: RecentlyViewedType; id: string } | null { const colon = member.indexOf(':'); if (colon < 0) return null; return { type: member.slice(0, colon) as RecentlyViewedType, id: member.slice(colon + 1), }; } /** * Records an entity view. Fire-and-forget - caller should NOT await this * in the hot path; the redis op is logged-and-swallowed on failure since * a missed view never breaks the user experience. */ export function trackView( userId: string, portId: string, type: RecentlyViewedType, id: string, ): void { if (!userId || !portId || !id) return; const k = key(userId, portId); const member = encode(type, id); const now = Date.now(); redis .zadd(k, now, member) .then(() => redis.zremrangebyrank(k, 0, -(RECENT_VIEW_MAX + 1))) .then(() => redis.expire(k, RECENT_VIEW_TTL)) .catch(() => { // intentionally swallowed }); } /** * Returns the user's recently-viewed entities, newest first. Limit is * defensively capped so a misbehaving caller can't pull thousands of rows. */ export async function getRecentlyViewed( userId: string, portId: string, limit = 10, ): Promise { const k = key(userId, portId); const cap = Math.min(Math.max(limit, 1), RECENT_VIEW_MAX); // ZREVRANGE WITHSCORES → flat array [member, score, member, score, …] const raw = await redis.zrevrange(k, 0, cap - 1, 'WITHSCORES'); const out: RecentlyViewedEntry[] = []; for (let i = 0; i < raw.length; i += 2) { const member = raw[i]; const score = raw[i + 1]; if (!member || !score) continue; const decoded = decode(member); if (!decoded) continue; out.push({ ...decoded, viewedAt: Number(score) }); } return out; } /** * Removes a single entity from a user's recent-views (e.g. after the * entity is hard-deleted). Best-effort. */ export function forgetView( userId: string, portId: string, type: RecentlyViewedType, id: string, ): void { redis.zrem(key(userId, portId), encode(type, id)).catch(() => {}); }