@communecter/cocolight-api-client 1.0.132 → 1.0.133

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/src/api/Answer.ts CHANGED
@@ -1,22 +1,117 @@
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
5
  import type { AnswerItemNormalized } from "./serverDataType/Answer.js";
6
+ import type { FormItemNormalized } from "./serverDataType/Form.js";
7
+
8
+ type UploadInput = File | Blob | Buffer | import("stream").Readable;
9
+
10
+ /**
11
+ * Format des données utilisateur d'un CoForm — `{ subFormId: { inputId: value } }`.
12
+ * Générique : la lib ne fait aucune supposition sur le type des valeurs.
13
+ */
14
+ export type AllStepsData = Record<string, Record<string, unknown>>;
15
+
16
+ /**
17
+ * Valeur d'un upload en attente — objet `{ name?, data: "data:...;base64,..." }`
18
+ * inséré par les inputs uploader avant le pré-upload backend.
19
+ */
20
+ export interface PendingUploadValue {
21
+ name?: string;
22
+ data: string;
23
+ }
24
+
25
+ /**
26
+ * Un pending upload détecté dans la structure `allData` — chemin imbriqué +
27
+ * type d'input dérivé du schéma (`formData.inputs[subFormId].inputs[inputId].type`).
28
+ */
29
+ export interface PendingUpload {
30
+ value: PendingUploadValue;
31
+ path: (string | number)[];
32
+ inputType?: string;
33
+ }
34
+
35
+ /**
36
+ * Options de `Answer.processUploads()`.
37
+ */
38
+ export interface ProcessUploadsOptions {
39
+ /**
40
+ * Schéma du Form (pour déduire `inputType` des champs uploader/simpleTable).
41
+ * Auto-récupéré via `this.parent.serverData` si parent est un Form.
42
+ */
43
+ formData?: FormItemNormalized;
44
+ /** Nombre d'uploads parallèles par batch. Défaut : 4. */
45
+ batchSize?: number;
46
+ }
47
+
48
+ /**
49
+ * Options pour `Answer.uploadFile()`. Tous les champs sont optionnels — les
50
+ * défauts reproduisent les valeurs backend (`docType: "image"`, `contentKey: "slider"`).
51
+ */
52
+ export interface UploadAnswerFileOptions {
53
+ /** Type de document : `image` (défaut) ou `file` (PDF, DOCX, etc.). */
54
+ docType?: "image" | "file";
55
+ /** Clé de contenu côté backend. `"slider"` (défaut) pour images, `"presentation"` pour inputs uploader. */
56
+ contentKey?: string;
57
+ /** Sous-clé optionnelle (ex: `"subFormId.inputId"` pour les inputs `*.uploader`). */
58
+ subKey?: string;
59
+ /** UUID client (compat fine-uploader). Auto-généré si absent. */
60
+ qquuid?: string;
61
+ /** Nom du fichier. Auto-déduit (File.name, stream.path, ou fallback) si absent. */
62
+ qqfilename?: string;
63
+ /**
64
+ * Taille du fichier en octets. Auto-déduite pour `File` (`.size`), `Blob`
65
+ * (`.size`), `Buffer` (`.length`), ou `Readable` annoté (`.size`).
66
+ *
67
+ * **À fournir explicitement pour un `Readable` non annoté** (ex:
68
+ * `fs.createReadStream(path)` → faire `fs.statSync(path).size` au préalable),
69
+ * sinon `0` est envoyé et le backend peut rejeter le multipart.
70
+ */
71
+ qqtotalfilesize?: number;
72
+ }
73
+
74
+ /**
75
+ * Retour normalisé de `Answer.uploadFile()`.
76
+ */
77
+ export interface UploadAnswerFileResult {
78
+ /** ID du document MongoDB (à stocker dans `{docId, docPath}` pour les inputs uploader). */
79
+ docId: string;
80
+ /** Chemin du document. URL absolue côté backend, à nettoyer en chemin relatif si besoin (cf. `cleanUploaderUrls` site-json). */
81
+ docPath: string;
82
+ /** ID de l'Answer (créée par cet upload si premier, sinon `= this.id`). */
83
+ answerId: string;
84
+ }
5
85
 
