SPB Git

spb/job-ka

Public

Favoris unifies « Mon univers Ka » (hub KA ID) : coeur sur cartes et fiche, page /favoris

- jobka/hubfav.py : client du magasin central de favoris du hub Groupe KA
  (HMAC signe, toggle synchrone, lecture cachee 30 s, zero stockage local)
- jobka/web.py : GET /api/favorites + POST /api/favorites/toggle (session
  KA ID requise, 401 sinon) ; route SPA /favoris
- jobka/auth.py : parametre next (chemin local, transporte dans le state
  signe) pour revenir a la page courante apres la connexion KA ID
- frontend : favorites.tsx (FavProvider + FavButton), coeur sur JobCard et
  la fiche /emploi/:uid, page /favoris + liens nav et prefooter, CSS fav-btn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent bb52548 Browse files

9 changed files +427 −15

modified frontend/src/App.tsx +7 −2
@@ -18,6 +18,8 @@ import Stats from "./pages/Stats";
18 import Contact from "./pages/Contact"; 18 import Contact from "./pages/Contact";
19 import Employeurs from "./pages/Employeurs"; 19 import Employeurs from "./pages/Employeurs";
20 import VillePage, { VillesPage } from "./pages/Ville"; 20 import VillePage, { VillesPage } from "./pages/Ville";
21+import FavorisPage from "./pages/Favoris";
22+import { FavProvider } from "./favorites";
21 import GroupeKaBadge from "./ka/GroupeKaBadge"; 23 import GroupeKaBadge from "./ka/GroupeKaBadge";
22 import KaFooter from "./ka/KaFooter"; 24 import KaFooter from "./ka/KaFooter";
23 25
@@ -69,6 +71,7 @@ function KaAuth() {
69 const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [ 71 const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [
70 { to: "/", label: "Offres", end: true }, 72 { to: "/", label: "Offres", end: true },
71 { to: "/carte", label: "Carte" }, 73 { to: "/carte", label: "Carte" },
74+ { to: "/favoris", label: "Favoris ♥" },
72 { to: "/sources", label: "Employeurs" }, 75 { to: "/sources", label: "Employeurs" },
73 { to: "/stats", label: "Stats" }, 76 { to: "/stats", label: "Stats" },
74 { to: "/employeurs", label: "Publier" }, 77 { to: "/employeurs", label: "Publier" },
@@ -113,7 +116,7 @@ export default function App() {
113 }, [menuOpen]); 116 }, [menuOpen]);
114 117
115 return ( 118 return (
116− <> 119+ <FavProvider>
117 <header className={menuOpen ? "site menu-open" : "site"}> 120 <header className={menuOpen ? "site menu-open" : "site"}>
118 <div className="container"> 121 <div className="container">
119 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink> 122 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink>
@@ -169,6 +172,7 @@ export default function App() {
169 </Suspense> 172 </Suspense>
170 } /> 173 } />
171 <Route path="/emploi/:uid" element={<JobPage />} /> 174 <Route path="/emploi/:uid" element={<JobPage />} />
175+ <Route path="/favoris" element={<FavorisPage />} />
172 <Route path="/villes" element={<VillesPage />} /> 176 <Route path="/villes" element={<VillesPage />} />
173 <Route path="/ville/:ville" element={<VillePage />} /> 177 <Route path="/ville/:ville" element={<VillePage />} />
174 <Route path="/sources" element={<Sources />} /> 178 <Route path="/sources" element={<Sources />} />
@@ -185,6 +189,7 @@ export default function App() {
185 boîte noire. 189 boîte noire.
186 </span> 190 </span>
187 <NavLink to="/">Offres</NavLink> 191 <NavLink to="/">Offres</NavLink>
192+ <NavLink to="/favoris">Mes favoris</NavLink>
188 <NavLink to="/villes">Villes</NavLink> 193 <NavLink to="/villes">Villes</NavLink>
189 <NavLink to="/carte">Carte</NavLink> 194 <NavLink to="/carte">Carte</NavLink>
190 <NavLink to="/sources">Employeurs</NavLink> 195 <NavLink to="/sources">Employeurs</NavLink>
@@ -194,6 +199,6 @@ export default function App() {
194 </div> 199 </div>
195 </div> 200 </div>
196 <KaFooter siteId="job-ka" /> 201 <KaFooter siteId="job-ka" />
197− </> 202+ </FavProvider>
198 ); 203 );
199 } 204 }
modified frontend/src/components/JobCard.tsx +5 −2
@@ -4,17 +4,20 @@
4 * Auteur : Simon-Pierre Boucher 4 * Auteur : Simon-Pierre Boucher
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/components/JobCard.tsx 6 * Fichier : frontend/src/components/JobCard.tsx
7− * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta) 7+ * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,
8− * Créé : 2026-08-17 Modifié : 2026-08-17 8+ * cœur ♥ favoris « Mon univers Ka »)
9+ * Créé : 2026-08-17 Modifié : 2026-08-23
9 * ============================================================================= 10 * =============================================================================
10 */ 11 */
11 import { Link } from "react-router-dom"; 12 import { Link } from "react-router-dom";
12 import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; 13 import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api";
14+import { FavButton } from "../favorites";
13 15
14 export default function JobCard({ job }: { job: Job }) { 16 export default function JobCard({ job }: { job: Job }) {
15 const salary = formatSalary(job); 17 const salary = formatSalary(job);
16 return ( 18 return (
17 <Link to={`/emploi/${job.uid}`} className="job-card"> 19 <Link to={`/emploi/${job.uid}`} className="job-card">
20+ <FavButton job={job} />
18 <h3>{displayTitle(job)}</h3> 21 <h3>{displayTitle(job)}</h3>
19 <span className="employer">{job.employer}</span> 22 <span className="employer">{job.employer}</span>
20 {job.city ? <span className="muted"> — {job.city}</span> : null} 23 {job.city ? <span className="muted"> — {job.city}</span> : null}
added frontend/src/favorites.tsx +139 −0
@@ -0,0 +1,139 @@
1+/**
2+ * =============================================================================
3+ * Job·Ka — Groupe KA
4+ * Auteur : Simon-Pierre Boucher
5+ * Contact : contact@spboucher.ai
6+ * Fichier : frontend/src/favorites.tsx
7+ * Rôle : Favoris ♥ « Mon univers Ka » — le hub Groupe KA est le magasin
8+ * central des favoris du groupe (aucun stockage navigateur) :
9+ * lecture/écriture via l'API locale /api/favorites qui relaie au
10+ * hub. FavProvider charge la liste une fois ; FavButton = cœur sur
11+ * les cartes d'offres et la fiche. Non connecté → redirection vers
12+ * la connexion KA ID avec retour à la page courante.
13+ * Créé : 2026-08-23 Modifié : 2026-08-23
14+ * =============================================================================
15+ */
16+import {
17+ ReactNode, createContext, useCallback, useContext,
18+ useEffect, useMemo, useState,
19+} from "react";
20+import { displayTitle, formatSalary, Job } from "./api";
21+
22+/** Item de favori tel que poussé au hub Groupe KA. */
23+export interface FavItem {
24+ item_id: string;
25+ title: string;
26+ subtitle?: string;
27+ price_label?: string;
28+ image_url?: string;
29+ url?: string;
30+}
31+
32+interface FavState {
33+ ready: boolean; // premier chargement terminé
34+ connected: boolean; // session KA ID active
35+ ids: Set<string>; // uid des offres en favoris
36+ items: FavItem[];
37+ toggle: (item: FavItem) => void;
38+}
39+
40+const FavContext = createContext<FavState>({
41+ ready: false, connected: false, ids: new Set(), items: [], toggle: () => {},
42+});
43+
44+/** Offre -> item de favori du hub (titre du poste, employeur · ville, salaire). */
45+export function jobFavItem(job: Job): FavItem {
46+ return {
47+ item_id: job.uid,
48+ title: displayTitle(job) || "Offre d'emploi",
49+ subtitle: [job.employer, job.city || job.region]
50+ .filter(Boolean).join(" · "),
51+ price_label: formatSalary(job) || "",
52+ image_url: job.company_logo || "",
53+ url: `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`,
54+ };
55+}
56+
57+/** Redirection vers la connexion KA ID, retour à la page courante. */
58+export function goLogin() {
59+ const next = encodeURIComponent(
60+ window.location.pathname + window.location.search);
61+ window.location.assign(`/api/auth/ka/login?next=${next}`);
62+}
63+
64+export function FavProvider({ children }: { children: ReactNode }) {
65+ const [ready, setReady] = useState(false);
66+ const [connected, setConnected] = useState(false);
67+ const [items, setItems] = useState<FavItem[]>([]);
68+
69+ useEffect(() => {
70+ fetch("/api/favorites", { credentials: "same-origin" })
71+ .then(async (res) => {
72+ if (res.status === 401) return; // pas connecté : cœurs vides
73+ if (!res.ok) throw new Error(`API ${res.status}`);
74+ const data = (await res.json()) as { items?: FavItem[] };
75+ setConnected(true);
76+ setItems(data.items ?? []);
77+ })
78+ .catch(() => {}) // hub muet : cœurs vides
79+ .finally(() => setReady(true));
80+ }, []);
81+
82+ const toggle = useCallback((item: FavItem) => {
83+ if (!connected) {
84+ goLogin(); // non connecté : connexion KA ID puis retour
85+ return;
86+ }
87+ const prev = items;
88+ const on = !prev.some((i) => i.item_id === item.item_id);
89+ // optimiste : le hub confirme (sinon on rétablit)
90+ setItems(on ? [...prev, item]
91+ : prev.filter((i) => i.item_id !== item.item_id));
92+ fetch("/api/favorites/toggle", {
93+ method: "POST",
94+ credentials: "same-origin",
95+ headers: { "Content-Type": "application/json" },
96+ body: JSON.stringify({ on, item }),
97+ })
98+ .then((res) => { if (!res.ok) throw new Error(`API ${res.status}`); })
99+ .catch(() => setItems(prev));
100+ }, [connected, items]);
101+
102+ const ids = useMemo(
103+ () => new Set(items.map((i) => i.item_id)), [items]);
104+
105+ return (
106+ <FavContext.Provider value={{ ready, connected, ids, items, toggle }}>
107+ {children}
108+ </FavContext.Provider>
109+ );
110+}
111+
112+export const useFav = () => useContext(FavContext);
113+
114+/** Cœur ♥ — cartes d'offres (par défaut) et fiche d'offre (`big`). */
115+export function FavButton({ job, big = false }: { job: Job; big?: boolean }) {
116+ const { ids, toggle } = useFav();
117+ const on = ids.has(job.uid);
118+ const label = on
119+ ? "Retirer de mes favoris (Mon univers Ka)"
120+ : "Ajouter à mes favoris (Mon univers Ka)";
121+ return (
122+ <button
123+ type="button"
124+ className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`}
125+ aria-label={label}
126+ aria-pressed={on}
127+ title={label}
128+ onClick={(e) => {
129+ e.preventDefault(); // la carte entière est un lien
130+ e.stopPropagation();
131+ toggle(jobFavItem(job));
132+ }}
133+ >
134+ <svg viewBox="0 0 24 24" aria-hidden="true">
135+ <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" />
136+ </svg>
137+ </button>
138+ );
139+}
added frontend/src/pages/Favoris.tsx +94 −0
@@ -0,0 +1,94 @@
1+/**
2+ * =============================================================================
3+ * Job·Ka — Groupe KA
4+ * Auteur : Simon-Pierre Boucher
5+ * Contact : contact@spboucher.ai
6+ * Fichier : frontend/src/pages/Favoris.tsx
7+ * Rôle : Mes favoris ♥ « Mon univers Ka » — offres mises en favoris,
8+ * lues au hub Groupe KA (magasin central, aucun stockage local).
9+ * Créé : 2026-08-23 Modifié : 2026-08-23
10+ * =============================================================================
11+ */
12+import { Link } from "react-router-dom";
13+import { FavItem, goLogin, useFav } from "../favorites";
14+
15+/** /emploi/<uid> local si l'url du favori pointe vers job-ka.com. */
16+function localPath(f: FavItem): string | null {
17+ if (!f.url) return `/emploi/${encodeURIComponent(f.item_id)}`;
18+ try {
19+ const u = new URL(f.url);
20+ if (u.hostname.endsWith("job-ka.com")) return u.pathname + u.search;
21+ } catch { /* url invalide : lien externe ignoré */ }
22+ return null;
23+}
24+
25+function FavCard({ f }: { f: FavItem }) {
26+ const { toggle } = useFav();
27+ const path = localPath(f);
28+ const inner = (
29+ <>
30+ <h3>{f.title}</h3>
31+ {f.subtitle && <span className="employer">{f.subtitle}</span>}
32+ <div className="meta">
33+ {f.price_label && <span className="badge salary">{f.price_label}</span>}
34+ </div>
35+ <button
36+ type="button"
37+ className="fav-btn on"
38+ aria-label="Retirer de mes favoris (Mon univers Ka)"
39+ title="Retirer de mes favoris (Mon univers Ka)"
40+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggle(f); }}
41+ >
42+ <svg viewBox="0 0 24 24" aria-hidden="true">
43+ <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" />
44+ </svg>
45+ </button>
46+ </>
47+ );
48+ return path
49+ ? <Link to={path} className="job-card fav-card">{inner}</Link>
50+ : <a href={f.url} className="job-card fav-card" target="_blank" rel="noopener noreferrer">{inner}</a>;
51+}
52+
53+export default function FavorisPage() {
54+ const { ready, connected, items } = useFav();
55+
56+ return (
57+ <main className="container">
58+ <h1>Mes favoris</h1>
59+ <p className="muted">
60+ Vos offres d'emploi mises en favoris ♥ — synchronisées avec votre
61+ compte <b>Mon univers Ka</b> sur toutes les plateformes du Groupe KA.
62+ </p>
63+ {!ready && <div className="empty">Chargement…</div>}
64+ {ready && !connected && (
65+ <div className="empty">
66+ <p>Connectez-vous avec votre KA ID pour retrouver vos favoris.</p>
67+ <button type="button" className="btn" onClick={goLogin}>
68+ Se connecter avec KA ID
69+ </button>
70+ </div>
71+ )}
72+ {ready && connected && items.length === 0 && (
73+ <div className="empty">
74+ <p>Aucun favori pour l'instant — touchez le cœur ♥ sur une offre
75+ pour la retrouver ici.</p>
76+ <Link className="btn" to="/">Parcourir les offres</Link>
77+ </div>
78+ )}
79+ {ready && connected && items.length > 0 && (
80+ <div className="job-list">
81+ {items.map((f) => <FavCard key={f.item_id} f={f} />)}
82+ </div>
83+ )}
84+ {ready && connected && (
85+ <p className="muted" style={{ marginTop: 24 }}>
86+ Tous vos favoris Groupe KA (logements, propriétés, produits…) :{" "}
87+ <a href="https://www.groupe-ka.com/compte" target="_blank" rel="noopener noreferrer">
88+ Mon univers Ka ↗
89+ </a>
90+ </p>
91+ )}
92+ </main>
93+ );
94+}
modified frontend/src/pages/Job.tsx +6 −3
@@ -4,13 +4,15 @@
4 * Auteur : Simon-Pierre Boucher 4 * Auteur : Simon-Pierre Boucher
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/pages/Job.tsx 6 * Fichier : frontend/src/pages/Job.tsx
7− * Rôle : Fiche d'une offre — faits, description, lien vers l'offre originale 7+ * Rôle : Fiche d'une offre — faits, description, lien vers l'offre
8− * Créé : 2026-08-17 Modifié : 2026-08-22 8+ * originale, cœur ♥ favoris « Mon univers Ka »
9+ * Créé : 2026-08-17 Modifié : 2026-08-23
9 * ============================================================================= 10 * =============================================================================
10 */ 11 */
11 import { useEffect, useState } from "react"; 12 import { useEffect, useState } from "react";
12 import { Link, useParams } from "react-router-dom"; 13 import { Link, useParams } from "react-router-dom";
13 import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api"; 14 import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api";
15+import { FavButton } from "../favorites";
14 16
15 export default function JobPage() { 17 export default function JobPage() {
16 const { uid = "" } = useParams(); 18 const { uid = "" } = useParams();
@@ -44,7 +46,8 @@ export default function JobPage() {
44 onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} 46 onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
45 /> 47 />
46 )} 48 )}
47− <h1 style={{ margin: 0 }}>{displayTitle(job)}</h1> 49+ <h1 style={{ margin: 0, flex: 1 }}>{displayTitle(job)}</h1>
50+ <FavButton job={job} big />
48 </div> 51 </div>
49 <p style={{ margin: "6px 0 0" }}> 52 <p style={{ margin: "6px 0 0" }}>
50 <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> 53 <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span>
modified frontend/src/styles.css +21 −0
@@ -352,3 +352,24 @@ table.data tr:last-child td { border-bottom: none; }
352 .bar-row { grid-template-columns: 120px 1fr 44px; } 352 .bar-row { grid-template-columns: 120px 1fr 44px; }
353 .sheet { padding: 20px; } 353 .sheet { padding: 20px; }
354 } 354 }
355+
356+/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */
357+.job-card { position: relative; }
358+.job-card h3 { padding-right: 48px; }
359+.fav-btn {
360+ position: absolute; right: 14px; top: 14px; z-index: 2;
361+ width: 40px; height: 40px; padding: 0;
362+ display: inline-flex; align-items: center; justify-content: center;
363+ border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;
364+ background: rgba(255, 255, 255, 0.95); color: var(--ink);
365+ transition: transform 0.12s ease, background 0.12s ease;
366+}
367+.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
368+.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); }
369+.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }
370+.fav-btn.on svg { fill: currentColor; }
371+/* fiche d'offre : le cœur à droite du titre */
372+.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; }
373+.fav-btn.big svg { width: 21px; height: 21px; }
374+/* page /favoris */
375+.fav-card { padding-right: 64px; }
modified jobka/auth.py +13 −5
@@ -140,11 +140,16 @@ def auth_config():
140 140
141 141
142 @router.get("/auth/ka/login") 142 @router.get("/auth/ka/login")
143−def ka_login(request: Request): 143+def ka_login(request: Request, next: str = ""):
144− """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" 144+ """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).
145+ `next` (chemin local seulement) = page de retour après connexion —
146+ transporté dans le state signé, donc infalsifiable."""
145 if not _ka_secret(): 147 if not _ka_secret():
146 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") 148 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
147− state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) 149+ # anti open-redirect : uniquement un chemin local ("/…", pas "//…")
150+ nxt = next if next.startswith("/") and not next.startswith("//") else ""
151+ state = _sign({"n": secrets.token_urlsafe(12), "next": nxt,
152+ "exp": time.time() + 600})
148 params = { 153 params = {
149 "client_id": CLIENT_ID, 154 "client_id": CLIENT_ID,
150 "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", 155 "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
@@ -157,7 +162,8 @@ def ka_login(request: Request):
157 def ka_callback(request: Request, ka_token: str = "", state: str = ""): 162 def ka_callback(request: Request, ka_token: str = "", state: str = ""):
158 """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local, 163 """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,
159 pose le cookie de session Job·Ka.""" 164 pose le cookie de session Job·Ka."""
160− if not ka_token or _verify(state) is None: 165+ st = _verify(state) if ka_token else None
166+ if st is None:
161 raise HTTPException(400, "state invalide ou expiré") 167 raise HTTPException(400, "state invalide ou expiré")
162 claims = _verify_ka_token(ka_token) 168 claims = _verify_ka_token(ka_token)
163 if claims is None: 169 if claims is None:
@@ -197,7 +203,9 @@ def ka_callback(request: Request, ka_token: str = "", state: str = ""):
197 "picture": picture, 203 "picture": picture,
198 "exp": time.time() + SESSION_DAYS * 86400, 204 "exp": time.time() + SESSION_DAYS * 86400,
199 }) 205 })
200− resp = RedirectResponse("/?login=ok") 206+ nxt = st.get("next") or ""
207+ resp = RedirectResponse(nxt if nxt.startswith("/") and not nxt.startswith("//")
208+ else "/?login=ok")
201 resp.set_cookie( 209 resp.set_cookie(
202 COOKIE, session, 210 COOKIE, session,
203 max_age=SESSION_DAYS * 86400, 211 max_age=SESSION_DAYS * 86400,
added jobka/hubfav.py +106 −0
@@ -0,0 +1,106 @@
1+# =============================================================================
2+# Job·Ka — Groupe KA
3+# Auteur : Simon-Pierre Boucher
4+# Contact : contact@spboucher.ai
5+# Fichier : jobka/hubfav.py
6+# Rôle : Favoris ♥ « Mon univers Ka » — le hub Groupe KA (groupe-ka.com)
7+# est le MAGASIN CENTRAL des favoris du groupe : Job·Ka ne stocke rien
8+# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un
9+# échec remonte à l'appelant) et la liste est lue au hub (GET signé, cache
10+# mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.
11+# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
12+# Créé : 2026-08-23 Modifié : 2026-08-23
13+# =============================================================================
14+from __future__ import annotations
15+
16+import hashlib
17+import hmac
18+import os
19+import threading
20+import time
21+
22+import requests
23+
24+CLIENT_ID = "job-ka"
25+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
26+CACHE_TTL = 30 # secondes — liste des favoris
27+TIMEOUT = 6 # secondes
28+
29+_cache: dict[str, tuple[float, list[dict]]] = {}
30+_lock = threading.Lock()
31+
32+# champs d'item acceptés -> longueur maximale (troncature défensive)
33+_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
34+ "price_label": 60, "image_url": 500, "url": 500}
35+
36+
37+def _sig(ka_id: str, ts: int) -> str | None:
38+ """Signature HMAC-SHA256 du hub : hex("job-ka.<ka_id>.<ts>")."""
39+ secret = os.environ.get("KA_SSO_SECRET")
40+ if not secret:
41+ return None
42+ return hmac.new(secret.encode(),
43+ f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
44+ hashlib.sha256).hexdigest()
45+
46+
47+def linked(ka_id: str | None) -> bool:
48+ """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe)."""
49+ return bool(ka_id) and str(ka_id).startswith("ka-")
50+
51+
52+def clean_item(item: dict) -> dict:
53+ """Ne garde que les champs d'item connus, en chaînes tronquées."""
54+ return {k: str(item.get(k) or "")[:n]
55+ for k, n in _FIELDS.items() if item.get(k)}
56+
57+
58+def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
59+ """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
60+ le hub est le magasin des favoris, l'échec doit remonter à l'appelant."""
61+ ts = int(time.time())
62+ sig = _sig(ka_id, ts)
63+ if not sig or not linked(ka_id) or action not in ("add", "remove"):
64+ return False
65+ try:
66+ r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
67+ json={"client_id": CLIENT_ID, "ka_id": ka_id,
68+ "ts": str(ts), "sig": sig,
69+ "action": action, "item": item})
70+ ok = r.status_code == 200
71+ except Exception:
72+ ok = False
73+ if ok:
74+ with _lock:
75+ _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle
76+ return ok
77+
78+
79+def hub_list(ka_id: str) -> list[dict] | None:
80+ """Favoris Job·Ka du membre, lus au hub (cache mémoire 30 s).
81+ [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache)."""
82+ if not linked(ka_id):
83+ return [] # compte legacy non relié au hub
84+ now = time.time()
85+ with _lock:
86+ hit = _cache.get(ka_id)
87+ if hit and now - hit[0] < CACHE_TTL:
88+ return hit[1]
89+ sig = _sig(ka_id, int(now))
90+ if not sig:
91+ return None
92+ try:
93+ r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
94+ params={"client_id": CLIENT_ID, "ka_id": ka_id,
95+ "ts": int(now), "sig": sig})
96+ if r.status_code != 200:
97+ return None
98+ favs = r.json().get("favorites") or []
99+ if not isinstance(favs, list):
100+ return None
101+ except Exception:
102+ return None
103+ favs = [f for f in favs if isinstance(f, dict)]
104+ with _lock:
105+ _cache[ka_id] = (now, favs)
106+ return favs
modified jobka/web.py +36 −3
@@ -15,14 +15,14 @@ import threading
15 import time 15 import time
16 from pathlib import Path 16 from pathlib import Path
17 17
18−from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query 18+from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request
19 from fastapi.middleware.cors import CORSMiddleware 19 from fastapi.middleware.cors import CORSMiddleware
20 from fastapi.middleware.gzip import GZipMiddleware 20 from fastapi.middleware.gzip import GZipMiddleware
21 from fastapi.responses import FileResponse, Response 21 from fastapi.responses import FileResponse, Response
22 from fastapi.staticfiles import StaticFiles 22 from fastapi.staticfiles import StaticFiles
23 from pydantic import BaseModel, Field 23 from pydantic import BaseModel, Field
24 24
25−from . import auth, db, ingest 25+from . import auth, db, hubfav, ingest
26 from .dedup import AGGREGATORS 26 from .dedup import AGGREGATORS
27 from .schema import JobPosting 27 from .schema import JobPosting
28 28
@@ -529,6 +529,39 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
529 return {"status": "démarré", "source": source or "toutes"} 529 return {"status": "démarré", "source": source or "toutes"}
530 530
531 531
532+# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------
533+@app.get("/api/favorites")
534+def favorites(request: Request):
535+ """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""
536+ user = auth.current_user(request)
537+ if not user:
538+ raise HTTPException(401, "Connexion KA ID requise")
539+ items = hubfav.hub_list(user.get("ka_id") or "")
540+ if items is None:
541+ raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")
542+ return {"ids": [i["item_id"] for i in items if i.get("item_id")],
543+ "items": items}
544+
545+
546+@app.post("/api/favorites/toggle")
547+def toggle_favorite(request: Request, body: dict = Body(...)):
548+ """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub
549+ Groupe KA de façon synchrone : le hub est la seule source de vérité."""
550+ user = auth.current_user(request)
551+ if not user:
552+ raise HTTPException(401, "Connexion KA ID requise")
553+ ka_id = user.get("ka_id") or ""
554+ if not hubfav.linked(ka_id):
555+ raise HTTPException(403, "Compte non relié au hub Groupe KA")
556+ on = bool(body.get("on"))
557+ item = hubfav.clean_item(body.get("item") or {})
558+ if not item.get("item_id"):
559+ raise HTTPException(422, "item.item_id requis")
560+ if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
561+ raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
562+ return {"ok": True, "on": on}
563+
564+
532 # --- Frontend (build Vite) --------------------------------------------------- 565 # --- Frontend (build Vite) ---------------------------------------------------
533 if FRONTEND_DIST.exists(): 566 if FRONTEND_DIST.exists():
534 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") 567 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
@@ -554,7 +587,7 @@ if FRONTEND_DIST.exists():
554 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie 587 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie
555 # index.html avec un statut 404 (pas de soft-404 pour les moteurs) 588 # index.html avec un statut 404 (pas de soft-404 pour les moteurs)
556 _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs", 589 _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs",
557− "confidentialite", "conditions"} 590+ "favoris", "confidentialite", "conditions"}
558 _CLIENT_PREFIXES = ("emploi/",) 591 _CLIENT_PREFIXES = ("emploi/",)
559 592
560 @app.get("/{full_path:path}") 593 @app.get("/{full_path:path}")
561 594
Menu

Theme