SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

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 1 mo ago (Aug 23, 2026) parent bb52548

9 changed files +427 −15

modified frontend/src/App.tsx +7 −2
@@ -18,6 +18,8 @@ import Stats from "./pages/Stats";
18 18 import Contact from "./pages/Contact";
19 19 import Employeurs from "./pages/Employeurs";
20 20 import VillePage, { VillesPage } from "./pages/Ville";
21 +import FavorisPage from "./pages/Favoris";
22 +import { FavProvider } from "./favorites";
21 23 import GroupeKaBadge from "./ka/GroupeKaBadge";
22 24 import KaFooter from "./ka/KaFooter";
23 25
@@ -69,6 +71,7 @@ function KaAuth() {
69 71 const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [
70 72 { to: "/", label: "Offres", end: true },
71 73 { to: "/carte", label: "Carte" },
74 + { to: "/favoris", label: "Favoris ♥" },
72 75 { to: "/sources", label: "Employeurs" },
73 76 { to: "/stats", label: "Stats" },
74 77 { to: "/employeurs", label: "Publier" },
@@ -113,7 +116,7 @@ export default function App() {
113 116 }, [menuOpen]);
114 117
115 118 return (
116 − <>
119 + <FavProvider>
117 120 <header className={menuOpen ? "site menu-open" : "site"}>
118 121 <div className="container">
119 122 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink>
@@ -169,6 +172,7 @@ export default function App() {
169 172 </Suspense>
170 173 } />
171 174 <Route path="/emploi/:uid" element={<JobPage />} />
175 + <Route path="/favoris" element={<FavorisPage />} />
172 176 <Route path="/villes" element={<VillesPage />} />
173 177 <Route path="/ville/:ville" element={<VillePage />} />
174 178 <Route path="/sources" element={<Sources />} />
@@ -185,6 +189,7 @@ export default function App() {
185 189 boîte noire.
186 190 </span>
187 191 <NavLink to="/">Offres</NavLink>
192 + <NavLink to="/favoris">Mes favoris</NavLink>
188 193 <NavLink to="/villes">Villes</NavLink>
189 194 <NavLink to="/carte">Carte</NavLink>
190 195 <NavLink to="/sources">Employeurs</NavLink>
@@ -194,6 +199,6 @@ export default function App() {
194 199 </div>
195 200 </div>
196 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 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/components/JobCard.tsx
7 − * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta)
8 − * Créé : 2026-08-17 Modifié : 2026-08-17
7 + * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,
8 + * cœur ♥ favoris « Mon univers Ka »)
9 + * Créé : 2026-08-17 Modifié : 2026-08-23
9 10 * =============================================================================
10 11 */
11 12 import { Link } from "react-router-dom";
12 13 import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api";
14 +import { FavButton } from "../favorites";
13 15
14 16 export default function JobCard({ job }: { job: Job }) {
15 17 const salary = formatSalary(job);
16 18 return (
17 19 <Link to={`/emploi/${job.uid}`} className="job-card">
20 + <FavButton job={job} />
18 21 <h3>{displayTitle(job)}</h3>
19 22 <span className="employer">{job.employer}</span>
20 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 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/pages/Job.tsx
7 − * Rôle : Fiche d'une offre — faits, description, lien vers l'offre originale
8 − * Créé : 2026-08-17 Modifié : 2026-08-22
7 + * Rôle : Fiche d'une offre — faits, description, lien vers l'offre
8 + * originale, cœur ♥ favoris « Mon univers Ka »
9 + * Créé : 2026-08-17 Modifié : 2026-08-23
9 10 * =============================================================================
10 11 */
11 12 import { useEffect, useState } from "react";
12 13 import { Link, useParams } from "react-router-dom";
13 14 import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api";
15 +import { FavButton } from "../favorites";
14 16
15 17 export default function JobPage() {
16 18 const { uid = "" } = useParams();
@@ -44,7 +46,8 @@ export default function JobPage() {
44 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 51 </div>
49 52 <p style={{ margin: "6px 0 0" }}>
50 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 352 .bar-row { grid-template-columns: 120px 1fr 44px; }
353 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 142 @router.get("/auth/ka/login")
143 −def ka_login(request: Request):
144 − """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""
143 +def ka_login(request: Request, next: str = ""):
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 147 if not _ka_secret():
146 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 153 params = {
149 154 "client_id": CLIENT_ID,
150 155 "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
@@ -157,7 +162,8 @@ def ka_login(request: Request):
157 162 def ka_callback(request: Request, ka_token: str = "", state: str = ""):
158 163 """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,
159 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 167 raise HTTPException(400, "state invalide ou expiré")
162 168 claims = _verify_ka_token(ka_token)
163 169 if claims is None:
@@ -197,7 +203,9 @@ def ka_callback(request: Request, ka_token: str = "", state: str = ""):
197 203 "picture": picture,
198 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 209 resp.set_cookie(
202 210 COOKIE, session,
203 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 15 import time
16 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 19 from fastapi.middleware.cors import CORSMiddleware
20 20 from fastapi.middleware.gzip import GZipMiddleware
21 21 from fastapi.responses import FileResponse, Response
22 22 from fastapi.staticfiles import StaticFiles
23 23 from pydantic import BaseModel, Field
24 24
25 −from . import auth, db, ingest
25 +from . import auth, db, hubfav, ingest
26 26 from .dedup import AGGREGATORS
27 27 from .schema import JobPosting
28 28
@@ -529,6 +529,39 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
529 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 565 # --- Frontend (build Vite) ---------------------------------------------------
533 566 if FRONTEND_DIST.exists():
534 567 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
@@ -554,7 +587,7 @@ if FRONTEND_DIST.exists():
554 587 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie
555 588 # index.html avec un statut 404 (pas de soft-404 pour les moteurs)
556 589 _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs",
557 − "confidentialite", "conditions"}
590 + "favoris", "confidentialite", "conditions"}
558 591 _CLIENT_PREFIXES = ("emploi/",)
559 592
560 593 @app.get("/{full_path:path}")
561 594