@communecter/cocolight-api-client 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@communecter/cocolight-api-client",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Client Axios simplifié pour l'API cocolight",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,8 +4,13 @@ import ObjectID from "bson-objectid";
4
4
  import pkg from "file-type";
5
5
 
6
6
  import { ApiAuthenticationError, ApiError, ApiResponseError, ApiValidationError } from "../error.js";
7
+ import { autoSyncDraftFromSchema, reactive } from "../utils/reactive.js";
7
8
  const { fromBuffer } = pkg;
8
9
 
10
+ /**
11
+ * @typedef {import("../ApiClient.js").default} ApiClient
12
+ * @typedef {import("./EndpointApi.js").default} EndpointApi
13
+ */
9
14
 
10
15
  /**
11
16
  * Classe de base pour toutes les entités métiers : utilisateurs, projets, organisations, etc.
@@ -26,6 +31,9 @@ export class BaseEntity {
26
31
  /** @type {boolean} Indique si `save()` est en cours */
27
32
  _calledFromSave = false;
28
33
 
34
+ /** @type {boolean} Indique si le draft est synchronisé avec le serveur */
35
+ _syncReactiveDraft = false;
36
+
29
37
  /**
30
38
  * Constructeur de l'entité.
31
39
  * @param {Object} parent - L'ApiClient ou une entité parente.
@@ -50,11 +58,11 @@ export class BaseEntity {
50
58
  this.deps = deps;
51
59
 
52
60
  if (parent?.__entityTag === "ApiClient") {
53
- /** @type {import("../ApiClient.js").default} */
61
+ /** @type {ApiClient} */
54
62
  this.apiClient = parent;
55
63
  this.parent = null;
