@tekkare/romulus 0.1.240 → 0.1.242
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/dist/runtime/assets/styles/tokens.css +1 -1
- package/layer/app/app.config.ts +90 -0
- package/layer/app/assets/tekkare_logo.svg +20 -0
- package/layer/app/components/OipDirectusSources.vue +192 -0
- package/layer/app/components/OipDocumentPreview.vue +158 -0
- package/layer/app/components/OipMetadataEditDialog.vue +350 -0
- package/layer/app/components/OipNoteEditDialog.vue +382 -0
- package/layer/app/components/OipShell.vue +711 -0
- package/layer/app/components/OipSourceEditDialog.vue +419 -0
- package/layer/app/components/OipSourceGroups.vue +107 -0
- package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
- package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
- package/layer/app/components/panels/OipPanelNotes.vue +99 -0
- package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
- package/layer/app/components/panels/OipPanelSources.vue +361 -0
- package/layer/app/components/panels/OipPanelTopics.vue +339 -0
- package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
- package/layer/app/composables/useOipCrumbLabel.ts +11 -0
- package/layer/app/composables/useOipEsIndices.ts +57 -0
- package/layer/app/composables/useOipFavorites.ts +69 -0
- package/layer/app/composables/useOipNotes.ts +90 -0
- package/layer/app/composables/useOipNotify.ts +21 -0
- package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
- package/layer/app/layouts/anonymous.vue +6 -0
- package/layer/app/layouts/error.vue +38 -0
- package/layer/app/notes/categories.ts +46 -0
- package/layer/app/notes/types.ts +100 -0
- package/layer/app/plugins/oip-es-indices.client.ts +18 -0
- package/layer/app/types/metadata.ts +38 -0
- package/layer/app/types/oip.ts +60 -0
- package/layer/app/types/tableGroups.ts +42 -0
- package/layer/app/utils/oipRail.ts +82 -0
- package/layer/nuxt.config.ts +32 -0
- package/layer/server/api/oip/asset/[id].get.ts +89 -0
- package/layer/server/api/oip/documents.get.ts +92 -0
- package/layer/server/api/oip/es-indices.get.ts +21 -0
- package/layer/server/api/oip/favorites.get.ts +107 -0
- package/layer/server/api/oip/metadata.get.ts +109 -0
- package/layer/server/api/oip/metadata.patch.ts +163 -0
- package/layer/server/api/oip/note-options.get.ts +89 -0
- package/layer/server/api/oip/note.get.ts +95 -0
- package/layer/server/api/oip/notes.get.ts +173 -0
- package/layer/server/api/oip/notes.patch.ts +198 -0
- package/layer/server/api/oip/sources.patch.ts +145 -0
- package/layer/server/api/oip/sources.post.ts +88 -0
- package/layer/server/api/oip/table-groups.get.ts +145 -0
- package/layer/server/api/oip/tutorial-video.get.ts +44 -0
- package/layer/server/api/romulus/version.get.ts +4 -0
- package/layer/server/utils/oipAuth.ts +69 -0
- package/layer/server/utils/oipEsIndices.ts +99 -0
- package/layer/server/utils/oipGuards.ts +90 -0
- package/layer/server/utils/oipNoteMarkup.ts +197 -0
- package/package.json +5 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Formes partagees par le cadre OIP 7 du layer. RmSidebarLeft (Romulus) ne
|
|
2
|
+
// publie pas ses types : on redecrit ici le strict necessaire.
|
|
3
|
+
|
|
4
|
+
export type OipNavItem = {
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: string;
|
|
7
|
+
to?: string;
|
|
8
|
+
href?: string;
|
|
9
|
+
badge?: string;
|
|
10
|
+
active?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type OipNavSection = {
|
|
14
|
+
key: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
items: OipNavItem[];
|
|
17
|
+
collapsible?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Une tuile du rail, telle que le layout la declare : une largeur de panneau
|
|
22
|
+
* quand la tuile en demande une autre que la largeur commune, un `count` pour
|
|
23
|
+
* une pastille de comptage, `dot` + `hint` pour un etat actif a signaler.
|
|
24
|
+
*/
|
|
25
|
+
export type OipRailTile = {
|
|
26
|
+
key: string;
|
|
27
|
+
label: string;
|
|
28
|
+
icon: string;
|
|
29
|
+
width?: string;
|
|
30
|
+
count?: number;
|
|
31
|
+
dot?: boolean;
|
|
32
|
+
hint?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Un maillon du fil d'Ariane qui quitte l'app (href) : plateforme, pays. */
|
|
37
|
+
export type OipExternalCrumb = {
|
|
38
|
+
label: string;
|
|
39
|
+
href: string;
|
|
40
|
+
/** Code ISO alpha-2 dessine devant le libelle (cf. RmFlag). */
|
|
41
|
+
flag?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Une partie d'un hub (fr_drugs : Medicaments, Hub-R...). Le fil d'Ariane
|
|
46
|
+
* nomme la partie courante et propose les autres au clic.
|
|
47
|
+
*/
|
|
48
|
+
export type OipPart = {
|
|
49
|
+
key: string;
|
|
50
|
+
label: string;
|
|
51
|
+
/** Absent : le libelle francais sert dans les deux langues. */
|
|
52
|
+
labelEn?: string;
|
|
53
|
+
/** Route d'accueil, non prefixee de la locale. */
|
|
54
|
+
to: string;
|
|
55
|
+
icon?: string;
|
|
56
|
+
/** Tenue hors du menu du fil, mais nommee quand on est dedans. */
|
|
57
|
+
hidden?: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type OipLocaleOption = { code: string; flag: string; label: string };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Les tablegroups Directus d'un sujet (un ecran), pour l'onglet « Tables » du
|
|
3
|
+
* panneau Sources. Un tablegroup regroupe des tables Redshift (`Dataset`).
|
|
4
|
+
*/
|
|
5
|
+
export interface TableGroupDataset {
|
|
6
|
+
schema_name: string;
|
|
7
|
+
table_name: string;
|
|
8
|
+
nickname: string;
|
|
9
|
+
short_description: string;
|
|
10
|
+
last_update: string | null;
|
|
11
|
+
update_frequency: string;
|
|
12
|
+
start_period: string | null;
|
|
13
|
+
end_period: string | null;
|
|
14
|
+
location: string;
|
|
15
|
+
geographical_scope: string;
|
|
16
|
+
geographical_granularity: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Le lien d'un tablegroup vers un data package, et ce qu'il y apporte. */
|
|
20
|
+
export interface TableGroupPackage {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Vide tant que le jeton ne lit pas `DataPackage`. */
|
|
23
|
+
name: string;
|
|
24
|
+
tags: string[];
|
|
25
|
+
summary: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TableGroup {
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
short_description: string;
|
|
32
|
+
category: string;
|
|
33
|
+
status: string;
|
|
34
|
+
date_updated: string | null;
|
|
35
|
+
packages: TableGroupPackage[];
|
|
36
|
+
datasets: TableGroupDataset[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SubjectTableGroups {
|
|
40
|
+
relative_path: string;
|
|
41
|
+
groups: TableGroup[];
|
|
42
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Catalogue du rail droit OIP 7 : l'ordre, le nom, l'icone et le regroupement
|
|
2
|
+
// de chaque tuile. Fixe pour toutes les apps : le lecteur retrouve chaque
|
|
3
|
+
// outil a la meme place d'un ecran et d'une app a l'autre. Une app ne declare
|
|
4
|
+
// que l'etat des tuiles (servie ou non, pastille, point), cf. OipShell `tiles`.
|
|
5
|
+
|
|
6
|
+
export type OipRailTileKey =
|
|
7
|
+
| "Sources"
|
|
8
|
+
| "SourcesTables"
|
|
9
|
+
| "Notes"
|
|
10
|
+
| "Documents"
|
|
11
|
+
| "Views"
|
|
12
|
+
| "Alerts"
|
|
13
|
+
| "AdminTemplates"
|
|
14
|
+
| "Export"
|
|
15
|
+
| "Projects"
|
|
16
|
+
| "Topics"
|
|
17
|
+
| "Tutorials"
|
|
18
|
+
| "Accessibility"
|
|
19
|
+
| "Metadata";
|
|
20
|
+
|
|
21
|
+
export type OipRailGroupKey = "Documentation" | "ViewsAlerts";
|
|
22
|
+
|
|
23
|
+
export type OipRailEntry = {
|
|
24
|
+
key: OipRailTileKey;
|
|
25
|
+
label: { fr: string; en: string };
|
|
26
|
+
icon: string;
|
|
27
|
+
/** Lue en onglet d'un panneau commun avec les autres membres du groupe. */
|
|
28
|
+
group?: OipRailGroupKey;
|
|
29
|
+
/** Reservee aux superadmins. */
|
|
30
|
+
admin?: boolean;
|
|
31
|
+
/** Toujours servie, quel que soit l'ecran : elle ne depend pas de la page. */
|
|
32
|
+
frame?: boolean;
|
|
33
|
+
/** Sa pastille reste sur son onglet, sans s'ajouter a celle du groupe. */
|
|
34
|
+
ownCount?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const OIP_RAIL_GROUPS: Record<OipRailGroupKey, { label: { fr: string; en: string }; icon?: string }> = {
|
|
38
|
+
Documentation: { label: { fr: "À propos de l'app", en: "About the app" }, icon: "info" },
|
|
39
|
+
ViewsAlerts: { label: { fr: "Vues et alertes", en: "Views and alerts" } },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const OIP_RAIL_TILES: OipRailEntry[] = [
|
|
43
|
+
{ key: "Sources", label: { fr: "Sources", en: "Sources" }, icon: "database", group: "Documentation" },
|
|
44
|
+
// Seconde vue du panneau Sources : les tablegroups Directus de l'ecran.
|
|
45
|
+
{
|
|
46
|
+
key: "SourcesTables",
|
|
47
|
+
label: { fr: "Tables", en: "Tables" },
|
|
48
|
+
icon: "table",
|
|
49
|
+
group: "Documentation",
|
|
50
|
+
ownCount: true,
|
|
51
|
+
},
|
|
52
|
+
{ key: "Notes", label: { fr: "Notes", en: "Notes" }, icon: "notepad", group: "Documentation" },
|
|
53
|
+
{ key: "Documents", label: { fr: "Documents", en: "Documents" }, icon: "file-text", group: "Documentation" },
|
|
54
|
+
{ key: "Views", label: { fr: "Mes vues", en: "My views" }, icon: "bookmark-simple", group: "ViewsAlerts" },
|
|
55
|
+
{ key: "Alerts", label: { fr: "Alertes", en: "Alerts" }, icon: "bell", group: "ViewsAlerts" },
|
|
56
|
+
{
|
|
57
|
+
key: "AdminTemplates",
|
|
58
|
+
label: { fr: "Admin templates", en: "Admin templates" },
|
|
59
|
+
icon: "magic-wand",
|
|
60
|
+
group: "ViewsAlerts",
|
|
61
|
+
admin: true,
|
|
62
|
+
},
|
|
63
|
+
{ key: "Export", label: { fr: "Export", en: "Export" }, icon: "export" },
|
|
64
|
+
{ key: "Projects", label: { fr: "Projets", en: "Projects" }, icon: "folder-simple" },
|
|
65
|
+
{ key: "Topics", label: { fr: "Mes sujets", en: "My topics" }, icon: "star" },
|
|
66
|
+
{ key: "Tutorials", label: { fr: "Tutoriels", en: "Tutorials" }, icon: "graduation-cap", frame: true },
|
|
67
|
+
{ key: "Accessibility", label: { fr: "Accessibilité", en: "Accessibility" }, icon: "person-arms-spread", frame: true },
|
|
68
|
+
{ key: "Metadata", label: { fr: "Métadonnées", en: "Metadata" }, icon: "brackets-curly", admin: true, frame: true },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Etat d'une tuile pour l'ecran : `true` la sert, un objet la sert avec sa pastille. */
|
|
72
|
+
export type OipRailTileState =
|
|
73
|
+
| boolean
|
|
74
|
+
| {
|
|
75
|
+
count?: number;
|
|
76
|
+
/** Point d'etat actif (une vue appliquee), avec son infobulle. */
|
|
77
|
+
dot?: boolean;
|
|
78
|
+
hint?: string;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type OipRailTileStates = Partial<Record<OipRailTileKey, OipRailTileState>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Layer Nuxt livre avec @tekkare/romulus : une app l'active avec
|
|
2
|
+
// `extends: ['@tekkare/romulus/layer']`.
|
|
3
|
+
// Ses dossiers app/ et server/ fusionnent avec ceux de l'app, qui garde la
|
|
4
|
+
// priorite sur tout fichier de meme nom (c'est la porte de sortie pour
|
|
5
|
+
// surcharger un composant, un layout ou une route).
|
|
6
|
+
//
|
|
7
|
+
// Le layer s'appuie sur le module Romulus et @nuxtjs/i18n mais ne les declare
|
|
8
|
+
// pas dans `modules` : l'app les enregistre deja avec ses options (romulus
|
|
9
|
+
// sans baseStyles, ses locales), et un second enregistrement par le layer
|
|
10
|
+
// les reinstallerait avec les options par defaut.
|
|
11
|
+
export default defineNuxtConfig({
|
|
12
|
+
future: {
|
|
13
|
+
compatibilityVersion: 4,
|
|
14
|
+
},
|
|
15
|
+
// Reglages serveur propres au layer. Les cles Directus (directus*Url,
|
|
16
|
+
// directus*Token) restent declarees par l'app, qui les avait deja.
|
|
17
|
+
runtimeConfig: {
|
|
18
|
+
/** Nom du cookie de session, sinon le premier `*.session-token`. */
|
|
19
|
+
oipSessionCookie: "",
|
|
20
|
+
/** Element OipTutorials de la video de prise en main. Vide : pas de video. */
|
|
21
|
+
oipTutorialVideoId: "",
|
|
22
|
+
/** `prod_url` de l'app dans le portail (fiche OipApplication), sinon NOTES_APP_URL. */
|
|
23
|
+
oipAppUrl: "",
|
|
24
|
+
/** API Virtual Lab pour le droit superadmin, sinon VIRTUAL_LAB_API_URL. */
|
|
25
|
+
oipVirtualLabApiUrl: "",
|
|
26
|
+
/** Directus qui porte la collection ElasticIndices, et un jeton qui la lit. */
|
|
27
|
+
oipEsIndicesUrl: "",
|
|
28
|
+
oipEsIndicesToken: "",
|
|
29
|
+
/** Vrai en preproduction : interroge `staging_physical` quand il existe. */
|
|
30
|
+
oipEsIndicesStaging: false,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { requireOipSession } from "../../../utils/oipAuth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/oip/asset/<id>, servi par le layer OIP.
|
|
6
|
+
*
|
|
7
|
+
* Sert un fichier Directus (les documents du panneau Documents).
|
|
8
|
+
*
|
|
9
|
+
* Passe par le serveur plutot qu'un lien direct pour que l'hote Directus ne
|
|
10
|
+
* parte jamais dans le navigateur. Le header Range est relaye — c'est lui qui
|
|
11
|
+
* permet d'ouvrir un PDF page par page — et `?download` demande a Directus une
|
|
12
|
+
* disposition de piece jointe.
|
|
13
|
+
*/
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
const PASS_THROUGH = [
|
|
17
|
+
"content-type",
|
|
18
|
+
"content-length",
|
|
19
|
+
"content-range",
|
|
20
|
+
"content-disposition",
|
|
21
|
+
"accept-ranges",
|
|
22
|
+
"etag",
|
|
23
|
+
"last-modified",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Types que le navigateur peut afficher en place. Tout autre type (HTML, SVG,
|
|
28
|
+
* texte...) part en piece jointe : servi sur l'origine de l'app, un document
|
|
29
|
+
* HTML s'executerait avec ses cookies.
|
|
30
|
+
*/
|
|
31
|
+
const INLINE_TYPES = new Set([
|
|
32
|
+
"application/pdf",
|
|
33
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
34
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
35
|
+
"text/csv",
|
|
36
|
+
"image/png",
|
|
37
|
+
"image/jpeg",
|
|
38
|
+
"image/gif",
|
|
39
|
+
"image/webp",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export default defineEventHandler(async (event) => {
|
|
43
|
+
// Documents reserves aux personnes connectees, comme le dit le cache-control.
|
|
44
|
+
await requireOipSession(event);
|
|
45
|
+
|
|
46
|
+
const config = useRuntimeConfig();
|
|
47
|
+
const base = (config.directusDocumentsUrl as string | undefined)?.replace(/\/+$/, "");
|
|
48
|
+
const id = getRouterParam(event, "id") ?? "";
|
|
49
|
+
|
|
50
|
+
if (!base) {
|
|
51
|
+
throw createError({ status: 503, message: "Directus is not configured: set DIRECTUS_URL" });
|
|
52
|
+
}
|
|
53
|
+
if (!UUID.test(id)) {
|
|
54
|
+
throw createError({ status: 400, message: "Invalid asset id" });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { download } = getQuery(event);
|
|
58
|
+
const range = getHeader(event, "range");
|
|
59
|
+
|
|
60
|
+
const upstream = await fetch(
|
|
61
|
+
`${base}/assets/${id}${download === undefined ? "" : "?download"}`,
|
|
62
|
+
{ headers: range ? { range } : {} }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!upstream.ok && upstream.status !== 206) {
|
|
66
|
+
throw createError({ status: upstream.status, message: "Asset unavailable" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setResponseStatus(event, upstream.status);
|
|
70
|
+
// Pas le `cache-control` de Directus (public, 30 jours) : ces documents ne
|
|
71
|
+
// sont servis qu'aux personnes connectees, une reponse gardee un mois par
|
|
72
|
+
// les caches partages n'a rien a faire la. Une heure suffit a eviter de
|
|
73
|
+
// retelecharger un PDF qu'on rouvre, et un en-tete de securite corrige
|
|
74
|
+
// arrive au navigateur dans la journee au lieu du mois prochain.
|
|
75
|
+
setResponseHeader(event, "cache-control", "private, max-age=3600");
|
|
76
|
+
for (const header of PASS_THROUGH) {
|
|
77
|
+
const value = upstream.headers.get(header);
|
|
78
|
+
if (value) setResponseHeader(event, header, value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const type = (upstream.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
82
|
+
if (!INLINE_TYPES.has(type)) {
|
|
83
|
+
// Le nom de fichier de Directus est garde, seule la disposition change.
|
|
84
|
+
const disposition = upstream.headers.get("content-disposition") || "attachment";
|
|
85
|
+
setResponseHeader(event, "content-disposition", disposition.replace(/^inline/i, "attachment"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return upstream.body;
|
|
89
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/oip/documents, servi par le layer OIP (panneau « Documents »).
|
|
5
|
+
*
|
|
6
|
+
* Documents de reference du portail OIP 7 (collection Directus oipDocuments),
|
|
7
|
+
* restreints a ceux qui parlent des donnees de l'app.
|
|
8
|
+
*
|
|
9
|
+
* La selection est explicite, ligne a ligne, et propre a chaque app : elle
|
|
10
|
+
* vit dans app.config.oip.panels.Documents.catalog (id Directus, categorie,
|
|
11
|
+
* parties de l'app concernees). Un document sans `apps` vaut pour toutes.
|
|
12
|
+
* `?app=` filtre la liste ; sans lui elle sort entiere (accueil d'un hub).
|
|
13
|
+
*
|
|
14
|
+
* Le navigateur ne voit ni l'URL ni le token Directus : les fichiers passent
|
|
15
|
+
* par /api/oip/asset/<id>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const COLLECTION = "oipDocuments";
|
|
19
|
+
|
|
20
|
+
export default defineEventHandler(async (event) => {
|
|
21
|
+
const config = useRuntimeConfig();
|
|
22
|
+
// La partie du hub qui demande. Inconnue ou absente : toute la liste, comme
|
|
23
|
+
// sur l'accueil, qui n'est aucune des cinq.
|
|
24
|
+
const { app } = getQuery(event) as { app?: string };
|
|
25
|
+
const catalog = useAppConfig().oip.panels.Documents.catalog as {
|
|
26
|
+
id: number;
|
|
27
|
+
category: string;
|
|
28
|
+
apps?: string[];
|
|
29
|
+
}[];
|
|
30
|
+
const wanted = catalog.filter((doc) => !app || !doc.apps?.length || doc.apps.includes(app));
|
|
31
|
+
if (!wanted.length) return [];
|
|
32
|
+
const url = (config.directusDocumentsUrl as string | undefined)?.replace(/\/+$/, "");
|
|
33
|
+
const token = config.directusDocumentsToken as string | undefined;
|
|
34
|
+
|
|
35
|
+
if (!url || !token) {
|
|
36
|
+
throw createError({
|
|
37
|
+
status: 503,
|
|
38
|
+
message:
|
|
39
|
+
"Documents are not configured: set DIRECTUS_URL and DIRECTUS_DOCUMENTS_TOKEN " +
|
|
40
|
+
"(or DIRECTUS_FAVORITES_TOKEN). The token needs read access on oipDocuments.",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await $fetch<{ data: any[] }>(`${url}/items/${COLLECTION}`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
46
|
+
query: {
|
|
47
|
+
limit: -1,
|
|
48
|
+
filter: JSON.stringify({ id: { _in: wanted.map((doc) => doc.id) } }),
|
|
49
|
+
fields: ["id", "title", "document"].join(","),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const rows = new Map<number, any>((response.data ?? []).map((row) => [Number(row.id), row]));
|
|
54
|
+
|
|
55
|
+
// L'ordre vient de la selection, pas de Directus : c'est le seul endroit ou
|
|
56
|
+
// il est decide, et un document retire du CMS disparait sans trou.
|
|
57
|
+
const selected = wanted.flatMap(({ id, category }) => {
|
|
58
|
+
const row = rows.get(id);
|
|
59
|
+
if (!row?.document) return [];
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
id,
|
|
63
|
+
category,
|
|
64
|
+
title: row.title ?? "",
|
|
65
|
+
fileId: row.document as string,
|
|
66
|
+
src: `/api/oip/asset/${row.document}`,
|
|
67
|
+
downloadSrc: `/api/oip/asset/${row.document}?download`,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Le type du fichier decide de ce que le panneau propose : un PDF s'ouvre en
|
|
73
|
+
// apercu dans l'app, un tableur non. Directus refuse /files a ce token, il
|
|
74
|
+
// vient donc des entetes de l'asset lui-meme.
|
|
75
|
+
return Promise.all(
|
|
76
|
+
selected.map(async ({ fileId, ...doc }) => ({
|
|
77
|
+
...doc,
|
|
78
|
+
mime: await assetMime(`${url}/assets/${fileId}`),
|
|
79
|
+
})),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Type d'un asset, lu dans ses entetes. Jamais fatal : un document dont le
|
|
84
|
+
* type est inconnu se liste et se telecharge quand meme. */
|
|
85
|
+
async function assetMime(url: string): Promise<string | null> {
|
|
86
|
+
try {
|
|
87
|
+
const head = await fetch(url, { method: "HEAD" });
|
|
88
|
+
return head.headers.get("content-type");
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { requireOipSession } from "../../utils/oipAuth";
|
|
3
|
+
import { oipEsIndexRegistry } from "../../utils/oipEsIndices";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/oip/es-indices, servi par le layer OIP.
|
|
7
|
+
*
|
|
8
|
+
* L'id Directus, le nom canonique et l'index physique de chaque index, pour les rares endroits
|
|
9
|
+
* du navigateur qui doivent transmettre un NOM a un autre systeme (moteur
|
|
10
|
+
* d'alertes, registre des types de ressources). Les requetes, elles, passent
|
|
11
|
+
* l'id au proxy, qui resout lui-meme.
|
|
12
|
+
*/
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
await requireOipSession(event);
|
|
15
|
+
const { byId } = await oipEsIndexRegistry();
|
|
16
|
+
return [...byId.values()].map((row) => ({
|
|
17
|
+
id: row.id,
|
|
18
|
+
name: row.canonical_name,
|
|
19
|
+
physical: row.physical_index,
|
|
20
|
+
}));
|
|
21
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { requireOipUserId } from "../../utils/oipAuth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/oip/favorites, servi par le layer OIP (panneau « Mes sujets »).
|
|
6
|
+
*
|
|
7
|
+
* The caller's starred topics, as set from the OIP platform menus.
|
|
8
|
+
*
|
|
9
|
+
* Read-only on purpose: the star lives in the platform, this app only lists
|
|
10
|
+
* what it holds so the reader can come back to a topic. Rows are resolved
|
|
11
|
+
* here (translations, icon, country, target URL) so the browser needs nothing
|
|
12
|
+
* from Directus, and never sees its URL or token.
|
|
13
|
+
*
|
|
14
|
+
* A favourite points at exactly one of two things: a menu item (a catalogue
|
|
15
|
+
* entry) or a subject (a page, with its application and path).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Directus renders "Oip Favorites" for this raw name. Overridable so a rename in the CMS is an env change. */
|
|
19
|
+
const COLLECTION = process.env.DIRECTUS_FAVORITES_COLLECTION || "Oip_Favorites";
|
|
20
|
+
|
|
21
|
+
const FIELDS = {
|
|
22
|
+
user: "user_id",
|
|
23
|
+
menuItem: "Menu_item",
|
|
24
|
+
subject: "Subject",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export default defineEventHandler(async (event) => {
|
|
28
|
+
const userId = await requireOipUserId(event);
|
|
29
|
+
const config = useRuntimeConfig();
|
|
30
|
+
const url = (config.directusFavoritesUrl as string | undefined)?.replace(/\/+$/, "");
|
|
31
|
+
const token = config.directusFavoritesToken as string | undefined;
|
|
32
|
+
|
|
33
|
+
if (!url || !token) {
|
|
34
|
+
throw createError({
|
|
35
|
+
status: 503,
|
|
36
|
+
message:
|
|
37
|
+
"Favourites are not configured: set DIRECTUS_URL and DIRECTUS_FAVORITES_TOKEN. " +
|
|
38
|
+
"The token needs read access on the Oip_Favorites collection.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const F = FIELDS;
|
|
43
|
+
try {
|
|
44
|
+
const response = await $fetch<{ data: any[] }>(`${url}/items/${COLLECTION}`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${token}`, "Cache-Control": "no-store" },
|
|
46
|
+
query: {
|
|
47
|
+
limit: 200,
|
|
48
|
+
// A star lit a moment ago in the platform must show up here. This
|
|
49
|
+
// Directus instance keeps its own five-minute cache keyed on the
|
|
50
|
+
// normalised query, so the extra clause carries the current instant
|
|
51
|
+
// and gives every read its own key. It excludes nothing: the bound is
|
|
52
|
+
// a day ahead, which no row reaches.
|
|
53
|
+
filter: JSON.stringify({
|
|
54
|
+
_and: [
|
|
55
|
+
{ [F.user]: { _eq: userId } },
|
|
56
|
+
{ date_created: { _lte: new Date(Date.now() + 86400000).toISOString() } },
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
sort: "-date_created",
|
|
60
|
+
fields: [
|
|
61
|
+
"id",
|
|
62
|
+
`${F.menuItem}.id`,
|
|
63
|
+
`${F.menuItem}.Label`,
|
|
64
|
+
`${F.menuItem}.Emoji`,
|
|
65
|
+
`${F.menuItem}.translations.*`,
|
|
66
|
+
`${F.menuItem}.Country.country_label`,
|
|
67
|
+
// Le code ISO-2 sert a poser le drapeau : Directus ne stocke pas
|
|
68
|
+
// d'emoji de pays.
|
|
69
|
+
`${F.menuItem}.Country.country_iso`,
|
|
70
|
+
// A menu item is a catalogue entry with no address of its own. Its
|
|
71
|
+
// subjects come along so a row can open the one behind it instead of
|
|
72
|
+
// dropping the reader on the platform hub.
|
|
73
|
+
`${F.menuItem}.Subjects.OipSubject_subject_id.subject_id`,
|
|
74
|
+
`${F.menuItem}.Subjects.OipSubject_subject_id.relative_path`,
|
|
75
|
+
`${F.menuItem}.Subjects.OipSubject_subject_id.app.prod_url`,
|
|
76
|
+
`${F.subject}.subject_id`,
|
|
77
|
+
`${F.subject}.relative_path`,
|
|
78
|
+
`${F.subject}.app.prod_url`,
|
|
79
|
+
`${F.subject}.translations.*`,
|
|
80
|
+
`${F.subject}.country_iso.country_label`,
|
|
81
|
+
`${F.subject}.country_iso.country_iso`,
|
|
82
|
+
].join(","),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Normalised on the way out: the CMS spelling of the columns stays here,
|
|
87
|
+
// the interface always sees `menu_item` and `subject`.
|
|
88
|
+
return (response.data ?? []).map((row: any) => ({
|
|
89
|
+
id: row.id,
|
|
90
|
+
menu_item: row[F.menuItem] ?? null,
|
|
91
|
+
subject: row[F.subject] ?? null,
|
|
92
|
+
}));
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
// Directus answers 403 both for "no permission" and for "no such
|
|
95
|
+
// collection", on purpose. Saying so plainly beats a bare 403.
|
|
96
|
+
const status = error?.response?.status ?? error?.statusCode;
|
|
97
|
+
if (status === 403 || status === 404) {
|
|
98
|
+
throw createError({
|
|
99
|
+
status: 503,
|
|
100
|
+
message:
|
|
101
|
+
`Directus collection "${COLLECTION}" is unreachable: the token in ` +
|
|
102
|
+
"DIRECTUS_FAVORITES_TOKEN has no read access on it.",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { assertOipSuperAdmin, oipAppUrls } from "../../utils/oipGuards";
|
|
3
|
+
import type { Metadata, MetadataTranslation } from "../../../app/types/metadata";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/oip/metadata, servi par le layer OIP (panneau « Métadonnées »).
|
|
7
|
+
*
|
|
8
|
+
* Les metadonnees Directus de cette app : sa fiche `OipApplication` et ses
|
|
9
|
+
* `OipSubject`, pour le panneau « Métadonnées » du rail.
|
|
10
|
+
*
|
|
11
|
+
* L'app est designee par son `prod_url`, comme pour les notes : la
|
|
12
|
+
* collection est commune a tout le portail, et un sujet d'une autre app ne
|
|
13
|
+
* regarde pas celle-ci.
|
|
14
|
+
*
|
|
15
|
+
* Reservee aux superadmins, comme la correction : c'est un outil de relecture
|
|
16
|
+
* du referentiel, pas une information de lecture.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function translations(rows: any[] | null | undefined, name: string, description: string): MetadataTranslation[] {
|
|
20
|
+
return (rows ?? []).map((row) => ({
|
|
21
|
+
id: row.id,
|
|
22
|
+
languages_code: String(row.languages_code || "").trim(),
|
|
23
|
+
name: row[name] ?? "",
|
|
24
|
+
description: row[description] ?? "",
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default defineEventHandler(async (event): Promise<Metadata> => {
|
|
29
|
+
await assertOipSuperAdmin(event);
|
|
30
|
+
const appUrls = oipAppUrls();
|
|
31
|
+
|
|
32
|
+
const config = useRuntimeConfig();
|
|
33
|
+
const url = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
|
|
34
|
+
const token = config.directusNotesToken as string | undefined;
|
|
35
|
+
if (!url || !token) {
|
|
36
|
+
throw createError({ status: 503, message: "Metadata is not configured" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const headers = { Authorization: `Bearer ${token}`, "Cache-Control": "no-store" };
|
|
40
|
+
|
|
41
|
+
const [apps, subjects] = await Promise.all([
|
|
42
|
+
$fetch<{ data: any[] }>(`${url}/items/OipApplication`, {
|
|
43
|
+
headers,
|
|
44
|
+
query: {
|
|
45
|
+
limit: 1,
|
|
46
|
+
filter: JSON.stringify({ prod_url: { _eq: appUrls[0] } }),
|
|
47
|
+
fields: [
|
|
48
|
+
"app_id",
|
|
49
|
+
"status",
|
|
50
|
+
"prod_url",
|
|
51
|
+
"staging_url",
|
|
52
|
+
"github_front_url",
|
|
53
|
+
"categories",
|
|
54
|
+
"date_updated",
|
|
55
|
+
"translations.id",
|
|
56
|
+
"translations.languages_code",
|
|
57
|
+
"translations.application_name",
|
|
58
|
+
"translations.application_description",
|
|
59
|
+
].join(","),
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
$fetch<{ data: any[] }>(`${url}/items/OipSubject`, {
|
|
63
|
+
headers,
|
|
64
|
+
query: {
|
|
65
|
+
limit: -1,
|
|
66
|
+
sort: "relative_path",
|
|
67
|
+
filter: JSON.stringify({ app: { prod_url: { _in: appUrls } } }),
|
|
68
|
+
fields: [
|
|
69
|
+
"subject_id",
|
|
70
|
+
"status",
|
|
71
|
+
"relative_path",
|
|
72
|
+
"Keywords",
|
|
73
|
+
"is_specialized_report",
|
|
74
|
+
"date_updated",
|
|
75
|
+
"translations.id",
|
|
76
|
+
"translations.languages_code",
|
|
77
|
+
"translations.subject_name",
|
|
78
|
+
"translations.subject_description",
|
|
79
|
+
].join(","),
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const app = apps.data?.[0];
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
application: app
|
|
88
|
+
? {
|
|
89
|
+
id: app.app_id,
|
|
90
|
+
status: app.status ?? "",
|
|
91
|
+
prod_url: app.prod_url ?? "",
|
|
92
|
+
staging_url: app.staging_url ?? "",
|
|
93
|
+
github_front_url: app.github_front_url ?? "",
|
|
94
|
+
categories: Array.isArray(app.categories) ? app.categories : [],
|
|
95
|
+
date_updated: app.date_updated ?? null,
|
|
96
|
+
translations: translations(app.translations, "application_name", "application_description"),
|
|
97
|
+
}
|
|
98
|
+
: null,
|
|
99
|
+
subjects: (subjects.data ?? []).map((row) => ({
|
|
100
|
+
id: row.subject_id,
|
|
101
|
+
status: row.status ?? "",
|
|
102
|
+
relative_path: row.relative_path ?? "",
|
|
103
|
+
keywords: row.Keywords ?? "",
|
|
104
|
+
is_specialized_report: !!row.is_specialized_report,
|
|
105
|
+
date_updated: row.date_updated ?? null,
|
|
106
|
+
translations: translations(row.translations, "subject_name", "subject_description"),
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
});
|