@tekkare/romulus 0.1.243 → 0.1.245

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +6 -0
  3. package/layer/app/components/OipShell.vue +3 -0
  4. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  5. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  6. package/layer/app/components/layout/tool/alerts.vue +969 -0
  7. package/layer/app/components/layout/tool/export.vue +590 -0
  8. package/layer/app/components/layout/tool/views.vue +933 -0
  9. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  10. package/layer/app/components/layout/viewPill.vue +550 -0
  11. package/layer/app/components/panels/OipPanelStatistics.vue +447 -0
  12. package/layer/app/composables/useAdminAccess.ts +86 -0
  13. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  14. package/layer/app/composables/useAlertSource.ts +210 -0
  15. package/layer/app/composables/useAlerts.ts +345 -0
  16. package/layer/app/composables/useExportSource.ts +236 -0
  17. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  18. package/layer/app/composables/usePermalink.ts +108 -0
  19. package/layer/app/composables/useSavedExports.ts +286 -0
  20. package/layer/app/composables/useViewCardActions.ts +500 -0
  21. package/layer/app/composables/useViewTemplates.ts +369 -0
  22. package/layer/app/composables/useViews.ts +574 -0
  23. package/layer/app/oip-defaults/alerts.ts +34 -0
  24. package/layer/app/oip-defaults/api.ts +18 -0
  25. package/layer/app/oip-defaults/export.ts +23 -0
  26. package/layer/app/oip-defaults/ui.ts +21 -0
  27. package/layer/app/oip-defaults/views.ts +59 -0
  28. package/layer/app/types/stats.ts +49 -0
  29. package/layer/app/utils/alertFieldLabels.ts +191 -0
  30. package/layer/app/utils/oipRail.ts +29 -3
  31. package/layer/app/utils/oipScopeStore.ts +112 -0
  32. package/layer/app/utils/oipScopes.ts +376 -0
  33. package/layer/app/utils/viewPages.ts +30 -0
  34. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  35. package/layer/app/utils/viewTemplates.ts +116 -0
  36. package/layer/nuxt.config.ts +18 -0
  37. package/layer/server/api/oip/notes.get.ts +1 -1
  38. package/layer/server/api/oip/stats/users.get.ts +80 -0
  39. package/layer/server/api/oip/stats.get.ts +88 -0
  40. package/layer/server/utils/oipPosthog.ts +134 -0
  41. package/layer/server/utils/oipWorkspaces.ts +51 -0
  42. package/package.json +3 -2
