@communecter/cocolight-api-client 1.0.133 → 1.0.135

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.133",
3
+ "version": "1.0.135",
4
4
  "description": "Client Axios simplifié pour l'API cocolight",
5
5
  "repository": {
6
6
  "type": "git",
package/src/api/Answer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BaseEntity } from "./BaseEntity.js";
2
2
  import { ApiError } from "../error.js";
3
3
 
4
- import type { CoformUploadAnswerFileData, DeleteElementData, SaveCoformAnswerData } from "./EndpointApi.types.js";
4
+ import type { CoformAnswersByIdData, CoformGetAnswerFilesData, CoformUploadAnswerFileData, DeleteElementData, SaveCoformAnswerData } from "./EndpointApi.types.js";
5
5
  import type { AnswerItemNormalized } from "./serverDataType/Answer.js";
6
6
  import type { FormItemNormalized } from "./serverDataType/Form.js";
7
7
 
@@ -45,6 +45,26 @@ export interface ProcessUploadsOptions {
45
45
  batchSize?: number;
46
46
  }
47
47
 
48
+ /**
49
+ * Options de `Answer.get()` — params optionnels du endpoint `COFORM_ANSWERS_BY_ID`.
50
+ */
51
+ export interface AnswerGetOptions {
52
+ /**
53
+ * Liste de champs à retourner côté backend (filtrage). Si absent, tous les
54
+ * champs sont renvoyés. Utile pour optimiser la taille de la réponse.
55
+ */
56
+ fields?: string[];
57
+ /**
58
+ * ID du formulaire parent. Utilisé côté backend pour calculer `canEdit` et
59
+ * `editDeniedReason` (droits d'édition contextuels). Si absent, **auto-injecté
60
+ * via `this.parent.id`** quand le parent est un `Form` (cas typique
61
+ * `form.answer({id})`).
62
+ */
63
+ formId?: string;
64
+ /** Chemin Finder (rarement utilisé). */
65
+ finderPath?: string;
66
+ }
67
+
48
68
  /**
49
69
  * Options pour `Answer.uploadFile()`. Tous les champs sont optionnels — les
50
70
  * défauts reproduisent les valeurs backend (`docType: "image"`, `contentKey: "slider"`).
@@ -83,6 +103,40 @@ export interface UploadAnswerFileResult {
83
103
  answerId: string;
84
104
  }
85
105
 
106
+ /**
107
+ * Options de `Answer.getFiles()`.
108
+ */
109
+ export interface GetFilesOptions {
110
+ /** Clé d'identification de l'input (ex: `"subFormId.inputId"`). Requise. */
111
+ subKey: string;
112
+ /** Type de document attendu. Défaut backend : `"file"`. */
113
+ docType?: "image" | "file";
114
+ /** Clé de contenu utilisée lors de l'upload. Défaut backend : `"presentation"`. */
115
+ contentKey?: string;
116
+ }
117
+
118
+ /**
119
+ * Élément renvoyé par `Answer.getFiles()` — un document attaché à l'Answer.
120
+ * Les champs `imagePath`/`imageThumbPath`/`imageMediumPath` ne sont présents
121
+ * que pour `docType: "image"`.
122
+ */
123
+ export interface AnswerFileItem {
124
+ /** ID MongoDB du document (utilisable pour suppression via `deleteDocumentById`). */
125
+ docId: string;
126
+ /** Chemin relatif du document côté backend. */
127
+ docPath: string;
128
+ /** Nom original du fichier. */
129
+ name?: string;
130
+ /** Taille en octets. */
131
+ size?: number;
132
+ /** Chemin de l'image (uniquement si `docType: "image"`). */
133
+ imagePath?: string | null;
134
+ /** Chemin du thumbnail (uniquement si `docType: "image"`). */
135
+ imageThumbPath?: string | null;
136
+ /** Chemin de la version medium (uniquement si `docType: "image"`). */
137
+ imageMediumPath?: string | null;
138
+ }
139
+
86
140
  export class Answer extends BaseEntity<AnswerItemNormalized> {
87
141
  static override entityType = "answers";
88
142
 
@@ -113,6 +167,79 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
113
167
  "voteCount", "vote", "project"
114
168
  ];
