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%
16.1 KB · 473 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA ID v2 — couche de données de la personnalisation Groupe KA.3// Le hub est LE feature store du groupe : événements comportementaux,4// recherches sauvegardées (+ alertes), éléments masqués, profil de5// préférences calculé (cache), réglages de confidentialité.6// Migrations idempotentes exécutées à l'import (même patron que db.ts).7import { db } from "./db";89/* ---------- migrations ---------- */1011db.exec(`12  CREATE TABLE IF NOT EXISTS user_events (13    id INTEGER PRIMARY KEY AUTOINCREMENT,14    user_id INTEGER NOT NULL,15    app TEXT NOT NULL,16    session_id TEXT,17    event_type TEXT NOT NULL,18    entity_type TEXT,19    entity_id TEXT,20    query TEXT,21    filters TEXT,22    position INTEGER,23    metadata TEXT,24    created_at TEXT NOT NULL DEFAULT (datetime('now'))25  );26  CREATE INDEX IF NOT EXISTS user_events_user ON user_events(user_id, created_at);27  CREATE INDEX IF NOT EXISTS user_events_user_app ON user_events(user_id, app);2829  CREATE TABLE IF NOT EXISTS saved_searches (30    id INTEGER PRIMARY KEY AUTOINCREMENT,31    user_id INTEGER NOT NULL,32    app TEXT NOT NULL,33    label TEXT NOT NULL,34    query TEXT,35    filters TEXT,36    location TEXT,37    url TEXT,38    alert_enabled INTEGER NOT NULL DEFAULT 0,39    alert_frequency TEXT NOT NULL DEFAULT 'daily',40    created_at TEXT NOT NULL DEFAULT (datetime('now')),41    last_run_at TEXT,42    last_triggered_at TEXT,43    fingerprint TEXT,44    UNIQUE(user_id, app, fingerprint)45  );46  CREATE INDEX IF NOT EXISTS saved_searches_user ON saved_searches(user_id, app);4748  CREATE TABLE IF NOT EXISTS hidden_items (49    user_id INTEGER NOT NULL,50    app TEXT NOT NULL,51    item_id TEXT NOT NULL,52    created_at TEXT NOT NULL DEFAULT (datetime('now')),53    PRIMARY KEY (user_id, app, item_id)54  );5556  CREATE TABLE IF NOT EXISTS user_prefs (57    user_id INTEGER PRIMARY KEY,58    profile TEXT NOT NULL,59    events_n INTEGER NOT NULL DEFAULT 0,60    updated_at TEXT NOT NULL DEFAULT (datetime('now'))61  );6263  CREATE TABLE IF NOT EXISTS pref_overrides (64    user_id INTEGER NOT NULL,65    app TEXT NOT NULL,66    dim TEXT NOT NULL,67    value TEXT NOT NULL,68    mode TEXT NOT NULL,               -- 'ban' (retirer) | 'boost' (renforcer)69    created_at TEXT NOT NULL DEFAULT (datetime('now')),70    PRIMARY KEY (user_id, app, dim, value)71  );72`);7374// Matrix factorization implicite (ALS) — recommandations batch écrites par75// scripts/kaid-mf.mjs (pm2 groupe-ka-kaid-mf, quotidien) ; lues par76// /api/sso/prefs et boostées au reranking des satellites.77db.exec(`78  CREATE TABLE IF NOT EXISTS mf_recs (79    user_id INTEGER NOT NULL,80    app TEXT NOT NULL,81    item_id TEXT NOT NULL,82    score REAL NOT NULL,83    rank INTEGER NOT NULL,84    run_at TEXT NOT NULL DEFAULT (datetime('now')),85    PRIMARY KEY (user_id, app, rank)86  );87  CREATE INDEX IF NOT EXISTS mf_recs_user ON mf_recs(user_id, app);88`);8990// Alertes actives (2026-08-26) : baseline du total de résultats + dernier envoi.91for (const col of ["last_total INTEGER", "last_notified_at TEXT"]) {92  try {93    db.exec(`ALTER TABLE saved_searches ADD COLUMN ${col}`);94  } catch {95    /* colonne déjà présente */96  }97}9899// Confidentialité — trois interrupteurs, tous ON par défaut (opt-out).100for (const col of [101  "personalization INTEGER DEFAULT 1", // reranking personnalisé102  "history_enabled INTEGER DEFAULT 1", // journal d'interactions103  "recos_enabled INTEGER DEFAULT 1",   // recommandations « Pour vous »104]) {105  try {106    db.exec(`ALTER TABLE users ADD COLUMN ${col}`);107  } catch {108    /* colonne déjà présente */109  }110}111112/* ---------- types ---------- */113114export type UserEventRow = {115  id: number;116  user_id: number;117  app: string;118  session_id: string | null;119  event_type: string;120  entity_type: string | null;121  entity_id: string | null;122  query: string | null;123  filters: string | null;124  position: number | null;125  metadata: string | null;126  created_at: string;127};128129export type SavedSearchRow = {130  id: number;131  user_id: number;132  app: string;133  label: string;134  query: string | null;135  filters: string | null;136  location: string | null;137  url: string | null;138  alert_enabled: number;139  alert_frequency: string;140  created_at: string;141  last_run_at: string | null;142  last_triggered_at: string | null;143  fingerprint: string | null;144};145146export type Privacy = {147  personalization: boolean;148  history: boolean;149  recos: boolean;150};151152export const EVENT_TYPES = new Set([153  "search", "impression", "click", "detail_view", "detail_dwell",154  "favorite", "unfavorite", "share", "compare", "hide", "unhide",155  "dismiss", "map_open", "map_marker_click", "filter_change",156  "price_filter", "location_filter", "scroll_depth", "return_visit",157  "alert_create", "alert_open", "external_click", "saved_search",158]);159160// Événements « forts » : ils invalident le profil calculé sur-le-champ.161const STRONG = new Set([162  "favorite", "unfavorite", "hide", "unhide", "alert_create", "saved_search",163]);164165const clip = (v: unknown, max: number): string | null => {166  if (typeof v === "number") return String(v).slice(0, max);167  return typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null;168};169170/* ---------- confidentialité ---------- */171172export function privacyOf(userId: number): Privacy {173  const r = db174    .prepare(175      "SELECT personalization, history_enabled, recos_enabled FROM users WHERE id = ?",176    )177    .get(userId) as178    | { personalization: number; history_enabled: number; recos_enabled: number }179    | undefined;180  return {181    personalization: (r?.personalization ?? 1) !== 0,182    history: (r?.history_enabled ?? 1) !== 0,183    recos: (r?.recos_enabled ?? 1) !== 0,184  };185}186187export function setPrivacy(userId: number, p: Partial<Privacy>): void {188  if (p.personalization !== undefined)189    db.prepare("UPDATE users SET personalization = ? WHERE id = ?").run(190      p.personalization ? 1 : 0, userId);191  if (p.history !== undefined)192    db.prepare("UPDATE users SET history_enabled = ? WHERE id = ?").run(193      p.history ? 1 : 0, userId);194  if (p.recos !== undefined)195    db.prepare("UPDATE users SET recos_enabled = ? WHERE id = ?").run(196      p.recos ? 1 : 0, userId);197  invalidatePrefs(userId);198}199200/* ---------- événements ---------- */201202export function ingestEvents(203  userId: number,204  app: string,205  events: Array<Record<string, unknown>>,206): number {207  const ins = db.prepare(208    `INSERT INTO user_events (user_id, app, session_id, event_type,209       entity_type, entity_id, query, filters, position, metadata, created_at)210     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, datetime('now')))`,211  );212  let n = 0;213  let strong = false;214  const tx = db.transaction(() => {215    for (const e of events.slice(0, 50)) {216      const type = clip(e.type ?? e.event_type, 30) ?? "";217      if (!EVENT_TYPES.has(type)) continue;218      const meta: Record<string, unknown> = {};219      if (e.features && typeof e.features === "object")220        meta.features = e.features;221      if (typeof e.dwell_ms === "number") meta.dwell_ms = e.dwell_ms;222      const ts =223        typeof e.ts === "number" && e.ts > 1_600_000_000 && e.ts < Date.now() / 1000 + 60224          ? new Date(e.ts * 1000).toISOString().replace("T", " ").slice(0, 19)225          : null;226      ins.run(227        userId, app, clip(e.session_id, 60), type,228        clip(e.entity_type, 30), clip(e.entity_id, 200), clip(e.query, 200),229        e.filters && typeof e.filters === "object"230          ? JSON.stringify(e.filters).slice(0, 2000) : null,231        typeof e.position === "number" ? Math.floor(e.position) : null,232        Object.keys(meta).length ? JSON.stringify(meta).slice(0, 4000) : null,233        ts,234      );235      if (STRONG.has(type)) strong = true;236      n++;237    }238  });239  tx();240  if (strong) invalidatePrefs(userId);241  // Rétention : purge douce des événements de plus de 180 jours (1 fois sur ~50).242  if (Math.floor(Math.random() * 50) === 0)243    db.prepare(244      "DELETE FROM user_events WHERE created_at < datetime('now', '-180 days')",245    ).run();246  return n;247}248249export function eventsOf(250  userId: number,251  opts?: { app?: string; limit?: number; sinceDays?: number },252): UserEventRow[] {253  const limit = Math.min(opts?.limit ?? 4000, 8000);254  const since = `-${Math.min(opts?.sinceDays ?? 180, 365)} days`;255  if (opts?.app)256    return db.prepare(257      `SELECT * FROM user_events WHERE user_id = ? AND app = ?258         AND created_at > datetime('now', ?)259       ORDER BY id DESC LIMIT ?`,260    ).all(userId, opts.app, since, limit) as UserEventRow[];261  return db.prepare(262    `SELECT * FROM user_events WHERE user_id = ?263       AND created_at > datetime('now', ?)264     ORDER BY id DESC LIMIT ?`,265  ).all(userId, since, limit) as UserEventRow[];266}267268export function eventsCount(userId: number): number {269  return (db.prepare(270    "SELECT COUNT(*) AS n FROM user_events WHERE user_id = ?",271  ).get(userId) as { n: number }).n;272}273274export function clearHistory(userId: number): void {275  db.prepare("DELETE FROM user_events WHERE user_id = ?").run(userId);276  invalidatePrefs(userId);277}278279/* ---------- profil calculé (cache) ---------- */280281export function cachedProfile(282  userId: number,283): { profile: string; events_n: number; updated_at: string } | undefined {284  return db.prepare("SELECT profile, events_n, updated_at FROM user_prefs WHERE user_id = ?")285    .get(userId) as { profile: string; events_n: number; updated_at: string } | undefined;286}287288export function storeProfile(userId: number, profile: unknown, eventsN: number): void {289  db.prepare(290    `INSERT INTO user_prefs (user_id, profile, events_n, updated_at)291     VALUES (?, ?, ?, datetime('now'))292     ON CONFLICT(user_id) DO UPDATE SET profile = excluded.profile,293       events_n = excluded.events_n, updated_at = datetime('now')`,294  ).run(userId, JSON.stringify(profile), eventsN);295}296297export function invalidatePrefs(userId: number): void {298  db.prepare("DELETE FROM user_prefs WHERE user_id = ?").run(userId);299}300301export function resetPersonalization(userId: number): void {302  db.prepare("DELETE FROM user_events WHERE user_id = ?").run(userId);303  db.prepare("DELETE FROM pref_overrides WHERE user_id = ?").run(userId);304  db.prepare("DELETE FROM hidden_items WHERE user_id = ?").run(userId);305  invalidatePrefs(userId);306}307308/* ---------- corrections utilisateur (« Ce que KA a appris ») ---------- */309310export type OverrideRow = {311  user_id: number; app: string; dim: string; value: string; mode: string;312};313314export function overridesOf(userId: number): OverrideRow[] {315  return db.prepare("SELECT * FROM pref_overrides WHERE user_id = ?")316    .all(userId) as OverrideRow[];317}318319export function setOverride(320  userId: number, app: string, dim: string, value: string,321  mode: "ban" | "boost" | "clear",322): void {323  if (mode === "clear")324    db.prepare(325      "DELETE FROM pref_overrides WHERE user_id = ? AND app = ? AND dim = ? AND value = ?",326    ).run(userId, app, dim, value);327  else328    db.prepare(329      `INSERT INTO pref_overrides (user_id, app, dim, value, mode)330       VALUES (?, ?, ?, ?, ?)331       ON CONFLICT(user_id, app, dim, value) DO UPDATE SET mode = excluded.mode`,332    ).run(userId, app, dim.slice(0, 40), value.slice(0, 120), mode);333  invalidatePrefs(userId);334}335336/* ---------- éléments masqués (« Pas pour moi ») ---------- */337338export function hiddenOf(userId: number, app?: string): { app: string; item_id: string }[] {339  return (340    app341      ? db.prepare("SELECT app, item_id FROM hidden_items WHERE user_id = ? AND app = ?")342          .all(userId, app)343      : db.prepare("SELECT app, item_id FROM hidden_items WHERE user_id = ?").all(userId)344  ) as { app: string; item_id: string }[];345}346347export function setHidden(userId: number, app: string, itemId: string, on: boolean): void {348  if (on)349    db.prepare(350      `INSERT INTO hidden_items (user_id, app, item_id) VALUES (?, ?, ?)351       ON CONFLICT DO NOTHING`,352    ).run(userId, app, itemId.slice(0, 200));353  else354    db.prepare(355      "DELETE FROM hidden_items WHERE user_id = ? AND app = ? AND item_id = ?",356    ).run(userId, app, itemId);357  invalidatePrefs(userId);358}359360/* ---------- recommandations du modèle (matrix factorization) ---------- */361362/** Recommandations ALS fraîches (≤ 48 h) d'un membre pour un univers. */363export function mfRecsOf(userId: number, app: string): string[] {364  return (db.prepare(365    `SELECT item_id FROM mf_recs366     WHERE user_id = ? AND app = ? AND run_at > datetime('now', '-48 hours')367     ORDER BY rank ASC LIMIT 20`,368  ).all(userId, app) as { item_id: string }[]).map((r) => r.item_id);369}370371/* ---------- recherches sauvegardées ---------- */372373import crypto from "crypto";374375function fingerprint(app: string, query: string | null, filters: string | null): string {376  return crypto.createHash("sha256")377    .update(`${app}|${query ?? ""}|${filters ?? ""}`).digest("hex").slice(0, 24);378}379380export function savedSearchesOf(userId: number, app?: string): SavedSearchRow[] {381  return (382    app383      ? db.prepare(384          "SELECT * FROM saved_searches WHERE user_id = ? AND app = ? ORDER BY created_at DESC",385        ).all(userId, app)386      : db.prepare(387          "SELECT * FROM saved_searches WHERE user_id = ? ORDER BY created_at DESC",388        ).all(userId)389  ) as SavedSearchRow[];390}391392export function addSavedSearch(393  userId: number,394  app: string,395  s: { label: string; query?: string; filters?: unknown; location?: string; url?: string;396       alert?: boolean; frequency?: string },397): number {398  const filters =399    s.filters && typeof s.filters === "object"400      ? JSON.stringify(s.filters).slice(0, 2000)401      : typeof s.filters === "string" ? s.filters.slice(0, 2000) : null;402  const query = clip(s.query, 300);403  const fp = fingerprint(app, query, filters);404  db.prepare(405    `INSERT INTO saved_searches (user_id, app, label, query, filters, location,406       url, alert_enabled, alert_frequency, fingerprint)407     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)408     ON CONFLICT(user_id, app, fingerprint) DO UPDATE SET409       label = excluded.label, url = excluded.url, location = excluded.location,410       alert_enabled = excluded.alert_enabled, alert_frequency = excluded.alert_frequency`,411  ).run(412    userId, app, (s.label || "Recherche").slice(0, 120), query, filters,413    clip(s.location, 120), clip(s.url, 600),414    s.alert ? 1 : 0,415    ["instant", "daily", "weekly"].includes(s.frequency ?? "") ? s.frequency! : "daily",416    fp,417  );418  return (db.prepare(419    "SELECT id FROM saved_searches WHERE user_id = ? AND app = ? AND fingerprint = ?",420  ).get(userId, app, fp) as { id: number }).id;421}422423export function removeSavedSearch(userId: number, id: number): void {424  db.prepare("DELETE FROM saved_searches WHERE user_id = ? AND id = ?").run(userId, id);425}426427export function touchSavedSearch(userId: number, id: number): void {428  db.prepare(429    "UPDATE saved_searches SET last_run_at = datetime('now') WHERE user_id = ? AND id = ?",430  ).run(userId, id);431}432433export function setSavedSearchAlert(434  userId: number, id: number, enabled: boolean, frequency?: string,435): void {436  db.prepare(437    `UPDATE saved_searches SET alert_enabled = ?,438       alert_frequency = COALESCE(?, alert_frequency)439     WHERE user_id = ? AND id = ?`,440  ).run(enabled ? 1 : 0,441        ["instant", "daily", "weekly"].includes(frequency ?? "") ? frequency : null,442        userId, id);443}444445/* ---------- export Loi 25 ---------- */446447export function exportUserData(userId: number): Record<string, unknown> {448  const user = db.prepare(449    `SELECT ka_id, email, name, city, role, created_at, last_login,450            personalization, history_enabled, recos_enabled451     FROM users WHERE id = ?`,452  ).get(userId);453  const favorites = db.prepare(454    "SELECT app, item_id, title, subtitle, price_label, url, created_at FROM favorites WHERE user_id = ?",455  ).all(userId);456  const searches = savedSearchesOf(userId);457  const hidden = hiddenOf(userId);458  const overrides = overridesOf(userId);459  const events = db.prepare(460    `SELECT app, session_id, event_type, entity_type, entity_id, query,461            filters, position, metadata, created_at462     FROM user_events WHERE user_id = ? ORDER BY id DESC LIMIT 8000`,463  ).all(userId);464  const prefs = cachedProfile(userId);465  return {466    exported_at: new Date().toISOString(),467    user, favorites, saved_searches: searches, hidden_items: hidden,468    pref_overrides: overrides,469    preference_profile: prefs ? JSON.parse(prefs.profile) : null,470    events,471  };472}473