6
86
  export class Answer extends BaseEntity<AnswerItemNormalized> {
7
87
  static override entityType = "answers";
8
88
 
9
89
  static override entityTag = "Answer";
10
90
  static override SCHEMA_CONSTANTS: string[] = [
91
+ "SAVE_COFORM_ANSWER"
11
92
  ];
12
93
 
13
- static ADD_BLOCKS: Map<string, string> = new Map([
14
- ]);
15
- static UPDATE_BLOCKS: Map<string, string> = new Map([
16
- ]);
94
+ static ADD_BLOCKS = new Map([
95
+ ["SAVE_COFORM_ANSWER", "saveCoformAnswer"]
96
+ ] as const);
97
+
98
+ static UPDATE_BLOCKS = new Map([
99
+ ["SAVE_COFORM_ANSWER", "saveCoformAnswer"]
100
+ ] as const);
101
+
17
102
  override defaultFields: Record<string, any> = {
18
103
  };
19
- override removeFields: string[] = [];
104
+
105
+ /**
106
+ * Champs `serverData` à ne pas renvoyer au serveur dans le payload de save.
107
+ * `answers`, `addedOptions`, `links`, `form` sont conservés (envoyés au serveur).
108
+ * Les autres sont calculés/posés par le backend.
109
+ */
110
+ override removeFields: string[] = [
111
+ "_id", "collection", "user", "created", "updated",
112
+ "modified", "draft", "finished", "context",
113
+ "voteCount", "vote", "project"
114
+ ];
20
115
 
21
116
  /**
22
117
  * Transforme les champs imbriqués (user, context etc.) en instances d'entités.
@@ -69,4 +164,746 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
69
164
  override async form(): Promise<never> {
70
165
  throw new ApiError(`form n'existe pas dans ${this.constructor.name}`, 501);
71
166
  }
167
+
168
+ /**
169
+ * Supprime cette Answer côté serveur via `DELETE_ELEMENT`
170
+ * (`type=answers`, `id=this.id`).
171
+ *
172
+ * Après succès :
173
+ * - `serverData` et `draftData` sont vidés (sans casser la réactivité)
174
+ * - `_isDeleted = true` → toute mutation ultérieure (`save`, `get`, etc.) throw
175
+ *
176
+ * @param reason - Raison libre transmise au backend (audit). Défaut : `"delete coform answer"`.
177
+ * @throws {ApiError} 400 si l'Answer n'a pas d'id (jamais sauvegardée).
178
+ *
179
+ * @example
180
+ * const answer = await form.answer({ id: "..." });
181
+ * await answer.delete();
182
+ * answer._isDeleted; // true
183
+ */
184
+ async delete(reason: string = "delete coform answer"): Promise<void> {
185
+ if (!this.id) {
186
+ throw new ApiError("Vous devez fournir un id pour supprimer une Answer.", 400);
187
+ }
188
+
189
+ const data: DeleteElementData = {
190
+ reason,
191
+ pathParams: { type: "answers", id: this.id },
192
+ };
193
+
194
+ await this.callIsConnected(() => this.endpointApi.deleteElement(data));
195
+
196
+ // Vider les objets réactifs sans casser la réactivité (cf. Comment.delete)
197
+ Object.keys(this._draftData).forEach(key => delete this._draftData[key]);
198
+ Object.keys(this._serverData).forEach(key => delete (this._serverData as any)[key]);
199
+
200
+ this._isDeleted = true;
201
+ }
202
+
203
+ /**
204
+ * Upload un fichier rattaché à cette Answer via `COFORM_UPLOAD_ANSWER_FILE`.
205
+ *
206
+ * **Cas premier upload (Answer sans id)** : le backend crée l'Answer et
207
+ * renvoie `answerId`. La méthode pose alors `this._draftData.id = answerId`
208
+ * — les uploads suivants utiliseront cet id, et un `save()` ultérieur passera
209
+ * en update.
210
+ *
211
+ * **Cas uploads suivants (Answer avec id)** : `answerId: this.id` est envoyé.
212
+ *
213
+ * Compatible browser (File/Blob) et Node (Buffer/Readable) via
214
+ * `BaseEntity._prepareUploadFile()` — le multipart encoder du package
215
+ * `form-data` (utilisé en Node) ignore les Buffer bruts, donc conversion en
216
+ * stream annoté.
217
+ *
218
+ * @param file - Fichier à uploader (File/Blob côté browser, Buffer/Readable côté Node)
219
+ * @param opts - `docType`, `contentKey`, `subKey`, `qquuid`, `qqfilename`
220
+ * @returns `{ docId, docPath, answerId }` — toujours fournis par le backend.
221
+ * @throws {ApiError} si `formId` introuvable, ou réponse invalide.
222
+ *
223
+ * @example
224
+ * // Browser (depuis un <input type="file">)
225
+ * const form = await org.form({ id: formId });
226
+ * const answer = await form.answer(); // draft
227
+ * const { docId, docPath } = await answer.uploadFile(htmlFile, {
228
+ * contentKey: "presentation",
229
+ * subKey: `${subFormId}.${inputId}`,
230
+ * });
231
+ * // answer.id désormais défini → uploads suivants utilisent cet id.
232
+ *
233
+ * @example
234
+ * // Node (depuis un Buffer)
235
+ * const buffer = await fs.promises.readFile("./photo.jpg");
236
+ * const result = await answer.uploadFile(buffer, { docType: "image" });
237
+ */
238
+ async uploadFile(
239
+ file: UploadInput,
240
+ opts: UploadAnswerFileOptions = {},
241
+ ): Promise<UploadAnswerFileResult> {
242
+ const formId = this._resolveFormId({});
243
+
244
+ const fallbackName = opts.qqfilename ?? `upload-${Date.now()}`;
245
+ const { qqfile, qqfilename, qqtotalfilesize } =
246
+ await this._prepareUploadFile(file, fallbackName);
247
+
248
+ const payload: CoformUploadAnswerFileData = {
249
+ formId,
250
+ docType: opts.docType ?? "image",
251
+ contentKey: opts.contentKey ?? "slider",
252
+ qquuid: opts.qquuid ?? this._generateUuid(),
253
+ qqfilename: opts.qqfilename ?? qqfilename,
254
+ // Priorité : override caller > taille déduite (File/Blob/Buffer/Readable.size)
255
+ qqtotalfilesize: opts.qqtotalfilesize ?? qqtotalfilesize,
256
+ qqfile: qqfile as unknown as Record<string, unknown>,
257
+ };
258
+
259
+ if (opts.subKey) payload.subKey = opts.subKey;
260
+ if (this.id) payload.answerId = this.id;
261
+
262
+ const response = await this.callIsConnected(() =>
263
+ this.endpointApi.coformUploadAnswerFile(payload)
264
+ ) as {
265
+ result?: boolean;
266
+ success?: boolean;
267
+ msg?: string;
268
+ answerId?: string;
269
+ docPath?: string;
270
+ id?: string;
271
+ };
272
+
273
+ // Forme observée (cf. upload-answer-shape.test.ts) :
274
+ // { result, success, answerId, docPath, id, name, size, msg, _id }
275
+ if (response?.result !== true && response?.success !== true) {
276
+ throw new ApiError(response?.msg || "Échec de l'upload coform.", 500);
277
+ }
278
+ if (!response.answerId || !response.docPath) {
279
+ throw new ApiError("Réponse d'upload incomplète (answerId ou docPath manquant).", 500);
280
+ }
281
+
282
+ // Pose this.id si premier upload (Answer auto-créée par le backend)
283
+ if (!this.id) {
284
+ this._draftData.id = response.answerId;
285
+ }
286
+
287
+ return {
288
+ docId: response.id ?? "",
289
+ docPath: response.docPath,
290
+ answerId: response.answerId,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Orchestre l'upload de tous les fichiers pendants (`data:URI`) trouvés dans
296
+ * `allData`, puis renvoie la structure transformée prête pour `answer.save()`.
297
+ *
298
+ * **Pipeline complet** :
299
+ * 1. Walk récursif de `allData` → collecte les `data:URI` avec leur path + inputType
300
+ * 2. Premier upload (si Answer sans id) → backend crée l'Answer + pose `this.id`
301
+ * 3. Uploads restants en batches parallèles (défaut 4)
302
+ * 4. Remplacement dans la structure :
303
+ * - inputs `*.uploader` → `{ docId, docPath }`
304
+ * - autres → `docPath` string
305
+ * 5. Normalisation legacy uploader : `Array<{docId, docPath}>` → `{ updateDate, files }`
306
+ * 6. Nettoyage URLs absolues → chemins relatifs (uploader/simpleTable uniquement)
307
+ *
308
+ * Ne touche PAS `addedOptions` ni `links` — caller les pose séparément avant `save()`.
309
+ *
310
+ * @param allData - Données utilisateur `{ subFormId: { inputId: value } }`
311
+ * @param options - `formData` (auto via parent Form), `batchSize` (défaut 4)
312
+ * @returns `allData` transformé, prêt à être affecté à `answer.data.answers`
313
+ *
314
+ * @example
315
+ * const form = await org.form({ id: formId });
316
+ * const answer = await form.answer(); // ou form.answer({ id }) en édition
317
+ *
318
+ * const prepared = await answer.processUploads(allStepsDataFromForm);
319
+ * answer.data.answers = prepared;
320
+ * answer.data.addedOptions = addedOptions; // optionnel
321
+ * answer.data.links = finderLinks; // optionnel
322
+ * await answer.save();
323
+ */
324
+ async processUploads(
325
+ allData: AllStepsData,
326
+ options: ProcessUploadsOptions = {},
327
+ ): Promise<AllStepsData> {
328
+ // Résoudre formData : argument > parent Form
329
+ const formData = options.formData ?? this._resolveFormData();
330
+
331
+ // 1. Collecter tous les pending uploads
332
+ const pending = this._collectPendingUploads(allData, [], formData);
333
+
334
+ if (pending.length === 0) {
335
+ // Pas de fichiers : on nettoie quand même les URLs absolues côté uploader
336
+ return this._cleanUploaderUrls(allData, formData);
337
+ }
338
+
339
+ let prepared = allData;
340
+ let startIndex = 0;
341
+
342
+ // 2. Premier upload (séparé) si pas d'id — pour récupérer answerId
343
+ if (!this.id) {
344
+ const first = pending[0];
345
+ const replaced = await this._uploadAndReplace(first, prepared, 1);
346
+ prepared = replaced;
347
+ startIndex = 1;
348
+ }
349
+
350
+ // 3. Uploads restants en batches parallèles
351
+ if (startIndex < pending.length) {
352
+ const remaining = pending.slice(startIndex);
353
+ const batchSize = options.batchSize ?? 4;
354
+
355
+ const replacements = await this._uploadInBatches(
356
+ remaining,
357
+ (item, idx) => this._uploadPendingItem(item, startIndex + idx + 1),
358
+ batchSize,
359
+ );
360
+
361
+ remaining.forEach((item, idx) => {
362
+ prepared = this._setValueAtPath(prepared, item.path, replacements[idx]) as AllStepsData;
363
+ });
364
+ }
365
+
366
+ // 4. Normalisation legacy uploader (Array → {updateDate, files})
367
+ const uploaderFieldPaths = new Map<string, (string | number)[]>();
368
+ for (const item of pending) {
369
+ if (item.inputType?.endsWith(".uploader") && item.path.length >= 2) {
370
+ const key = JSON.stringify(item.path.slice(0, 2));
371
+ if (!uploaderFieldPaths.has(key)) {
372
+ uploaderFieldPaths.set(key, item.path.slice(0, 2));
373
+ }
374
+ }
375
+ }
376
+ for (const fieldPath of uploaderFieldPaths.values()) {
377
+ const value = this._getValueAtPath(prepared, fieldPath);
378
+ prepared = this._setValueAtPath(
379
+ prepared,
380
+ fieldPath,
381
+ this._normalizeUploaderValue(value),
382
+ ) as AllStepsData;
383
+ }
384
+
385
+ // 5. Clean URLs absolues → chemins relatifs (uploader/simpleTable)
386
+ return this._cleanUploaderUrls(prepared, formData);
387
+ }
388
+
389
+ /**
390
+ * UUID v4 compatible browser + Node.
391
+ * Utilise `crypto.randomUUID()` si dispo, sinon fallback Math.random.
392
+ * @private
393
+ */
394
+ private _generateUuid(): string {
395
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
396
+ return crypto.randomUUID();
397
+ }
398
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
399
+ const r = Math.random() * 16 | 0;
400
+ const v = c === "x" ? r : (r & 0x3 | 0x8);
401
+ return v.toString(16);
402
+ });
403
+ }
404
+
405
+ /**
406
+ * Sauvegarde une nouvelle Answer (sans `id`) via `SAVE_COFORM_ANSWER`.
407
+ *
408
+ * Le payload caller-side attendu (dans `draftData`) :
409
+ * - `answers` (requis) — objet `{ subFormId: { inputId: value } }`
410
+ * - `form` ou parent Form (requis indirectement) — pour résoudre `formId`
411
+ * - `addedOptions` (optionnel) — multiCheckboxPlus dynamiques
412
+ * - `links` (optionnel) — sélections Finder à attacher à l'answer
413
+ *
414
+ * Sérialise automatiquement `answers`/`addedOptions`/`links` en JSON
415
+ * (le wire-format `application/x-www-form-urlencoded` exige des strings).
416
+ *
417
+ * Après succès, l'ID retourné par le serveur est posé dans `_draftData.id`
418
+ * → le `refresh()` de `BaseEntity.save()` peut alors récupérer l'Answer canonique.
419
+ *
420
+ * @protected — utiliser `answer.save()` (BaseEntity dispatch automatiquement).
421
+ */
422
+ override _add = async (payload: Record<string, any>): Promise<void> => {
423
+ if (!this._calledFromSave) {
424
+ throw new Error("utilisation invalide de _add, utilisez save");
425
+ }
426
+
427
+ const formId = this._resolveFormId(payload);
428
+ const requestData = this._buildSavePayload(formId, payload, /* withAnswerId */ false);
429
+
430
+ const response = await this.callIsConnected(() =>
431
+ this.endpointApi.saveCoformAnswer(requestData)
432
+ ) as { result?: boolean; msg?: string; data?: { id?: string } };
433
+
434
+ // Forme observée du backend (cf. save-answer-shape.test.ts) :
435
+ // { result: true, msg, data: { id: "<24hex>", _id: {_str}, collection, ... } }
436
+ const newId = response?.data?.id;
437
+ if (!this.id && typeof newId === "string" && newId.length === 24) {
438
+ this._draftData.id = newId;
439
+ }
440
+ };
441
+
442
+ /**
443
+ * Met à jour une Answer existante via `SAVE_COFORM_ANSWER` (avec `answerId`).
444
+ *
445
+ * Si `payload.answers` est `undefined` (le caller a modifié uniquement
446
+ * `addedOptions` ou `links`), on retombe sur `serverData.answers` pour ne
447
+ * pas envoyer un payload vide qui écraserait les réponses existantes.
448
+ *
449
+ * @protected — utiliser `answer.save()`.
450
+ */
451
+ override _update = async (payload: Record<string, any>): Promise<boolean> => {
452
+ if (!this._calledFromSave) {
453
+ throw new Error("utilisation invalide de _update, utilisez save");
454
+ }
455
+ if (!this.id) {
456
+ throw new ApiError("Answer sans id, impossible de mettre à jour.", 400);
457
+ }
458
+
459
+ const formId = this._resolveFormId(payload);
460
+ const requestData = this._buildSavePayload(formId, payload, /* withAnswerId */ true);
461
+
462
+ await this.callIsConnected(() => this.endpointApi.saveCoformAnswer(requestData));
463
+ return true;
464
+ };
465
+
466
+ /**
467
+ * Résout le `formId` à envoyer à `saveCoformAnswer`.
468
+ *
469
+ * Priorité :
470
+ * 1. `payload.form` (posé via `draftData.form` par `form.answer()`)
471
+ * 2. `serverData.form` (Answer chargée via `get()`)
472
+ * 3. `this.parent.id` si le parent est un `Form`
473
+ *
474
+ * @throws {ApiError} 400 si aucune source ne fournit un formId valide.
475
+ * @private
476
+ */
477
+ private _resolveFormId(payload: Record<string, any>): string {
478
+ const fromDraftOrPayload = typeof payload.form === "string" ? payload.form : undefined;
479
+ const fromServer = typeof this._serverData.form === "string" ? this._serverData.form : undefined;
480
+ const parent = this.parent;
481
+ const fromParent =
482
+ parent && typeof (parent as any).getEntityType === "function"
483
+ && (parent as any).getEntityType() === "forms"
484
+ && typeof (parent as any).id === "string"
485
+ ? (parent as any).id as string
486
+ : undefined;
487
+
488
+ const formId = fromDraftOrPayload ?? fromServer ?? fromParent;
489
+ if (typeof formId !== "string" || formId.length === 0) {
490
+ throw new ApiError(
491
+ "formId introuvable pour sauvegarder l'Answer. "
492
+ + "Créez l'Answer via `form.answer()` ou définissez `answer.data.form` manuellement.",
493
+ 400
494
+ );
495
+ }
496
+ return formId;
497
+ }
498
+
499
+ /**
500
+ * Construit le payload `SaveCoformAnswerData` en sérialisant les champs JSON
501
+ * requis par le wire-format `application/x-www-form-urlencoded`.
502
+ *
503
+ * Si `payload.answers` est absent en update, retombe sur `serverData.answers`
504
+ * (cf. design point 6.5 — indulgent envers le caller).
505
+ *
506
+ * @private
507
+ */
508
+ private _buildSavePayload(
509
+ formId: string,
510
+ payload: Record<string, any>,
511
+ withAnswerId: boolean
512
+ ): SaveCoformAnswerData {
513
+ const answersValue = payload.answers ?? (withAnswerId ? this._serverData.answers : undefined);
514
+ if (answersValue === undefined || answersValue === null) {
515
+ throw new ApiError(
516
+ "answer.data.answers est requis (objet `{ subFormId: { inputId: value } }`).",
517
+ 400
518
+ );
519
+ }
520
+
521
+ const data: SaveCoformAnswerData = {
522
+ formId,
523
+ answers: JSON.stringify(answersValue),
524
+ };
525
+
526
+ if (withAnswerId) {
527
+ data.answerId = this.id!;
528
+ }
529
+
530
+ if (payload.addedOptions && typeof payload.addedOptions === "object"
531
+ && Object.keys(payload.addedOptions).length > 0) {
532
+ data.addedOptions = JSON.stringify(payload.addedOptions);
533
+ }
534
+
535
+ if (payload.links && typeof payload.links === "object"
536
+ && Object.keys(payload.links).length > 0) {
537
+ data.links = JSON.stringify(payload.links);
538
+ }
539
+
540
+ return data;
541
+ }
542
+
543
+ // ===========================================================================
544
+ // Helpers internes pour processUploads — équivalents portés depuis
545
+ // site-json/coform/actions/mutations/uploadHelpers.ts (Sprint 3).
546
+ // ===========================================================================
547
+
548
+ /**
549
+ * Récupère `formData` (schéma du Form) depuis le parent si c'est un Form.
550
+ * Renvoie `undefined` si pas exploitable — `processUploads` continuera mais
551
+ * sans détection automatique d'`inputType` (pas de normalisation uploader,
552
+ * pas de clean URLs).
553
+ * @private
554
+ */
555
+ private _resolveFormData(): FormItemNormalized | undefined {
556
+ const parent = this.parent;
557
+ if (parent && typeof (parent as any).getEntityType === "function"
558
+ && (parent as any).getEntityType() === "forms") {
559
+ return (parent as any).serverData as FormItemNormalized;
560
+ }
561
+ return undefined;
562
+ }
563
+
564
+ private _isObjectRecord(value: unknown): value is Record<string, unknown> {
565
+ return typeof value === "object" && value !== null && !Array.isArray(value);
566
+ }
567
+
568
+ private _isDataUri(value: string): boolean {
569
+ return /^data:[^;]+;base64,/.test(value);
570
+ }
571
+
572
+ private _isPendingUploadValue(value: unknown): value is PendingUploadValue {
573
+ if (!this._isObjectRecord(value)) return false;
574
+ return typeof value.data === "string" && this._isDataUri(value.data);
575
+ }
576
+
577
+ private _parseMimeType(dataUri: string): string {
578
+ const match = dataUri.match(/^data:([^;]+);base64,/i);
579
+ return match?.[1]?.toLowerCase() ?? "application/octet-stream";
580
+ }
581
+
582
+ private _inferExtensionFromMimeType(mimeType: string): string {
583
+ const map: Record<string, string> = {
584
+ "image/jpeg": "jpg",
585
+ "image/jpg": "jpg",
586
+ "image/png": "png",
587
+ "image/gif": "gif",
588
+ "image/webp": "webp",
589
+ "image/svg+xml": "svg",
590
+ "application/pdf": "pdf",
591
+ "text/plain": "txt",
592
+ "text/csv": "csv",
593
+ "application/msword": "doc",
594
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
595
+ "application/vnd.ms-excel": "xls",
596
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
597
+ "application/vnd.ms-powerpoint": "ppt",
598
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
599
+ "application/zip": "zip",
600
+ "application/x-rar-compressed": "rar",
601
+ };
602
+ return map[mimeType] ?? "bin";
603
+ }
604
+
605
+ private _sanitizeBaseName(fileName: string): string {
606
+ const raw = fileName.replace(/\.[^.]+$/, "");
607
+ const cleaned = raw.replace(/[^a-zA-Z0-9_. -]+/g, "-").trim();
608
+ return cleaned || "upload";
609
+ }
610
+
611
+ /**
612
+ * Convertit un `PendingUploadValue` (data:URI) en `{file, docType, filename}`
613
+ * exploitable par `uploadFile()`. Compatible browser + Node :
614
+ * - Browser → `File` natif
615
+ * - Node → `Buffer` (sera converti en stream par `_prepareUploadFile()`)
616
+ * @private
617
+ */
618
+ private async _dataUriToFile(
619
+ value: PendingUploadValue,
620
+ fallbackName: string,
621
+ ): Promise<{ file: UploadInput; docType: "image" | "file"; filename: string }> {
622
+ const mime = this._parseMimeType(value.data);
623
+ const docType: "image" | "file" = mime.startsWith("image/") ? "image" : "file";
624
+
625
+ const extension = this._inferExtensionFromMimeType(mime);
626
+ const sourceName = value.name && value.name.trim() !== "" ? value.name : fallbackName;
627
+ const hasExtension = /\.[a-zA-Z0-9]+$/.test(sourceName);
628
+ const filename = hasExtension ? sourceName : `${this._sanitizeBaseName(sourceName)}.${extension}`;
629
+
630
+ const isBrowser = typeof window !== "undefined" && typeof File !== "undefined";
631
+
632
+ if (isBrowser) {
633
+ const blob = await (await fetch(value.data)).blob();
634
+ return { file: new File([blob], filename, { type: mime }), docType, filename };
635
+ }
636
+
637
+ // Node : Buffer (sera converti en stream par uploadFile → _prepareUploadFile)
638
+ const base64Match = value.data.match(/^data:[^;]+;base64,(.*)$/);
639
+ if (!base64Match) {
640
+ throw new ApiError("data:URI invalide (base64 attendu).", 400);
641
+ }
642
+ const buffer = Buffer.from(base64Match[1], "base64");
643
+ return { file: buffer, docType, filename };
644
+ }
645
+
646
+ /**
647
+ * Parcourt récursivement `allData` pour trouver tous les `data:URI` à uploader.
648
+ * Renvoie chaque pending avec son `path` (chemin imbriqué) et `inputType`
649
+ * (déduit du schéma `formData` si fourni).
650
+ * @private
651
+ */
652
+ private _collectPendingUploads(
653
+ node: unknown,
654
+ path: (string | number)[] = [],
655
+ formData?: FormItemNormalized,
656
+ ): PendingUpload[] {
657
+ const uploads: PendingUpload[] = [];
658
+
659
+ const getInputType = (): string | undefined => {
660
+ if (!formData || path.length < 2) return undefined;
661
+ const [subFormId, inputId] = path;
662
+ if (typeof subFormId !== "string" || typeof inputId !== "string") return undefined;
663
+ const subForm = formData.inputs?.[subFormId];
664
+ if (!subForm) return undefined;
665
+ const input = subForm.inputs?.[inputId];
666
+ return input && typeof input === "object" && "type" in input
667
+ ? (input.type as string | undefined)
668
+ : undefined;
669
+ };
670
+
671
+ if (typeof node === "string") {
672
+ if (this._isDataUri(node)) {
673
+ uploads.push({ value: { data: node }, path, inputType: getInputType() });
674
+ }
675
+ return uploads;
676
+ }
677
+
678
+ if (this._isPendingUploadValue(node)) {
679
+ uploads.push({ value: node, path, inputType: getInputType() });
680
+ return uploads;
681
+ }
682
+
683
+ if (Array.isArray(node)) {
684
+ node.forEach((item, index) => {
685
+ uploads.push(...this._collectPendingUploads(item, [...path, index], formData));
686
+ });
687
+ return uploads;
688
+ }
689
+
690
+ if (this._isObjectRecord(node)) {
691
+ Object.entries(node).forEach(([key, value]) => {
692
+ uploads.push(...this._collectPendingUploads(value, [...path, key], formData));
693
+ });
694
+ }
695
+
696
+ return uploads;
697
+ }
698
+
699
+ /**
700
+ * Détermine `contentKey` et `subKey` pour un upload selon le type d'input :
701
+ * - `*.uploader` → `presentation` + subKey `"subFormId.inputId"`
702
+ * - autres → `slider` sans subKey
703
+ * @private
704
+ */
705
+ private _getUploadKeys(
706
+ inputType: string | undefined,
707
+ path: (string | number)[],
708
+ ): { contentKey: string; subKey?: string } {
709
+ if (inputType && (inputType === "uploader" || inputType.endsWith(".uploader"))) {
710
+ const subKey = path
711
+ .slice(0, 2)
712
+ .filter(p => typeof p === "string")
713
+ .join(".");
714
+ return { contentKey: "presentation", subKey: subKey || undefined };
715
+ }
716
+ return { contentKey: "slider" };
717
+ }
718
+
719
+ private _getValueAtPath(obj: unknown, path: (string | number)[]): unknown {
720
+ if (path.length === 0) return obj;
721
+ const [first, ...rest] = path;
722
+ if (Array.isArray(obj)) return this._getValueAtPath(obj[first as number], rest);
723
+ if (this._isObjectRecord(obj)) return this._getValueAtPath(obj[first as string], rest);
724
+ return undefined;
725
+ }
726
+
727
+ private _setValueAtPath(obj: unknown, path: (string | number)[], value: unknown): unknown {
728
+ if (path.length === 0) return value;
729
+ const [first, ...rest] = path;
730
+ if (Array.isArray(obj)) {
731
+ const next = [...obj];
732
+ next[first as number] = this._setValueAtPath(next[first as number], rest, value);
733
+ return next;
734
+ }
735
+ if (this._isObjectRecord(obj)) {
736
+ return { ...obj, [first]: this._setValueAtPath(obj[first as string], rest, value) };
737
+ }
738
+ return obj;
739
+ }
740
+
741
+ /**
742
+ * Normalise une valeur d'input uploader vers le format legacy backend :
743
+ * `{ updateDate: ["DD/MM/YYYY"], files: { "<docId>": "<docPath>", ... } }`
744
+ * @private
745
+ */
746
+ private _normalizeUploaderValue(value: unknown): unknown {
747
+ const today = new Date().toLocaleDateString("fr-FR");
748
+
749
+ const toFilesObject = (arr: unknown[]): Record<string, string> => {
750
+ const obj: Record<string, string> = {};
751
+ for (const item of arr) {
752
+ if (this._isObjectRecord(item)
753
+ && typeof item.docId === "string"
754
+ && typeof item.docPath === "string") {
755
+ obj[item.docId as string] = item.docPath as string;
756
+ } else if (typeof item === "string") {
757
+ obj[item] = item;
758
+ }
759
+ }
760
+ return obj;
761
+ };
762
+
763
+ if (this._isObjectRecord(value) && "updateDate" in value && Array.isArray(value.updateDate)) {
764
+ if (this._isObjectRecord(value.files)) return value;
765
+ if (Array.isArray(value.files)) return { ...value, files: toFilesObject(value.files) };
766
+ return value;
767
+ }
768
+
769
+ if (Array.isArray(value)) {
770
+ return { updateDate: [today], files: toFilesObject(value) };
771
+ }
772
+
773
+ return value;
774
+ }
775
+
776
+ private _cleanUrlToRelativePath(value: unknown): unknown {
777
+ if (typeof value === "string") {
778
+ if (this._isDataUri(value)) return value;
779
+ const urlMatch = value.match(/^https?:\/\/[^/]+(\/.*)/i);
780
+ if (urlMatch) return urlMatch[1];
781
+ return value;
782
+ }
783
+
784
+ if (Array.isArray(value)) return value.map(v => this._cleanUrlToRelativePath(v));
785
+
786
+ if (this._isObjectRecord(value)) {
787
+ if ("data" in value && typeof value.data === "string") {
788
+ const cleanedData = !this._isDataUri(value.data)
789
+ ? this._cleanUrlToRelativePath(value.data)
790
+ : value.data;
791
+ return { ...value, data: cleanedData };
792
+ }
793
+ const cleaned: Record<string, unknown> = {};
794
+ for (const [k, v] of Object.entries(value)) {
795
+ cleaned[k] = this._cleanUrlToRelativePath(v);
796
+ }
797
+ return cleaned;
798
+ }
799
+
800
+ return value;
801
+ }
802
+
803
+ /**
804
+ * Suffixes de types d'inputs dont les valeurs contiennent des URLs de fichiers
805
+ * à nettoyer avant envoi au serveur.
806
+ * @private
807
+ */
808
+ private static readonly INPUT_TYPES_TO_CLEAN_URLS = [".uploader", ".simpleTable"] as const;
809
+
810
+ /**
811
+ * Nettoie les URLs absolues → chemins relatifs **uniquement** sur les champs
812
+ * uploader/simpleTable. Les autres champs (finder, text…) peuvent contenir
813
+ * des URLs légitimes qu'il ne faut pas tronquer.
814
+ * @private
815
+ */
816
+ private _cleanUploaderUrls(
817
+ data: AllStepsData,
818
+ formData: FormItemNormalized | undefined,
819
+ ): AllStepsData {
820
+ if (!formData) return data;
821
+ const result: Record<string, unknown> = {};
822
+
823
+ for (const [subFormId, subFormData] of Object.entries(data)) {
824
+ if (!this._isObjectRecord(subFormData)) {
825
+ result[subFormId] = subFormData;
826
+ continue;
827
+ }
828
+ const subForm = formData.inputs?.[subFormId];
829
+ if (!subForm) {
830
+ result[subFormId] = subFormData;
831
+ continue;
832
+ }
833
+ const cleaned: Record<string, unknown> = {};
834
+ for (const [inputId, inputValue] of Object.entries(subFormData as Record<string, unknown>)) {
835
+ const input = subForm.inputs?.[inputId];
836
+ const inputType = input && typeof input === "object" && "type" in input
837
+ ? (input.type as string | undefined)
838
+ : undefined;
839
+ const shouldClean = !!inputType
840
+ && Answer.INPUT_TYPES_TO_CLEAN_URLS.some(suffix => inputType.endsWith(suffix));
841
+ cleaned[inputId] = shouldClean
842
+ ? this._cleanUrlToRelativePath(inputValue)
843
+ : inputValue;
844
+ }
845
+ result[subFormId] = cleaned;
846
+ }
847
+ return result as AllStepsData;
848
+ }
849
+
850
+ /**
851
+ * Upload par batches parallèles (défaut : 4 fichiers simultanés).
852
+ * @private
853
+ */
854
+ private async _uploadInBatches<T, R>(
855
+ items: T[],
856
+ uploadFn: (item: T, index: number) => Promise<R>,
857
+ batchSize: number = 4,
858
+ ): Promise<R[]> {
859
+ const results: R[] = [];
860
+ for (let i = 0; i < items.length; i += batchSize) {
861
+ const batch = items.slice(i, i + batchSize);
862
+ const batchResults = await Promise.all(
863
+ batch.map((item, batchIndex) => uploadFn(item, i + batchIndex)),
864
+ );
865
+ results.push(...batchResults);
866
+ }
867
+ return results;
868
+ }
869
+
870
+ /**
871
+ * Upload un PendingUpload, retourne la valeur de remplacement à insérer
872
+ * dans `allData` (`{docId, docPath}` pour uploader, `docPath` string sinon).
873
+ * @private
874
+ */
875
+ private async _uploadPendingItem(
876
+ item: PendingUpload,
877
+ index: number,
878
+ ): Promise<{ docId: string; docPath: string } | string> {
879
+ const fallbackName = `upload-${index}`;
880
+ const { file, docType, filename } = await this._dataUriToFile(item.value, fallbackName);
881
+ const { contentKey, subKey } = this._getUploadKeys(item.inputType, item.path);
882
+
883
+ const result = await this.uploadFile(file, {
884
+ docType,
885
+ contentKey,
886
+ subKey,
887
+ qqfilename: filename,
888
+ });
889
+
890
+ const isUploader = item.inputType?.endsWith(".uploader") ?? false;
891
+ return isUploader
892
+ ? { docId: result.docId, docPath: result.docPath }
893
+ : result.docPath;
894
+ }
895
+
896
+ /**
897
+ * Upload un PendingUpload + remplace immédiatement dans `prepared` à son path.
898
+ * Utilisé pour le premier upload (séquentiel) qui crée l'Answer.
899
+ * @private
900
+ */
901
+ private async _uploadAndReplace(
902
+ item: PendingUpload,
903
+ prepared: AllStepsData,
904
+ index: number,
905
+ ): Promise<AllStepsData> {
906
+ const replacement = await this._uploadPendingItem(item, index);
907
+ return this._setValueAtPath(prepared, item.path, replacement) as AllStepsData;
908
+ }
72
909
  }