SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%
6.5 KB · 147 lines typescript
Raw Blame History
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