@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,210 @@
1
+ /**
2
+ * useAlertSource - ce qu'une page donne a surveiller.
3
+ *
4
+ * Une alerte rejoue la requete d'une vue sauvegardee et rapporte ce qui y
5
+ * apparait, y change ou en sort. L'API ne sait pas construire cette requete :
6
+ * la DSL Elasticsearch est assemblee dans le navigateur, clause par clause,
7
+ * par les composants de filtre de la page (`elasticQueryStore`). La page la
8
+ * remet donc telle quelle au moment ou l'alerte est creee, et l'API la garde
9
+ * figee.
10
+ *
11
+ * Consequence assumee : une vue modifiee doit repointer ses alertes. Quand
12
+ * l'edition part de la page, le client renvoie la nouvelle requete avec les
13
+ * criteres et l'API repart d'une nouvelle reference. Par tout autre chemin,
14
+ * l'alerte passe en `stale` et attend d'etre rearmee ici.
15
+ *
16
+ * Meme forme que `useExportSource` et `useViews` : un `shallowRef` de module,
17
+ * pose au montage, retire au demontage, donc rien a nettoyer a la main et rien
18
+ * qui traverse le rendu serveur (les descripteurs portent des fonctions).
19
+ */
20
+ import type { AlertResourceType as HubrResourceType } from "#oip-app/alerts";
21
+ import { VIEW_BRAND_FILTERS } from "#oip-app/views";
22
+
23
+ /** Un champ que la page propose de surveiller. */
24
+ export interface AlertField {
25
+ /** Chemin du champ dans le `_source`, points admis. */
26
+ value: string;
27
+ /** Libelle affiche, deja traduit. */
28
+ label: string;
29
+ }
30
+
31
+ export interface AlertSource {
32
+ /** Index Elasticsearch interroge par la page. */
33
+ index: string;
34
+ /** Famille du document, cf. `useHubrResourceKinds`. */
35
+ resourceType: HubrResourceType;
36
+ /**
37
+ * Requete de l'ecran, relue au moment ou l'alerte est creee.
38
+ *
39
+ * La clause `query` seule : ni tri, ni agregation, ni pagination. Le tri ne
40
+ * decrit rien pour une alerte (le diff porte sur un ensemble, pas sur un
41
+ * classement) et les agregations couteraient cher pour rien.
42
+ */
43
+ query: () => any;
44
+ /**
45
+ * Champs relus pour nommer un element dans le journal : titre, puis
46
+ * sous-titre. Ils sont aussi gardes dans la reference, seul endroit ou le
47
+ * nom d'un element sorti de la vue existe encore.
48
+ */
49
+ labelFields: [string] | [string, string];
50
+ /** Champs surveillables, tels que la page les propose a l'ecran. */
51
+ fields: AlertField[];
52
+ /** Titre suggere a la creation. Defaut : le nom de la vue. */
53
+ defaultTitle?: string;
54
+ }
55
+
56
+ const registry = shallowRef<(() => AlertSource | null) | null>(null);
57
+
58
+ /**
59
+ * Marques que la page affichee met en avant, quand elle en met une.
60
+ *
61
+ * Deuxieme registre plutot qu'un champ de `AlertSource` : une fiche produit et
62
+ * l'ecran de recherche ne livrent aucune requete surveillable (l'une est un
63
+ * document, l'autre un agregat de dix index), ils n'ont donc pas de source du
64
+ * tout. Ils ont pourtant la seule chose dont une alerte de MARQUE a besoin :
65
+ * le nom du produit qu'on regarde.
66
+ *
67
+ * Ce sont des candidats, pas des marques : le panneau les confronte au
68
+ * repertoire avant de cocher quoi que ce soit, parce qu'un terme de recherche
69
+ * peut etre une molecule (« pembrolizumab ») ou n'exister nulle part.
70
+ */
71
+ const brandRegistry = shallowRef<(() => string[]) | null>(null);
72
+
73
+ export function useAlertSource() {
74
+ /**
75
+ * Declare la source de la page. A appeler dans le `setup`, une seule fois :
76
+ * le descripteur est relu a chaque acces, donc il peut lire des refs.
77
+ */
78
+ const declare = (build: () => AlertSource | null) => {
79
+ // Page du tiroir de projet : les alertes restent celles de l'ecran dessous.
80
+ if (useHubrEmbedded()) return;
81
+ // Le moteur d'alertes interroge Elasticsearch lui-meme, par NOM : l'id
82
+ // Directus du catalogue est traduit ici, une fois pour tous les appels.
83
+ const { nameOf } = useOipEsIndices();
84
+ const named = () => {
85
+ const source = build();
86
+ return source ? { ...source, index: nameOf(source.index) } : null;
87
+ };
88
+ registry.value = named;
89
+ onScopeDispose(() => {
90
+ if (registry.value === named) registry.value = null;
91
+ });
92
+ };
93
+
94
+ /** La source de la page affichee, ou `null` si elle n'en declare pas. */
95
+ const current = computed(() => registry.value?.() ?? null);
96
+
97
+ /**
98
+ * Declare les marques de la page. Meme forme que `declare`, et independant :
99
+ * une page peut n'avoir que ca.
100
+ */
101
+ const declareBrands = (build: () => string[]) => {
102
+ if (useHubrEmbedded()) return;
103
+ brandRegistry.value = build;
104
+ onScopeDispose(() => {
105
+ if (brandRegistry.value === build) brandRegistry.value = null;
106
+ });
107
+ };
108
+
109
+ /** Les marques candidates de la page affichee, vides si elle n'en propose pas. */
110
+ const brands = computed(() =>
111
+ (brandRegistry.value?.() ?? []).map((brand) => String(brand).trim()).filter(Boolean),
112
+ );
113
+
114
+ return { declare, current, declareBrands, brands };
115
+ }
116
+
117
+ /**
118
+ * Noms sous lesquels une vue range sa selection de produits.
119
+ *
120
+ * Le portail ecrit `brands` (cf. useDrugsViews), les delivrances
121
+ * hospitalieres `brandSelection` : c'est le nom qu'elles donnent en base
122
+ * depuis l'origine, garde tel quel pour ne pas perdre les vues deja
123
+ * enregistrees.
124
+ */
125
+
126
+ /**
127
+ * Les marques que porte une vue, s'il y en a.
128
+ *
129
+ * Sur les pages de ventes, une vue EST une selection de produits : faute de
130
+ * requete de documents a livrer, « surveiller cette vue » ne peut vouloir
131
+ * dire que « surveiller ces marques ». C'est ce qui pre-remplit le formulaire
132
+ * d'alerte, et ce qu'on propose de reporter sur une alerte existante quand la
133
+ * vue change.
134
+ */
135
+ export function viewBrandsFromCriteria(criteria: any[]): string[] {
136
+ const entry = (Array.isArray(criteria) ? criteria : []).find((item: any) =>
137
+ VIEW_BRAND_FILTERS.includes(item?.filter),
138
+ );
139
+ return Array.isArray(entry?.values) ? entry.values.map(String) : [];
140
+ }
141
+
142
+ /** Deux chemins qui designent la meme page, `trailingSlash` etant actif. */
143
+ const samePage = (a: unknown, b: unknown): boolean =>
144
+ String(a ?? "").replace(/\/$/, "") === String(b ?? "").replace(/\/$/, "");
145
+
146
+ /**
147
+ * Ce qu'une mise a jour de vue doit emporter pour que ses alertes survivent.
148
+ *
149
+ * L'API repointe toute seule les alertes d'une vue dont les criteres ont
150
+ * bouge, mais elle a besoin de la requete : sa DSL est assemblee dans le
151
+ * navigateur, elle ne sait pas la reconstruire. Sans elle, les alertes
152
+ * passent en `stale` et attendent un rearmement a la main depuis la page.
153
+ *
154
+ * Rien n'est envoye si la vue mise a jour n'est pas celle de la page
155
+ * affichee : la requete de l'ecran decrirait alors une autre page que la vue
156
+ * qu'on enregistre.
157
+ */
158
+ export function alertRefreshPayload(
159
+ page: unknown,
160
+ ): { alertQuery?: any; alertIndex?: string } {
161
+ const declared = registry.value?.() ?? null;
162
+ if (!declared) return {};
163
+ if (!samePage(page, useViews().current.value?.page)) return {};
164
+ return { alertQuery: declared.query(), alertIndex: declared.index };
165
+ }
166
+
167
+ /**
168
+ * Ce que la snackbar ajoute quand des alertes ont suivi, ou pas.
169
+ *
170
+ * Sans ce mot, l'API rendait deja ces deux nombres et personne ne les lisait :
171
+ * une vue elargie endormait ses alertes en silence, et la panne ne se
172
+ * decouvrait qu'en ouvrant le panneau, parfois des jours plus tard.
173
+ */
174
+ export function alertRefreshNotice(result: unknown): string {
175
+ const refreshed = Number((result as any)?.alertsRefreshed ?? 0);
176
+ const stale = Number((result as any)?.alertsStale ?? 0);
177
+ // Appelee depuis les magasins, hors setup : la langue vient de l'app, pas de useI18n.
178
+ const fr = String(tryUseNuxtApp()?.$i18n?.locale?.value ?? "fr") !== "en";
179
+ const plural = (n: number) => (n > 1 ? "s" : "");
180
+ if (stale > 0) {
181
+ return fr
182
+ ? ` (${stale} alerte${plural(stale)} à réarmer)`
183
+ : ` (${stale} alert${plural(stale)} to re-arm)`;
184
+ }
185
+ if (refreshed > 0) {
186
+ return fr
187
+ ? ` (${refreshed} alerte${plural(refreshed)} mise${plural(refreshed)} à jour)`
188
+ : ` (${refreshed} alert${plural(refreshed)} updated)`;
189
+ }
190
+ return "";
191
+ }
192
+
193
+ /**
194
+ * Requete d'alerte a partir des filtres d'une page heart.
195
+ *
196
+ * Les pages du hub tiennent leurs clauses dans `elasticQueryStore` sous la
197
+ * forme `{ filter, page, values, keyName }`, et `useHeartElastic` les assemble
198
+ * en `bool.must` au moment d'interroger le cluster. On reprend exactement le
199
+ * meme assemblage : une alerte qui ne poserait pas la meme question que
200
+ * l'ecran ne vaut rien.
201
+ */
202
+ export function alertQueryFromFilters(filters: any[]): any {
203
+ return {
204
+ bool: {
205
+ must: (filters ?? [])
206
+ .map((entry: any) => entry?.values)
207
+ .filter((clause: unknown) => clause !== undefined && clause !== null),
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * useAlerts - les alertes posees sur les vues sauvegardees.
3
+ *
4
+ * Une alerte = une vue + ce qu'on y surveille. Elle vit cote API (worker
5
+ * quotidien, journal d'evenements) ; ici on ne fait que lire la liste, la
6
+ * creer depuis la page et marquer les evenements comme lus.
7
+ *
8
+ * Le point delicat est la requete : c'est la page qui la fournit, via
9
+ * `useAlertSource`. Une alerte ne peut donc etre creee que depuis la page
10
+ * qu'elle surveille, et une vue temporaire (posee par un lien profond, jamais
11
+ * enregistree) n'en accepte pas : il n'y a rien a quoi l'attacher.
12
+ */
13
+ import { useOipApi, type Alert, type AlertEvent, type AlertTrigger } from "#oip-app/api";
14
+ import { sameViewPage } from "../utils/viewPages";
15
+
16
+ export function useAlerts() {
17
+ const elysiaApi = useOipApi();
18
+ const source = useAlertSource();
19
+ const views = useViews();
20
+
21
+ const alerts = useState<Alert[]>("alerts:list", () => []);
22
+ /**
23
+ * Le mode d'essai est ouvert cote API : cadence de cinq minutes et passage
24
+ * manuel. Faux en production, et l'ecran n'en montre alors aucune trace.
25
+ */
26
+ const testCadence = useState<boolean>("alerts:testCadence", () => false);
27
+ const loaded = useState<boolean>("alerts:loaded", () => false);
28
+ const loading = useState<boolean>("alerts:loading", () => false);
29
+ /**
30
+ * Formulaire de creation ouvert.
31
+ *
32
+ * L'etat est partage plutot que local au panneau parce que la visite guidee
33
+ * des alertes doit pouvoir ouvrir le formulaire : ses etapes expliquent le
34
+ * parametrage, elles n'auraient rien a designer devant un panneau ferme. Le
35
+ * panneau le remet a faux en se demontant (cf. son composant), donc rouvrir
36
+ * la tuile ne rouvre pas la modale d'hier.
37
+ */
38
+ const creating = useState<boolean>("alerts:creating", () => false);
39
+
40
+ const load = async (force = false) => {
41
+ if (loading.value) return;
42
+ if (loaded.value && !force) return;
43
+ loading.value = true;
44
+ try {
45
+ const response = await elysiaApi.alerts.list();
46
+ alerts.value = response?.alerts ?? [];
47
+ testCadence.value = !!response?.testCadence;
48
+ loaded.value = true;
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Suit une alerte jusqu'a ce que sa reference soit posee.
56
+ *
57
+ * La capture prend quelques secondes cote worker. Sans ce suivi, la carte
58
+ * reste sur « en préparation » indefiniment alors que le passage a peut-etre
59
+ * deja echoue — et le message d'echec est justement ce qu'il faut lire.
60
+ */
61
+ const followSeeding = async (attempts = 6, delayMs = 2500) => {
62
+ for (let i = 0; i < attempts; i++) {
63
+ if (!alerts.value.some((alert) => alert.status === "seeding")) return;
64
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
65
+ await load(true);
66
+ }
67
+ };
68
+
69
+ const router = useRouter();
70
+
71
+ /**
72
+ * L'alerte a ete posee depuis cette app.
73
+ *
74
+ * L'API rend les alertes de tout l'espace de travail, toutes apps
75
+ * confondues : sans ce tri, le panneau de France Drugs montrait celles de
76
+ * France Epidemio. L'hote de la vue surveillee dit d'ou elle vient. Une
77
+ * alerte sans URL (projet) n'appartient a aucune app : elle reste.
78
+ *
79
+ * En local, l'hote ne dit rien (les vues portent celui du staging) : on
80
+ * demande alors au routeur si la page existe ici.
81
+ */
82
+ const fromThisApp = (alert: Alert): boolean => {
83
+ if (!alert.url || !import.meta.client) return true;
84
+ let url: URL;
85
+ try {
86
+ url = new URL(alert.url);
87
+ } catch {
88
+ return true;
89
+ }
90
+ if (url.host === window.location.host) return true;
91
+ if (!/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(window.location.hostname)) return false;
92
+ return router.resolve(url.pathname).matched.length > 0;
93
+ };
94
+
95
+ /**
96
+ * Pastille de la tuile du rail : ce que l'utilisateur n'a pas encore lu,
97
+ * dans cette app. Les autres apps ont leur propre pastille.
98
+ */
99
+ const unreadTotal = computed(() =>
100
+ alerts.value
101
+ .filter(fromThisApp)
102
+ .reduce((total, alert) => total + alert.unreadCount, 0),
103
+ );
104
+
105
+ /** Les alertes de la page affichee, celles que son panneau montre en premier. */
106
+ const forCurrentPage = computed(() => {
107
+ const page = views.current.value?.page;
108
+ if (!page) return [];
109
+ return alerts.value.filter((alert) => samePage(alert.page, page));
110
+ });
111
+
112
+ /**
113
+ * Vue active, quand elle existe en base.
114
+ *
115
+ * Une vue temporaire n'a pas d'identifiant (cf. `isTemporaryView`) : elle
116
+ * n'existe pas en base, une alerte ne peut pas s'y accrocher telle quelle.
117
+ */
118
+ const watchableView = computed(() => {
119
+ const active = views.active.value;
120
+ return active && !isTemporaryView(active) ? active : null;
121
+ });
122
+
123
+ /** Les vues enregistrees de la page affichee. */
124
+ const pageViews = computed(() => {
125
+ const page = views.current.value?.page;
126
+ if (!page) return [];
127
+ return views.scopes
128
+ .value()
129
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, page));
130
+ });
131
+
132
+ /**
133
+ * Une alerte peut naitre des que la page sait livrer sa requete et qu'il y
134
+ * a de quoi la rattacher : une vue deja enregistree, ou des filtres a
135
+ * l'ecran dont on fera une vue.
136
+ *
137
+ * Ne pas exiger une vue ACTIVE : c'est la modale qui choisit, et exiger
138
+ * l'application prealable obligeait a passer par un autre panneau avant de
139
+ * pouvoir cliquer.
140
+ *
141
+ * Sans source declaree, il reste l'alerte de MARQUE : elle ne surveille
142
+ * aucune page, donc aucune requete d'ecran ne lui manque, et depuis que
143
+ * l'API accepte une alerte sans pulse, aucune vue non plus. C'est ce qui
144
+ * ouvre les alertes aux pages de ventes (Medic'AM, delivrances
145
+ * hospitalieres, statistiques), dont les ecrans sont des agregats, et aux
146
+ * pages qui nomment un produit sans rien offrir a filtrer : la fiche
147
+ * produit, l'ecran de recherche.
148
+ */
149
+ const canCreate = computed(() => {
150
+ if (source.brands.value.length > 0) return true;
151
+ // Sans requete, une alerte de marque suffit : les produits de la vue
152
+ // affichee, enregistree ou non, en font une. Exiger une vue en base
153
+ // grisait le bouton sur tout le portail des qu'aucune vue n'existait sur
154
+ // ce domaine, alors que la selection etait bien a l'ecran.
155
+ if (!source.current.value) {
156
+ return (
157
+ pageViews.value.length > 0 ||
158
+ viewBrandsFromCriteria(views.current.value?.criteria?.() ?? []).length > 0
159
+ );
160
+ }
161
+ return (
162
+ pageViews.value.length > 0 ||
163
+ (views.current.value?.criteria?.().length ?? 0) > 0
164
+ );
165
+ });
166
+
167
+ const create = async (payload: {
168
+ title: string;
169
+ /** `product` : des marques, sur tout le hub. Defaut : une vue. */
170
+ kind?: "view" | "product";
171
+ brands?: string[];
172
+ /** Vue a surveiller. Absent : la vue active. */
173
+ viewId?: string | null;
174
+ triggers: AlertTrigger[];
175
+ watchedFields: string[];
176
+ channels?: string[];
177
+ cadence?: string;
178
+ visibility?: "private" | "workspace";
179
+ }) => {
180
+ const declared = source.current.value;
181
+ const product = payload.kind === "product";
182
+ // La vue nommee par la modale l'emporte : elle vient d'etre appliquee a
183
+ // l'ecran, donc c'est bien elle que la requete decrit. Pas de repli sur
184
+ // la premiere vue de la page : quand la vue active est temporaire,
185
+ // l'alerte s'accrocherait a une vue qui n'a rien a voir avec l'ecran.
186
+ //
187
+ // Une alerte de produit, elle, ne surveille aucune vue : elle nomme des
188
+ // marques, couvre tous les index du hub et ne tient d'aucune page. Elle
189
+ // garde pourtant l'identifiant de la vue active quand il y en a une : c'est
190
+ // par lui que `useAlertBrandSync` la retrouve pour proposer de suivre les
191
+ // marques de la vue. Il ne sert qu'a ce lien, jamais a l'affichage, qui se
192
+ // decide sur `kind`.
193
+ const pulseId = product
194
+ ? (watchableView.value?.id ?? null)
195
+ : (payload.viewId ?? watchableView.value?.id ?? null);
196
+ if (!product && (!pulseId || !declared)) return null;
197
+
198
+ const alert = await elysiaApi.alerts.create({
199
+ pulseId: pulseId ?? undefined,
200
+ kind: payload.kind,
201
+ brands: payload.brands,
202
+ title: payload.title,
203
+ // Une alerte de produit n'a ni index ni requete : le moteur les engendre
204
+ // pour les sept index du hub.
205
+ index: product ? undefined : declared!.index,
206
+ // Relue maintenant : c'est l'ecran tel qu'il est, pas tel qu'il etait a
207
+ // l'ouverture du panneau.
208
+ query: product ? undefined : declared!.query(),
209
+ resourceType: product ? undefined : declared!.resourceType,
210
+ // L'API nomme l'app par son registre quand elle connait l'hote ; ce
211
+ // nom-ci sert de repli, pour que le digest ne dise pas « OIP » a un
212
+ // client tant que l'app n'y est pas inscrite.
213
+ appName: String(useRuntimeConfig().public.app_name ?? "") || undefined,
214
+ watchedFields: payload.watchedFields,
215
+ // Les libelles partent avec l'alerte : seule l'app les traduit, et le
216
+ // digest ne peut pas ecrire `last_price_update` dans la boite d'un
217
+ // client.
218
+ fieldLabels: Object.fromEntries(
219
+ (declared?.fields ?? [])
220
+ .filter(
221
+ (field) =>
222
+ // « * » suit le document entier : tous les libelles servent.
223
+ payload.watchedFields.includes("*") ||
224
+ payload.watchedFields.includes(field.value),
225
+ )
226
+ .map((field) => [field.value, field.label]),
227
+ ),
228
+ labelFields: product ? ["label", "brand"] : declared!.labelFields,
229
+ triggers: payload.triggers,
230
+ channels: payload.channels,
231
+ cadence: payload.cadence,
232
+ visibility: payload.visibility,
233
+ });
234
+
235
+ if (alert) {
236
+ alerts.value = [alert, ...alerts.value];
237
+ // Pas d'attente : la modale se ferme, la carte se met a jour derriere.
238
+ followSeeding();
239
+ }
240
+ return alert;
241
+ };
242
+
243
+ const update = async (
244
+ id: string,
245
+ payload: Parameters<typeof elysiaApi.alerts.update>[1],
246
+ ) => {
247
+ const updated = await elysiaApi.alerts.update(id, payload);
248
+ if (updated) {
249
+ alerts.value = alerts.value.map((alert) =>
250
+ alert.id === id ? { ...alert, ...updated } : alert,
251
+ );
252
+ }
253
+ return updated;
254
+ };
255
+
256
+ const remove = async (id: string) => {
257
+ await elysiaApi.alerts.delete(id);
258
+ alerts.value = alerts.value.filter((alert) => alert.id !== id);
259
+ };
260
+
261
+ /**
262
+ * Repointe une alerte perimee sur la requete courante de la page.
263
+ *
264
+ * Ne marche que depuis la page surveillee, seule a savoir construire la
265
+ * requete. L'alerte repart d'une nouvelle reference : l'ancienne repondait a
266
+ * une autre question.
267
+ */
268
+ const rearm = async (id: string) => {
269
+ const declared = source.current.value;
270
+ if (!declared) return null;
271
+ const result = await elysiaApi.alerts.rearm(id, {
272
+ query: declared.query(),
273
+ index: declared.index,
274
+ });
275
+ await load(true);
276
+ return result;
277
+ };
278
+
279
+ /** Rejoue une alerte et relit la liste : le resultat se voit tout de suite. */
280
+ const runNow = async (id: string) => {
281
+ const result = await elysiaApi.alerts.run(id);
282
+ await load(true);
283
+ return result;
284
+ };
285
+
286
+ /** Envoie son propre digest tout de suite. */
287
+ const simulate = async (
288
+ id: string,
289
+ payload: { kind?: "appeared" | "changed" | "disappeared"; count?: number; sources?: number } = {},
290
+ ) => {
291
+ const result = await elysiaApi.alerts.simulate(id, payload);
292
+ await load(true);
293
+ return result;
294
+ };
295
+
296
+ const sendDigestNow = async () => elysiaApi.alerts.runDigest();
297
+
298
+ const events = async (id: string, limit = 50): Promise<AlertEvent[]> => {
299
+ const response = await elysiaApi.alerts.events(id, limit);
300
+ return response?.events ?? [];
301
+ };
302
+
303
+ const markRead = async (payload: { alertId?: string; eventIds?: string[] }) => {
304
+ const result = await elysiaApi.alerts.markRead(payload);
305
+ if (payload.alertId) {
306
+ alerts.value = alerts.value.map((alert) =>
307
+ alert.id === payload.alertId ? { ...alert, unreadCount: 0 } : alert,
308
+ );
309
+ }
310
+ return result;
311
+ };
312
+
313
+ return {
314
+ alerts,
315
+ loading,
316
+ creating,
317
+ load,
318
+ followSeeding,
319
+ unreadTotal,
320
+ fromThisApp,
321
+ forCurrentPage,
322
+ watchableView,
323
+ pageViews,
324
+ canCreate,
325
+ create,
326
+ update,
327
+ remove,
328
+ rearm,
329
+ events,
330
+ markRead,
331
+ testCadence,
332
+ runNow,
333
+ simulate,
334
+ sendDigestNow,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Deux chemins qui designent la meme page. Meme raison que dans `useViews` :
340
+ * `trailingSlash` est actif et les vues d'un lien profond portent le chemin
341
+ * sans barre finale.
342
+ */
343
+ function samePage(a: unknown, b: unknown): boolean {
344
+ return String(a ?? "").replace(/\/$/, "") === String(b ?? "").replace(/\/$/, "");
345
+ }