@communecter/cocolight-api-client 1.0.134 → 1.0.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@communecter/cocolight-api-client",
3
- "version": "1.0.134",
3
+ "version": "1.0.136",
4
4
  "description": "Client Axios simplifié pour l'API cocolight",
5
5
  "repository": {
6
6
  "type": "git",
package/src/Api.ts CHANGED
@@ -16,6 +16,7 @@ import { User } from "./api/User.js";
16
16
  import { UserApi } from "./api/UserApi.js";
17
17
  import { ApiAuthenticationError, ApiClientError, ApiError, ApiResponseError } from "./error.js";
18
18
 
19
+ import type { SearchTagsData } from "./api/EndpointApi.types.js";
19
20
  import type ApiClient from "./ApiClient.js";
20
21
  import type { ApiClientOptions } from "./ApiClient.js";
21
22
  import type { GetElementsKeyResponse } from "./types/api-responses.js";
@@ -310,6 +311,31 @@ export default class Api {
310
311
  return new EndpointApi(this._client);
311
312
  }
312
313
 
314
+ /**
315
+ * Recherche des tags correspondant à un mot-clé.
316
+ *
317
+ * Wrap de `SEARCH_TAGS` (auth=none) — accessible même non connecté.
318
+ * Méthode exposée sur la facade `Api` (pas d'entité hôte naturelle pour
319
+ * un index global de tags).
320
+ *
321
+ * @param q - Mot-clé de recherche (au moins 1 caractère).
322
+ * @returns Tableau de résultats : soit `[{tag}]` si aucun match (echo du
323
+ * terme), soit `[{_id, tag, field_length}, ...]` avec les tags
324
+ * existants triés par `field_length`.
325
+ * @throws {ApiError} 400 si `q` vide.
326
+ *
327
+ * @example
328
+ * const results = await api.searchTags("permac");
329
+ * results.forEach(t => console.log(t.tag, t.field_length));
330
+ */
331
+ async searchTags(q: string): Promise<unknown> {
332
+ if (typeof q !== "string" || q.length === 0) {
333
+ throw new ApiError("Le mot-clé `q` est requis pour searchTags.", 400);
334
+ }
335
+ const payload: SearchTagsData = { pathParams: { q } };
336
+ return this.endpointApi.searchTags(payload);
337
+ }
338
+
313
339
  /**
314
340
  * Déconnecte l'utilisateur et réinitialise la session.
315
341
  */
package/src/api/Action.ts CHANGED
@@ -557,16 +557,12 @@ export class Action extends BaseEntity<ActionItemNormalized> {
557
557
  // ───────────────────────────────────────────────────────────────────────────
558
558
 
559
559
  /**
560
- * Helper privé : envoie un UPDATE_PATH_VALUE pour un path donné.
560
+ * Helper privé : délègue à `BaseEntity.updateField()` pour un path donné.
561
+ * Bénéficie du normalize automatique (R0 Date → isoDate, R1 collapse setType,
562
+ * R2 pull + empty → "", R6 drop setType vide).
561
563
  */
562
564
  private async _updatePath(path: string, value: unknown, setType?: string): Promise<unknown> {
563
- return this.endpointApi.updatePathValue({
564
- id: this.id!,
565
- collection: "actions",
566
- path,
567
- value: value as { [k: string]: unknown },
568
- ...(setType ? { setType } : {})
569
- });
565
+ return this.updateField(path, value, setType ? { setType } : {});
570
566
  }
571
567
 
572
568
  /**
package/src/api/Answer.ts CHANGED
@@ -167,6 +167,79 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
167
167
  "voteCount", "vote", "project"
168
168
  ];
169
169
 
170
+ /**
171
+ * {@inheritDoc BaseEntity#_isAdminViaHierarchy}
172
+ *
173
+ * Override Answer : la hiérarchie d'Answer passe par son **parent instance
174
+ * Form** (pas par `serverData.parent` direct comme pour `events`/`projects`/`poi`).
175
+ *
176
+ * Chaîne : `Answer → parent (Form) → form.serverData.parent ({[orgId]: {type, name}}) → check userContext.links`.
177
+ *
178
+ * Si le parent n'est pas un Form (cas `api.answer({id})` direct sans contexte),
179
+ * renvoie `false` — impossible de vérifier la hiérarchie sans le Form.
180
+ *
181
+ * @protected
182
+ */
183
+ protected override _isAdminViaHierarchy(): boolean {
184
+ const form = this.parent;
185
+ if (!form || typeof (form as any).getEntityType !== "function"
186
+ || (form as any).getEntityType() !== "forms") {
187
+ return false;
188
+ }
189
+
190
+ const formParents = (form as { serverData?: { parent?: Record<string, unknown> } }).serverData?.parent;
191
+ if (!formParents || typeof formParents !== "object") {
192
+ return false;
193
+ }
194
+
195
+ for (const [parentId, parentRef] of Object.entries(formParents)) {
196
+ if (!parentRef || typeof parentRef !== "object") continue;
197
+ const ref = parentRef as { type?: string };
198
+ if (!ref.type) continue;
199
+ if (this._isAdminOfParent(parentId, ref.type)) {
200
+ return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+
206
+ /**
207
+ * {@inheritDoc BaseEntity#isAuthor}
208
+ *
209
+ * Override Answer : le champ d'autorité est `serverData.user` (pas `creator`
210
+ * comme la version par défaut de BaseEntity). Le `user` peut être :
211
+ * - un `string` (ID MongoDB brut renvoyé par le backend)
212
+ * - une instance `User` (après `_transformServerData` qui le link en entité)
213
+ *
214
+ * Les pré-conditions (connexion, id, serverData) sont vérifiées comme dans
215
+ * la version de base — `silent: true` (défaut) renvoie `false` au lieu de throw.
216
+ */
217
+ override isAuthor(options?: { silent?: boolean }): boolean {
218
+ const silent = options?.silent ?? true;
219
+ try {
220
+ // Note : on utilise `isConnected` (user logged in) au lieu de `isMe`
221
+ // (l'entité IS le user logged in) qui est sémantiquement faux sur Answer.
222
+ // `_checkAccess` du parent est private et utilise isMe — non réutilisable ici.
223
+ if (!this.isConnected) throw new ApiError("Vous devez être connecté pour vérifier l'auteur.", 401);
224
+ if (!this.id) throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
225
+ if (!this.serverData) throw new ApiError("Aucune donnée serveur disponible.", 404);
226
+ if (!this.userId) throw new ApiError("Utilisateur non connecté.", 401);
227
+ } catch (e) {
228
+ if (silent) return false;
229
+ throw e;
230
+ }
231
+
232
+ const user = this._serverData.user;
233
+ if (!user) return false;
234
+
235
+ const authorId = typeof user === "string" ? user : (user as { id?: string }).id;
236
+ return Boolean(
237
+ this.userId
238
+ && typeof authorId === "string"
239
+ && authorId === this.userId
240
+ );
241
+ }
242
+
170
243
  /**
171
244
  * Transforme les champs imbriqués (user, context etc.) en instances d'entités.
172
245
  * @param data - Les données brutes du serveur.
@@ -269,12 +342,26 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
269
342
  * - `serverData` et `draftData` sont vidés (sans casser la réactivité)
270
343
  * - `_isDeleted = true` → toute mutation ultérieure (`save`, `get`, etc.) throw
271
344
  *
345
+ * **Guard** : autorisation côté client si l'utilisateur courant est :
346
+ * - l'**auteur** de l'Answer (`serverData.user === userId`), OU
347
+ * - **admin** d'un parent du Form (org/project) via la hiérarchie
348
+ * (`form.serverData.parent[orgId]` × `userContext.links.memberOf[orgId].isAdmin`).
349
+ *
350
+ * Le check utilise `isAuthorOrAdmin({checkHierarchy: true})` qui s'appuie sur
351
+ * l'override `Answer._isAdminViaHierarchy` (remonte via parent instance Form).
352
+ *
353
+ * **Pré-requis** :
354
+ * - `this.parent` doit être un Form (cas `form.answer({id})`)
355
+ * - `userContext` (User connecté) doit avoir `serverData.links` chargés (cas `api.me()`)
356
+ * Sans ces pré-requis, seul `isAuthor` peut autoriser.
357
+ *
272
358
  * @param reason - Raison libre transmise au backend (audit). Défaut : `"delete coform answer"`.
273
359
  * @throws {ApiError} 400 si l'Answer n'a pas d'id (jamais sauvegardée).
360
+ * @throws {ApiError} 403 si ni auteur ni admin du parent.
274
361
  *
275
362
  * @example
276
363
  * const answer = await form.answer({ id: "..." });
277
- * await answer.delete();
364
+ * await answer.delete(); // OK si auteur OU admin org/project parent du Form
278
365
  * answer._isDeleted; // true
279
366
  */
280
367
  async delete(reason: string = "delete coform answer"): Promise<void> {
@@ -282,6 +369,15 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
282
369
  throw new ApiError("Vous devez fournir un id pour supprimer une Answer.", 400);
283
370
  }
284
371
 
372
+ // Guard : auteur OU admin du parent Form (org/project).
373
+ if (!this.isAuthorOrAdmin({ checkHierarchy: true })) {
374
+ throw new ApiError(
375
+ "Suppression refusée — vous devez être l'auteur de l'Answer "
376
+ + "ou administrateur du parent (organisation/projet) du Form.",
377
+ 403
378
+ );
379
+ }
380
+
285
381
  const data: DeleteElementData = {
286
382
  reason,
287
383
  pathParams: { type: "answers", id: this.id },
@@ -296,6 +392,54 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
296
392
  this._isDeleted = true;
297
393
  }
298
394
 
395
+ /**
396
+ * {@inheritDoc BaseEntity#deleteFile}
397
+ *
398
+ * Override avec **cleanup local** : après suppression backend, retire le
399
+ * `docId` des structures uploader normalisées dans `serverData.answers` et
400
+ * `_draftData.answers` (format `{updateDate, files: {docId: docPath}}`
401
+ * produit par `processUploads()` et `_transformServerData`).
402
+ *
403
+ * Le caller n'a pas besoin de refetch — `answer.serverData.answers[*][*].files`
404
+ * reflète directement l'état post-suppression.
405
+ *
406
+ * @example
407
+ * const files = await answer.getFiles({ subKey: "sf.input", docType: "image" });
408
+ * await answer.deleteFile(files[0].docId);
409
+ * // answer.serverData.answers[sf][input].files[files[0].docId] === undefined
410
+ */
411
+ override async deleteFile(docId: string): Promise<void> {
412
+ await super.deleteFile(docId);
413
+
414
+ // Cleanup local : retire le docId des structures uploader normalisées
415
+ this._removeDocIdFromUploaderFields(this._serverData.answers, docId);
416
+ this._removeDocIdFromUploaderFields(this._draftData.answers as Record<string, unknown> | undefined, docId);
417
+ }
418
+
419
+ /**
420
+ * Walk `answers[subFormId][inputId]` et retire `docId` des structures
421
+ * `{updateDate, files: {docId: docPath}}` (format canonique uploader).
422
+ * Mutation in-place pour préserver la réactivité.
423
+ * @private
424
+ */
425
+ private _removeDocIdFromUploaderFields(
426
+ answers: Record<string, unknown> | undefined,
427
+ docId: string,
428
+ ): void {
429
+ if (!answers || typeof answers !== "object") return;
430
+
431
+ for (const subFormData of Object.values(answers)) {
432
+ if (!this._isObjectRecord(subFormData)) continue;
433
+ for (const inputValue of Object.values(subFormData)) {
434
+ if (!this._isObjectRecord(inputValue)) continue;
435
+ const files = (inputValue as { files?: unknown }).files;
436
+ if (this._isObjectRecord(files) && docId in files) {
437
+ delete (files as Record<string, unknown>)[docId];
438
+ }
439
+ }
440
+ }
441
+ }
442
+
299
443
  /**
300
444
  * Récupère la liste des fichiers attachés à un input précis de cette Answer
301
445
  * via `COFORM_GET_ANSWER_FILES`.
@@ -376,6 +520,13 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
376
520
  /**
377
521
  * Upload un fichier rattaché à cette Answer via `COFORM_UPLOAD_ANSWER_FILE`.
378
522
  *
523
+ * **Building block du pipeline** : conçu pour être utilisé via
524
+ * `processUploads()` suivi de `save()`. L'Answer créée par le premier upload
525
+ * est en état "placeholder" côté backend (champ `user` non posé) — `save()`
526
+ * finalise. Appeler `get()` ou `delete()` (via l'entité) sur une Answer
527
+ * upload-only sans save échouera : le backend la considère comme orpheline
528
+ * (`editDeniedReason: "not_owner"`, stub sans `data.id`).
529
+ *
379
530
  * **Cas premier upload (Answer sans id)** : le backend crée l'Answer et
380
531
  * renvoie `answerId`. La méthode pose alors `this._draftData.id = answerId`
381
532
  * — les uploads suivants utiliseront cet id, et un `save()` ultérieur passera
@@ -58,7 +58,9 @@ import type {
58
58
  LinkMediawikiAccountData,
59
59
  UnlinkMediawikiAccountData,
60
60
  GetMediawikiContributionsData,
61
- CoremuOperationData
61
+ CoremuOperationData,
62
+ DeleteDocumentByIdData,
63
+ UpdatePathValueData
62
64
  } from "./EndpointApi.types.js";
63
65
  import type { GetElementsKeyResponse } from "../types/api-responses.js";
64
66
  import type { TransformsMap } from "../types/entities.js";
@@ -118,6 +120,52 @@ type ParentLike = BaseEntity<any> & { apiClient: ApiClient, userContext?: User |
118
120
  */
119
121
  export type SearchCostumVariant = "default" | "navigator-tl";
120
122
 
123
+ /**
124
+ * Types de coercion backend supportés par `UPDATE_PATH_VALUE.setType`.
125
+ *
126
+ * Référence côté backend : `string_set_type` (PHP) accepte ces 5 valeurs +
127
+ * tout autre string (passthrough — pas de coercion). Le `& Record<never, never>`
128
+ * garde l'autocomplete des literals tout en acceptant des strings custom.
129
+ *
130
+ * - `"int"` → `intval()` (PHP)
131
+ * - `"float"` → `floatval()`
132
+ * - `"boolean"` / `"bool"` → `FILTER_VALIDATE_BOOLEAN` (alias)
133
+ * - `"isoDate"` → parse multi-format → `MongoDate`
134
+ * Formats acceptés : ISO 8601 natif, `m-d-Y`, `d-m-Y`, `{sec, usec}` legacy,
135
+ * string `"now"` (date actuelle), slashes auto-convertis en tirets.
136
+ * - `"timestamp"` → parse → Unix seconds (`int`)
137
+ */
138
+ export type SetTypeValue =
139
+ | "int"
140
+ | "float"
141
+ | "boolean"
142
+ | "bool"
143
+ | "isoDate"
144
+ | "timestamp"
145
+ | (string & Record<never, never>);
146
+
147
+ /**
148
+ * Magic values reconnues par `UPDATE_PATH_VALUE` côté backend.
149
+ * Utilisable comme `value` ou `path` selon le cas.
150
+ */
151
+ export const UPDATE_PATH_MAGIC = {
152
+ /**
153
+ * `value: "updatedTime"` → backend remplace par `time()` Unix seconds courant
154
+ * (utile pour `lastEditedAt`, audit trail, etc. sans avoir à fournir un timestamp côté client).
155
+ */
156
+ CURRENT_TIME: "updatedTime",
157
+ /**
158
+ * `value: "now"` avec `setType: "isoDate"` → résolu côté backend en `MongoDate(now)`.
159
+ */
160
+ NOW: "now",
161
+ /**
162
+ * `path: "allToRoot"` → la `value` (qui doit être un objet) est exposée à la
163
+ * racine de l'entité : chaque clé devient un champ racine. Combiner avec
164
+ * `updatePartial: true` pour un merge avec préservation des autres champs.
165
+ */
166
+ PATH_ALL_TO_ROOT: "allToRoot",
167
+ } as const;
168
+
121
169
  const SEARCH_COSTUM_ENDPOINTS = {
122
170
  "default": "globalAutocompleteCostum",
123
171
  "navigator-tl": "navigatorGettl",
@@ -1225,6 +1273,214 @@ export class BaseEntity<TServerData = any> {
1225
1273
  throw new ApiError("Type de fichier non reconnu pour l'upload.", 400);
1226
1274
  }
1227
1275
 
1276
+ /**
1277
+ * Supprime un document (fichier ou image) par son `docId` MongoDB via
1278
+ * `DELETE_DOCUMENT_BY_ID`.
1279
+ *
1280
+ * Méthode générique exposée sur toutes les entités — le endpoint backend
1281
+ * opère par docId seul, sans contexte parent requis. Disponible ici pour
1282
+ * être découvrable et **override-able** par les entités qui veulent un
1283
+ * cleanup local après suppression (ex: `News.deleteFile` pourrait retirer
1284
+ * l'id de `mediaImg.images` côté serverData).
1285
+ *
1286
+ * Ne met PAS à jour `serverData` après suppression — le caller doit
1287
+ * invalider son cache local s'il en a un (cas typique : React Query).
1288
+ *
1289
+ * @param docId - ID MongoDB du document (24 hex)
1290
+ * @throws {ApiError} 400 si `docId` invalide.
1291
+ *
1292
+ * @example
1293
+ * // Depuis une Answer (suppression d'un fichier uploader)
1294
+ * const files = await answer.getFiles({ subKey: "sf.input", docType: "image" });
1295
+ * await answer.deleteFile(files[0].docId);
1296
+ *
1297
+ * @example
1298
+ * // Depuis n'importe quelle entité qui possède un docId
1299
+ * await news.deleteFile(imageDocId);
1300
+ */
1301
+ async deleteFile(docId: string): Promise<void> {
1302
+ if (typeof docId !== "string" || docId.length !== 24) {
1303
+ throw new ApiError("docId requis (24 hex) pour supprimer un document.", 400);
1304
+ }
1305
+ const payload: DeleteDocumentByIdData = { pathParams: { id: docId } };
1306
+ await this.callIsConnected(() => this.endpointApi.deleteDocumentById(payload));
1307
+ }
1308
+
1309
+ /**
1310
+ * Met à jour un champ unique (ou multi-fields via `updatePartial`) sur cette
1311
+ * entité via `UPDATE_PATH_VALUE`.
1312
+ *
1313
+ * **Cas d'usage couverts** :
1314
+ * - `$set` simple : `entity.updateField("name", "Nouveau nom")`
1315
+ * - `$unset` : `entity.updateField("description", null)`
1316
+ * - `$push` array : `entity.updateField("tags", "x", { arrayForm: true })`
1317
+ * - `$pull` array : `entity.updateField("tags.3", null, { pull: "tags" })`
1318
+ * - `$pullAll` array : `entity.updateField("tags", ["a","b"], { arrayForm: true, pull: "tags" })`
1319
+ * - Coercion type : `entity.updateField("credits", 100, { setType: "int" })`
1320
+ * - Date auto : `entity.updateField("startDate", new Date())` → setType "isoDate" + ISO string
1321
+ * - Multi-field merge : `entity.updateField("address", {...}, { updatePartial: true })`
1322
+ * - Auth sub-form : `entity.updateField("...", value, { formParentId: "..." })`
1323
+ *
1324
+ * **Magic values** :
1325
+ * - `value: "updatedTime"` → backend remplace par `time()` Unix seconds
1326
+ * - `value: "now"` + `setType: "isoDate"` → résout en date actuelle
1327
+ * - `path: "allToRoot"` → value exposée à la racine de l'entité
1328
+ *
1329
+ * Le payload est normalisé via `_normalizeUpdatePathPayload` avant envoi
1330
+ * (cf. les 4 règles documentées sur cette méthode).
1331
+ *
1332
+ * @param path - Chemin MongoDB dot-notation (ex: `"address.street"`, `"answers.aapStep1.depense.3"`).
1333
+ * @param value - Valeur à poser. Accepte `Date` (auto-convertie en ISO + setType isoDate).
1334
+ * @param opts - Options : `setType`, `arrayForm`, `pull`, `updatePartial`, `formParentId`, `edit`.
1335
+ * @returns Réponse brute de l'API.
1336
+ * @throws {ApiError} 404 si l'entité n'a pas d'id.
1337
+ * @throws {ApiError} 400 si `value === undefined`, `path` vide, ou conflit `arrayForm` + `pull` sans value array, ou `updatePartial` sans value object.
1338
+ *
1339
+ * @example
1340
+ * // Update simple
1341
+ * await org.updateField("name", "Nouvelle organisation");
1342
+ *
1343
+ * @example
1344
+ * // Date instance auto-convertie
1345
+ * await action.updateField("startDate", new Date());
1346
+ *
1347
+ * @example
1348
+ * // Multi-field merge (préserve les autres clés de address)
1349
+ * await org.updateField("address", {
1350
+ * street: "123 rue X",
1351
+ * city: "Paris",
1352
+ * }, { updatePartial: true });
1353
+ *
1354
+ * @example
1355
+ * // Push dans array
1356
+ * await project.updateField("tags", "permaculture", { arrayForm: true });
1357
+ *
1358
+ * @example
1359
+ * // Pull from array (avec normalisation R2 : null → "")
1360
+ * await project.updateField("oceco.milestones.3", null, { pull: "oceco.milestones" });
1361
+ */
1362
+ async updateField(
1363
+ path: string,
1364
+ value: unknown,
1365
+ opts: {
1366
+ setType?: SetTypeValue | Array<{ path: string; type: SetTypeValue }>;
1367
+ arrayForm?: boolean;
1368
+ pull?: string;
1369
+ updatePartial?: boolean;
1370
+ formParentId?: string;
1371
+ edit?: boolean;
1372
+ } = {},
1373
+ ): Promise<unknown> {
1374
+ // R3 — value: undefined → throw (distinction nette vs null pour $unset)
1375
+ if (value === undefined) {
1376
+ throw new ApiError(
1377
+ "`value` ne peut pas être undefined. Utiliser `null` pour $unset.",
1378
+ 400
1379
+ );
1380
+ }
1381
+ if (!this.id) {
1382
+ throw new ApiError(`${this.constructor.name} non enregistrée.`, 404);
1383
+ }
1384
+ // R4 — path validation
1385
+ if (typeof path !== "string" || path.length === 0) {
1386
+ throw new ApiError("`path` est requis et doit être une string non vide.", 400);
1387
+ }
1388
+ // R5 — arrayForm + pull : autorisé uniquement si value est array ($pullAll)
1389
+ if (opts.arrayForm && opts.pull) {
1390
+ if (!Array.isArray(value)) {
1391
+ throw new ApiError(
1392
+ "`arrayForm` + `pull` requièrent value: array (mode $pullAll). "
1393
+ + "Pour un $pull simple, utiliser uniquement `pull` (sans arrayForm).",
1394
+ 400
1395
+ );
1396
+ }
1397
+ }
1398
+ // R9 — updatePartial requiert value: object
1399
+ if (opts.updatePartial && (typeof value !== "object" || value === null || Array.isArray(value))) {
1400
+ throw new ApiError(
1401
+ "`updatePartial: true` requiert value: object (les sous-clés deviennent des sub-paths).",
1402
+ 400
1403
+ );
1404
+ }
1405
+ // Vérif collection : UPDATE_PATH_VALUE backend exclut badges/news/comments/classifieds
1406
+ // (ces entités ont leurs propres endpoints update — cf. enum schema).
1407
+ this._assertNotEntityType("updateField", ["badges", "news", "comments", "classifieds"]);
1408
+
1409
+ // Construction du payload
1410
+ const payload: UpdatePathValueData = {
1411
+ id: this.id,
1412
+ collection: this.getEntityType() as UpdatePathValueData["collection"],
1413
+ path,
1414
+ value: value as UpdatePathValueData["value"],
1415
+ };
1416
+ if (opts.setType !== undefined) payload.setType = opts.setType;
1417
+ if (opts.arrayForm) payload.arrayForm = true;
1418
+ if (opts.pull) payload.pull = opts.pull;
1419
+ if (opts.updatePartial) payload.updatePartial = true;
1420
+ if (opts.formParentId) payload.formParentId = opts.formParentId;
1421
+ if (opts.edit) payload.edit = true;
1422
+
1423
+ // Normalisation wire format
1424
+ return this.endpointApi.updatePathValue(this._normalizeUpdatePathPayload(payload));
1425
+ }
1426
+
1427
+ /**
1428
+ * Normalise le payload `updatePathValue` avant envoi au backend.
1429
+ *
1430
+ * Règles appliquées (4) :
1431
+ * - **R0** : `value instanceof Date` → `value.toISOString()` + `setType: "isoDate"`
1432
+ * (override d'un setType existant — l'intent `Date` est prioritaire).
1433
+ * - **R1** : `setType: [{type:"isoDate"}, ...]` uniforme + `value: string` →
1434
+ * collapse en `setType: "isoDate"` (forme scalaire préférée backend).
1435
+ * - **R2** : `pull` présent + `value: ""|null|undefined` → force `value: ""`
1436
+ * (le backend exige `value: string` quand `pull` est présent — contrainte
1437
+ * schema `allOf`).
1438
+ * - **R6** : `setType: []` array vide → drop silencieusement (évite wire
1439
+ * format inutile, sécurise contre filtres qui ont tout retiré).
1440
+ *
1441
+ * Pour traiter une string ISO comme date, le caller doit passer `setType`
1442
+ * explicitement (`"isoDate"` ou `[{path, type:"isoDate"}]`). Pas d'heuristique
1443
+ * sur les strings pour éviter les faux positifs sur champs texte libre.
1444
+ *
1445
+ * @protected
1446
+ */
1447
+ protected _normalizeUpdatePathPayload(payload: UpdatePathValueData): UpdatePathValueData {
1448
+ let normalized = payload;
1449
+
1450
+ // R6 — drop setType array vide
1451
+ if (Array.isArray(normalized.setType) && normalized.setType.length === 0) {
1452
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1453
+ const { setType, ...rest } = normalized;
1454
+ normalized = rest as UpdatePathValueData;
1455
+ }
1456
+
1457
+ // R0 — Date instance → ISO + setType "isoDate"
1458
+ if (normalized.value instanceof Date) {
1459
+ normalized = {
1460
+ ...normalized,
1461
+ value: normalized.value.toISOString(),
1462
+ setType: "isoDate",
1463
+ };
1464
+ }
1465
+
1466
+ // R1 — setType array uniforme isoDate + value string → collapse scalaire
1467
+ const isIsoDateSetTypeArray =
1468
+ Array.isArray(normalized.setType)
1469
+ && normalized.setType.length > 0
1470
+ && normalized.setType.every(item => item?.type === "isoDate");
1471
+ if (typeof normalized.value === "string" && isIsoDateSetTypeArray) {
1472
+ normalized = { ...normalized, setType: "isoDate" };
1473
+ }
1474
+
1475
+ // R2 — pull + value empty → force ""
1476
+ if (normalized.pull
1477
+ && (normalized.value === "" || normalized.value === null || normalized.value === undefined)) {
1478
+ normalized = { ...normalized, value: "" };
1479
+ }
1480
+
1481
+ return normalized;
1482
+ }
1483
+
1228
1484
  /**
1229
1485
  * Valide les entrées d'upload de fichiers.
1230
1486
  *
@@ -2505,10 +2761,15 @@ export class BaseEntity<TServerData = any> {
2505
2761
  }
2506
2762
 
2507
2763
  /**
2508
- * Vérifie si l'utilisateur est admin via la hiérarchie parent (logique généraliste)
2509
- * @private
2764
+ * Vérifie si l'utilisateur est admin via la hiérarchie parent (logique généraliste).
2765
+ *
2766
+ * Note : passé `protected` pour permettre l'override par les entités dont la
2767
+ * hiérarchie n'est pas un simple champ `serverData.parent` ou `.organizer`
2768
+ * (cf. `Answer._isAdminViaHierarchy` qui remonte via son parent instance Form).
2769
+ *
2770
+ * @protected
2510
2771
  */
2511
- private _isAdminViaHierarchy(): boolean {
2772
+ protected _isAdminViaHierarchy(): boolean {
2512
2773
  const entityType = this.getEntityType();
2513
2774
  const entityData = this.serverData as any;
2514
2775
 
@@ -2545,10 +2806,14 @@ export class BaseEntity<TServerData = any> {
2545
2806
  }
2546
2807
 
2547
2808
  /**
2548
- * Vérifie si l'utilisateur est admin d'un parent spécifique
2549
- * @private
2809
+ * Vérifie si l'utilisateur est admin d'un parent spécifique.
2810
+ *
2811
+ * Note : passé `protected` pour être utilisable par les overrides de
2812
+ * `_isAdminViaHierarchy()` (cf. `Answer._isAdminViaHierarchy`).
2813
+ *
2814
+ * @protected
2550
2815
  */
2551
- private _isAdminOfParent(parentId: string, parentType: string): boolean {
2816
+ protected _isAdminOfParent(parentId: string, parentType: string): boolean {
2552
2817
  const userLinks = this.userContext?.serverData?.links;
2553
2818
  if (!userLinks) return false;
2554
2819
 
@@ -4152,10 +4417,13 @@ export class BaseEntity<TServerData = any> {
4152
4417
  *
4153
4418
  * @param options - Options de vérification.
4154
4419
  * @param options.silent - Si `true`, retourne `false` au lieu de lever une exception. Par défaut `true`.
4420
+ * @param options.checkHierarchy - Si `true`, propagé à `isAdmin` pour vérifier
4421
+ * également la hiérarchie parent (ex: `Answer.isAdmin({checkHierarchy: true})`
4422
+ * remonte vers org/project du Form parent).
4155
4423
  * @returns - `true` si l'utilisateur est l'auteur ou administrateur, `false` sinon.
4156
4424
  * @throws {ApiError} - Si `silent` est `false` et que les préconditions ne sont pas remplies.
4157
4425
  */
4158
- isAuthorOrAdmin(options?: { silent?: boolean }): boolean {
4426
+ isAuthorOrAdmin(options?: { silent?: boolean; checkHierarchy?: boolean }): boolean {
4159
4427
  return this.isAuthor() || this.isAdmin(options);
4160
4428
  }
4161
4429
 
@@ -5133,21 +5401,32 @@ export class BaseEntity<TServerData = any> {
5133
5401
  }
5134
5402
 
5135
5403
  /**
5136
- * Generer un nouveau identifiant unique pour un answers basé sur un formulaire donnée
5137
- *
5138
- * @param formId - Identifiant du formulaire pour lequel générer un nouvel ID d'answers
5139
- * @returns Un nouvel answers avec le nouveau ID généré
5140
- * @throws {ApiError} Si formId est absent ou invalide
5141
- *
5404
+ * Génère côté backend une nouvelle Answer placeholder rattachée au formulaire
5405
+ * donné (insert en BDD avec nouvel ObjectId) et retourne l'instance Answer
5406
+ * connectée (parent = `this`).
5407
+ *
5408
+ * **Note workflow** : l'Answer renvoyée est en état "placeholder" — pas de
5409
+ * `user` posé côté backend tant que `save()` n'a pas été appelé. Pour
5410
+ * récupérer plus tard via `coformAnswersById`, faire `save()` avec au moins
5411
+ * `answers` posés.
5412
+ *
5413
+ * @param formId - ID du formulaire (24 hex)
5414
+ * @returns Instance `Answer` connectée à `this` (parent = entité courante)
5415
+ * @throws {ApiError} 400 si `formId` est absent ou invalide
5416
+ *
5417
+ * @example
5418
+ * const org = await api.organization({ slug: "monOrga" });
5419
+ * const answer = await org.generateNewAnswerId(formId);
5420
+ * await answer.updateField(`${finder}.${org.id}`, { id: org.id, type: "organizations", name: org.serverData.name });
5142
5421
  */
5143
5422
  async generateNewAnswerId(
5144
5423
  formId: string
5145
- ): Promise<any> {
5424
+ ): Promise<Answer> {
5146
5425
  if (!formId || typeof formId !== "string") {
5147
5426
  throw new ApiError("formId est requis et doit être une chaîne de caractères.", 400);
5148
5427
  }
5149
5428
  const result = await this.endpointApi.generateAnswerFromForm({ pathParams: { formId }, action: "new" });
5150
- return this._linkEntity?.(result.collection, result) ?? result;
5429
+ return (this._linkEntity?.(result.collection, result) ?? result) as Answer;
5151
5430
  }
5152
5431
 
5153
5432
  /**
@@ -1,7 +1,7 @@
1
1
  import { ApiError } from "../error.js";
2
2
  import { BaseEntity } from "./BaseEntity.js";
3
3
 
4
- import type { AddCommentsData, AddReportAbuseData, AddVoteData, DeleteCommentsData, UpdateCommentsData } from "./EndpointApi.types.js";
4
+ import type { AddCommentsData, AddReportAbuseData, AddVoteData, DeleteCommentsData, ShowVoteData, UpdateCommentsData } from "./EndpointApi.types.js";
5
5
  import type { CommentItemNormalized } from "./serverDataType/Comment.js";
6
6
 
7
7
  export class Comment extends BaseEntity<CommentItemNormalized> {
@@ -222,6 +222,31 @@ export class Comment extends BaseEntity<CommentItemNormalized> {
222
222
  return await this.callIsConnected(() => this.endpointApi.addVote(payload));
223
223
  }
224
224
 
225
+ /**
226
+ * Récupère la liste des votes (like, love, etc.) sur ce commentaire.
227
+ *
228
+ * Wrap de `SHOW_VOTE` avec auto-injection du `type` (`"comments"`) et de
229
+ * `this.id` dans `pathParams`. Le retour contient `vote` (votes individuels
230
+ * indexés par userId) et `voteCount` (compteurs par statut).
231
+ *
232
+ * @returns Réponse API : `{ _id, vote, voteCount }` ou variante d'erreur.
233
+ * @throws {ApiError} 404 si le commentaire n'a pas d'id (non enregistré).
234
+ *
235
+ * @example
236
+ * const votes = await comment.getVotes();
237
+ * votes.voteCount?.like; // nombre de likes
238
+ * votes.vote?.[userId]; // vote détaillé d'un user
239
+ */
240
+ async getVotes(): Promise<unknown> {
241
+ if (!this.id) {
242
+ throw new ApiError(`${this.constructor.name} non enregistré.`, 404);
243
+ }
244
+ const payload: ShowVoteData = {
245
+ pathParams: { type: "comments", id: this.id },
246
+ };
247
+ return this.endpointApi.showVote(payload);
248
+ }
249
+
225
250
  /**
226
251
  * Signale un abus sur ce commentaire
227
252
  *