115
169
 
170
+ /**
171
+ * {@inheritDoc BaseEntity#isAuthor}
172
+ *
173
+ * Override Answer : le champ d'autorité est `serverData.user` (pas `creator`
174
+ * comme la version par défaut de BaseEntity). Le `user` peut être :
175
+ * - un `string` (ID MongoDB brut renvoyé par le backend)
176
+ * - une instance `User` (après `_transformServerData` qui le link en entité)
177
+ *
178
+ * Les pré-conditions (connexion, id, serverData) sont vérifiées comme dans
179
+ * la version de base — `silent: true` (défaut) renvoie `false` au lieu de throw.
180
+ */
181
+ /**
182
+ * {@inheritDoc BaseEntity#_isAdminViaHierarchy}
183
+ *
184
+ * Override Answer : la hiérarchie d'Answer passe par son **parent instance
185
+ * Form** (pas par `serverData.parent` direct comme pour `events`/`projects`/`poi`).
186
+ *
187
+ * Chaîne : `Answer → parent (Form) → form.serverData.parent ({[orgId]: {type, name}}) → check userContext.links`.
188
+ *
189
+ * Si le parent n'est pas un Form (cas `api.answer({id})` direct sans contexte),
190
+ * renvoie `false` — impossible de vérifier la hiérarchie sans le Form.
191
+ *
192
+ * @protected
193
+ */
194
+ protected override _isAdminViaHierarchy(): boolean {
195
+ const form = this.parent;
196
+ if (!form || typeof (form as any).getEntityType !== "function"
197
+ || (form as any).getEntityType() !== "forms") {
198
+ return false;
199
+ }
200
+
201
+ const formParents = (form as { serverData?: { parent?: Record<string, unknown> } }).serverData?.parent;
202
+ if (!formParents || typeof formParents !== "object") {
203
+ return false;
204
+ }
205
+
206
+ for (const [parentId, parentRef] of Object.entries(formParents)) {
207
+ if (!parentRef || typeof parentRef !== "object") continue;
208
+ const ref = parentRef as { type?: string };
209
+ if (!ref.type) continue;
210
+ if (this._isAdminOfParent(parentId, ref.type)) {
211
+ return true;
212
+ }
213
+ }
214
+ return false;
215
+ }
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
+
116
243
  /**
117
244
  * Transforme les champs imbriqués (user, context etc.) en instances d'entités.
118
245
  * @param data - Les données brutes du serveur.
@@ -126,33 +253,61 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
126
253
  data.user = this._linkNestedEntity({ type: "citoyens", collection: "citoyens", ...data.user });
127
254
  }
128
255
 
129
- // Transformer context en instance Organization/Project
130
- // if (data.context && typeof data.context === "object" && !("id" in data.context)) {
131
- // // context est un ParentsMap: { "id1": {type: "organizations", name: "..."}, "id2": {...} }
132
- // // On transforme chaque entrée en instance d'entité
133
- // const transformedContext: Record<string, any> = {};
134
- // for (const [id, parentRef] of Object.entries(data.context)) {
135
- // if (parentRef && typeof parentRef === "object" && "type" in parentRef) {
136
- // transformedContext[id] = this._linkNestedEntity({ id, type: parentRef.type });
137
- // }
138
- // }
139
- // data.context = transformedContext;
140
- // }
256
+ // Normalisation legacy uploader : si parent Form connu, convertit les valeurs
257
+ // d'inputs `*.uploader` du format Array `[{docId, docPath}]` vers le format
258
+ // canonique `{updateDate, files: {docId: docPath}}`. Symétrique de ce que
259
+ // `processUploads()` écrit. Si parent pas Form → skip (pas de schéma pour
260
+ // identifier les inputs uploader).
261
+ const formData = this._resolveFormData();
262
+ if (formData && data.answers && typeof data.answers === "object") {
263
+ data.answers = this._normalizeLegacyUploaderInAnswers(
264
+ data.answers as Record<string, Record<string, unknown>>,
265
+ formData,
266
+ );
267
+ }
141
268
 
142
269
  return data;
143
270
  }
144
271
 
145
272
  /**
146
- * Rafraîchit les données de l'entité depuis l'API.
147
- * Constant : COFORM_ANSWERS_BY_ID
148
- */
149
- override async get(): Promise<Record<string, any>> {
273
+ * Rafraîchit les données de l'entité depuis l'API (`COFORM_ANSWERS_BY_ID`).
274
+ *
275
+ * **Auto-injection du `formId`** : si le parent est un `Form` (cas typique
276
+ * `form.answer({id})`), le param `formId` est automatiquement envoyé pour
277
+ * que le backend calcule `canEdit` et `editDeniedReason` dans la réponse.
278
+ * Le caller peut override via `opts.formId` ou couper avec `opts.formId: ""`.
279
+ *
280
+ * @param opts - Params optionnels :
281
+ * - `fields` : filtrage des champs côté backend
282
+ * - `formId` : override de l'auto-injection (utile pour disable ou forcer un autre form)
283
+ * - `finderPath` : chemin Finder (rare)
284
+ *
285
+ * @example
286
+ * // Cas commun : form.answer({id}) → get() auto avec formId
287
+ * const answer = await form.answer({ id: answerId });
288
+ * answer.serverData.canEdit; // true/false calculé backend
289
+ * answer.serverData.editDeniedReason; // raison si false
290
+ *
291
+ * @example
292
+ * // Cas avancé : filtrage de champs pour optim perf
293
+ * const answer = await form.answer();
294
+ * answer._id(answerId);
295
+ * await answer.get({ fields: ["answers", "canEdit", "editDeniedReason"] });
296
+ */
297
+ override async get(opts: AnswerGetOptions = {}): Promise<Record<string, any>> {
150
298
  if (!this.id) throw new ApiError("Impossible de rafraîchir sans ID.", 400);
151
299
  const answerId = this.id; // Type narrowing
152
300
 
153
- const answer = await this.endpointApi.coformAnswersById({
154
- answerId
155
- });
301
+ // Auto-injection du formId via parent Form (sauf si caller a explicitement
302
+ // passé opts.formId — même chaîne vide pour disable).
303
+ const formId = "formId" in opts ? opts.formId : this._autoFormIdFromParent();
304
+
305
+ const payload: CoformAnswersByIdData = { answerId };
306
+ if (opts.fields && opts.fields.length > 0) payload.fields = opts.fields;
307
+ if (typeof formId === "string" && formId.length > 0) payload.formId = formId;
308
+ if (opts.finderPath) payload.finderPath = opts.finderPath;
309
+
310
+ const answer = await this.endpointApi.coformAnswersById(payload);
156
311
 
157
312
  if (answer?.data?.id) {
158
313
  this._setData(answer.data as AnswerItemNormalized, { forceInitialDraftReset: true });
@@ -161,6 +316,20 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
161
316
  throw new ApiError(`Aucune réponse trouvée pour l'ID ${this.id}`, 404);
162
317
  }
163
318
 
319
+ /**
320
+ * Récupère l'ID du parent Form si applicable, pour l'auto-injection dans `get()`.
321
+ * @private
322
+ */
323
+ private _autoFormIdFromParent(): string | undefined {
324
+ const parent = this.parent;
325
+ if (parent && typeof (parent as any).getEntityType === "function"
326
+ && (parent as any).getEntityType() === "forms"
327
+ && typeof (parent as any).id === "string") {
328
+ return (parent as any).id as string;
329
+ }
330
+ return undefined;
331
+ }
332
+
164
333
  override async form(): Promise<never> {
165
334
  throw new ApiError(`form n'existe pas dans ${this.constructor.name}`, 501);
166
335
  }
@@ -173,12 +342,26 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
173
342
  * - `serverData` et `draftData` sont vidés (sans casser la réactivité)
174
343
  * - `_isDeleted = true` → toute mutation ultérieure (`save`, `get`, etc.) throw
175
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
+ *
176
358
  * @param reason - Raison libre transmise au backend (audit). Défaut : `"delete coform answer"`.
177
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.
178
361
  *
179
362
  * @example
180
363
  * const answer = await form.answer({ id: "..." });
181
- * await answer.delete();
364
+ * await answer.delete(); // OK si auteur OU admin org/project parent du Form
182
365
  * answer._isDeleted; // true
183
366
  */
184
367
  async delete(reason: string = "delete coform answer"): Promise<void> {
@@ -186,6 +369,15 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
186
369
  throw new ApiError("Vous devez fournir un id pour supprimer une Answer.", 400);
187
370
  }
188
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
+
189
381
  const data: DeleteElementData = {
190
382
  reason,
191
383
  pathParams: { type: "answers", id: this.id },
@@ -200,9 +392,141 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
200
392
  this._isDeleted = true;
201
393
  }
202
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
+
443
+ /**
444
+ * Récupère la liste des fichiers attachés à un input précis de cette Answer
445
+ * via `COFORM_GET_ANSWER_FILES`.
446
+ *
447
+ * **Cas d'usage principal** : récupérer les fichiers d'inputs uploader stockés
448
+ * en **format legacy** `{updateDate: [...]}` (sans la clé `files`). Le backend
449
+ * recherche alors les documents MongoDB liés à `{answerId, subKey, docType}`.
450
+ *
451
+ * Pour les Answer en format moderne `{updateDate, files: {<docId>: <path>}}`,
452
+ * les fichiers sont déjà dans `answer.serverData.answers[subFormId][inputId].files`
453
+ * — pas besoin d'appel réseau.
454
+ *
455
+ * @param opts - `subKey` (requis, ex: `"subFormId.inputId"`), `docType`, `contentKey`
456
+ * @returns Liste normalisée des fichiers `{docId, docPath, name?, size?, imagePath?, imageThumbPath?, imageMediumPath?}`.
457
+ * Tableau vide si aucun fichier trouvé.
458
+ * @throws {ApiError} 400 si pas d'id Answer. Throws aussi si le backend renvoie `result: false`.
459
+ *
460
+ * @example
461
+ * const form = await org.form({ id: formId });
462
+ * const answer = await form.answer({ id: answerId });
463
+ * const files = await answer.getFiles({
464
+ * subKey: `${subFormId}.${inputId}`,
465
+ * docType: "image",
466
+ * });
467
+ * files[0].docId; // ID du document (pour suppression future)
468
+ * files[0].docPath; // Chemin relatif (à concaténer avec baseURL pour affichage)
469
+ */
470
+ async getFiles(opts: GetFilesOptions): Promise<AnswerFileItem[]> {
471
+ if (!this.id) {
472
+ throw new ApiError("Answer sans id, impossible de lister ses fichiers.", 400);
473
+ }
474
+ if (!opts.subKey || opts.subKey.length === 0) {
475
+ throw new ApiError("`subKey` requis pour lister les fichiers d'un input.", 400);
476
+ }
477
+
478
+ const payload: CoformGetAnswerFilesData = {
479
+ answerId: this.id,
480
+ subKey: opts.subKey,
481
+ };
482
+ if (opts.docType) payload.docType = opts.docType;
483
+ if (opts.contentKey) payload.contentKey = opts.contentKey;
484
+
485
+ // Forme observée du backend (cf. site-json useCoFormAnswerFiles.tsx
486
+ // et schema COFORM_GET_ANSWER_FILES) :
487
+ // { result, answerId, subKey, contentKey, count, files: [{ id, name, size,
488
+ // docPath, imagePath, imageThumbPath, imageMediumPath }] }
489
+ const response = await this.callIsConnected(() =>
490
+ this.endpointApi.coformGetAnswerFiles(payload)
491
+ ) as {
492
+ result?: boolean;
493
+ msg?: string;
494
+ count?: number;
495
+ files?: Array<{
496
+ id?: string | null;
497
+ name?: string | null;
498
+ size?: number | null;
499
+ docPath?: string;
500
+ imagePath?: string | null;
501
+ imageThumbPath?: string | null;
502
+ imageMediumPath?: string | null;
503
+ }>;
504
+ };
505
+
506
+ // Filtre les entrées invalides (id + docPath requis) et normalise.
507
+ return (response.files ?? [])
508
+ .filter(f => typeof f.id === "string" && typeof f.docPath === "string")
509
+ .map(f => ({
510
+ docId: f.id!,
511
+ docPath: f.docPath!,
512
+ name: f.name ?? undefined,
513
+ size: f.size ?? undefined,
514
+ imagePath: f.imagePath ?? undefined,
515
+ imageThumbPath: f.imageThumbPath ?? undefined,
516
+ imageMediumPath: f.imageMediumPath ?? undefined,
517
+ }));
518
+ }
519
+
203
520
  /**
204
521
  * Upload un fichier rattaché à cette Answer via `COFORM_UPLOAD_ANSWER_FILE`.
205
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
+ *
206
530
  * **Cas premier upload (Answer sans id)** : le backend crée l'Answer et
207
531
  * renvoie `answerId`. La méthode pose alors `this._draftData.id = answerId`
208
532
  * — les uploads suivants utiliseront cet id, et un `save()` ultérieur passera
@@ -738,6 +1062,52 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
738
1062
  return obj;
739
1063
  }
740
1064
 
1065
+ /**
1066
+ * Walk les `answers[subFormId][inputId]` pour normaliser les valeurs des
1067
+ * inputs `*.uploader` vers le format canonique `{updateDate, files: {docId: docPath}}`.
1068
+ *
1069
+ * Utilisé par `_transformServerData()` pour harmoniser les Answer en format
1070
+ * legacy Array `[{docId, docPath}]` avec le format moderne (ce que
1071
+ * `processUploads()` écrit).
1072
+ *
1073
+ * Skip si le schéma `formData` n'a pas l'inputType pour un champ donné.
1074
+ * @private
1075
+ */
1076
+ private _normalizeLegacyUploaderInAnswers(
1077
+ answers: Record<string, Record<string, unknown>>,
1078
+ formData: FormItemNormalized,
1079
+ ): Record<string, Record<string, unknown>> {
1080
+ const normalized: Record<string, Record<string, unknown>> = {};
1081
+
1082
+ for (const [subFormId, subFormData] of Object.entries(answers)) {
1083
+ if (!this._isObjectRecord(subFormData)) {
1084
+ normalized[subFormId] = subFormData as any;
1085
+ continue;
1086
+ }
1087
+
1088
+ const subForm = formData.inputs?.[subFormId];
1089
+ if (!subForm) {
1090
+ normalized[subFormId] = subFormData;
1091
+ continue;
1092
+ }
1093
+
1094
+ const cleanedSubForm: Record<string, unknown> = {};
1095
+ for (const [inputId, inputValue] of Object.entries(subFormData)) {
1096
+ const input = subForm.inputs?.[inputId];
1097
+ const inputType = input && typeof input === "object" && "type" in input
1098
+ ? (input.type as string | undefined)
1099
+ : undefined;
1100
+
1101
+ cleanedSubForm[inputId] = inputType?.endsWith(".uploader")
1102
+ ? this._normalizeUploaderValue(inputValue)
1103
+ : inputValue;
1104
+ }
1105
+ normalized[subFormId] = cleanedSubForm;
1106
+ }
1107
+
1108
+ return normalized;
1109
+ }
1110
+
741
1111
  /**
742
1112
  * Normalise une valeur d'input uploader vers le format legacy backend :
743
1113
  * `{ updateDate: ["DD/MM/YYYY"], files: { "<docId>": "<docPath>", ... } }`
@@ -58,7 +58,8 @@ import type {
58
58
  LinkMediawikiAccountData,
59
59
  UnlinkMediawikiAccountData,
60
60
  GetMediawikiContributionsData,
61
- CoremuOperationData
61
+ CoremuOperationData,
62
+ DeleteDocumentByIdData
62
63
  } from "./EndpointApi.types.js";
63
64
  import type { GetElementsKeyResponse } from "../types/api-responses.js";
64
65
  import type { TransformsMap } from "../types/entities.js";
@@ -1225,6 +1226,39 @@ export class BaseEntity<TServerData = any> {
1225
1226
  throw new ApiError("Type de fichier non reconnu pour l'upload.", 400);
1226
1227
  }
1227
1228
 
1229
+ /**
1230
+ * Supprime un document (fichier ou image) par son `docId` MongoDB via
1231
+ * `DELETE_DOCUMENT_BY_ID`.
1232
+ *
1233
+ * Méthode générique exposée sur toutes les entités — le endpoint backend
1234
+ * opère par docId seul, sans contexte parent requis. Disponible ici pour
1235
+ * être découvrable et **override-able** par les entités qui veulent un
1236
+ * cleanup local après suppression (ex: `News.deleteFile` pourrait retirer
1237
+ * l'id de `mediaImg.images` côté serverData).
1238
+ *
1239
+ * Ne met PAS à jour `serverData` après suppression — le caller doit
1240
+ * invalider son cache local s'il en a un (cas typique : React Query).
1241
+ *
1242
+ * @param docId - ID MongoDB du document (24 hex)
1243
+ * @throws {ApiError} 400 si `docId` invalide.
1244
+ *
1245
+ * @example
1246
+ * // Depuis une Answer (suppression d'un fichier uploader)
1247
+ * const files = await answer.getFiles({ subKey: "sf.input", docType: "image" });
1248
+ * await answer.deleteFile(files[0].docId);
1249
+ *
1250
+ * @example
1251
+ * // Depuis n'importe quelle entité qui possède un docId
1252
+ * await news.deleteFile(imageDocId);
1253
+ */
1254
+ async deleteFile(docId: string): Promise<void> {
1255
+ if (typeof docId !== "string" || docId.length !== 24) {
1256
+ throw new ApiError("docId requis (24 hex) pour supprimer un document.", 400);
1257
+ }
1258
+ const payload: DeleteDocumentByIdData = { pathParams: { id: docId } };
1259
+ await this.callIsConnected(() => this.endpointApi.deleteDocumentById(payload));
1260
+ }
1261
+
1228
1262
  /**
1229
1263
  * Valide les entrées d'upload de fichiers.
1230
1264
  *
@@ -2505,10 +2539,15 @@ export class BaseEntity<TServerData = any> {
2505
2539
  }
2506
2540
 
2507
2541
  /**
2508
- * Vérifie si l'utilisateur est admin via la hiérarchie parent (logique généraliste)
2509
- * @private
2542
+ * Vérifie si l'utilisateur est admin via la hiérarchie parent (logique généraliste).
2543
+ *
2544
+ * Note : passé `protected` pour permettre l'override par les entités dont la
2545
+ * hiérarchie n'est pas un simple champ `serverData.parent` ou `.organizer`
2546
+ * (cf. `Answer._isAdminViaHierarchy` qui remonte via son parent instance Form).
2547
+ *
2548
+ * @protected
2510
2549
  */
2511
- private _isAdminViaHierarchy(): boolean {
2550
+ protected _isAdminViaHierarchy(): boolean {
2512
2551
  const entityType = this.getEntityType();
2513
2552
  const entityData = this.serverData as any;
2514
2553
 
@@ -2545,10 +2584,14 @@ export class BaseEntity<TServerData = any> {
2545
2584
  }
2546
2585
 
2547
2586
  /**
2548
- * Vérifie si l'utilisateur est admin d'un parent spécifique
2549
- * @private
2587
+ * Vérifie si l'utilisateur est admin d'un parent spécifique.
2588
+ *
2589
+ * Note : passé `protected` pour être utilisable par les overrides de
2590
+ * `_isAdminViaHierarchy()` (cf. `Answer._isAdminViaHierarchy`).
2591
+ *
2592
+ * @protected
2550
2593
  */
2551
- private _isAdminOfParent(parentId: string, parentType: string): boolean {
2594
+ protected _isAdminOfParent(parentId: string, parentType: string): boolean {
2552
2595
  const userLinks = this.userContext?.serverData?.links;
2553
2596
  if (!userLinks) return false;
2554
2597
 
@@ -4152,10 +4195,13 @@ export class BaseEntity<TServerData = any> {
4152
4195
  *
4153
4196
  * @param options - Options de vérification.
4154
4197
  * @param options.silent - Si `true`, retourne `false` au lieu de lever une exception. Par défaut `true`.
4198
+ * @param options.checkHierarchy - Si `true`, propagé à `isAdmin` pour vérifier
4199
+ * également la hiérarchie parent (ex: `Answer.isAdmin({checkHierarchy: true})`
4200
+ * remonte vers org/project du Form parent).
4155
4201
  * @returns - `true` si l'utilisateur est l'auteur ou administrateur, `false` sinon.
4156
4202
  * @throws {ApiError} - Si `silent` est `false` et que les préconditions ne sont pas remplies.
4157
4203
  */
4158
- isAuthorOrAdmin(options?: { silent?: boolean }): boolean {
4204
+ isAuthorOrAdmin(options?: { silent?: boolean; checkHierarchy?: boolean }): boolean {
4159
4205
  return this.isAuthor() || this.isAdmin(options);
4160
4206
  }
4161
4207
 
@@ -64,6 +64,17 @@ export interface AnswerItemJson {
64
64
  [key: string]: unknown;
65
65
  }
66
66
 
67
+ /**
68
+ * Raisons de refus d'édition côté backend, calculées par `coformAnswersById`
69
+ * et présentes dans la réponse normalisée. Utilisé par les UI pour distinguer
70
+ * les modes readonly/edit et afficher des messages contextuels.
71
+ */
72
+ export type AnswerEditDeniedReason =
73
+ | "not_logged_in"
74
+ | "not_owner"
75
+ | "form_inactive"
76
+ | "form_closed";
77
+
67
78
  export interface AnswerItemNormalized {
68
79
  id: string;
69
80
  _id: ObjectID;
@@ -73,6 +84,7 @@ export interface AnswerItemNormalized {
73
84
  user?: string | User;
74
85
  links?: AnswerLinksBlock;
75
86
  draft?: boolean;
87
+ finished?: boolean;
76
88
  answers?: Record<string, unknown>;
77
89
  context?: ParentsMap | Record<string, Organization | Project>;
78
90
  form?: string;
@@ -83,5 +95,12 @@ export interface AnswerItemNormalized {
83
95
  date: Date;
84
96
  }>;
85
97
  project?: AnswerProjectRef;
98
+ /**
99
+ * L'utilisateur courant peut-il éditer cette réponse ? Calculé côté backend.
100
+ * Présent par défaut, ou enrichi avec le contexte si `formId` est fourni.
101
+ */
102
+ canEdit?: boolean;
103
+ /** Raison si `canEdit === false`. `null` si `canEdit === true`. */
104
+ editDeniedReason?: AnswerEditDeniedReason | null;
86
105
  [key: string]: unknown;
87
106
  }