SPB Git forge

spb/trouve-ka

Public ★ Pinned

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%

web : favoris unifiés « Mon univers Ka » (KA ID) — hub groupe-ka.com

· lib/hubfav.ts : client serveur du hub (HMAC-SHA256 KA_SSO_SECRET, GET
  cache 30 s invalidé au toggle, POST synchrone timeout 6 s) — transposition
  TypeScript de food-ka/foodka/hubfav.py, AUCUN stockage local.
· /api/favorites (GET) + /api/favorites/toggle (POST) : session tk_ka_session
  requise (401 sinon), 502 si hub injoignable ; item_id stable dérivé de
  l URL du résultat (pg-<sha256[:40]>).
· UI : cœur ♥ sur chaque carte de résultat (fav-button + fav-store partagé,
  toggle optimiste), page /favoris (CTA « Se connecter avec KA ID » hors
  session), liens nav + footer. fr-CA, design éditorial sharp du site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 105720d

9 changed files +669 −0

added apps/web/app/api/favorites/route.ts +25 −0
@@ -0,0 +1,25 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Favoris « Mon univers Ka » du membre connecté, lus au hub groupe-ka.com
3 +// (magasin central — aucun stockage local, cache serveur 30 s dans hubfav).
4 +// 401 sans session KA ID valide ; 502 si le hub est injoignable.
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
7 +import { hubList } from "@/lib/hubfav";
8 +
9 +export async function GET(req: NextRequest) {
10 + const session = readSession(req.cookies.get(SESSION_COOKIE)?.value);
11 + if (!session) {
12 + return NextResponse.json({ error: "Connexion KA ID requise." }, { status: 401 });
13 + }
14 + const favorites = await hubList(session.ka_id);
15 + if (favorites === null) {
16 + return NextResponse.json(
17 + { error: "Le hub Groupe KA est momentanément injoignable." },
18 + { status: 502 },
19 + );
20 + }
21 + return NextResponse.json(
22 + { favorites },
23 + { headers: { "Cache-Control": "no-store" } },
24 + );
25 +}
added apps/web/app/api/favorites/toggle/route.ts +53 −0
@@ -0,0 +1,53 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Toggle d'un favori « Mon univers Ka » : pousse « add » / « remove » au hub
3 +// groupe-ka.com (POST signé HMAC, SYNCHRONE — un échec remonte au client).
4 +// item_id = identifiant stable du résultat, dérivé de son URL si absent.
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
7 +import { cleanItem, hubToggle, itemIdForUrl } from "@/lib/hubfav";
8 +
9 +export async function POST(req: NextRequest) {
10 + const session = readSession(req.cookies.get(SESSION_COOKIE)?.value);
11 + if (!session) {
12 + return NextResponse.json({ error: "Connexion KA ID requise." }, { status: 401 });
13 + }
14 +
15 + let body: { action?: unknown; item?: unknown };
16 + try {
17 + body = await req.json();
18 + } catch {
19 + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
20 + }
21 +
22 + const action = body.action;
23 + if (action !== "add" && action !== "remove") {
24 + return NextResponse.json(
25 + { error: "action doit être « add » ou « remove »." },
26 + { status: 400 },
27 + );
28 + }
29 + if (typeof body.item !== "object" || body.item === null) {
30 + return NextResponse.json({ error: "item requis." }, { status: 400 });
31 + }
32 +
33 + const item = cleanItem(body.item as Record<string, unknown>);
34 + if (!item.item_id && item.url) item.item_id = itemIdForUrl(item.url);
35 + if (!item.item_id) {
36 + return NextResponse.json(
37 + { error: "item.item_id ou item.url requis." },
38 + { status: 400 },
39 + );
40 + }
41 + if (action === "add" && !item.title) {
42 + return NextResponse.json({ error: "item.title requis." }, { status: 400 });
43 + }
44 +
45 + const ok = await hubToggle(session.ka_id, action, item);
46 + if (!ok) {
47 + return NextResponse.json(
48 + { error: "Le hub Groupe KA est momentanément injoignable." },
49 + { status: 502 },
50 + );
51 + }
52 + return NextResponse.json({ ok: true, action, item_id: item.item_id });
53 +}
added apps/web/app/favoris/favoris-content.tsx +158 −0
@@ -0,0 +1,158 @@
1 +/**
2 + * Trouve-KA — contenu client de la page « Mes favoris » (Mon univers Ka)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Liste vivante des favoris du membre, lue au hub Groupe KA via
7 + * /api/favorites (magasin partagé du fav-store — mêmes cœurs que les
8 + * résultats). Retrait immédiat (toggle optimiste, le hub tranche).
9 + */
10 +
11 +"use client";
12 +
13 +import { useEffect } from "react";
14 +import Link from "next/link";
15 +import { Favicon } from "@/components/favicon";
16 +import {
17 + ensureLoaded,
18 + toggleFav,
19 + useFavorites,
20 + type Fav,
21 +} from "@/components/fav-store";
22 +
23 +function domainOf(url: string | undefined): string {
24 + if (!url) return "";
25 + try {
26 + return new URL(url).hostname.replace(/^www\./, "");
27 + } catch {
28 + return "";
29 + }
30 +}
31 +
32 +function FavCard({ fav }: { fav: Fav }) {
33 + const domain = domainOf(fav.url);
34 + return (
35 + <article className="group flex items-start gap-4 py-6 sm:gap-6">
36 + <div className="min-w-0 flex-1">
37 + {domain ? (
38 + <div className="flex items-center gap-2">
39 + <Favicon domain={domain} />
40 + <span className="gk-mono truncate text-xs font-medium text-green">
41 + {fav.subtitle || domain}
42 + </span>
43 + </div>
44 + ) : null}
45 + <h3 className="mt-1.5 text-[17px] font-bold leading-snug tracking-[-0.02em] sm:text-lg">
46 + <a
47 + href={fav.url || "#"}
48 + className="rounded-sm text-ink underline decoration-transparent decoration-[3px] underline-offset-[3px] transition-colors hover:decoration-lime focus-visible:decoration-lime"
49 + >
50 + {fav.title || fav.url}
51 + </a>
52 + </h3>
53 + {fav.price_label ? (
54 + <p className="mt-1.5 text-sm font-medium text-ink-2">
55 + {fav.price_label}
56 + </p>
57 + ) : null}
58 + <button
59 + type="button"
60 + onClick={() =>
61 + void toggleFav({
62 + url: fav.url || fav.item_id,
63 + title: fav.title,
64 + subtitle: fav.subtitle,
65 + image_url: fav.image_url,
66 + })
67 + }
68 + className="gk-display mt-3 inline-flex min-h-[36px] items-center gap-1.5 rounded-full border-[1.5px] border-ink bg-surface px-3.5 text-[12px] font-bold text-ink transition-colors hover:bg-[var(--accent-soft)]"
69 + >
70 + <svg
71 + width="12"
72 + height="12"
73 + viewBox="0 0 24 24"
74 + fill="currentColor"
75 + aria-hidden="true"
76 + >
77 + <path d="M12 21c-4.8-3.6-9-6.9-9-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 4.1-4.2 7.4-9 11Z" />
78 + </svg>
79 + Retirer
80 + </button>
81 + </div>
82 + {fav.image_url ? (
83 + // eslint-disable-next-line @next/next/no-img-element
84 + <img
85 + src={fav.image_url}
86 + alt=""
87 + loading="lazy"
88 + className="h-[76px] w-[76px] shrink-0 rounded-card border border-line object-cover sm:h-[110px] sm:w-[110px]"
89 + />
90 + ) : null}
91 + </article>
92 + );
93 +}
94 +
95 +export function FavorisContent({ name }: { name: string }) {
96 + const favs = useFavorites();
97 +
98 + useEffect(() => {
99 + // Liste fraîche à l'arrivée sur la page (le cache serveur reste à 30 s).
100 + void ensureLoaded(true);
101 + }, []);
102 +
103 + const loading = favs.status === "idle" || favs.status === "loading";
104 +
105 + return (
106 + <main id="contenu" className="mx-auto w-full max-w-3xl flex-1 px-4 py-8">
107 + <p className="klabel">Mon univers Ka</p>
108 + <h1 className="mt-2 text-3xl font-bold leading-tight tracking-[-0.03em] sm:text-4xl">
109 + Mes favoris
110 + </h1>
111 + <p className="mt-3 max-w-xl text-sm leading-relaxed text-ink-2">
112 + {name ? `${name}, voici` : "Voici"} les pages que vous avez gardées —
113 + la même liste vous suit sur toutes les plateformes du Groupe KA.
114 + </p>
115 +
116 + {loading ? (
117 + <div aria-hidden="true" className="mt-6 divide-y divide-line">
118 + {Array.from({ length: 3 }, (_, i) => (
119 + <div key={i} className="animate-pulse space-y-2.5 py-6">
120 + <div className="h-3.5 w-1/3 rounded bg-[var(--line)]" />
121 + <div className="h-5 w-2/3 rounded bg-[var(--line)]" />
122 + <div className="h-9 w-24 rounded-full bg-[var(--line)]" />
123 + </div>
124 + ))}
125 + </div>
126 + ) : favs.status === "error" ? (
127 + <div className="mt-10 text-center">
128 + <p className="font-display font-bold text-ink">
129 + Le hub Groupe KA est momentanément injoignable.
130 + </p>
131 + <p className="mt-2 text-sm text-ink-2">
132 + Vos favoris sont en sécurité — réessayez dans quelques instants.
133 + </p>
134 + </div>
135 + ) : favs.items.length === 0 ? (
136 + <div className="rise mt-10 py-10 text-center">
137 + <p className="klabel">0 favori</p>
138 + <h2 className="mx-auto mt-4 max-w-xl text-2xl font-bold leading-tight tracking-[-0.03em] sm:text-3xl">
139 + Rien dans votre univers — <span className="hl">encore</span>.
140 + </h2>
141 + <p className="mx-auto mt-5 max-w-md text-sm leading-relaxed text-ink-2">
142 + Lancez une recherche et touchez le cœur ♥ d&apos;un résultat pour
143 + le retrouver ici, et sur tous les sites du Groupe KA.
144 + </p>
145 + <Link href="/" className="btn btn-primary mt-8">
146 + Lancer une recherche
147 + </Link>
148 + </div>
149 + ) : (
150 + <div className="stagger mt-2 divide-y divide-line">
151 + {favs.items.map((fav) => (
152 + <FavCard key={fav.item_id || fav.url} fav={fav} />
153 + ))}
154 + </div>
155 + )}
156 + </main>
157 + );
158 +}
added apps/web/app/favoris/page.tsx +53 −0
@@ -0,0 +1,53 @@
1 +/**
2 + * Trouve-KA — page « Mes favoris » (Mon univers Ka)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Enveloppe serveur : vérifie la session KA ID (cookie tk_ka_session).
7 + * Connecté → liste des favoris lue au hub Groupe KA (client, /api/favorites) ;
8 + * sinon → invitation « Se connecter avec KA ID » (flux SSO existant).
9 + */
10 +
11 +import type { Metadata } from "next";
12 +import { cookies } from "next/headers";
13 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
14 +import { FavorisContent } from "./favoris-content";
15 +
16 +export const metadata: Metadata = {
17 + title: "Mes favoris",
18 + description:
19 + "Mon univers Ka — vos favoris, partagés entre toutes les plateformes du Groupe KA.",
20 + robots: { index: false, follow: true },
21 +};
22 +
23 +export default async function FavorisPage() {
24 + const jar = await cookies();
25 + const session = readSession(jar.get(SESSION_COOKIE)?.value);
26 +
27 + if (!session) {
28 + return (
29 + <main id="contenu" className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
30 + <div className="rise text-center">
31 + <p className="klabel">Mon univers Ka</p>
32 + <h1 className="mx-auto mt-4 max-w-xl text-3xl font-bold leading-tight tracking-[-0.03em] sm:text-4xl">
33 + Vos favoris, sur <span className="hl">toutes</span> les plateformes
34 + KA.
35 + </h1>
36 + <p className="mx-auto mt-5 max-w-md text-sm leading-relaxed text-ink-2">
37 + Touchez le cœur ♥ d&apos;un résultat pour le garder dans Mon
38 + univers Ka — la même liste vous suit sur les 14 sites du Groupe
39 + KA. Connectez-vous avec votre KA ID pour commencer.
40 + </p>
41 + <a
42 + href="/api/auth/ka/login?next=%2Ffavoris"
43 + className="btn btn-primary mt-8"
44 + >
45 + Se connecter avec KA ID
46 + </a>
47 + </div>
48 + </main>
49 + );
50 + }
51 +
52 + return <FavorisContent name={session.name} />;
53 +}
modified apps/web/app/layout.tsx +2 −0
@@ -84,6 +84,7 @@ export const viewport: Viewport = {
84 84 };
85 85
86 86 const NAV_LINKS = [
87 + { href: "/favoris", label: "Favoris" },
87 88 { href: "/status", label: "État du moteur" },
88 89 { href: "/stats", label: "Stats" },
89 90 { href: "/soumettre", label: "Soumettre une page" },
@@ -91,6 +92,7 @@ const NAV_LINKS = [
91 92 ] as const;
92 93
93 94 const LOCAL_FOOTER_LINKS = [
95 + { href: "/favoris", label: "Mes favoris" },
94 96 { href: "/trouveka-bot", label: "À propos du robot" },
95 97 { href: "/status", label: "État du moteur" },
96 98 { href: "/stats", label: "Statistiques" },
added apps/web/components/fav-button.tsx +97 −0
@@ -0,0 +1,97 @@
1 +/**
2 + * Trouve-KA — cœur ♥ « Mon univers Ka » d'un résultat de recherche
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Toggle du favori au hub Groupe KA (via /api/favorites/toggle). Connecté :
7 + * cœur plein/contour ; non connecté : le tap envoie au flux « Se connecter
8 + * avec KA ID » existant (retour sur la page courante). Zone tactile 44 px
9 + * via ::after (pattern du socle mobile KA).
10 + */
11 +
12 +"use client";
13 +
14 +import { useEffect, useState } from "react";
15 +import { cn } from "@/lib/utils";
16 +import { ensureLoaded, isFavorite, toggleFav, useFavorites } from "@/components/fav-store";
17 +
18 +interface FavButtonProps {
19 + url: string;
20 + title: string;
21 + subtitle?: string;
22 + imageUrl?: string | null;
23 + className?: string;
24 +}
25 +
26 +export function FavButton({ url, title, subtitle, imageUrl, className }: FavButtonProps) {
27 + const favs = useFavorites();
28 + const [busy, setBusy] = useState(false);
29 +
30 + useEffect(() => {
31 + void ensureLoaded();
32 + }, []);
33 +
34 + const fav = isFavorite(favs, url);
35 + const anon = favs.status === "anon";
36 +
37 + const onClick = async () => {
38 + if (busy) return;
39 + if (anon) {
40 + const here = window.location.pathname + window.location.search;
41 + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(here)}`;
42 + return;
43 + }
44 + setBusy(true);
45 + await toggleFav({
46 + url,
47 + title,
48 + subtitle,
49 + image_url: imageUrl || undefined,
50 + });
51 + setBusy(false);
52 + };
53 +
54 + return (
55 + <button
56 + type="button"
57 + onClick={onClick}
58 + disabled={busy}
59 + aria-pressed={fav}
60 + aria-label={
61 + anon
62 + ? "Se connecter avec KA ID pour ajouter aux favoris"
63 + : fav
64 + ? "Retirer des favoris"
65 + : "Ajouter aux favoris"
66 + }
67 + title={
68 + anon
69 + ? "Se connecter avec KA ID"
70 + : fav
71 + ? "Retirer de Mon univers Ka"
72 + : "Ajouter à Mon univers Ka"
73 + }
74 + className={cn(
75 + "relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors after:absolute after:-inset-1.5 after:content-['']",
76 + fav
77 + ? "border-ink bg-ink text-lime"
78 + : "border-transparent text-ink-2 hover:border-ink hover:text-ink",
79 + busy && "opacity-60",
80 + className,
81 + )}
82 + >
83 + <svg
84 + width="15"
85 + height="15"
86 + viewBox="0 0 24 24"
87 + fill={fav ? "currentColor" : "none"}
88 + stroke="currentColor"
89 + strokeWidth="2"
90 + strokeLinejoin="round"
91 + aria-hidden="true"
92 + >
93 + <path d="M12 21c-4.8-3.6-9-6.9-9-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 4.1-4.2 7.4-9 11Z" />
94 + </svg>
95 + </button>
96 + );
97 +}
added apps/web/components/fav-store.ts +130 −0
@@ -0,0 +1,130 @@
1 +/**
2 + * Trouve-KA — magasin client des favoris « Mon univers Ka »
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Singleton partagé par tous les cœurs ♥ de la page (useSyncExternalStore) :
7 + * une seule lecture de /api/favorites (le hub, via le serveur), toggles
8 + * optimistes annulés si le hub refuse. Aucun stockage local — le hub
9 + * groupe-ka.com est la source de vérité, ce magasin n'est qu'un miroir.
10 + */
11 +
12 +"use client";
13 +
14 +import { useSyncExternalStore } from "react";
15 +
16 +export type Fav = {
17 + item_id: string;
18 + title: string;
19 + subtitle?: string;
20 + price_label?: string;
21 + image_url?: string;
22 + url?: string;
23 +};
24 +
25 +export type FavStatus = "idle" | "loading" | "ready" | "anon" | "error";
26 +
27 +export type FavState = {
28 + status: FavStatus;
29 + items: readonly Fav[];
30 +};
31 +
32 +let state: FavState = { status: "idle", items: [] };
33 +const listeners = new Set<() => void>();
34 +let loadPromise: Promise<void> | null = null;
35 +
36 +function setState(next: FavState) {
37 + state = next;
38 + for (const l of listeners) l();
39 +}
40 +
41 +function subscribe(listener: () => void): () => void {
42 + listeners.add(listener);
43 + return () => listeners.delete(listener);
44 +}
45 +
46 +const getSnapshot = () => state;
47 +const SERVER_SNAPSHOT: FavState = { status: "idle", items: [] };
48 +const getServerSnapshot = () => SERVER_SNAPSHOT;
49 +
50 +/** Charge la liste des favoris (une seule requête partagée par page). */
51 +export function ensureLoaded(force = false): Promise<void> {
52 + if (!force && (loadPromise || state.status === "ready" || state.status === "anon")) {
53 + return loadPromise ?? Promise.resolve();
54 + }
55 + if (state.status === "idle" || force) {
56 + setState({ ...state, status: "loading" });
57 + }
58 + loadPromise = fetch("/api/favorites", { cache: "no-store" })
59 + .then(async (r) => {
60 + if (r.status === 401) {
61 + setState({ status: "anon", items: [] });
62 + return;
63 + }
64 + if (!r.ok) throw new Error(`Erreur ${r.status}`);
65 + const data = (await r.json()) as { favorites?: Fav[] };
66 + setState({ status: "ready", items: data.favorites ?? [] });
67 + })
68 + .catch(() => {
69 + setState({ status: "error", items: state.items });
70 + })
71 + .finally(() => {
72 + loadPromise = null;
73 + });
74 + return loadPromise;
75 +}
76 +
77 +function hasFav(items: readonly Fav[], url: string): boolean {
78 + return items.some((f) => f.url === url);
79 +}
80 +
81 +/**
82 + * Toggle optimiste d'un favori (par URL — identité d'un résultat Trouve-KA).
83 + * Rendu : "added" | "removed" en cas de succès, "error" si le hub refuse
84 + * (l'état local est alors restauré), "anon" si non connecté.
85 + */
86 +export async function toggleFav(item: {
87 + url: string;
88 + title: string;
89 + subtitle?: string;
90 + image_url?: string;
91 +}): Promise<"added" | "removed" | "error" | "anon"> {
92 + if (state.status === "anon") return "anon";
93 + const before = state.items;
94 + const removing = hasFav(before, item.url);
95 + const action = removing ? "remove" : "add";
96 +
97 + // Optimiste : on reflète le geste tout de suite, le hub tranche ensuite.
98 + setState({
99 + status: "ready",
100 + items: removing
101 + ? before.filter((f) => f.url !== item.url)
102 + : [{ item_id: "", ...item }, ...before],
103 + });
104 +
105 + try {
106 + const r = await fetch("/api/favorites/toggle", {
107 + method: "POST",
108 + headers: { "Content-Type": "application/json" },
109 + body: JSON.stringify({ action, item }),
110 + });
111 + if (r.status === 401) {
112 + setState({ status: "anon", items: [] });
113 + return "anon";
114 + }
115 + if (!r.ok) throw new Error(`Erreur ${r.status}`);
116 + return removing ? "removed" : "added";
117 + } catch {
118 + setState({ status: state.status, items: before }); // annule l'optimisme
119 + return "error";
120 + }
121 +}
122 +
123 +/** État réactif des favoris — à consommer dans les composants clients. */
124 +export function useFavorites(): FavState {
125 + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
126 +}
127 +
128 +export function isFavorite(stateNow: FavState, url: string): boolean {
129 + return hasFav(stateNow.items, url);
130 +}
modified apps/web/components/result-item.tsx +9 −0
@@ -4,6 +4,7 @@
4 4 * Contact: contact@spboucher.ai
5 5 */
6 6
7 +import { FavButton } from "@/components/fav-button";
7 8 import { Favicon } from "@/components/favicon";
8 9 import { ResultThumbnail } from "@/components/result-thumbnail";
9 10 import { Snippet } from "@/components/snippet";
@@ -40,6 +41,14 @@ export function ResultItem({ result }: ResultItemProps) {
40 41 <span className="gk-mono truncate text-xs font-medium text-green">
41 42 {result.display_url || result.domain}
42 43 </span>
44 + {/* ♥ Mon univers Ka : favori partagé sur tout le Groupe KA */}
45 + <FavButton
46 + url={result.url}
47 + title={result.title || result.url}
48 + subtitle={result.display_url || result.domain}
49 + imageUrl={result.image}
50 + className="-my-1.5 ml-auto"
51 + />
43 52 </div>
44 53 <h3 className="mt-1.5 text-[17px] font-bold leading-snug tracking-[-0.02em] sm:text-lg">
45 54 <a
added apps/web/lib/hubfav.ts +142 −0
@@ -0,0 +1,142 @@
1 +/**
2 + * Trouve-KA — favoris « Mon univers Ka » : client serveur du hub Groupe KA
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Le hub groupe-ka.com est le MAGASIN CENTRAL des favoris du groupe :
7 + * Trouve-KA ne stocke rien localement. Chaque ♥ est poussé au hub (POST
8 + * signé HMAC-SHA256, synchrone : un échec remonte à l'appelant) et la liste
9 + * est lue au hub (GET signé, cache mémoire 30 s, invalidé à chaque toggle).
10 + * Même secret que le SSO (KA_SSO_SECRET). Appels SERVEUR uniquement.
11 + * Modèle de référence : food-ka/foodka/hubfav.py.
12 + */
13 +import { createHash, createHmac } from "node:crypto";
14 +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";
15 +
16 +const CACHE_TTL_MS = 30_000; // liste des favoris
17 +const TIMEOUT_MS = 6_000;
18 +
19 +/** Un favori tel que stocké au hub. */
20 +export type HubFavItem = {
21 + item_id: string;
22 + title: string;
23 + subtitle?: string;
24 + price_label?: string;
25 + image_url?: string;
26 + url?: string;
27 + meta?: Record<string, unknown>;
28 +};
29 +
30 +/** Champs d'item acceptés -> longueur maximale (troncature défensive). */
31 +const FIELD_MAX: Record<string, number> = {
32 + item_id: 120,
33 + title: 200,
34 + subtitle: 200,
35 + price_label: 60,
36 + image_url: 500,
37 + url: 500,
38 +};
39 +
40 +const cache = new Map<string, { at: number; favs: HubFavItem[] }>();
41 +
42 +/** Signature HMAC-SHA256 du hub : hex("trouve-ka.<ka_id>.<ts>"). */
43 +function sig(kaId: string, ts: number): string | null {
44 + const secret = process.env.KA_SSO_SECRET;
45 + if (!secret) return null;
46 + return createHmac("sha256", secret)
47 + .update(`${CLIENT_ID}.${kaId}.${ts}`)
48 + .digest("hex");
49 +}
50 +
51 +/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */
52 +export function linked(kaId: string | null | undefined): boolean {
53 + return Boolean(kaId) && String(kaId).startsWith("ka-");
54 +}
55 +
56 +/** Identifiant STABLE d'un résultat : dérivé de son URL (les résultats de
57 + * Trouve-KA sont des pages — l'URL est leur identité). */
58 +export function itemIdForUrl(url: string): string {
59 + return `pg-${createHash("sha256").update(url).digest("hex").slice(0, 40)}`;
60 +}
61 +
62 +/** Ne garde que les champs d'item connus, en chaînes tronquées. */
63 +export function cleanItem(item: Record<string, unknown>): Partial<HubFavItem> {
64 + const out: Record<string, string> = {};
65 + for (const [k, max] of Object.entries(FIELD_MAX)) {
66 + const v = item?.[k];
67 + if (v !== undefined && v !== null && v !== "") {
68 + out[k] = String(v).slice(0, max);
69 + }
70 + }
71 + return out as Partial<HubFavItem>;
72 +}
73 +
74 +/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
75 + * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */
76 +export async function hubToggle(
77 + kaId: string,
78 + action: "add" | "remove",
79 + item: Partial<HubFavItem>,
80 +): Promise<boolean> {
81 + const ts = Math.floor(Date.now() / 1000);
82 + const s = sig(kaId, ts);
83 + if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) {
84 + return false;
85 + }
86 + let ok = false;
87 + try {
88 + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, {
89 + method: "POST",
90 + headers: { "Content-Type": "application/json" },
91 + body: JSON.stringify({
92 + client_id: CLIENT_ID,
93 + ka_id: kaId,
94 + ts: String(ts),
95 + sig: s,
96 + action,
97 + item,
98 + }),
99 + signal: AbortSignal.timeout(TIMEOUT_MS),
100 + cache: "no-store",
101 + });
102 + ok = r.status === 200;
103 + } catch {
104 + ok = false;
105 + }
106 + if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle
107 + return ok;
108 +}
109 +
110 +/** Favoris Trouve-KA du membre, lus au hub (cache mémoire 30 s).
111 + * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */
112 +export async function hubList(kaId: string): Promise<HubFavItem[] | null> {
113 + if (!linked(kaId)) return []; // compte legacy non relié au hub
114 + const now = Date.now();
115 + const hit = cache.get(kaId);
116 + if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs;
117 + const ts = Math.floor(now / 1000);
118 + const s = sig(kaId, ts);
119 + if (!s) return null;
120 + try {
121 + const params = new URLSearchParams({
122 + client_id: CLIENT_ID,
123 + ka_id: kaId,
124 + ts: String(ts),
125 + sig: s,
126 + });
127 + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${params}`, {
128 + signal: AbortSignal.timeout(TIMEOUT_MS),
129 + cache: "no-store",
130 + });
131 + if (r.status !== 200) return null;
132 + const data = (await r.json()) as { favorites?: unknown };
133 + if (!Array.isArray(data.favorites)) return null;
134 + const favs = data.favorites.filter(
135 + (f): f is HubFavItem => typeof f === "object" && f !== null,
136 + );
137 + cache.set(kaId, { at: now, favs });
138 + return favs;
139 + } catch {
140 + return null;
141 + }
142 +}
143