@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.
Files changed (54) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/assets/styles/tokens.css +1 -1
  3. package/layer/app/app.config.ts +90 -0
  4. package/layer/app/assets/tekkare_logo.svg +20 -0
  5. package/layer/app/components/OipDirectusSources.vue +192 -0
  6. package/layer/app/components/OipDocumentPreview.vue +158 -0
  7. package/layer/app/components/OipMetadataEditDialog.vue +350 -0
  8. package/layer/app/components/OipNoteEditDialog.vue +382 -0
  9. package/layer/app/components/OipShell.vue +711 -0
  10. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  11. package/layer/app/components/OipSourceGroups.vue +107 -0
  12. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  13. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  14. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  15. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  16. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  17. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  18. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  19. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  20. package/layer/app/composables/useOipEsIndices.ts +57 -0
  21. package/layer/app/composables/useOipFavorites.ts +69 -0
  22. package/layer/app/composables/useOipNotes.ts +90 -0
  23. package/layer/app/composables/useOipNotify.ts +21 -0
  24. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  25. package/layer/app/layouts/anonymous.vue +6 -0
  26. package/layer/app/layouts/error.vue +38 -0
  27. package/layer/app/notes/categories.ts +46 -0
  28. package/layer/app/notes/types.ts +100 -0
  29. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  30. package/layer/app/types/metadata.ts +38 -0
  31. package/layer/app/types/oip.ts +60 -0
  32. package/layer/app/types/tableGroups.ts +42 -0
  33. package/layer/app/utils/oipRail.ts +82 -0
  34. package/layer/nuxt.config.ts +32 -0
  35. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  36. package/layer/server/api/oip/documents.get.ts +92 -0
  37. package/layer/server/api/oip/es-indices.get.ts +21 -0
  38. package/layer/server/api/oip/favorites.get.ts +107 -0
  39. package/layer/server/api/oip/metadata.get.ts +109 -0
  40. package/layer/server/api/oip/metadata.patch.ts +163 -0
  41. package/layer/server/api/oip/note-options.get.ts +89 -0
  42. package/layer/server/api/oip/note.get.ts +95 -0
  43. package/layer/server/api/oip/notes.get.ts +173 -0
  44. package/layer/server/api/oip/notes.patch.ts +198 -0
  45. package/layer/server/api/oip/sources.patch.ts +145 -0
  46. package/layer/server/api/oip/sources.post.ts +88 -0
  47. package/layer/server/api/oip/table-groups.get.ts +145 -0
  48. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  49. package/layer/server/api/romulus/version.get.ts +4 -0
  50. package/layer/server/utils/oipAuth.ts +69 -0
  51. package/layer/server/utils/oipEsIndices.ts +99 -0
  52. package/layer/server/utils/oipGuards.ts +90 -0
  53. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  54. 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
+ });
@@ -0,0 +1,4 @@
1
+ import pkg from '../../../../package.json'
2
+
3
+ // Indique quelle version de Romulus (et donc du layer) tourne dans une app.
4
+ export default defineEventHandler(() => ({ version: pkg.version }))