@tekkare/romulus 0.1.241 → 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/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,198 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineEventHandler } from "h3";
|
|
3
|
+
import {
|
|
4
|
+
assertOipSuperAdmin as assertSuperAdmin,
|
|
5
|
+
oipCheckRateLimit as checkRateLimit,
|
|
6
|
+
oipClientIp as getClientIp,
|
|
7
|
+
oipValidateBody as validateBody,
|
|
8
|
+
} from "../../utils/oipGuards";
|
|
9
|
+
import { oipSerializeNoteContent as serializeNoteContent } from "../../utils/oipNoteMarkup";
|
|
10
|
+
import type { NoteSection } from "../../../app/notes/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PATCH /api/oip/notes, servi par le layer OIP (correction des notes).
|
|
14
|
+
*
|
|
15
|
+
* Correction d'une note depuis le panneau « Notes » du rail.
|
|
16
|
+
*
|
|
17
|
+
* Meme forme que la correction des sources : le droit est celui de la console
|
|
18
|
+
* d'OIP 7, revalide ici et pas seulement a l'affichage du crayon, et le jeton
|
|
19
|
+
* Directus d'ecriture reste cote serveur.
|
|
20
|
+
*
|
|
21
|
+
* Quatre ecritures possibles, toutes facultatives : la fiche (categorie,
|
|
22
|
+
* statut), ses traductions, ses sujets, ses sources. Un champ absent du corps
|
|
23
|
+
* n'est pas efface ; une liste de rattachements presente, en revanche, fait
|
|
24
|
+
* foi : c'est ce que l'editeur a sous les yeux, y compris vide.
|
|
25
|
+
*
|
|
26
|
+
* Les rattachements sont compares a ce que Directus porte, pas a ce que le
|
|
27
|
+
* navigateur affirme : les lignes a defaire sont relues ici, sinon un client
|
|
28
|
+
* pourrait faire supprimer la liaison d'une autre note en envoyant son id.
|
|
29
|
+
*
|
|
30
|
+
* Le contenu arrive en blocs et repart en texte balise : le CMS garde un texte
|
|
31
|
+
* relisible dans son propre editeur, et une note corrigee depuis l'app se
|
|
32
|
+
* relit a l'identique (cf. server/utils/note-markup).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const LANGUAGES = ["fr-FR", "en-US"] as const;
|
|
36
|
+
|
|
37
|
+
const block: z.ZodType<any> = z.discriminatedUnion("type", [
|
|
38
|
+
z.object({ type: z.literal("text"), text: z.string().max(4000) }),
|
|
39
|
+
z.object({ type: z.literal("list"), items: z.array(z.string().max(2000)).max(50) }),
|
|
40
|
+
z.object({
|
|
41
|
+
type: z.literal("terms"),
|
|
42
|
+
items: z
|
|
43
|
+
.array(z.object({ term: z.string().max(200), def: z.string().max(2000).optional() }))
|
|
44
|
+
.max(50),
|
|
45
|
+
}),
|
|
46
|
+
z.object({
|
|
47
|
+
type: z.literal("callout"),
|
|
48
|
+
text: z.string().max(4000),
|
|
49
|
+
tone: z.enum(["info", "warning"]).optional(),
|
|
50
|
+
}),
|
|
51
|
+
z.object({ type: z.literal("example"), text: z.string().max(4000) }),
|
|
52
|
+
z.object({ type: z.literal("link"), to: z.string().max(300), label: z.string().max(200) }),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const schema = z.object({
|
|
56
|
+
id: z.string().uuid(),
|
|
57
|
+
status: z.enum(["published", "draft", "archived"]).optional(),
|
|
58
|
+
category: z.string().uuid().nullable().optional(),
|
|
59
|
+
translations: z
|
|
60
|
+
.array(
|
|
61
|
+
z.object({
|
|
62
|
+
id: z.number().int().positive().optional(),
|
|
63
|
+
languages_code: z.enum(LANGUAGES),
|
|
64
|
+
title: z.string().max(300).optional(),
|
|
65
|
+
summary: z.string().max(2000).optional(),
|
|
66
|
+
sections: z
|
|
67
|
+
.array(z.object({ title: z.string().max(200).optional(), blocks: z.array(block).max(80) }))
|
|
68
|
+
.max(40)
|
|
69
|
+
.optional(),
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
.max(LANGUAGES.length)
|
|
73
|
+
.optional(),
|
|
74
|
+
/** Liste complete des ecrans rattaches. Presente, elle fait foi. */
|
|
75
|
+
subjects: z.array(z.string().uuid()).max(100).optional(),
|
|
76
|
+
/** Liste complete des jeux de donnees rattaches. Presente, elle fait foi. */
|
|
77
|
+
sources: z.array(z.string().uuid()).max(100).optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export default defineEventHandler(async (event) => {
|
|
81
|
+
checkRateLimit(`notes-write:${getClientIp(event)}`, 30, 15 * 60 * 1000);
|
|
82
|
+
|
|
83
|
+
await assertSuperAdmin(event);
|
|
84
|
+
|
|
85
|
+
const body = await validateBody(event, schema);
|
|
86
|
+
const config = useRuntimeConfig();
|
|
87
|
+
|
|
88
|
+
const base = (config.directusSourcesWriteUrl as string | undefined)?.replace(/\/$/, "");
|
|
89
|
+
const token = config.directusSourcesWriteToken as string | undefined;
|
|
90
|
+
const readUrl = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
|
|
91
|
+
const readToken = config.directusNotesToken as string | undefined;
|
|
92
|
+
if (!base || !token || !readUrl || !readToken) {
|
|
93
|
+
throw createError({ status: 500, message: "Directus write credentials are not configured" });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const write = (
|
|
97
|
+
method: "PATCH" | "POST" | "DELETE",
|
|
98
|
+
path: string,
|
|
99
|
+
payload?: unknown
|
|
100
|
+
) =>
|
|
101
|
+
$fetch(`${base}${path}`, {
|
|
102
|
+
method,
|
|
103
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
104
|
+
...(payload === undefined ? {} : { body: payload }),
|
|
105
|
+
}).catch((err: any) => {
|
|
106
|
+
const reason = err?.data?.errors?.[0]?.message || err?.message || "unknown error";
|
|
107
|
+
throw createError({ status: 502, message: `Directus update failed: ${reason}` });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const fields: Record<string, unknown> = {};
|
|
111
|
+
if (body.status !== undefined) fields.status = body.status;
|
|
112
|
+
if (body.category !== undefined) fields.category = body.category;
|
|
113
|
+
if (Object.keys(fields).length) await write("PATCH", `/items/Notes/${body.id}`, fields);
|
|
114
|
+
|
|
115
|
+
for (const translation of body.translations ?? []) {
|
|
116
|
+
const payload: Record<string, unknown> = {};
|
|
117
|
+
if (translation.title !== undefined) payload.title = translation.title;
|
|
118
|
+
if (translation.summary !== undefined) payload.summary = translation.summary;
|
|
119
|
+
if (translation.sections !== undefined) {
|
|
120
|
+
payload.content = serializeNoteContent(translation.sections as NoteSection[]);
|
|
121
|
+
}
|
|
122
|
+
if (!Object.keys(payload).length) continue;
|
|
123
|
+
|
|
124
|
+
if (translation.id) {
|
|
125
|
+
await write("PATCH", `/items/Notes_translations/${translation.id}`, payload);
|
|
126
|
+
} else {
|
|
127
|
+
await write("POST", "/items/Notes_translations", {
|
|
128
|
+
...payload,
|
|
129
|
+
Notes_id: body.id,
|
|
130
|
+
languages_code: translation.languages_code,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (body.subjects || body.sources) {
|
|
136
|
+
const current = await $fetch<{ data: any }>(`${readUrl}/items/Notes/${body.id}`, {
|
|
137
|
+
headers: { Authorization: `Bearer ${readToken}`, "Cache-Control": "no-store" },
|
|
138
|
+
query: {
|
|
139
|
+
fields: [
|
|
140
|
+
"linked_subjects.id",
|
|
141
|
+
"linked_subjects.OipSubject_subject_id",
|
|
142
|
+
"linked_sources.id",
|
|
143
|
+
"linked_sources.DataSource_source_uuid",
|
|
144
|
+
].join(","),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await syncLinks({
|
|
149
|
+
write,
|
|
150
|
+
collection: "Notes_OipSubject",
|
|
151
|
+
column: "OipSubject_subject_id",
|
|
152
|
+
noteId: body.id,
|
|
153
|
+
wanted: body.subjects,
|
|
154
|
+
existing: (current.data?.linked_subjects ?? []).map((link: any) => ({
|
|
155
|
+
linkId: link.id,
|
|
156
|
+
value: link.OipSubject_subject_id,
|
|
157
|
+
})),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await syncLinks({
|
|
161
|
+
write,
|
|
162
|
+
collection: "Notes_DataSource",
|
|
163
|
+
column: "DataSource_source_uuid",
|
|
164
|
+
noteId: body.id,
|
|
165
|
+
wanted: body.sources,
|
|
166
|
+
existing: (current.data?.linked_sources ?? []).map((link: any) => ({
|
|
167
|
+
linkId: link.id,
|
|
168
|
+
value: link.DataSource_source_uuid,
|
|
169
|
+
})),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { ok: true };
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** Les lignes de liaison d'une collection, alignees sur ce que l'editeur montre. */
|
|
177
|
+
async function syncLinks(options: {
|
|
178
|
+
write: (method: "PATCH" | "POST" | "DELETE", path: string, payload?: unknown) => Promise<unknown>;
|
|
179
|
+
collection: string;
|
|
180
|
+
column: string;
|
|
181
|
+
noteId: string;
|
|
182
|
+
wanted?: string[];
|
|
183
|
+
existing: { linkId: number; value: string }[];
|
|
184
|
+
}) {
|
|
185
|
+
const { write, collection, column, noteId, wanted, existing } = options;
|
|
186
|
+
if (!wanted) return;
|
|
187
|
+
|
|
188
|
+
const keep = new Set(wanted);
|
|
189
|
+
const held = new Set(existing.map((link) => link.value));
|
|
190
|
+
|
|
191
|
+
const remove = existing.filter((link) => !keep.has(link.value)).map((link) => link.linkId);
|
|
192
|
+
if (remove.length) await write("DELETE", `/items/${collection}`, remove);
|
|
193
|
+
|
|
194
|
+
const add = wanted.filter((value) => !held.has(value));
|
|
195
|
+
for (const value of add) {
|
|
196
|
+
await write("POST", `/items/${collection}`, { Notes_id: noteId, [column]: value });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineEventHandler } from "h3";
|
|
3
|
+
import {
|
|
4
|
+
assertOipSuperAdmin as assertSuperAdmin,
|
|
5
|
+
oipCheckRateLimit as checkRateLimit,
|
|
6
|
+
oipClientIp as getClientIp,
|
|
7
|
+
oipValidateBody as validateBody,
|
|
8
|
+
} from "../../utils/oipGuards";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PATCH /api/oip/sources, servi par le layer OIP (panneau « Sources »).
|
|
12
|
+
*
|
|
13
|
+
* Correction du referentiel de sources depuis le panneau « Sources » du rail.
|
|
14
|
+
*
|
|
15
|
+
* Trois ecritures independantes, sur trois collections Directus : la fiche elle
|
|
16
|
+
* meme (lien, frequence), sa traduction dans une langue (nom, description) et
|
|
17
|
+
* la traduction du fournisseur (nom, description). Une traduction absente est
|
|
18
|
+
* creee, une traduction existante est corrigee : le referentiel est presque
|
|
19
|
+
* entierement anglais, et une source internationale n'a souvent que ca.
|
|
20
|
+
*
|
|
21
|
+
* L'autorisation est revalidee ICI, pas seulement a l'affichage du crayon :
|
|
22
|
+
* cacher un bouton ne protege rien, la requete se fabrique a la main. Le droit
|
|
23
|
+
* est le meme que celui de la console d'OIP 7 (`/superadmin/me`), demande avec
|
|
24
|
+
* le jeton de l'utilisateur, jamais avec celui du serveur.
|
|
25
|
+
*
|
|
26
|
+
* Le jeton Directus d'ecriture est distinct de celui des rapports : il ne porte
|
|
27
|
+
* que `update` sur les sources et les fournisseurs, plus `create` sur leurs
|
|
28
|
+
* traductions, sans droit de suppression, pour qu'une faiblesse ailleurs dans
|
|
29
|
+
* l'app ne puisse pas devenir une ecriture libre dans le referentiel partage
|
|
30
|
+
* par toutes les apps pays.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Langues ouvertes a la saisie. Une liste fermee, pas le contenu de la table
|
|
34
|
+
* `languages` : le referentiel porte des codes abimes (un `ja-JP` suivi d'une
|
|
35
|
+
* tabulation) qu'il ne faut pas propager, et rien ne justifie de creer une
|
|
36
|
+
* traduction dans une langue qu'aucune app ne lit.
|
|
37
|
+
*/
|
|
38
|
+
const LANGUAGES = ["en-US", "fr-FR", "es-ES", "de-DE", "ja-JP", "ko-KR"] as const;
|
|
39
|
+
|
|
40
|
+
/** Corriger demande un id, creer demande une langue. L'un ou l'autre. */
|
|
41
|
+
const target = { id: z.number().int().positive().optional(), languages_code: z.enum(LANGUAGES).optional() };
|
|
42
|
+
const hasTarget = (t: { id?: number; languages_code?: string }) => !!t.id || !!t.languages_code;
|
|
43
|
+
|
|
44
|
+
const schema = z.object({
|
|
45
|
+
uuid: z.string().uuid(),
|
|
46
|
+
/** Necessaire pour creer une traduction de fournisseur. */
|
|
47
|
+
providerUuid: z.string().uuid().optional(),
|
|
48
|
+
source: z
|
|
49
|
+
.object({
|
|
50
|
+
source_link: z.string().url().max(500).nullable().optional(),
|
|
51
|
+
update_frequency: z.string().max(50).nullable().optional(),
|
|
52
|
+
})
|
|
53
|
+
.optional(),
|
|
54
|
+
translation: z
|
|
55
|
+
.object({
|
|
56
|
+
...target,
|
|
57
|
+
source_name: z.string().max(300).nullable().optional(),
|
|
58
|
+
source_description: z.string().max(20000).nullable().optional(),
|
|
59
|
+
})
|
|
60
|
+
.refine(hasTarget, { message: "translation needs an id or a languages_code" })
|
|
61
|
+
.optional(),
|
|
62
|
+
providerTranslation: z
|
|
63
|
+
.object({
|
|
64
|
+
...target,
|
|
65
|
+
provider_name: z.string().max(300).nullable().optional(),
|
|
66
|
+
provider_description: z.string().max(20000).nullable().optional(),
|
|
67
|
+
})
|
|
68
|
+
.refine(hasTarget, { message: "providerTranslation needs an id or a languages_code" })
|
|
69
|
+
.optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export default defineEventHandler(async (event) => {
|
|
73
|
+
checkRateLimit(`sources-write:${getClientIp(event)}`, 30, 15 * 60 * 1000);
|
|
74
|
+
|
|
75
|
+
await assertSuperAdmin(event);
|
|
76
|
+
|
|
77
|
+
const body = await validateBody(event, schema);
|
|
78
|
+
const config = useRuntimeConfig();
|
|
79
|
+
|
|
80
|
+
const base = (config.directusSourcesWriteUrl as string | undefined)?.replace(/\/$/, "");
|
|
81
|
+
const token = config.directusSourcesWriteToken as string | undefined;
|
|
82
|
+
if (!base || !token) {
|
|
83
|
+
throw createError({ status: 500, message: "Directus write credentials are not configured" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Un champ absent du corps n'est pas efface : seules les cles envoyees
|
|
87
|
+
// partent a Directus.
|
|
88
|
+
const sourceFields = Object.fromEntries(
|
|
89
|
+
Object.entries(body.source ?? {}).filter(([, v]) => v !== undefined)
|
|
90
|
+
);
|
|
91
|
+
const textFields = (input: Record<string, unknown> | undefined) =>
|
|
92
|
+
Object.fromEntries(
|
|
93
|
+
Object.entries(input ?? {}).filter(
|
|
94
|
+
([k, v]) => k !== "id" && k !== "languages_code" && v !== undefined
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
const translationFields = textFields(body.translation);
|
|
98
|
+
const providerFields = textFields(body.providerTranslation);
|
|
99
|
+
|
|
100
|
+
const write = (method: "PATCH" | "POST", path: string, payload: Record<string, unknown>) =>
|
|
101
|
+
$fetch(`${base}${path}`, {
|
|
102
|
+
method,
|
|
103
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
104
|
+
body: payload,
|
|
105
|
+
}).catch((err: any) => {
|
|
106
|
+
const reason =
|
|
107
|
+
err?.data?.errors?.[0]?.message || err?.message || "unknown error";
|
|
108
|
+
throw createError({ status: 502, message: `Directus update failed: ${reason}` });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const patch = (path: string, payload: Record<string, unknown>) =>
|
|
112
|
+
write("PATCH", path, payload);
|
|
113
|
+
|
|
114
|
+
if (Object.keys(sourceFields).length) {
|
|
115
|
+
await patch(`/items/DataSource/${body.uuid}`, sourceFields);
|
|
116
|
+
}
|
|
117
|
+
if (body.translation && Object.keys(translationFields).length) {
|
|
118
|
+
if (body.translation.id) {
|
|
119
|
+
await patch(`/items/DataSource_translations/${body.translation.id}`, translationFields);
|
|
120
|
+
} else {
|
|
121
|
+
await write("POST", "/items/DataSource_translations", {
|
|
122
|
+
...translationFields,
|
|
123
|
+
DataSource_source_uuid: body.uuid,
|
|
124
|
+
languages_code: body.translation.languages_code,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (body.providerTranslation && Object.keys(providerFields).length) {
|
|
130
|
+
if (body.providerTranslation.id) {
|
|
131
|
+
await patch(
|
|
132
|
+
`/items/DataProvider_translations/${body.providerTranslation.id}`,
|
|
133
|
+
providerFields
|
|
134
|
+
);
|
|
135
|
+
} else if (body.providerUuid) {
|
|
136
|
+
await write("POST", "/items/DataProvider_translations", {
|
|
137
|
+
...providerFields,
|
|
138
|
+
DataProvider_provider_uuid: body.providerUuid,
|
|
139
|
+
languages_code: body.providerTranslation.languages_code,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { ok: true };
|
|
145
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineEventHandler } from "h3";
|
|
3
|
+
import {
|
|
4
|
+
oipCheckRateLimit as checkRateLimit,
|
|
5
|
+
oipClientIp as getClientIp,
|
|
6
|
+
oipValidateBody as validateBody,
|
|
7
|
+
} from "../../utils/oipGuards";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/oip/sources, servi par le layer OIP (panneau « Sources »).
|
|
11
|
+
*
|
|
12
|
+
* Sources proxy — resolves source_uuids to their Directus DataSource records
|
|
13
|
+
* (name, description, provider, latest_download_date, update_frequency,
|
|
14
|
+
* tags_terms, provider sector + ISO country) so the client never sees the
|
|
15
|
+
* Directus URL or token. Directus creds come from server env; the browser only
|
|
16
|
+
* ever talks to /api/sources.
|
|
17
|
+
*/
|
|
18
|
+
const schema = z.object({
|
|
19
|
+
uuids: z.array(z.string().uuid()).min(1).max(100),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const SOURCES_QUERY = `
|
|
23
|
+
query Sources($uuids: [ID!]) {
|
|
24
|
+
DataSource(filter: { source_uuid: { _in: $uuids } }) {
|
|
25
|
+
source_uuid
|
|
26
|
+
Nickname
|
|
27
|
+
source_link
|
|
28
|
+
latest_download_date
|
|
29
|
+
source_last_updated
|
|
30
|
+
update_frequency
|
|
31
|
+
translations {
|
|
32
|
+
id
|
|
33
|
+
languages_code
|
|
34
|
+
source_name
|
|
35
|
+
source_description
|
|
36
|
+
}
|
|
37
|
+
tags_terms
|
|
38
|
+
provider {
|
|
39
|
+
provider_uuid
|
|
40
|
+
provider_country
|
|
41
|
+
status
|
|
42
|
+
geo_zones {
|
|
43
|
+
country_label
|
|
44
|
+
country_iso
|
|
45
|
+
}
|
|
46
|
+
translations {
|
|
47
|
+
id
|
|
48
|
+
languages_code
|
|
49
|
+
provider_name
|
|
50
|
+
provider_description
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
export default defineEventHandler(async (event) => {
|
|
58
|
+
checkRateLimit(`sources:${getClientIp(event)}`, 60, 15 * 60 * 1000);
|
|
59
|
+
|
|
60
|
+
const { uuids } = await validateBody(event, schema);
|
|
61
|
+
const config = useRuntimeConfig();
|
|
62
|
+
|
|
63
|
+
const url =
|
|
64
|
+
(config.directusUrl as string) ||
|
|
65
|
+
"https://metadata.rcd.lensuscloud.com/graphql";
|
|
66
|
+
const token = config.directusToken as string | undefined;
|
|
67
|
+
|
|
68
|
+
const response = await $fetch<{ data?: { DataSource?: any[] }; errors?: any[] }>(
|
|
69
|
+
url,
|
|
70
|
+
{
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
75
|
+
},
|
|
76
|
+
body: { query: SOURCES_QUERY, variables: { uuids } },
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (response.errors?.length) {
|
|
81
|
+
throw createError({
|
|
82
|
+
status: 502,
|
|
83
|
+
message: `Directus query failed: ${response.errors[0]?.message ?? "unknown error"}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { sources: response.data?.DataSource ?? [] };
|
|
88
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { oipAppUrls } from "../../utils/oipGuards";
|
|
3
|
+
import type { SubjectTableGroups, TableGroup } from "../../../app/types/tableGroups";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/oip/table-groups, servi par le layer OIP (panneau « Sources »).
|
|
7
|
+
*
|
|
8
|
+
* Les tablegroups Directus rattaches aux sujets de cette app, pour l'onglet
|
|
9
|
+
* « Tables » du panneau Sources du rail.
|
|
10
|
+
*
|
|
11
|
+
* Un sujet (`OipSubject`, un par ecran) cite ses `TableGroups` par la liaison
|
|
12
|
+
* `OipSubject_TableGroups` ; un tablegroup regroupe des `Dataset`, une table
|
|
13
|
+
* Redshift chacun.
|
|
14
|
+
*
|
|
15
|
+
* La reponse sort entiere, comme les notes : le choix du sujet de l'ecran
|
|
16
|
+
* ouvert se fait cote client, qui seul sait quelle page est ouverte.
|
|
17
|
+
*
|
|
18
|
+
* Le jeton peut lire la liaison sans lire `TableGroups` : les tablegroups
|
|
19
|
+
* sortent alors avec leur seul identifiant, et `readable` le dit au panneau.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export default defineEventHandler(async (): Promise<{ readable: boolean; subjects: SubjectTableGroups[] }> => {
|
|
24
|
+
const config = useRuntimeConfig();
|
|
25
|
+
const url = (config.directusNotesUrl as string | undefined)?.replace(/\/+$/, "");
|
|
26
|
+
const token = config.directusNotesToken as string | undefined;
|
|
27
|
+
if (!url || !token) {
|
|
28
|
+
throw createError({ status: 503, message: "Table groups are not configured" });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
32
|
+
|
|
33
|
+
const subjects = await $fetch<{ data: any[] }>(`${url}/items/OipSubject`, {
|
|
34
|
+
headers,
|
|
35
|
+
query: {
|
|
36
|
+
limit: -1,
|
|
37
|
+
filter: JSON.stringify({ app: { prod_url: { _in: oipAppUrls() } } }),
|
|
38
|
+
fields: "relative_path,table_group.TableGroups_id",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const rows = (subjects.data ?? []).map((row) => ({
|
|
43
|
+
relative_path: String(row.relative_path ?? ""),
|
|
44
|
+
ids: (row.table_group ?? [])
|
|
45
|
+
.map((link: any) => link?.TableGroups_id)
|
|
46
|
+
.filter((id: unknown): id is string => typeof id === "string" && !!id),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const ids = [...new Set(rows.flatMap((row) => row.ids))];
|
|
50
|
+
const byId = new Map<string, TableGroup>();
|
|
51
|
+
let readable = true;
|
|
52
|
+
|
|
53
|
+
if (ids.length) {
|
|
54
|
+
try {
|
|
55
|
+
const groups = await $fetch<{ data: any[] }>(`${url}/items/TableGroups`, {
|
|
56
|
+
headers,
|
|
57
|
+
query: {
|
|
58
|
+
limit: -1,
|
|
59
|
+
filter: JSON.stringify({ id: { _in: ids } }),
|
|
60
|
+
fields: [
|
|
61
|
+
"id",
|
|
62
|
+
"title",
|
|
63
|
+
"short_description",
|
|
64
|
+
"Category",
|
|
65
|
+
"status",
|
|
66
|
+
"date_updated",
|
|
67
|
+
"data_packages.DataPackage_id",
|
|
68
|
+
"data_packages.link_tags",
|
|
69
|
+
"data_packages.link_summary",
|
|
70
|
+
"datasets.Dataset_id.schema_name",
|
|
71
|
+
"datasets.Dataset_id.table_name",
|
|
72
|
+
"datasets.Dataset_id.nickname",
|
|
73
|
+
"datasets.Dataset_id.short_description",
|
|
74
|
+
"datasets.Dataset_id.last_update",
|
|
75
|
+
"datasets.Dataset_id.update_frequency",
|
|
76
|
+
"datasets.Dataset_id.start_period",
|
|
77
|
+
"datasets.Dataset_id.end_period",
|
|
78
|
+
"datasets.Dataset_id.location",
|
|
79
|
+
"datasets.Dataset_id.geographical_scope",
|
|
80
|
+
"datasets.Dataset_id.geographical_granularity",
|
|
81
|
+
].join(","),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
for (const g of groups.data ?? []) {
|
|
85
|
+
byId.set(g.id, {
|
|
86
|
+
id: g.id,
|
|
87
|
+
title: g.title ?? "",
|
|
88
|
+
short_description: g.short_description ?? "",
|
|
89
|
+
category: g.Category ?? "",
|
|
90
|
+
status: g.status ?? "",
|
|
91
|
+
date_updated: g.date_updated ?? null,
|
|
92
|
+
packages: (g.data_packages ?? []).map((link: any) => ({
|
|
93
|
+
id: String(link?.DataPackage_id ?? ""),
|
|
94
|
+
name: "",
|
|
95
|
+
tags: Array.isArray(link?.link_tags) ? link.link_tags : [],
|
|
96
|
+
summary: link?.link_summary ?? "",
|
|
97
|
+
})),
|
|
98
|
+
datasets: (g.datasets ?? [])
|
|
99
|
+
.map((link: any) => link?.Dataset_id)
|
|
100
|
+
.filter((d: any) => d && typeof d === "object")
|
|
101
|
+
.map((d: any) => ({
|
|
102
|
+
schema_name: d.schema_name ?? "",
|
|
103
|
+
table_name: d.table_name ?? "",
|
|
104
|
+
nickname: d.nickname ?? "",
|
|
105
|
+
short_description: d.short_description ?? "",
|
|
106
|
+
last_update: d.last_update ?? null,
|
|
107
|
+
update_frequency: d.update_frequency ?? "",
|
|
108
|
+
start_period: d.start_period ?? null,
|
|
109
|
+
end_period: d.end_period ?? null,
|
|
110
|
+
location: typeof d.location === "string" ? d.location : "",
|
|
111
|
+
geographical_scope: d.geographical_scope ?? "",
|
|
112
|
+
geographical_granularity: d.geographical_granularity ?? "",
|
|
113
|
+
})),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
if (err?.statusCode !== 403 && err?.status !== 403) throw err;
|
|
118
|
+
readable = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Le nom des data packages, s'il est lisible : sans droit sur `DataPackage`,
|
|
123
|
+
// demander `DataPackage_id.*` effacerait le lien entier, d'ou un appel a part.
|
|
124
|
+
const packageIds = [...new Set([...byId.values()].flatMap((g) => g.packages.map((p) => p.id)).filter(Boolean))];
|
|
125
|
+
if (packageIds.length) {
|
|
126
|
+
try {
|
|
127
|
+
const packages = await $fetch<{ data: any[] }>(`${url}/items/DataPackage`, {
|
|
128
|
+
headers,
|
|
129
|
+
query: { limit: -1, filter: JSON.stringify({ id: { _in: packageIds } }) },
|
|
130
|
+
});
|
|
131
|
+
const names = new Map((packages.data ?? []).map((p) => [String(p.id), String(p.title ?? p.name ?? p.package_name ?? "")]));
|
|
132
|
+
for (const g of byId.values()) for (const p of g.packages) p.name = names.get(p.id) ?? "";
|
|
133
|
+
} catch (err: any) {
|
|
134
|
+
if (err?.statusCode !== 403 && err?.status !== 403) throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
readable,
|
|
140
|
+
subjects: rows.map((row) => ({
|
|
141
|
+
relative_path: row.relative_path,
|
|
142
|
+
groups: row.ids.map((id: string) => byId.get(id) ?? { id, title: "", short_description: "", category: "", status: "", date_updated: null, packages: [], datasets: [] }),
|
|
143
|
+
})),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/oip/tutorial-video, servi par le layer OIP (panneau « Tutoriels »).
|
|
5
|
+
*
|
|
6
|
+
* Resout la video de prise en main OIP 7 (collection Directus OipTutorials).
|
|
7
|
+
* L'element vient de `runtimeConfig.oipTutorialVideoId` (env
|
|
8
|
+
* NUXT_OIP_TUTORIAL_VIDEO_ID, ou DIRECTUS_VIDEO_TUTORIAL_ID) : sans lui, pas
|
|
9
|
+
* de video, le panneau n'affiche que les visites.
|
|
10
|
+
*
|
|
11
|
+
* Le navigateur ne voit ni l'URL Directus ni son token : il recoit l'URL du
|
|
12
|
+
* proxy /api/oip/asset qui sert le fichier.
|
|
13
|
+
*/
|
|
14
|
+
export default defineEventHandler(async () => {
|
|
15
|
+
const config = useRuntimeConfig();
|
|
16
|
+
const id = config.oipTutorialVideoId || process.env.DIRECTUS_VIDEO_TUTORIAL_ID;
|
|
17
|
+
if (!id) return null;
|
|
18
|
+
|
|
19
|
+
const base = (config.directusVideoTutorialsUrl as string | undefined)?.replace(/\/+$/, "");
|
|
20
|
+
const token = config.directusVideoTutorialsToken as string | undefined;
|
|
21
|
+
if (!base || !token) {
|
|
22
|
+
throw createError({
|
|
23
|
+
status: 503,
|
|
24
|
+
message: "Tutorial video is not configured: set DIRECTUS_URL and DIRECTUS_TOKEN",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = await $fetch<{ data?: any }>(`${base}/items/OipTutorials/${id}`, {
|
|
29
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
30
|
+
query: { "fields[]": ["id", "Title", "description", "video.directus_files_id"] },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const item = response.data;
|
|
34
|
+
const video = Array.isArray(item?.video) ? item.video[0] : item?.video;
|
|
35
|
+
const fileId = typeof video === "string" ? video : video?.directus_files_id;
|
|
36
|
+
if (!fileId) return null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: item.id,
|
|
40
|
+
title: item.Title ?? "",
|
|
41
|
+
description: item.description ?? "",
|
|
42
|
+
src: `/api/oip/asset/${fileId}`,
|
|
43
|
+
};
|
|
44
|
+
});
|