feat(search): full-platform search overhaul + view tracking + notes bucket
Service rewrite covers 14 entity buckets (clients, residential clients, yachts, companies, interests, residential interests, berths, invoices, expenses, documents, files, reminders, brochures, tags, notes, navigation) with prefix tsquery + trigram fallback, phone-digit normalization, and JOINs to client_contacts for email matching. New `notes` bucket searches across the four note tables (client, interest, yacht, company) via UNION + parent-entity label resolution (berth mooring for interests, name for yachts/companies). Renders at the bottom of the dropdown so broad-content matches don't crowd entity-specific hits — per the user's "low-noise" preference. Recently-viewed tracking persists last 20 entity views per user in Redis sorted set; CommandSearch surfaces them as the dropdown's default state and applies affinity ranking when the user types. ID-resolve endpoint accepts pasted UUIDs (or invoice numbers like `INV-2025-001`) and routes the rep straight to the entity, skipping the normal search bucket. Audit search service gains `entityIds[]` array filter for the new loadClientActivityAggregated() path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
src/lib/services/recently-viewed.service.ts
Normal file
120
src/lib/services/recently-viewed.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user