@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/dist/cocolight-api-client.browser.js +1 -1
- package/dist/cocolight-api-client.cjs +1 -1
- package/dist/cocolight-api-client.mjs.js +1 -1
- package/dist/cocolight-api-client.vite.mjs.js +1 -1
- package/dist/cocolight-api-client.vite.mjs.js.map +1 -1
- package/package.json +1 -1
- package/src/api/Answer.ts +391 -21
- package/src/api/BaseEntity.ts +54 -8
- package/src/api/serverDataType/Answer.ts +19 -0
- package/types/api/Answer.d.ts +195 -5
- package/types/api/BaseEntity.d.ts +45 -6
- package/types/api/serverDataType/Answer.d.ts +14 -0
package/package.json
CHANGED
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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>", ... } }`
|
package/src/api/BaseEntity.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|