ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 91.9%
Python 6%
CSS 2.1%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// -----------------------------------------------------------------------------3// hubfav — favoris « Mon univers Ka » : le hub Groupe KA (groupe-ka.com) est le4// MAGASIN CENTRAL des favoris du groupe — ValoPlex ne stocke RIEN localement.5// Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un échec remonte à6// l'appelant) et la liste est lue au hub (GET signé, cache mémoire 30 s,7// invalidé à chaque toggle). Même secret que le SSO (KA_SSO_SECRET).8// sig = HMAC-SHA256(KA_SSO_SECRET, "valoplex.<ka_id>.<ts>") en hex,9// ts en secondes epoch (fenêtre ±300 s côté hub).10// -----------------------------------------------------------------------------11import { createHmac } from "node:crypto";12import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";1314const CACHE_TTL_MS = 30_000; // liste des favoris15const TIMEOUT_MS = 6_000;1617/** Item de favori tel qu'accepté par le hub (champ → longueur maximale). */18const FIELDS: Record<string, number> = {19 item_id: 120,20 title: 200,21 subtitle: 200,22 price_label: 60,23 image_url: 500,24 url: 500,25};2627export type FavItem = {28 item_id: string;29 title: string;30 subtitle?: string;31 price_label?: string;32 image_url?: string;33 url?: string;34} & Record<string, unknown>;3536const cache = new Map<string, { at: number; favs: FavItem[] }>();3738function sig(kaId: string, ts: number): string | null {39 const secret = process.env.KA_SSO_SECRET;40 if (!secret) return null;41 return createHmac("sha256", secret)42 .update(`${CLIENT_ID}.${kaId}.${ts}`)43 .digest("hex");44}4546/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */47export function linked(kaId: string | null | undefined): boolean {48 return typeof kaId === "string" && kaId.startsWith("ka-");49}5051/** Ne garde que les champs d'item connus, en chaînes tronquées. */52export function cleanItem(raw: Record<string, unknown>): FavItem {53 const out: Record<string, string> = {};54 for (const [k, max] of Object.entries(FIELDS)) {55 const v = raw?.[k];56 if (v != null && String(v)) out[k] = String(v).slice(0, max);57 }58 return out as FavItem;59}6061/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :62 * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */63export async function hubToggle(64 kaId: string,65 action: string,66 item: FavItem,67): Promise<boolean> {68 const ts = Math.floor(Date.now() / 1000);69 const s = sig(kaId, ts);70 if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) {71 return false;72 }73 let ok = false;74 try {75 const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, {76 method: "POST",77 headers: { "Content-Type": "application/json" },78 signal: AbortSignal.timeout(TIMEOUT_MS),79 body: JSON.stringify({80 client_id: CLIENT_ID,81 ka_id: kaId,82 ts: String(ts),83 sig: s,84 action,85 item,86 }),87 });88 ok = r.status === 200;89 } catch {90 ok = false;91 }92 if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle93 return ok;94}9596/** Favoris ValoPlex du membre, lus au hub (cache mémoire 30 s).97 * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */98export async function hubList(kaId: string): Promise<FavItem[] | null> {99 if (!linked(kaId)) return []; // compte non relié au hub100 const now = Date.now();101 const hit = cache.get(kaId);102 if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs;103 const ts = Math.floor(now / 1000);104 const s = sig(kaId, ts);105 if (!s) return null;106 let favs: FavItem[];107 try {108 const q = new URLSearchParams({109 client_id: CLIENT_ID,110 ka_id: kaId,111 ts: String(ts),112 sig: s,113 });114 const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${q}`, {115 signal: AbortSignal.timeout(TIMEOUT_MS),116 cache: "no-store",117 });118 if (r.status !== 200) return null;119 const body = (await r.json()) as { favorites?: unknown };120 if (!Array.isArray(body.favorites)) return null;121 favs = body.favorites.filter(122 (f): f is FavItem => typeof f === "object" && f !== null,123 );124 } catch {125 return null;126 }127 cache.set(kaId, { at: now, favs });128 return favs;129}130