Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// -----------------------------------------------------------------------------3// Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le MAGASIN4// CENTRAL des favoris du groupe : Vrai-Prix ne stocke RIEN localement.5// · Chaque ♥ est poussé au hub (POST signé HMAC, SYNCHRONE : un échec6// remonte à l'appelant) ; la liste est lue au hub (GET signé, cache7// mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.8// · sig = HMAC-SHA256(KA_SSO_SECRET, "vrai-prix.<ka_id>.<ts>") en hex,9// ts epoch en secondes (fenêtre ±300 s côté hub).10// · Appels SERVEUR uniquement (route handlers) — jamais depuis le navigateur.11// Aucune dépendance : node:crypto + fetch natif seulement.12// Config .env.local : KA_SSO_SECRET, KA_HUB_URL (optionnel).13// -----------------------------------------------------------------------------14import { createHmac } from "node:crypto";15import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";1617const CACHE_TTL_MS = 30_000; // 30 s — liste des favoris18const TIMEOUT_MS = 6_000; // 6 s1920/** Item de favori tel qu'accepté / rendu par le hub. */21export type FavItem = {22 item_id: string;23 title: string;24 subtitle?: string;25 price_label?: string;26 image_url?: string;27 url?: string;28 meta?: Record<string, unknown>;29};3031// champs d'item acceptés → longueur maximale (troncature défensive)32const FIELDS: { key: Exclude<keyof FavItem, "meta">; max: number }[] = [33 { key: "item_id", max: 120 },34 { key: "title", max: 200 },35 { key: "subtitle", max: 200 },36 { key: "price_label", max: 60 },37 { key: "image_url", max: 500 },38 { key: "url", max: 500 },39];4041/** Signature HMAC-SHA256 du hub : hex("vrai-prix.<ka_id>.<ts>"). */42function sig(kaId: string, ts: number): string | null {43 const secret = process.env.KA_SSO_SECRET;44 if (!secret) return null;45 return createHmac("sha256", secret)46 .update(`${CLIENT_ID}.${kaId}.${ts}`)47 .digest("hex");48}4950/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */51export function linked(kaId: string | null | undefined): boolean {52 return !!kaId && String(kaId).startsWith("ka-");53}5455/** Ne garde que les champs d'item connus (chaînes tronquées) — null si pas56 * d'item_id exploitable. `title` retombe sur l'item_id. */57export function cleanItem(raw: unknown): FavItem | null {58 if (typeof raw !== "object" || raw === null) return null;59 const src = raw as Record<string, unknown>;60 const out: Record<string, unknown> = {};61 for (const { key, max } of FIELDS) {62 const v = src[key];63 if (v == null || v === "") continue;64 out[key] = String(v).slice(0, max);65 }66 if (typeof out.item_id !== "string" || !out.item_id) return null;67 if (typeof out.title !== "string" || !out.title) out.title = out.item_id;68 if (src.meta && typeof src.meta === "object" && !Array.isArray(src.meta)) {69 out.meta = src.meta as Record<string, unknown>;70 }71 return out as FavItem;72}7374// Cache mémoire process-wide (Map globale — survit au HMR en dev).75type CacheEntry = { at: number; favs: FavItem[] };76const g = globalThis as unknown as { __vpHubFavCache?: Map<string, CacheEntry> };77const cache: Map<string, CacheEntry> =78 g.__vpHubFavCache ?? (g.__vpHubFavCache = new Map());7980/** fetch avec délai maximal (AbortController) — null sur toute erreur réseau. */81async function hubFetch(url: string, init?: RequestInit): Promise<Response | null> {82 const ctrl = new AbortController();83 const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);84 try {85 return await fetch(url, { ...init, signal: ctrl.signal, cache: "no-store" });86 } catch {87 return null;88 } finally {89 clearTimeout(timer);90 }91}9293/** Favoris Vrai-Prix du membre, lus au hub (cache mémoire 30 s).94 * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */95export async function hubList(kaId: string): Promise<FavItem[] | null> {96 if (!linked(kaId)) return []; // compte non relié au hub97 const now = Date.now();98 const hit = cache.get(kaId);99 if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs;100101 const ts = Math.floor(now / 1000);102 const s = sig(kaId, ts);103 if (!s) return null; // KA_SSO_SECRET absent104 const url =105 `${KA_HUB_URL}/api/sso/favorites?client_id=${encodeURIComponent(CLIENT_ID)}` +106 `&ka_id=${encodeURIComponent(kaId)}&ts=${ts}&sig=${s}`;107 const r = await hubFetch(url);108 if (!r || !r.ok) return null;109 let favs: unknown;110 try {111 favs = ((await r.json()) as { favorites?: unknown })?.favorites ?? [];112 } catch {113 return null;114 }115 if (!Array.isArray(favs)) return null;116 const clean = favs.filter(117 (f): f is FavItem =>118 typeof f === "object" && f !== null && typeof (f as FavItem).item_id === "string",119 );120 cache.set(kaId, { at: now, favs: clean });121 return clean;122}123124/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :125 * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */126export async function hubToggle(127 kaId: string,128 action: "add" | "remove",129 item: FavItem,130): Promise<boolean> {131 const ts = Math.floor(Date.now() / 1000);132 const s = sig(kaId, ts);133 if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) return false;134 const r = await hubFetch(`${KA_HUB_URL}/api/sso/favorites`, {135 method: "POST",136 headers: { "Content-Type": "application/json" },137 body: JSON.stringify({138 client_id: CLIENT_ID,139 ka_id: kaId,140 ts: String(ts),141 sig: s,142 action,143 item,144 }),145 });146 const ok = r?.status === 200;147 if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle148 return ok;149}150