Files
pn-new-crm/src/lib/services/recently-viewed.service.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

121 lines
3.3 KiB
TypeScript

/**
* 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:<userId>:<portId>`, score is the unix epoch of the view,
* member is `<entityType>:<entityId>`. 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<RecentlyViewedEntry[]> {
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(() => {});
}