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%

feat(ka-id v2.1): le moteur connaît mieux le membre — shrinkage par valeur (une seule consultation ne vaut jamais une affinité pleine), affinité de langue transversale, filtrage collaboratif item-item par co-favoris (profile.apps[app].similar) ; alertes ACTIVES : évaluation baseline sur les API publiques (src/lib/alerts.ts, adaptateurs 9 apps), courriels Resend selon la fréquence, route /api/kaid/alerts/tick (clé KAID_TICK_KEY) + cron pm2 horaire groupe-ka-kaid-alerts ; « Pour vous » enrichi des co-favoris des membres semblables

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent 681a15f

5 changed files +298 −24

added src/app/api/kaid/alerts/tick/route.ts +23 −0
@@ -0,0 +1,23 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA ID v2 — tick d'évaluation des alertes (pm2 cron horaire sur le nœud) :
3 +// GET /api/kaid/alerts/tick?key=<KAID_TICK_KEY>
4 +// Relance chaque recherche sauvegardée avec alerte active sur l'API publique
5 +// de sa plateforme, compare au dernier total connu et notifie par courriel
6 +// (voir src/lib/alerts.ts). Clé requise — jamais exposée au navigateur.
7 +import { NextRequest, NextResponse } from "next/server";
8 +import crypto from "crypto";
9 +import { runAlerts } from "@/lib/alerts";
10 +
11 +export const dynamic = "force-dynamic";
12 +export const maxDuration = 300;
13 +
14 +export async function GET(req: NextRequest) {
15 + const key = req.nextUrl.searchParams.get("key") ?? "";
16 + const expected = process.env.KAID_TICK_KEY ?? "";
17 + const a = Buffer.from(key);
18 + const b = Buffer.from(expected);
19 + if (!expected || a.length !== b.length || !crypto.timingSafeEqual(a, b))
20 + return NextResponse.json({ error: "clé invalide" }, { status: 401 });
21 + const summary = await runAlerts();
22 + return NextResponse.json({ ok: true, ...summary });
23 +}
modified src/app/mon-ka/page.tsx +29 −2
@@ -6,7 +6,7 @@
6 6 import type { Metadata } from "next";
7 7 import { redirect } from "next/navigation";
8 8 import { getSessionUser } from "@/lib/auth";
9 −import { ensureKaId, favoritesOf, type FavoriteRow } from "@/lib/db";
9 +import { db, ensureKaId, favoritesOf, type FavoriteRow } from "@/lib/db";
10 10 import { SSO_CLIENTS } from "@/lib/sso";
11 11 import {
12 12 privacyOf, savedSearchesOf, eventsOf, eventsCount, hiddenOf,
@@ -121,6 +121,31 @@ function buildFeed(
121 121 return feed.slice(0, 8);
122 122 }
123 123
124 +/** Filtrage collaboratif : ce que les membres aux goûts semblables ont aussi
125 + * aimé (co-favoris calculés par le moteur) — métadonnées relues du magasin
126 + * central de favoris. */
127 +function similarFeed(profile: ReturnType<typeof getProfile> | null): FeedItem[] {
128 + if (!profile) return [];
129 + const out: FeedItem[] = [];
130 + const meta = db.prepare(
131 + `SELECT title, url FROM favorites
132 + WHERE app = ? AND item_id = ? ORDER BY updated_at DESC LIMIT 1`,
133 + );
134 + for (const [app, p] of Object.entries(profile.apps)) {
135 + for (const id of (p.similar ?? []).slice(0, 3)) {
136 + const row = meta.get(app, id) as { title: string; url: string | null } | undefined;
137 + if (!row) continue;
138 + out.push({
139 + icon: "✦",
140 + app,
141 + text: `Les membres aux goûts proches des vôtres aiment aussi « ${row.title} » (${appLabel(app)})`,
142 + href: row.url ?? undefined,
143 + });
144 + }
145 + }
146 + return out.slice(0, 4);
147 +}
148 +
124 149 export default async function MonKa() {
125 150 const user = await getSessionUser();
126 151 if (!user) redirect("/connexion?next=%2Fmon-ka");
@@ -133,7 +158,9 @@ export default async function MonKa() {
133 158 const profile = privacy.personalization ? getProfile(user.id) : null;
134 159 const learned = profile ? learnedSummary(profile) : [];
135 160 const recent = privacy.history ? eventsOf(user.id, { limit: 30 }) : [];
136 − const feed = privacy.recos ? buildFeed(learned, searches, favs) : [];
161 + const feed = privacy.recos
162 + ? [...similarFeed(profile), ...buildFeed(learned, searches, favs)].slice(0, 10)
163 + : [];
137 164
138 165 const byApp = new Map<string, FavoriteRow[]>();
139 166 for (const f of favs) {
added src/lib/alerts.ts +148 −0
@@ -0,0 +1,148 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA ID v2 — évaluation des alertes de recherches sauvegardées.
3 +// Méthode « baseline » : pour chaque alerte active, on relance la recherche
4 +// sur l'API publique de la plateforme (les filtres sauvegardés SONT ses
5 +// paramètres) et on compare le total au dernier total connu — une hausse
6 +// = des nouveautés → courriel Resend (fréquence respectée). Robuste et
7 +// 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).
9 +import { db } from "./db";
10 +import { sendEmail } from "./email";
11 +
12 +type Adapter = { base: string; path: string; small: Record<string, string>; total: string };
13 +
14 +// app → API de listing publique (paramètre « 1 résultat » + clé du total)
15 +const 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 + "resto-ka": { base: "https://www.resto-ka.com", path: "/api/restaurants", small: { limit: "1" }, total: "total" },
22 + "sorti-ka": { base: "https://www.sorti-ka.com", path: "/api/events", small: { limit: "1" }, total: "total" },
23 + "job-ka": { base: "https://www.job-ka.com", path: "/api/jobs", small: { limit: "1" }, total: "total" },
24 + "crea-ka": { base: "https://www.crea-ka.com", path: "/api/creators", small: { limit: "1" }, total: "total" },
25 +};
26 +
27 +const APP_LABELS: Record<string, string> = {
28 + "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "auto-ka": "Auto·Ka",
29 + "food-ka": "Food·Ka", "fabri-ka": "Fabri·Ka", "resto-ka": "Resto·Ka",
30 + "sorti-ka": "Sorti·Ka", "job-ka": "Job·Ka", "crea-ka": "Créa·Ka",
31 +};
32 +
33 +type AlertRow = {
34 + id: number; user_id: number; app: string; label: string;
35 + query: string | null; filters: string | null; url: string | null;
36 + alert_frequency: string; last_total: number | null;
37 + last_notified_at: string | null;
38 + email: string; name: string;
39 +};
40 +
41 +function dueByFrequency(row: AlertRow): boolean {
42 + if (!row.last_notified_at) return true;
43 + const hours =
44 + (Date.now() - Date.parse(row.last_notified_at.replace(" ", "T") + "Z")) / 3.6e6;
45 + if (row.alert_frequency === "instant") return hours >= 0.9;
46 + if (row.alert_frequency === "weekly") return hours >= 156;
47 + return hours >= 20; // daily
48 +}
49 +
50 +async function currentTotal(row: AlertRow): Promise<number | null> {
51 + const a = ADAPTERS[row.app];
52 + if (!a) return null;
53 + const params = new URLSearchParams(a.small);
54 + if (row.query) params.set("q", row.query);
55 + if (row.filters) {
56 + try {
57 + const f = JSON.parse(row.filters) as Record<string, unknown>;
58 + for (const [k, v] of Object.entries(f)) {
59 + if (v == null || typeof v === "object") continue;
60 + if (k === "q" || k in a.small) continue;
61 + params.set(k.slice(0, 40), String(v).slice(0, 120));
62 + }
63 + } catch { /* filtres illisibles : requête large */ }
64 + }
65 + try {
66 + const r = await fetch(`${a.base}${a.path}?${params}`, {
67 + signal: AbortSignal.timeout(8000),
68 + headers: { "User-Agent": "ka-id-alerts/1.0 (groupe-ka.com)" },
69 + });
70 + if (!r.ok) return null;
71 + const data = (await r.json()) as Record<string, unknown>;
72 + const total = data[a.total];
73 + return typeof total === "number" ? total : null;
74 + } catch {
75 + return null;
76 + }
77 +}
78 +
79 +function alertEmail(row: AlertRow, nouveaux: number): { html: string; text: string } {
80 + const label = APP_LABELS[row.app] ?? row.app;
81 + const link = row.url ?? ADAPTERS[row.app]?.base ?? "https://www.groupe-ka.com";
82 + const text =
83 + `Bonjour ${row.name.split(" ")[0]},\n\n` +
84 + `${nouveaux} nouveau${nouveaux > 1 ? "x" : ""} résultat${nouveaux > 1 ? "s" : ""} ` +
85 + `correspond${nouveaux > 1 ? "ent" : ""} à votre recherche sauvegardée ` +
86 + `« ${row.label} » sur ${label}.\n\nVoir : ${link}\n\n` +
87 + `Gérer vos alertes : https://www.groupe-ka.com/mon-ka\n— Groupe KA`;
88 + const html =
89 + `<p>Bonjour ${row.name.split(" ")[0]},</p>` +
90 + `<p><strong>${nouveaux} nouveau${nouveaux > 1 ? "x" : ""} résultat${nouveaux > 1 ? "s" : ""}</strong> ` +
91 + `correspond${nouveaux > 1 ? "ent" : ""} à votre recherche sauvegardée ` +
92 + `« <strong>${row.label}</strong> » sur ${label}.</p>` +
93 + `<p><a href="${link}">Voir les résultats →</a></p>` +
94 + `<p style="color:#666;font-size:13px">Vous recevez ce courriel parce qu'une alerte est active ` +
95 + `sur cette recherche. <a href="https://www.groupe-ka.com/mon-ka">Gérer mes alertes</a></p>`;
96 + return { html, text };
97 +}
98 +
99 +export async function runAlerts(): Promise<{
100 + checked: number; notified: number; baselined: number; errors: number;
101 +}> {
102 + const rows = db.prepare(
103 + `SELECT s.id, s.user_id, s.app, s.label, s.query, s.filters, s.url,
104 + s.alert_frequency, s.last_total, s.last_notified_at,
105 + u.email, u.name
106 + FROM saved_searches s JOIN users u ON u.id = s.user_id
107 + WHERE s.alert_enabled = 1`,
108 + ).all() as AlertRow[];
109 + let checked = 0, notified = 0, baselined = 0, errors = 0;
110 + for (const row of rows) {
111 + if (!ADAPTERS[row.app]) continue;
112 + checked++;
113 + const total = await currentTotal(row);
114 + if (total == null) { errors++; continue; }
115 + if (row.last_total == null) {
116 + db.prepare("UPDATE saved_searches SET last_total = ? WHERE id = ?")
117 + .run(total, row.id);
118 + baselined++;
119 + continue;
120 + }
121 + if (total > row.last_total && dueByFrequency(row)) {
122 + const nouveaux = total - row.last_total;
123 + try {
124 + const { html, text } = alertEmail(row, nouveaux);
125 + await sendEmail({
126 + to: row.email,
127 + subject: `${nouveaux} nouveauté${nouveaux > 1 ? "s" : ""} — ${row.label}`,
128 + html, text,
129 + });
130 + db.prepare(
131 + `UPDATE saved_searches SET last_total = ?, last_run_at = datetime('now'),
132 + last_triggered_at = datetime('now'), last_notified_at = datetime('now')
133 + WHERE id = ?`,
134 + ).run(total, row.id);
135 + notified++;
136 + } catch {
137 + errors++;
138 + }
139 + } else if (total < row.last_total) {
140 + // baisse (annonces expirées) : rebaser sans bruit. Une hausse hors
141 + // fenêtre de fréquence n'est PAS rebasée — elle sera notifiée au
142 + // prochain tick admissible.
143 + db.prepare("UPDATE saved_searches SET last_total = ? WHERE id = ?")
144 + .run(total, row.id);
145 + }
146 + }
147 + return { checked, notified, baselined, errors };
148 +}
modified src/lib/kaid-data.ts +9 −0
@@ -71,6 +71,15 @@ db.exec(`
71 71 );
72 72 `);
73 73
74 +// Alertes actives (2026-08-26) : baseline du total de résultats + dernier envoi.
75 +for (const col of ["last_total INTEGER", "last_notified_at TEXT"]) {
76 + try {
77 + db.exec(`ALTER TABLE saved_searches ADD COLUMN ${col}`);
78 + } catch {
79 + /* colonne déjà présente */
80 + }
81 +}
82 +
74 83 // Confidentialité — trois interrupteurs, tous ON par défaut (opt-out).
75 84 for (const col of [
76 85 "personalization INTEGER DEFAULT 1", // reranking personnalisé
modified src/lib/personal.ts +89 −22
@@ -45,8 +45,12 @@ const HALF_LIFE_DAYS = 30;
45 45 const LOCATION_DIMS = new Set([
46 46 "city", "region", "sector", "quartier", "neighborhood", "location", "ville",
47 47 ]);
48 +const LANGUAGE_DIMS = new Set(["language", "langue"]);
48 49 const MAX_VALUES_PER_DIM = 12;
49 50 const MIN_AFFINITY = 0.05;
51 +// Shrinkage : une valeur vue une seule fois ne mérite pas une affinité
52 +// pleine — le facteur de support tend vers 1 avec l'accumulation de poids.
53 +const SUPPORT_SCALE = 3;
50 54
51 55 /* ---------- types ---------- */
52 56
@@ -56,13 +60,17 @@ export type AppProfile = {
56 60 n: number; // volume pondéré de signaux positifs
57 61 dims: Record<string, DimProfile>;
58 62 ranges: Record<string, RangeProfile>;
63 + similar?: string[]; // co-favoris des membres semblables
59 64 };
60 65 export type Profile = {
61 66 version: 2;
62 67 computed_at: string;
63 68 events_n: number;
64 69 apps: Record<string, AppProfile>;
65 − global: { location: DimProfile & { apps: number } };
70 + global: {
71 + location: DimProfile & { apps: number };
72 + language?: DimProfile & { apps: number };
73 + };
66 74 };
67 75
68 76 /* ---------- accumulation ---------- */
@@ -215,6 +223,7 @@ export function computeProfile(userId: number): Profile {
215 223
216 224 const apps: Record<string, AppProfile> = {};
217 225 const globalLoc = new Map<string, { score: number; apps: Set<string> }>();
226 + const globalLang = new Map<string, { score: number; apps: Set<string> }>();
218 227
219 228 for (const [app, acc] of perApp) {
220 229 const dims: Record<string, DimProfile> = {};
@@ -228,7 +237,14 @@ export function computeProfile(userId: number): Profile {
228 237 }
229 238 const entries = [...m.entries()]
230 239 .filter(([val]) => !banned.has(`${app}|${dim}|${val}`) && !banned.has(`*|${dim}|${val}`))
231 − .map(([val, { score }]) => [val, Math.max(-1, Math.min(1, score / maxPos))] as const)
240 + .map(([val, { score, pos }]) => {
241 + // shrinkage : affinité relative × support accumulé (une seule
242 + // consultation ⇒ affinité prudente, jamais 1.0 d'emblée)
243 + const support = pos + Math.max(0, -score);
244 + const factor = 1 - Math.exp(-support / SUPPORT_SCALE);
245 + const aff = Math.max(-1, Math.min(1, score / maxPos)) * factor;
246 + return [val, aff] as const;
247 + })
232 248 .filter(([, aff]) => Math.abs(aff) >= MIN_AFFINITY)
233 249 .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
234 250 .slice(0, MAX_VALUES_PER_DIM);
@@ -238,13 +254,15 @@ export function computeProfile(userId: number): Profile {
238 254 );
239 255 const conf = Math.round((1 - Math.exp(-acc.posw / 8)) * 100) / 100;
240 256 dims[dim] = { values, conf };
241 − if (LOCATION_DIMS.has(dim))
257 + const globalMap = LOCATION_DIMS.has(dim) ? globalLoc
258 + : LANGUAGE_DIMS.has(dim) ? globalLang : null;
259 + if (globalMap)
242 260 for (const [val, aff] of entries) {
243 261 if (aff <= 0) continue;
244 − const g = globalLoc.get(val) ?? { score: 0, apps: new Set<string>() };
262 + const g = globalMap.get(val) ?? { score: 0, apps: new Set<string>() };
245 263 g.score += aff;
246 264 g.apps.add(app);
247 − globalLoc.set(val, g);
265 + globalMap.set(val, g);
248 266 }
249 267 }
250 268 const ranges: Record<string, RangeProfile> = {};
@@ -252,8 +270,13 @@ export function computeProfile(userId: number): Profile {
252 270 const r = weightedQuartiles(arr);
253 271 if (r) ranges[dim] = r;
254 272 }
255 − if (Object.keys(dims).length || Object.keys(ranges).length)
256 − apps[app] = { n: Math.round(acc.posw * 10) / 10, dims, ranges };
273 + // Filtrage collaboratif léger (item-item par co-favoris) : « les membres
274 + // qui ont aimé les mêmes annonces que vous ont aussi aimé… ». Ne devient
275 + // actif que lorsque d'autres membres partagent des favoris — sinon vide.
276 + const similar = coFavorites(userId, app);
277 + if (Object.keys(dims).length || Object.keys(ranges).length || similar.length)
278 + apps[app] = { n: Math.round(acc.posw * 10) / 10, dims, ranges,
279 + ...(similar.length ? { similar } : {}) };
257 280 }
258 281
259 282 // Renforcements explicites.
@@ -263,33 +286,77 @@ export function computeProfile(userId: number): Profile {
263 286 dim.values[o.value.toLowerCase()] = 1;
264 287 }
265 288
266 − // Localisation transversale : forte seulement si vue dans 2 univers ou +.
267 − let maxLoc = 0;
268 − for (const g of globalLoc.values()) maxLoc = Math.max(maxLoc, g.score);
269 − const locValues: Record<string, number> = {};
270 − let locApps = 0;
271 − for (const [val, g] of [...globalLoc.entries()]
272 − .sort((a, b) => b[1].score - a[1].score).slice(0, MAX_VALUES_PER_DIM)) {
273 − const crossFactor = g.apps.size >= 2 ? 1 : 0.5;
274 − locValues[val] = Math.round((g.score / (maxLoc || 1)) * crossFactor * 100) / 100;
275 − locApps = Math.max(locApps, g.apps.size);
289 + // CF aussi pour les univers où le membre n'a QUE des favoris (pas d'événement)
290 + const favApps = db.prepare(
291 + "SELECT DISTINCT app FROM favorites WHERE user_id = ?",
292 + ).all(userId) as { app: string }[];
293 + for (const { app } of favApps) {
294 + if (apps[app]?.similar) continue;
295 + const similar = coFavorites(userId, app);
296 + if (!similar.length) continue;
297 + if (apps[app]) apps[app].similar = similar;
298 + else apps[app] = { n: 0, dims: {}, ranges: {}, similar };
276 299 }
277 300
301 + // Transversal : fort seulement si vu dans 2 univers ou + (localisation,
302 + // langue) — jamais « cherche à Montréal sur Lou·Ka ⇒ tout à Montréal ».
303 + const aggregate = (m: Map<string, { score: number; apps: Set<string> }>) => {
304 + let max = 0;
305 + for (const g of m.values()) max = Math.max(max, g.score);
306 + const values: Record<string, number> = {};
307 + let napps = 0;
308 + for (const [val, g] of [...m.entries()]
309 + .sort((a, b) => b[1].score - a[1].score).slice(0, MAX_VALUES_PER_DIM)) {
310 + const crossFactor = g.apps.size >= 2 ? 1 : 0.5;
311 + values[val] = Math.round((g.score / (max || 1)) * crossFactor * 100) / 100;
312 + napps = Math.max(napps, g.apps.size);
313 + }
314 + return {
315 + values,
316 + conf: Math.round(Math.min(1, napps / 3) * 100) / 100,
317 + apps: napps,
318 + };
319 + };
320 +
278 321 return {
279 322 version: 2,
280 323 computed_at: new Date().toISOString(),
281 324 events_n: events.length,
282 325 apps,
283 326 global: {
284 − location: {
285 − values: locValues,
286 − conf: Math.round(Math.min(1, locApps / 3) * 100) / 100,
287 − apps: locApps,
288 − },
327 + location: aggregate(globalLoc),
328 + ...(globalLang.size ? { language: aggregate(globalLang) } : {}),
289 329 },
290 330 };
291 331 }
292 332
333 +/** Co-favoris : les autres favoris des membres qui partagent au moins un
334 + * favori avec l'utilisateur dans cet univers (item-item, max 12). */
335 +function coFavorites(userId: number, app: string): string[] {
336 + try {
337 + const rows = db.prepare(
338 + `WITH mine AS (
339 + SELECT item_id FROM favorites WHERE user_id = ? AND app = ?
340 + ),
341 + peers AS (
342 + SELECT DISTINCT user_id FROM favorites
343 + WHERE app = ? AND user_id != ?
344 + AND item_id IN (SELECT item_id FROM mine)
345 + )
346 + SELECT f.item_id, COUNT(DISTINCT f.user_id) AS n
347 + FROM favorites f
348 + WHERE f.app = ? AND f.user_id IN (SELECT user_id FROM peers)
349 + AND f.item_id NOT IN (SELECT item_id FROM mine)
350 + GROUP BY f.item_id
351 + ORDER BY n DESC, MAX(f.created_at) DESC
352 + LIMIT 12`,
353 + ).all(userId, app, app, userId, app) as { item_id: string }[];
354 + return rows.map((r) => r.item_id);
355 + } catch {
356 + return [];
357 + }
358 +}
359 +
293 360 /** Profil (avec cache 15 min, invalidé par les événements forts). */
294 361 export function getProfile(userId: number): Profile {
295 362 const cached = cachedProfile(userId);
296 363