import { BaseEntity } from "./BaseEntity.js"; import type { AnswerItemNormalized } from "./serverDataType/Answer.js"; import type { FormItemNormalized } from "./serverDataType/Form.js"; type UploadInput = File | Blob | Buffer | import("stream").Readable; /** * Format des données utilisateur d'un CoForm — `{ subFormId: { inputId: value } }`. * Générique : la lib ne fait aucune supposition sur le type des valeurs. */ export type AllStepsData = Record>; /** * Une entrée de l'historique d'audit d'une réponse (réponse de * `GET_COFORM_ANSWER_HISTORY`). `userName`/`userSlug` sont dénormalisés * au moment de la modification : survivent à une suppression de l'user. */ export interface AnswerChange { userId: string; userName: string; userSlug: string; /** Timestamp unix en SECONDES (pas ms) — `new Date(at * 1000)`. */ at: number; /** `""` possible : cast PHP `(string)` avec défaut vide si champ absent. */ mutationType: "create" | "update" | ""; changedFields: string[]; } /** Un axe du radar multi-eval (= un input radioNew d'une step donnée). */ export interface MultiEvalAxis { key: string; label: string; options: string[]; } /** Un dataset = la contribution d'un user à un step (1 dataset = 1 user). */ export interface MultiEvalDataset { userId: string; userName: string; userSlug: string; evaluatedAt: number | null; values: Record; } /** Une step avec ses axes et ses contributeurs (élément de `getMultiEvalData`). */ export interface MultiEvalStepData { stepKey: string; stepName: string; axes: MultiEvalAxis[]; datasets: MultiEvalDataset[]; } /** Réponse de `Answer.getMultiEvalData()`. */ export interface MultiEvalDataResponse { steps: MultiEvalStepData[]; } /** * Valeur d'un upload en attente — objet `{ name?, data: "data:...;base64,..." }` * inséré par les inputs uploader avant le pré-upload backend. */ export interface PendingUploadValue { name?: string; data: string; } /** * Un pending upload détecté dans la structure `allData` — chemin imbriqué + * type d'input dérivé du schéma (`formData.inputs[subFormId].inputs[inputId].type`). */ export interface PendingUpload { value: PendingUploadValue; path: (string | number)[]; inputType?: string; } /** * Options de `Answer.processUploads()`. */ export interface ProcessUploadsOptions { /** * Schéma du Form (pour déduire `inputType` des champs uploader/simpleTable). * Auto-récupéré via `this.parent.serverData` si parent est un Form. */ formData?: FormItemNormalized; /** Nombre d'uploads parallèles par batch. Défaut : 4. */ batchSize?: number; } /** * Options de `Answer.get()` — params optionnels du endpoint `COFORM_ANSWERS_BY_ID`. */ export interface AnswerGetOptions { /** * Liste de champs à retourner côté backend (filtrage). Si absent, tous les * champs sont renvoyés. Utile pour optimiser la taille de la réponse. */ fields?: string[]; /** * ID du formulaire parent. Utilisé côté backend pour calculer `canEdit` et * `editDeniedReason` (droits d'édition contextuels). Si absent, **auto-injecté * via `this.parent.id`** quand le parent est un `Form` (cas typique * `form.answer({id})`). */ formId?: string; /** Chemin Finder (rarement utilisé). */ finderPath?: string; } /** * Options pour `Answer.uploadFile()`. Tous les champs sont optionnels — les * défauts reproduisent les valeurs backend (`docType: "image"`, `contentKey: "slider"`). */ export interface UploadAnswerFileOptions { /** Type de document : `image` (défaut) ou `file` (PDF, DOCX, etc.). */ docType?: "image" | "file"; /** Clé de contenu côté backend. `"slider"` (défaut) pour images, `"presentation"` pour inputs uploader. */ contentKey?: string; /** Sous-clé optionnelle (ex: `"subFormId.inputId"` pour les inputs `*.uploader`). */ subKey?: string; /** UUID client (compat fine-uploader). Auto-généré si absent. */ qquuid?: string; /** Nom du fichier. Auto-déduit (File.name, stream.path, ou fallback) si absent. */ qqfilename?: string; /** * Taille du fichier en octets. Auto-déduite pour `File` (`.size`), `Blob` * (`.size`), `Buffer` (`.length`), ou `Readable` annoté (`.size`). * * **À fournir explicitement pour un `Readable` non annoté** (ex: * `fs.createReadStream(path)` → faire `fs.statSync(path).size` au préalable), * sinon `0` est envoyé et le backend peut rejeter le multipart. */ qqtotalfilesize?: number; } /** * Retour normalisé de `Answer.uploadFile()`. */ export interface UploadAnswerFileResult { /** ID du document MongoDB (à stocker dans `{docId, docPath}` pour les inputs uploader). */ docId: string; /** Chemin du document. URL absolue côté backend, à nettoyer en chemin relatif si besoin (cf. `cleanUploaderUrls` site-json). */ docPath: string; /** ID de l'Answer (créée par cet upload si premier, sinon `= this.id`). */ answerId: string; } /** * Options de `Answer.getFiles()`. */ export interface GetFilesOptions { /** Clé d'identification de l'input (ex: `"subFormId.inputId"`). Requise. */ subKey: string; /** Type de document attendu. Défaut backend : `"file"`. */ docType?: "image" | "file"; /** Clé de contenu utilisée lors de l'upload. Défaut backend : `"presentation"`. */ contentKey?: string; } /** * Élément renvoyé par `Answer.getFiles()` — un document attaché à l'Answer. * Les champs `imagePath`/`imageThumbPath`/`imageMediumPath` ne sont présents * que pour `docType: "image"`. */ export interface AnswerFileItem { /** ID MongoDB du document (utilisable pour suppression via `deleteDocumentById`). */ docId: string; /** Chemin relatif du document côté backend. */ docPath: string; /** Nom original du fichier. */ name?: string; /** Taille en octets. */ size?: number; /** Chemin de l'image (uniquement si `docType: "image"`). */ imagePath?: string | null; /** Chemin du thumbnail (uniquement si `docType: "image"`). */ imageThumbPath?: string | null; /** Chemin de la version medium (uniquement si `docType: "image"`). */ imageMediumPath?: string | null; } export declare class Answer extends BaseEntity { static entityType: string; static entityTag: string; static SCHEMA_CONSTANTS: string[]; static ADD_BLOCKS: Map<"SAVE_COFORM_ANSWER", "saveCoformAnswer">; static UPDATE_BLOCKS: Map<"SAVE_COFORM_ANSWER", "saveCoformAnswer">; defaultFields: Record; /** * Champs `serverData` à ne pas renvoyer au serveur dans le payload de save. * `answers`, `addedOptions`, `links`, `form` sont conservés (envoyés au serveur). * Les autres sont calculés/posés par le backend. */ removeFields: string[]; /** * {@inheritDoc BaseEntity#_isAdminViaHierarchy} * * Override Answer : la hiérarchie d'Answer passe par son **parent instance * Form** (pas par `serverData.parent` direct comme pour `events`/`projects`/`poi`). * * Chaîne : `Answer → parent (Form) → form.serverData.parent ({[orgId]: {type, name}}) → check userContext.links`. * * Si le parent n'est pas un Form (cas `api.answer({id})` direct sans contexte), * renvoie `false` — impossible de vérifier la hiérarchie sans le Form. * * @protected */ protected _isAdminViaHierarchy(): boolean; /** * {@inheritDoc BaseEntity#isAuthor} * * Override Answer : le champ d'autorité est `serverData.user` (pas `creator` * comme la version par défaut de BaseEntity). Le `user` peut être : * - un `string` (ID MongoDB brut renvoyé par le backend) * - une instance `User` (après `_transformServerData` qui le link en entité) * * Les pré-conditions (connexion, id, serverData) sont vérifiées comme dans * la version de base — `silent: true` (défaut) renvoie `false` au lieu de throw. */ isAuthor(options?: { silent?: boolean; }): boolean; /** * Transforme les champs imbriqués (user, context etc.) en instances d'entités. * @param data - Les données brutes du serveur. * @returns Les données transformées. * @protected */ protected _transformServerData(data: AnswerItemNormalized): AnswerItemNormalized; /** * Rafraîchit les données de l'entité depuis l'API (`COFORM_ANSWERS_BY_ID`). * * **Auto-injection du `formId`** : si le parent est un `Form` (cas typique * `form.answer({id})`), le param `formId` est automatiquement envoyé pour * que le backend calcule `canEdit` et `editDeniedReason` dans la réponse. * Le caller peut override via `opts.formId` ou couper avec `opts.formId: ""`. * * @param opts - Params optionnels : * - `fields` : filtrage des champs côté backend * - `formId` : override de l'auto-injection (utile pour disable ou forcer un autre form) * - `finderPath` : chemin Finder (rare) * * @example * // Cas commun : form.answer({id}) → get() auto avec formId * const answer = await form.answer({ id: answerId }); * answer.serverData.canEdit; // true/false calculé backend * answer.serverData.editDeniedReason; // raison si false * * @example * // Cas avancé : filtrage de champs pour optim perf * const answer = await form.answer(); * answer._id(answerId); * await answer.get({ fields: ["answers", "canEdit", "editDeniedReason"] }); */ get(opts?: AnswerGetOptions): Promise>; /** * Récupère l'ID du parent Form si applicable, pour l'auto-injection dans `get()`. * @private */ private _autoFormIdFromParent; form(): Promise; /** * Supprime cette Answer côté serveur via `DELETE_ELEMENT` * (`type=answers`, `id=this.id`). * * Après succès : * - `serverData` et `draftData` sont vidés (sans casser la réactivité) * - `_isDeleted = true` → toute mutation ultérieure (`save`, `get`, etc.) throw * * **Guard** : autorisation côté client si l'utilisateur courant est : * - l'**auteur** de l'Answer (`serverData.user === userId`), OU * - **admin** d'un parent du Form (org/project) via la hiérarchie * (`form.serverData.parent[orgId]` × `userContext.links.memberOf[orgId].isAdmin`). * * Le check utilise `isAuthorOrAdmin({checkHierarchy: true})` qui s'appuie sur * l'override `Answer._isAdminViaHierarchy` (remonte via parent instance Form). * * **Pré-requis** : * - `this.parent` doit être un Form (cas `form.answer({id})`) * - `userContext` (User connecté) doit avoir `serverData.links` chargés (cas `api.me()`) * Sans ces pré-requis, seul `isAuthor` peut autoriser. * * @param reason - Raison libre transmise au backend (audit). Défaut : `"delete coform answer"`. * @throws {ApiError} 400 si l'Answer n'a pas d'id (jamais sauvegardée). * @throws {ApiError} 403 si ni auteur ni admin du parent. * * @example * const answer = await form.answer({ id: "..." }); * await answer.delete(); // OK si auteur OU admin org/project parent du Form * answer._isDeleted; // true */ delete(reason?: string): Promise; /** * {@inheritDoc BaseEntity#deleteFile} * * Override Answer : appelle l'endpoint coform **answer-authorisé** * `DELETE_COFORM_ANSWER_FILE` (`{formId, answerId, docId}`) plutôt que le * `DELETE_DOCUMENT_BY_ID` générique (qui opère par docId nu, sans contexte * d'autorisation). Le backend vérifie ainsi le droit d'éditer CETTE réponse * (propriétaire OU `canAdminAnswer`) — même périmètre d'auth que l'upload, * sans dépendre du `canEdit` générique du document. * * Après succès : **cleanup local** — retire le `docId` des structures uploader * normalisées `{updateDate, files: {docId: docPath}}` dans `serverData.answers` * et `_draftData.answers`. Le caller n'a pas besoin de refetch. * * @example * const files = await answer.getFiles({ subKey: "sf.input", docType: "image" }); * await answer.deleteFile(files[0].docId); * // answer.serverData.answers[sf][input].files[files[0].docId] === undefined */ deleteFile(docId: string): Promise; /** * Supprime PLUSIEURS fichiers uploader en une seule opération, par batches * parallèles (défaut 4 simultanés) — MIROIR exact du batching d'upload de * `processUploads` (réutilise `_uploadInBatches`). Chaque suppression passe * par `deleteFile` (endpoint answer-authorisé + cleanup local). * * Best-effort : une suppression échouée (ex. fichier déjà supprimé côté * serveur) est loguée mais n'interrompt PAS les autres. Conçue pour la * réconciliation au save (fichiers retirés du formulaire) : le caller fait * **un seul appel** au lieu d'une boucle de requêtes. * * @param docIds - liste des docId à supprimer (vide → no-op). */ deleteFiles(docIds: readonly string[]): Promise; /** * Walk `answers[subFormId][inputId]` et retire `docId` des structures * `{updateDate, files: {docId: docPath}}` (format canonique uploader). * Mutation in-place pour préserver la réactivité. * @private */ private _removeDocIdFromUploaderFields; /** * Récupère la liste des fichiers attachés à un input précis de cette Answer * via `COFORM_GET_ANSWER_FILES`. * * **Cas d'usage principal** : récupérer les fichiers d'inputs uploader stockés * en **format legacy** `{updateDate: [...]}` (sans la clé `files`). Le backend * recherche alors les documents MongoDB liés à `{answerId, subKey, docType}`. * * Pour les Answer en format moderne `{updateDate, files: {: }}`, * les fichiers sont déjà dans `answer.serverData.answers[subFormId][inputId].files` * — pas besoin d'appel réseau. * * @param opts - `subKey` (requis, ex: `"subFormId.inputId"`), `docType`, `contentKey` * @returns Liste normalisée des fichiers `{docId, docPath, name?, size?, imagePath?, imageThumbPath?, imageMediumPath?}`. * Tableau vide si aucun fichier trouvé. * @throws {ApiError} 400 si pas d'id Answer. Throws aussi si le backend renvoie `result: false`. * * @example * const form = await org.form({ id: formId }); * const answer = await form.answer({ id: answerId }); * const files = await answer.getFiles({ * subKey: `${subFormId}.${inputId}`, * docType: "image", * }); * files[0].docId; // ID du document (pour suppression future) * files[0].docPath; // Chemin relatif (à concaténer avec baseURL pour affichage) */ getFiles(opts: GetFilesOptions): Promise; /** * Récupère l'historique d'audit de cette Answer. * * Trace `create` + `update` triée du plus récent au plus ancien, max 200 * entrées (cap backend). Auth côté serveur : owner OU canAdminAnswer * (admin du parent du form, admin/membre finder selon * `membersCanEditSharedAnswer`, ou form `publicCanEditSharedAnswer`). * * Constant : `GET_COFORM_ANSWER_HISTORY` (POST * `/survey/coform/getanswerhistory`, auth bearer). * * @returns Liste des changements ; tableau vide si l'historique est vide. * @throws {ApiError} 400 si Answer sans id. * @throws {ApiAuthenticationError} si non connecté. * * @example * const form = await org.form({ id: formId }); * const answer = await form.answer({ id: answerId }); * const history = await answer.getHistory(); * new Date(history[0].at * 1000); // date de la dernière modif (at en SECONDES) */ getHistory(): Promise; /** * Récupère les datasets agrégés des évaluations multiples (radar) de cette * Answer. Pour une réponse partagée contenant des inputs `radioNew` avec * `activeMultieval: true`, retourne un dataset par contributeur pour * chaque step. * * Constant : `GET_COFORM_MULTIEVAL_DATA` (POST * `/survey/coform/getmultievaldata`, auth none — radar lisible déconnecté). * Lecture publique du radar agrégé ; l'identité des contributeurs est * renvoyée telle quelle (anonymisation pour visiteurs anonymes à ajouter * si besoin). * * @param opts.stepKey - Optionnel : filtrer aux datasets d'une seule * step. Sinon retourne toutes les steps. * @throws {ApiError} 400 si Answer sans id. * * @example * const data = await answer.getMultiEvalData(); * data.steps.forEach(s => renderRadar(s)); */ getMultiEvalData(opts?: { stepKey?: string | null; }): Promise; /** * Upload un fichier rattaché à cette Answer via `COFORM_UPLOAD_ANSWER_FILE`. * * **Building block du pipeline** : conçu pour être utilisé via * `processUploads()` suivi de `save()`. L'Answer créée par le premier upload * est en état "placeholder" côté backend (champ `user` non posé) — `save()` * finalise. Appeler `get()` ou `delete()` (via l'entité) sur une Answer * upload-only sans save échouera : le backend la considère comme orpheline * (`editDeniedReason: "not_owner"`, stub sans `data.id`). * * **Cas premier upload (Answer sans id)** : le backend crée l'Answer et * renvoie `answerId`. La méthode pose alors `this._draftData.id = answerId` * — les uploads suivants utiliseront cet id, et un `save()` ultérieur passera * en update. * * **Cas uploads suivants (Answer avec id)** : `answerId: this.id` est envoyé. * * Compatible browser (File/Blob) et Node (Buffer/Readable) via * `BaseEntity._prepareUploadFile()` — le multipart encoder du package * `form-data` (utilisé en Node) ignore les Buffer bruts, donc conversion en * stream annoté. * * @param file - Fichier à uploader (File/Blob côté browser, Buffer/Readable côté Node) * @param opts - `docType`, `contentKey`, `subKey`, `qquuid`, `qqfilename` * @returns `{ docId, docPath, answerId }` — toujours fournis par le backend. * @throws {ApiError} si `formId` introuvable, ou réponse invalide. * * @example * // Browser (depuis un ) * const form = await org.form({ id: formId }); * const answer = await form.answer(); // draft * const { docId, docPath } = await answer.uploadFile(htmlFile, { * contentKey: "presentation", * subKey: `${subFormId}.${inputId}`, * }); * // answer.id désormais défini → uploads suivants utilisent cet id. * * @example * // Node (depuis un Buffer) * const buffer = await fs.promises.readFile("./photo.jpg"); * const result = await answer.uploadFile(buffer, { docType: "image" }); */ uploadFile(file: UploadInput, opts?: UploadAnswerFileOptions): Promise; /** * Orchestre l'upload de tous les fichiers pendants (`data:URI`) trouvés dans * `allData`, puis renvoie la structure transformée prête pour `answer.save()`. * * **Pipeline complet** : * 1. Walk récursif de `allData` → collecte les `data:URI` avec leur path + inputType * 2. Premier upload (si Answer sans id) → backend crée l'Answer + pose `this.id` * 3. Uploads restants en batches parallèles (défaut 4) * 4. Remplacement dans la structure : * - inputs `*.uploader` → `{ docId, docPath }` * - autres → `docPath` string * 5. Normalisation legacy uploader : `Array<{docId, docPath}>` → `{ updateDate, files }` * 6. Nettoyage URLs absolues → chemins relatifs (uploader/simpleTable uniquement) * * Ne touche PAS `addedOptions` ni `links` — caller les pose séparément avant `save()`. * * @param allData - Données utilisateur `{ subFormId: { inputId: value } }` * @param options - `formData` (auto via parent Form), `batchSize` (défaut 4) * @returns `allData` transformé, prêt à être affecté à `answer.data.answers` * * @example * const form = await org.form({ id: formId }); * const answer = await form.answer(); // ou form.answer({ id }) en édition * * const prepared = await answer.processUploads(allStepsDataFromForm); * answer.data.answers = prepared; * answer.data.addedOptions = addedOptions; // optionnel * answer.data.links = finderLinks; // optionnel * await answer.save(); */ processUploads(allData: AllStepsData, options?: ProcessUploadsOptions): Promise; /** * UUID v4 compatible browser + Node. * Utilise `crypto.randomUUID()` si dispo, sinon fallback Math.random. * @private */ private _generateUuid; /** * Sauvegarde une nouvelle Answer (sans `id`) via `SAVE_COFORM_ANSWER`. * * Le payload caller-side attendu (dans `draftData`) : * - `answers` (requis) — objet `{ subFormId: { inputId: value } }` * - `form` ou parent Form (requis indirectement) — pour résoudre `formId` * - `addedOptions` (optionnel) — multiCheckboxPlus dynamiques * - `links` (optionnel) — sélections Finder à attacher à l'answer * * Sérialise automatiquement `answers`/`addedOptions`/`links` en JSON * (le wire-format `application/x-www-form-urlencoded` exige des strings). * * Après succès, l'ID retourné par le serveur est posé dans `_draftData.id` * → le `refresh()` de `BaseEntity.save()` peut alors récupérer l'Answer canonique. * * @protected — utiliser `answer.save()` (BaseEntity dispatch automatiquement). */ _add: (payload: Record) => Promise; /** * Met à jour une Answer existante via `SAVE_COFORM_ANSWER` (avec `answerId`). * * Si `payload.answers` est `undefined` (le caller a modifié uniquement * `addedOptions` ou `links`), on retombe sur `serverData.answers` pour ne * pas envoyer un payload vide qui écraserait les réponses existantes. * * @protected — utiliser `answer.save()`. */ _update: (payload: Record) => Promise; /** * Résout le `formId` à envoyer à `saveCoformAnswer`. * * Priorité : * 1. `payload.form` (posé via `draftData.form` par `form.answer()`) * 2. `serverData.form` (Answer chargée via `get()`) * 3. `this.parent.id` si le parent est un `Form` * * @throws {ApiError} 400 si aucune source ne fournit un formId valide. * @private */ private _resolveFormId; /** * Construit le payload `SaveCoformAnswerData` en sérialisant les champs JSON * requis par le wire-format `application/x-www-form-urlencoded`. * * Si `payload.answers` est absent en update, retombe sur `serverData.answers` * (cf. design point 6.5 — indulgent envers le caller). * * @private */ private _buildSavePayload; /** * Récupère `formData` (schéma du Form) depuis le parent si c'est un Form. * Renvoie `undefined` si pas exploitable — `processUploads` continuera mais * sans détection automatique d'`inputType` (pas de normalisation uploader, * pas de clean URLs). * @private */ private _resolveFormData; private _isObjectRecord; private _isDataUri; private _isPendingUploadValue; private _parseMimeType; private _inferExtensionFromMimeType; private _sanitizeBaseName; /** * Convertit un `PendingUploadValue` (data:URI) en `{file, docType, filename}` * exploitable par `uploadFile()`. Compatible browser + Node : * - Browser → `File` natif * - Node → `Buffer` (sera converti en stream par `_prepareUploadFile()`) * @private */ private _dataUriToFile; /** * Parcourt récursivement `allData` pour trouver tous les `data:URI` à uploader. * Renvoie chaque pending avec son `path` (chemin imbriqué) et `inputType` * (déduit du schéma `formData` si fourni). * @private */ private _collectPendingUploads; /** * Détermine `contentKey` et `subKey` pour un upload selon le type d'input : * - `*.uploader` → `presentation` + subKey `"subFormId.inputId"` * - autres → `slider` sans subKey * @private */ private _getUploadKeys; private _getValueAtPath; private _setValueAtPath; /** * Walk les `answers[subFormId][inputId]` pour normaliser les valeurs des * inputs `*.uploader` vers le format canonique `{updateDate, files: {docId: docPath}}`. * * Utilisé par `_transformServerData()` pour harmoniser les Answer en format * legacy Array `[{docId, docPath}]` avec le format moderne (ce que * `processUploads()` écrit). * * Skip si le schéma `formData` n'a pas l'inputType pour un champ donné. * @private */ private _normalizeLegacyUploaderInAnswers; /** * Normalise une valeur d'input uploader vers le format legacy backend : * `{ updateDate: ["DD/MM/YYYY"], files: { "": "", ... } }` * @private */ private _normalizeUploaderValue; private _cleanUrlToRelativePath; /** * Suffixes de types d'inputs dont les valeurs contiennent des URLs de fichiers * à nettoyer avant envoi au serveur. * @private */ private static readonly INPUT_TYPES_TO_CLEAN_URLS; /** * Nettoie les URLs absolues → chemins relatifs **uniquement** sur les champs * uploader/simpleTable. Les autres champs (finder, text…) peuvent contenir * des URLs légitimes qu'il ne faut pas tronquer. * @private */ private _cleanUploaderUrls; /** * Upload par batches parallèles (défaut : 4 fichiers simultanés). * @private */ private _uploadInBatches; /** * Upload un PendingUpload, retourne la valeur de remplacement à insérer * dans `allData` (`{docId, docPath}` pour uploader, `docPath` string sinon). * @private */ private _uploadPendingItem; /** * Upload un PendingUpload + remplace immédiatement dans `prepared` à son path. * Utilisé pour le premier upload (séquentiel) qui crée l'Answer. * @private */ private _uploadAndReplace; } export {};