@@ -0,0 +1,116 @@
1
+ /**
2
+ * viewTemplates - les vues prêtes a l'emploi fournies par Tekkare.
3
+ *
4
+ * Un template est une vue que personne n'a enregistree : un jeu de criteres
5
+ * ecrit ici, applique comme une vue temporaire (sans identifiant, cf.
6
+ * `isTemporaryView`). L'utilisateur le demarre, le retouche, et l'enregistre
7
+ * dans ses vues s'il veut le garder. Rien ne part en base tant qu'il ne le
8
+ * demande pas.
9
+ *
10
+ * Les criteres se construisent avec la langue de l'ecran, jamais en dur :
11
+ * - les filtres tapent `status_fr` ou `status_en` selon la locale, et la vue
12
+ * stocke le libelle affiche (« Insuffisant » / « Insufficient ») ;
13
+ * - un filtre booleen (`CheckFilter`) stocke `"<libelle du filtre>: true"`,
14
+ * donc « Oncologie: true » en francais et « Oncology: true » en anglais ;
15
+ * - sur la page Prix, c'est le NOM du filtre qui est traduit.
16
+ * D'ou le contexte `{ lang, t }` passe a chaque `criteria()`.
17
+ *
18
+ * Les valeurs sont celles des index : elles ont ete relevees par agregation,
19
+ * pas devinees (cf. la page projet du vault). Une valeur inventee ne filtre
20
+ * rien et le template ouvre un ecran vide.
21
+ */
22
+
23
+ import { VIEW_TEMPLATES } from "#oip-app/views";
24
+
25
+ export type ViewTemplateTone = "primary" | "success" | "warning" | "danger" | "info";
26
+
27
+ export interface ViewTemplateContext {
28
+ lang: "fr" | "en";
29
+ t: (key: string) => string;
30
+ /**
31
+ * Une cle i18n existe-t-elle. Les templates en dur n'en ont pas besoin
32
+ * (leurs cles sont ecrites ici, donc sures) ; ceux publies depuis l'app,
33
+ * si : ils relisent une cle rangee en base, qu'un renommage a pu perimer.
34
+ */
35
+ te: (key: string) => boolean;
36
+ }
37
+
38
+ /** Un critere de vue. `values` est un tableau, sauf pour la periode. */
39
+ export interface ViewTemplateCriterion {
40
+ filter: string;
41
+ values: string[] | string;
42
+ }
43
+
44
+ export interface ViewTemplate {
45
+ /** Identifiant stable : il sert de cle de carte, jamais d'id de vue. */
46
+ id: string;
47
+ /** Chemin de la page, sans prefixe de langue ni barre finale. */
48
+ page: string;
49
+ /** Icone Phosphor de la pastille. */
50
+ icon: string;
51
+ tone?: ViewTemplateTone;
52
+ title: Record<"fr" | "en", string>;
53
+ description: Record<"fr" | "en", string>;
54
+ criteria: (ctx: ViewTemplateContext) => ViewTemplateCriterion[];
55
+ /**
56
+ * Le template se demarre par un lien profond plutot qu'en posant les
57
+ * criteres a l'ecran.
58
+ *
59
+ * Les pages du portail « Medicaments France » ne portent pas des filtres
60
+ * mais une selection de produits, et personne ne la resout dans la page :
61
+ * c'est le middleware `03.scope-from-query` qui expanse un code ATC en
62
+ * liste de marques, remplit les listes par source (`medicamBrandList`,
63
+ * `susBrandList`...) et pose la vue temporaire. Un template de panier est
64
+ * donc l'URL qui le declenche, ce qui evite d'y figer une liste de marques
65
+ * qui vieillirait a chaque nouvelle AMM.
66
+ */
67
+ deepLink?: boolean;
68
+ /**
69
+ * Publie pour l'espace du lecteur nommement, et non pour tout le monde :
70
+ * la carte le dit (« Créé pour vous »).
71
+ */
72
+ forYou?: boolean;
73
+ }
74
+
75
+ /**
76
+ * Parametre d'URL qui dit de quel template vient le perimetre pose. Le
77
+ * middleware du lien profond le recopie sur la vue temporaire, la pastille de
78
+ * la barre nomme alors le template plutot que de dire « Vue temporaire », et la
79
+ * navigation le garde avec les autres cles de perimetre (cf. useDrugsNav).
80
+ */
81
+ export const VIEW_TEMPLATE_QUERY_PARAM = "template";
82
+
83
+ /**
84
+ * Critere de vue vers parametre d'URL (template en lien profond) et
85
+ * parametres a nettoyer avant d'en poser de nouveaux : metier de l'app, cf.
86
+ * contrat #oip-app/views.
87
+ */
88
+ export { VIEW_TEMPLATE_QUERY_KEYS, VIEW_TEMPLATE_QUERY_RESET } from "#oip-app/views";
89
+
90
+ /**
91
+ * Chemin comparable : sans prefixe de langue (`/en/price`), sans barre finale.
92
+ * Les pages declarent `route.path`, qui porte les deux.
93
+ */
94
+ export function normalizePage(path: string): string {
95
+ return String(path ?? "")
96
+ .replace(/^\/(?:en|fr)(?=\/|$)/, "")
97
+ .replace(/\/$/, "");
98
+ }
99
+
100
+ /**
101
+ * Un template du catalogue par identifiant, quelle que soit sa page : la
102
+ * pastille nomme le template dont vient la vue, et la vue suit le lecteur
103
+ * d'une page du portail a l'autre alors que l'identifiant porte la page de
104
+ * depart.
105
+ */
106
+ export function viewTemplateById(id: string): ViewTemplate | undefined {
107
+ return VIEW_TEMPLATES.find((template) => template.id === id);
108
+ }
109
+
110
+ /** Les templates d'une page, dans l'ordre du catalogue. */
111
+ export function viewTemplatesFor(page: string): ViewTemplate[] {
112
+ const wanted = normalizePage(page);
113
+ return VIEW_TEMPLATES.filter(
114
+ (template) => template.page === wanted,
115
+ );
116
+ }
@@ -8,7 +8,16 @@
8
8
  // pas dans `modules` : l'app les enregistre deja avec ses options (romulus
