@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.
- package/dist/module.json +1 -1
- package/layer/app/app.config.ts +6 -0
- package/layer/app/components/OipShell.vue +3 -0
- package/layer/app/components/layout/alertBrandSync.vue +143 -0
- package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
- package/layer/app/components/layout/tool/alerts.vue +969 -0
- package/layer/app/components/layout/tool/export.vue +590 -0
- package/layer/app/components/layout/tool/views.vue +933 -0
- package/layer/app/components/layout/viewEmptyState.vue +278 -0
- package/layer/app/components/layout/viewPill.vue +550 -0
- package/layer/app/components/panels/OipPanelStatistics.vue +447 -0
- package/layer/app/composables/useAdminAccess.ts +86 -0
- package/layer/app/composables/useAlertBrandSync.ts +85 -0
- package/layer/app/composables/useAlertSource.ts +210 -0
- package/layer/app/composables/useAlerts.ts +345 -0
- package/layer/app/composables/useExportSource.ts +236 -0
- package/layer/app/composables/useHubrEmbedded.ts +13 -0
- package/layer/app/composables/usePermalink.ts +108 -0
- package/layer/app/composables/useSavedExports.ts +286 -0
- package/layer/app/composables/useViewCardActions.ts +500 -0
- package/layer/app/composables/useViewTemplates.ts +369 -0
- package/layer/app/composables/useViews.ts +574 -0
- package/layer/app/oip-defaults/alerts.ts +34 -0
- package/layer/app/oip-defaults/api.ts +18 -0
- package/layer/app/oip-defaults/export.ts +23 -0
- package/layer/app/oip-defaults/ui.ts +21 -0
- package/layer/app/oip-defaults/views.ts +59 -0
- package/layer/app/types/stats.ts +49 -0
- package/layer/app/utils/alertFieldLabels.ts +191 -0
- package/layer/app/utils/oipRail.ts +29 -3
- package/layer/app/utils/oipScopeStore.ts +112 -0
- package/layer/app/utils/oipScopes.ts +376 -0
- package/layer/app/utils/viewPages.ts +30 -0
- package/layer/app/utils/viewTemplateCriteria.ts +215 -0
- package/layer/app/utils/viewTemplates.ts +116 -0
- package/layer/nuxt.config.ts +18 -0
- package/layer/server/api/oip/notes.get.ts +1 -1
- package/layer/server/api/oip/stats/users.get.ts +80 -0
- package/layer/server/api/oip/stats.get.ts +88 -0
- package/layer/server/utils/oipPosthog.ts +134 -0
- package/layer/server/utils/oipWorkspaces.ts +51 -0
- 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
|
+
}
|