SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%

favoris unifiés « Mon univers Ka » : le hub Groupe KA devient le magasin central

- restoka/hubfav.py : client HMAC du hub (calqué sur foodka/hubfav.py) —
  toggle synchrone, lecture avec cache mémoire 30 s invalidé au toggle
- web.py : GET /api/favorites (liste du hub + fiches locales) et
  POST /api/favorites/toggle, protégés par la session KA ID (401 sinon)
- auth.py : retrait des anciennes routes locales /api/favoris (plus aucun
  stockage local des favoris)
- frontend : favorites.tsx (FavProvider/FavButton), cœur ♥ sur les cartes
  ET la fiche détail (non connecté : clic → connexion KA ID), page /favoris
  branchée au hub, entrée Favoris dans la nav, icône IcoHeart, .fav-btn.big

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

12 changed files +370 −107

modified frontend/src/App.tsx +5 −2
@@ -13,8 +13,9 @@ import {
13 13 registerSourceNames, sourceName,
14 14 } from "./api";
15 15 import {
16 − IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch,
16 + IcoChart, IcoCompass, IcoFolder, IcoHeart, IcoPlate, IcoSearch,
17 17 } from "./components/Icons";
18 +import { FavProvider } from "./favorites";
18 19 import { LogoIcon } from "./components/Logo";
19 20 import GroupeKaBadge from "./ka/GroupeKaBadge";
20 21 import KaFooter from "./ka/KaFooter";
@@ -139,6 +140,7 @@ function Ticker() {
139 140
140 141 const NAV_LINKS = [
141 142 { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },
143 + { to: "/favoris", label: "Favoris", icon: <IcoHeart size={17} />, end: false },
142 144 { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },
143 145 { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },
144 146 { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false },
@@ -224,7 +226,6 @@ function Footer() {
224 226 <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>
225 227 ))}
226 228 <NavLink to="/villes">Villes</NavLink>
227 − <NavLink to="/favoris">Favoris</NavLink>
228 229 </nav>
229 230 <div className="prefoot-osm">
230 231 Données de découverte ©{" "}
@@ -261,6 +262,7 @@ function MobileTabBar() {
261 262 export default function App() {
262 263 return (
263 264 <AccountProvider>
265 + <FavProvider>
264 266 <Header />
265 267 <main>
266 268 <Routes>
@@ -286,6 +288,7 @@ export default function App() {
286 288 </main>
287 289 <Footer />
288 290 <MobileTabBar />
291 + </FavProvider>
289 292 </AccountProvider>
290 293 );
291 294 }
modified frontend/src/account.tsx +6 −25
@@ -2,51 +2,32 @@
2 2 // Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 3 // File: account.tsx
4 4 // Desc: Contexte de compte membre (« Se connecter avec KA » — hub Groupe KA) :
5 −// profil de session + favoris partagés, comme le account.tsx de Lou·Ka.
5 +// profil de session, comme le account.tsx de Lou·Ka. Les favoris ♥
6 +// vivent au hub Groupe KA (« Mon univers Ka ») — voir favorites.tsx.
6 7 // ==============================================================================
7 8 import {
8 9 createContext, useCallback, useContext, useEffect, useMemo, useState,
9 10 } from "react";
10 −import { Me, fetchFavoris, fetchMe, toggleFavori } from "./api";
11 +import { Me, fetchMe } from "./api";
11 12
12 13 interface AccountCtx {
13 14 me: Me | null;
14 − favs: Set<string>;
15 15 refresh: () => void;
16 − toggleFav: (uid: string) => Promise<void>;
17 16 }
18 17
19 −const Ctx = createContext<AccountCtx>({
20 − me: null, favs: new Set(), refresh: () => {}, toggleFav: async () => {},
21 −});
18 +const Ctx = createContext<AccountCtx>({ me: null, refresh: () => {} });
22 19
23 20 export const useAccount = () => useContext(Ctx);
24 21
25 22 export function AccountProvider({ children }: { children: React.ReactNode }) {
26 23 const [me, setMe] = useState<Me | null>(null);
27 − const [favs, setFavs] = useState<Set<string>>(new Set());
28 24
29 25 const refresh = useCallback(() => {
30 − fetchMe().then((m) => {
31 − setMe(m);
32 − if (m) fetchFavoris().then((f) => setFavs(new Set(f.uids))).catch(() => {});
33 − else setFavs(new Set());
34 − });
26 + fetchMe().then((m) => setMe(m));
35 27 }, []);
36 28
37 29 useEffect(() => { refresh(); }, [refresh]);
38 30
39 − const toggleFav = useCallback(async (uid: string) => {
40 − const on = await toggleFavori(uid);
41 − setFavs((prev) => {
42 − const next = new Set(prev);
43 − if (on) next.add(uid);
44 − else next.delete(uid);
45 − return next;
46 − });
47 − }, []);
48 −
49 − const value = useMemo(() => ({ me, favs, refresh, toggleFav }),
50 − [me, favs, refresh, toggleFav]);
31 + const value = useMemo(() => ({ me, refresh }), [me, refresh]);
51 32 return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
52 33 }
modified frontend/src/api.ts +23 −5
@@ -306,11 +306,29 @@ export async function logout(): Promise<void> {
306 306 await fetch("/api/auth/logout", { method: "POST" });
307 307 }
308 308
309 −export const fetchFavoris = () =>
310 − get<{ count: number; restaurants: Restaurant[]; uids: string[] }>("/api/favoris");
309 +// --- Favoris « Mon univers Ka » (magasin central : hub Groupe KA) ------------
310 +
311 +/** Item de favori tel que poussé au hub Groupe KA (groupe-ka.com). */
312 +export interface FavItem {
313 + item_id: string;
314 + title: string;
315 + subtitle?: string;
316 + price_label?: string;
317 + image_url?: string;
318 + url?: string;
319 +}
311 320
312 −export async function toggleFavori(uid: string): Promise<boolean> {
313 − const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: "POST" });
321 +/** Favoris du membre (lus au hub) + fiches locales correspondantes. */
322 +export const fetchFavorites = () =>
323 + get<{ ids: string[]; items: FavItem[]; restaurants: Restaurant[] }>("/api/favorites");
324 +
325 +/** Pousse un ♥ (on=true : ajout ; on=false : retrait) au hub, via l'API locale. */
326 +export async function toggleFavorite(on: boolean, item: FavItem): Promise<void> {
327 + const resp = await fetch("/api/favorites/toggle", {
328 + method: "POST",
329 + credentials: "same-origin",
330 + headers: { "Content-Type": "application/json" },
331 + body: JSON.stringify({ on, item }),
332 + });
314 333 if (!resp.ok) throw new Error(`API ${resp.status}`);
315 − return (await resp.json()).favori as boolean;
316 334 }
modified frontend/src/components/Icons.tsx +6 −0
@@ -78,3 +78,9 @@ export const IcoBag = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
78 78 <path d="M9 8a3 3 0 0 1 6 0" />
79 79 </svg>
80 80 );
81 +
82 +export const IcoHeart = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
83 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
84 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
85 + </svg>
86 +);
modified frontend/src/components/RestoCard.tsx +2 −13
@@ -6,18 +6,16 @@
6 6 // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.
7 7 // ==============================================================================
8 8 import { Link } from "react-router-dom";
9 −import { useAccount } from "../account";
10 9 import {
11 10 CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,
12 11 } from "../api";
12 +import { FavButton } from "../favorites";
13 13 import { IcoPlate } from "./Icons";
14 14
15 15 export default function RestoCard({ r }: { r: Restaurant }) {
16 16 const ms = r.menu_summary;
17 17 const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord
18 18 const logo = (r.images ?? [])[0] || null;
19 − const { me, favs, toggleFav } = useAccount();
20 − const fav = favs.has(r.uid);
21 19 return (
22 20 <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card">
23 21 <div className="card-img">
@@ -34,16 +32,7 @@ export default function RestoCard({ r }: { r: Restaurant }) {
34 32 {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}
35 33 </span>
36 34 )}
37 − {me && (
38 − <button
39 − className={`fav-btn ${fav ? "on" : ""}`}
40 − aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"}
41 − aria-pressed={fav}
42 − onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }}
43 − >
44 − {fav ? "♥" : "♡"}
45 − </button>
46 − )}
35 + <FavButton r={r} />
47 36 </div>
48 37 <div className="card-body">
49 38 <div className="card-price">
added frontend/src/favorites.tsx +114 −0
@@ -0,0 +1,114 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: favorites.tsx
4 +// Desc: Favoris ♥ « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est
5 +// le magasin central des favoris du groupe (aucun stockage local ni
6 +// navigateur) : lecture/écriture via l'API locale /api/favorites qui
7 +// relaie au hub. FavProvider charge la liste une fois ; FavButton =
8 +// cœur sur les cartes de restos et la fiche détail. Non connecté →
9 +// clic = « Se connecter avec KA ID ». Calqué sur foodka/favorites.tsx.
10 +// ==============================================================================
11 +import {
12 + ReactNode, createContext, useCallback, useContext,
13 + useEffect, useMemo, useState,
14 +} from "react";
15 +import { FavItem, Restaurant, fmtPrice, toggleFavorite } from "./api";
16 +
17 +interface FavState {
18 + ready: boolean; // premier chargement terminé
19 + connected: boolean; // session KA ID active
20 + ids: Set<string>; // uid des restos en favoris
21 + items: FavItem[];
22 + toggle: (item: FavItem) => void;
23 +}
24 +
25 +const FavContext = createContext<FavState>({
26 + ready: false, connected: false, ids: new Set(), items: [], toggle: () => {},
27 +});
28 +
29 +/** Restaurant -> item de favori du hub (nom, ville, prix médian, image, url). */
30 +export function restoFavItem(r: Restaurant): FavItem {
31 + const ms = r.menu_summary;
32 + return {
33 + item_id: r.uid,
34 + title: r.name || "Restaurant",
35 + subtitle: [r.city, r.region].filter(Boolean).join(" · ")
36 + || r.address || "",
37 + price_label: ms?.price_median != null
38 + ? `${fmtPrice(ms.price_median)} · plat médian`
39 + : (r.price_range || ""),
40 + image_url: ms?.image || (r.images ?? [])[1] || (r.images ?? [])[0] || "",
41 + url: `https://www.resto-ka.com/resto/${encodeURIComponent(r.uid)}`,
42 + };
43 +}
44 +
45 +export function FavProvider({ children }: { children: ReactNode }) {
46 + const [ready, setReady] = useState(false);
47 + const [connected, setConnected] = useState(false);
48 + const [items, setItems] = useState<FavItem[]>([]);
49 +
50 + useEffect(() => {
51 + fetch("/api/favorites", { credentials: "same-origin" })
52 + .then(async (res) => {
53 + if (res.status === 401) return; // pas connecté : cœurs vides
54 + if (!res.ok) throw new Error(`API ${res.status}`);
55 + const data = (await res.json()) as { items?: FavItem[] };
56 + setConnected(true);
57 + setItems(data.items ?? []);
58 + })
59 + .catch(() => {}) // hub muet : cœurs vides
60 + .finally(() => setReady(true));
61 + }, []);
62 +
63 + const toggle = useCallback((item: FavItem) => {
64 + if (!connected) {
65 + // non connecté : passage par la connexion KA ID (hub Groupe KA)
66 + window.location.assign("/api/auth/ka/login");
67 + return;
68 + }
69 + const prev = items;
70 + const on = !prev.some((i) => i.item_id === item.item_id);
71 + // optimiste : le hub confirme (sinon on rétablit)
72 + setItems(on ? [...prev, item]
73 + : prev.filter((i) => i.item_id !== item.item_id));
74 + toggleFavorite(on, item).catch(() => setItems(prev));
75 + }, [connected, items]);
76 +
77 + const ids = useMemo(
78 + () => new Set(items.map((i) => i.item_id)), [items]);
79 +
80 + return (
81 + <FavContext.Provider value={{ ready, connected, ids, items, toggle }}>
82 + {children}
83 + </FavContext.Provider>
84 + );
85 +}
86 +
87 +export const useFav = () => useContext(FavContext);
88 +
89 +/** Cœur ♥ — cartes de resto (par défaut) et fiche détail (`big`). */
90 +export function FavButton({ r, big = false }: { r: Restaurant; big?: boolean }) {
91 + const { connected, ids, toggle } = useFav();
92 + const on = ids.has(r.uid);
93 + const label = !connected
94 + ? "Se connecter avec KA ID pour ajouter aux favoris"
95 + : on
96 + ? "Retirer de mes favoris (Mon univers Ka)"
97 + : "Ajouter à mes favoris (Mon univers Ka)";
98 + return (
99 + <button
100 + type="button"
101 + className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`}
102 + aria-label={label}
103 + aria-pressed={on}
104 + title={label}
105 + onClick={(e) => {
106 + e.preventDefault(); // la carte entière est un lien
107 + e.stopPropagation();
108 + toggle(restoFavItem(r));
109 + }}
110 + >
111 + {on ? "♥" : "♡"}
112 + </button>
113 + );
114 +}
modified frontend/src/pages/Favoris.tsx +47 −14
@@ -1,56 +1,89 @@
1 1 // ==============================================================================
2 2 // Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 3 // File: pages/Favoris.tsx
4 −// Desc: Restos favoris du membre connecté (« Se connecter avec KA »).
4 +// Desc: Restos favoris ♥ « Mon univers Ka » du membre connecté — lus au hub
5 +// Groupe KA (groupe-ka.com), magasin central des favoris du groupe :
6 +// les mêmes ♥ suivent le membre sur toutes les plateformes ·Ka.
5 7 // ==============================================================================
6 8 import { useEffect, useState } from "react";
9 +import { Link } from "react-router-dom";
7 10 import { useAccount } from "../account";
8 −import { Restaurant, fetchFavoris } from "../api";
11 +import { Restaurant, fetchFavorites } from "../api";
9 12 import RestoCard from "../components/RestoCard";
13 +import { useFav } from "../favorites";
10 14
11 15 export default function FavorisPage() {
12 − const { me, favs } = useAccount();
16 + const { me } = useAccount();
17 + const { ready, connected, ids, items } = useFav();
13 18 const [restos, setRestos] = useState<Restaurant[]>([]);
14 19 const [loading, setLoading] = useState(true);
15 20
16 21 useEffect(() => {
17 − if (!me) { setLoading(false); return; }
18 − fetchFavoris()
22 + if (!ready) return;
23 + if (!connected) { setLoading(false); return; }
24 + fetchFavorites()
19 25 .then((f) => setRestos(f.restaurants))
20 26 .catch(() => {})
21 27 .finally(() => setLoading(false));
22 − }, [me, favs.size]);
28 + }, [ready, connected]);
23 29
24 − if (!me) {
30 + if (ready && !connected) {
25 31 return (
26 32 <div className="notice container">
27 33 <h2>Connectez-vous pour vos favoris</h2>
28 34 <p>
29 35 <a className="login-btn" href="/api/auth/ka/login" style={{ display: "inline-flex" }}>
30 36 <span className="ka-badge" aria-hidden="true">KA</span>
31 − Se connecter avec KA
37 + Se connecter avec KA ID
32 38 </a>
33 39 </p>
34 − <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p>
40 + <p>
41 + Un seul compte Groupe KA : vos ♥ vous suivent sur Resto·Ka, Lou·Ka,
42 + Food·Ka et toutes les plateformes ·Ka — c'est « Mon univers Ka ».
43 + </p>
35 44 </div>
36 45 );
37 46 }
38 47
48 + // fiches locales encore en favoris (le toggle retire la carte sans recharger)
49 + const shown = restos.filter((r) => ids.has(r.uid));
50 + // favoris du hub sans fiche locale (resto retiré du catalogue) : carte simple
51 + const known = new Set(restos.map((r) => r.uid));
52 + const extras = items.filter((i) => i.item_id && !known.has(i.item_id));
53 +
39 54 return (
40 55 <div className="container sources">
41 − <span className="kicker">Mon compte — {me.ka_id}</span>
56 + <span className="kicker">Mon univers Ka{me?.ka_id ? ` — ${me.ka_id}` : ""}</span>
42 57 <h1>Mes restos favoris</h1>
43 − <p className="sub">Les restos que vous avez épinglés, sur tous vos appareils.</p>
44 − {loading ? (
58 + <p className="sub">
59 + Vos ♥ sont conservés au hub Groupe KA : les mêmes favoris, sur tous vos
60 + appareils et toutes les plateformes ·Ka.
61 + </p>
62 + {loading || !ready ? (
45 63 <p>Chargement…</p>
46 − ) : restos.length === 0 ? (
64 + ) : shown.length === 0 && extras.length === 0 ? (
47 65 <div className="notice">
48 66 <h2>Aucun favori pour l'instant</h2>
49 67 <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p>
50 68 </div>
51 69 ) : (
52 70 <div className="grid">
53 − {restos.map((r) => <RestoCard key={r.uid} r={r} />)}
71 + {shown.map((r) => <RestoCard key={r.uid} r={r} />)}
72 + {extras.map((i) => (
73 + <Link key={i.item_id} className="card"
74 + to={`/resto/${encodeURIComponent(i.item_id)}`}>
75 + <div className="card-img">
76 + {i.image_url
77 + ? <img src={i.image_url} alt={i.title} loading="lazy" />
78 + : <div className="noimg" />}
79 + </div>
80 + <div className="card-body">
81 + {i.price_label && <div className="card-price">{i.price_label}</div>}
82 + <div className="card-title">{i.title}</div>
83 + {i.subtitle && <div className="card-meta"><span>{i.subtitle}</span></div>}
84 + </div>
85 + </Link>
86 + ))}
54 87 </div>
55 88 )}
56 89 </div>
modified frontend/src/pages/Resto.tsx +2 −0
@@ -13,6 +13,7 @@ import {
13 13 fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,
14 14 } from "../api";
15 15 import { IcoCompass } from "../components/Icons";
16 +import { FavButton } from "../favorites";
16 17
17 18 function MenuBlock({ menu }: { menu: Menu }) {
18 19 const [openSections, setOpenSections] = useState<Set<number>>(
@@ -162,6 +163,7 @@ export default function RestoPage() {
162 163 <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" />
163 164 )}
164 165 {resto.price_range && <div className="price">{resto.price_range}</div>}
166 + <FavButton r={resto} big />
165 167 </div>
166 168 <h1>{resto.name}</h1>
167 169 <div className="loc">
modified frontend/src/styles.css +5 −0
@@ -793,6 +793,11 @@ img { display: block; }
793 793 }
794 794 .fav-btn:hover { transform: scale(1.08); }
795 795 .fav-btn.on { background: var(--accent); color: var(--ink); }
796 +/* fiche détail : le cœur dans l'en-tête du panneau, en flux (pas absolu) */
797 +.fav-btn.big {
798 + position: static; flex-shrink: 0; margin-left: auto;
799 + width: 44px; height: 44px; font-size: 21px;
800 +}
796 801
797 802 /* --- Menu déroulant mobile ------------------------------------------------- */
798 803 .menu-btn {
modified restoka/auth.py +5 −46
@@ -12,7 +12,7 @@
12 12 # GET /api/me -> profil (fusionné avec le hub)
13 13 # POST /api/auth/logout -> efface le cookie
14 14 # GET /api/auth/config -> {"ka": bool}
15 −# + FAVORIS : GET /api/favoris, POST /api/favoris/{uid} (toggle).
15 +# (FAVORIS : au hub Groupe KA — voir hubfav.py + routes dans web.py.)
16 16 # Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly.
17 17 # Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL.
18 18 # ==============================================================================
@@ -249,48 +249,7 @@ def me(request: Request):
249 249
250 250
251 251 # -- favoris ------------------------------------------------------------------------
252 −
253 −@router.get("/favoris")
254 −def favoris(request: Request):
255 − """Fiches complètes des restos favoris du membre connecté."""
256 − user = current_user(request)
257 − if user is None:
258 − raise HTTPException(401, "non connecté")
259 − from .web import _menu_summaries, _row_to_dict
260 − con = db.connect()
261 − rows = con.execute(
262 − """SELECT r.* FROM favorites f JOIN restaurants r ON r.uid=f.uid
263 − WHERE f.user_id=? ORDER BY f.ts DESC""", (user["uid"],)).fetchall()
264 − out = [_row_to_dict(r) for r in rows]
265 − menus = _menu_summaries(con, [r["uid"] for r in out])
266 − for r in out:
267 − r["menu_summary"] = menus.get(r["uid"])
268 − con.close()
269 − return {"count": len(out), "restaurants": out,
270 − "uids": [r["uid"] for r in out]}
271 −
272 −
273 −@router.post("/favoris/{uid:path}")
274 −def toggle_favori(uid: str, request: Request):
275 − """Ajoute/retire un resto des favoris. Retourne {favori: bool}."""
276 − user = current_user(request)
277 − if user is None:
278 − raise HTTPException(401, "non connecté")
279 − con = db.connect()
280 − if con.execute("SELECT 1 FROM restaurants WHERE uid=?", (uid,)).fetchone() is None:
281 − con.close()
282 − raise HTTPException(404, "resto introuvable")
283 − existing = con.execute(
284 − "SELECT 1 FROM favorites WHERE user_id=? AND uid=?",
285 − (user["uid"], uid)).fetchone()
286 − if existing:
287 − con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?",
288 − (user["uid"], uid))
289 − fav = False
290 − else:
291 − con.execute("INSERT INTO favorites (user_id, uid, ts) VALUES (?,?,?)",
292 − (user["uid"], uid, time.time()))
293 − fav = True
294 − con.commit()
295 − con.close()
296 − return {"favori": fav}
252 +# Les favoris « Mon univers Ka » vivent au HUB Groupe KA (groupe-ka.com),
253 +# magasin central du groupe — plus AUCUN stockage local. Voir restoka/hubfav.py
254 +# et les routes /api/favorites + /api/favorites/toggle dans restoka/web.py.
255 +# (Les anciennes routes locales /api/favoris ont été retirées le 2026-08-23.)
added restoka/hubfav.py +104 −0
@@ -0,0 +1,104 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/hubfav.py
4 +# Desc: Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le
5 +# MAGASIN CENTRAL des favoris du groupe : Resto·Ka ne stocke rien
6 +# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone :
7 +# un échec remonte à l'appelant) et la liste est lue au hub (GET signé,
8 +# cache mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.
9 +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
10 +# Calqué sur foodka/hubfav.py (implémentation de référence du groupe).
11 +# ==============================================================================
12 +from __future__ import annotations
13 +
14 +import hashlib
15 +import hmac
16 +import os
17 +import threading
18 +import time
19 +
20 +import requests
21 +
22 +CLIENT_ID = "resto-ka"
23 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
24 +CACHE_TTL = 30 # secondes — liste des favoris
25 +TIMEOUT = 6 # secondes
26 +
27 +_cache: dict[str, tuple[float, list[dict]]] = {}
28 +_lock = threading.Lock()
29 +
30 +# champs d'item acceptés -> longueur maximale (troncature défensive)
31 +_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
32 + "price_label": 60, "image_url": 500, "url": 500}
33 +
34 +
35 +def _sig(ka_id: str, ts: int) -> str | None:
36 + """Signature HMAC-SHA256 du hub : hex("resto-ka.<ka_id>.<ts>")."""
37 + secret = os.environ.get("KA_SSO_SECRET")
38 + if not secret:
39 + return None
40 + return hmac.new(secret.encode(),
41 + f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
42 + hashlib.sha256).hexdigest()
43 +
44 +
45 +def linked(ka_id: str | None) -> bool:
46 + """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe)."""
47 + return bool(ka_id) and str(ka_id).startswith("ka-")
48 +
49 +
50 +def clean_item(item: dict) -> dict:
51 + """Ne garde que les champs d'item connus, en chaînes tronquées."""
52 + return {k: str(item.get(k) or "")[:n]
53 + for k, n in _FIELDS.items() if item.get(k)}
54 +
55 +
56 +def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
57 + """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
58 + le hub est le magasin des favoris, l'échec doit remonter à l'appelant."""
59 + ts = int(time.time())
60 + sig = _sig(ka_id, ts)
61 + if not sig or not linked(ka_id) or action not in ("add", "remove"):
62 + return False
63 + try:
64 + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
65 + json={"client_id": CLIENT_ID, "ka_id": ka_id,
66 + "ts": str(ts), "sig": sig,
67 + "action": action, "item": item})
68 + ok = r.status_code == 200
69 + except Exception:
70 + ok = False
71 + if ok:
72 + with _lock:
73 + _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle
74 + return ok
75 +
76 +
77 +def hub_list(ka_id: str) -> list[dict] | None:
78 + """Favoris Resto·Ka du membre, lus au hub (cache mémoire 30 s).
79 + [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache)."""
80 + if not linked(ka_id):
81 + return [] # compte legacy non relié au hub
82 + now = time.time()
83 + with _lock:
84 + hit = _cache.get(ka_id)
85 + if hit and now - hit[0] < CACHE_TTL:
86 + return hit[1]
87 + sig = _sig(ka_id, int(now))
88 + if not sig:
89 + return None
90 + try:
91 + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
92 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
93 + "ts": int(now), "sig": sig})
94 + if r.status_code != 200:
95 + return None
96 + favs = r.json().get("favorites") or []
97 + if not isinstance(favs, list):
98 + return None
99 + except Exception:
100 + return None
101 + favs = [f for f in favs if isinstance(f, dict)]
102 + with _lock:
103 + _cache[ka_id] = (now, favs)
104 + return favs
modified restoka/web.py +51 −2
@@ -13,13 +13,13 @@ import statistics
13 13 import threading
14 14 from pathlib import Path
15 15
16 −from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query
16 +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request
17 17 from fastapi.middleware.cors import CORSMiddleware
18 18 from fastapi.middleware.gzip import GZipMiddleware
19 19 from fastapi.responses import FileResponse, Response
20 20 from fastapi.staticfiles import StaticFiles
21 21
22 −from . import db, ingest
22 +from . import db, hubfav, ingest
23 23 from .normalize import PRICE_CONTEXTS
24 24 from .regions import REGIONS
25 25
@@ -535,6 +535,55 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
535 535 return {"status": "démarré", "source": source or "toutes"}
536 536
537 537
538 +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----
539 +@app.get("/api/favorites")
540 +def favorites(request: Request):
541 + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage
542 + local). Retourne aussi les fiches locales correspondantes (ordre du hub)
543 + pour l'affichage de la page /favoris."""
544 + user = auth.current_user(request)
545 + if not user:
546 + raise HTTPException(401, "Connexion KA ID requise")
547 + items = hubfav.hub_list(user.get("ka_id") or "")
548 + if items is None:
549 + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")
550 + ids = [i["item_id"] for i in items if i.get("item_id")]
551 + restos: list[dict] = []
552 + if ids:
553 + con = db.connect()
554 + marks = ",".join("?" * len(ids))
555 + rows = {r["uid"]: _row_to_dict(r) for r in con.execute(
556 + f"SELECT * FROM restaurants WHERE uid IN ({marks})",
557 + ids).fetchall()}
558 + menus = _menu_summaries(con, list(rows))
559 + con.close()
560 + for uid in ids:
561 + r = rows.get(uid)
562 + if r is not None:
563 + r["menu_summary"] = menus.get(uid)
564 + restos.append(r)
565 + return {"ids": ids, "items": items, "restaurants": restos}
566 +
567 +
568 +@app.post("/api/favorites/toggle")
569 +def toggle_favorite(request: Request, body: dict = Body(...)):
570 + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub
571 + Groupe KA de façon synchrone : le hub est la seule source de vérité."""
572 + user = auth.current_user(request)
573 + if not user:
574 + raise HTTPException(401, "Connexion KA ID requise")
575 + ka_id = user.get("ka_id") or ""
576 + if not hubfav.linked(ka_id):
577 + raise HTTPException(403, "Compte non relié au hub Groupe KA")
578 + on = bool(body.get("on"))
579 + item = hubfav.clean_item(body.get("item") or {})
580 + if not item.get("item_id"):
581 + raise HTTPException(422, "item.item_id requis")
582 + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
583 + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
584 + return {"ok": True, "on": on}
585 +
586 +
538 587 # --- Frontend React (build Vite) --------------------------------------------
539 588 if FRONTEND_DIST.exists():
540 589 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),
541 590