Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 70.4%
HTML 18.4%
JavaScript 4%
Python 3.8%
CSS 3.4%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA ID v2 — évaluation des alertes de recherches sauvegardées.3// Méthode « baseline » : pour chaque alerte active, on relance la recherche4// sur l'API publique de la plateforme (les filtres sauvegardés SONT ses5// paramètres) et on compare le total au dernier total connu — une hausse6// = des nouveautés → courriel Resend (fréquence respectée). Robuste et7// générique : aucune notion de « date de publication » requise côté app.8// Déclenchée par GET /api/kaid/alerts/tick (pm2 cron horaire sur le nœud).9import { db } from "./db";10import { sendEmail } from "./email";1112type Adapter = { base: string; path: string; small: Record<string, string>; total: string };1314// app → API de listing publique (paramètre « 1 résultat » + clé du total)15const ADAPTERS: Record<string, Adapter> = {16 "lou-ka": { base: "https://www.lou-ka.com", path: "/api/listings", small: { limit: "1" }, total: "total" },17 "immo-ka": { base: "https://www.immo-ka.com", path: "/api/listings", small: { limit: "1" }, total: "total" },18 "auto-ka": { base: "https://www.auto-ka.com", path: "/api/vehicles", small: { limit: "1" }, total: "total" },19 "food-ka": { base: "https://www.food-ka.com", path: "/api/products", small: { limit: "1" }, total: "total" },20 "fabri-ka": { base: "https://www.fabri-ka.com", path: "/api/products", small: { per_page: "1" }, total: "total" },21 "sorti-ka": { base: "https://www.sorti-ka.com", path: "/api/events", small: { limit: "1" }, total: "total" },22 "job-ka": { base: "https://www.job-ka.com", path: "/api/jobs", small: { limit: "1" }, total: "total" },23};2425const APP_LABELS: Record<string, string> = {26 "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "auto-ka": "Auto·Ka",27 "food-ka": "Food·Ka", "fabri-ka": "Fabri·Ka",28 "sorti-ka": "Sorti·Ka", "job-ka": "Job·Ka",29};3031type AlertRow = {32 id: number; user_id: number; app: string; label: string;33 query: string | null; filters: string | null; url: string | null;34 alert_frequency: string; last_total: number | null;35 last_notified_at: string | null;36 email: string; name: string;37};3839function dueByFrequency(row: AlertRow): boolean {40 if (!row.last_notified_at) return true;41 const hours =42 (Date.now() - Date.parse(row.last_notified_at.replace(" ", "T") + "Z")) / 3.6e6;43 if (row.alert_frequency === "instant") return hours >= 0.9;44 if (row.alert_frequency === "weekly") return hours >= 156;45 return hours >= 20; // daily46}4748async function currentTotal(row: AlertRow): Promise<number | null> {49 const a = ADAPTERS[row.app];50 if (!a) return null;51 const params = new URLSearchParams(a.small);52 if (row.query) params.set("q", row.query);53 if (row.filters) {54 try {55 const f = JSON.parse(row.filters) as Record<string, unknown>;56 for (const [k, v] of Object.entries(f)) {57 if (v == null || typeof v === "object") continue;58 if (k === "q" || k in a.small) continue;59 params.set(k.slice(0, 40), String(v).slice(0, 120));60 }61 } catch { /* filtres illisibles : requête large */ }62 }63 try {64 const r = await fetch(`${a.base}${a.path}?${params}`, {65 signal: AbortSignal.timeout(8000),66 headers: { "User-Agent": "ka-id-alerts/1.0 (groupe-ka.com)" },67 });68 if (!r.ok) return null;69 const data = (await r.json()) as Record<string, unknown>;70 const total = data[a.total];71 return typeof total === "number" ? total : null;72 } catch {73 return null;74 }75}7677function alertEmail(row: AlertRow, nouveaux: number): { html: string; text: string } {78 const label = APP_LABELS[row.app] ?? row.app;79 const link = row.url ?? ADAPTERS[row.app]?.base ?? "https://www.groupe-ka.com";80 const text =81 `Bonjour ${row.name.split(" ")[0]},\n\n` +82 `${nouveaux} nouveau${nouveaux > 1 ? "x" : ""} résultat${nouveaux > 1 ? "s" : ""} ` +83 `correspond${nouveaux > 1 ? "ent" : ""} à votre recherche sauvegardée ` +84 `« ${row.label} » sur ${label}.\n\nVoir : ${link}\n\n` +85 `Gérer vos alertes : https://www.groupe-ka.com/mon-ka\n— Groupe KA`;86 const html =87 `<p>Bonjour ${row.name.split(" ")[0]},</p>` +88 `<p><strong>${nouveaux} nouveau${nouveaux > 1 ? "x" : ""} résultat${nouveaux > 1 ? "s" : ""}</strong> ` +89 `correspond${nouveaux > 1 ? "ent" : ""} à votre recherche sauvegardée ` +90 `« <strong>${row.label}</strong> » sur ${label}.</p>` +91 `<p><a href="${link}">Voir les résultats →</a></p>` +92 `<p style="color:#666;font-size:13px">Vous recevez ce courriel parce qu'une alerte est active ` +93 `sur cette recherche. <a href="https://www.groupe-ka.com/mon-ka">Gérer mes alertes</a></p>`;94 return { html, text };95}9697export async function runAlerts(): Promise<{98 checked: number; notified: number; baselined: number; errors: number;99}> {100 const rows = db.prepare(101 `SELECT s.id, s.user_id, s.app, s.label, s.query, s.filters, s.url,102 s.alert_frequency, s.last_total, s.last_notified_at,103 u.email, u.name104 FROM saved_searches s JOIN users u ON u.id = s.user_id105 WHERE s.alert_enabled = 1`,106 ).all() as AlertRow[];107 let checked = 0, notified = 0, baselined = 0, errors = 0;108 for (const row of rows) {109 if (!ADAPTERS[row.app]) continue;110 checked++;111 const total = await currentTotal(row);112 if (total == null) { errors++; continue; }113 if (row.last_total == null) {114 db.prepare("UPDATE saved_searches SET last_total = ? WHERE id = ?")115 .run(total, row.id);116 baselined++;117 continue;118 }119 if (total > row.last_total && dueByFrequency(row)) {120 const nouveaux = total - row.last_total;121 try {122 const { html, text } = alertEmail(row, nouveaux);123 await sendEmail({124 to: row.email,125 subject: `${nouveaux} nouveauté${nouveaux > 1 ? "s" : ""} — ${row.label}`,126 html, text,127 });128 db.prepare(129 `UPDATE saved_searches SET last_total = ?, last_run_at = datetime('now'),130 last_triggered_at = datetime('now'), last_notified_at = datetime('now')131 WHERE id = ?`,132 ).run(total, row.id);133 notified++;134 } catch {135 errors++;136 }137 } else if (total < row.last_total) {138 // baisse (annonces expirées) : rebaser sans bruit. Une hausse hors139 // fenêtre de fréquence n'est PAS rebasée — elle sera notifiée au140 // prochain tick admissible.141 db.prepare("UPDATE saved_searches SET last_total = ? WHERE id = ?")142 .run(total, row.id);143 }144 }145 return { checked, notified, baselined, errors };146}147