@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.
- package/dist/cocolight-api-client.browser.js +1 -1
- 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/ApiClient.ts +2 -2
- package/src/api/BaseEntity.ts +12 -0
- package/src/api/EndpointApi.ts +80 -1
- package/src/api/EndpointApi.types.ts +296 -0
- package/src/api/Form.ts +43 -1
- package/src/api/Notifications.ts +196 -0
- package/src/api/Organization.ts +77 -8
- package/src/api/Poi.ts +101 -1
- package/src/api/Project.ts +10 -7
- package/src/api/User.ts +126 -1
- package/src/api/serverDataType/Form.ts +21 -0
- package/src/api/serverDataType/Notification.ts +81 -0
- package/src/endpoints.module.ts +194 -106
- package/src/index.ts +7 -0
- package/types/api/EndpointApi.d.ts +50 -1
- package/types/api/EndpointApi.types.d.ts +286 -0
- package/types/api/Form.d.ts +28 -1
- package/types/api/Notifications.d.ts +79 -0
- package/types/api/Organization.d.ts +38 -0
- package/types/api/Poi.d.ts +36 -0
- package/types/api/User.d.ts +45 -1
- package/types/api/serverDataType/Form.d.ts +20 -0
- package/types/api/serverDataType/Notification.d.ts +73 -0
- package/types/endpoints.module.d.ts +7123 -1105
- package/types/index.d.ts +3 -0
|
@@ -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
|
+
}
|
package/src/api/Organization.ts
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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),
|
package/src/api/Project.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|