@communecter/cocolight-api-client 1.0.54 → 1.0.56

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 (179) hide show
  1. package/dist/401.cocolight-api-client.browser.js +1 -0
  2. package/dist/401.cocolight-api-client.cjs +1 -0
  3. package/dist/401.cocolight-api-client.mjs.js +1 -0
  4. package/dist/588.cocolight-api-client.browser.js +1 -0
  5. package/dist/588.cocolight-api-client.cjs +1 -0
  6. package/dist/588.cocolight-api-client.mjs.js +1 -0
  7. package/dist/593.cocolight-api-client.browser.js +1 -0
  8. package/dist/593.cocolight-api-client.cjs +1 -0
  9. package/dist/593.cocolight-api-client.mjs.js +1 -0
  10. package/dist/839.cocolight-api-client.browser.js +1 -0
  11. package/dist/839.cocolight-api-client.cjs +1 -0
  12. package/dist/839.cocolight-api-client.mjs.js +1 -0
  13. package/dist/cocolight-api-client.browser.js +3 -3
  14. package/dist/cocolight-api-client.cjs +1 -1
  15. package/dist/cocolight-api-client.mjs.js +1 -1
  16. package/dist/cocolight-api-client.vite.mjs.js +1 -1
  17. package/dist/cocolight-api-client.vite.mjs.js.map +1 -1
  18. package/package.json +29 -17
  19. package/src/{Api.js → Api.ts} +85 -95
  20. package/src/{ApiClient.js → ApiClient.ts} +436 -247
  21. package/src/EJSONType.ts +103 -0
  22. package/src/api/{Badge.js → Badge.ts} +56 -45
  23. package/src/api/BaseEntity.ts +3890 -0
  24. package/src/api/Comment.ts +200 -0
  25. package/src/api/{EndpointApi.js → EndpointApi.ts} +363 -297
  26. package/src/api/EndpointApi.types.ts +4609 -0
  27. package/src/api/EntityRegistry.ts +203 -0
  28. package/src/api/Event.ts +332 -0
  29. package/src/api/News.ts +331 -0
  30. package/src/api/{Organization.js → Organization.ts} +155 -119
  31. package/src/api/{Poi.js → Poi.ts} +68 -60
  32. package/src/api/{Project.js → Project.ts} +150 -127
  33. package/src/api/{User.js → User.ts} +321 -256
  34. package/src/api/UserApi.ts +148 -0
  35. package/src/api/serverDataType/Comment.ts +88 -0
  36. package/src/api/serverDataType/Event.ts +80 -0
  37. package/src/api/serverDataType/News.ts +138 -0
  38. package/src/api/serverDataType/Organization.ts +80 -0
  39. package/src/api/serverDataType/Project.ts +71 -0
  40. package/src/api/serverDataType/User.ts +103 -0
  41. package/src/api/serverDataType/common.ts +80 -0
  42. package/src/endpoints.module.ts +2621 -0
  43. package/src/error.ts +86 -0
  44. package/src/index.ts +86 -0
  45. package/src/mixin/UserMixin.ts +4 -0
  46. package/src/types/api-responses.ts +217 -0
  47. package/src/types/entities.ts +22 -0
  48. package/src/types/error-guards.ts +230 -0
  49. package/src/types/index.ts +39 -0
  50. package/src/types/payloads.ts +21 -0
  51. package/src/types/transforms.ts +110 -0
  52. package/src/utils/{FileOfflineStorageStrategy.node.js → FileOfflineStorageStrategy.node.ts} +15 -12
  53. package/src/utils/{FileStorageStrategy.node.js → FileStorageStrategy.node.ts} +16 -39
  54. package/src/utils/MultiServerFileStorageStrategy.node.ts +67 -0
  55. package/src/utils/MultiServerTokenStorageStrategy.ts +139 -0
  56. package/src/utils/{OfflineClientManager.js → OfflineClientManager.ts} +82 -86
  57. package/src/utils/OfflineQueueStorageStrategy.ts +47 -0
  58. package/src/utils/TokenStorage.ts +77 -0
  59. package/src/utils/compat.ts +12 -0
  60. package/src/utils/createDefaultMultiServerTokenStorageStrategy.ts +35 -0
  61. package/src/utils/{createDefaultOfflineStrategy.js → createDefaultOfflineStrategy.ts} +8 -3
  62. package/src/utils/createDefaultTokenStorageStrategy.ts +33 -0
  63. package/src/utils/{reactive.js → reactive.ts} +49 -40
  64. package/src/utils/stream-utils.node.ts +12 -0
  65. package/types/Api.d.ts +38 -82
  66. package/types/Api.d.ts.map +1 -0
  67. package/types/ApiClient.d.ts +244 -184
  68. package/types/ApiClient.d.ts.map +1 -0
  69. package/types/EJSONType.d.ts +48 -22
  70. package/types/EJSONType.d.ts.map +1 -0
  71. package/types/api/Badge.d.ts +20 -20
  72. package/types/api/Badge.d.ts.map +1 -0
  73. package/types/api/BaseEntity.d.ts +751 -446
  74. package/types/api/BaseEntity.d.ts.map +1 -0
  75. package/types/api/Comment.d.ts +36 -0
  76. package/types/api/EndpointApi.d.ts +347 -295
  77. package/types/api/EndpointApi.d.ts.map +1 -0
  78. package/types/api/EndpointApi.types.d.ts +3914 -4133
  79. package/types/api/EntityRegistry.d.ts +18 -16
  80. package/types/api/EntityRegistry.d.ts.map +1 -0
  81. package/types/api/Event.d.ts +119 -35
  82. package/types/api/Event.d.ts.map +1 -0
  83. package/types/api/News.d.ts +52 -20
  84. package/types/api/News.d.ts.map +1 -0
  85. package/types/api/Organization.d.ts +165 -49
  86. package/types/api/Organization.d.ts.map +1 -0
  87. package/types/api/Poi.d.ts +51 -22
  88. package/types/api/Poi.d.ts.map +1 -0
  89. package/types/api/Project.d.ts +151 -52
  90. package/types/api/Project.d.ts.map +1 -0
  91. package/types/api/User.d.ts +222 -93
  92. package/types/api/User.d.ts.map +1 -0
  93. package/types/api/UserApi.d.ts +60 -9
  94. package/types/api/UserApi.d.ts.map +1 -0
  95. package/types/api/serverDataType/Comment.d.ts +83 -0
  96. package/types/api/serverDataType/Event.d.ts +67 -0
  97. package/types/api/serverDataType/News.d.ts +130 -0
  98. package/types/api/serverDataType/Organization.d.ts +65 -0
  99. package/types/api/serverDataType/Organization.d.ts.map +1 -0
  100. package/types/api/serverDataType/Project.d.ts +58 -0
  101. package/types/api/serverDataType/Project.d.ts.map +1 -0
  102. package/types/api/serverDataType/User.d.ts +86 -0
  103. package/types/api/serverDataType/User.d.ts.map +1 -0
  104. package/types/api/serverDataType/common.d.ts +71 -0
  105. package/types/api/serverDataType/common.d.ts.map +1 -0
  106. package/types/endpoints.module.d.ts +6922 -1215
  107. package/types/endpoints.module.d.ts.map +1 -0
  108. package/types/error.d.ts +25 -51
  109. package/types/error.d.ts.map +1 -0
  110. package/types/index.d.ts +55 -48
  111. package/types/index.d.ts.map +1 -0
  112. package/types/mixin/UserMixin.d.ts +1 -1
  113. package/types/mixin/UserMixin.d.ts.map +1 -0
  114. package/types/types/api-responses.d.ts +190 -0
  115. package/types/types/api-responses.d.ts.map +1 -0
  116. package/types/types/entities.d.ts +17 -0
  117. package/types/types/entities.d.ts.map +1 -0
  118. package/types/types/error-guards.d.ts +99 -0
  119. package/types/types/error-guards.d.ts.map +1 -0
  120. package/types/types/index.d.ts +7 -0
  121. package/types/types/payloads.d.ts +17 -0
  122. package/types/types/payloads.d.ts.map +1 -0
  123. package/types/types/transforms.d.ts +79 -0
  124. package/types/types/transforms.d.ts.map +1 -0
  125. package/types/utils/FileOfflineStorageStrategy.node.d.ts +10 -9
  126. package/types/utils/FileOfflineStorageStrategy.node.d.ts.map +1 -0
  127. package/types/utils/FileStorageStrategy.node.d.ts +9 -20
  128. package/types/utils/FileStorageStrategy.node.d.ts.map +1 -0
  129. package/types/utils/MultiServerFileStorageStrategy.node.d.ts +13 -18
  130. package/types/utils/MultiServerFileStorageStrategy.node.d.ts.map +1 -0
  131. package/types/utils/MultiServerTokenStorageStrategy.d.ts +30 -51
  132. package/types/utils/MultiServerTokenStorageStrategy.d.ts.map +1 -0
  133. package/types/utils/OfflineClientManager.d.ts +52 -88
  134. package/types/utils/OfflineClientManager.d.ts.map +1 -0
  135. package/types/utils/OfflineQueueStorageStrategy.d.ts +12 -9
  136. package/types/utils/OfflineQueueStorageStrategy.d.ts.map +1 -0
  137. package/types/utils/TokenStorage.d.ts +20 -70
  138. package/types/utils/TokenStorage.d.ts.map +1 -0
  139. package/types/utils/compat.d.ts +4 -0
  140. package/types/utils/compat.d.ts.map +1 -0
  141. package/types/utils/createDefaultMultiServerTokenStorageStrategy.d.ts +2 -11
  142. package/types/utils/createDefaultMultiServerTokenStorageStrategy.d.ts.map +1 -0
  143. package/types/utils/createDefaultOfflineStrategy.d.ts +2 -3
  144. package/types/utils/createDefaultOfflineStrategy.d.ts.map +1 -0
  145. package/types/utils/createDefaultTokenStorageStrategy.d.ts +2 -12
  146. package/types/utils/createDefaultTokenStorageStrategy.d.ts.map +1 -0
  147. package/types/utils/reactive.d.ts +10 -16
  148. package/types/utils/reactive.d.ts.map +1 -0
  149. package/types/utils/stream-utils.node.d.ts +3 -2
  150. package/types/utils/stream-utils.node.d.ts.map +1 -0
  151. package/dist/123.cocolight-api-client.browser.js +0 -1
  152. package/dist/123.cocolight-api-client.cjs +0 -1
  153. package/dist/22.cocolight-api-client.mjs.js +0 -1
  154. package/dist/339.cocolight-api-client.mjs.js +0 -1
  155. package/dist/394.cocolight-api-client.browser.js +0 -1
  156. package/dist/394.cocolight-api-client.cjs +0 -1
  157. package/dist/405.cocolight-api-client.browser.js +0 -1
  158. package/dist/405.cocolight-api-client.cjs +0 -1
  159. package/dist/774.cocolight-api-client.mjs.js +0 -1
  160. package/dist/790.cocolight-api-client.mjs.js +0 -1
  161. package/dist/931.cocolight-api-client.browser.js +0 -1
  162. package/dist/931.cocolight-api-client.cjs +0 -1
  163. package/src/EJSONType.js +0 -53
  164. package/src/api/BaseEntity.js +0 -2828
  165. package/src/api/EntityRegistry.js +0 -152
  166. package/src/api/Event.js +0 -226
  167. package/src/api/News.js +0 -244
  168. package/src/api/UserApi.js +0 -81
  169. package/src/endpoints.module.js +0 -5
  170. package/src/error.js +0 -121
  171. package/src/index.js +0 -97
  172. package/src/mixin/UserMixin.js +0 -8
  173. package/src/utils/MultiServerFileStorageStrategy.node.js +0 -87
  174. package/src/utils/MultiServerTokenStorageStrategy.js +0 -188
  175. package/src/utils/OfflineQueueStorageStrategy.js +0 -51
  176. package/src/utils/TokenStorage.js +0 -153
  177. package/src/utils/createDefaultMultiServerTokenStorageStrategy.js +0 -51
  178. package/src/utils/createDefaultTokenStorageStrategy.js +0 -49
  179. package/src/utils/stream-utils.node.js +0 -10
