/** * Trouve-KA — favoris « Mon univers Ka » : client serveur du hub Groupe KA * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * * Le hub groupe-ka.com est le MAGASIN CENTRAL des favoris du groupe : * Trouve-KA ne stocke rien localement. Chaque ♥ est poussé au hub (POST * signé HMAC-SHA256, synchrone : un échec remonte à l'appelant) et la liste * est lue au hub (GET signé, cache mémoire 30 s, invalidé à chaque toggle). * Même secret que le SSO (KA_SSO_SECRET). Appels SERVEUR uniquement. * Modèle de référence : food-ka/foodka/hubfav.py. */ import { createHash, createHmac } from "node:crypto"; import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth"; const CACHE_TTL_MS = 30_000; // liste des favoris const TIMEOUT_MS = 6_000; /** Un favori tel que stocké au hub. */ export type HubFavItem = { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; meta?: Record; }; /** Champs d'item acceptés -> longueur maximale (troncature défensive). */ const FIELD_MAX: Record = { item_id: 120, title: 200, subtitle: 200, price_label: 60, image_url: 500, url: 500, }; const cache = new Map(); /** Signature HMAC-SHA256 du hub : hex("trouve-ka.."). */ function sig(kaId: string, ts: number): string | null { const secret = process.env.KA_SSO_SECRET; if (!secret) return null; return createHmac("sha256", secret) .update(`${CLIENT_ID}.${kaId}.${ts}`) .digest("hex"); } /** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */ export function linked(kaId: string | null | undefined): boolean { return Boolean(kaId) && String(kaId).startsWith("ka-"); } /** Identifiant STABLE d'un résultat : dérivé de son URL (les résultats de * Trouve-KA sont des pages — l'URL est leur identité). */ export function itemIdForUrl(url: string): string { return `pg-${createHash("sha256").update(url).digest("hex").slice(0, 40)}`; } /** Ne garde que les champs d'item connus, en chaînes tronquées. */ export function cleanItem(item: Record): Partial { const out: Record = {}; for (const [k, max] of Object.entries(FIELD_MAX)) { const v = item?.[k]; if (v !== undefined && v !== null && v !== "") { out[k] = String(v).slice(0, max); } } return out as Partial; } /** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */ export async function hubToggle( kaId: string, action: "add" | "remove", item: Partial, ): Promise { const ts = Math.floor(Date.now() / 1000); const s = sig(kaId, ts); if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) { return false; } let ok = false; try { const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CLIENT_ID, ka_id: kaId, ts: String(ts), sig: s, action, item, }), signal: AbortSignal.timeout(TIMEOUT_MS), cache: "no-store", }); ok = r.status === 200; } catch { ok = false; } if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle return ok; } /** Favoris Trouve-KA du membre, lus au hub (cache mémoire 30 s). * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */ export async function hubList(kaId: string): Promise { if (!linked(kaId)) return []; // compte legacy non relié au hub const now = Date.now(); const hit = cache.get(kaId); if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs; const ts = Math.floor(now / 1000); const s = sig(kaId, ts); if (!s) return null; try { const params = new URLSearchParams({ client_id: CLIENT_ID, ka_id: kaId, ts: String(ts), sig: s, }); const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${params}`, { signal: AbortSignal.timeout(TIMEOUT_MS), cache: "no-store", }); if (r.status !== 200) return null; const data = (await r.json()) as { favorites?: unknown }; if (!Array.isArray(data.favorites)) return null; const favs = data.favorites.filter( (f): f is HubFavItem => typeof f === "object" && f !== null, ); cache.set(kaId, { at: now, favs }); return favs; } catch { return null; } }