Badge « Offre directe » + stats directes/portails + canal de dépôt direct employeurs
- is_direct (source hors AGGREGATORS) exposé sur /api/jobs, /api/jobs/{uid}
et jobs.geojson ; badge sobre sur les cartes d offres (frontend rebuild)
- /api/stats : direct_jobs, direct_employers, direct_sources, portal_jobs,
duplicates_hidden ; KPI « Offres directes employeur » au tableau de bord
- POST /api/employeurs/offres : dépôt direct validé (pydantic + pot de miel),
normalisé par JobPosting.finalize(), stocké source=depot-direct INACTIF en
attente de modération ; page /employeurs (formulaire + explications)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +311 −1
modified
frontend/src/App.tsx
+4 −0
@@ -16,6 +16,7 @@ import JobPage from "./pages/Job"; | ||
| 16 | 16 | import Sources from "./pages/Sources"; |
| 17 | 17 | import Stats from "./pages/Stats"; |
| 18 | 18 | import Contact from "./pages/Contact"; |
| 19 | +import Employeurs from "./pages/Employeurs"; | |
| 19 | 20 | import GroupeKaBadge from "./ka/GroupeKaBadge"; |
| 20 | 21 | import KaFooter from "./ka/KaFooter"; |
| 21 | 22 | |
@@ -75,6 +76,7 @@ export default function App() { | ||
| 75 | 76 | <NavLink to="/carte" className={({ isActive }) => (isActive ? "active" : "")}>Carte</NavLink> |
| 76 | 77 | <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>Employeurs</NavLink> |
| 77 | 78 | <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>Stats</NavLink> |
| 79 | + <NavLink to="/employeurs" className={({ isActive }) => (isActive ? "active" : "")}>Publier</NavLink> | |
| 78 | 80 | <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>Contact</NavLink> |
| 79 | 81 | </nav> |
| 80 | 82 | <div className="auth-box"> |
@@ -93,6 +95,7 @@ export default function App() { | ||
| 93 | 95 | <Route path="/sources" element={<Sources />} /> |
| 94 | 96 | <Route path="/stats" element={<Stats />} /> |
| 95 | 97 | <Route path="/contact" element={<Contact />} /> |
| 98 | + <Route path="/employeurs" element={<Employeurs />} /> | |
| 96 | 99 | <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} /> |
| 97 | 100 | </Routes> |
| 98 | 101 | <div className="prefooter"> |
@@ -106,6 +109,7 @@ export default function App() { | ||
| 106 | 109 | <NavLink to="/carte">Carte</NavLink> |
| 107 | 110 | <NavLink to="/sources">Employeurs</NavLink> |
| 108 | 111 | <NavLink to="/stats">Stats</NavLink> |
| 112 | + <NavLink to="/employeurs">Publier une offre</NavLink> | |
| 109 | 113 | <NavLink to="/contact">Contact</NavLink> |
| 110 | 114 | </div> |
| 111 | 115 | </div> |
modified
frontend/src/api.ts
+6 −0
@@ -43,6 +43,7 @@ export interface Job { | ||
| 43 | 43 | lng: number | null; |
| 44 | 44 | active: number; |
| 45 | 45 | dup_sources?: string[]; |
| 46 | + is_direct?: boolean; | |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | export interface JobList { |
@@ -81,6 +82,11 @@ export interface Stats { | ||
| 81 | 82 | avg_salary_year: number | null; |
| 82 | 83 | remote: number; |
| 83 | 84 | geocoded: number; |
| 85 | + direct_jobs?: number; | |
| 86 | + direct_employers?: number; | |
| 87 | + direct_sources?: number; | |
| 88 | + portal_jobs?: number; | |
| 89 | + duplicates_hidden?: number; | |
| 84 | 90 | top_cities: { city: string; n: number }[]; |
| 85 | 91 | categories: { category: string; n: number }[]; |
| 86 | 92 | recent_syncs: { |
modified
frontend/src/components/JobCard.tsx
+8 −0
@@ -19,6 +19,14 @@ export default function JobCard({ job }: { job: Job }) { | ||
| 19 | 19 | <span className="employer">{job.employer}</span> |
| 20 | 20 | {job.city ? <span className="muted"> — {job.city}</span> : null} |
| 21 | 21 | <div className="meta"> |
| 22 | + {job.is_direct !== false && ( | |
| 23 | + <span | |
| 24 | + className="badge direct" | |
| 25 | + title="Offre publiée à la source : page carrière de l'employeur, pas un portail" | |
| 26 | + > | |
| 27 | + Offre directe | |
| 28 | + </span> | |
| 29 | + )} | |
| 22 | 30 | {salary && <span className="badge salary">{salary}</span>} |
| 23 | 31 | {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} |
| 24 | 32 | {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} |
added
frontend/src/pages/Employeurs.tsx
+173 −0
@@ -0,0 +1,173 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/Employeurs.tsx | |
| 7 | + * Rôle : Page « Publier une offre » — dépôt direct par les employeurs | |
| 8 | + * (POST /api/employeurs/offres, modération avant publication) | |
| 9 | + * Créé : 2026-08-18 Modifié : 2026-08-18 | |
| 10 | + * ============================================================================= | |
| 11 | + */ | |
| 12 | +import { FormEvent, useEffect, useState } from "react"; | |
| 13 | + | |
| 14 | +interface FormState { | |
| 15 | + employer: string; | |
| 16 | + contact_email: string; | |
| 17 | + title: string; | |
| 18 | + city: string; | |
| 19 | + url: string; | |
| 20 | + salary_label: string; | |
| 21 | + employment_type: string; | |
| 22 | + work_mode: string; | |
| 23 | + description: string; | |
| 24 | + website: string; // pot de miel anti-robots (doit rester vide) | |
| 25 | +} | |
| 26 | + | |
| 27 | +const EMPTY: FormState = { | |
| 28 | + employer: "", contact_email: "", title: "", city: "", url: "", | |
| 29 | + salary_label: "", employment_type: "", work_mode: "", description: "", | |
| 30 | + website: "", | |
| 31 | +}; | |
| 32 | + | |
| 33 | +export default function EmployeursPage() { | |
| 34 | + const [form, setForm] = useState<FormState>(EMPTY); | |
| 35 | + const [sending, setSending] = useState(false); | |
| 36 | + const [done, setDone] = useState<string | null>(null); | |
| 37 | + const [err, setErr] = useState<string | null>(null); | |
| 38 | + | |
| 39 | + useEffect(() => { | |
| 40 | + document.title = "Publier une offre | Job-Ka — Un service Groupe KA"; | |
| 41 | + window.scrollTo(0, 0); | |
| 42 | + }, []); | |
| 43 | + | |
| 44 | + const set = (k: keyof FormState) => | |
| 45 | + (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => | |
| 46 | + setForm((f) => ({ ...f, [k]: e.target.value })); | |
| 47 | + | |
| 48 | + const submit = (e: FormEvent) => { | |
| 49 | + e.preventDefault(); | |
| 50 | + setSending(true); | |
| 51 | + setErr(null); | |
| 52 | + fetch("/api/employeurs/offres", { | |
| 53 | + method: "POST", | |
| 54 | + headers: { "Content-Type": "application/json" }, | |
| 55 | + body: JSON.stringify({ | |
| 56 | + ...form, | |
| 57 | + employment_type: form.employment_type || null, | |
| 58 | + work_mode: form.work_mode || null, | |
| 59 | + }), | |
| 60 | + }) | |
| 61 | + .then(async (r) => { | |
| 62 | + if (!r.ok) { | |
| 63 | + const body = await r.json().catch(() => null); | |
| 64 | + throw new Error(body?.detail?.[0]?.msg ?? body?.detail ?? `Erreur ${r.status}`); | |
| 65 | + } | |
| 66 | + return r.json(); | |
| 67 | + }) | |
| 68 | + .then((d) => { | |
| 69 | + setDone(d.message ?? "Offre reçue — merci !"); | |
| 70 | + setForm(EMPTY); | |
| 71 | + }) | |
| 72 | + .catch((ex) => setErr(String(ex?.message ?? ex))) | |
| 73 | + .finally(() => setSending(false)); | |
| 74 | + }; | |
| 75 | + | |
| 76 | + return ( | |
| 77 | + <div className="container contact-page"> | |
| 78 | + <span className="kicker">Employeurs</span> | |
| 79 | + <h1> | |
| 80 | + Publiez votre offre <span className="hl">directement</span> | |
| 81 | + </h1> | |
| 82 | + <p className="lede"> | |
| 83 | + Job-Ka référence les offres à la source : pages carrières des | |
| 84 | + employeurs québécois d'abord, portails ensuite. Vous êtes un employeur | |
| 85 | + sans page carrière connectée ? Déposez votre offre ici — elle est | |
| 86 | + vérifiée par notre équipe avant publication, puis affichée avec le | |
| 87 | + badge « Offre directe employeur ». C'est gratuit. | |
| 88 | + </p> | |
| 89 | + | |
| 90 | + <ul className="muted" style={{ lineHeight: 1.8, marginBottom: 24 }}> | |
| 91 | + <li>Votre offre reste <b>traçable</b> : elle renvoie vers votre site si vous fournissez un lien.</li> | |
| 92 | + <li>Si votre entreprise utilise un ATS (Workday, Lever, Njoyn, Taleo…), écrivez-nous : | |
| 93 | + nous pouvons <b>connecter votre page carrière</b> et synchroniser toutes vos offres automatiquement.</li> | |
| 94 | + <li>Le courriel de contact sert uniquement à la modération — il n'est jamais affiché.</li> | |
| 95 | + </ul> | |
| 96 | + | |
| 97 | + {done ? ( | |
| 98 | + <div className="card" style={{ padding: 24 }}> | |
| 99 | + <h3 style={{ marginTop: 0 }}>Offre reçue ✓</h3> | |
| 100 | + <p>{done}</p> | |
| 101 | + <button className="btn" type="button" onClick={() => setDone(null)}> | |
| 102 | + Déposer une autre offre | |
| 103 | + </button> | |
| 104 | + </div> | |
| 105 | + ) : ( | |
| 106 | + <form onSubmit={submit} className="card depot-form" | |
| 107 | + style={{ padding: 24, display: "grid", gap: 14, maxWidth: 760 }}> | |
| 108 | + <div style={{ display: "grid", gap: 14, gridTemplateColumns: "1fr 1fr" }}> | |
| 109 | + <label>Entreprise * | |
| 110 | + <input required minLength={2} maxLength={120} | |
| 111 | + value={form.employer} onChange={set("employer")} /> | |
| 112 | + </label> | |
| 113 | + <label>Courriel de contact * | |
| 114 | + <input required type="email" maxLength={160} | |
| 115 | + value={form.contact_email} onChange={set("contact_email")} /> | |
| 116 | + </label> | |
| 117 | + <label>Titre du poste * | |
| 118 | + <input required minLength={3} maxLength={160} | |
| 119 | + value={form.title} onChange={set("title")} /> | |
| 120 | + </label> | |
| 121 | + <label>Ville | |
| 122 | + <input maxLength={80} value={form.city} onChange={set("city")} /> | |
| 123 | + </label> | |
| 124 | + <label>Lien vers l'offre (votre site) | |
| 125 | + <input type="url" placeholder="https://…" maxLength={400} | |
| 126 | + value={form.url} onChange={set("url")} /> | |
| 127 | + </label> | |
| 128 | + <label>Salaire (texte libre) | |
| 129 | + <input maxLength={120} placeholder="ex. 25 $ à 30 $/h" | |
| 130 | + value={form.salary_label} onChange={set("salary_label")} /> | |
| 131 | + </label> | |
| 132 | + <label>Type d'emploi | |
| 133 | + <select value={form.employment_type} onChange={set("employment_type")}> | |
| 134 | + <option value="">—</option> | |
| 135 | + <option value="temps_plein">Temps plein</option> | |
| 136 | + <option value="temps_partiel">Temps partiel</option> | |
| 137 | + <option value="contractuel">Contractuel</option> | |
| 138 | + <option value="stage">Stage</option> | |
| 139 | + <option value="saisonnier">Saisonnier</option> | |
| 140 | + </select> | |
| 141 | + </label> | |
| 142 | + <label>Mode de travail | |
| 143 | + <select value={form.work_mode} onChange={set("work_mode")}> | |
| 144 | + <option value="">—</option> | |
| 145 | + <option value="presentiel">Présentiel</option> | |
| 146 | + <option value="hybride">Hybride</option> | |
| 147 | + <option value="teletravail">Télétravail</option> | |
| 148 | + </select> | |
| 149 | + </label> | |
| 150 | + </div> | |
| 151 | + <label>Description du poste * <span className="muted">(30 caractères minimum)</span> | |
| 152 | + <textarea required minLength={30} maxLength={20000} rows={10} | |
| 153 | + value={form.description} onChange={set("description")} /> | |
| 154 | + </label> | |
| 155 | + {/* pot de miel : caché aux humains, rempli par les robots */} | |
| 156 | + <input type="text" name="website" value={form.website} | |
| 157 | + onChange={set("website")} autoComplete="off" tabIndex={-1} | |
| 158 | + style={{ position: "absolute", left: "-9999px" }} aria-hidden="true" /> | |
| 159 | + {err && <p style={{ color: "var(--red, #c0392b)" }}>{err}</p>} | |
| 160 | + <div> | |
| 161 | + <button className="btn btn-primary" type="submit" disabled={sending}> | |
| 162 | + {sending ? "Envoi…" : "Soumettre l'offre pour modération"} | |
| 163 | + </button> | |
| 164 | + </div> | |
| 165 | + <p className="muted" style={{ fontSize: 13, margin: 0 }}> | |
| 166 | + En soumettant, vous confirmez être autorisé à publier cette offre. | |
| 167 | + Les offres sont vérifiées manuellement avant d'apparaître sur Job-Ka. | |
| 168 | + </p> | |
| 169 | + </form> | |
| 170 | + )} | |
| 171 | + </div> | |
| 172 | + ); | |
| 173 | +} | |
modified
frontend/src/styles.css
+5 −0
@@ -134,6 +134,11 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10% | ||
| 134 | 134 | } |
| 135 | 135 | .badge.salary { background: var(--green-soft); color: var(--green); } |
| 136 | 136 | .badge.mode { background: var(--accent-soft); color: var(--accent-deep); } |
| 137 | +.badge.direct { | |
| 138 | + background: transparent; | |
| 139 | + border: 1px solid var(--accent, #0c8599); | |
| 140 | + color: var(--accent-deep, #0b7285); | |
| 141 | +} | |
| 137 | 142 | |
| 138 | 143 | /* --- fiche ------------------------------------------------------------------------- */ |
| 139 | 144 | .sheet { |
modified
jobka/statsdash.py
+11 −0
@@ -214,6 +214,14 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict: | ||
| 214 | 214 | f"SELECT COUNT(*) n{BASE} AND work_mode='teletravail'").fetchone()["n"] |
| 215 | 215 | pct_remote = round(100.0 * remote_n / actives_now, 1) if actives_now else None |
| 216 | 216 | |
| 217 | + # offres directes (pages carrières + dépôt direct) c. portails agrégateurs | |
| 218 | + from .dedup import AGGREGATORS | |
| 219 | + _agg = sorted(AGGREGATORS) or ["__aucun__"] | |
| 220 | + _ph = ",".join("?" * len(_agg)) | |
| 221 | + direct_n = con.execute( | |
| 222 | + f"SELECT COUNT(*) n{BASE} AND source NOT IN ({_ph})", _agg).fetchone()["n"] | |
| 223 | + pct_direct = round(100.0 * direct_n / actives_now, 1) if actives_now else None | |
| 224 | + | |
| 217 | 225 | kpis = [ |
| 218 | 226 | {"id": "actives", "label": "Offres actives", "value": actives_now, |
| 219 | 227 | "delta_pct": _pct(actives_now, actives_prev) if actives_prev else None, |
@@ -243,6 +251,9 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict: | ||
| 243 | 251 | if pct_remote is not None: |
| 244 | 252 | kpis.append({"id": "teletravail", "label": "Offres en télétravail", |
| 245 | 253 | "value": pct_remote, "unit": "%"}) |
| 254 | + if pct_direct is not None: | |
| 255 | + kpis.append({"id": "directes", "label": "Offres directes employeur", | |
| 256 | + "value": pct_direct, "unit": "%"}) | |
| 246 | 257 | kpis = [{k: v for k, v in kpi.items() if v is not None} for kpi in kpis] |
| 247 | 258 | |
| 248 | 259 | # ---- séries temporelles ---------------------------------------------------- |
modified
jobka/web.py
+104 −1
@@ -9,6 +9,8 @@ | ||
| 9 | 9 | from __future__ import annotations |
| 10 | 10 | |
| 11 | 11 | import json |
| 12 | +import re | |
| 13 | +import secrets | |
| 12 | 14 | import threading |
| 13 | 15 | import time |
| 14 | 16 | from pathlib import Path |
@@ -18,8 +20,11 @@ from fastapi.middleware.cors import CORSMiddleware | ||
| 18 | 20 | from fastapi.middleware.gzip import GZipMiddleware |
| 19 | 21 | from fastapi.responses import FileResponse, Response |
| 20 | 22 | from fastapi.staticfiles import StaticFiles |
| 23 | +from pydantic import BaseModel, Field | |
| 21 | 24 | |
| 22 | 25 | from . import auth, db, ingest |
| 26 | +from .dedup import AGGREGATORS | |
| 27 | +from .schema import JobPosting | |
| 23 | 28 | |
| 24 | 29 | ROOT = Path(__file__).resolve().parent.parent |
| 25 | 30 | SOURCES_PATH = ROOT / "data" / "sources.json" |
@@ -48,6 +53,8 @@ def _row_to_dict(row) -> dict: | ||
| 48 | 53 | d["dup_sources"] = json.loads(d["dup_sources"]) |
| 49 | 54 | except (ValueError, TypeError): |
| 50 | 55 | d["dup_sources"] = [] |
| 56 | + # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail) | |
| 57 | + d["is_direct"] = d.get("source") not in AGGREGATORS | |
| 51 | 58 | return d |
| 52 | 59 | |
| 53 | 60 | |
@@ -206,6 +213,7 @@ def jobs_geojson( | ||
| 206 | 213 | "salary_year_min": r["salary_year_min"], |
| 207 | 214 | "salary_year_max": r["salary_year_max"], |
| 208 | 215 | "date_posted": r["date_posted"], "source": r["source"], |
| 216 | + "is_direct": r["source"] not in AGGREGATORS, | |
| 209 | 217 | }, |
| 210 | 218 | }) |
| 211 | 219 | con.close() |
@@ -293,8 +301,25 @@ def stats(): | ||
| 293 | 301 | GROUP BY category ORDER BY n DESC""")] |
| 294 | 302 | log = [dict(r) for r in con.execute( |
| 295 | 303 | "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] |
| 304 | + # offres directes (pages carrières + dépôt direct) c. portails agrégateurs | |
| 305 | + agg = sorted(AGGREGATORS) or ["__aucun__"] | |
| 306 | + ph = ",".join("?" * len(agg)) | |
| 307 | + direct = con.execute( | |
| 308 | + f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) e, | |
| 309 | + COUNT(DISTINCT source) s | |
| 310 | + FROM jobs WHERE active=1 AND dup_of IS NULL | |
| 311 | + AND source NOT IN ({ph})""", agg).fetchone() | |
| 312 | + portal = con.execute( | |
| 313 | + f"""SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL | |
| 314 | + AND source IN ({ph})""", agg).fetchone() | |
| 315 | + dups = con.execute( | |
| 316 | + "SELECT COUNT(*) n FROM jobs WHERE active=1" | |
| 317 | + " AND dup_of IS NOT NULL").fetchone() | |
| 296 | 318 | con.close() |
| 297 | 319 | return {**dict(row), "top_cities": cities, "categories": categories, |
| 320 | + "direct_jobs": direct["n"], "direct_employers": direct["e"], | |
| 321 | + "direct_sources": direct["s"], "portal_jobs": portal["n"], | |
| 322 | + "duplicates_hidden": dups["n"], | |
| 298 | 323 | "recent_syncs": log} |
| 299 | 324 | |
| 300 | 325 | |
@@ -333,6 +358,84 @@ def stats_report(period: str = "30j", | ||
| 333 | 358 | headers={"Content-Disposition": f'attachment; filename="{fname}"'}) |
| 334 | 359 | |
| 335 | 360 | |
| 361 | +# --- Dépôt direct d'offres par les employeurs --------------------------------- | |
| 362 | +class OffreDeposee(BaseModel): | |
| 363 | + """Offre soumise par un employeur (validation minimale, modérée ensuite).""" | |
| 364 | + | |
| 365 | + employer: str = Field(min_length=2, max_length=120) | |
| 366 | + title: str = Field(min_length=3, max_length=160) | |
| 367 | + description: str = Field(min_length=30, max_length=20000) | |
| 368 | + contact_email: str = Field(min_length=5, max_length=160) | |
| 369 | + city: str = Field("", max_length=80) | |
| 370 | + url: str = Field("", max_length=400) | |
| 371 | + salary_label: str = Field("", max_length=120) | |
| 372 | + employment_type: str | None = None | |
| 373 | + work_mode: str | None = None | |
| 374 | + website: str = "" # pot de miel anti-robots : doit rester vide | |
| 375 | + | |
| 376 | + | |
| 377 | +@app.post("/api/employeurs/offres") | |
| 378 | +def deposer_offre(offre: OffreDeposee): | |
| 379 | + """Canal de dépôt direct : l'offre est stockée INACTIVE (source | |
| 380 | + « depot-direct ») en attente de modération manuelle. Voir /employeurs.""" | |
| 381 | + if offre.website: # robot pris au pot de miel | |
| 382 | + return {"status": "reçu"} | |
| 383 | + if not re.match(r"^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$", offre.contact_email): | |
| 384 | + raise HTTPException(422, "Courriel de contact invalide") | |
| 385 | + if offre.url and not re.match(r"^https?://", offre.url): | |
| 386 | + raise HTTPException(422, "Lien invalide : http(s):// requis") | |
| 387 | + if offre.employment_type and offre.employment_type not in ( | |
| 388 | + "temps_plein", "temps_partiel", "contractuel", "stage", "saisonnier"): | |
| 389 | + raise HTTPException(422, "Type d'emploi inconnu") | |
| 390 | + if offre.work_mode and offre.work_mode not in ( | |
| 391 | + "presentiel", "hybride", "teletravail"): | |
| 392 | + raise HTTPException(422, "Mode de travail inconnu") | |
| 393 | + | |
| 394 | + job = JobPosting( | |
| 395 | + source="depot-direct", external_id=secrets.token_hex(6), | |
| 396 | + url=offre.url, employer=offre.employer, title=offre.title, | |
| 397 | + description=offre.description, city=offre.city, | |
| 398 | + salary_label=offre.salary_label, | |
| 399 | + employment_type=offre.employment_type, work_mode=offre.work_mode, | |
| 400 | + ats="depot-direct", | |
| 401 | + ) | |
| 402 | + job.details["contact_email"] = offre.contact_email | |
| 403 | + job.details["moderation"] = "en_attente" | |
| 404 | + job.finalize() | |
| 405 | + | |
| 406 | + now = time.time() | |
| 407 | + cols = { | |
| 408 | + "uid": job.uid, "source": job.source, "external_id": job.external_id, | |
| 409 | + "url": job.url, "employer": job.employer, "title": job.title, | |
| 410 | + "description": job.description, "city": job.city, | |
| 411 | + "region": job.region, "postal_code": job.postal_code, | |
| 412 | + "location_label": job.location_label, "work_mode": job.work_mode, | |
| 413 | + "employment_type": job.employment_type, | |
| 414 | + "salary_min": job.salary_min, "salary_max": job.salary_max, | |
| 415 | + "salary_unit": job.salary_unit, "salary_label": job.salary_label, | |
| 416 | + "salary_year_min": job.salary_year_min(), | |
| 417 | + "salary_year_max": job.salary_year_max(), | |
| 418 | + "salary_hour_min": job.salary_hour_min(), | |
| 419 | + "salary_hour_max": job.salary_hour_max(), | |
| 420 | + "benefits": json.dumps(job.benefits, ensure_ascii=False), | |
| 421 | + "requirements": json.dumps(job.requirements, ensure_ascii=False), | |
| 422 | + "category": job.category, "ats": job.ats, | |
| 423 | + "details": json.dumps(job.details, ensure_ascii=False), | |
| 424 | + "content_hash": job.content_hash(), | |
| 425 | + "first_seen": now, "last_seen": now, "updated_at": now, | |
| 426 | + "miss_count": 0, "active": 0, # inactive tant que non modérée | |
| 427 | + } | |
| 428 | + con = db.connect() | |
| 429 | + con.execute( | |
| 430 | + f"INSERT INTO jobs ({', '.join(cols)}) VALUES " | |
| 431 | + f"({', '.join(':' + k for k in cols)})", cols) | |
| 432 | + con.commit() | |
| 433 | + con.close() | |
| 434 | + return {"status": "reçu", "uid": job.uid, | |
| 435 | + "message": "Merci ! Votre offre a bien été reçue. Elle sera " | |
| 436 | + "vérifiée par notre équipe avant publication."} | |
| 437 | + | |
| 438 | + | |
| 336 | 439 | @app.post("/api/sync") |
| 337 | 440 | def trigger_sync(background: BackgroundTasks, source: str | None = None): |
| 338 | 441 | """Déclenche une synchronisation (équivalent d'un webhook entrant).""" |
@@ -361,7 +464,7 @@ if FRONTEND_DIST.exists(): | ||
| 361 | 464 | |
| 362 | 465 | # routes rendues uniquement côté client — tout autre chemin inconnu renvoie |
| 363 | 466 | # index.html avec un statut 404 (pas de soft-404 pour les moteurs) |
| 364 | − _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", | |
| 467 | + _CLIENT_ROUTES = {"carte", "sources", "stats", "contact", "employeurs", | |
| 365 | 468 | "confidentialite", "conditions"} |
| 366 | 469 | _CLIENT_PREFIXES = ("emploi/",) |
| 367 | 470 | |
| 368 | 471 | |