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