SPB Git

spb/groupe-ka Public

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

TypeScript 93.9% CSS 6%
8.3 KB · 295 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA ID — base d'utilisateurs partagée du groupe (SQLite WAL).3import Database from "better-sqlite3";4import path from "path";5import fs from "fs";67function open() {8  const dbPath =9    process.env.KA_DB_PATH ?? path.join(process.cwd(), "data", "ka-id.db");10  fs.mkdirSync(path.dirname(dbPath), { recursive: true });11  const db = new Database(dbPath);12  db.pragma("journal_mode = WAL");13  db.exec(`14    CREATE TABLE IF NOT EXISTS users (15      id INTEGER PRIMARY KEY AUTOINCREMENT,16      email TEXT UNIQUE NOT NULL,17      name TEXT NOT NULL,18      avatar_url TEXT,19      password_hash TEXT,20      google_sub TEXT UNIQUE,21      created_at TEXT NOT NULL DEFAULT (datetime('now'))22    );23  `);24  // KA ID — identifiant membre du groupe (« ka- » + 10 chiffres), créé ICI,25  // au hub : les plateformes (Lou·Ka…) le reçoivent via le jeton SSO.26  try {27    db.exec("ALTER TABLE users ADD COLUMN ka_id TEXT");28  } catch {29    /* colonne déjà présente */30  }31  try {32    db.exec("ALTER TABLE users ADD COLUMN last_login TEXT");33  } catch {34    /* colonne déjà présente */35  }36  // Sign in with Apple (2026-08-13) — même rôle que google_sub.37  try {38    db.exec("ALTER TABLE users ADD COLUMN apple_sub TEXT");39  } catch {40    /* colonne déjà présente */41  }42  db.exec(43    "CREATE UNIQUE INDEX IF NOT EXISTS users_apple_sub ON users(apple_sub)",44  );45  try {46    db.exec("ALTER TABLE users ADD COLUMN role TEXT");47  } catch {48    /* colonne déjà présente */49  }50  // Profil enrichi (2026-08-13) — saisi sur le hub, LA source de vérité51  // affichée par toutes les plateformes du groupe.52  for (const col of [53    "bio TEXT",54    "city TEXT",55    "phone TEXT",56    "website TEXT",57    "job_title TEXT",58    "company TEXT",59    "birth_date TEXT",60    "socials TEXT",61    "public INTEGER DEFAULT 0",62  ]) {63    try {64      db.exec(`ALTER TABLE users ADD COLUMN ${col}`);65    } catch {66      /* colonne déjà présente */67    }68  }69  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id)");70  // rattrapage : tout compte existant sans KA ID en reçoit un71  const missing = db72    .prepare("SELECT id FROM users WHERE ka_id IS NULL")73    .all() as { id: number }[];74  for (const row of missing) assignKaId(db, row.id);75  // Favoris unifiés « Mon univers Ka » (2026-08-13) — le hub est LE magasin76  // central : les plateformes poussent/lisent via /api/sso/favorites.77  db.exec(`78    CREATE TABLE IF NOT EXISTS favorites (79      id INTEGER PRIMARY KEY AUTOINCREMENT,80      user_id INTEGER NOT NULL,81      app TEXT NOT NULL,82      item_id TEXT NOT NULL,83      title TEXT NOT NULL,84      subtitle TEXT,85      price_label TEXT,86      image_url TEXT,87      url TEXT,88      meta TEXT,89      created_at TEXT NOT NULL DEFAULT (datetime('now')),90      updated_at TEXT NOT NULL DEFAULT (datetime('now')),91      UNIQUE(user_id, app, item_id)92    );93    CREATE INDEX IF NOT EXISTS favorites_user ON favorites(user_id, app);94  `);95  return db;96}9798function randomKaId(): string {99  let digits = "";100  for (let i = 0; i < 10; i++)101    digits += Math.floor(Math.random() * 10).toString();102  return `ka-${digits}`;103}104105function assignKaId(d: Database.Database, id: number): string {106  for (;;) {107    const kid = randomKaId();108    try {109      d.prepare("UPDATE users SET ka_id = ? WHERE id = ?").run(kid, id);110      return kid;111    } catch {112      /* collision (1 chance sur 10 milliards) : on retente */113    }114  }115}116117/** KA ID d'un compte — l'attribue s'il manque (source de vérité du groupe). */118export function ensureKaId(id: number): string {119  const row = db.prepare("SELECT ka_id FROM users WHERE id = ?").get(id) as120    | { ka_id: string | null }121    | undefined;122  if (row?.ka_id) return row.ka_id;123  return assignKaId(db, id);124}125126// Singleton (survit au rechargement à chaud en dev).127const g = globalThis as unknown as { __kaDb?: Database.Database };128export const db: Database.Database = g.__kaDb ?? (g.__kaDb = open());129130export type UserRow = {131  id: number;132  email: string;133  name: string;134  avatar_url: string | null;135  password_hash: string | null;136  google_sub: string | null;137  apple_sub: string | null;138  ka_id: string | null;139  created_at: string;140  last_login: string | null;141  role: string | null;142  bio: string | null;143  city: string | null;144  phone: string | null;145  website: string | null;146  job_title: string | null;147  company: string | null;148  birth_date: string | null;149  socials: string | null; // JSON {instagram, facebook, x, linkedin, tiktok, youtube}150  public: number;151};152153export const SOCIAL_KEYS = [154  "instagram",155  "facebook",156  "x",157  "linkedin",158  "tiktok",159  "youtube",160] as const;161162export function parseSocials(raw: string | null): Record<string, string> {163  try {164    const obj = JSON.parse(raw ?? "{}") as Record<string, unknown>;165    const out: Record<string, string> = {};166    for (const k of SOCIAL_KEYS)167      if (typeof obj[k] === "string" && obj[k]) out[k] = obj[k] as string;168    return out;169  } catch {170    return {};171  }172}173174export function ageFromBirthDate(birth: string | null): number | null {175  if (!birth || !/^\d{4}-\d{2}-\d{2}$/.test(birth)) return null;176  const b = new Date(birth + "T00:00:00Z");177  if (Number.isNaN(b.getTime())) return null;178  const now = new Date();179  let age = now.getUTCFullYear() - b.getUTCFullYear();180  const m = now.getUTCMonth() - b.getUTCMonth();181  if (m < 0 || (m === 0 && now.getUTCDate() < b.getUTCDate())) age--;182  return age >= 0 && age < 130 ? age : null;183}184185/** Horodate la connexion (appelé à chaque login/register/SSO). */186export function touchLastLogin(id: number): void {187  db.prepare(188    "UPDATE users SET last_login = datetime('now') WHERE id = ?",189  ).run(id);190}191192export function findUserByEmail(email: string): UserRow | undefined {193  return db194    .prepare("SELECT * FROM users WHERE email = ?")195    .get(email.toLowerCase()) as UserRow | undefined;196}197198export function findUserById(id: number): UserRow | undefined {199  return db.prepare("SELECT * FROM users WHERE id = ?").get(id) as200    | UserRow201    | undefined;202}203204export function findUserByGoogleSub(sub: string): UserRow | undefined {205  return db.prepare("SELECT * FROM users WHERE google_sub = ?").get(sub) as206    | UserRow207    | undefined;208}209210export function findUserByAppleSub(sub: string): UserRow | undefined {211  return db.prepare("SELECT * FROM users WHERE apple_sub = ?").get(sub) as212    | UserRow213    | undefined;214}215216export function findUserByKaId(kaId: string): UserRow | undefined {217  return db.prepare("SELECT * FROM users WHERE ka_id = ?").get(kaId) as218    | UserRow219    | undefined;220}221222/* ---------- Favoris unifiés ---------- */223224export type FavoriteRow = {225  id: number;226  user_id: number;227  app: string;228  item_id: string;229  title: string;230  subtitle: string | null;231  price_label: string | null;232  image_url: string | null;233  url: string | null;234  meta: string | null;235  created_at: string;236  updated_at: string;237};238239export function upsertFavorite(240  userId: number,241  app: string,242  item: {243    item_id: string;244    title: string;245    subtitle?: string;246    price_label?: string;247    image_url?: string;248    url?: string;249    meta?: unknown;250  },251): void {252  db.prepare(253    `INSERT INTO favorites (user_id, app, item_id, title, subtitle,254                            price_label, image_url, url, meta)255     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)256     ON CONFLICT(user_id, app, item_id) DO UPDATE SET257       title = excluded.title, subtitle = excluded.subtitle,258       price_label = excluded.price_label, image_url = excluded.image_url,259       url = excluded.url, meta = excluded.meta,260       updated_at = datetime('now')`,261  ).run(262    userId,263    app,264    item.item_id,265    item.title,266    item.subtitle ?? null,267    item.price_label ?? null,268    item.image_url ?? null,269    item.url ?? null,270    item.meta != null ? JSON.stringify(item.meta) : null,271  );272}273274export function removeFavorite(275  userId: number,276  app: string,277  itemId: string,278): void {279  db.prepare(280    "DELETE FROM favorites WHERE user_id = ? AND app = ? AND item_id = ?",281  ).run(userId, app, itemId);282}283284export function favoritesOf(userId: number, app?: string): FavoriteRow[] {285  return (286    app287      ? db.prepare(288          "SELECT * FROM favorites WHERE user_id = ? AND app = ? ORDER BY created_at DESC",289        ).all(userId, app)290      : db.prepare(291          "SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC",292        ).all(userId)293  ) as FavoriteRow[];294}295