@@ -0,0 +1,3890 @@
1
+ // BaseEntity.ts
2
+ import objectId from "bson-objectid";
3
+ // import { fileTypeFromBuffer } from "file-type";
4
+ import * as pkg from "file-type";
5
+
6
+ import { ApiAuthenticationError, ApiError, ApiResponseError, ApiValidationError } from "../error.js";
7
+ import { EJSON } from "../utils/compat.js";
8
+ import { isReactive, isSignal, reactive } from "../utils/reactive.js";
9
+
10
+ import type {
11
+ ConnectData,
12
+ DisconnectData,
13
+ LinkValidateData,
14
+ FollowData,
15
+ UpdateSettingsData,
16
+ UpdateBlockDescriptionData,
17
+ UpdateBlockInfoData,
18
+ UpdateBlockSocialData,
19
+ UpdateBlockLocalityData,
20
+ UpdateBlockSlugData,
21
+ CheckData,
22
+ ProfilImageData,
23
+ GetNewsData,
24
+ GetGalleryData,
25
+ GetCostumJsonData,
26
+ GlobalAutocompleteCostumData,
27
+ CostumEventRequestActorsData,
28
+ CostumEventRequestSubeventsData,
29
+ CostumEventRequestDatesData,
30
+ CostumEventRequestElementEventData,
31
+ CostumEventRequestCategoriesData,
32
+ CostumEventRequestEventData,
33
+ CostumEventRequestLinkTlToEventData,
34
+ CostumEventRequestLoadContextTagData,
35
+ GetOrganizationsAdminData,
36
+ GetOrganizationsNoAdminData,
37
+ GetProjectsAdminData,
38
+ GetProjectsNoAdminData,
39
+ GetPoisAdminData,
40
+ GetPoisNoAdminData,
41
+ GetSubscribersData,
42
+ GetBadgesData
43
+ } from "./EndpointApi.types.js";
44
+ import type { GetElementsKeyResponse } from "../types/api-responses.js";
45
+ import type { TransformsMap } from "../types/entities.js";
46
+ const { fromBuffer } = pkg;
47
+
48
+ /**
49
+ * Types pour les méthodes d'entité (organization, project, poi, event, badge, news)
50
+ * Permettent soit de récupérer une entité existante (GET), soit de créer une nouvelle instance (CREATE)
51
+ */
52
+ type OrganizationInput = { id: string } | { slug: string } | Record<string, any>;
53
+ type ProjectInput = { id: string } | { slug: string } | Record<string, any>;
54
+ type PoiInput = { id: string } | { slug: string } | Record<string, any>;
55
+ type EventInput = { id: string } | { slug: string } | Record<string, any>;
56
+ type BadgeInput = { id: string } | Record<string, any>;
57
+ type NewsInput = { id: string } | Record<string, any>;
58
+
59
+ /**
60
+ * On force le type de l'import comme une fabrique qui renvoie un objet
61
+ * possédant au moins .toHexString().
62
+ * (peu importe la d.ts du module, on impose notre contrat minimal)
63
+ */
64
+ const createObjectId: (arg?: number|string|number[]|Buffer) => { toHexString(): string } = objectId as any;
65
+
66
+ // Types TypeScript importés
67
+ type ApiClient = import("../ApiClient.js").default;
68
+ type EndpointApi = import("./EndpointApi.js").default;
69
+ type User = import("./User.js").User;
70
+ type Organization = import("./Organization.js").Organization;
71
+ type Project = import("./Project.js").Project;
72
+ type EventEntity = import("./Event.js").Event;
73
+ type Poi = import("./Poi.js").Poi;
74
+ type News = import("./News.js").News;
75
+ type Badge = import("./Badge.js").Badge;
76
+ type Comment = import("./Comment.js").Comment;
77
+
78
+ // Types d'union
79
+ type AnyEntity = User | Organization | Project | Poi | EventEntity | Badge | News | Comment;
80
+ type ParentLike = BaseEntity<any> & { apiClient: ApiClient, userContext?: User | null };
81
+
82
+ // Types pour les dépendances
83
+ type EndpointApiCtor = { new(apiClient: ApiClient): EndpointApi };
84
+ type EndpointApiDep = EndpointApi | EndpointApiCtor;
85
+
86
+ interface Deps {
87
+ EndpointApi: EndpointApiDep;
88
+ User?: any;
89
+ Organization?: any;
90
+ Project?: any;
91
+ Poi?: any;
92
+ Event?: any;
93
+ Badge?: any;
94
+ News?: any;
95
+ Comment?: any;
96
+ }
97
+
98
+ interface BaseEntityConfig {
99
+ entityTag?: string;
100
+ }
101
+
102
+ // EntityTypeMap - mappe les clés "collection" vers leurs classes d'entités
103
+ interface EntityTypeMap {
104
+ citoyens: User;
105
+ organizations: Organization;
106
+ projects: Project;
107
+ events: EventEntity;
108
+ poi: Poi;
109
+ news: News;
110
+ badges: Badge;
111
+ comments: Comment;
112
+ }
113
+
114
+ // Types pour les streams et uploads
115
+ type ReadableWithMeta = import("stream").Readable & { path?: string, mimeType?: string };
116
+ type UploadInput = File | Blob | Buffer | import("stream").Readable;
117
+ type ValidatedUpload = File | Buffer | ReadableWithMeta;
118
+
119
+ // PaginatorPage interface pour les résultats paginés
120
+ export interface PaginatorPage<T> {
121
+ count: {
122
+ total: number;
123
+ };
124
+ results: T[];
125
+ pageIndex: number;
126
+ pageNumber: number;
127
+ hasNext: boolean;
128
+ hasPrev: boolean;
129
+ next?: () => Promise<PaginatorPage<T>>;
130
+ prev?: () => Promise<PaginatorPage<T>>;
131
+ }
132
+
133
+ /**
134
+ * Type helper pour extraire les noms de méthodes depuis un Map as const
135
+ *
136
+ * @example
137
+ * static UPDATE_BLOCKS = new Map([
138
+ * ["UPDATE_BLOCK_DESCRIPTION", "updateDescription"],
139
+ * ["UPDATE_BLOCK_INFO", "updateInfo"]
140
+ * ] as const);
141
+ *
142
+ * type MethodNames = ExtractMethodNames<typeof MyClass.UPDATE_BLOCKS>;
143
+ * // Result: "updateDescription" | "updateInfo"
144
+ */
145
+ export type ExtractMethodNames<T> = T extends Map<any, infer V>
146
+ ? V extends readonly (readonly [any, infer M])[]
147
+ ? M
148
+ : never
149
+ : never;
150
+
151
+ // LinkMeta interface
152
+ interface LinkMeta {
153
+ linkType: "memberOf" | "projects" | "events" | "friends";
154
+ connectTypeConnect: "member" | "contributor" | "attendee" | "friend";
155
+ connectTypeDisconnect: "members" | "contributors" | "attendees" | "friends";
156
+ }
157
+
158
+ // Type du constructeur de BaseEntity avec ses propriétés statiques
159
+ type BaseEntityCtor = typeof BaseEntity & {
160
+ entityTag: string;
161
+ entityType: "citoyens" | "organizations" | "projects" | "events" | "poi" | "badges" | "news" | "comments";
162
+ SCHEMA_CONSTANTS: string | string[];
163
+ };
164
+
165
+ // Constructeur d'entité compatible (new + fromServerData)
166
+ type EntityCtorLike = typeof BaseEntity & {
167
+ new(parent: ApiClient | ParentLike, data?: object, deps?: Deps, config?: BaseEntityConfig): AnyEntity;
168
+ fromServerData(data: any, parent: ApiClient | ParentLike, deps: Deps): AnyEntity;
169
+ };
170
+
171
+ // Métadonnées retournées par _getEntityMeta
172
+ type EntityMeta = { entityClass: EntityCtorLike; deps: Deps };
173
+
174
+ // Types de filtres pour les requêtes MongoDB
175
+ interface FilterValueExistsOnly {
176
+ $exists: boolean;
177
+ }
178
+
179
+ interface FilterValueInOnly {
180
+ $in: string[];
181
+ }
182
+
183
+ // Type pour JSON Schema avec support des mots-clés standards
184
+ type JsonSchema = Record<string, unknown> & {
185
+ $id?: string;
186
+ $ref?: string;
187
+ $defs?: Record<string, JsonSchema>;
188
+ definitions?: Record<string, JsonSchema>;
189
+ allOf?: JsonSchema[];
190
+ if?: JsonSchema;
191
+ then?: JsonSchema;
192
+ else?: JsonSchema;
193
+ properties?: Record<string, JsonSchemaProperty>;
194
+ };
195
+
196
+ // Type pour les propriétés de schéma JSON
197
+ type JsonSchemaProperty = Record<string, unknown> & {
198
+ readOnly?: boolean;
199
+ const?: unknown;
200
+ };
201
+
202
+ // Type pour le curseur de pagination utilisé dans _createPaginatorEngine
203
+ type PaginationCursor = {
204
+ searchType?: string[];
205
+ searchBy?: string;
206
+ countType?: string[];
207
+ ranges?: Record<string, { indexMin: number; indexMax: number }>;
208
+ indexMin?: number;
209
+ indexMax?: number;
210
+ indexStep?: number;
211
+ [key: string]: unknown; // Pour les propriétés additionnelles dynamiques
212
+ };
213
+
214
+ interface FilterValueCombined {
215
+ $in: string[];
216
+ $exists: boolean;
217
+ }
218
+
219
+ type FilterValue = FilterValueExistsOnly | FilterValueInOnly | FilterValueCombined;
220
+
221
+ type LinkFilters = Record<string, FilterValue>;
222
+
223
+ // Type pour la validation de lien utilisateur
224
+ interface MinimalUserLink {
225
+ toBeValidated?: boolean;
226
+ isInviting?: boolean;
227
+ }
228
+
229
+ // Types pour linkEntitiesFromServerData
230
+ type EntityFilterValue = string | boolean | RegExp | ((value: any) => boolean);
231
+ type EntityFilters = Record<string, EntityFilterValue>;
232
+
233
+ interface LinkEntitiesOptions {
234
+ key?: string;
235
+ mapFn?: (entity: any) => any;
236
+ }
237
+
238
+ // ============================================================================
239
+ // Types convertis depuis JSDoc @typedef
240
+ // ============================================================================
241
+
242
+ // Type pour les entités supprimables
243
+ type Deletable = { _isDeleted?: boolean };
244
+
245
+ // Type pour les données réactives (objets qui peuvent être wrappés avec reactive())
246
+ // Les objets réactifs ont une propriété __isReactive cachée
247
+ type ReactiveData = Record<string, any> & { __isReactive?: boolean; __raw?: any };
248
+
249
+ // Types pour les adresses et géolocalisation
250
+ type Address = UpdateBlockLocalityData["address"];
251
+ type PostalAddress = Exclude<Address, "">;
252
+ type AddressGeo = UpdateBlockLocalityData["geo"];
253
+ type Geo = Exclude<AddressGeo, "">;
254
+ type AddressGeoPosition = UpdateBlockLocalityData["geoPosition"];
255
+ type GeoPosition = Exclude<AddressGeoPosition, "">;
256
+
257
+ // Types pour le système de pagination
258
+ interface FinalizerResult<TOut> {
259
+ results: TOut[];
260
+ count: { total: number } & Record<string, any>;
261
+ }
262
+
263
+ // Types pour les entités
264
+ type EntityType = "citoyens" | "organizations" | "projects" | "events" | "poi" | "badges" | "news";
265
+
266
+ // ============================================================================
267
+
268
+ /** Détecte un Readable Node sans toucher à des props privées */
269
+ function isNodeReadable(obj: any) {
270
+ return !!obj && typeof obj === "object"
271
+ && typeof (obj as any).pipe === "function"
272
+ && typeof (obj as any).on === "function";
273
+ }
274
+
275
+ /**
276
+ * Classe de base pour toutes les entités métiers : utilisateurs, projets, organisations, etc.
277
+ * Fournit un système de brouillon (draft), transformation, appel API sécurisé,
278
+ * et gestion de données côté client avec support du mode offline.
279
+ * @template object TServerData
280
+ * @abstract
281
+ */
282
+ export class BaseEntity<TServerData = any> {
283
+ // Propriétés TypeScript avec types corrects
284
+ __entityTag: string;
285
+ deps: Partial<Deps>;
286
+ apiClient: ApiClient;
287
+ parent: ParentLike | null;
288
+ userContext: User | null;
289
+ endpointApi: EndpointApi;
290
+ data: any; // Proxy dynamique - difficile à typer précisément
291
+ meta?: any; // Métadonnées ajoutées dynamiquement par linkEntitiesFromServerData()
292
+
293
+ // Propriétés existantes avec types TypeScript
294
+ // _draftData est réactif (wrappé avec reactive())
295
+ _draftData: ReactiveData = {};
296
+ // _initialDraftData est un snapshot non-réactif (plain object)
297
+ _initialDraftData: Record<string, any> = {};
298
+ // _serverData est réactif (wrappé avec reactive()) et typé selon TServerData
299
+ _serverData: TServerData & ReactiveData = reactive({}) as TServerData & ReactiveData;
300
+ _calledFromSave: boolean = false;
301
+ _syncReactiveDraft: boolean = false;
302
+ _isDeleted: boolean = false;
303
+ _add?: ((payload: any) => Promise<void | unknown>) | undefined;
304
+ _update?: ((payload: any) => Promise<boolean>) | undefined;
305
+
306
+ // Metadata pour recalcul dynamique des allowedFields dans le proxy
307
+ _allowedFieldsCache?: string[];
308
+ _allowedFieldsMetadata?: {
309
+ combinedSchema: any;
310
+ transforms: TransformsMap;
311
+ removeFields: string[];
312
+ };
313
+
314
+ static entityTag = "BaseEntity";
315
+ static entityType?: string;
316
+ static SCHEMA_CONSTANTS?: string | string[];
317
+
318
+ /**
319
+ * Vérifie que l'objet n'a pas été supprimé.
320
+ * @throws {ApiError} - Si l'objet a été supprimé.
321
+ */
322
+ protected _checkNotDeleted(): void {
323
+ if (this._isDeleted) {
324
+ throw new ApiError(`Cet objet ${this.__entityTag} a été supprimé et ne peut plus être utilisé.`, 400);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Constructeur de l'entité.
330
+ *
331
+ * `parent` peut être :
332
+ * - une instance d'ApiClient
333
+ * - une instance d'entité (User, Organization, Project, Poi, Event, Badge, News)
334
+ *
335
+ * @param parent
336
+ * @param data - Données initiales.
337
+ * @param deps - Dépendances injectées (EndpointApi, autres entités).
338
+ * @param config - Configuration optionnelle (ex. `entityTag`).
339
+ * @throws {ApiError} Si `parent` est invalide ou si `deps.EndpointApi` n'est ni une instance ni un constructeur.
340
+ */
341
+ constructor(parent: ApiClient | ParentLike, data: object = {}, deps: Partial<Deps> = {}, config: BaseEntityConfig = {}) {
342
+ this.__entityTag = config.entityTag || this.getEntityTag(this._getCtor().entityTag) || "BaseEntity";
343
+ this.deps = deps;
344
+
345
+ const isApiClientParent = this.getEntityTag(parent?.__entityTag) === "ApiClient";
346
+
347
+ if (isApiClientParent) {
348
+ this.apiClient = parent as ApiClient;
349
+ this.parent = null;
350
+ this.userContext = null;
351
+ } else if (parent && "apiClient" in parent) {
352
+ const p = parent as ParentLike;
353
+ this.apiClient = p.apiClient;
354
+ this.parent = p;
355
+ this.userContext = this.getEntityTag(p?.__entityTag) === "User" ? (p as User) : (p.userContext || null);
356
+ } else {
357
+ throw new ApiError("Parent invalide ou ApiClient manquant.", 400);
358
+ }
359
+
360
+ // Gérer les deux cas : fonction constructeur ou instance
361
+ if (typeof deps.EndpointApi === "function") {
362
+ this.endpointApi = new deps.EndpointApi(this.apiClient);
363
+ } else if (typeof deps.EndpointApi === "object") {
364
+ this.endpointApi = deps.EndpointApi;
365
+ } else {
366
+ throw new ApiError("deps.EndpointApi doit être une classe ou une instance valide.", 500);
367
+ }
368
+
369
+ this._serverData = reactive({});
370
+
371
+ const { draft, proxy, initial } = this._buildDraftAndProxy({
372
+ data: { ...data, ...this.defaultFields },
373
+ serverData: this._serverData,
374
+ constant: this._getCtor().SCHEMA_CONSTANTS,
375
+ apiClient: this.apiClient,
376
+ transforms: this.transforms,
377
+ removeFields: this.removeFields
378
+ });
379
+
380
+ this._initialDraftData = initial;
381
+ this._draftData = draft;
382
+ this.data = proxy;
383
+ }
384
+
385
+ /**
386
+ * Permet de récupérer le constructeur typé correctement.
387
+ */
388
+ protected _getCtor(): BaseEntityCtor {
389
+ return this.constructor as BaseEntityCtor;
390
+ }
391
+
392
+ getEntityTag = (__entityTag: string | undefined) => __entityTag?.replace(/^_/, "");
393
+
394
+ /** @returns Identifiant de l'entité */
395
+ get id(): string | null {
396
+ return this._draftData.id || null;
397
+ }
398
+
399
+ /** @returns Slug de l'entité */
400
+ get slug(): string | null {
401
+ return this._draftData.slug || null;
402
+ }
403
+
404
+ /**
405
+ * Définit un ID (utilisé en interne)
406
+ * @param newId - Nouvel ID à définir.
407
+ */
408
+ _id(newId: string) {
409
+ this._draftData.id = newId;
410
+ }
411
+
412
+ /** @returns Indique si l'utilisateur est connecté */
413
+ get isConnected(): boolean {
414
+ return this.apiClient.isConnected;
415
+ }
416
+
417
+ /** @returns Identifiant utilisateur associé */
418
+ get userId(): string | null {
419
+ return this.apiClient.userId;
420
+ }
421
+
422
+ /** @returns Données de brouillon courantes (réactif) */
423
+ get draftData(): ReactiveData {
424
+ return this._draftData;
425
+ }
426
+
427
+ /** @returns Données de brouillon initiales (non-réactif) */
428
+ get initialDraftData(): Record<string, any> {
429
+ return this._initialDraftData;
430
+ }
431
+
432
+ /** @returns Données brutes du serveur */
433
+ get serverData(): TServerData {
434
+ return this._serverData;
435
+ }
436
+
437
+ /** @returns Indique si cette entité représente l'utilisateur connecté */
438
+ get isMe(): boolean {
439
+ return !!(this.isConnected && this.userId && this.userContext?.id && typeof this.userId=== "string" && typeof this.userContext?.id === "string" && this.userId === this.userContext?.id);
440
+ }
441
+
442
+ /** @returns Type de l'entité (ex: 'citoyens') */
443
+ getEntityType(): "citoyens" | "organizations" | "projects" | "events" | "poi" | "badges" | "news" | "comments" {
444
+ return this._getCtor().entityType;
445
+ }
446
+
447
+ /**
448
+ * Indique si le draft contient des modifications par rapport aux données initiales.
449
+ * @returns {boolean}
450
+ */
451
+ hasChanges(): boolean {
452
+ const draftRaw = this._toRawDeep(this._draftData);
453
+ const draftSerialized = this._serialize(draftRaw);
454
+ const initialSerialized = this._serialize(this._initialDraftData);
455
+
456
+ // Comparer les chaînes JSON au lieu des références d'objets
457
+ return JSON.stringify(draftSerialized) !== JSON.stringify(initialSerialized);
458
+ }
459
+
460
+ /**
461
+ * Rafraîchit l'entité en rechargeant ses données depuis le serveur.
462
+ * @returns Données mises à jour
463
+ */
464
+ async refresh(): Promise<Record<string, any>> {
465
+ if (!this.id) throw new ApiError("Impossible de rafraîchir sans ID.", 400);
466
+ return this.get();
467
+ }
468
+
469
+ /**
470
+ * Sauvegarde les modifications locales vers le serveur (add ou update).
471
+ * @returns Données serveur mises à jour (après éventuel `refresh()`)
472
+ */
473
+ async save(): Promise<Record<string, any>> {
474
+ this._checkNotDeleted();
475
+ if (!this.isConnected) throw new ApiError("Non connecté.", 401);
476
+ this._calledFromSave = true;
477
+ try {
478
+ const payload = { ...this._draftData };
479
+
480
+ if (!this.id && typeof this._add === "function") {
481
+ await this._add(payload);
482
+ this._resetInitialDraftData();
483
+ // on refresh le contexte utilisateur si besoin
484
+ if(this.userContext) {
485
+ await this.userContext?.refresh();
486
+ }
487
+ return await this.refresh();
488
+ } else if (typeof this._update === "function") {
489
+ const hasChanged = await this._update(payload);
490
+ this._resetInitialDraftData();
491
+ if (hasChanged) return await this.refresh();
492
+ }
493
+
494
+ return this._serverData;
495
+ } finally {
496
+ this._calledFromSave = false;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Crée une nouvelle instance d'entité à partir des données du serveur.
502
+ *
503
+ * @param data - Données du serveur.
504
+ * @param parent - Instance parente (ApiClient ou BaseEntity).
505
+ * @param deps - Dépendances injectées (classes d'entités disponibles).
506
+ * @returns Nouvelle instance d'entité.
507
+ */
508
+ static fromServerData(
509
+ data: object,
510
+ parent: ApiClient | ParentLike,
511
+ deps: object
512
+ ): BaseEntity<any> {
513
+ const instance = new this(parent, {}, deps);
514
+ instance._setData(data as any);
515
+ return instance;
516
+ }
517
+
518
+ /**
519
+ * Hook pour transformer les données du serveur avant qu'elles ne soient appliquées.
520
+ * Peut être overridé dans les classes filles pour transformer des champs imbriqués en instances d'entités.
521
+ *
522
+ * @param data - Les données brutes du serveur.
523
+ * @returns Les données transformées.
524
+ * @protected
525
+ */
526
+ protected _transformServerData(data: TServerData): TServerData {
527
+ return data;
528
+ }
529
+
530
+ /**
531
+ * Met à jour les données de l'entité avec de nouvelles données.
532
+ *
533
+ * @param newData - Les nouvelles données à appliquer.
534
+ * @param {{ forceInitialDraftReset?: boolean }} [options]
535
+ * @protected
536
+ */
537
+ protected _setData(newData: TServerData, { forceInitialDraftReset = false }: { forceInitialDraftReset?: boolean } = {}): void {
538
+ if (this.userContext && this.userContext !== (this as object)) {
539
+ this.apiClient._logger?.info?.(`[${this.__entityTag}] Mise à jour liée à userContext : ${this.userContext.id}`);
540
+ }
541
+
542
+ const transformed = this._transformServerData(newData);
543
+ const incoming = (transformed ?? {});
544
+ if (this._serverData && isReactive(this._serverData)) {
545
+ Object.assign(this._serverData, incoming);
546
+ } else {
547
+ this._serverData = reactive({ ...incoming }) as TServerData & ReactiveData;
548
+ }
549
+
550
+ const clientDraft = this._draftData ? this._toRawDeep(this._draftData) : {};
551
+
552
+ const mergedData = {
553
+ ...(newData || {}),
554
+ ...this.defaultFields,
555
+ ...clientDraft
556
+ };
557
+
558
+ const { draft, proxy, initial } = this._buildDraftAndProxy({
559
+ data: mergedData,
560
+ serverData: this._serverData,
561
+ previousDraft: this._draftData,
562
+ constant: this._getCtor().SCHEMA_CONSTANTS,
563
+ apiClient: this.apiClient,
564
+ transforms: this.transforms,
565
+ removeFields: this.removeFields
566
+ });
567
+
568
+ if (forceInitialDraftReset) {
569
+ this._initialDraftData = structuredClone(this._toRawDeep(draft));
570
+ } else if (!this._initialDraftData) {
571
+ this._initialDraftData = initial;
572
+ }
573
+
574
+ if (isReactive(this._draftData)) {
575
+ this._updateDraftPreservingUserChanges(draft);
576
+ } else {
577
+ this._draftData = reactive(draft);
578
+ }
579
+
580
+ if (!this.data) {
581
+ this.data = proxy;
582
+ }
583
+
584
+ }
585
+
586
+ _updateDraftPreservingUserChanges(draft: Record<string, any>): void {
587
+ for (const key of Object.keys(draft)) {
588
+ const current = this._draftData?.[key];
589
+ const initialValue = this._initialDraftData?.[key];
590
+
591
+ const isModified =
592
+ current !== undefined &&
593
+ initialValue !== undefined &&
594
+ this._serialize(this._toRawDeep(current)) !== this._serialize(initialValue);
595
+
596
+ if (!isModified) {
597
+ this._draftData[key] = draft[key];
598
+ }
599
+ }
600
+ }
601
+
602
+ _resetInitialDraftData(): void {
603
+ const raw = this._toRawDeep(this._draftData);
604
+ this._initialDraftData = structuredClone(raw);
605
+ }
606
+
607
+
608
+ _toRawDeep(obj: any): any {
609
+
610
+ if (!this || typeof this._toRawDeep !== "function") {
611
+ throw new Error("`this._toRawDeep` is not bound correctly. Use a lambda to preserve context.");
612
+ }
613
+
614
+ if (isSignal(obj)) {
615
+ return this._toRawDeep(obj.value);
616
+ }
617
+
618
+ // Déproxifier les objets réactifs AVANT tout autre check
619
+ if (obj && typeof obj === "object" && obj.__isReactive && obj.__raw) {
620
+ return this._toRawDeep(obj.__raw);
621
+ }
622
+
623
+ // Préserver les objets Date (avant le check générique "object")
624
+ if (obj instanceof Date) {
625
+ return obj;
626
+ }
627
+
628
+ // Préserver les types d'upload pour éviter leur sérialisation incorrecte
629
+ // Buffer (Node.js)
630
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(obj)) {
631
+ return obj;
632
+ }
633
+
634
+ // File (Browser)
635
+ if (typeof File !== "undefined" && obj instanceof File) {
636
+ return obj;
637
+ }
638
+
639
+ // Blob (Browser)
640
+ if (typeof Blob !== "undefined" && obj instanceof Blob) {
641
+ return obj;
642
+ }
643
+
644
+ // ReadableStream (Node.js) - détecté via les méthodes pipe et on
645
+ if (obj && typeof obj.pipe === "function" && typeof obj.on === "function") {
646
+ return obj;
647
+ }
648
+
649
+ if (Array.isArray(obj)) {
650
+ return obj.map((item) => this._toRawDeep(item));
651
+ }
652
+
653
+ if (typeof obj === "object" && obj !== null) {
654
+ const result: any = {};
655
+ for (const key of Object.keys(obj)) {
656
+ result[key] = this._toRawDeep(obj[key]);
657
+ }
658
+ return result;
659
+ }
660
+
661
+ return obj; // valeur primitive
662
+ }
663
+
664
+ /**
665
+ * Champs à ajouter automatiquement à chaque draft (ex: `typeElement`).
666
+ * Souvent utilisés dans les conditions `if/then` du JSON Schema.
667
+ */
668
+ defaultFields: { [key: string]: any } = {};
669
+
670
+ /**
671
+ * Champs à exclure explicitement du draft et des payloads.
672
+ */
673
+ removeFields: string[] = [];
674
+
675
+ /**
676
+ * Transformations à appliquer à certains champs lors de la lecture depuis le draft.
677
+ * Clé = champ, valeur = fonction (val, full) => valeur transformée.
678
+ */
679
+ transforms: TransformsMap = {};
680
+
681
+ /**
682
+ * ───────────────────────────────
683
+ * JSON
684
+ * ───────────────────────────────
685
+ */
686
+
687
+ /**
688
+ * Convertit l'instance en JSON pour l'envoi au serveur.
689
+ *
690
+ * @returns Représentation JSON de l'instance.
691
+ */
692
+ toJSON(): { __entityTag: string; __isSerializedEntity: boolean; serverData: any; parent: { id?: string; type?: string; __entityTag?: string; slug?: string } | null } {
693
+ const parentMeta: { id?: string; type?: string; __entityTag?: string; slug?: string } = {};
694
+ if (this.parent?.id) parentMeta.id = this.parent.id;
695
+ if (typeof this.parent?.getEntityType === "function") parentMeta.type = this.parent.getEntityType();
696
+ if (this.parent?.__entityTag) parentMeta.__entityTag = this.parent.__entityTag;
697
+ if (this.parent?.slug) parentMeta.slug = this.parent.slug;
698
+
699
+ return {
700
+ __entityTag: this.__entityTag,
701
+ __isSerializedEntity: true,
702
+ serverData: this._serialize(this._serverData),
703
+ parent: Object.keys(parentMeta).length > 0 ? parentMeta : null
704
+ };
705
+ }
706
+
707
+
708
+ _serialize(obj: any): any {
709
+ try {
710
+ return JSON.parse(EJSON.stringify(this._removeUnserializables(obj)));
711
+ } catch (e) {
712
+ const error = e as Error;
713
+ this.apiClient?._logger?.error?.("Erreur de sérialisation EJSON:", {
714
+ message: error?.message,
715
+ stack: error?.stack,
716
+ error: e
717
+ });
718
+ return null;
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Supprime les propriétés non sérialisables d'un objet.
724
+ *
725
+ * @param obj - L'objet à nettoyer.
726
+ * @param seen - Ensemble pour éviter les références circulaires.
727
+ * @returns L'objet nettoyé.
728
+ * @private
729
+ */
730
+ private _removeUnserializables(obj: any, seen = new WeakSet<object>()): any {
731
+ if (obj === null || typeof obj !== "object") return obj;
732
+
733
+ if (seen.has(obj)) return null;
734
+ seen.add(obj);
735
+
736
+ // Ignore les proxys réactifs
737
+ if (obj.__isReactive && typeof obj.__raw === "object") {
738
+ return this._removeUnserializables(obj.__raw, seen);
739
+ }
740
+
741
+ if (Array.isArray(obj)) {
742
+ return obj.map((el) => this._removeUnserializables(el, seen));
743
+ }
744
+
745
+ const clean: Record<string, any> = {};
746
+ for (const key of Object.keys(obj)) {
747
+ const val = obj[key];
748
+
749
+ if (
750
+ typeof val === "function" ||
751
+ typeof val === "symbol" ||
752
+ typeof val === "undefined"
753
+ ) {
754
+ continue;
755
+ }
756
+
757
+ try {
758
+ clean[key] = this._removeUnserializables(val, seen);
759
+
760
+ } catch {
761
+ clean[key] = null;
762
+ }
763
+ }
764
+
765
+ return clean;
766
+ }
767
+
768
+ /**
769
+ * Restaure les données sérialisées en un objet d'origine.
770
+ *
771
+ * @param obj - L'objet à restaurer.
772
+ * @returns L'objet restauré.
773
+ */
774
+ static _revive(obj: object): any {
775
+ return EJSON.fromJSONValue(obj);
776
+ }
777
+
778
+ /**
779
+ * Crée une instance d'entité à partir de données JSON.
780
+ *
781
+ * @param json - Données JSON à utiliser.
782
+ * @param parent - Instance parente.
783
+ * @param deps - Dépendances injectées.
784
+ * @returns Nouvelle instance d'entité.
785
+ */
786
+ static fromJSON(
787
+ json: unknown,
788
+ parent?: ApiClient | BaseEntity<any> | null,
789
+ deps?: object
790
+ ): BaseEntity<any> {
791
+ if (!json || typeof json !== "object" || !("serverData" in json)) {
792
+ throw new Error("Invalid JSON format: missing serverData");
793
+ }
794
+ const { serverData } = json as { serverData: object };
795
+
796
+ const instance = this.fromServerData(this._revive(serverData), parent as ApiClient | ParentLike, deps || {});
797
+
798
+ return instance;
799
+ }
800
+
801
+ /**
802
+ * ───────────────────────────────
803
+ * UtilMixin
804
+ * ───────────────────────────────
805
+ */
806
+
807
+ /**
808
+ * Appelle une méthode de l'API.
809
+ *
810
+ * @param constant - Le nom de la méthode à appeler.
811
+ * @param data - Les données à passer à la méthode.
812
+ * @returns - `response.data` transformé ; peut être `null` si la requête est **mise en file** (offline/breaker).
813
+ * @throws {ApiValidationError} - Si une erreur se produit lors de l'appel de l'API.
814
+ * @throws {ApiResponseError} - Si une erreur se produit lors de l'appel de l'API.
815
+ * @throws {ApiClientError} - Si l'utilisateur n'est pas authentifié.
816
+ */
817
+ async call(constant: string, data: object = {}): Promise<unknown> {
818
+ return this.apiClient.safeCall(async () => {
819
+ const response = await this.apiClient.callEndpoint(constant, data);
820
+ this.apiClient.checkAndThrowApiResponseError(response);
821
+ return response.data;
822
+ });
823
+ }
824
+
825
+ /**
826
+ * Appelle une méthode de l'API si l'utilisateur n'est pas connecté.
827
+ *
828
+ * @param constant - Le nom de la méthode à appeler.
829
+ * @param data - Les données à passer à la méthode.
830
+ * @returns - `response.data` ou `null` si enqueue offline/breaker.
831
+ * @throws {ApiAuthenticationError} - Si l'utilisateur est connecté.
832
+ * @throws {ApiValidationError} - Si une erreur se produit lors de l'appel de l'API.
833
+ * @throws {ApiResponseError} - Si une erreur se produit lors de l'appel de l'API.
834
+ * @throws {ApiClientError} - Si une erreur se produit lors de l'appel de l'API.
835
+ */
836
+ async callNoConnected(constant: string, data: object = {}): Promise<unknown> {
837
+ if(this.isConnected) {
838
+ throw new ApiAuthenticationError("Vous devez ne devez pas être connecté pour faire cette action.", 403);
839
+ }
840
+ return this.call(constant, data);
841
+ }
842
+
843
+ /**
844
+ * Appelle une méthode de l'API si l'utilisateur est connecté.
845
+ *
846
+ * @param param - Le nom de la méthode à appeler ou une fonction de rappel.
847
+ * @param data - Les données à passer à la méthode.
848
+ * @returns - `response.data` ou `null` si enqueue offline/breaker.
849
+ * @throws {ApiAuthenticationError} - Si l'utilisateur n'est pas connecté.
850
+ * @throws {ApiValidationError} - Si une erreur se produit lors de l'appel de l'API.
851
+ * @throws {ApiResponseError} - Si une erreur se produit lors de l'appel de l'API.
852
+ * @throws {ApiClientError} - Si une erreur se produit lors de l'appel de l'API.
853
+ */
854
+ async callIsConnected(param: string | (() => Promise<any>), data: object = {}): Promise<unknown> {
855
+ if(!this.isConnected) {
856
+ throw new ApiAuthenticationError("Vous devez être connecté pour faire cette action.", 401);
857
+ }
858
+ // Si le premier paramètre est une fonction, on l'exécute en tant que callback
859
+ if (typeof param === "function") {
860
+ return await param();
861
+ }
862
+ return this.call(param, data);
863
+ }
864
+
865
+ /**
866
+ * Appelle une méthode de l'API si l'utilisateur est lui-même.
867
+ *
868
+ * @param param - Le nom de la méthode à appeler ou une fonction de rappel.
869
+ * @param data - Les données à passer à la méthode.
870
+ * @returns - `response.data` ou `null` si enqueue offline/breaker.
871
+ * @throws {ApiAuthenticationError} - Si l'utilisateur n'est pas lui-même.
872
+ * @throws {ApiValidationError} - Si une erreur se produit lors de l'appel de l'API.
873
+ * @throws {ApiResponseError} - Si une erreur se produit lors de l'appel de l'API.
874
+ * @throws {ApiClientError} - Si une erreur se produit lors de l'appel de l'API.
875
+ */
876
+ async callIsMe(param: string | (() => Promise<any>), data: object = {}): Promise<unknown> {
877
+ if (!this.isMe) {
878
+ throw new ApiAuthenticationError("Vous devez être vous-même pour faire cette action.", 403);
879
+ }
880
+ // Si le premier paramètre est une fonction, on l'exécute en tant que callback
881
+ if (typeof param === "function") {
882
+ return await param();
883
+ }
884
+ // Sinon, on considère qu'il s'agit d'un constant et on appelle la méthode par défaut
885
+ return await this.callIsConnected(param, data);
886
+ }
887
+
888
+ /**
889
+ * Valide une image d'entrée.
890
+ *
891
+ * @param imageInput - L'image à valider.
892
+ * @returns - L'image validée.
893
+ * @private
894
+ */
895
+ protected async _validateImage(imageInput: UploadInput): Promise<ValidatedUpload> {
896
+ const image = await this._validateUploadInput(imageInput, {
897
+ allowedMimeTypes: ["image/jpeg", "image/png", "image/jpg"],
898
+ expectedType: "image"
899
+ });
900
+ return image;
901
+ }
902
+
903
+ /**
904
+ * Valide un fichier d'entrée.
905
+ *
906
+ * @param fileInput - Le fichier à valider.
907
+ * @returns - Le fichier validé.
908
+ * @private
909
+ */
910
+ protected async _validateFile(fileInput: UploadInput): Promise<ValidatedUpload> {
911
+ const file = await this._validateUploadInput(fileInput, {
912
+ allowedMimeTypes: ["application/pdf", "text/plain", "text/csv"],
913
+ expectedType: "file"
914
+ });
915
+ return file;
916
+ }
917
+
918
+ /**
919
+ * Valide les entrées d'upload de fichiers.
920
+ *
921
+ * @param input - Le fichier à valider.
922
+ * @param {{ allowedMimeTypes?: string[], expectedType?: "image"|"file"|"any" }} options - Options de validation.
923
+ * @returns - Le fichier validé.
924
+ * @throws {ApiValidationError} - Si le type de fichier est invalide.
925
+ * @throws {Error} - Si le type de fichier est inconnu.
926
+ * @private
927
+ */
928
+ private async _validateUploadInput(input: UploadInput, { allowedMimeTypes = [], expectedType = "any" }: { allowedMimeTypes?: string[]; expectedType?: string }): Promise<ValidatedUpload> {
929
+ if (!input) {
930
+ throw new ApiValidationError("Le fichier est requis.", 400, ["Le fichier est requis"]);
931
+ }
932
+
933
+ const isNode = typeof window === "undefined" && typeof process !== "undefined";
934
+
935
+ let output: ValidatedUpload | null = null;
936
+ let mimeType = "";
937
+
938
+ // Navigateur : File
939
+ if (typeof File !== "undefined" && input instanceof File) {
940
+ mimeType = input.type;
941
+ if (!allowedMimeTypes.includes(mimeType)) {
942
+ throw new ApiValidationError("Le type du fichier est invalide.", 400, ["Le type du fichier est invalide"]);
943
+ }
944
+ }
945
+
946
+ // Navigateur : Blob
947
+ else if (typeof Blob !== "undefined" && input instanceof Blob) {
948
+ mimeType = input.type;
949
+ if (!allowedMimeTypes.includes(mimeType)) {
950
+ throw new ApiValidationError("Le type du fichier est invalide.", 400, ["Le type du fichier est invalide"]);
951
+ }
952
+
953
+ const ext = mimeType.split("/")[1] || "bin";
954
+ const fileName = `${Date.now()}.${ext}`;
955
+ output = new File([input], fileName, { type: mimeType });
956
+ }
957
+
958
+ // Node.js : Buffer
959
+ else if (isNode && Buffer.isBuffer(input)) {
960
+ const fileTypeResult = await fromBuffer(input);
961
+
962
+ if (!fileTypeResult) {
963
+ throw new ApiValidationError("Le type du fichier est invalide.", 400, ["Le type du fichier est invalide"]);
964
+ }
965
+
966
+ mimeType = fileTypeResult.mime;
967
+
968
+ if (!allowedMimeTypes.includes(mimeType)) {
969
+ throw new ApiValidationError("Le type du fichier est invalide.", 400, ["Le type du fichier est invalide"]);
970
+ }
971
+
972
+ // Pour un fichier image, on transforme en stream
973
+ if (expectedType === "image") {
974
+ const ext = fileTypeResult.ext;
975
+ const filename = `${Date.now()}.${ext}`;
976
+ output = await this._createReadStreamFromBuffer(input, filename, mimeType);
977
+ } else {
978
+ output = input;
979
+ }
980
+ }
981
+
982
+ // Node.js : ReadableStream
983
+ else if (isNode && isNodeReadable(input)) {
984
+ const readableIn = input as import("stream").Readable;
985
+
986
+ const previewChunks: Buffer[] = [];
987
+ const tee = await this._passThrough();
988
+ const resultStream = await this._passThrough(); // PassThrough est aussi un Readable
989
+
990
+ const MAX_BYTES = 4100;
991
+ let bytesRead = 0;
992
+
993
+ readableIn.on("data", (chunk) => {
994
+ if (bytesRead < MAX_BYTES) {
995
+ previewChunks.push(chunk);
996
+ bytesRead += chunk.length;
997
+ }
998
+ });
999
+
1000
+ readableIn.pipe(tee).pipe(resultStream);
1001
+ await new Promise((resolve) => setTimeout(resolve, 10));
1002
+
1003
+ const previewBuffer = Buffer.concat(previewChunks);
1004
+ const fileTypeResult = await fromBuffer(previewBuffer);
1005
+ if (!fileTypeResult) {
1006
+ throw new ApiValidationError("Le type du fichier est invalide.", 400, ["Le type du fichier est invalide"]);
1007
+ }
1008
+ mimeType = fileTypeResult.mime;
1009
+
1010
+ if (!allowedMimeTypes.includes(mimeType)) {
1011
+ throw new ApiValidationError("Le type du fichier est invalide.", 400, ["Le type du fichier est invalide"]);
1012
+ }
1013
+
1014
+ // on annote explicitement pour TS
1015
+ (resultStream as ReadableWithMeta).path = `${Date.now()}.${fileTypeResult.ext}`;
1016
+ (resultStream as ReadableWithMeta).mimeType = mimeType;
1017
+
1018
+ output = resultStream as ReadableWithMeta;
1019
+ }
1020
+
1021
+ else {
1022
+ throw new ApiValidationError("Type de fichier non reconnu.", 400, ["Type de fichier non reconnu."]);
1023
+ }
1024
+
1025
+ return output!;
1026
+ }
1027
+
1028
+ /**
1029
+ * Transforme un Buffer en ReadableStream équivalent à fs.createReadStream
1030
+ * @param buffer - Le buffer contenant les données binaires
1031
+ * @param [filename="file.bin"] - Nom de fichier (utilisé dans FormData)
1032
+ * @param [mimeType="application/octet-stream"] - Type MIME (utilisé dans FormData)
1033
+ * @returns - Readable doté de `path` et `mimeType`
1034
+ * @private
1035
+ */
1036
+ private async _createReadStreamFromBuffer(buffer: Buffer, filename = "file.bin", mimeType = "application/octet-stream"): Promise<ReadableWithMeta> {
1037
+ const stream = await this._bufferToReadable(buffer);
1038
+ (stream as ReadableWithMeta).path = filename;
1039
+ (stream as ReadableWithMeta).mimeType = mimeType;
1040
+ return stream as ReadableWithMeta;
1041
+ }
1042
+
1043
+ /**
1044
+ * Transforme un Buffer en ReadableStream.
1045
+ *
1046
+ * @param buffer - Le buffer à transformer.
1047
+ * @returns - Un ReadableStream.
1048
+ * @throws {Error} - Si appelé dans le navigateur.
1049
+ * @private
1050
+ */
1051
+ private async _bufferToReadable(buffer: Buffer): Promise<import("stream").Readable> {
1052
+ if (typeof window === "undefined") {
1053
+ const { bufferToReadable } = await import("../utils/stream-utils.node.js");
1054
+ return bufferToReadable(buffer);
1055
+ } else {
1056
+ throw new Error("bufferToReadable ne doit pas être appelé dans le navigateur");
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Crée un PassThrough stream pour le traitement des fichiers.
1062
+ *
1063
+ * @returns - Un PassThrough stream.
1064
+ * @throws {Error} - Si appelé dans le navigateur.
1065
+ * @private
1066
+ */
1067
+ private async _passThrough(): Promise<import("stream").PassThrough> {
1068
+ if (typeof window === "undefined") {
1069
+ const { createPassThrough } = await import("../utils/stream-utils.node.js");
1070
+ return createPassThrough();
1071
+ } else {
1072
+ throw new Error("passThrough ne doit pas être appelé dans le navigateur");
1073
+ }
1074
+ }
1075
+
1076
+ // async _bufferToReadable(buffer) {
1077
+ // if (typeof window === "undefined") {
1078
+ // const { Readable } = await import("stream");
1079
+ // return Readable.from(buffer);
1080
+ // } else {
1081
+ // throw new Error("bufferToReadable ne doit pas être appelé dans le navigateur");
1082
+ // }
1083
+ // },
1084
+
1085
+ // async _passThrough() {
1086
+ // if (typeof window === "undefined") {
1087
+ // const { PassThrough } = await import("stream");
1088
+ // return new PassThrough();
1089
+ // } else {
1090
+ // throw new Error("passThrough ne doit pas être appelé dans le navigateur");
1091
+ // }
1092
+ // },
1093
+
1094
+ /**
1095
+ * Supprime les propriétés d'un objet en fonction d'une liste de clés.
1096
+ *
1097
+ * @param obj - L'objet source.
1098
+ * @param propsToRemove - Liste des clés à supprimer
1099
+ * @returns - Un nouvel objet sans les propriétés supprimées.
1100
+ * @private
1101
+ */
1102
+ private _omitProps(obj: object, propsToRemove: string[]): Record<string, any> {
1103
+ if (!obj || typeof obj !== "object") return {};
1104
+
1105
+ const result: Record<string, any> = { ...obj };
1106
+ for (const prop of propsToRemove) {
1107
+ delete result[prop];
1108
+ }
1109
+ return result;
1110
+ }
1111
+
1112
+ /**
1113
+ * Extrait les propriétés d'un objet en fonction d'une liste de clés.
1114
+ *
1115
+ * @param obj - L'objet source.
1116
+ * @param keys - Liste des clés à extraire.
1117
+ * @param [transforms={}] - Transformations à appliquer aux valeurs.
1118
+ * @returns - Un nouvel objet contenant les propriétés extraites.
1119
+ * @private
1120
+ */
1121
+ private _pickProps(obj: any, keys: string[], transforms: TransformsMap = {}): Record<string, any> {
1122
+ if (!obj || typeof obj !== "object") return {};
1123
+
1124
+ const result: Record<string, any> = {};
1125
+
1126
+ for (const key of keys) {
1127
+ if (key in obj) {
1128
+ const value = obj[key];
1129
+ if (typeof transforms[key] === "function") {
1130
+ result[key] = transforms[key](value, obj); // (valeur, objet source)
1131
+ } else {
1132
+ result[key] = value;
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ return result;
1138
+ }
1139
+
1140
+ /**
1141
+ * Vérifie si au moins une clé est présente dans l'objet et n'est pas nulle.
1142
+ *
1143
+ * @param obj - L'objet à vérifier.
1144
+ * @param keys - Liste des clés à vérifier.
1145
+ * @returns - true si au moins une clé est présente et non nulle, sinon false.
1146
+ * @private
1147
+ */
1148
+ private _hasAtLeastOne(obj: any, keys: string[] = []): boolean {
1149
+ return keys.some((key) => key in obj && obj[key] != null);
1150
+ }
1151
+
1152
+ /**
1153
+ * Crée un proxy filtré pour une liste d'entités.
1154
+ * @private
1155
+ */
1156
+ _createFilteredProxy<T extends Deletable>(list: T[]): T[] {
1157
+ return new Proxy(list, {
1158
+ get(target, prop, receiver) {
1159
+ const active = target.filter(n => !n._isDeleted);
1160
+
1161
+ if (prop === "length") return active.length;
1162
+
1163
+ if (typeof prop === "string" && /^\d+$/.test(prop)) {
1164
+ return active[Number(prop)];
1165
+ }
1166
+
1167
+ // Récupère la propriété depuis la vue filtrée
1168
+ const value = Reflect.get(active, prop, receiver);
1169
+
1170
+ // Si c’est une méthode, lier à 'active' (une seule évaluation du filter)
1171
+ return typeof value === "function" ? value.bind(active) : value;
1172
+ }
1173
+ });
1174
+ }
1175
+
1176
+ /**
1177
+ * Génère un nouvel identifiant unique.
1178
+ * @returns Un identifiant unique.
1179
+ * @protected
1180
+ */
1181
+ protected _newId(): string {
1182
+ return createObjectId().toHexString();
1183
+ }
1184
+
1185
+ /**
1186
+ * ───────────────────────────────
1187
+ * DraftStateMixin
1188
+ * ───────────────────────────────
1189
+ */
1190
+
1191
+ /**
1192
+ * Recalcule les champs autorisés en fonction de l'état actuel du draft.
1193
+ * Utilisé pour validation dynamique dans le proxy this.data.
1194
+ * @returns Liste des champs autorisés
1195
+ * @private
1196
+ */
1197
+ private _getAllowedFieldsForCurrentState(): string[] {
1198
+ if (!this._allowedFieldsMetadata) {
1199
+ return this._allowedFieldsCache || [];
1200
+ }
1201
+
1202
+ const { combinedSchema, removeFields } = this._allowedFieldsMetadata;
1203
+
1204
+ // Combiner defaultFields + draft pour évaluer les conditions if/then
1205
+ // typeElement vient de defaultFields et ne change jamais
1206
+ const rawDraft = this._toRawDeep(this._draftData);
1207
+ const currentData = { ...this.defaultFields, ...rawDraft };
1208
+
1209
+ let allowed = this._extractWritableFields(combinedSchema, currentData);
1210
+
1211
+ if (currentData.id && !allowed.includes("id")) {
1212
+ allowed.push("id");
1213
+ }
1214
+ if (currentData.slug && !allowed.includes("slug")) {
1215
+ allowed.push("slug");
1216
+ }
1217
+
1218
+ allowed = allowed.filter(k => !removeFields.includes(k));
1219
+
1220
+ return allowed;
1221
+ }
1222
+
1223
+ /**
1224
+ * Crée un proxy combinant draft + serveur, avec transformations facultatives.
1225
+ * @param apiClient
1226
+ * @param [server={}]
1227
+ * @param [draft={}]
1228
+ * @param _allowedFields - champs autorisés dans le draft (fallback si pas de metadata)
1229
+ * @param [transforms={}] - transformateurs de lecture
1230
+ * @param {{ throwOnError?: boolean }} [options={}] - options
1231
+ * @param [entity=this] - référence à l'entité pour recalcul dynamique
1232
+ * @returns {object}
1233
+ * @private
1234
+ */
1235
+ private _createDraftProxy(apiClient: ApiClient, server: Record<string, unknown> = {}, draft: Record<string, unknown> = {}, _allowedFields: string[] = [], transforms: TransformsMap = {}, options: { throwOnError?: boolean } = {}, entity: BaseEntity<any> = this): Record<string, unknown> {
1236
+ return new Proxy({}, {
1237
+ get: (_, prop) => {
1238
+ if (typeof prop !== "string" && typeof prop !== "symbol") return undefined;
1239
+
1240
+ // 1) Draft prioritaire
1241
+ if (typeof prop === "string" && prop in draft) {
1242
+ const value = draft[prop];
1243
+ const transformer = transforms[prop];
1244
+ return typeof transformer === "function" ? transformer(value, draft) : value;
1245
+ }
1246
+
1247
+ // 2) Fallback serveur
1248
+ if (server && typeof server === "object" && typeof prop === "string" && prop in server) {
1249
+ const value = server[prop];
1250
+ const transformer = transforms[prop];
1251
+ return typeof transformer === "function" ? transformer(value, server) : value;
1252
+ }
1253
+
1254
+ return undefined;
1255
+ },
1256
+
1257
+ set: (_, prop, value) => {
1258
+ if (typeof prop !== "string") {
1259
+ // On refuse tout ce qui n'est pas un champ "string"
1260
+ return false;
1261
+ }
1262
+
1263
+ // Recalcul dynamique des champs autorisés basé sur l'état actuel
1264
+ const currentAllowed = entity._getAllowedFieldsForCurrentState();
1265
+
1266
+ if (!currentAllowed.includes(prop)) {
1267
+ const message = `[DraftProxy] Le champ "${prop}" n'est pas autorisé.`;
1268
+ if (options.throwOnError) {
1269
+ throw new ApiValidationError(message, 400, [message], { field: prop, value, allowedFields: currentAllowed });
1270
+ }
1271
+ apiClient._logger?.warn?.(message);
1272
+ return false;
1273
+ }
1274
+
1275
+ const current = draft[prop];
1276
+ if (current && typeof current === "object" && (current as any).__isSignal === true) {
1277
+ (current as any).value = value; // met à jour le signal
1278
+ } else {
1279
+ draft[prop] = value; // fallback classique
1280
+ }
1281
+ return true;
1282
+ },
1283
+
1284
+ deleteProperty: (_, prop) => {
1285
+ if (typeof prop !== "string") return false;
1286
+ const currentAllowed = entity._getAllowedFieldsForCurrentState();
1287
+ if (!currentAllowed.includes(prop)) return false;
1288
+ delete draft[prop];
1289
+ return true;
1290
+ },
1291
+
1292
+ has: (_, prop) => (typeof prop === "string" ? (prop in draft || (server && typeof server === "object" && prop in server)) : false),
1293
+
1294
+ ownKeys: () => {
1295
+ const keys = new Set([
1296
+ ...Object.keys(server || {}),
1297
+ ...Object.keys(draft || {}),
1298
+ ]);
1299
+ return Array.from(keys);
1300
+ },
1301
+
1302
+ getOwnPropertyDescriptor: (_, prop) => {
1303
+ if (typeof prop !== "string") return undefined;
1304
+ if (prop in draft || (server && typeof server === "object" && prop in server)) {
1305
+ return { enumerable: true, configurable: true };
1306
+ }
1307
+ return undefined;
1308
+ }
1309
+ });
1310
+ }
1311
+
1312
+ /**
1313
+ * Extrait les champs modifiables d'un schéma JSON.
1314
+ *
1315
+ * @param schema - Le schéma JSON à analyser.
1316
+ * @param data - Les données à comparer.
1317
+ * @param ctx - Contexte d'extraction (pour la récursion).
1318
+ * @param ctx.defs - Définitions de schéma.
1319
+ * @param ctx.visited - Ensemble des schémas déjà visités.
1320
+ * @returns Liste des champs modifiables (propriétés `writeable` implicites).
1321
+ * @private
1322
+ */
1323
+ private _extractWritableFields(schema: JsonSchema = {}, data: Record<string, unknown> = {}, ctx: { defs: Record<string, JsonSchema>; visited: Set<string> } = { defs: {}, visited: new Set() }): string[] {
1324
+ if (!schema || typeof schema !== "object") return [];
1325
+ if (schema.$id && ctx.visited.has(schema.$id)) return [];
1326
+ if (schema.$id) ctx.visited.add(schema.$id);
1327
+ ctx.defs = schema.$defs || schema.definitions || ctx.defs;
1328
+
1329
+ const fields: string[] = [];
1330
+
1331
+ if (schema.$ref) {
1332
+ const refKey = schema.$ref.replace(/^#\/?(\$defs|definitions)\//, "");
1333
+ const resolved = ctx.defs[refKey];
1334
+ if (resolved) fields.push(...this._extractWritableFields(resolved, data, ctx));
1335
+ }
1336
+
1337
+ if (schema.allOf) {
1338
+ schema.allOf.forEach((s) => fields.push(...this._extractWritableFields(s, data, ctx)));
1339
+ }
1340
+
1341
+ if (schema.if && schema.then) {
1342
+ const condition = schema.if.properties;
1343
+ let matches = true;
1344
+ if (condition) {
1345
+ for (const key in condition) {
1346
+ const expected = condition[key]?.const;
1347
+ if (data[key] !== expected) {
1348
+ matches = false;
1349
+ break;
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ if (matches && schema.then) {
1355
+ fields.push(...this._extractWritableFields(schema.then, data, ctx));
1356
+ } else if (!matches && schema.else) {
1357
+ fields.push(...this._extractWritableFields(schema.else, data, ctx));
1358
+ }
1359
+ }
1360
+
1361
+ if (schema.properties) {
1362
+ fields.push(...Object.entries(schema.properties)
1363
+
1364
+ .filter(([_, def]) => def.readOnly !== true && def.const === undefined)
1365
+ .map(([key]) => key));
1366
+ }
1367
+
1368
+ return Array.from(new Set(fields));
1369
+ }
1370
+
1371
+ /**
1372
+ * Construit un brouillon et un proxy à partir des données et du schéma.
1373
+ *
1374
+ * @param options - Options de construction.
1375
+ * @param options.data - Données à utiliser pour le brouillon.
1376
+ * @param options.serverData - Données du serveur.
1377
+ * @param options.previousDraft - Brouillon précédent pour la réactivité.
1378
+ * @param options.constant - Nom de la constante ou tableau de constantes.
1379
+ * @param options.apiClient - Instance de l'API.
1380
+ * @param options.transforms - Transformations à appliquer.
1381
+ * @param options.throwOnError - Si vrai, lève une erreur en cas de problème.
1382
+ * @param options.removeFields - Liste des champs à ignorer.
1383
+ * @returns Draft réactif, proxy combiné, snapshot initial.
1384
+ * @private
1385
+ */
1386
+ private _buildDraftAndProxy({ data = {}, serverData = null, previousDraft = null, constant, apiClient, transforms = {}, throwOnError = true, removeFields = [] }: { data?: Record<string, unknown>; serverData?: Record<string, unknown> | null; previousDraft?: Record<string, unknown> | null; constant: string | string[]; apiClient: ApiClient; transforms?: TransformsMap; throwOnError?: boolean; removeFields?: string[] }): { draft: ReactiveData; proxy: Record<string, unknown>; initial: Record<string, unknown> } {
1387
+ const constants = Array.isArray(constant) ? constant : [constant];
1388
+ const combinedSchema: {allOf: JsonSchema[], $defs: Record<string, JsonSchema>} = {
1389
+ allOf: [],
1390
+ $defs: {}
1391
+ };
1392
+
1393
+ for (const key of constants) {
1394
+ const sch = apiClient.getRequestSchema(key);
1395
+ if (!sch) throw new ApiError(`Unable to find schema for ${key}.`, 404);
1396
+
1397
+ // Extraire et fusionner les $defs
1398
+ if (sch.$defs) {
1399
+ for (const [defKey, defVal] of Object.entries(sch.$defs)) {
1400
+ if (combinedSchema.$defs[defKey]) {
1401
+ apiClient._logger.warn(`Duplicate $defs key '${defKey}' from schema '${key}'`);
1402
+ } else {
1403
+ combinedSchema.$defs[defKey] = defVal as JsonSchema;
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ combinedSchema.allOf.push(sch as JsonSchema);
1409
+ }
1410
+
1411
+ let allowed = this._extractWritableFields(combinedSchema, data);
1412
+
1413
+ if (data.id && allowed.indexOf("id") === -1) {
1414
+ allowed.push("id");
1415
+ }
1416
+ if (data.slug && allowed.indexOf("slug") === -1) {
1417
+ allowed.push("slug");
1418
+ }
1419
+
1420
+ allowed = allowed.filter(k => !removeFields.includes(k));
1421
+
1422
+ // Stocker metadata pour recalcul dynamique des allowedFields dans le proxy
1423
+ this._allowedFieldsCache = allowed;
1424
+ this._allowedFieldsMetadata = {
1425
+ combinedSchema,
1426
+ transforms,
1427
+ removeFields
1428
+ };
1429
+
1430
+ // Transformation des champs autorisés
1431
+ const rawDraft = Object.fromEntries(
1432
+ allowed.map((key) => {
1433
+ const raw = data[key];
1434
+ const transformed = typeof transforms[key] === "function"
1435
+ ? transforms[key](raw, data)
1436
+ : raw;
1437
+ return [key, transformed];
1438
+
1439
+ }).filter(([_, v]) => v !== undefined)
1440
+ );
1441
+
1442
+ const initial = structuredClone ? structuredClone(rawDraft) : JSON.parse(JSON.stringify(rawDraft));
1443
+
1444
+ const draft = previousDraft && isReactive(previousDraft)
1445
+ ? Object.assign(previousDraft, rawDraft)
1446
+ : reactive(rawDraft);
1447
+
1448
+ // Assure que serverData est réactif si c'est un objet
1449
+ const reactiveServer = isReactive(serverData)
1450
+ ? serverData
1451
+ : (serverData && typeof serverData === "object" ? reactive(serverData) : serverData);
1452
+
1453
+ const proxy = this._createDraftProxy(apiClient, reactiveServer, draft, allowed, transforms, { throwOnError });
1454
+
1455
+ return { draft, proxy, initial};
1456
+ }
1457
+
1458
+ /**
1459
+ * Invoque une méthode de bloc de manière type-safe.
1460
+ * Élimine le besoin de casts `as any` dans les classes dérivées.
1461
+ *
1462
+ * @param blocks - Le Map contenant les noms de méthodes (doit être `as const`)
1463
+ * @param methodName - Le nom de la méthode à invoquer
1464
+ * @param blockData - Les données à passer à la méthode
1465
+ * @returns La valeur retournée par la méthode
1466
+ * @protected
1467
+ *
1468
+ * @example
1469
+ * // Dans une classe dérivée :
1470
+ * await this._invokeBlockMethod(User.UPDATE_BLOCKS, methodName, blockData);
1471
+ */
1472
+ protected async _invokeBlockMethod<T extends Map<string, any>>(
1473
+ blocks: T,
1474
+ methodName: string,
1475
+ blockData: any
1476
+ ): Promise<any> {
1477
+ type MethodName = ExtractMethodNames<T>;
1478
+ const method = this[methodName as MethodName] as (data: any) => Promise<any>;
1479
+ return await method.call(this, blockData);
1480
+ }
1481
+
1482
+ /**
1483
+ * Extrait les champs modifiés du schéma.
1484
+ *
1485
+ * @param apiClient - Instance de l'API.
1486
+ * @param constant - Nom de la constante.
1487
+ * @param data - Données à comparer.
1488
+ * @param getInitialDraft - Fonction pour obtenir le brouillon initial.
1489
+ * @param removeFields - Liste des champs à ignorer.
1490
+ * @returns - Champs modifiés ou `null` si aucun changement.
1491
+ * @protected
1492
+ */
1493
+ /**
1494
+ * Détecte si une valeur est un type d'upload (Buffer, File, Blob, Stream)
1495
+ * @param obj - La valeur à tester
1496
+ * @returns true si c'est un type d'upload
1497
+ * @private
1498
+ */
1499
+ private _isUploadType(obj: any): boolean {
1500
+ // Buffer (Node.js)
1501
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(obj)) return true;
1502
+ // File (Browser)
1503
+ if (typeof File !== "undefined" && obj instanceof File) return true;
1504
+ // Blob (Browser)
1505
+ if (typeof Blob !== "undefined" && obj instanceof Blob) return true;
1506
+ // ReadableStream (Node.js)
1507
+ if (obj && typeof obj.pipe === "function" && typeof obj.on === "function") return true;
1508
+ return false;
1509
+ }
1510
+
1511
+ /**
1512
+ * Compare deux valeurs de manière intelligente :
1513
+ * - Types d'upload (Buffer, File, etc.) : comparaison par référence
1514
+ * - Autres types : comparaison JSON
1515
+ * @param current - Valeur actuelle
1516
+ * @param initial - Valeur initiale
1517
+ * @returns true si les valeurs sont différentes
1518
+ * @private
1519
+ */
1520
+ private _compareValues(current: any, initial: any): boolean {
1521
+ // Si l'un des deux est un type d'upload, comparer par référence
1522
+ if (this._isUploadType(current) || this._isUploadType(initial)) {
1523
+ return current !== initial;
1524
+ }
1525
+
1526
+ // Sinon, comparaison JSON standard
1527
+ return JSON.stringify(this._toRawDeep(current)) !== JSON.stringify(this._toRawDeep(initial));
1528
+ }
1529
+
1530
+ _extractChangedFieldsFromSchema(apiClient: ApiClient, constant: string, data: Record<string, any> = {}, getInitialDraft: () => Record<string, any> | void, removeFields: string[] = []): Record<string, any> | null {
1531
+ const schema = apiClient.getRequestSchema(constant);
1532
+ let allowed = this._extractWritableFields(schema, data);
1533
+ const changed: Record<string, any> = {};
1534
+ const initialDraft = getInitialDraft?.() || {};
1535
+
1536
+ // on enlève les champs qui ne sont pas dans le draft
1537
+ // ou qui sont dans removeFields
1538
+ allowed = allowed.filter(k => !removeFields.includes(k));
1539
+
1540
+ for (const key of allowed) {
1541
+ // on verifie que le champ existe dans le draft
1542
+ // sinon on ne le prend pas en compte
1543
+
1544
+ if (data[key] === undefined) continue;
1545
+
1546
+ const current = data[key];
1547
+ const initial = initialDraft[key];
1548
+
1549
+ const changedValue = this._compareValues(current, initial);
1550
+
1551
+ if (changedValue) {
1552
+ changed[key] = current;
1553
+ }
1554
+ }
1555
+
1556
+ return Object.keys(changed).length > 0 ? changed : null;
1557
+ }
1558
+
1559
+ /**
1560
+ * Extrait tous les champs valides selon le schéma, et retourne uniquement ceux qui ont changé par rapport au draft initial.
1561
+ * Contrairement à `_extractChangedFieldsFromSchema`, cette méthode retourne l'ensemble des champs valides (`updated`)
1562
+ * uniquement s'il y a au moins un champ modifié (`changed`).
1563
+ *
1564
+ * ⚠️ Les champs sont filtrés en fonction :
1565
+ * - des champs définis comme modifiables dans le schéma (`writeable`)
1566
+ * - des champs non exclus dans `removeFields`
1567
+ * - des champs définis dans `data` (les `undefined` sont ignorés)
1568
+ *
1569
+ * @param apiClient - L'instance de client API contenant les schémas.
1570
+ * @param constant - Le nom de la constante de schéma (ex: "ADD_EVENT").
1571
+ * @param data - Les nouvelles données à comparer avec le draft initial.
1572
+ * @param getInitialDraft - Fonction qui retourne le draft initial (souvent `this.initialDraftData`).
1573
+ * @param removeFields - Champs à ignorer même s'ils sont valides.
1574
+ * @returns - Un objet `updated` avec les champs valides si au moins un champ a changé, sinon `null`.
1575
+ * @private
1576
+ */
1577
+ _extractAllValidFieldsFromSchema(apiClient: ApiClient, constant: string, data: Record<string, any> = {}, getInitialDraft: () => Record<string, any>, removeFields: string[] = []): Record<string, any> | null {
1578
+ const schema = apiClient.getRequestSchema(constant);
1579
+ let allowed = this._extractWritableFields(schema, data);
1580
+ const changed: Record<string, any> = {};
1581
+ const updated: Record<string, any> = {};
1582
+ const initialDraft = getInitialDraft?.() || {};
1583
+
1584
+ // on enlève les champs qui ne sont pas dans le draft
1585
+ // ou qui sont dans removeFields
1586
+ allowed = allowed.filter(k => !removeFields.includes(k));
1587
+
1588
+ for (const key of allowed) {
1589
+ // on verifie que le champ existe dans le draft
1590
+ // sinon on ne le prend pas en compte
1591
+
1592
+ if (data[key] === undefined) continue;
1593
+
1594
+ const current = data[key];
1595
+ const initial = initialDraft[key];
1596
+
1597
+ const changedValue =
1598
+ JSON.stringify(current) !== JSON.stringify(initial);
1599
+
1600
+ if (changedValue) {
1601
+ changed[key] = current;
1602
+ }
1603
+
1604
+ updated[key] = current;
1605
+ }
1606
+
1607
+ return Object.keys(changed).length > 0 ? updated : null;
1608
+ }
1609
+
1610
+ /**
1611
+ * ───────────────────────────────
1612
+ * MutualEntityMixin
1613
+ * ───────────────────────────────
1614
+ */
1615
+
1616
+ /**
1617
+ * Résout l'identifiant de l'entité si seul le slug est fourni.
1618
+ * @param type - Le type d'entité (ex : "citoyens", "organizations", "projects").
1619
+ * @returns L'identifiant résolu.
1620
+ * @throws {ApiResponseError} - Si le slug ne correspond pas à l'entité attendue.
1621
+ * @throws {ApiError} - Si l'identifiant ne peut pas être résolu.
1622
+ * @throws {ApiResponseError} - Si une erreur se produit lors de la récupération
1623
+ * @private
1624
+ */
1625
+ private async _resolveId(type: string): Promise<string> {
1626
+ if (!this.id && this.slug) {
1627
+ try {
1628
+ const data = await this.endpointApi.getElementsKey({
1629
+ pathParams:{
1630
+ slug: this.slug
1631
+ }
1632
+ }) as GetElementsKeyResponse;
1633
+
1634
+ if(data.contextId && data.contextType === type) {
1635
+ this._id(data.contextId);
1636
+ } else {
1637
+ throw new ApiResponseError(`Le slug ${this.slug} ne correspond pas à un ${type}`, 200, data as object);
1638
+ }
1639
+ } catch (error) {
1640
+ if(error instanceof ApiResponseError) {
1641
+ const errorResponseData = error.responseData as Record<string, unknown>;
1642
+ if(errorResponseData.contextType !== type) {
1643
+ throw error;
1644
+ } else {
1645
+ throw new ApiResponseError(`Impossible de récupérer l'identifiant pour le slug ${this.slug}`, error.status, error.responseData);
1646
+ }
1647
+ } else {
1648
+ throw error;
1649
+ }
1650
+ }
1651
+ }
1652
+ if (!this.id) {
1653
+ throw new ApiError(`Impossible de résoudre l'identifiant pour le type ${type}.`, 404);
1654
+ }
1655
+ return this.id;
1656
+ }
1657
+
1658
+ /**
1659
+ * Récupère le profil public de l'entité.
1660
+ *
1661
+ * @returns - Les données du profil public.
1662
+ * @protected
1663
+ */
1664
+ protected async _getPublicProfile(): Promise<Record<string, any>> {
1665
+ await this._resolveId(this.getEntityType());
1666
+ if (!this.id) {
1667
+ throw new ApiError("L'identifiant de l'entité n'est pas défini.", 400);
1668
+ }
1669
+ const type = this.getEntityType();
1670
+
1671
+ if (type === "news" || type === "comments") {
1672
+ throw new ApiError("getElementsAbout ne supporte pas le type 'news'.", 400);
1673
+ }
1674
+ return this.endpointApi.getElementsAbout({ pathParams: { id: this.id, type: type }, tpl: "ficheInfoElement" });
1675
+ }
1676
+
1677
+ /**
1678
+ * Récupère la classe d'entité et ses dépendances à partir du type d'entité.
1679
+ *
1680
+ * @private
1681
+ */
1682
+ private _getEntityMeta(entityType: string): EntityMeta | null {
1683
+ const selfClass = this.constructor;
1684
+ const selfTag = this.__entityTag;
1685
+
1686
+ const commonDeps = {
1687
+ EndpointApi: this.deps.EndpointApi,
1688
+ User: selfTag === "User" ? selfClass : this.deps.User,
1689
+ Organization: selfTag === "Organization" ? selfClass : this.deps.Organization,
1690
+ Project: selfTag === "Project" ? selfClass : this.deps.Project,
1691
+ Event: selfTag === "Event" ? selfClass : this.deps.Event,
1692
+ Poi: selfTag === "Poi" ? selfClass : this.deps.Poi,
1693
+ Badge: selfTag === "Badge" ? selfClass : this.deps.Badge,
1694
+ News: selfTag === "News" ? selfClass : this.deps.News,
1695
+ Comment: selfTag === "Comment" ? selfClass : this.deps.Comment,
1696
+ };
1697
+
1698
+ const map = {
1699
+ citoyens: { entityClass: commonDeps.User, deps: commonDeps },
1700
+ organizations:{ entityClass: commonDeps.Organization, deps: commonDeps },
1701
+ projects: { entityClass: commonDeps.Project, deps: commonDeps },
1702
+ events: { entityClass: commonDeps.Event, deps: { ...commonDeps, Badge: undefined } },
1703
+ poi: { entityClass: commonDeps.Poi, deps: { ...commonDeps, Badge: undefined, News: undefined } },
1704
+ news: { entityClass: commonDeps.News, deps: { ...commonDeps } },
1705
+ badges: { entityClass: commonDeps.Badge, deps: {
1706
+ EndpointApi: commonDeps.EndpointApi,
1707
+ User: commonDeps.User,
1708
+ Organization: commonDeps.Organization,
1709
+ Project: commonDeps.Project
1710
+ } },
1711
+ comments: { entityClass: commonDeps.Comment, deps: { ...commonDeps } },
1712
+ };
1713
+
1714
+ return (map as Record<string, EntityMeta | undefined>)[entityType] || null;
1715
+ }
1716
+
1717
+ /**
1718
+ * Lier des données d'entité à une instance d'entité.
1719
+ *
1720
+ * @param entityType - Le type d'entité (ex : "citoyens", "organisations", "projets").
1721
+ * @param entityData - Les données de l'entité à lier.
1722
+ * @return L'entité liée ou les données brutes si la metadata n'existe pas.
1723
+ * @private
1724
+ */
1725
+ _linkEntity(entityType: string, entityData: object): AnyEntity | object {
1726
+ const meta = this._getEntityMeta(entityType);
1727
+ if (!meta) return entityData;
1728
+ return meta.entityClass.fromServerData(entityData, this, meta.deps);
1729
+ }
1730
+
1731
+ /**
1732
+ * Transforme un objet imbriqué avec {id, type} en instance d'entité.
1733
+ * Utilisé pour transformer des champs comme author, target, etc.
1734
+ *
1735
+ * @param obj - L'objet à transformer (doit avoir au minimum {id, type}).
1736
+ * @returns L'instance d'entité ou l'objet original si la transformation échoue.
1737
+ * @protected
1738
+ */
1739
+ protected _linkNestedEntity<T = any>(obj: any): T | any {
1740
+ if (!obj || typeof obj !== "object") return obj;
1741
+ if (!obj.id || !obj.type) return obj;
1742
+
1743
+ try {
1744
+ return this._linkEntity(obj.type, obj) as T;
1745
+ } catch (error) {
1746
+ this.apiClient._logger?.warn?.(`Impossible de lier l'entité imbriquée de type ${obj.type}:`, error);
1747
+ return obj;
1748
+ }
1749
+ }
1750
+
1751
+ /**
1752
+ * Récupère et lie une entité à partir de son ID.
1753
+ *
1754
+ * @param entityType - Le type d'entité.
1755
+ * @param entityId - L'identifiant de l'entité.
1756
+ * @param options - Options supplémentaires :
1757
+ * @param options.skipGet - Si true, ne pas appeler `get()` sur l'entité.
1758
+ * @return L'entité liée ou null.
1759
+ * @private
1760
+ */
1761
+ async _linkEntityById(entityType: string, entityId: string, options?: { skipGet?: boolean }): Promise<AnyEntity|null> {
1762
+ const meta = this._getEntityMeta(entityType);
1763
+ if (!meta) return null;
1764
+
1765
+
1766
+ const parent: ParentLike = this as ParentLike;
1767
+
1768
+ const entity = new meta.entityClass(parent, { id: entityId }, meta.deps) as AnyEntity;
1769
+ if (options?.skipGet) return entity;
1770
+ await entity.get();
1771
+ return entity;
1772
+ }
1773
+
1774
+ /**
1775
+ * Lie une liste d'entités à partir de leurs données.
1776
+ *
1777
+ * @param results - Liste de données d'entités.
1778
+ * @return Liste d'entités liées.
1779
+ * @private
1780
+ */
1781
+ protected _linkEntities(results: any[]): any[] {
1782
+ return results.flatMap((d: any) => {
1783
+ if (!d?.collection) {
1784
+ this.apiClient._logger?.warn?.(`Objet ignoré car sans 'collection' : ${d?.id}`);
1785
+ return []; // exclu de la liste
1786
+ }
1787
+
1788
+ return [this._linkEntity?.(d.collection, d) ?? d];
1789
+ });
1790
+ }
1791
+
1792
+ /**
1793
+ * Lie des entités présentes dans `this.serverData` à partir de leurs IDs,
1794
+ * en les filtrant dynamiquement et en optionnellement les transformant.
1795
+ *
1796
+ * @param entityType - Le type d'entité (ex : "badges", "citoyens", etc.).
1797
+ * @param filters - Clés/valeurs de filtres dynamiques. Les valeurs peuvent être :
1798
+ * - un littéral (comparaison stricte ou intelligente selon le type),
1799
+ * - une chaîne (utilise `includes` insensible à la casse),
1800
+ * - une RegExp (appliquée si la valeur est une chaîne),
1801
+ * - une fonction `(value) => boolean`.
1802
+ * @param options - Options supplémentaires :
1803
+ * @param options.key - Le champ de `this.serverData` à utiliser (par défaut = entityType).
1804
+ * @param options.mapFn - Fonction de transformation `(entity) => any` appliquée au résultat.
1805
+ *
1806
+ * @return Liste des entités liées, filtrées et éventuellement transformées.
1807
+ *
1808
+ * @example
1809
+ * // Tous les badges avec `name` contenant "codev"
1810
+ * const badges = await this.linkEntitiesFromServerData("badges", { name: "codev" });
1811
+ *
1812
+ * @example
1813
+ * // Badges non expirés et visibles
1814
+ * const badges = await this.linkEntitiesFromServerData("badges", {
1815
+ * expiredOn: false,
1816
+ * show: "true"
1817
+ * });
1818
+ *
1819
+ * @example
1820
+ * // Badges émis après 2023
1821
+ * const badges = await this.linkEntitiesFromServerData("badges", {
1822
+ * issuedOn: (v) => new Date(v) >= new Date("2023-01-01")
1823
+ * });
1824
+ *
1825
+ * @example
1826
+ * // Extraire uniquement les noms des badges
1827
+ * const namesOnly = await this.linkEntitiesFromServerData("badges", {}, {
1828
+ * mapFn: (badge) => badge.meta.name
1829
+ * });
1830
+ */
1831
+ async linkEntitiesFromServerData(entityType: string, filters: EntityFilters = {}, options: LinkEntitiesOptions = {}): Promise<any[]> {
1832
+ const key = options.key || entityType;
1833
+ const mapFn = typeof options.mapFn === "function" ? options.mapFn : null;
1834
+
1835
+ const isTruthy = (v: any) => v === true || v === "true";
1836
+ const isFalsy = (v: any) => v === false || v === "false";
1837
+
1838
+ const data = (this.serverData as Record<string, any>)?.[key];
1839
+ const result: any[] = [];
1840
+
1841
+ if (data && typeof data === "object" && Object.keys(data).length > 0) {
1842
+ for (const id of Object.keys(data)) {
1843
+ const meta = data[id];
1844
+
1845
+ const matches = Object.entries(filters).every(([key, expected]) => {
1846
+ const actual = meta[key];
1847
+
1848
+ if (typeof expected === "function") {
1849
+ try {
1850
+ return expected(actual);
1851
+ } catch (err) {
1852
+ console.warn(`Erreur dans le filtre personnalisé pour ${key}`, err);
1853
+ return false;
1854
+ }
1855
+ }
1856
+
1857
+ if (expected instanceof RegExp) {
1858
+ return typeof actual === "string" && expected.test(actual);
1859
+ }
1860
+
1861
+ if (typeof expected === "string" && typeof actual === "string") {
1862
+ return actual.toLowerCase().includes(expected.toLowerCase());
1863
+ }
1864
+
1865
+ if (isTruthy(expected)) return isTruthy(actual);
1866
+ if (isFalsy(expected)) return isFalsy(actual);
1867
+
1868
+ if (
1869
+ typeof actual === "string" &&
1870
+ typeof expected === "string" &&
1871
+ !isNaN(Date.parse(actual)) &&
1872
+ !isNaN(Date.parse(expected))
1873
+ ) {
1874
+ return new Date(actual).getTime() === new Date(expected).getTime();
1875
+ }
1876
+
1877
+ return actual === expected;
1878
+ });
1879
+
1880
+ if (!matches) continue;
1881
+
1882
+ try {
1883
+ const entity = await this._linkEntityById(entityType, id);
1884
+ if (entity) {
1885
+ (entity as AnyEntity & { meta: any }).meta = meta;
1886
+ result.push(mapFn ? mapFn(entity) : entity);
1887
+ }
1888
+ } catch (error) {
1889
+ this.apiClient._logger?.error?.(`Erreur lors de la récupération de l'entité ${entityType} (${id}) :`, error);
1890
+ }
1891
+ }
1892
+ }
1893
+ return result;
1894
+ }
1895
+
1896
+ /**
1897
+ * Crée une instance d'entité à partir des données fournies.
1898
+ * @template keyof EntityTypeMap T
1899
+ * @param entityType
1900
+ * @param entityData
1901
+ * @return {EntityTypeMap[T]}
1902
+ * @private
1903
+ */
1904
+ _entityInstanceData<T extends keyof EntityTypeMap>(entityType: T, entityData: object): EntityTypeMap[T] {
1905
+ const meta = this._getEntityMeta(entityType);
1906
+ if (!meta) {
1907
+ throw new ApiError(`Type d'entité inconnu: ${entityType}`, 400);
1908
+ }
1909
+ const selfKey = this.__entityTag; // ex: "User", "Organization", etc.
1910
+ const selfClass = this.constructor;
1911
+
1912
+
1913
+ const parent: ParentLike = this as ParentLike;
1914
+
1915
+ // pour citoyens la signature est différentes
1916
+ return new meta.entityClass(parent, entityData, {
1917
+ [selfKey]: selfClass,
1918
+ ...meta.deps
1919
+ }) as EntityTypeMap[T];
1920
+ }
1921
+
1922
+ /**
1923
+ * Crée une instance d'entité, et déclenche get() si certaines propriétés sont présentes.
1924
+ * @template keyof EntityTypeMap T
1925
+ * @param entityType
1926
+ * @param [entityData={}]
1927
+ * @return {Promise<EntityTypeMap[T]>}
1928
+ */
1929
+ async entity<T extends keyof EntityTypeMap>(entityType: T, entityData: object = {}): Promise<EntityTypeMap[T]> {
1930
+ try {
1931
+ const entity = this._entityInstanceData(entityType, entityData);
1932
+
1933
+ const fetchKeysByEntity = {
1934
+ citoyens: ["id", "slug"],
1935
+ organizations: ["id", "slug"],
1936
+ projects: ["id", "slug"],
1937
+ events: ["id", "slug"],
1938
+ poi: ["id", "slug"],
1939
+ news: ["id"],
1940
+ badges: ["id"],
1941
+ comments: [], // Pas de get() pour les commentaires
1942
+ };
1943
+
1944
+ const fetchKeys = (fetchKeysByEntity as Record<string, string[] | undefined>)[entityType];
1945
+
1946
+ if (fetchKeys && this._hasAtLeastOne(entityData, fetchKeys)) {
1947
+ await entity.get();
1948
+ }
1949
+
1950
+ return entity;
1951
+ } catch (error) {
1952
+ this.apiClient._logger.error(`[Api.${this.__entityTag}.${entityType}] Erreur lors de la création d'une instance ${entityType} :`, (error as Error).message);
1953
+ throw error;
1954
+ }
1955
+ }
1956
+
1957
+ /**
1958
+ * Récupère et lie une entité à partir de son slug.
1959
+ * @param slug - Le slug de l'entité.
1960
+ * @return L'entité liée.
1961
+ * @throws {ApiError} Si le slug est vide.
1962
+ * @throws {ApiResponseError} Si le slug n'existe pas ou est invalide.
1963
+ */
1964
+ async entityBySlug(slug: string): Promise<AnyEntity> {
1965
+ if (!slug) {
1966
+ throw new ApiError("Le slug est requis.", 400);
1967
+ }
1968
+ try {
1969
+ const data = await this.endpointApi.getElementsKey({
1970
+ pathParams:{
1971
+ slug: slug
1972
+ }
1973
+ }) as GetElementsKeyResponse;
1974
+
1975
+ if(data.contextId && data.contextType) {
1976
+ const entity = await this.entity(data.contextType, { id: data.contextId });
1977
+ return entity;
1978
+ } else {
1979
+ throw new ApiResponseError(`Le slug ${slug} n'est pas valide`, 200, data as object);
1980
+ }
1981
+ } catch (error) {
1982
+ if(error instanceof ApiResponseError) {
1983
+ throw new ApiResponseError(`Impossible de récupérer l'identifiant pour le slug ${slug}`, error.status, error.responseData);
1984
+ } else {
1985
+ throw error;
1986
+ }
1987
+ }
1988
+ }
1989
+
1990
+ /**
1991
+ * Construit dynamiquement des filtres sur un lien entre entités
1992
+ *
1993
+ * @param id - L'ID de l'entité cible.
1994
+ * @param options - Options de filtrage.
1995
+ * @param options.linkType - Le type de lien (ex: "memberOf", "projects", etc.).
1996
+ * @param options.isAdmin - Si défini, filtre selon l'existence de isAdmin.
1997
+ * @param options.isAdminPending - Si défini, filtre selon l'existence de isAdminPending.
1998
+ * @param options.isInviting - Si défini, filtre selon l'existence de isInviting.
1999
+ * @param options.toBeValidated - Si défini, filtre selon l'existence de toBeValidated.
2000
+ * @param options.roles - Rôles à filtrer.
2001
+ * @returns - Un objet de filtres à passer à une requête.
2002
+ * @throws {TypeError} - Si les types des paramètres ne sont pas valides.
2003
+ * @protected
2004
+ */
2005
+ _buildLinkFilters(id: string | null, {
2006
+ linkType,
2007
+ isAdmin,
2008
+ isAdminPending,
2009
+ isInviting,
2010
+ toBeValidated = false,
2011
+ roles = []
2012
+ }: {
2013
+ linkType: "memberOf" | "projects" | "events";
2014
+ isAdmin?: boolean;
2015
+ isAdminPending?: boolean;
2016
+ isInviting?: boolean;
2017
+ toBeValidated?: boolean;
2018
+ roles?: string[];
2019
+ }): LinkFilters {
2020
+ if (typeof id !== "string" || id === null) throw new TypeError("id doit être une chaîne non-nulle.");
2021
+ if (typeof linkType !== "string") throw new TypeError("linkType doit être une chaîne.");
2022
+ if (typeof isAdmin !== "undefined" && typeof isAdmin !== "boolean") {
2023
+ throw new TypeError("isAdmin doit être un booléen.");
2024
+ }
2025
+ if (typeof isAdminPending !== "undefined" && typeof isAdminPending !== "boolean") {
2026
+ throw new TypeError("isAdminPending doit être un booléen.");
2027
+ }
2028
+ if (typeof isInviting !== "undefined" && typeof isInviting !== "boolean") {
2029
+ throw new TypeError("isInviting doit être un booléen.");
2030
+ }
2031
+ if (typeof toBeValidated !== "undefined" && typeof toBeValidated !== "boolean") {
2032
+ throw new TypeError("toBeValidated doit être un booléen.");
2033
+ }
2034
+ if (!Array.isArray(roles)) {
2035
+ throw new TypeError("roles doit être un tableau de chaînes.");
2036
+ }
2037
+
2038
+ const path = `links.${linkType}.${id}`;
2039
+
2040
+ const filters: LinkFilters = {
2041
+ [`${path}`]: { $exists: true }
2042
+ };
2043
+
2044
+ if (typeof toBeValidated === "boolean") {
2045
+ filters[`${path}.toBeValidated`] = { $exists: toBeValidated };
2046
+ }
2047
+
2048
+ if (typeof isInviting === "boolean") {
2049
+ filters[`${path}.isInviting`] = { $exists: isInviting };
2050
+ }
2051
+
2052
+ if (isAdmin === true) {
2053
+ filters[`${path}.isAdmin`] = { $exists: true };
2054
+ }
2055
+
2056
+ if(isAdminPending === true) {
2057
+ filters[`${path}.isAdminPending`] = { $exists: true };
2058
+ }
2059
+
2060
+ if (roles.length > 0) {
2061
+ filters[`${path}.roles`] = { $in: roles };
2062
+ }
2063
+
2064
+ return filters;
2065
+ }
2066
+
2067
+ /**
2068
+ * Retourne les métadonnées de lien pour une entité :
2069
+ * - `linkType` : clé dans `serverData.links`
2070
+ * - `connectTypeConnect` : valeur envoyée pour `connect()`
2071
+ * - `connectTypeDisconnect` : valeur envoyée pour `disconnect()`
2072
+ * @throws {ApiError} - Si le type d'entité est inconnu.
2073
+ * @protected
2074
+ */
2075
+ protected _getLinkMeta(): LinkMeta {
2076
+ const map = {
2077
+ organizations: {
2078
+ linkType: "memberOf",
2079
+ connectTypeConnect: "member",
2080
+ connectTypeDisconnect: "members"
2081
+ },
2082
+ projects: {
2083
+ linkType: "projects",
2084
+ connectTypeConnect: "contributor",
2085
+ connectTypeDisconnect: "contributors"
2086
+ },
2087
+ events: {
2088
+ linkType: "events",
2089
+ connectTypeConnect: "attendee",
2090
+ connectTypeDisconnect: "attendees"
2091
+ },
2092
+ citoyens: {
2093
+ linkType: "friends",
2094
+ connectTypeConnect: "friend",
2095
+ connectTypeDisconnect: "friends"
2096
+ }
2097
+ };
2098
+
2099
+ const entityType = this.getEntityType();
2100
+ const meta = (map as Record<string, LinkMeta | undefined>)[entityType];
2101
+
2102
+ if (!meta) {
2103
+ throw new ApiError(`Aucune correspondance de lien définie pour le type d'entité "${entityType}"`, 404);
2104
+ }
2105
+
2106
+ return meta;
2107
+ }
2108
+
2109
+ /**
2110
+ * Vérifie si l'entité prend en charge les opérations de lien (`connect`, `disconnect`, etc.).
2111
+ * Utilise `_getLinkMeta()` comme source unique de vérité.
2112
+ *
2113
+ * @throws {ApiError} - Si l'entité ne supporte pas les liens.
2114
+ * @protected
2115
+ */
2116
+ protected _checkLinkableEntity() {
2117
+ try {
2118
+ this._getLinkMeta(); // si l'entité n'est pas supportée, ça throw déjà
2119
+ } catch (error) {
2120
+ if (error instanceof ApiError) {
2121
+ throw new ApiError(`L'entité "${this.getEntityType()}" ne prend pas en charge les actions de lien.`, 400);
2122
+ }
2123
+ throw error;
2124
+ }
2125
+ }
2126
+
2127
+ /**
2128
+ * Retourne le lien de l'utilisateur connecté avec l'entité cible (dans `parent.serverData.links`)
2129
+ * @protected
2130
+ */
2131
+ protected _getLinkFromConnectedUser() {
2132
+ const { linkType } = this._getLinkMeta();
2133
+ if(!this.id) {
2134
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2135
+ }
2136
+ return this?.userContext?.serverData?.links?.[linkType]?.[this.id] || null;
2137
+ }
2138
+
2139
+ /**
2140
+ * Soumet une demande de connexion à une entité (ex : membre, contributeur),
2141
+ * ou valide l'invitation si elle existe déjà.
2142
+ *
2143
+ * @returns - Résultat de l'API
2144
+ * @throws {ApiError} - En cas d'erreur de connexion ou d'invitation.
2145
+ * @private
2146
+ */
2147
+ private async _submitLinkRequest(): Promise<unknown> {
2148
+
2149
+ if (!this.id) {
2150
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2151
+ }
2152
+
2153
+ if(!this.userId) {
2154
+ throw new ApiError("L'utilisateur connecté n'est pas défini.", 400);
2155
+ }
2156
+
2157
+ const { connectTypeConnect } = this._getLinkMeta();
2158
+
2159
+ const userLink = this._getLinkFromConnectedUser();
2160
+
2161
+ // Cas : aucun lien → on demande à se connecter
2162
+ if (!userLink) {
2163
+
2164
+ const t = this.getEntityType();
2165
+
2166
+ // 1) Garde runtime pour exclure les types non pris en charge par ConnectData
2167
+ if (t === "poi" || t === "badges" || t === "news") {
2168
+ throw new ApiError(`Le type d'entité "${t}" ne supporte pas cette connexion.`, 400);
2169
+ }
2170
+
2171
+ // 2) Narrow de type pour TypeScript
2172
+ const parentType = t as ConnectData["parentType"];
2173
+
2174
+ const data: ConnectData = {
2175
+ childId: this.userId,
2176
+ childType: "citoyens",
2177
+ parentType,
2178
+ parentId: this.id,
2179
+ connectType: connectTypeConnect
2180
+ };
2181
+
2182
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
2183
+ const retour = await this.callIsMe(() => this.endpointApi.connect(data));
2184
+ await this.userContext?.refresh();
2185
+ return retour;
2186
+ }
2187
+
2188
+ // Cas : invitation reçue → on valide l'invitation
2189
+ if (userLink.isInviting) {
2190
+ return this._acceptLinkRequest();
2191
+ }
2192
+
2193
+ // Cas : déjà en attente
2194
+ if (userLink.toBeValidated) {
2195
+ throw new ApiError("Vous êtes déjà en attente de validation.", 400);
2196
+ }
2197
+
2198
+ // Cas par défaut : rien à faire
2199
+ throw new ApiError("Vous êtes déjà connecté à cette entité.", 400);
2200
+ }
2201
+
2202
+ /**
2203
+ * Soumet une demande pour devenir administrateur d'une entité.
2204
+ *
2205
+ * @returns {Promise<Object>}
2206
+ * @throws {ApiError}
2207
+ * @private
2208
+ */
2209
+ private async _submitLinkRequestAdmin(): Promise<unknown> {
2210
+
2211
+ if (!this.id) {
2212
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2213
+ }
2214
+
2215
+ if (!this.userId) {
2216
+ throw new ApiError("L'utilisateur connecté n'est pas défini.", 400);
2217
+ }
2218
+
2219
+ const userLink = this._getLinkFromConnectedUser();
2220
+
2221
+ // Aucun lien existant → envoie une demande avec rôle "admin"
2222
+ if (!userLink) {
2223
+
2224
+ const t = this.getEntityType();
2225
+
2226
+ // 1) Garde runtime pour exclure les types non pris en charge par ConnectData
2227
+ if (t === "poi" || t === "badges" || t === "news") {
2228
+ throw new ApiError(`Le type d'entité "${t}" ne supporte pas cette connexion.`, 400);
2229
+ }
2230
+
2231
+ // 2) Narrow de type pour TypeScript
2232
+ const parentType = t as ConnectData["parentType"];
2233
+
2234
+ const data: ConnectData = {
2235
+ childId: this.userId,
2236
+ childType: "citoyens",
2237
+ parentType,
2238
+ parentId: this.id,
2239
+ connectType: "admin"
2240
+ };
2241
+
2242
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
2243
+ const retour = await this.callIsMe(() => this.endpointApi.connect(data));
2244
+ await this.userContext?.refresh();
2245
+ return retour;
2246
+ }
2247
+
2248
+ // Invitation reçue → accepte automatiquement
2249
+ if (userLink.isInviting) {
2250
+ return this._acceptLinkRequest();
2251
+ }
2252
+
2253
+ // Déjà en attente pour admin
2254
+ if (userLink.toBeValidated && userLink.isAdminPending) {
2255
+ throw new ApiError("Vous êtes déjà en attente de validation pour devenir admin.", 400);
2256
+ }
2257
+
2258
+ // Déjà en attente pour un autre rôle
2259
+ if (userLink.toBeValidated) {
2260
+ throw new ApiError("Vous êtes déjà en attente de validation pour rejoindre cette entité.", 400);
2261
+ }
2262
+
2263
+ // Déjà connecté
2264
+ throw new ApiError("Vous êtes déjà connecté à cette entité.", 400);
2265
+ }
2266
+
2267
+ /**
2268
+ * Accepte une demande de lien (ex : invitation à rejoindre un groupe).
2269
+ *
2270
+ * @returns - Résultat de l'API
2271
+ * @throws {ApiError} - En cas d'erreur de connexion ou d'invitation.
2272
+ * @private
2273
+ */
2274
+ private async _acceptLinkRequest(): Promise<unknown> {
2275
+
2276
+ if (!this.id) {
2277
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2278
+ }
2279
+
2280
+ if (!this.userId) {
2281
+ throw new ApiError("L'utilisateur connecté n'est pas défini.", 400);
2282
+ }
2283
+
2284
+ const userLink = this._getLinkFromConnectedUser();
2285
+
2286
+ if (userLink && userLink.isInviting) {
2287
+ const t = this.getEntityType();
2288
+
2289
+ // 1) Garde runtime pour exclure les types non pris en charge par LinkValidateData
2290
+ if (t === "poi" || t === "badges" || t === "news") {
2291
+ throw new ApiError(`Le type d'entité "${t}" ne supporte pas cette connexion.`, 400);
2292
+ }
2293
+
2294
+ // 2) Narrow de type pour TypeScript
2295
+ const parentType = t as LinkValidateData["parentType"];
2296
+
2297
+ const data: LinkValidateData = {
2298
+ childId: this.userId,
2299
+ childType: "citoyens",
2300
+ parentType,
2301
+ parentId: this.id,
2302
+ linkOption: "isInviting"
2303
+ };
2304
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
2305
+ const retour = await this.callIsMe(() => this.endpointApi.linkValidate(data));
2306
+ await this.userContext?.refresh();
2307
+ return retour;
2308
+ }
2309
+
2310
+ throw new ApiError("Vous n'avez pas d'invitation à valider.", 400);
2311
+ }
2312
+
2313
+ /**
2314
+ * Annule une demande de lien ou se déconnecte d'une entité.
2315
+ *
2316
+ * @returns - Résultat de l'API
2317
+ * @throws {ApiError} - En cas d'erreur de connexion ou d'invitation.
2318
+ * @private
2319
+ */
2320
+ private async _leaveLinkRequest(): Promise<unknown> {
2321
+
2322
+ if (!this.id) {
2323
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2324
+ }
2325
+
2326
+ if (!this.userId) {
2327
+ throw new ApiError("L'utilisateur connecté n'est pas défini.", 400);
2328
+ }
2329
+
2330
+ const { connectTypeDisconnect } = this._getLinkMeta();
2331
+
2332
+ const userLink = this._getLinkFromConnectedUser();
2333
+
2334
+ if (!userLink) {
2335
+ throw new ApiError("Vous n'êtes pas connecté à cette entité.", 404);
2336
+ }
2337
+
2338
+ const t = this.getEntityType();
2339
+
2340
+ // 1) Garde runtime pour exclure les types non pris en charge par DisconnectData
2341
+ if (t === "poi" || t === "badges" || t === "news") {
2342
+ throw new ApiError(`Le type d'entité "${t}" ne supporte pas cette connexion.`, 400);
2343
+ }
2344
+
2345
+ // 2) Narrow de type pour TypeScript
2346
+ const parentType = t as DisconnectData["parentType"];
2347
+
2348
+ const data: DisconnectData = {
2349
+ childId: this.userId,
2350
+ childType: "citoyens",
2351
+ parentType,
2352
+ parentId: this.id,
2353
+ // Normalement en auto dans le schema mais je le met quand même
2354
+ connectType: connectTypeDisconnect
2355
+ };
2356
+
2357
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
2358
+ const retour = await this.callIsMe(() => this.endpointApi.disconnect(data));
2359
+ await this.userContext?.refresh();
2360
+ return retour;
2361
+ }
2362
+
2363
+ /**
2364
+ * ───────────────────────────────
2365
+ * EntityMixin
2366
+ * ───────────────────────────────
2367
+ */
2368
+
2369
+ /**
2370
+ * Récupérer le profil public de l'entité
2371
+ *
2372
+ * @returns - Les données de réponse.
2373
+ */
2374
+ async get(): Promise<Record<string, any>> {
2375
+ return this.apiClient.safeCall(async () => {
2376
+ const data = await this._getPublicProfile();
2377
+ this._setData(data as TServerData, { forceInitialDraftReset: true });
2378
+ return data;
2379
+ });
2380
+ }
2381
+
2382
+ /**
2383
+ * Mettre à jour les paramètres d'un élément : Mise à jour des paramètres spécifiques d'un élément.
2384
+ * Constant : UPDATE_SETTINGS
2385
+ * @param data
2386
+ * @returns - Les données de réponse.
2387
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2388
+ * @throws {ApiAuthenticationError} - En cas d'erreur d'authentification.
2389
+ * @throws {Error} - En cas d'erreur inattendue.
2390
+ */
2391
+ async updateSettings(data: Omit<UpdateSettingsData, "idEntity" | "typeEntity">): Promise<unknown> {
2392
+
2393
+ if (!this.id) {
2394
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2395
+ }
2396
+
2397
+ // Réduit le type de l'entité aux seuls autorisés par UpdateSettingsData
2398
+ const t = this.getEntityType();
2399
+ if (t === "poi" || t === "badges" || t === "news") {
2400
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_SETTINGS.`, 400);
2401
+ }
2402
+ const typeEntity = t as UpdateSettingsData["typeEntity"];
2403
+
2404
+ // Garde runtime pour s'assurer que 'type' et 'value' sont bien fournis
2405
+ const { type, value } = data;
2406
+ if (typeof type !== "string") {
2407
+ throw new ApiValidationError("Le champ 'type' est requis (string).", 400, ["type requis"]);
2408
+ }
2409
+
2410
+ const payload: UpdateSettingsData = {
2411
+ type,
2412
+ value,
2413
+ idEntity: this.id,
2414
+ typeEntity
2415
+ };
2416
+
2417
+ return this.callIsConnected(() => this.endpointApi.updateSettings(payload));
2418
+ }
2419
+
2420
+ /**
2421
+ * Mettre à jour la description d'un élément : Permet de mettre à jour la description courte et complète d'un élément.
2422
+ * Constant : UPDATE_BLOCK_DESCRIPTION
2423
+ * @param data - Les données à envoyer.
2424
+ * @returns - Les données de réponse.
2425
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2426
+ * @throws {ApiAuthenticationError} - En cas d'erreur d'authentification.
2427
+ * @throws {Error} - En cas d'erreur inattendue.
2428
+ */
2429
+ async updateDescription(data: Omit<UpdateBlockDescriptionData, "id" | "typeElement" | "block">): Promise<unknown> {
2430
+
2431
+ if (!this.id) {
2432
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2433
+ }
2434
+
2435
+ // Réduit le type de l'entité aux seuls autorisés par UpdateBlockDescriptionData
2436
+ const t = this.getEntityType();
2437
+ if (t === "badges" || t === "news") {
2438
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_DESCRIPTION.`, 400);
2439
+ }
2440
+ const typeElement = t as UpdateBlockDescriptionData["typeElement"];
2441
+
2442
+ const payload: UpdateBlockDescriptionData = {
2443
+ block: "descriptions",
2444
+ id: this.id,
2445
+ typeElement,
2446
+ ...data
2447
+ };
2448
+
2449
+ return this.callIsConnected(() => this.endpointApi.updateBlockDescription(payload));
2450
+ }
2451
+
2452
+ /**
2453
+ * Mettre à jour les informations d'un élément : Permet de mettre à jour les informations générales d'un élément (nom, contacts, etc.).
2454
+ * Constant : UPDATE_BLOCK_INFO
2455
+ * @param data - Les données à envoyer.
2456
+ * @returns - Les données de réponse.
2457
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2458
+ * @throws {ApiAuthenticationError} - En cas d'erreur d'authentification.
2459
+ * @throws {Error} - En cas d'erreur inattendue.
2460
+ */
2461
+ async updateInfo(data: Omit<UpdateBlockInfoData, "id" | "typeElement" | "block">): Promise<unknown> {
2462
+
2463
+ if (!this.id) {
2464
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2465
+ }
2466
+
2467
+ // Réduit le type de l'entité aux seuls autorisés par UpdateBlockDescriptionData
2468
+ const t = this.getEntityType();
2469
+ if (t === "badges" || t === "news") {
2470
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_INFO.`, 400);
2471
+ }
2472
+ const typeElement = t as UpdateBlockInfoData["typeElement"];
2473
+
2474
+ const payload: UpdateBlockInfoData = {
2475
+ block: "info",
2476
+ id: this.id,
2477
+ typeElement,
2478
+ ...data
2479
+ };
2480
+ return this.callIsConnected(() => this.endpointApi.updateBlockInfo(payload));
2481
+ }
2482
+
2483
+ /**
2484
+ * Mettre à jour les réseaux sociaux d'un élément : Permet de mettre à jour les liens vers les réseaux sociaux d'un élément.
2485
+ * Constant : UPDATE_BLOCK_SOCIAL
2486
+ * @param data - Les données à envoyer.
2487
+ * @returns - Les données de réponse.
2488
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2489
+ * @throws {ApiAuthenticationError} - En cas d'erreur d'authentification.
2490
+ * @throws {Error} - En cas d'erreur inattendue.
2491
+ */
2492
+ async updateSocial(data: Omit<UpdateBlockSocialData, "id" | "typeElement" | "block">): Promise<unknown> {
2493
+ if (!this.id) {
2494
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2495
+ }
2496
+
2497
+ // Réduit le type de l'entité aux seuls autorisés par UpdateBlockDescriptionData
2498
+ const t = this.getEntityType();
2499
+ if (t === "badges" || t === "news" || t === "poi" || t === "events") {
2500
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_INFO.`, 400);
2501
+ }
2502
+ const typeElement = t as UpdateBlockSocialData["typeElement"];
2503
+
2504
+ const payload: UpdateBlockSocialData = {
2505
+ block: "network",
2506
+ id: this.id,
2507
+ typeElement,
2508
+ ...data
2509
+ };
2510
+ return this.callIsConnected(() => this.endpointApi.updateBlockSocial(payload));
2511
+ }
2512
+
2513
+ /**
2514
+ * @private
2515
+ */
2516
+ private _isPostalAddress(a: any): a is PostalAddress {
2517
+ return !!a
2518
+ && typeof a === "object"
2519
+ && a["@type"] === "PostalAddress"
2520
+ && typeof a.addressCountry === "string"
2521
+ && typeof a.addressLocality === "string"
2522
+ && typeof a.level1 === "string"
2523
+ && typeof a.level1Name === "string"
2524
+ && typeof a.codeInsee === "string"
2525
+ && typeof a.localityId === "string";
2526
+ }
2527
+
2528
+ /**
2529
+ * @private
2530
+ */
2531
+ private _isGeo(a: any): a is Geo {
2532
+ return !!a
2533
+ && typeof a === "object"
2534
+ && (typeof a.latitude === "number" || typeof a.latitude === "string")
2535
+ && (typeof a.longitude === "number" || typeof a.longitude === "string");
2536
+ }
2537
+
2538
+ /**
2539
+ * @private
2540
+ */
2541
+ private _isGeoPosition(a: any): a is GeoPosition {
2542
+ return !!a
2543
+ && typeof a === "object"
2544
+ && a.type === "Point"
2545
+ && Array.isArray(a.coordinates)
2546
+ && a.coordinates.length === 2
2547
+ && (typeof a.coordinates[0] === "number" || typeof a.coordinates[0] === "string")
2548
+ && (typeof a.coordinates[1] === "number" || typeof a.coordinates[1] === "string")
2549
+ && a.float === true;
2550
+ }
2551
+
2552
+ /**
2553
+ * Mettre à jour les localités d'un élément : Permet de mettre à jour l'adresse et les informations géographiques d'un élément.
2554
+ * Constant : UPDATE_BLOCK_LOCALITY
2555
+ * @param data - Les données à envoyer.
2556
+ * @returns - Les données de réponse.
2557
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2558
+ * @throws {ApiAuthenticationError} - En cas d'erreur d'authentification.
2559
+ * @throws {Error} - En cas d'erreur inattendue.
2560
+ */
2561
+ async updateLocality(data: Omit<UpdateBlockLocalityData, "id" | "typeElement" | "block">): Promise<unknown> {
2562
+ if (!this.id) {
2563
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2564
+ }
2565
+
2566
+ // Réduit le type de l'entité aux seuls autorisés par UpdateBlockLocalityData
2567
+ const t = this.getEntityType();
2568
+ if (t === "badges" || t === "news" || t === "comments") {
2569
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_LOCALITY.`, 400);
2570
+ }
2571
+
2572
+ // Garde runtime pour s'assurer que 'address' est bien fourni
2573
+ if (!("address" in data)) {
2574
+ throw new ApiValidationError("Le champ 'address' est requis.", 400, ["address requis"]);
2575
+ }
2576
+
2577
+ const addrUnknown = data.address as unknown;
2578
+
2579
+ if (!(addrUnknown === "" || this._isPostalAddress(addrUnknown))) {
2580
+ throw new ApiValidationError("Format de 'address' invalide.", 400, ["address invalide"]);
2581
+ }
2582
+
2583
+ const addr: UpdateBlockLocalityData["address"] = addrUnknown;
2584
+
2585
+
2586
+ const typeElement: UpdateBlockLocalityData["typeElement"] = t;
2587
+
2588
+ const payload: UpdateBlockLocalityData = {
2589
+ block: "localities" as const,
2590
+ id: this.id,
2591
+ typeElement,
2592
+ address: addr,
2593
+ // On ne recopie que les champs autorisés pour éviter de casser le typage
2594
+ ...( "geo" in data && this._isGeo(data.geo) ? { geo: data.geo } : {} ),
2595
+ ...( "geoPosition" in data && this._isGeoPosition(data.geoPosition) ? { geoPosition: data.geoPosition } : {} ),
2596
+ ...( "locality" in data ? { locality: data.locality } : {} ),
2597
+ };
2598
+ return this.callIsConnected(() => this.endpointApi.updateBlockLocality(payload));
2599
+ }
2600
+
2601
+ /**
2602
+ * Mettre à jour le slug d'un élément : Permet de mettre à jour le slug pour une URL simplifiée.
2603
+ * Constant : UPDATE_BLOCK_SLUG
2604
+ * @param data - Les données à envoyer.
2605
+ * @param data.slug - Le nouveau slug à appliquer.
2606
+ * @returns - Les données de réponse.
2607
+ */
2608
+ async updateSlug({ slug }: { slug: string }): Promise<unknown> {
2609
+
2610
+ if (!this.id) {
2611
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2612
+ }
2613
+
2614
+ const t = this.getEntityType();
2615
+ if (t === "badges" || t === "news" || t === "poi" || t === "comments") {
2616
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_LOCALITY.`, 400);
2617
+ }
2618
+
2619
+ const type = t as CheckData["type"];
2620
+
2621
+ try {
2622
+ await this.endpointApi.check({ block: "info", type, id: this.id, slug });
2623
+ } catch (error) {
2624
+ if(error instanceof ApiResponseError) {
2625
+ throw new ApiResponseError("Erreur lors de la vérification du slug.", error.status, error.responseData);
2626
+ }
2627
+ throw error;
2628
+ }
2629
+
2630
+ const payload: UpdateBlockSlugData = { block: "info", typeElement: type, id: this.id, slug };
2631
+
2632
+ return this.callIsConnected(() => this.endpointApi.updateBlockSlug(payload));
2633
+ }
2634
+
2635
+ /**
2636
+ * Mettre à jour l'image de profil : Permet de mettre à jour l'image de profil d'un utilisateur ou d'une entité.
2637
+ * Constant : PROFIL_IMAGE
2638
+ * @param data - Les données à envoyer.
2639
+ * @param data.profil_avatar - L'image de profil à mettre à jour.
2640
+ * @returns - Les données de réponse.
2641
+ */
2642
+ async updateImageProfil({ profil_avatar: image }: { profil_avatar: UploadInput }): Promise<unknown> {
2643
+
2644
+ if (!this.id) {
2645
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2646
+ }
2647
+
2648
+ const t = this.getEntityType();
2649
+ if (t === "badges" || t === "news" || t === "comments") {
2650
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_LOCALITY.`, 400);
2651
+ }
2652
+
2653
+ const folder: NonNullable<ProfilImageData["pathParams"]>["folder"] = t;
2654
+
2655
+ image = await this._validateImage(image);
2656
+
2657
+ const data: ProfilImageData = { pathParams: { folder, ownerId: this.id }, profil_avatar: image as unknown as Record<string, unknown>, };
2658
+ return this.callIsConnected(() => this.endpointApi.profilImage(data));
2659
+ }
2660
+
2661
+ /**
2662
+ * Crée une instance d'organisation et récupère son profil si nécessaire.
2663
+ *
2664
+ * @param organizationData - Les données pour initialiser l'organisation.
2665
+ * - Si { id } ou { slug } : récupère l'organisation existante (GET)
2666
+ * - Sinon : crée une nouvelle instance (CREATE)
2667
+ * @returns Une promesse qui résout l'objet Organisation créé.
2668
+ * @throws {Error} Si une erreur se produit lors de la création de l'organisation.
2669
+ */
2670
+ async organization(organizationData: OrganizationInput = {}): Promise<Organization> {
2671
+ if(!this.isMe){
2672
+ throw new ApiError("Vous devez être connecté et être l'utilisateur pour créer une organisation.", 403);
2673
+ }
2674
+ const entity = await this.entity("organizations", organizationData);
2675
+ return entity as Organization;
2676
+ }
2677
+
2678
+ /**
2679
+ * Crée une instance de projet et récupère son profil si nécessaire.
2680
+ *
2681
+ * @param projectData - Les données pour initialiser le projet.
2682
+ * - Si { id } ou { slug } : récupère le projet existant (GET)
2683
+ * - Sinon : crée une nouvelle instance (CREATE)
2684
+ * @returns Une promesse qui résout l'objet Projet créé.
2685
+ * @throws {Error} Si une erreur se produit lors de la création du projet.
2686
+ */
2687
+ async project(projectData: ProjectInput = {}): Promise<Project> {
2688
+ // TODO: Vérifier si l'utilisateur est admin de l'organisation
2689
+ if(!this.isConnected){
2690
+ throw new ApiError("Vous devez être connecté pour créer un projet.", 401);
2691
+ }
2692
+
2693
+ const entity = await this.entity("projects", projectData);
2694
+ return entity as Project;
2695
+ }
2696
+
2697
+ /**
2698
+ * Crée une instance de POI et la récupère si nécessaire.
2699
+ *
2700
+ * @param poiData - Les données pour initialiser le POI.
2701
+ * - Si { id } ou { slug } : récupère le POI existant (GET)
2702
+ * - Sinon : crée une nouvelle instance (CREATE)
2703
+ * @returns Une promesse qui résout l'objet POI créé.
2704
+ * @throws {Error} Si une erreur se produit lors de la création du POI.
2705
+ */
2706
+ async poi(poiData: PoiInput = {}): Promise<Poi> {
2707
+ // TODO: Vérifier si l'utilisateur est admin de l'organisation
2708
+ if(!this.isConnected){
2709
+ throw new ApiError("Vous devez être connecté pour créer un POI.", 401);
2710
+ }
2711
+
2712
+ const entity = await this.entity("poi", poiData);
2713
+ return entity as Poi;
2714
+ }
2715
+
2716
+ /**
2717
+ * Crée une instance d'événement et la récupère si nécessaire.
2718
+ *
2719
+ * @param eventData - Les données pour initialiser l'événement.
2720
+ * - Si { id } ou { slug } : récupère l'événement existant (GET)
2721
+ * - Sinon : crée une nouvelle instance (CREATE)
2722
+ * @returns Une promesse qui résout l'objet Événement créé.
2723
+ * @throws {Error} Si une erreur se produit lors de la création de l'événement.
2724
+ */
2725
+ async event(eventData: EventInput = {}): Promise<EventEntity> {
2726
+ // TODO: Vérifier si l'utilisateur est admin de l'organisation
2727
+ if(!this.isConnected){
2728
+ throw new ApiError("Vous devez être connecté pour créer un événement.", 401);
2729
+ }
2730
+ const entity = await this.entity("events", eventData);
2731
+ return entity as EventEntity;
2732
+ }
2733
+
2734
+ /**
2735
+ * Crée une instance de badge et la récupère si nécessaire.
2736
+ *
2737
+ * @param badgeData - Les données pour initialiser le badge.
2738
+ * - Si { id } : récupère le badge existant (GET)
2739
+ * - Sinon : crée une nouvelle instance (CREATE)
2740
+ * @returns Une promesse qui résout l'objet Badge créé.
2741
+ * @throws {Error} Si une erreur se produit lors de la création du badge.
2742
+ */
2743
+ async badge(badgeData: BadgeInput = {}): Promise<Badge> {
2744
+ // TODO: Vérifier si l'utilisateur est admin de l'organisation
2745
+ if(!this.isConnected){
2746
+ throw new ApiError("Vous devez être connecté pour créer un badge.", 401);
2747
+ }
2748
+ const entity = await this.entity("badges", badgeData);
2749
+ return entity as Badge;
2750
+ }
2751
+
2752
+ /**
2753
+ * Crée une instance de news et la récupère si nécessaire.
2754
+ *
2755
+ * @param newsData - Les données pour initialiser la news.
2756
+ * - Si { id } : récupère la news existante (GET)
2757
+ * - Sinon : crée une nouvelle instance (CREATE)
2758
+ * @returns Une promesse qui résout l'objet News créé.
2759
+ * @throws {Error} Si une erreur se produit lors de la création de la news.
2760
+ */
2761
+ async news(newsData: NewsInput = {}): Promise<News> {
2762
+ if(!this.isConnected){
2763
+ throw new ApiError("Vous devez être connecté.", 401);
2764
+ }
2765
+
2766
+ const entity = await this.entity("news", newsData);
2767
+ return entity as News;
2768
+ }
2769
+
2770
+ /**
2771
+ * Récupérer les organisations d'une entitée : la liste des organisations dont l'entité est membre ou admin valide.
2772
+ * Constant : GET_ORGANIZATIONS_ADMIN | GET_ORGANIZATIONS_NO_ADMIN
2773
+ * @param data - Paramètres (partiels) de recherche/pagination.
2774
+ * @returns - Les données de réponse.
2775
+ */
2776
+ async getOrganizations(data: Partial<GetOrganizationsAdminData | GetOrganizationsNoAdminData> = {}): Promise<PaginatorPage<Organization>> {
2777
+ data.searchType = this._getDefaultFromEndpoint("GET_ORGANIZATIONS_NO_ADMIN", "searchType") as GetOrganizationsNoAdminData["searchType"];
2778
+ // data.searchBy = "ALL";
2779
+
2780
+ const paginator = this._createPaginatorEngine({
2781
+ initialData: data,
2782
+ finalizer: async (finalData) => {
2783
+ if(this.isMe){
2784
+ finalData.pathParams = { type: this.getEntityType(), id: this.id };
2785
+ // NOTE : dans le schema je crois que si pas de finalData.filters alors le default ce fait avec finalData.pathParams
2786
+ // finalData.filters = {
2787
+ // [`links.members.${this.id}`]: { "$exists": true },
2788
+ // [`links.members.${this.id}.toBeValidated`]: { "$exists": false },
2789
+ // [`links.members.${this.id}.isInviting`]: { "$exists": false }
2790
+ // };
2791
+ } else {
2792
+ delete finalData?.pathParams;
2793
+ finalData.filters = {
2794
+ [`links.members.${this.id}`]: { "$exists": true },
2795
+ [`links.members.${this.id}.toBeValidated`]: { "$exists": false },
2796
+ [`links.members.${this.id}.isInviting`]: { "$exists": false }
2797
+ };
2798
+ }
2799
+
2800
+ const fetchFn = this.isMe
2801
+ ? () => this.callIsMe(() => this.endpointApi.getOrganizationsAdmin(finalData))
2802
+ : () => this.endpointApi.getOrganizationsNoAdmin(finalData);
2803
+
2804
+ return fetchFn();
2805
+ }
2806
+ });
2807
+
2808
+ return paginator.next() as Promise<PaginatorPage<Organization>>;
2809
+ }
2810
+
2811
+ /**
2812
+ * Récupérer les projets d'une entitée : liste des projets de l'entité ou elle est "parent" ou "contributeur".
2813
+ * Constant : GET_PROJECTS_ADMIN | GET_PROJECTS_NO_ADMIN
2814
+ * @param data - Paramètres (partiels) de recherche/pagination.
2815
+ * @returns - Les données de réponse.
2816
+ */
2817
+ async getProjects(data: Partial<GetProjectsAdminData | GetProjectsNoAdminData> = {}): Promise<PaginatorPage<Project>> {
2818
+ data.searchType = this._getDefaultFromEndpoint("GET_PROJECTS_ADMIN", "searchType") as GetProjectsAdminData["searchType"];
2819
+ // data.searchBy = "ALL";
2820
+
2821
+ const paginator = this._createPaginatorEngine({
2822
+ initialData: data,
2823
+ finalizer: async (finalData) => {
2824
+ if(this.isMe){
2825
+ finalData.pathParams = { type: this.getEntityType(), id: this.id };
2826
+ // NOTE : dans le schema je crois que si pas de finalData.filters alors le default ce fait avec finalData.pathParams
2827
+ // finalData.filters = {
2828
+ // "$or": {
2829
+ // [`links.contributors.${this.id}`]: { "$exists": true },
2830
+ // [`parent.${this.id}`]: { "$exists": true }
2831
+ // },
2832
+ // [`links.contributors.${this.id}`]: { "$exists": true }
2833
+ // };
2834
+ } else {
2835
+ delete finalData?.pathParams;
2836
+ finalData.filters = {
2837
+ "$or": {
2838
+ [`links.contributors.${this.id}`]: { "$exists": true },
2839
+ [`parent.${this.id}`]: { "$exists": true }
2840
+ },
2841
+ [`links.contributors.${this.id}`]: { "$exists": true }
2842
+ };
2843
+ }
2844
+
2845
+ const fetchFn = this.isMe
2846
+ ? () => this.callIsMe(() => this.endpointApi.getProjectsAdmin(finalData))
2847
+ : () => this.endpointApi.getProjectsNoAdmin(finalData);
2848
+
2849
+ return fetchFn();
2850
+ }
2851
+ });
2852
+
2853
+ return paginator.next() as Promise<PaginatorPage<Project>>;
2854
+ }
2855
+
2856
+ /**
2857
+ * Récupérer les POIs d'une entité : liste des POIs de l'entité ou elle est "parent".
2858
+ * Constant : GET_POIS_NO_ADMIN / GET_POIS_ADMIN
2859
+ * @param data - Paramètres (partiels) de recherche/pagination.
2860
+ * @returns - Les données de réponse.
2861
+ */
2862
+ async getPois(data: Partial<GetPoisAdminData | GetPoisNoAdminData> = {}): Promise<PaginatorPage<Poi>> {
2863
+ data.searchType = this._getDefaultFromEndpoint("GET_POIS_ADMIN", "searchType") as GetPoisAdminData["searchType"];
2864
+ // data.searchBy = "ALL";
2865
+
2866
+ const paginator = this._createPaginatorEngine({
2867
+ initialData: data,
2868
+ finalizer: async (finalData) => {
2869
+ if(this.isMe){
2870
+ finalData.pathParams = { type: this.getEntityType(), id: this.id };
2871
+ // NOTE : dans le schema je crois que si pas de finalData.filters alors le default ce fait avec finalData.pathParams
2872
+ // finalData.filters = {
2873
+ // [`parent.${this.id}`]: { "$exists": true },
2874
+ // };
2875
+ } else {
2876
+ delete finalData?.pathParams;
2877
+ finalData.filters = {
2878
+ [`parent.${this.id}`]: { "$exists": true },
2879
+ };
2880
+ }
2881
+
2882
+ const fetchFn = this.isMe
2883
+ ? () => this.callIsMe(() => this.endpointApi.getPoisAdmin(finalData))
2884
+ : () => this.endpointApi.getPoisNoAdmin(finalData);
2885
+
2886
+ return fetchFn();
2887
+ }
2888
+ });
2889
+
2890
+ return paginator.next() as Promise<PaginatorPage<Poi>>;
2891
+ }
2892
+
2893
+ /**
2894
+ * Récupérer les abonnés d'une entité
2895
+ * Constant : GET_SUBSCRIBERS
2896
+ * @param data - Paramètres (partiels) de recherche/pagination.
2897
+ * @returns - Les données de réponse.
2898
+ */
2899
+ async getSubscribers(data: Partial<GetSubscribersData> = {}): Promise<PaginatorPage<User>> {
2900
+ data.searchType = this._getDefaultFromEndpoint("GET_SUBSCRIBERS", "searchType") as GetSubscribersData["searchType"];
2901
+ // data.searchBy = "ALL";
2902
+
2903
+ const paginator = this._createPaginatorEngine({
2904
+ initialData: data,
2905
+ finalizer: async (finalData) => {
2906
+ delete finalData?.pathParams;
2907
+
2908
+ finalData.filters = {
2909
+ [`links.follows.${this.id}`]: { "$exists": true },
2910
+ [`links.follows.${this.id}.toBeValidated`]: { "$exists": false },
2911
+ [`links.follows.${this.id}.isInviting`]: { "$exists": false }
2912
+ };
2913
+
2914
+ return this.endpointApi.getSubscribers(finalData);
2915
+ }
2916
+ });
2917
+
2918
+ return paginator.next() as Promise<PaginatorPage<User>>;
2919
+ }
2920
+
2921
+ /**
2922
+ * Liste des badges créés par l'entité
2923
+ * Constant : GET_BADGES
2924
+ * @param data - Paramètres (partiels) de recherche/pagination.
2925
+ * @returns - Les données de réponse.
2926
+ */
2927
+ async getBadgesIssuer(data: Partial<GetBadgesData> = {}): Promise<PaginatorPage<Badge>> {
2928
+ data.searchType = this._getDefaultFromEndpoint("GET_BADGES", "searchType") as GetBadgesData["searchType"];
2929
+ // data.searchBy = "ALL";
2930
+
2931
+ const paginator = this._createPaginatorEngine({
2932
+ initialData: data,
2933
+ finalizer: async (finalData) => {
2934
+ delete finalData?.pathParams;
2935
+
2936
+ finalData.filters = finalData.filters || { "preferences.private": false };
2937
+ finalData.filters["$or"] = {};
2938
+ finalData.filters["$or"][`issuer.${this.id}`] = { "$exists": true };
2939
+
2940
+ return this.endpointApi.getBadges(finalData);
2941
+ }
2942
+ });
2943
+
2944
+ return paginator.next() as Promise<PaginatorPage<Badge>>;
2945
+ }
2946
+
2947
+ /**
2948
+ * Récupérer les actualités : Récupère la liste d'actualités selon plusieurs critères.
2949
+ * Constant : GET_NEWS
2950
+ * @param data - Paramètres (partiels) de recherche/pagination.
2951
+ * @returns - Les données de réponse.
2952
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2953
+ * @throws {Error} - En cas d'erreur inattendue.
2954
+ */
2955
+ async getNews(data: Partial<Omit<GetNewsData, "pathParams">> = {}): Promise<News[]> {
2956
+
2957
+ if (!this.id) {
2958
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
2959
+ }
2960
+
2961
+ const t = this.getEntityType();
2962
+ if (t === "badges" || t === "news" || t === "poi" || t === "events" || t === "comments") {
2963
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par GET_NEWS.`, 400);
2964
+ }
2965
+
2966
+ const type: NonNullable<GetNewsData["pathParams"]>["type"] = t;
2967
+
2968
+ const dateLimit = "dateLimit" in data && typeof data.dateLimit === "number" ? { dateLimit: data.dateLimit } : { dateLimit: 0 };
2969
+ const indexStep = "indexStep" in data && typeof data.indexStep === "number" ? { indexStep: data.indexStep } : { indexStep: 12 };
2970
+ const search = "search" in data && typeof data.search === "object" && data.search !== null && "name" in data.search && typeof data.search.name === "string" ? { search: { name: data.search.name } } : {};
2971
+
2972
+ const payload: GetNewsData = {
2973
+ pathParams: { type, id: this.id, isLive: true },
2974
+ ...dateLimit,
2975
+ ...indexStep,
2976
+ ...search
2977
+ };
2978
+
2979
+ const arrayObjet = await this.endpointApi.getNews(payload);
2980
+ if(!Array.isArray(arrayObjet)){
2981
+ throw new ApiResponseError("Erreur lors de la récupération des actualités.", 500, arrayObjet as object);
2982
+ }
2983
+
2984
+ const rawList = this._linkEntities(arrayObjet);
2985
+
2986
+ return this._createFilteredProxy(rawList);
2987
+ }
2988
+
2989
+ /**
2990
+ * Récupérer la galerie d'une entité.
2991
+ * Constant : GET_GALLERY
2992
+ * @param data - Paramètres (partiels) de recherche.
2993
+ * @returns - Les données de réponse.
2994
+ * @throws {ApiResponseError} - En cas d'erreur détectée dans la réponse.
2995
+ * @throws {Error} - En cas d'erreur inattendue.
2996
+ */
2997
+ async getGallery(data: Partial<Omit<GetGalleryData, "pathParams">> & { pathParams?: { docType?: "image" | "file" } } = {}): Promise<Record<string, any>> {
2998
+
2999
+ if (!this.id) {
3000
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3001
+ }
3002
+
3003
+ const t = this.getEntityType();
3004
+ if (t === "badges" || t === "news" || t === "poi" || t === "events" || t === "comments") {
3005
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_LOCALITY.`, 400);
3006
+ }
3007
+
3008
+ const type: NonNullable<GetGalleryData["pathParams"]>["type"] = t;
3009
+
3010
+ const payload: GetGalleryData = {
3011
+ pathParams: { type, id: this.id, docType: data.pathParams?.docType || "image" }
3012
+ };
3013
+
3014
+ const arrayObjet = await this.endpointApi.getGallery(payload);
3015
+
3016
+ return arrayObjet as Record<string, any>;
3017
+ }
3018
+
3019
+ /**
3020
+ * Soumet une demande pour rejoindre l'entité courante (ex. organisation, projet, événement...).
3021
+ * Si une invitation est en attente, elle est automatiquement acceptée.
3022
+ *
3023
+ * @returns - Résultat de la demande ou de la validation.
3024
+ * @throws {ApiError} - Si l'entité ne supporte pas l'action ou si une demande est déjà en cours.
3025
+ */
3026
+ async requestToJoin(): Promise<unknown> {
3027
+ if (!this.isMe) {
3028
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.", 401);
3029
+ }
3030
+ this._checkLinkableEntity();
3031
+ return this._submitLinkRequest();
3032
+ }
3033
+
3034
+ /**
3035
+ * Soumet une demande pour devenir administrateur de l'entité courante.
3036
+ * Si une invitation est en attente, elle est automatiquement validée.
3037
+ *
3038
+ * @returns - Résultat de la demande ou de la validation.
3039
+ * @throws {ApiError} - Si l'entité ne supporte pas l'action ou si une demande est déjà en cours.
3040
+ */
3041
+ async requestToJoinAdmin(): Promise<unknown> {
3042
+ if (!this.isMe) {
3043
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.", 401);
3044
+ }
3045
+ this._checkLinkableEntity();
3046
+ return this._submitLinkRequestAdmin();
3047
+ }
3048
+
3049
+ /**
3050
+ * Accepte une invitation à rejoindre l'entité courante.
3051
+ * Ne fonctionne que si un lien avec `isInviting` est détecté.
3052
+ *
3053
+ * @returns - Résultat de l'acceptation de l'invitation.
3054
+ * @throws {ApiError} - Si aucune invitation n'est en attente ou si l'entité ne supporte pas cette action.
3055
+ */
3056
+ async acceptInvitation(): Promise<unknown> {
3057
+ if (!this.isMe) {
3058
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.", 401);
3059
+ }
3060
+ this._checkLinkableEntity();
3061
+ return this._acceptLinkRequest();
3062
+ }
3063
+
3064
+ /**
3065
+ * Se désengage de l'entité courante : quitte un rôle (membre, contributeur, etc.).
3066
+ *
3067
+ * @returns - Résultat de la déconnexion.
3068
+ * @throws {ApiError} - Si l'entité ne supporte pas l'action ou si l'utilisateur n'est pas lié à cette entité.
3069
+ */
3070
+ async leave(): Promise<unknown> {
3071
+ if (!this.isMe) {
3072
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.", 401);
3073
+ }
3074
+ this._checkLinkableEntity();
3075
+ return this._leaveLinkRequest();
3076
+ }
3077
+
3078
+
3079
+ /**
3080
+ * S'abonne à l'entité courante.
3081
+ *
3082
+ * @returns - Résultat de l'abonnement.
3083
+ * @throws {ApiError} - Si l'utilisateur n'est pas connecté ou si l'entité n'est pas enregistrée.
3084
+ */
3085
+ async follow(): Promise<unknown> {
3086
+ if (!this.isMe) {
3087
+ throw new ApiError("Vous devez être connecté pour suivre cette entité.", 401);
3088
+ }
3089
+
3090
+ if (!this.id) {
3091
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3092
+ }
3093
+
3094
+ if(!this.userId) {
3095
+ throw new ApiError("Utilisateur non connecté.", 401);
3096
+ }
3097
+
3098
+ const t = this.getEntityType();
3099
+ if (t === "badges" || t === "news" || t === "poi" || t === "comments") {
3100
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_LOCALITY.`, 400);
3101
+ }
3102
+
3103
+ const parentType = t as FollowData["parentType"];
3104
+
3105
+ const userLink = this.userContext?.serverData?.links?.["follows"]?.[this.id] || null;
3106
+
3107
+ if (!userLink) {
3108
+ const data: FollowData = {
3109
+ childId: this.userId,
3110
+ childType: "citoyens",
3111
+ parentType,
3112
+ parentId: this.id
3113
+ };
3114
+ const retour = await this.callIsMe(() => this.endpointApi.follow(data));
3115
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
3116
+ await this.userContext?.refresh();
3117
+ return retour;
3118
+ }
3119
+
3120
+ throw new ApiError("Vous êtes déjà abonné à cette entité.", 409);
3121
+ }
3122
+
3123
+
3124
+ /**
3125
+ * Se désabonne de l'entité courante.
3126
+ *
3127
+ * @returns - Résultat de la désinscription.
3128
+ * @throws {ApiError} - Si l'utilisateur n'est pas connecté ou si l'entité n'est pas enregistrée.
3129
+ */
3130
+ async unfollow(): Promise<unknown> {
3131
+ if (!this.isMe) {
3132
+ throw new ApiError("Vous devez être connecté pour vous désabonner.", 401);
3133
+ }
3134
+
3135
+ if (!this.id) {
3136
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3137
+ }
3138
+
3139
+ if(!this.userId) {
3140
+ throw new ApiError("Utilisateur non connecté.", 401);
3141
+ }
3142
+
3143
+ const userLink = this.userContext?.serverData?.links?.["follows"]?.[this.id] || null;
3144
+
3145
+ const t = this.getEntityType();
3146
+ if (t === "badges" || t === "news" || t === "poi") {
3147
+ throw new ApiError(`Le type d'entité "${t}" n'est pas supporté par UPDATE_BLOCK_LOCALITY.`, 400);
3148
+ }
3149
+
3150
+ const parentType = t as DisconnectData["parentType"];
3151
+
3152
+ if (userLink) {
3153
+ const data: DisconnectData = {
3154
+ childId: this.userId,
3155
+ childType: "citoyens",
3156
+ parentType,
3157
+ parentId: this.id,
3158
+ connectType: "followers"
3159
+ };
3160
+ const retour = await this.callIsMe(() => this.endpointApi.disconnect(data));
3161
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
3162
+ await this.userContext?.refresh();
3163
+ return retour;
3164
+ }
3165
+
3166
+ throw new ApiError("Vous n'êtes pas abonné à cette entité.", 404);
3167
+ }
3168
+
3169
+
3170
+ /**
3171
+ * Vérifie si l'utilisateur est connecté et a accès à l'entité.
3172
+ *
3173
+ * @param action - Action à effectuer.
3174
+ * @throws {ApiError} - Si l'utilisateur n'est pas connecté ou si l'entité n'est pas enregistrée.
3175
+ * @private
3176
+ */
3177
+ private _checkAccess(action: string = "effectuer cette action"): void {
3178
+ if (!this.isMe) {
3179
+ throw new ApiError(`Vous devez être connecté pour ${action}.`, 401);
3180
+ }
3181
+ if (!this.id) {
3182
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3183
+ }
3184
+ }
3185
+
3186
+ /**
3187
+ * Vérifie si l'utilisateur a un lien valide avec l'entité.
3188
+ *
3189
+ * @param userLink
3190
+ * @returns - `true` si le lien est valide, `false` sinon.
3191
+ * @protected
3192
+ */
3193
+ protected _validateUserLink(userLink: MinimalUserLink | null | undefined): boolean {
3194
+ if (!userLink) return false;
3195
+ const { toBeValidated, isInviting } = userLink;
3196
+ return !toBeValidated && !isInviting;
3197
+ }
3198
+
3199
+ /**
3200
+ * Vérifie si l'entité est d'un type spécifique.
3201
+ *
3202
+ * @param types - Types d'entité attendus.
3203
+ * @throws {ApiError} - Si l'entité n'est pas du type attendu.
3204
+ * @protected
3205
+ */
3206
+ protected _assertEntityType(...types: string[]): void {
3207
+ const expectedTypes = Array.isArray(types[0]) ? types[0] : types;
3208
+ if (!expectedTypes.includes(this.getEntityType())) {
3209
+ throw new ApiError(`L'entité doit être de type : ${expectedTypes.join(", ")}, reçu : ${this.getEntityType()}`, 400);
3210
+ }
3211
+ }
3212
+
3213
+ /**
3214
+ * Vérifie si l'entité est liée à un type de lien spécifique.
3215
+ *
3216
+ * @param linkType - Type de lien à vérifier.
3217
+ * @returns - `true` si l'entité est liée, `false` sinon.
3218
+ * @protected
3219
+ */
3220
+ protected _isLinked(linkType: string): boolean {
3221
+ if(!this.id) {
3222
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3223
+ }
3224
+ const links = this.userContext?.serverData?.links;
3225
+ if (!links) return false;
3226
+ return !!(links as Record<string, Record<string, unknown>>)[linkType]?.[this.id];
3227
+ }
3228
+
3229
+ /**
3230
+ * Vérifie si l'utilisateur est l'auteur de l'entité.
3231
+ *
3232
+ * @returns - `true` si l'utilisateur est l'auteur, `false` sinon.
3233
+ * @throws {ApiError} - Si l'utilisateur n'est pas connecté ou si les données du serveur ne sont pas disponibles.
3234
+ */
3235
+ isAuthor(): boolean {
3236
+ this._checkAccess("vérifier l'auteur");
3237
+ if (!this.serverData) {
3238
+ throw new ApiError("Aucune donnée serveur disponible.", 404);
3239
+ }
3240
+ if (!this.userId) {
3241
+ throw new ApiError("Utilisateur non connecté.", 401);
3242
+ }
3243
+
3244
+ const sd: { creator?: string | null } = this.serverData;
3245
+
3246
+ // Si pas de creator (données anciennes), l'utilisateur n'est pas l'auteur
3247
+ if (!sd.creator) {
3248
+ return false;
3249
+ }
3250
+
3251
+ return Boolean(this.userId && typeof sd.creator === "string" && sd.creator === this.userId);
3252
+ }
3253
+
3254
+ /**
3255
+ * Vérifie si l'utilisateur est administrateur de l'entité.
3256
+ *
3257
+ * @returns - `true` si l'utilisateur est administrateur, `false` sinon.
3258
+ * @throws {ApiError}
3259
+ */
3260
+ isAdmin(): boolean {
3261
+ this._checkAccess("vérifier l'administrateur.");
3262
+ this._assertEntityType("organizations", "projects", "events");
3263
+ const userLink = this._getLinkFromConnectedUser();
3264
+ return this._validateUserLink(userLink) && userLink?.isAdmin === true && !userLink?.isAdminPending;
3265
+ }
3266
+
3267
+ /**
3268
+ * Vérifie si l'utilisateur est soit l'auteur, soit administrateur de l'entité.
3269
+ *
3270
+ * @returns - `true` si l'utilisateur est l'auteur ou administrateur, `false` sinon.
3271
+ * @throws {ApiError}
3272
+ */
3273
+ isAuthorOrAdmin(): boolean {
3274
+ this._checkAccess("vérifier l'auteur ou l'administrateur.");
3275
+ return this.isAuthor() || this.isAdmin();
3276
+ }
3277
+
3278
+ /**
3279
+ * Vérifie si l'utilisateur est membre de l'entité.
3280
+ *
3281
+ * @returns - `true` si l'utilisateur est membre, `false` sinon.
3282
+ * @throws {ApiError}
3283
+ */
3284
+ isMember(): boolean {
3285
+ this._checkAccess("vérifier le membre.");
3286
+ this._assertEntityType("organizations");
3287
+ const userLink = this._getLinkFromConnectedUser();
3288
+ return this._validateUserLink(userLink);
3289
+ }
3290
+
3291
+ /**
3292
+ * Vérifie si l'utilisateur est contributeur de l'entité.
3293
+ *
3294
+ * @returns - `true` si l'utilisateur est contributeur, `false` sinon.
3295
+ * @throws {ApiError}
3296
+ */
3297
+ isContributor(): boolean {
3298
+ this._checkAccess("vérifier le contributeur.");
3299
+ this._assertEntityType("projects");
3300
+ const userLink = this._getLinkFromConnectedUser();
3301
+ return this._validateUserLink(userLink);
3302
+ }
3303
+
3304
+ /**
3305
+ * Vérifie si l'utilisateur est participant de l'entité.
3306
+ *
3307
+ * @returns - `true` si l'utilisateur est participant, `false` sinon.
3308
+ * @throws {ApiError}
3309
+ */
3310
+ isAttendee(): boolean {
3311
+ this._checkAccess("vérifier si vous êtes un participant.");
3312
+ this._assertEntityType("events");
3313
+ const userLink = this._getLinkFromConnectedUser();
3314
+ return this._validateUserLink(userLink);
3315
+ }
3316
+
3317
+ /**
3318
+ * Vérifie si l'utilisateur suit l'entité.
3319
+ *
3320
+ * @returns - `true` si l'utilisateur suit l'entité, `false` sinon.
3321
+ * @throws {ApiError}
3322
+ */
3323
+ isFollower(): boolean {
3324
+ this._checkAccess("vérifier si il vous suit.");
3325
+ this._assertEntityType("citoyens","organizations", "projects", "events", "poi");
3326
+ return this._isLinked("followers");
3327
+ }
3328
+
3329
+ /**
3330
+ * Vérifie si l'utilisateur est abonné à l'entité.
3331
+ *
3332
+ * @returns - `true` si l'utilisateur est abonné, `false` sinon.
3333
+ * @throws {ApiError}
3334
+ */
3335
+ isFollowing(): boolean {
3336
+ this._checkAccess("vérifier si vous le suivez.");
3337
+ this._assertEntityType("citoyens","organizations", "projects", "events", "poi");
3338
+ return this._isLinked("follows");
3339
+ }
3340
+
3341
+ /**
3342
+ * Récupère le JSON personnalisé de l'entité
3343
+ *
3344
+ * @returns - Le JSON personnalisé de l'entité.
3345
+ * @throws {ApiError} - Si le slug de l'entité n'est pas défini.
3346
+ */
3347
+ async getCostumJson(): Promise<unknown> {
3348
+ if (!this.serverData) {
3349
+ throw new ApiError("Aucune donnée serveur disponible.", 404);
3350
+ }
3351
+ const sd: { slug?: string | null } = this.serverData;
3352
+
3353
+ if (!sd || typeof sd.slug !== "string" || sd.slug.trim() === "") {
3354
+ throw new ApiError("slug de l'entité non défini", 400);
3355
+ }
3356
+
3357
+ const payload: GetCostumJsonData = {
3358
+ pathParams: { slug: sd.slug }
3359
+ };
3360
+
3361
+ return this.endpointApi.getCostumJson(payload);
3362
+ }
3363
+
3364
+ /**
3365
+ * Génère des plages d'index pour la pagination.
3366
+ *
3367
+ * @param searchType - Types de recherche.
3368
+ * @param indexStep - Pas d'index.
3369
+ * @param previousRanges - Plages précédentes.
3370
+ * @returns - Plages d'index générées.
3371
+ * @private
3372
+ */
3373
+ private _generateRanges(searchType: string[], indexStep: number, previousRanges: Record<string, { indexMax: number }> = {}): Record<string, { indexMin: number; indexMax: number }> {
3374
+ const ranges: Record<string, { indexMin: number; indexMax: number }> = {};
3375
+ for (const type of searchType) {
3376
+ const previous = previousRanges[type] || { indexMax: 0 };
3377
+ ranges[type] = {
3378
+ indexMin: previous.indexMax || 0,
3379
+ indexMax: previous.indexMax + indexStep
3380
+ };
3381
+ }
3382
+ return ranges;
3383
+ }
3384
+
3385
+ /**
3386
+ * Normalise le compte des résultats.
3387
+ *
3388
+ * @param count - Compte des résultats.
3389
+ * @returns - Compte normalisé.
3390
+ * @private
3391
+ */
3392
+ private _normalizeCount(count: any = {}): { total: number } & Record<string, any> {
3393
+ // suppression des indésirables
3394
+ delete count.spam;
3395
+
3396
+ // calcul du total (somme des valeurs numériques)
3397
+ count.total = Object.values(count).reduce((acc, val) => (acc as number) + (typeof val === "number" ? val : 0), 0);
3398
+
3399
+ return count;
3400
+ }
3401
+
3402
+ /**
3403
+ * Récupère une valeur par défaut depuis un endpoint donné.
3404
+ *
3405
+ * @param constant - Le nom unique de l'endpoint (ex: "GET_ORGANIZATIONS_NO_ADMIN")
3406
+ * @param path - Le chemin vers la propriété (ex: "searchType")
3407
+ * @returns La valeur par défaut, ou undefined si non trouvée
3408
+ */
3409
+ _getDefaultFromEndpoint(constant: string, path: string): unknown {
3410
+ const requestSchema = this.apiClient.getRequestSchema(constant);
3411
+ if (!requestSchema?.properties?.[path]) return undefined;
3412
+ return requestSchema.properties[path].default;
3413
+ }
3414
+
3415
+ /**
3416
+ * Coeur de pagination stateless et réutilisable.
3417
+ */
3418
+ _createPaginatorEngine<TData extends Record<string, any>, TOut>({ initialData, finalizer }: {
3419
+ initialData: Partial<TData>,
3420
+ finalizer: (data: TData) => Promise<FinalizerResult<TOut>>
3421
+ }): { next: () => Promise<PaginatorPage<TOut>> } {
3422
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
3423
+ const Entity = this;
3424
+
3425
+ const state: { cursor: PaginationCursor | null; count: number; index: number; history: PaginationCursor[]; sizes: number[] } = { cursor: null, count: 0, index: 0, history: [], sizes: [] };
3426
+
3427
+ const hasStep = (d: PaginationCursor | null | undefined) => Boolean(d?.indexStep && d.indexStep > 0);
3428
+
3429
+ async function getPage(isNext = false) {
3430
+ const data: PaginationCursor = { ...initialData };
3431
+
3432
+ if (!state.cursor || (!isNext && state.history.length === 0)) {
3433
+ state.count = 0;
3434
+ state.index = 0;
3435
+ state.history = [];
3436
+ state.sizes = [];
3437
+
3438
+ // hydrate data pour le premier appel
3439
+ data.countType = data.searchType;
3440
+
3441
+ if (!data.searchBy && hasStep(data)) {
3442
+ data.ranges = Entity._generateRanges(
3443
+ data.searchType as string[],
3444
+ data.indexStep as number
3445
+ );
3446
+ data.indexMin = data.indexMin ?? 0;
3447
+ data.indexMax = data.indexMax ?? data.indexStep;
3448
+ } else if (data.searchBy === "ALL" && hasStep(data)) {
3449
+ data.indexMin = data.indexMin ?? 0;
3450
+ data.indexMax = data.indexMax ?? data.indexStep;
3451
+ }
3452
+
3453
+ state.cursor = { ...data };
3454
+ }
3455
+
3456
+ const cursor: PaginationCursor = state.cursor!;
3457
+
3458
+ if (isNext && cursor) {
3459
+ state.history.push({ ...cursor });
3460
+
3461
+ if (!cursor.searchBy && hasStep(cursor)) {
3462
+ cursor.ranges = Entity._generateRanges(
3463
+ cursor.searchType as string[],
3464
+ cursor.indexStep as number,
3465
+ cursor.ranges as Record<string, { indexMax: number }>
3466
+ );
3467
+ cursor.indexMin = cursor.indexMax ?? 0;
3468
+ cursor.indexMax = (cursor.indexMax ?? 0) + (cursor.indexStep as number);
3469
+ } else if (cursor.searchBy === "ALL" && hasStep(cursor)) {
3470
+ cursor.indexMin = cursor.indexMax ?? 0;
3471
+ cursor.indexMax = (cursor.indexMax ?? 0) + (cursor.indexStep as number);
3472
+ }
3473
+
3474
+ state.cursor = { ...cursor };
3475
+ }
3476
+
3477
+ const finalizerInput: PaginationCursor = { ...(state.cursor || {}) };
3478
+
3479
+ if (!isNext) {
3480
+ const st = finalizerInput.searchType;
3481
+ if (!Array.isArray(st) || st.length === 0) {
3482
+ throw new Error("searchType non défini");
3483
+ }
3484
+ }
3485
+
3486
+ const result = await finalizer(finalizerInput as TData);
3487
+
3488
+ if (!Array.isArray(result.results)) {
3489
+ throw new Error("Les résultats doivent être un tableau");
3490
+ }
3491
+
3492
+ state.count += result.results.length;
3493
+ state.sizes.push(result.results.length);
3494
+ if (isNext) state.index++;
3495
+
3496
+ const count = Entity._normalizeCount(result.count);
3497
+ const rawList = Entity._linkEntities(result.results);
3498
+
3499
+ const hasNext = hasStep(finalizerInput) && state.count < count.total;
3500
+ const hasPrev = state.history.length > 0;
3501
+
3502
+ return {
3503
+ count,
3504
+ results: rawList,
3505
+ pageIndex: state.index,
3506
+ pageNumber: state.index + 1,
3507
+ hasNext,
3508
+ hasPrev,
3509
+ next: hasNext ? () => getPage(true) : undefined,
3510
+ prev: hasPrev
3511
+ ? async () => {
3512
+ const previous: PaginationCursor = state.history.pop()!;
3513
+ const lastPageSize = state.sizes.pop() ?? 0;
3514
+ state.count -= lastPageSize;
3515
+ state.index = Math.max(0, state.index - 1);
3516
+ state.cursor = { ...previous };
3517
+ return getPage(false);
3518
+ }
3519
+ : undefined
3520
+ };
3521
+ }
3522
+
3523
+ return { next: () => getPage(false) };
3524
+ }
3525
+
3526
+ /**
3527
+ * Injection de contexte Communecter dans une requête finalizer.
3528
+ */
3529
+ _withCostumContext<TIn extends Record<string, unknown>, TOut>(
3530
+ baseFinalizer: (data: TIn & {
3531
+ costumSlug: string,
3532
+ contextId: string,
3533
+ contextType: EntityType,
3534
+ sourceKey: string[]
3535
+ }) => Promise<TOut>
3536
+ ): (data: TIn) => Promise<TOut> {
3537
+ return async (data: TIn) => {
3538
+ if (!this.serverData) {
3539
+ throw new ApiError("Aucune donnée serveur disponible.", 404);
3540
+ }
3541
+ const sd: { slug?: string | null, id?: string | null } = this.serverData;
3542
+
3543
+ if (!sd || typeof sd.slug !== "string" || sd.slug.trim() === "") {
3544
+ throw new ApiError("slug de l'entité non défini", 400);
3545
+ }
3546
+ if (!sd.id || typeof sd.id !== "string") {
3547
+ throw new ApiError("id de l'entité non défini", 400);
3548
+ }
3549
+
3550
+ // sourceKey peut être absent ou non-tableau : on normalise
3551
+ const inSourceKey: string[] = Array.isArray(data?.sourceKey)
3552
+ ? (data.sourceKey as string[])
3553
+ : [];
3554
+
3555
+ const finalData = {
3556
+ ...(data || {}),
3557
+ costumSlug: sd.slug,
3558
+ contextId: sd.id,
3559
+ contextType: this.getEntityType(),
3560
+ sourceKey: [...inSourceKey, sd.slug] as string[]
3561
+ } as TIn & {
3562
+ costumSlug: string;
3563
+ contextId: string;
3564
+ contextType: EntityType;
3565
+ sourceKey: string[];
3566
+ };
3567
+
3568
+ return baseFinalizer(finalData);
3569
+ };
3570
+ }
3571
+
3572
+ /**
3573
+ * ───────────────────────────────
3574
+ * custom
3575
+ * ───────────────────────────────
3576
+ */
3577
+
3578
+ /**
3579
+ * Recherche globale liée au *costum* (stateless).
3580
+ * Injecte automatiquement le contexte (slug/id/type) via `_withCostumContext`,
3581
+ * puis pagine via `_createPaginatorEngine`. Cette méthode renvoie **directement
3582
+ * la première page** (rétro-compatible) avec `.results`, `.count`, et des helpers
3583
+ * de pagination si disponibles.
3584
+ *
3585
+ * Remarque : les éléments de `results` peuvent être hétérogènes (multi-collections)
3586
+ * et éventuellement « liés » en entités par le moteur interne de pagination.
3587
+ *
3588
+ * @param data
3589
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3590
+ * par le moteur de pagination et le contexte).
3591
+ * @returns
3592
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3593
+ * @throws {ApiResponseError}
3594
+ * @throws {Error}
3595
+ *
3596
+ * @example
3597
+ * // Recherche simple (première page)
3598
+ * const page = await entity.searchCostum({ name: "marseille", searchType: ["projects"], indexStep: 12, indexMin: 0 });
3599
+ * console.log(page.results, page.count.total);
3600
+ *
3601
+ * @example
3602
+ * // Paginer
3603
+ * if (page.hasNext) {
3604
+ * const nextPage = await page.next();
3605
+ * console.log(nextPage.pageNumber, nextPage.results.length);
3606
+ * }
3607
+ */
3608
+ async searchCostum(data: Partial<GlobalAutocompleteCostumData> = {}): Promise<PaginatorPage<any>> {
3609
+ const paginator = this._createPaginatorEngine({
3610
+ initialData: data,
3611
+ finalizer: this._withCostumContext(
3612
+ (finalData: GlobalAutocompleteCostumData) => this.endpointApi.globalAutocompleteCostum(finalData)
3613
+ ),
3614
+ });
3615
+ return paginator.next() as Promise<PaginatorPage<any>>;
3616
+ }
3617
+
3618
+ /**
3619
+ * @param data
3620
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3621
+ * par le moteur de pagination et le contexte).
3622
+ * @returns
3623
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3624
+ * @throws {ApiResponseError}
3625
+ * @throws {Error}
3626
+ */
3627
+ async costumEventRequestActors(data: Partial<Omit<CostumEventRequestActorsData, "pathParams">> = {}): Promise<unknown> {
3628
+
3629
+ if(!this.id) {
3630
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3631
+ }
3632
+
3633
+ const fullData = {
3634
+ pathParams: {
3635
+ id: this.id,
3636
+ type: this.getEntityType()
3637
+ },
3638
+ ...data
3639
+ };
3640
+
3641
+ const wrappedFinalizer = this._withCostumContext(
3642
+ (finalData: CostumEventRequestActorsData) => this.endpointApi.costumEventRequestActors(finalData)
3643
+ );
3644
+
3645
+ const actors = await wrappedFinalizer(fullData);
3646
+
3647
+ // const transformedActors = actors
3648
+ // .filter(actor => {
3649
+ // const isValid = !!actor?.type;
3650
+ // if (!isValid) {
3651
+ // this.apiClient._logger?.warn?.(`Objet ignoré car sans 'type' : ${actor?.id}`);
3652
+ // }
3653
+ // return isValid;
3654
+ // })
3655
+ // .map(({ id, name, type, ...meta }) => ({
3656
+ // id,
3657
+ // name,
3658
+ // collection: type,
3659
+ // meta
3660
+ // }));
3661
+
3662
+ // const rawList = this._linkEntities(transformedActors);
3663
+
3664
+ return actors;
3665
+ }
3666
+
3667
+ /**
3668
+ * @param data
3669
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3670
+ * par le moteur de pagination et le contexte).
3671
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3672
+ * @throws {ApiResponseError}
3673
+ * @throws {Error}
3674
+ */
3675
+ async costumEventRequestSubevents(data: Partial<Omit<CostumEventRequestSubeventsData, "pathParams">> = {}): Promise<unknown> {
3676
+
3677
+ if(!this.id) {
3678
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3679
+ }
3680
+
3681
+ const fullData = {
3682
+ pathParams: {
3683
+ id: this.id,
3684
+ type: this.getEntityType()
3685
+ },
3686
+ ...data
3687
+ };
3688
+
3689
+ const wrappedFinalizer = this._withCostumContext(
3690
+ (finalData: CostumEventRequestSubeventsData) => this.endpointApi.costumEventRequestSubevents(finalData)
3691
+ );
3692
+
3693
+ return wrappedFinalizer(fullData);
3694
+ }
3695
+
3696
+ /**
3697
+ * @param data
3698
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3699
+ * par le moteur de pagination et le contexte).
3700
+ * @returns
3701
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3702
+ * @throws {ApiResponseError}
3703
+ * @throws {Error}
3704
+ */
3705
+ async costumEventRequestDates(data: Partial<Omit<CostumEventRequestDatesData, "pathParams">> = {}): Promise<unknown> {
3706
+
3707
+ if(!this.id) {
3708
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3709
+ }
3710
+
3711
+ const fullData = {
3712
+ pathParams: {
3713
+ id: this.id,
3714
+ type: this.getEntityType()
3715
+ },
3716
+ ...data
3717
+ };
3718
+
3719
+ const wrappedFinalizer = this._withCostumContext(
3720
+ (finalData: CostumEventRequestDatesData) => this.endpointApi.costumEventRequestDates(finalData)
3721
+ );
3722
+
3723
+ return wrappedFinalizer(fullData);
3724
+ }
3725
+
3726
+ /**
3727
+ * @param data
3728
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3729
+ * par le moteur de pagination et le contexte).
3730
+ * @returns
3731
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3732
+ * @throws {ApiResponseError}
3733
+ * @throws {Error}
3734
+ */
3735
+ async costumEventRequestElementEvent(data: Partial<Omit<CostumEventRequestElementEventData, "pathParams">> = {}): Promise<unknown> {
3736
+
3737
+ if(!this.id) {
3738
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3739
+ }
3740
+
3741
+ const fullData = {
3742
+ pathParams: {
3743
+ id: this.id,
3744
+ type: this.getEntityType()
3745
+ },
3746
+ ...data
3747
+ };
3748
+
3749
+ const wrappedFinalizer = this._withCostumContext(
3750
+ (finalData: CostumEventRequestElementEventData) => this.endpointApi.costumEventRequestElementEvent(finalData)
3751
+ );
3752
+
3753
+ return wrappedFinalizer(fullData);
3754
+ }
3755
+
3756
+ /**
3757
+ * @param data
3758
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3759
+ * par le moteur de pagination et le contexte).
3760
+ * @returns
3761
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3762
+ * @throws {ApiResponseError}
3763
+ * @throws {Error}
3764
+ */
3765
+ async costumEventRequestCategories(data: Partial<Omit<CostumEventRequestCategoriesData, "pathParams">> = {}): Promise<unknown> {
3766
+
3767
+ if(!this.id) {
3768
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3769
+ }
3770
+
3771
+ const fullData = {
3772
+ pathParams: {
3773
+ id: this.id,
3774
+ type: this.getEntityType()
3775
+ },
3776
+ ...data
3777
+ };
3778
+
3779
+ const wrappedFinalizer = this._withCostumContext(
3780
+ (finalData: CostumEventRequestCategoriesData) => this.endpointApi.costumEventRequestCategories(finalData)
3781
+ );
3782
+
3783
+ return wrappedFinalizer(fullData);
3784
+ }
3785
+
3786
+ /**
3787
+ * @param data
3788
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3789
+ * par le moteur de pagination et le contexte).
3790
+ * @returns
3791
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3792
+ * @throws {ApiResponseError}
3793
+ * @throws {Error}
3794
+ */
3795
+ async costumEventRequestEvent(data: Partial<Omit<CostumEventRequestEventData, "pathParams">> = {}): Promise<unknown> {
3796
+
3797
+ if(!this.id) {
3798
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3799
+ }
3800
+
3801
+ const fullData = {
3802
+ pathParams: {
3803
+ id: this.id,
3804
+ type: this.getEntityType()
3805
+ },
3806
+ ...data
3807
+ };
3808
+
3809
+ const wrappedFinalizer = this._withCostumContext(
3810
+ (finalData: CostumEventRequestEventData) => this.endpointApi.costumEventRequestEvent(finalData)
3811
+ );
3812
+
3813
+ return wrappedFinalizer(fullData);
3814
+ }
3815
+
3816
+ /**
3817
+ * @param data
3818
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3819
+ * par le moteur de pagination et le contexte).
3820
+ * @returns
3821
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3822
+ * @throws {ApiResponseError}
3823
+ * @throws {Error}
3824
+ */
3825
+ async costumEventRequestLinkTlToEvent(
3826
+ data: Pick<CostumEventRequestLinkTlToEventData, "tl" | "event"> &
3827
+ Partial<Omit<CostumEventRequestLinkTlToEventData, "tl" | "event" | "pathParams">>
3828
+ ): Promise<unknown> {
3829
+
3830
+ if(!this.id) {
3831
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3832
+ }
3833
+
3834
+ if(!data.tl && !data.event) {
3835
+ throw new ApiError("Les paramètres 'tl' et 'event' sont requis.", 400);
3836
+ }
3837
+
3838
+ if(typeof data.tl !== "string" || typeof data.event !== "string") {
3839
+ throw new ApiError("Les paramètres 'tl' et 'event' doivent être des chaînes de caractères.", 400);
3840
+ }
3841
+
3842
+ const payload: CostumEventRequestLinkTlToEventData = {
3843
+ pathParams: {
3844
+ id: this.id,
3845
+ type: this.getEntityType()
3846
+ },
3847
+ ...data
3848
+ };
3849
+
3850
+ const wrappedFinalizer = this._withCostumContext(
3851
+ (finalData: CostumEventRequestLinkTlToEventData) => this.endpointApi.costumEventRequestLinkTlToEvent(finalData)
3852
+ );
3853
+
3854
+ return wrappedFinalizer(payload);
3855
+ }
3856
+
3857
+ /**
3858
+ * @param data
3859
+ * Paramètres de recherche (partiels — les valeurs manquantes sont complétées
3860
+ * par le moteur de pagination et le contexte).
3861
+ * @returns
3862
+ * Première page paginée : `results` (T[]), `count.total`, `hasNext`, etc.
3863
+ * @throws {ApiResponseError}
3864
+ * @throws {Error}
3865
+ */
3866
+ async costumEventRequestLoadContextTag(data: Partial<Omit<CostumEventRequestLoadContextTagData, "pathParams">> = {}): Promise<unknown> {
3867
+
3868
+ if(!this.id) {
3869
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
3870
+ }
3871
+
3872
+ const fullData = {
3873
+ pathParams: {
3874
+ id: this.id,
3875
+ type: this.getEntityType()
3876
+ },
3877
+ ...data
3878
+ };
3879
+
3880
+ const wrappedFinalizer = this._withCostumContext(
3881
+ (finalData: CostumEventRequestLoadContextTagData) => this.endpointApi.costumEventRequestLoadContextTag(finalData)
3882
+ );
3883
+
3884
+ return wrappedFinalizer(fullData);
3885
+ }
3886
+
3887
+
3888
+ }
3889
+
3890
+ export default BaseEntity;