56
64
  } else if (parent?.apiClient) {
57
- /** @type {import("../ApiClient.js").default} */
65
+ /** @type {ApiClient} */
58
66
  this.apiClient = parent.apiClient;
59
67
  this.parent = parent;
60
68
  } else {
@@ -63,10 +71,10 @@ export class BaseEntity {
63
71
 
64
72
  // Gérer les deux cas : fonction constructeur ou instance
65
73
  if (typeof deps.EndpointApi === "function") {
66
- /** @type {import("./EndpointApi.js").default} */
74
+ /** @type {EndpointApi} */
67
75
  this.endpointApi = new deps.EndpointApi(this.apiClient);
68
76
  } else if (typeof deps.EndpointApi === "object") {
69
- /** @type {import("./EndpointApi.js").default} */
77
+ /** @type {EndpointApi} */
70
78
  this.endpointApi = deps.EndpointApi;
71
79
  } else {
72
80
  throw new ApiError("deps.EndpointApi doit être une classe ou une instance valide.");
@@ -184,19 +192,7 @@ export class BaseEntity {
184
192
  */
185
193
  static fromServerData(data, parent, deps) {
186
194
  const instance = new this(parent, {}, deps);
187
- instance._serverData = { ...data };
188
-
189
- const { draft, proxy } = instance._buildDraftAndProxy({
190
- data: { ...data, ...instance.defaultFields },
191
- serverData: instance._serverData,
192
- constant: this.SCHEMA_CONSTANTS,
193
- apiClient: instance.apiClient,
194
- transforms: instance.transforms,
195
- removeFields: instance.removeFields
196
- });
197
-
198
- instance._draftData = draft;
199
- instance.data = proxy;
195
+ instance._setData(data);
200
196
  return instance;
201
197
  }
202
198
 
@@ -208,7 +204,7 @@ export class BaseEntity {
208
204
  * @private
209
205
  */
210
206
  _setData(newData) {
211
- this._serverData = { ...newData };
207
+ this._serverData = reactive({ ...newData });
212
208
 
213
209
  const { draft, proxy } = this._buildDraftAndProxy({
214
210
  data: { ...newData, ...this.defaultFields },
@@ -221,6 +217,10 @@ export class BaseEntity {
221
217
  this._initialDraftData = JSON.parse(JSON.stringify(draft));
222
218
  this._draftData = draft;
223
219
  this.data = proxy;
220
+
221
+ if (this._syncReactiveDraft) {
222
+ this.setupReactiveSync();
223
+ }
224
224
  }
225
225
 
226
226
 
@@ -831,6 +831,25 @@ export class BaseEntity {
831
831
  return Object.keys(changed).length > 0 ? changed : null;
832
832
  }
833
833
 
834
+ /**
835
+ * ───────────────────────────────
836
+ * ReactiveMixin
837
+ * ───────────────────────────────
838
+ */
839
+
840
+ /**
841
+ * Active la synchronisation réactive entre le brouillon et le schéma.
842
+ *
843
+ * @returns {void}
844
+ * @public
845
+ */
846
+ setupReactiveSync() {
847
+ if (!this._syncReactiveDraft) {
848
+ this._syncReactiveDraft = true;
849
+ }
850
+ autoSyncDraftFromSchema(this);
851
+ }
852
+
834
853
  /**
835
854
  * ───────────────────────────────
836
855
  * MutualEntityMixin
@@ -1122,6 +1141,307 @@ export class BaseEntity {
1122
1141
  }
1123
1142
  }
1124
1143
 
1144
+ /**
1145
+ * Construit dynamiquement des filtres sur un lien entre entités
1146
+ *
1147
+ * @param {string} id - L'ID de l'entité cible.
1148
+ * @param {Object} options - Options de filtrage.
1149
+ * @param {"memberOf" | "projects"} [options.linkType] - Le type de lien (ex: "memberOf", "projects", etc.).
1150
+ * @param {boolean} [options.isAdmin] - Si défini, filtre selon l'existence de isAdmin.
1151
+ * @param {boolean} [options.isAdminPending] - Si défini, filtre selon l'existence de isAdminPending.
1152
+ * @param {boolean} [options.isInviting] - Si défini, filtre selon l'existence de isInviting.
1153
+ * @param {boolean} [options.toBeValidated] - Si défini, filtre selon l'existence de toBeValidated.
1154
+ * @param {Array<string>} [options.roles] - Rôles à filtrer.
1155
+ * @returns {Object} - Un objet de filtres à passer à une requête.
1156
+ * @throws {TypeError} - Si les types des paramètres ne sont pas valides.
1157
+ * @private
1158
+ */
1159
+ _buildLinkFilters(id, {
1160
+ linkType,
1161
+ isAdmin,
1162
+ isAdminPending,
1163
+ isInviting,
1164
+ toBeValidated = false,
1165
+ roles = []
1166
+ } = {}) {
1167
+ if (typeof id !== "string") throw new TypeError("id doit être une chaîne.");
1168
+ if (typeof linkType !== "string") throw new TypeError("linkType doit être une chaîne.");
1169
+ if (typeof isAdmin !== "undefined" && typeof isAdmin !== "boolean") {
1170
+ throw new TypeError("isAdmin doit être un booléen.");
1171
+ }
1172
+ if (typeof isAdminPending !== "undefined" && typeof isAdminPending !== "boolean") {
1173
+ throw new TypeError("isAdminPending doit être un booléen.");
1174
+ }
1175
+ if (typeof isInviting !== "undefined" && typeof isInviting !== "boolean") {
1176
+ throw new TypeError("isInviting doit être un booléen.");
1177
+ }
1178
+ if (typeof toBeValidated !== "undefined" && typeof toBeValidated !== "boolean") {
1179
+ throw new TypeError("toBeValidated doit être un booléen.");
1180
+ }
1181
+ if (!Array.isArray(roles)) {
1182
+ throw new TypeError("roles doit être un tableau de chaînes.");
1183
+ }
1184
+
1185
+ const path = `links.${linkType}.${id}`;
1186
+ const filters = {
1187
+ [`${path}`]: { $exists: true }
1188
+ };
1189
+
1190
+ if (typeof toBeValidated === "boolean") {
1191
+ filters[`${path}.toBeValidated`] = { $exists: toBeValidated };
1192
+ }
1193
+
1194
+ if (typeof isInviting === "boolean") {
1195
+ filters[`${path}.isInviting`] = { $exists: isInviting };
1196
+ }
1197
+
1198
+ if (isAdmin === true) {
1199
+ filters[`${path}.isAdmin`] = { $exists: true };
1200
+ }
1201
+
1202
+ if(isAdminPending === true) {
1203
+ filters[`${path}.isAdminPending`] = { $exists: true };
1204
+ }
1205
+
1206
+ if (roles.length > 0) {
1207
+ filters[`${path}.roles`] = { $in: roles };
1208
+ }
1209
+
1210
+ return filters;
1211
+ }
1212
+
1213
+ /**
1214
+ * Retourne les métadonnées de lien pour une entité :
1215
+ * - `linkType` : clé dans `serverData.links`
1216
+ * - `connectTypeConnect` : valeur envoyée pour `connect()`
1217
+ * - `connectTypeDisconnect` : valeur envoyée pour `disconnect()`
1218
+ * @typedef {Object} LinkMeta
1219
+ * @property {string} linkType
1220
+ * @property {string} connectTypeConnect
1221
+ * @property {string} connectTypeDisconnect
1222
+ * @returns {LinkMeta}
1223
+ * @throws {ApiError} - Si le type d'entité est inconnu.
1224
+ * @private
1225
+ */
1226
+ _getLinkMeta() {
1227
+ const map = {
1228
+ organizations: {
1229
+ linkType: "memberOf",
1230
+ connectTypeConnect: "member",
1231
+ connectTypeDisconnect: "members"
1232
+ },
1233
+ projects: {
1234
+ linkType: "projects",
1235
+ connectTypeConnect: "contributor",
1236
+ connectTypeDisconnect: "contributors"
1237
+ },
1238
+ events: {
1239
+ linkType: "events",
1240
+ connectTypeConnect: "attendee",
1241
+ connectTypeDisconnect: "attendees"
1242
+ },
1243
+ citoyens: {
1244
+ linkType: "friends",
1245
+ connectTypeConnect: "friend",
1246
+ connectTypeDisconnect: "friends"
1247
+ }
1248
+ };
1249
+
1250
+ const entityType = this.getEntityType();
1251
+ const meta = map[entityType];
1252
+
1253
+ if (!meta) {
1254
+ throw new ApiError(`Aucune correspondance de lien définie pour le type d'entité "${entityType}"`);
1255
+ }
1256
+
1257
+ return meta;
1258
+ }
1259
+
1260
+ /**
1261
+ * Vérifie si l'entité prend en charge les opérations de lien (`connect`, `disconnect`, etc.).
1262
+ * Utilise `_getLinkMeta()` comme source unique de vérité.
1263
+ *
1264
+ * @throws {ApiError} - Si l'entité ne supporte pas les liens.
1265
+ * @private
1266
+ */
1267
+ _checkLinkableEntity() {
1268
+ try {
1269
+ this._getLinkMeta(); // si l'entité n'est pas supportée, ça throw déjà
1270
+ } catch (error) {
1271
+ if (error instanceof ApiError) {
1272
+ throw new ApiError(`L'entité "${this.getEntityType()}" ne prend pas en charge les actions de lien.`);
1273
+ }
1274
+ throw error;
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * Retourne le lien de l'utilisateur connecté avec l'entité cible (dans `parent.serverData.links`)
1280
+ * @private
1281
+ */
1282
+ _getLinkFromConnectedUser() {
1283
+ const { linkType } = this._getLinkMeta();
1284
+ return this.parent?.serverData?.links?.[linkType]?.[this.id] || null;
1285
+ }
1286
+
1287
+ /**
1288
+ * Soumet une demande de connexion à une entité (ex : membre, contributeur),
1289
+ * ou valide l'invitation si elle existe déjà.
1290
+ *
1291
+ * @returns {Promise<Object>} - Résultat de l'API
1292
+ * @throws {ApiError} - En cas d'erreur de connexion ou d'invitation.
1293
+ * @private
1294
+ */
1295
+ async _submitLinkRequest() {
1296
+
1297
+ if (!this.id) {
1298
+ throw new ApiError(`${this.constructor.name} non enregistrée.`);
1299
+ }
1300
+
1301
+ const { connectTypeConnect } = this._getLinkMeta();
1302
+
1303
+ const userLink = this._getLinkFromConnectedUser();
1304
+
1305
+ // Cas : aucun lien → on demande à se connecter
1306
+ if (!userLink) {
1307
+ const data = {
1308
+ parentType: this.getEntityType(),
1309
+ parentId: this.id,
1310
+ connectType: connectTypeConnect
1311
+ };
1312
+
1313
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
1314
+ const retour = await this.callIsMe(() => this.endpointApi.connect(data));
1315
+ await this.parent.refresh();
1316
+ return retour;
1317
+ }
1318
+
1319
+ // Cas : invitation reçue → on valide l'invitation
1320
+ if (userLink.isInviting) {
1321
+ return this._acceptLinkRequest();
1322
+ }
1323
+
1324
+ // Cas : déjà en attente
1325
+ if (userLink.toBeValidated) {
1326
+ throw new ApiError("Vous êtes déjà en attente de validation.");
1327
+ }
1328
+
1329
+ // Cas par défaut : rien à faire
1330
+ throw new ApiError("Vous êtes déjà connecté à cette entité.");
1331
+ }
1332
+
1333
+ /**
1334
+ * Soumet une demande pour devenir administrateur d'une entité.
1335
+ *
1336
+ * @returns {Promise<Object>}
1337
+ * @throws {ApiError}
1338
+ * @private
1339
+ */
1340
+ async _submitLinkRequestAdmin() {
1341
+
1342
+ if (!this.id) {
1343
+ throw new ApiError(`${this.constructor.name} non enregistrée.`);
1344
+ }
1345
+
1346
+ const userLink = this._getLinkFromConnectedUser();
1347
+
1348
+ // Aucun lien existant → envoie une demande avec rôle "admin"
1349
+ if (!userLink) {
1350
+ const data = {
1351
+ parentType: this.getEntityType(),
1352
+ parentId: this.id,
1353
+ connectType: "admin"
1354
+ };
1355
+
1356
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
1357
+ const retour = await this.callIsMe(() => this.endpointApi.connect(data));
1358
+ await this.parent.refresh();
1359
+ return retour;
1360
+ }
1361
+
1362
+ // Invitation reçue → accepte automatiquement
1363
+ if (userLink.isInviting) {
1364
+ return this._acceptLinkRequest();
1365
+ }
1366
+
1367
+ // Déjà en attente pour admin
1368
+ if (userLink.toBeValidated && userLink.isAdminPending) {
1369
+ throw new ApiError("Vous êtes déjà en attente de validation pour devenir admin.");
1370
+ }
1371
+
1372
+ // Déjà en attente pour un autre rôle
1373
+ if (userLink.toBeValidated) {
1374
+ throw new ApiError("Vous êtes déjà en attente de validation pour rejoindre cette entité.");
1375
+ }
1376
+
1377
+ // Déjà connecté
1378
+ throw new ApiError("Vous êtes déjà connecté à cette entité.");
1379
+ }
1380
+
1381
+ /**
1382
+ * Accepte une demande de lien (ex : invitation à rejoindre un groupe).
1383
+ *
1384
+ * @returns {Promise<Object>} - Résultat de l'API
1385
+ * @throws {ApiError} - En cas d'erreur de connexion ou d'invitation.
1386
+ * @private
1387
+ */
1388
+ async _acceptLinkRequest() {
1389
+
1390
+ if (!this.id) {
1391
+ throw new ApiError(`${this.constructor.name} non enregistrée.`);
1392
+ }
1393
+
1394
+ const userLink = this._getLinkFromConnectedUser();
1395
+
1396
+ if (userLink.isInviting) {
1397
+ const data = {
1398
+ parentType: this.getEntityType(),
1399
+ parentId: this.id,
1400
+ linkOption: "isInviting"
1401
+ };
1402
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
1403
+ const retour = await this.callIsMe(() => this.endpointApi.linkValidate(data));
1404
+ await this.parent.refresh();
1405
+ return retour;
1406
+ }
1407
+
1408
+ throw new ApiError("Vous n'avez pas d'invitation à valider.");
1409
+ }
1410
+
1411
+ /**
1412
+ * Annule une demande de lien ou se déconnecte d'une entité.
1413
+ *
1414
+ * @returns {Promise<Object>} - Résultat de l'API
1415
+ * @throws {ApiError} - En cas d'erreur de connexion ou d'invitation.
1416
+ * @private
1417
+ */
1418
+ async _leaveLinkRequest() {
1419
+
1420
+ if (!this.id) {
1421
+ throw new ApiError(`${this.constructor.name} non enregistrée.`);
1422
+ }
1423
+
1424
+ const { connectTypeDisconnect } = this._getLinkMeta();
1425
+
1426
+ const userLink = this._getLinkFromConnectedUser();
1427
+
1428
+ if (!userLink) {
1429
+ throw new ApiError("Vous n'êtes pas connecté à cette entité.");
1430
+ }
1431
+
1432
+ const data = {
1433
+ parentType: this.getEntityType(),
1434
+ parentId: this.id,
1435
+ // Normalement en auto dans le schema mais je le met quand même
1436
+ connectType: connectTypeDisconnect
1437
+ };
1438
+
1439
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
1440
+ const retour = await this.callIsMe(() => this.endpointApi.disconnect(data));
1441
+ await this.parent.refresh();
1442
+ return retour;
1443
+ }
1444
+
1125
1445
  /**
1126
1446
  * ───────────────────────────────
1127
1447
  * EntityMixin
@@ -1543,6 +1863,128 @@ export class BaseEntity {
1543
1863
  return this._createFilteredProxy(rawList);
1544
1864
  }
1545
1865
 
1866
+ /**
1867
+ * Soumet une demande pour rejoindre l'entité courante (ex. organisation, projet, événement...).
1868
+ * Si une invitation est en attente, elle est automatiquement acceptée.
1869
+ *
1870
+ * @returns {Promise<Object>} - Résultat de la demande ou de la validation.
1871
+ * @throws {ApiError} - Si l'entité ne supporte pas l'action ou si une demande est déjà en cours.
1872
+ */
1873
+ async requestToJoin(){
1874
+ if (!this.isMe) {
1875
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.");
1876
+ }
1877
+ this._checkLinkableEntity();
1878
+ return this._submitLinkRequest();
1879
+ }
1880
+
1881
+ /**
1882
+ * Soumet une demande pour devenir administrateur de l'entité courante.
1883
+ * Si une invitation est en attente, elle est automatiquement validée.
1884
+ *
1885
+ * @returns {Promise<Object>} - Résultat de la demande ou de la validation.
1886
+ * @throws {ApiError} - Si l'entité ne supporte pas l'action ou si une demande est déjà en cours.
1887
+ */
1888
+ async requestToJoinAdmin(){
1889
+ if (!this.isMe) {
1890
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.");
1891
+ }
1892
+ this._checkLinkableEntity();
1893
+ return this._submitLinkRequestAdmin();
1894
+ }
1895
+
1896
+ /**
1897
+ * Accepte une invitation à rejoindre l'entité courante.
1898
+ * Ne fonctionne que si un lien avec `isInviting` est détecté.
1899
+ *
1900
+ * @returns {Promise<Object>} - Résultat de l'acceptation de l'invitation.
1901
+ * @throws {ApiError} - Si aucune invitation n'est en attente ou si l'entité ne supporte pas cette action.
1902
+ */
1903
+ async acceptInvitation(){
1904
+ if (!this.isMe) {
1905
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.");
1906
+ }
1907
+ this._checkLinkableEntity();
1908
+ return this._acceptLinkRequest();
1909
+ }
1910
+
1911
+ /**
1912
+ * Se désengage de l'entité courante : quitte un rôle (membre, contributeur, etc.).
1913
+ *
1914
+ * @returns {Promise<Object>} - Résultat de la déconnexion.
1915
+ * @throws {ApiError} - Si l'entité ne supporte pas l'action ou si l'utilisateur n'est pas lié à cette entité.
1916
+ */
1917
+ async leave() {
1918
+ if (!this.isMe) {
1919
+ throw new ApiError("Vous devez être connecté pour envoyer une demande de participation.");
1920
+ }
1921
+ this._checkLinkableEntity();
1922
+ return this._leaveLinkRequest();
1923
+ }
1924
+
1925
+ /**
1926
+ * S'abonne à l'entité courante.
1927
+ *
1928
+ * @returns {Promise<Object>} - Résultat de l'abonnement.
1929
+ * @throws {ApiError} - Si l'utilisateur n'est pas connecté ou si l'entité n'est pas enregistrée.
1930
+ */
1931
+ async follow() {
1932
+ if (!this.isMe) {
1933
+ throw new ApiError("Vous devez être connecté pour suivre cette entité.");
1934
+ }
1935
+
1936
+ if (!this.id) {
1937
+ throw new ApiError(`${this.constructor.name} non enregistrée.`);
1938
+ }
1939
+
1940
+ const userLink = this.parent?.serverData?.links?.["follows"]?.[this.id] || null;
1941
+
1942
+ if (!userLink) {
1943
+ const data = {
1944
+ parentType: this.getEntityType(),
1945
+ parentId: this.id
1946
+ };
1947
+ const retour = await this.callIsMe(() => this.endpointApi.follow(data));
1948
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
1949
+ await this.parent.refresh();
1950
+ return retour;
1951
+ }
1952
+
1953
+ throw new ApiError("Vous êtes déjà abonné à cette entité.");
1954
+ }
1955
+
1956
+ /**
1957
+ * Se désabonne de l'entité courante.
1958
+ *
1959
+ * @returns {Promise<Object>} - Résultat de la désinscription.
1960
+ * @throws {ApiError} - Si l'utilisateur n'est pas connecté ou si l'entité n'est pas enregistrée.
1961
+ */
1962
+ async unfollow() {
1963
+ if (!this.isMe) {
1964
+ throw new ApiError("Vous devez être connecté pour vous désabonner.");
1965
+ }
1966
+
1967
+ if (!this.id) {
1968
+ throw new ApiError(`${this.constructor.name} non enregistrée.`);
1969
+ }
1970
+
1971
+ const userLink = this.parent?.serverData?.links?.["follows"]?.[this.id] || null;
1972
+
1973
+ if (userLink) {
1974
+ const data = {
1975
+ parentType: this.getEntityType(),
1976
+ parentId: this.id,
1977
+ connectType: "followers"
1978
+ };
1979
+ const retour = await this.callIsMe(() => this.endpointApi.disconnect(data));
1980
+ // TODO : reflechir au moyen de remplir parent.serverData et this.serverData avec les data de retour pour eviter un refresh
1981
+ await this.parent.refresh();
1982
+ return retour;
1983
+ }
1984
+
1985
+ throw new ApiError("Vous n'êtes pas abonné à cette entité.");
1986
+ }
1987
+
1546
1988
  }
1547
1989
 
1548
1990
  export default BaseEntity;