|
1 |
+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
|
2 |
+// ----------------------------------------------------------------------------- |
|
3 |
+// Groupe KA — kaid.ts : client KA ID v2 (personnalisation) pour les apps Next. |
|
4 |
+// SOURCE CANONIQUE : ka-ui.git/kaid/kaid.ts — copié dans src/lib/kaid.ts des |
|
5 |
+// apps TypeScript (vrai-prix, valoplex, trouve-ka). Ne pas diverger. |
|
6 |
+// |
|
7 |
+// Équivalent TS de kaid.py : track() (journal, fire-and-forget) et |
|
8 |
+// fetchPrefs() (profil appris, cache 90 s, fail-open). Appels SERVEUR |
|
9 |
+// uniquement (route handlers) — le secret SSO ne va jamais au navigateur. |
|
10 |
+// sig = HMAC-SHA256(KA_SSO_SECRET, `${clientId}.${kaId}.${ts}`) en hex. |
|
11 |
+// Aucune dépendance : node:crypto + fetch natif. |
|
12 |
+// ----------------------------------------------------------------------------- |
|
13 |
+import { createHmac } from "node:crypto"; |
|
14 |
+ |
|
15 |
+export type KaidEvent = { |
|
16 |
+ type: string; |
|
17 |
+ entity_type?: string; |
|
18 |
+ entity_id?: string; |
|
19 |
+ query?: string; |
|
20 |
+ filters?: Record<string, unknown>; |
|
21 |
+ position?: number; |
|
22 |
+ features?: Record<string, unknown>; |
|
23 |
+ dwell_ms?: number; |
|
24 |
+ session_id?: string; |
|
25 |
+}; |
|
26 |
+ |
|
27 |
+export type KaidPrefs = { |
|
28 |
+ ok: boolean; |
|
29 |
+ personalization: boolean; |
|
30 |
+ profile: { |
|
31 |
+ app: { |
|
32 |
+ n: number; |
|
33 |
+ dims: Record<string, { values: Record<string, number>; conf: number }>; |
|
34 |
+ ranges: Record<string, { p25: number; p50: number; p75: number; n: number }>; |
|
35 |
+ } | null; |
|
36 |
+ global: { location: { values: Record<string, number>; conf: number } }; |
|
37 |
+ } | null; |
|
38 |
+ hidden: string[]; |
|
39 |
+} | null; |
|
40 |
+ |
|
41 |
+const TIMEOUT_MS = 5_000; |
|
42 |
+const PREFS_TTL_MS = 90_000; |
|
43 |
+const prefsCache = new Map<string, { t: number; data: KaidPrefs }>(); |
|
44 |
+ |
|
45 |
+function hubUrl(): string { |
|
46 |
+ return (process.env.KA_HUB_URL ?? "https://www.groupe-ka.com").replace(/\/+$/, ""); |
|
47 |
+} |
|
48 |
+ |
|
49 |
+function signedParams(clientId: string, kaId: string): URLSearchParams | null { |
|
50 |
+ const secret = process.env.KA_SSO_SECRET; |
|
51 |
+ if (!secret || !kaId.startsWith("ka-")) return null; |
|
52 |
+ const ts = String(Math.floor(Date.now() / 1000)); |
|
53 |
+ const sig = createHmac("sha256", secret) |
|
54 |
+ .update(`${clientId}.${kaId}.${ts}`) |
|
55 |
+ .digest("hex"); |
|
56 |
+ return new URLSearchParams({ client_id: clientId, ka_id: kaId, ts, sig }); |
|
57 |
+} |
|
58 |
+ |
|
59 |
+/** Journalise des événements au hub — fire-and-forget, jamais bloquant. */ |
|
60 |
+export function track( |
|
61 |
+ clientId: string, |
|
62 |
+ kaId: string | null | undefined, |
|
63 |
+ events: KaidEvent[], |
|
64 |
+): void { |
|
65 |
+ if (!kaId || !events.length) return; |
|
66 |
+ const p = signedParams(clientId, kaId); |
|
67 |
+ if (!p) return; |
|
68 |
+ const body = JSON.stringify({ |
|
69 |
+ ...Object.fromEntries(p), |
|
70 |
+ events: events.slice(0, 20), |
|
71 |
+ }); |
|
72 |
+ fetch(`${hubUrl()}/api/sso/events`, { |
|
73 |
+ method: "POST", |
|
74 |
+ headers: { "Content-Type": "application/json" }, |
|
75 |
+ body, |
|
76 |
+ signal: AbortSignal.timeout(TIMEOUT_MS), |
|
77 |
+ }).catch(() => {}); |
|
78 |
+} |
|
79 |
+ |
|
80 |
+/** Profil de personnalisation (cache 90 s) — null si indisponible (fail-open). */ |
|
81 |
+export async function fetchPrefs( |
|
82 |
+ clientId: string, |
|
83 |
+ kaId: string | null | undefined, |
|
84 |
+): Promise<KaidPrefs> { |
|
85 |
+ if (!kaId) return null; |
|
86 |
+ const hit = prefsCache.get(kaId); |
|
87 |
+ if (hit && Date.now() - hit.t < PREFS_TTL_MS) return hit.data; |
|
88 |
+ const p = signedParams(clientId, kaId); |
|
89 |
+ if (!p) return null; |
|
90 |
+ let data: KaidPrefs = null; |
|
91 |
+ try { |
|
92 |
+ const r = await fetch(`${hubUrl()}/api/sso/prefs?${p}`, { |
|
93 |
+ signal: AbortSignal.timeout(TIMEOUT_MS), |
|
94 |
+ }); |
|
95 |
+ if (r.ok) data = (await r.json()) as KaidPrefs; |
|
96 |
+ } catch { |
|
97 |
+ data = null; |
|
98 |
+ } |
|
99 |
+ prefsCache.set(kaId, { t: Date.now(), data }); |
|
100 |
+ if (prefsCache.size > 500) |
|
101 |
+ for (const k of [...prefsCache.keys()].slice(0, 100)) prefsCache.delete(k); |
|
102 |
+ return data; |
|
103 |
+} |
|
104 |
+ |
|
105 |
+/** Affinité [−1,1] d'une valeur dans une dimension du profil (0 si inconnue). */ |
|
106 |
+export function affinity( |
|
107 |
+ prefs: KaidPrefs, |
|
108 |
+ dim: string, |
|
109 |
+ value: string | null | undefined, |
|
110 |
+): number { |
|
111 |
+ const d = prefs?.profile?.app?.dims?.[dim]; |
|
112 |
+ if (!d || !value) return 0; |
|
113 |
+ return d.values[value.trim().toLowerCase()] ?? 0; |
|
114 |
+} |
|
115 |
+ |
|
116 |
+/** Affinité de localisation transversale (tous univers confondus). */ |
|
117 |
+export function globalLocationAffinity( |
|
118 |
+ prefs: KaidPrefs, |
|
119 |
+ value: string | null | undefined, |
|
120 |
+): number { |
|
121 |
+ const loc = prefs?.profile?.global?.location; |
|
122 |
+ if (!loc || !value) return 0; |
|
123 |
+ return (loc.values[value.trim().toLowerCase()] ?? 0) * (loc.conf || 0.3); |
|
124 |
+} |
|
125 |
|