All files / app/features/analytics searchAttribution.ts

86.79% Statements 46/53
69.23% Branches 9/13
100% Functions 10/10
93.18% Lines 41/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118                  3x 3x 3x                       5x 5x 80x                       4x 4x 4x 4x             4x 4x 4x           4x 4x 4x 4x 1x 1x   3x 3x 3x     2x 2x     1x         1x     3x     3x 3x 3x                 16x 16x 16x 16x 16x 3x 3x 2x 1x           23x     23x              
/**
 * Search session ID and click-referrer storage backed by sessionStorage.
 *
 * The search session ID rolls over after 30 minutes of inactivity. The click
 * referrer captures the (session, query, result) triple at the moment the user
 * clicks a search result so downstream pages (profile, host request form) can
 * attribute their events back to the originating search.
 */
 
const SESSION_KEY = "search.session";
const REFERRER_KEY = "search.referrer";
const SESSION_TTL_MS = 30 * 60 * 1000;
 
interface StoredSession {
  id: string;
  lastActiveAt: number;
}
 
/**
 * Random 128-bit hex string. getRandomValues() works in insecure contexts,
 * unlike the other Web Crypto random helpers.
 */
function randomToken(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
 
export interface SearchReferrer {
  searchSessionId: string;
  searchQueryId: string;
  resultId: string;
  userId: number;
  setAt: number;
}
 
function readSession(): StoredSession | null {
  Iif (typeof window === "undefined") return null;
  try {
    const raw = window.sessionStorage.getItem(SESSION_KEY);
    return raw ? (JSON.parse(raw) as StoredSession) : null;
  } catch {
    return null;
  }
}
 
function writeSession(s: StoredSession): void {
  Iif (typeof window === "undefined") return;
  try {
    window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(s));
  } catch {
    // sessionStorage unavailable (private mode, quota) — drop silently
  }
}
 
export function getOrCreateSearchSessionId(): string {
  const now = Date.now();
  const existing = readSession();
  if (existing && now - existing.lastActiveAt < SESSION_TTL_MS) {
    writeSession({ id: existing.id, lastActiveAt: now });
    return existing.id;
  }
  const id = randomToken();
  writeSession({ id, lastActiveAt: now });
  return id;
}
 
export function makeSearchQueryId(): string {
  return randomToken();
}
 
export function makeResultId(
  searchQueryId: string,
  userId: number,
  position: number,
): string {
  return `${searchQueryId}:${userId}:${position}`;
}
 
export function setSearchReferrer(
  referrer: Omit<SearchReferrer, "setAt">,
): void {
  Iif (typeof window === "undefined") return;
  try {
    window.sessionStorage.setItem(
      REFERRER_KEY,
      JSON.stringify({ ...referrer, setAt: Date.now() }),
    );
  } catch {
    // sessionStorage unavailable — drop silently
  }
}
 
export function readSearchReferrer(userId: number): SearchReferrer | null {
  Iif (typeof window === "undefined") return null;
  try {
    const raw = window.sessionStorage.getItem(REFERRER_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw) as SearchReferrer;
    if (parsed.userId !== userId) return null;
    if (Date.now() - parsed.setAt > SESSION_TTL_MS) return null;
    return parsed;
  } catch {
    return null;
  }
}
 
export function referrerToProperties(
  referrer: SearchReferrer | null,
): Record<string, unknown> {
  if (!referrer) return {};
  return {
    referrer_search_session_id: referrer.searchSessionId,
    referrer_search_query_id: referrer.searchQueryId,
    referrer_result_id: referrer.resultId,
  };
}