@communecter/cocolight-api-client 1.0.141 → 1.0.143

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.
@@ -0,0 +1,196 @@
1
+ import { batch, reactive } from "../utils/reactive.js";
2
+
3
+ import type {
4
+ NotificationAuthor,
5
+ NotificationItemData
6
+ } from "./serverDataType/Notification.js";
7
+
8
+ type User = import("./User.js").User; // backref ; User porte __entityTag donc non proxifié par reactive()
9
+
10
+ /** Taille de page côté serveur (ActivityStream::getNotificationsByStep indexStep=15). */
11
+ const DEFAULT_PAGE_SIZE = 15;
12
+
13
+ /** isUnread/isUnseen de CE destinataire (le serveur unset la clé quand lu/vu -> absence = faux). */
14
+ function stateFor(data: NotificationItemData, userId: string | null, key: "isUnread" | "isUnseen"): boolean {
15
+ if (!userId) return false;
16
+ return data?.notify?.id?.[userId]?.[key] === true;
17
+ }
18
+
19
+ /**
20
+ * Item de notification fin. N'étend PAS BaseEntity : il garde la donnée brute, un backref
21
+ * User (pour router les appels via callIsMe + endpointApi) et un backref manager (pour le recount).
22
+ * Les drapeaux lu/vu sont réactifs (objet reactive({...}) — surface scalaire fiable).
23
+ */
24
+ export class Notification {
25
+ private readonly _data: NotificationItemData;
26
+ private readonly _owner: User;
27
+ private _manager: Notifications | null;
28
+ private readonly _state: { isUnread: boolean; isUnseen: boolean };
29
+
30
+ constructor(data: NotificationItemData, owner: User, manager: Notifications | null = null) {
31
+ this._data = data;
32
+ this._owner = owner;
33
+ this._manager = manager;
34
+ const uid = owner.userId;
35
+ this._state = reactive({
36
+ isUnread: stateFor(data, uid, "isUnread"),
37
+ isUnseen: stateFor(data, uid, "isUnseen")
38
+ });
39
+ }
40
+
41
+ static fromData(data: NotificationItemData, owner: User, manager: Notifications | null = null): Notification {
42
+ return new Notification(data, owner, manager);
43
+ }
44
+
45
+ /** Lie l'item à son manager (appelé par le manager au wrap). */
46
+ _attach(manager: Notifications): this { this._manager = manager; return this; }
47
+
48
+ get id(): string { return this._data.id; } // injecté par ApiClient._transformData (clé de la map = hex)
49
+ get isUnread(): boolean { return this._state.isUnread; } // lecture réactive
50
+ get isUnseen(): boolean { return this._state.isUnseen; }
51
+ get data(): NotificationItemData { return this._data; }
52
+ get author(): NotificationAuthor { return this._data.author; }
53
+ get createdAt(): Date {
54
+ // ApiClient._transformData rend `created` en Date. La string ISO survient après un
55
+ // round-trip toJSON()->JSON.parse->restore(). On fait confiance à la normalisation amont.
56
+ const c = this._data.created;
57
+ return c instanceof Date ? c : new Date(c as string | number);
58
+ }
59
+
60
+ // setters locaux (utilisés par l'optimiste, sans appel réseau)
61
+ _setReadLocal(): void { this._state.isUnread = false; }
62
+ _setSeenLocal(): void { this._state.isUnseen = false; }
63
+ _restoreLocal(unread: boolean, unseen: boolean): void { this._state.isUnread = unread; this._state.isUnseen = unseen; }
64
+
65
+ /** Marque CET item comme lu : optimiste + rollback + recount manager. */
66
+ async markRead(): Promise<void> {
67
+ if (!this._state.isUnread) return;
68
+ const pu = this._state.isUnread, ps = this._state.isUnseen;
69
+ this._setReadLocal();
70
+ this._manager?._recount();
71
+ try {
72
+ await this._owner.markNotificationAsRead(this.id);
73
+ } catch (err) {
74
+ this._restoreLocal(pu, ps);
75
+ this._manager?._recount();
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ /** Pas d'endpoint "seen" par item : flip local uniquement. */
81
+ markSeen(): void { this._setSeenLocal(); this._manager?._recount(); }
82
+
83
+ toJSON(): NotificationItemData { return this._data; }
84
+ }
85
+
86
+ /**
87
+ * Gestionnaire de notifications de l'utilisateur connecté. Accroché à User via `me.notifications`.
88
+ * N'étend PAS BaseEntity. Items dans un tableau SIMPLE (jamais proxifié) ; seuls les scalaires
89
+ * vivent dans un reactive({...}) (loadedUnreadCount, unseenTotal, cursor, hasMore, version).
90
+ * Aucun effect/computed créé ici (pas d'API de dispose -> pas de fuite).
91
+ */
92
+ export class Notifications {
93
+ private readonly _owner: User;
94
+ private readonly _pageSize: number;
95
+ private _items: Notification[] = [];
96
+ private readonly _state: {
97
+ loadedUnreadCount: number; // non-lus PARMI les items chargés (dérivé)
98
+ unseenTotal: number; // total NON-VUS côté serveur (countNotif) — badge cloche
99
+ cursor: number; // offset indexMin (nb d'items déjà chargés)
100
+ hasMore: boolean;
101
+ version: number; // bump à chaque mutation de liste -> réactivité de `items`
102
+ };
103
+
104
+ constructor(owner: User, pageSize: number = DEFAULT_PAGE_SIZE) {
105
+ this._owner = owner;
106
+ this._pageSize = pageSize;
107
+ this._state = reactive({ loadedUnreadCount: 0, unseenTotal: 0, cursor: 0, hasMore: true, version: 0 });
108
+ }
109
+
110
+ // surface réactive consommateur
111
+ get items(): Notification[] { void this._state.version; return this._items; }
112
+ get loadedUnreadCount(): number { return this._state.loadedUnreadCount; }
113
+ get unseenTotal(): number { return this._state.unseenTotal; } // badge "non vus"
114
+ get hasMore(): boolean { return this._state.hasMore; }
115
+
116
+ _recount(): void { this._state.loadedUnreadCount = this._items.filter((n) => n.isUnread).length; }
117
+ private _wrap(raw: NotificationItemData[]): Notification[] { return raw.map((d) => Notification.fromData(d, this._owner, this)); }
118
+ private _commit(items: Notification[]): void { this._items = items; this._state.version++; this._recount(); }
119
+
120
+ /** Première page (reset le curseur). Tri serveur : updated desc, 15/page. */
121
+ async list({ indexMin = 0 }: { indexMin?: number } = {}): Promise<Notification[]> {
122
+ const next = this._wrap(await this._owner.fetchNotifications({ indexMin }));
123
+ this._commit(next);
124
+ this._state.cursor = next.length;
125
+ this._state.hasMore = next.length >= this._pageSize;
126
+ return next;
127
+ }
128
+
129
+ /** Page suivante (curseur indexMin = nb d'items déjà chargés). Concatène par réassignation. */
130
+ async loadMore(): Promise<Notification[]> {
131
+ if (!this._state.hasMore) return [];
132
+ const page = this._wrap(await this._owner.fetchNotifications({ indexMin: this._state.cursor }));
133
+ this._commit([...this._items, ...page]);
134
+ this._state.cursor += page.length;
135
+ this._state.hasMore = page.length >= this._pageSize;
136
+ return page;
137
+ }
138
+
139
+ /** Recharge depuis le début. */
140
+ async refresh(): Promise<Notification[]> { this._state.cursor = 0; this._state.hasMore = true; return this.list(); }
141
+
142
+ /**
143
+ * Badge : total des NON-VUS via GET_NOTIFICATIONS_COUNT (refreshTimestamp = maintenant ->
144
+ * liste vide + countNotif). Met à jour unseenTotal et le retourne.
145
+ */
146
+ async count(): Promise<number> {
147
+ this._state.unseenTotal = await this._owner.fetchNotificationsCount();
148
+ return this._state.unseenTotal;
149
+ }
150
+
151
+ /**
152
+ * PUR : convertit des `NotificationItemData[]` plats (ex. cache React Query après
153
+ * hydratation SSR) en `Notification[]` liées à l'utilisateur, SANS modifier l'état du
154
+ * manager. À utiliser dans un `select` React Query (cache plat -> instances vivantes).
155
+ */
156
+ toItems(data: NotificationItemData[]): Notification[] {
157
+ return this._wrap(data);
158
+ }
159
+
160
+ async markAllRead(): Promise<void> { await this._bulk("read"); }
161
+ async markAllSeen(): Promise<void> { await this._bulk("seen"); }
162
+
163
+ private async _bulk(action: "seen" | "read"): Promise<void> {
164
+ const snap = this._items.map((n) => ({ n, u: n.isUnread, s: n.isUnseen }));
165
+ batch(() => { for (const { n } of snap) { if (action === "read") n._setReadLocal(); else n._setSeenLocal(); } });
166
+ if (action === "seen") this._state.unseenTotal = 0;
167
+ this._recount();
168
+ try {
169
+ await this._owner.markAllNotifications(action);
170
+ } catch (err) {
171
+ batch(() => { for (const { n, u, s } of snap) n._restoreLocal(u, s); });
172
+ this._recount();
173
+ throw err;
174
+ }
175
+ }
176
+
177
+ /** Supprime toutes les notifications de l'utilisateur + vide l'état local. */
178
+ async clear(): Promise<void> {
179
+ await this._owner.removeAllNotifications();
180
+ this._commit([]);
181
+ this._state.cursor = 0;
182
+ this._state.hasMore = false;
183
+ this._state.unseenTotal = 0;
184
+ }
185
+
186
+ // sérialisation (donnée brute ; le backref owner + l'état réactif sont re-construits au restore)
187
+ toJSON(): NotificationItemData[] { return this._items.map((n) => n.toJSON()); }
188
+ restore(rawItems: NotificationItemData[]): this {
189
+ this._commit(this._wrap(rawItems));
190
+ this._state.cursor = rawItems.length;
191
+ return this;
192
+ }
193
+ static restore(rawItems: NotificationItemData[], owner: User): Notifications {
194
+ return new Notifications(owner).restore(rawItems);
195
+ }
196
+ }
@@ -173,13 +173,16 @@ export class Organization extends BaseEntity<OrganizationItemNormalized> {
173
173
  if (rawAnswers && typeof rawAnswers === "object") {
174
174
  const linkedAnswers: Record<string, unknown[]> = {};
175
175
  for (const [formId, docs] of Object.entries(rawAnswers)) {
176
- if (Array.isArray(docs)) {
177
- linkedAnswers[formId] = docs.map((doc) =>
178
- doc && typeof doc === "object"
179
- ? this._linkEntity("answers", { ...(doc as Record<string, unknown>), collection: "answers" })
180
- : doc
181
- );
182
- }
176
+ // Le backend renvoie tantôt un tableau `[doc, ...]`, tantôt une map indexée
177
+ // par id `{ answerId: doc }`. On normalise vers un tableau dans les deux cas.
178
+ const list = Array.isArray(docs)
179
+ ? docs
180
+ : (docs && typeof docs === "object" ? Object.values(docs) : []);
181
+ linkedAnswers[formId] = list.map((doc) =>
182
+ doc && typeof doc === "object"
183
+ ? this._linkEntity("answers", { ...(doc as Record<string, unknown>), collection: "answers" })
184
+ : doc
185
+ );
183
186
  }
184
187
  (data as Record<string, unknown>).answers = linkedAnswers;
185
188
  }
@@ -448,9 +451,75 @@ export class Organization extends BaseEntity<OrganizationItemNormalized> {
448
451
  return paginator.next() as Promise<PaginatorPage<User | Organization>>;
449
452
  }
450
453
 
454
+ /**
455
+ * Récupère les organisations dont **cette** organisation est membre
456
+ * (lien montant : réseau, fédération, collectif… dont l'orga fait partie).
457
+ *
458
+ * C'est le miroir de {@link Organization#getMembers} : `getMembers()` liste
459
+ * *qui* est membre de cette orga, tandis que `getMemberOf()` liste *les orgas
460
+ * dont cette orga est elle-même membre*.
461
+ *
462
+ * Contrairement à {@link BaseEntity#getOrganizations} (désactivée ici car pensée
463
+ * pour un citoyen via `GET_ORGANIZATIONS_*`), on passe par la recherche réseau
464
+ * globale `searchCostum` : on cible `searchType: ["organizations"]` et on filtre
465
+ * sur `links.members.{thisOrgId}`. Seules les adhésions **confirmées** sont
466
+ * retournées (les liens `toBeValidated` et `isInviting` sont exclus).
467
+ *
468
+ * La pagination (`hasNext`/`next()`) et la restauration via `restoredState`
469
+ * fonctionnent comme pour `searchCostum` : le filtre membership est conservé
470
+ * d'une page à l'autre.
471
+ *
472
+ * @param data - Paramètres de recherche/pagination additionnels (`name`, `indexStep`,
473
+ * `sortBy`, `locality`, `filters` complémentaires…). Les `filters` fournis sont
474
+ * fusionnés avec le filtre membership, ce dernier restant prioritaire.
475
+ * @param options - Options. `restoredState` pour reprendre une pagination sérialisée.
476
+ * @returns Première page paginée d'organisations.
477
+ * @throws {ApiError} Si l'organisation n'a pas d'`id`.
478
+ *
479
+ * @example
480
+ * // Toutes les orgas dont cette orga est membre
481
+ * const page = await org.getMemberOf();
482
+ * console.log(page.results, page.count.total);
483
+ *
484
+ * @example
485
+ * // Filtrer par nom + pagination plus large
486
+ * const page = await org.getMemberOf({ name: "réseau", indexStep: 50 });
487
+ * if (page.hasNext) await page.next();
488
+ */
489
+ async getMemberOf(
490
+ data: Parameters<BaseEntity<OrganizationItemNormalized>["searchCostum"]>[0] = {},
491
+ options: { restoredState?: PaginatorState } = {}
492
+ ): Promise<PaginatorPage<Organization>> {
493
+ if (!this.id) {
494
+ throw new ApiError("L'organisation n'est pas définie, impossible de récupérer ses adhésions", 400);
495
+ }
496
+
497
+ const path = `links.members.${this.id}`;
498
+ const membershipFilters = {
499
+ [path]: { $exists: true },
500
+ [`${path}.toBeValidated`]: { $exists: false },
501
+ [`${path}.isInviting`]: { $exists: false }
502
+ };
503
+ const callerFilters = (data?.filters && typeof data.filters === "object") ? data.filters : {};
504
+
505
+ const orgParam: Parameters<typeof this.searchCostum>[0] = {
506
+ indexMin: 0,
507
+ indexStep: 20,
508
+ notSourceKey: true,
509
+ ...data,
510
+ // Champs imposés (le caller ne peut pas casser la sémantique « adhésions »)
511
+ searchType: ["organizations"],
512
+ countType: ["organizations"],
513
+ count: true,
514
+ filters: { ...callerFilters, ...membershipFilters }
515
+ };
516
+
517
+ return this.searchCostum(orgParam, options) as Promise<PaginatorPage<Organization>>;
518
+ }
519
+
451
520
  /**
452
521
  * {@inheritDoc BaseEntity#getGallery}
453
- *
522
+ *
454
523
  * Récupère la galerie de l'organisation.
455
524
  */
456
525
  override async getGallery(data: Parameters<BaseEntity<OrganizationItemNormalized>["getGallery"]>[0] = {}) {
package/src/api/Poi.ts CHANGED
@@ -3,8 +3,38 @@ import { BaseEntity } from "./BaseEntity.js";
3
3
  import { PoiItemNormalized } from "./serverDataType/Poi.js";
4
4
  import { transformEntityRefs } from "../types/transforms.js";
5
5
 
6
+ import type { SetTypeValue } from "./BaseEntity.js";
6
7
  import type { AddPoiData } from "./EndpointApi.types.js";
7
8
 
9
+ /**
10
+ * Source unique des champs « équipement sportif » (RES) éditables sur le POI :
11
+ * - `schema` : type JSON → alimente le VIRTUAL_SCHEMA (draft-allow + cohérence des types)
12
+ * - `setType` : coercion backend pour `UPDATE_PATH_VALUE` (absent = string/array)
13
+ * On en dérive `Poi.VIRTUAL_SCHEMAS`, `Poi.CUSTOM_FIELD_HANDLERS` et `Poi.EQUIPMENT_SETTYPE`.
14
+ */
15
+ const POI_EQUIPMENT_FIELDS: ReadonlyArray<{ name: string; schema: Record<string, unknown>; setType?: SetTypeValue }> = [
16
+ // Nombres (dimensions)
17
+ ...["equip_surf", "equip_larg", "equip_long"].map((name) => ({ name, schema: { type: "number" }, setType: "float" as SetTypeValue })),
18
+ // Dates (string côté schéma, normalisées en Date à la lecture ; isoDate à l'écriture)
19
+ ...["equip_maj_date", "inst_date_creation", "inst_enqu_date"].map((name) => ({ name, schema: { type: "string" }, setType: "isoDate" as SetTypeValue })),
20
+ // Booléens
21
+ ...[
22
+ "inst_acc_handi_bool", "inst_trans_bool", "equip_eclair", "equip_acc_libre", "inst_part_bool",
23
+ "equip_pmr_acc", "equip_pmr_chem", "equip_pmr_douche", "equip_pmr_sanit", "equip_pmr_trib", "equip_pmr_vest",
24
+ "equip_pshs_aire", "equip_pshs_chem", "equip_pshs_sanit", "equip_pshs_trib", "equip_pshs_vest", "equip_pshs_sign",
25
+ "equip_douche"
26
+ ].map((name) => ({ name, schema: { type: "boolean" }, setType: "bool" as SetTypeValue })),
27
+ // Strings
28
+ ...[
29
+ "categorie", "equip_type_name", "inst_nom", "equip_type_famille", "equip_nature", "equip_sol",
30
+ "inst_acc_handi_type", "inst_trans_type", "equip_prop_nom", "equip_prop_type", "equip_gest_type"
31
+ ].map((name) => ({ name, schema: { type: "string" } })),
32
+ // Arrays de strings
33
+ ...[
34
+ "aps_name", "inst_part_type", "equip_loc_type", "equip_utilisateur"
35
+ ].map((name) => ({ name, schema: { type: "array", items: { type: "string" } } }))
36
+ ];
37
+
8
38
  export class Poi extends BaseEntity<PoiItemNormalized> {
9
39
  static override entityType = "poi";
10
40
 
@@ -16,7 +46,8 @@ export class Poi extends BaseEntity<PoiItemNormalized> {
16
46
  "UPDATE_BLOCK_INFO",
17
47
  "UPDATE_BLOCK_LOCALITY",
18
48
  "UPDATE_BLOCK_SLUG",
19
- "PROFIL_IMAGE"
49
+ "PROFIL_IMAGE",
50
+ "VIRTUAL_POI_EQUIPMENT"
20
51
  ];
21
52
 
22
53
  static ADD_BLOCKS = new Map([
@@ -32,6 +63,30 @@ export class Poi extends BaseEntity<PoiItemNormalized> {
32
63
  ["PROFIL_IMAGE", "updateImageProfil"]
33
64
  ] as const);
34
65
 
66
+ /**
67
+ * Champs « équipement sportif » (RES) éditables hors `UPDATE_BLOCK_*` : déclarés en
68
+ * VIRTUAL_SCHEMA (autorise l'écriture sur le draft + fixe les types) et persistés à
69
+ * l'update via `CUSTOM_FIELD_HANDLERS` → `updateEquipmentField` → `UPDATE_PATH_VALUE`.
70
+ * Même pattern qu'`Organization`/`Action`. (Ils restent aussi dans `ADD_POI` pour la création.)
71
+ */
72
+ static override VIRTUAL_SCHEMAS = {
73
+ VIRTUAL_POI_EQUIPMENT: {
74
+ type: "object",
75
+ properties: Object.fromEntries(POI_EQUIPMENT_FIELDS.map((f) => [f.name, f.schema]))
76
+ }
77
+ };
78
+
79
+ static override CUSTOM_FIELD_HANDLERS = new Map(
80
+ POI_EQUIPMENT_FIELDS.map((f) =>
81
+ [f.name, { updateMethod: "updateEquipmentField", schemaConstant: "VIRTUAL_POI_EQUIPMENT" }] as [string, { updateMethod: string; schemaConstant?: string }]
82
+ )
83
+ );
84
+
85
+ /** setType backend par champ équipement (consommé par `updateEquipmentField`). */
86
+ private static readonly EQUIPMENT_SETTYPE = new Map<string, SetTypeValue | undefined>(
87
+ POI_EQUIPMENT_FIELDS.map((f) => [f.name, f.setType])
88
+ );
89
+
35
90
  override defaultFields: Record<string, any> = {
36
91
  typeElement: this.getEntityType()
37
92
  };
@@ -83,6 +138,23 @@ export class Poi extends BaseEntity<PoiItemNormalized> {
83
138
  if (payload.id) delete payload.id;
84
139
  let hasChanged = false;
85
140
 
141
+ // 1. Champs équipement (RES) : non couverts par les UPDATE_BLOCK_*. Persistés
142
+ // individuellement via CUSTOM_FIELD_HANDLERS -> updateEquipmentField -> UPDATE_PATH_VALUE
143
+ // (même mécanisme qu'Organization/Action).
144
+ const customHandlers = Poi.CUSTOM_FIELD_HANDLERS;
145
+ if (customHandlers) {
146
+ const processedFields = new Set<string>();
147
+ for (const [fieldName, config] of customHandlers) {
148
+ if (fieldName in payload && this._hasFieldChanged(fieldName)) {
149
+ await this._invokeCustomFieldHandler(config.updateMethod, payload[fieldName], fieldName);
150
+ processedFields.add(fieldName);
151
+ hasChanged = true;
152
+ }
153
+ }
154
+ processedFields.forEach((f) => delete payload[f]);
155
+ }
156
+
157
+ // 2. Blocs standards (description, info, locality, slug, image).
86
158
  for (const [constant, methodName] of Array.from(Poi.UPDATE_BLOCKS)) {
87
159
  const blockData = this._extractChangedFieldsFromSchema(
88
160
  this.apiClient,
@@ -100,6 +172,34 @@ export class Poi extends BaseEntity<PoiItemNormalized> {
100
172
  return hasChanged;
101
173
  };
102
174
 
175
+ /**
176
+ * Handler générique des champs « équipement sportif » (RES), enregistré dans
177
+ * `CUSTOM_FIELD_HANDLERS`. Persiste un champ via `UPDATE_PATH_VALUE` avec le `setType`
178
+ * adéquat (lu dans `EQUIPMENT_SETTYPE`). `fieldName` est fourni par `_invokeCustomFieldHandler`.
179
+ *
180
+ * Effacement façon `Action` : `null`/`""` → on envoie `""` SANS `setType` → le backend fait
181
+ * `!empty($value)` → `$unset` (sinon `floatval("")=0` écraserait au lieu d'effacer).
182
+ */
183
+ async updateEquipmentField(value: unknown, fieldName: string): Promise<unknown> {
184
+ const setType = Poi.EQUIPMENT_SETTYPE.get(fieldName);
185
+ const isClear = value === null || value === "";
186
+ return this.updateField(fieldName, isClear ? "" : value, isClear || !setType ? {} : { setType });
187
+ }
188
+
189
+ /**
190
+ * Override confiné à `Poi` : forwarde `fieldName` au handler (2e argument) pour permettre
191
+ * un handler générique unique (`updateEquipmentField`) couvrant les ~39 champs équipement,
192
+ * sans toucher `BaseEntity._invokeCustomFieldHandler` (utilisé par les autres entités, qui
193
+ * ne passent que `value`). Copie volontaire du corps de base + le forward du `fieldName`.
194
+ */
195
+ protected override async _invokeCustomFieldHandler(methodName: string, value: any, fieldName: string): Promise<any> {
196
+ const method = (this as any)[methodName];
197
+ if (typeof method !== "function") {
198
+ throw new ApiError(`Custom handler "${methodName}" not found for field "${fieldName}"`, 500);
199
+ }
200
+ return await method.call(this, value, fieldName);
201
+ }
202
+
103
203
  async addPoi(data: Partial<AddPoiData> = {}): Promise<unknown> {
104
204
 
105
205
  // Si le parent direct n'est pas l'utilisateur connecté (ex: création via une org),
@@ -74,13 +74,16 @@ export class Project extends BaseEntity<ProjectItemNormalized> {
74
74
  if (rawAnswers && typeof rawAnswers === "object") {
75
75
  const linkedAnswers: Record<string, unknown[]> = {};
76
76
  for (const [formId, docs] of Object.entries(rawAnswers)) {
77
- if (Array.isArray(docs)) {
78
- linkedAnswers[formId] = docs.map((doc) =>
79
- doc && typeof doc === "object"
80
- ? this._linkEntity("answers", { ...(doc as Record<string, unknown>), collection: "answers" })
81
- : doc
82
- );
83
- }
77
+ // Le backend renvoie tantôt un tableau `[doc, ...]`, tantôt une map indexée
78
+ // par id `{ answerId: doc }`. On normalise vers un tableau dans les deux cas.
79
+ const list = Array.isArray(docs)
80
+ ? docs
81
+ : (docs && typeof docs === "object" ? Object.values(docs) : []);
82
+ linkedAnswers[formId] = list.map((doc) =>
83
+ doc && typeof doc === "object"
84
+ ? this._linkEntity("answers", { ...(doc as Record<string, unknown>), collection: "answers" })
85
+ : doc
86
+ );
84
87
  }
85
88
  (data as Record<string, unknown>).answers = linkedAnswers;
86
89
  }
package/src/api/User.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ApiError } from "../error.js";
2
2
  import { BaseEntity } from "./BaseEntity.js";
3
+ import { Notifications } from "./Notifications.js";
3
4
  import { UserMixin } from "../mixin/UserMixin.js";
4
5
  import { createSocialTransform } from "../types/transforms.js";
5
6
 
@@ -16,10 +17,16 @@ import type {
16
17
  GetSubscriptionsData,
17
18
  GetOrganizationsNoAdminData,
18
19
  GetOrganizationsAdminData,
20
+ GetUserEligiblePlacesData,
19
21
  GetFriendsAdminData,
20
- DemoteAdminData
22
+ DemoteAdminData,
23
+ GetNotificationsData,
24
+ GetNotificationsCountData,
25
+ NotificationUpdateData,
26
+ MarkNotificationAsReadData
21
27
  } from "./EndpointApi.types.js";
22
28
  import type { Organization } from "./Organization.js";
29
+ import type { NotificationItemData } from "./serverDataType/Notification.js";
23
30
  import type { EntityTypes } from "@/types/entities.js";
24
31
 
25
32
  type ApiClient = import("../ApiClient.js").default;
@@ -404,6 +411,59 @@ export class User extends BaseEntity<UserItemNormalized> {
404
411
  return paginator.next() as Promise<PaginatorPage<Organization>>;
405
412
  }
406
413
 
414
+ /**
415
+ * Récupère les lieux (organizations) memberOf de l'utilisateur, filtrés
416
+ * côté serveur par des filters arbitraires (tags, source.key, etc.) et
417
+ * un flag `notSourceKey` configurables.
418
+ *
419
+ * Mirroir de `getOrganizations` mais permet de passer dynamiquement les
420
+ * filtres du finder du formulaire (vue collaborative coform/place), pour
421
+ * que la pagination soit correcte (filtrage côté serveur, pas côté client).
422
+ *
423
+ * Constant : GET_USER_ELIGIBLE_PLACES
424
+ */
425
+ async getEligiblePlaces(
426
+ data: Partial<GetUserEligiblePlacesData> = {},
427
+ options?: { restoredState?: PaginatorState }
428
+ ) {
429
+ // Force le default `searchType` ([NGO, Cooperative, ...]) — le PHP ne
430
+ // retourne rien si le tableau est vide / absent. Pattern aligné avec
431
+ // `getOrganizations`.
432
+ data.searchType = this._getDefaultFromEndpoint(
433
+ "GET_USER_ELIGIBLE_PLACES",
434
+ "searchType"
435
+ ) as GetUserEligiblePlacesData["searchType"];
436
+
437
+ const paginator = this._createPaginatorEngine({
438
+ initialData: data,
439
+ methodName: "getEligiblePlaces",
440
+ restoredState: options?.restoredState,
441
+ finalizer: async (finalData) => {
442
+ delete finalData?.pathParams;
443
+
444
+ // Auto-merge du filtre memberOf par défaut si l'appelant n'en pousse
445
+ // que des filters "métier" (tags, etc.). On garde la sémantique
446
+ // "uniquement les orgs où l'user est membre validé".
447
+ const userFilters = finalData.filters ?? {};
448
+ const memberOfDefaults: Record<string, unknown> = {
449
+ [`links.members.${this.id}`]: { "$exists": true },
450
+ [`links.members.${this.id}.toBeValidated`]: { "$exists": false },
451
+ [`links.members.${this.id}.isInviting`]: { "$exists": false },
452
+ };
453
+ finalData.filters = { ...memberOfDefaults, ...userFilters };
454
+
455
+ // Cast nécessaire : callIsMe est typé Promise<unknown>, mais l'endpoint
456
+ // retourne bien la shape `{ results, count }` attendue par le finalizer.
457
+ // Pattern aligné avec getOrganizations (cf. la branche ternaire au-dessus).
458
+ return this.callIsMe(() => this.endpointApi.getUserEligiblePlaces(finalData)) as ReturnType<
459
+ typeof this.endpointApi.getUserEligiblePlaces
460
+ >;
461
+ },
462
+ });
463
+
464
+ return paginator.next() as Promise<PaginatorPage<Organization>>;
465
+ }
466
+
407
467
  /**
408
468
  * {@inheritDoc BaseEntity#getProjects}
409
469
  *
@@ -1717,6 +1777,71 @@ export class User extends BaseEntity<UserItemNormalized> {
1717
1777
  return retour;
1718
1778
  }
1719
1779
 
1780
+ // ===========================================================================
1781
+ // Notifications (sous-ressource de l'utilisateur connecté)
1782
+ // ===========================================================================
1783
+
1784
+ /** Gestionnaire de notifications, créé paresseusement et mis en cache. */
1785
+ private _notifications?: Notifications;
1786
+
1787
+ /**
1788
+ * Point d'entrée `me.notifications` (composition, PAS une entité BaseEntity).
1789
+ * Sûr : endpointApi/apiClient sont initialisés dans le constructeur avant tout accès getter.
1790
+ */
1791
+ get notifications(): Notifications {
1792
+ return (this._notifications ??= new Notifications(this));
1793
+ }
1794
+
1795
+ /**
1796
+ * Récupère les notifications (mode liste). STATELESS + plat (`NotificationItemData[]`),
1797
+ * idéal comme `queryFn` React Query / prefetch SSR — ne touche PAS le manager réactif
1798
+ * (`me.notifications`). `ApiClient._transformData` garantit `notif` = tableau (map -> array
1799
+ * + `id` injecté), donc on fait confiance à la normalisation amont.
1800
+ * pathParams.id est OBLIGATOIRE : le défaut "@userId" ne matche pas le pattern de l'endpoint.
1801
+ */
1802
+ async fetchNotifications({ indexMin }: { indexMin?: number } = {}): Promise<NotificationItemData[]> {
1803
+ const res = (await this.callIsMe(() => {
1804
+ const data: GetNotificationsData = {
1805
+ pathParams: { type: "citoyens", id: this.userId! },
1806
+ ...(indexMin != null ? { indexMin } : {})
1807
+ };
1808
+ return this.endpointApi.getNotifications(data);
1809
+ })) as { notif?: NotificationItemData[] };
1810
+ return Array.isArray(res?.notif) ? res.notif : [];
1811
+ }
1812
+
1813
+ /**
1814
+ * Compte les notifications NON VUES (badge). STATELESS. refreshTimestamp = maintenant ->
1815
+ * le serveur renvoie `countNotif` (total non-vus) + une liste vide.
1816
+ */
1817
+ async fetchNotificationsCount(): Promise<number> {
1818
+ const res = (await this.callIsMe(() => {
1819
+ const data: GetNotificationsCountData = {
1820
+ pathParams: { type: "citoyens", id: this.userId! },
1821
+ refreshTimestamp: Math.floor(Date.now() / 1000)
1822
+ };
1823
+ return this.endpointApi.getNotificationsCount(data);
1824
+ })) as { countNotif?: number };
1825
+ return typeof res?.countNotif === "number" ? res.countNotif : 0;
1826
+ }
1827
+
1828
+ /** Marque une notification comme lue (MARK_NOTIFICATION_AS_READ). */
1829
+ async markNotificationAsRead(id: string): Promise<unknown> {
1830
+ const data: MarkNotificationAsReadData = { id };
1831
+ return this.callIsMe(() => this.endpointApi.markNotificationAsRead(data));
1832
+ }
1833
+
1834
+ /** Marque toutes les notifications comme vues/lues (NOTIFICATION_UPDATE). */
1835
+ async markAllNotifications(action: "seen" | "read"): Promise<unknown> {
1836
+ const data: NotificationUpdateData = { action, all: true };
1837
+ return this.callIsMe(() => this.endpointApi.notificationUpdate(data));
1838
+ }
1839
+
1840
+ /** Supprime toutes les notifications de l'utilisateur (REMOVE_ALL_NOTIFICATIONS). */
1841
+ async removeAllNotifications(): Promise<unknown> {
1842
+ return this.callIsMe(() => this.endpointApi.removeAllNotifications());
1843
+ }
1844
+
1720
1845
  }
1721
1846
 
1722
1847
  // Incorporation des mixins dans User
@@ -134,3 +134,24 @@ export interface FormItemNormalized {
134
134
  useBannerImg?: boolean;
135
135
  [key: string]: unknown;
136
136
  }
137
+
138
+ /**
139
+ * Une contribution individuelle à une table commune (commonTable) d'un coform.
140
+ * Retournée par `Form.getCommonTableContributors()` (endpoint GET_COFORM_COMMONTABLE_CONTRIBUTORS).
141
+ * Plusieurs entrées possibles pour un même `userId` (plusieurs solutions au même usage).
142
+ */
143
+ export interface CoformCommonTableContributor {
144
+ userId: string;
145
+ userName?: string;
146
+ userSlug?: string;
147
+ criteriaId: string;
148
+ /** Nom de la solution saisie. */
149
+ criteria: string;
150
+ /** love | happySmile | neutral | sad | cry | "" */
151
+ happiness?: string;
152
+ /** Note de 0 à 5. */
153
+ note?: number;
154
+ comment?: string;
155
+ fromAnswerId?: string;
156
+ [k: string]: unknown;
157
+ }