9
9
  // sans baseStyles, ses locales), et un second enregistrement par le layer
10
10
  // les reinstallerait avec les options par defaut.
11
+ import { fileURLToPath } from "node:url";
12
+
11
13
  export default defineNuxtConfig({
14
+ // Contrat avec l'app pour ce qui lui est propre (client API, bandeau,
15
+ // tables metier des vues et des alertes). Par defaut, les valeurs neutres
16
+ // du layer ; une app pointe `#oip-app` vers son dossier `app/oip` dans son
17
+ // propre nuxt.config, et sa valeur l'emporte.
18
+ alias: {
19
+ "#oip-app": fileURLToPath(new URL("./app/oip-defaults", import.meta.url)),
20
+ },
12
21
  future: {
13
22
  compatibilityVersion: 4,
14
23
  },
@@ -28,5 +37,14 @@ export default defineNuxtConfig({
28
37
  oipEsIndicesToken: "",
29
38
  /** Vrai en preproduction : interroge `staging_physical` quand il existe. */
30
39
  oipEsIndicesStaging: false,
40
+ /**
41
+ * Panneau Statistiques : cle personnelle PostHog (scope query:read), id
42
+ * numerique du projet et hote de l'API (pas celui d'ingestion). Vides :
43
+ * POSTHOG_STATS_KEY (ou POSTHOG_PERSONAL_API_KEY), POSTHOG_PROJECT_ID,
44
+ * POSTHOG_API_HOST.
45
+ */
46
+ oipPosthogKey: "",
47
+ oipPosthogProjectId: "",
48
+ oipPosthogHost: "",
31
49
  },
32
50
  });
@@ -146,7 +146,7 @@ export default defineEventHandler(async (event): Promise<ApiNote[]> => {
146
146
  // tout le portail. Sans sujet de cette app, la note n'est pas la sienne.
147
147
  const paths = (row.linked_subjects ?? [])
148
148
  .map((link) => link.OipSubject_subject_id)
149
- .filter((subject) => appUrls.includes(subject?.app?.prod_url))
149
+ .filter((subject) => appUrls.includes(subject?.app?.prod_url ?? ""))
150
150
  .map((subject) => (subject!.relative_path ?? "").trim())
151
151
  .filter(Boolean);
152
152
  const sources = (row.linked_sources ?? [])
@@ -0,0 +1,80 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { OIP_STATS_PATH, OIP_STATS_SINCE, oipStatsContext } from "../../../utils/oipPosthog";
3
+ import { oipWorkspaceNames } from "../../../utils/oipWorkspaces";
4
+ import { OIP_STATS_PERIODS, type OipStatsPeriod, type OipStatsUsers } from "../../../../app/types/stats";
5
+
6
+ /**
7
+ * GET /api/oip/stats/users?period=7d&scope=app&offset=0&limit=10&path=&internal=0,
8
+ * servi par le layer OIP (panneau « Statistiques »).
9
+ *
10
+ * Le classement des utilisateurs par vues de page sur la periode, pour toute
11
+ * l'app (`scope=app`) ou l'ecran `path` (`scope=page`), une page a la fois :
12
+ * `hasMore` dit s'il reste des lignes apres celle-ci.
13
+ *
14
+ * Le workspace est le dernier `workspace_id` porte par les vues de la
15
+ * personne sur la periode, nomme par la Virtual Lab API.
16
+ *
17
+ * Perimetre, cle et droit : cf. oipStatsContext.
18
+ */
19
+
20
+ const MAX_LIMIT = 100;
21
+ const MAX_OFFSET = 5000;
22
+ const CHUNK = 100;
23
+
24
+ const prop = (name: string) => `nullIf(toString(person.properties.${name}), '')`;
25
+
26
+ export default defineEventHandler(async (event): Promise<OipStatsUsers> => {
27
+ const { query, where, hogql } = await oipStatsContext(event);
28
+
29
+ const period = (OIP_STATS_PERIODS as readonly string[]).includes(String(query.period))
30
+ ? (query.period as OipStatsPeriod)
31
+ : "7d";
32
+ const onPage = query.scope === "page";
33
+ const offset = Math.min(MAX_OFFSET, Math.max(0, Math.floor(Number(query.offset) || 0)));
34
+ const limit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(Number(query.limit) || 10)));
35
+
36
+ // PostHog refuse OFFSET avec une cle personnelle : on lit depuis le haut du
37
+ // classement et on coupe ici. Une ligne de plus que demande : sa presence
38
+ // dit qu'il en reste. Lu par paquets de CHUNK lignes : les « Charger plus »
39
+ // suivants retombent sur la meme requete, servie par le cache.
40
+ const rowCap = Math.ceil((offset + limit + 1) / CHUNK) * CHUNK;
41
+ const ranking = hogql(`
42
+ SELECT
43
+ any(${prop("email")}),
44
+ any(${prop("firstName")}), any(${prop("lastName")}),
45
+ any(${prop("first_name")}), any(${prop("last_name")}), any(${prop("name")}),
46
+ argMaxIf(toString(properties.workspace_id), timestamp, notEmpty(coalesce(toString(properties.workspace_id), ''))),
47
+ count() AS pageviews,
48
+ uniq($session_id),
49
+ uniq(${OIP_STATS_PATH}),
50
+ max(timestamp) AS last_seen
51
+ FROM events
52
+ WHERE ${where} AND timestamp >= ${OIP_STATS_SINCE[period]}${onPage ? ` AND ${OIP_STATS_PATH} = {path}` : ""}
53
+ GROUP BY person_id
54
+ ORDER BY pageviews DESC, last_seen DESC
55
+ LIMIT ${rowCap}
56
+ `);
57
+ const [rows, workspaces] = await Promise.all([ranking, oipWorkspaceNames(event)]);
58
+
59
+ const text = (v: unknown) => (v == null ? "" : String(v).trim());
60
+ return {
61
+ hasMore: rows.length > offset + limit,
62
+ users: rows.slice(offset, offset + limit).map((r) => {
63
+ const [email, firstName, lastName, first_name, last_name, name, workspaceId, pageviews, sessions, pages, lastSeen] =
64
+ r;
65
+ const full =
66
+ [text(firstName), text(lastName)].filter(Boolean).join(" ") ||
67
+ [text(first_name), text(last_name)].filter(Boolean).join(" ") ||
68
+ text(name);
69
+ return {
70
+ email: text(email),
71
+ name: full,
72
+ workspace: workspaces.get(text(workspaceId)) ?? text(workspaceId),
73
+ pageviews: Number(pageviews) || 0,
74
+ sessions: Number(sessions) || 0,
75
+ pages: Number(pages) || 0,
76
+ lastSeen: text(lastSeen),
77
+ };
78
+ }),
79
+ };
80
+ });
@@ -0,0 +1,88 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { OIP_STATS_PATH, OIP_STATS_SINCE, oipStatsContext } from "../../utils/oipPosthog";
3
+ import {
4
+ OIP_STATS_PERIODS,
5
+ type OipStats,
6
+ type OipStatsMetrics,
7
+ type OipStatsPeriod,
8
+ } from "../../../app/types/stats";
9
+
10
+ /**
11
+ * GET /api/oip/stats?path=/hubr/x&internal=0, servi par le layer OIP
12
+ * (panneau « Statistiques »).
13
+ *
14
+ * L'audience de l'ecran ouvert et de toute l'app, lue dans PostHog par une
15
+ * requete HogQL : personnes, sessions et vues de page, aujourd'hui et sur 7,
16
+ * 30 et 90 jours, plus les pages les plus vues.
17
+ *
18
+ * L'app est designee par l'hote de ses `prod_url` (`$host` des evenements),
19
+ * comme les notes la designent par son URL : le projet PostHog est commun a
20
+ * toute la flotte. Le plugin PostHog des apps ne capture ni localhost ni
21
+ * staging, il n'y a donc que la prod a compter.
22
+ *
23
+ * `internal=0` (defaut) ecarte les comptes @tekkare.com.
24
+ *
25
+ * Cle PostHog et perimetre commun : cf. oipStatsContext.
26
+ *
27
+ * Reservee aux superadmins, comme les metadonnees.
28
+ */
29
+
30
+ const TOP_PAGES = 100;
31
+
32
+ export default defineEventHandler(async (event): Promise<OipStats> => {
33
+ const { path, hosts, where, hogql } = await oipStatsContext(event);
34
+ const SINCE = OIP_STATS_SINCE;
35
+ const PATH = OIP_STATS_PATH;
36
+
37
+ const onPage = `${PATH} = {path}`;
38
+ const metrics = (scope: string, cond: string) =>
39
+ OIP_STATS_PERIODS.flatMap((p) => {
40
+ const c = `timestamp >= ${SINCE[p]}${cond ? ` AND ${cond}` : ""}`;
41
+ return [
42
+ `uniqIf(person_id, ${c}) AS ${scope}_users_${p.replace(/\W/g, "")}`,
43
+ `uniqIf($session_id, ${c}) AS ${scope}_sessions_${p.replace(/\W/g, "")}`,
44
+ `countIf(${c}) AS ${scope}_pageviews_${p.replace(/\W/g, "")}`,
45
+ ];
46
+ });
47
+
48
+ const [totals, top] = await Promise.all([
49
+ hogql(`SELECT ${[...metrics("page", onPage), ...metrics("app", "")].join(", ")} FROM events WHERE ${where}`),
50
+ hogql(`
51
+ SELECT ${PATH} AS page,
52
+ ${OIP_STATS_PERIODS.map((p) => `countIf(timestamp >= ${SINCE[p]})`).join(", ")},
53
+ ${OIP_STATS_PERIODS.map((p) => `uniqIf(person_id, timestamp >= ${SINCE[p]})`).join(", ")}
54
+ FROM events
55
+ WHERE ${where}
56
+ GROUP BY page
57
+ ORDER BY count() DESC
58
+ LIMIT ${TOP_PAGES}
59
+ `),
60
+ ]);
61
+
62
+ // Une ligne : page puis app, chaque periode portant users, sessions, pageviews.
63
+ const row = (totals[0] ?? []).map(Number);
64
+ const read = (offset: number) =>
65
+ Object.fromEntries(
66
+ OIP_STATS_PERIODS.map((p, i) => {
67
+ const [users = 0, sessions = 0, pageviews = 0] = row.slice(offset + i * 3, offset + i * 3 + 3);
68
+ return [p, { users, sessions, pageviews } satisfies OipStatsMetrics];
69
+ }),
70
+ ) as Record<OipStatsPeriod, OipStatsMetrics>;
71
+ const span = OIP_STATS_PERIODS.length * 3;
72
+
73
+ const n = OIP_STATS_PERIODS.length;
74
+ const byPeriod = (cells: unknown[]) =>
75
+ Object.fromEntries(OIP_STATS_PERIODS.map((p, i) => [p, Number(cells[i]) || 0])) as Record<OipStatsPeriod, number>;
76
+
77
+ return {
78
+ path,
79
+ hosts,
80
+ page: read(0),
81
+ app: read(span),
82
+ topPages: top.map((r) => ({
83
+ path: String(r[0] || "/"),
84
+ pageviews: byPeriod(r.slice(1, 1 + n)),
85
+ users: byPeriod(r.slice(1 + n, 1 + 2 * n)),
86
+ })),
87
+ };
88
+ });
@@ -0,0 +1,134 @@
1
+ import type { H3Event } from "h3";
2
+ import type { OipStatsPeriod } from "../../app/types/stats";
3
+ import { assertOipSuperAdmin, oipAppUrls } from "./oipGuards";
4
+
5
+ // Socle des routes /api/oip/stats* (panneau « Statistiques ») : droit, cle
6
+ // PostHog, perimetre de l'app et appel HogQL. Prefixe `oip` pour ne pas
7
+ // masquer les helpers des apps (utils serveur auto-importes).
8
+
9
+ // Debut de chaque periode, dans le fuseau du projet PostHog.
10
+ export const OIP_STATS_SINCE: Record<OipStatsPeriod, string> = {
11
+ today: "toStartOfDay(now())",
12
+ "7d": "now() - INTERVAL 7 DAY",
13
+ "30d": "now() - INTERVAL 30 DAY",
14
+ "90d": "now() - INTERVAL 90 DAY",
15
+ };
16
+
17
+ // Chemin sans prefixe de langue ni barre finale : `/fr/hubr/x/` et `/hubr/x`
18
+ // sont le meme ecran (meme peigne que `oipStatsNormalize`).
19
+ const BARE = "replaceRegexpOne(replaceRegexpOne(toString(properties.$pathname), '^/(en|fr)(/|$)', '/'), '/+$', '')";
20
+ export const OIP_STATS_PATH = `if(empty(${BARE}), '/', ${BARE})`;
21
+
22
+ // PostHog plafonne son API de requetes (429 au-dela) : chaque ouverture du
23
+ // panneau, chaque periode, chaque « Charger plus » en coutait une. Les memes
24
+ // requetes sont donc servies de memoire cinq minutes ; les chiffres d'une
25
+ // audience n'ont pas besoin d'etre plus frais.
26
+ const CACHE_TTL_MS = 5 * 60 * 1000;
27
+ const CACHE_MAX = 500;
28
+ const RETRY_MAX_S = 20;
29
+ const cache = new Map<string, { at: number; rows: Promise<unknown[][]> }>();
30
+
31
+ export function oipStatsNormalize(path: string) {
32
+ return (path.split("?")[0] || "").replace(/^\/(fr|en)(?=\/|$)/, "").replace(/\/+$/, "") || "/";
33
+ }
34
+
35
+ /**
36
+ * Verifie le droit et la configuration, puis rend le perimetre commun : les
37
+ * vues de page des 90 derniers jours sur les hotes de l'app (`prod_url`),
38
+ * sans les comptes @tekkare.com sauf `internal=1`, et `hogql` pour
39
+ * interroger PostHog. `path` (normalise) est passe en placeholder `{path}`.
40
+ *
41
+ * Cle personnelle PostHog (scope query:read) et id numerique du projet :
42
+ * `runtimeConfig.oipPosthogKey` / `oipPosthogProjectId`, sinon les env
43
+ * POSTHOG_STATS_KEY (ou POSTHOG_PERSONAL_API_KEY, le nom d'oip7) /
44
+ * POSTHOG_PROJECT_ID. La cle ne quitte jamais le serveur.
45
+ */
46
+ export async function oipStatsContext(event: H3Event) {
47
+ await assertOipSuperAdmin(event);
48
+
49
+ const config = useRuntimeConfig();
50
+ const key =
51
+ (config.oipPosthogKey as string) || process.env.POSTHOG_STATS_KEY || process.env.POSTHOG_PERSONAL_API_KEY;
52
+ const projectId = (config.oipPosthogProjectId as string) || process.env.POSTHOG_PROJECT_ID;
53
+ if (!key || !projectId) {
54
+ throw createError({
55
+ status: 501,
56
+ message: "PostHog stats are not configured (POSTHOG_STATS_KEY, POSTHOG_PROJECT_ID)",
57
+ });
58
+ }
59
+ const apiHost = ((config.oipPosthogHost as string) || process.env.POSTHOG_API_HOST || "https://eu.posthog.com").replace(
60
+ /\/+$/,
61
+ "",
62
+ );
63
+
64
+ const query = getQuery(event);
65
+ const path = oipStatsNormalize(String(query.path || "/"));
66
+ const internal = query.internal === "1";
67
+ const hosts = [...new Set(oipAppUrls().map((url) => new URL(url).host))];
68
+
69
+ // Valeurs passees en placeholders HogQL : PostHog les substitue lui-meme.
70
+ const values: Record<string, string> = { path };
71
+ hosts.forEach((h, i) => (values[`host${i}`] = h));
72
+
73
+ const where = [
74
+ "event = '$pageview'",
75
+ `timestamp >= ${OIP_STATS_SINCE["90d"]}`,
76
+ `toString(properties.$host) IN (${hosts.map((_, i) => `{host${i}}`).join(", ")})`,
77
+ internal ? "" : "NOT endsWith(lower(coalesce(toString(person.properties.email), '')), '@tekkare.com')",
78
+ ]
79
+ .filter(Boolean)
80
+ .join(" AND ");
81
+
82
+ function hogql(sql: string): Promise<unknown[][]> {
83
+ // Seules les valeurs que la requete lit : le classement de toute l'app ne
84
+ // depend pas de l'ecran ouvert, et doit rester en cache d'une page a l'autre.
85
+ const used = Object.fromEntries(Object.entries(values).filter(([name]) => sql.includes(`{${name}}`)));
86
+ const cacheKey = JSON.stringify([projectId, sql, used]);
87
+ const hit = cache.get(cacheKey);
88
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.rows;
89
+
90
+ if (cache.size >= CACHE_MAX) {
91
+ for (const [k, v] of cache) if (Date.now() - v.at >= CACHE_TTL_MS) cache.delete(k);
92
+ if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!);
93
+ }
94
+ // La promesse elle-meme : deux appels simultanes partagent la requete.
95
+ const rows = run(sql, used);
96
+ cache.set(cacheKey, { at: Date.now(), rows });
97
+ rows.catch(() => cache.delete(cacheKey));
98
+ return rows;
99
+ }
100
+
101
+ async function run(sql: string, used: Record<string, string>, retried = false): Promise<unknown[][]> {
102
+ try {
103
+ const res = await $fetch<{ results?: unknown[][] }>(`${apiHost}/api/projects/${projectId}/query/`, {
104
+ method: "POST",
105
+ headers: { Authorization: `Bearer ${key}` },
106
+ body: { query: { kind: "HogQLQuery", query: sql, values: used } },
107
+ });
108
+ return res.results || [];
109
+ } catch (err: any) {
110
+ const status = err?.response?.status ?? err?.statusCode ?? "?";
111
+ const detail = err?.data?.detail || err?.data?.message || err?.message || String(err);
112
+ console.error(`[oip/stats] PostHog query failed (${status}): ${String(detail).slice(0, 300)}`);
113
+ if (status === 429) {
114
+ // « Request was throttled. Expected available in 1028 seconds. »
115
+ const wait = Number(/available in (\d+) second/.exec(String(detail))?.[1]);
116
+ // Limite a la minute (l'ouverture du panneau envoie trois requetes
117
+ // d'un coup) : on attend et on reessaie une fois. Le quota horaire,
118
+ // lui, se dit au lecteur.
119
+ if (!retried && wait && wait <= RETRY_MAX_S) {
120
+ await new Promise((resolve) => setTimeout(resolve, (wait + 1) * 1000));
121
+ return run(sql, used, true);
122
+ }
123
+ const minutes = wait ? Math.max(1, Math.ceil(wait / 60)) : 1;
124
+ throw createError({
125
+ status: 429,
126
+ message: `Quota de requêtes PostHog atteint, réessayer dans ${minutes} min`,
127
+ });
128
+ }
129
+ throw createError({ status: 502, message: `PostHog query failed (${status})` });
130
+ }
131
+ }
132
+
133
+ return { query, path, hosts, where, hogql };
134
+ }
@@ -0,0 +1,51 @@
1
+ import type { H3Event } from "h3";
2
+ import { getOipAccessToken } from "./oipAuth";
3
+
4
+ // Noms des workspaces de la Virtual Lab API, par id, pour le panneau
5
+ // « Statistiques » : PostHog ne porte que `workspace_id` sur les evenements.
6
+ // Prefixe `oip` pour ne pas masquer les helpers des apps.
7
+
8
+ // La liste pagine a 100 et trie par date de creation : une seule page
9
+ // cacherait les plus anciens.
10
+ const PAGE_SIZE = 100;
11
+ const MAX_PAGES = 20;
12
+ const TTL_MS = 10 * 60 * 1000;
13
+
14
+ let cache: { at: number; names: Map<string, string> } | null = null;
15
+
16
+ /**
17
+ * Id vers nom de tous les workspaces, lus par `/superadmin/workspaces` avec le
18
+ * jeton de l'appelant (deja verifie superadmin), gardes dix minutes. En cas
19
+ * d'echec, une table vide : les statistiques restent lisibles sans les noms.
20
+ */
21
+ export async function oipWorkspaceNames(event: H3Event): Promise<Map<string, string>> {
22
+ if (cache && Date.now() - cache.at < TTL_MS) return cache.names;
23
+
24
+ const token = await getOipAccessToken(event);
25
+ const apiUrl = (useRuntimeConfig().oipVirtualLabApiUrl as string) || process.env.VIRTUAL_LAB_API_URL;
26
+ if (!token || !apiUrl) return new Map();
27
+
28
+ type Page = { workspaces?: { id?: string; name?: string }[]; pagination?: { totalPages?: number } };
29
+ const listPage = (page: number) =>
30
+ $fetch<Page>(`${apiUrl}/superadmin/workspaces`, {
31
+ headers: { Authorization: `Bearer ${token}` },
32
+ query: { page, limit: PAGE_SIZE },
33
+ });
34
+
35
+ try {
36
+ const first = await listPage(1);
37
+ const pages = [first];
38
+ const total = Math.min(first.pagination?.totalPages ?? 1, MAX_PAGES);
39
+ if (total > 1) pages.push(...(await Promise.all(Array.from({ length: total - 1 }, (_, i) => listPage(i + 2)))));
40
+
41
+ const names = new Map<string, string>();
42
+ for (const page of pages) {
43
+ for (const w of page.workspaces ?? []) if (w.id && w.name) names.set(w.id, w.name);
44
+ }
45
+ cache = { at: Date.now(), names };
46
+ return names;
47
+ } catch (err: any) {
48
+ console.error(`[oip/stats] workspace list failed (${err?.response?.status ?? err?.statusCode ?? "?"})`);
49
+ return new Map();
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.243",
3
+ "version": "0.1.245",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "import": "./dist/runtime/utils/*.js"
19
19
  },
20
20
  "./layer": "./layer/nuxt.config.ts",
21
- "./layer-utils/*": "./layer/app/utils/*.ts"
21
+ "./layer-utils/*": "./layer/app/utils/*.ts",
22
+ "./layer-composables/*": "./layer/app/composables/*.ts"
22
23
  },
23
24
  "main": "./dist/module.cjs",
24
25
  "module": "./dist/module.mjs",