@communecter/cocolight-api-client 1.0.130 → 1.0.132
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 +3 -3
- 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 +4 -4
- package/src/ApiClient.ts +3 -1
- package/src/api/Action.ts +535 -4
- package/src/api/Answer.ts +4 -1
- package/src/api/Badge.ts +5 -0
- package/src/api/BaseEntity.ts +118 -7
- package/src/api/Classified.ts +4 -0
- package/src/api/Comment.ts +3 -0
- package/src/api/EndpointApi.ts +96 -1
- package/src/api/EndpointApi.types.ts +448 -44
- package/src/api/Event.ts +5 -0
- package/src/api/Form.ts +64 -2
- package/src/api/News.ts +3 -0
- package/src/api/Organization.ts +47 -3
- package/src/api/Poi.ts +4 -0
- package/src/api/Project.ts +61 -0
- package/src/api/User.ts +10 -0
- package/src/api/serverDataType/Answer.ts +25 -0
- package/src/api/serverDataType/Form.ts +84 -4
- package/src/api/serverDataType/Organization.ts +13 -0
- package/src/api/serverDataType/Project.ts +15 -0
- package/src/endpoints.module.ts +1252 -268
- package/types/api/Action.d.ts +276 -2
- package/types/api/Answer.d.ts +1 -0
- package/types/api/Badge.d.ts +1 -0
- package/types/api/BaseEntity.d.ts +62 -0
- package/types/api/Classified.d.ts +1 -0
- package/types/api/Comment.d.ts +1 -0
- package/types/api/EndpointApi.d.ts +60 -1
- package/types/api/EndpointApi.types.d.ts +397 -41
- package/types/api/Event.d.ts +1 -0
- package/types/api/Form.d.ts +34 -0
- package/types/api/News.d.ts +1 -0
- package/types/api/Organization.d.ts +20 -1
- package/types/api/Poi.d.ts +1 -0
- package/types/api/Project.d.ts +29 -0
- package/types/api/User.d.ts +7 -0
- package/types/api/serverDataType/Answer.d.ts +22 -0
- package/types/api/serverDataType/Form.d.ts +89 -4
- package/types/api/serverDataType/Organization.d.ts +13 -0
- package/types/api/serverDataType/Project.d.ts +15 -0
- package/types/endpoints.module.d.ts +2825 -1491
package/package.json
CHANGED
package/src/Api.ts
CHANGED
|
@@ -132,9 +132,9 @@ export default class Api {
|
|
|
132
132
|
async user(userData: EntityData): Promise<User> {
|
|
133
133
|
try {
|
|
134
134
|
if (!userData.id && !userData.slug) {
|
|
135
|
-
return new User(this._client, userData, { EndpointApi, Organization, Project, Event, Poi, Badge, News, Comment, Answer, Classified, Action });
|
|
135
|
+
return new User(this._client, userData, { EndpointApi, Organization, Project, Event, Poi, Badge, News, Comment, Answer, Form, Classified, Action });
|
|
136
136
|
} else {
|
|
137
|
-
const user = new User(this._client, userData, { EndpointApi, Organization, Project, Event, Poi, Badge, News, Comment, Answer, Classified, Action });
|
|
137
|
+
const user = new User(this._client, userData, { EndpointApi, Organization, Project, Event, Poi, Badge, News, Comment, Answer, Form, Classified, Action });
|
|
138
138
|
await user.get();
|
|
139
139
|
return user;
|
|
140
140
|
}
|
|
@@ -150,7 +150,7 @@ export default class Api {
|
|
|
150
150
|
*/
|
|
151
151
|
async organization(organizationData: EntityData): Promise<Organization> {
|
|
152
152
|
try {
|
|
153
|
-
const organization = new Organization(this._client, organizationData, { EndpointApi, User, Project, Event, Poi, Badge, News, Comment, Answer, Classified, Action });
|
|
153
|
+
const organization = new Organization(this._client, organizationData, { EndpointApi, User, Project, Event, Poi, Badge, News, Comment, Answer, Form, Classified, Action });
|
|
154
154
|
if (!organizationData.id && !organizationData.slug) {
|
|
155
155
|
throw new Error("Vous devez fournir un id ou un slug pour créer une instance Organization.");
|
|
156
156
|
}
|
|
@@ -167,7 +167,7 @@ export default class Api {
|
|
|
167
167
|
*/
|
|
168
168
|
async project(projectData: EntityData): Promise<Project> {
|
|
169
169
|
try {
|
|
170
|
-
const project = new Project(this._client, projectData, { EndpointApi, User, Organization, Event, Poi, Badge, News, Comment, Answer, Classified, Action });
|
|
170
|
+
const project = new Project(this._client, projectData, { EndpointApi, User, Organization, Event, Poi, Badge, News, Comment, Answer, Form, Classified, Action });
|
|
171
171
|
if (!projectData.id && !projectData.slug) {
|
|
172
172
|
throw new Error("Vous devez fournir un id ou un slug pour créer une instance Project.");
|
|
173
173
|
}
|
package/src/ApiClient.ts
CHANGED
package/src/api/Action.ts
CHANGED
|
@@ -11,8 +11,10 @@ import type { ActionItemNormalized } from "./serverDataType/Action.js";
|
|
|
11
11
|
* S'instancie via `project.action(...)` plutôt que directement.
|
|
12
12
|
*
|
|
13
13
|
* Endpoints utilisés :
|
|
14
|
-
* - `get()`
|
|
15
|
-
* - `_add()`
|
|
14
|
+
* - `get()` : hérité (GET_ELEMENTS_ABOUT via `/co2/element/about/type/actions/id/{id}`)
|
|
15
|
+
* - `_add()` : COSTUM_PROJECT_ACTION_REQUEST_NEW (parentId/parentType injectés depuis le parent)
|
|
16
|
+
* - `_update()` : UPDATE_PATH_VALUE field par field via CUSTOM_FIELD_HANDLERS
|
|
17
|
+
* (pas d'endpoint dédié par bloc côté backend — on dispatche chaque champ modifié)
|
|
16
18
|
*/
|
|
17
19
|
export class Action extends BaseEntity<ActionItemNormalized> {
|
|
18
20
|
static override entityType = "actions";
|
|
@@ -20,18 +22,97 @@ export class Action extends BaseEntity<ActionItemNormalized> {
|
|
|
20
22
|
static override entityTag = "Action";
|
|
21
23
|
|
|
22
24
|
static override SCHEMA_CONSTANTS: string[] = [
|
|
23
|
-
"COSTUM_PROJECT_ACTION_REQUEST_NEW"
|
|
25
|
+
"COSTUM_PROJECT_ACTION_REQUEST_NEW",
|
|
26
|
+
"VIRTUAL_ACTION_EDITABLE"
|
|
24
27
|
];
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Schémas virtuels : enregistrent les champs éditables auprès du draft proxy
|
|
31
|
+
* via _composeAllowedFields. Aucun endpoint backend réel — l'update passe par
|
|
32
|
+
* UPDATE_PATH_VALUE field par field.
|
|
33
|
+
*/
|
|
34
|
+
static override VIRTUAL_SCHEMAS = {
|
|
35
|
+
VIRTUAL_ACTION_EDITABLE: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
name: { type: "string" },
|
|
39
|
+
credits: { type: "integer" },
|
|
40
|
+
min: { type: "integer", minimum: 0 },
|
|
41
|
+
max: { type: "integer", minimum: 0 },
|
|
42
|
+
status: { type: "string", enum: ["todo", "done"] },
|
|
43
|
+
tags: { type: "array", items: { type: "string" } },
|
|
44
|
+
links: { type: "object" },
|
|
45
|
+
startDate: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
46
|
+
endDate: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
47
|
+
milestone: { oneOf: [{ type: "object" }, { type: "null" }] },
|
|
48
|
+
importance: { type: "string" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
26
53
|
static ADD_BLOCKS = new Map([
|
|
27
54
|
["COSTUM_PROJECT_ACTION_REQUEST_NEW", "addAction"]
|
|
28
55
|
] as const);
|
|
29
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Champs éditables → méthode publique qui réalise l'update (via UPDATE_PATH_VALUE).
|
|
59
|
+
* Même pattern qu'Organization.openingHours : chaque field nom → method name.
|
|
60
|
+
* Les `updateXxx` méthodes ci-dessous délèguent toutes à `_updatePath`.
|
|
61
|
+
*/
|
|
62
|
+
static override CUSTOM_FIELD_HANDLERS = new Map([
|
|
63
|
+
["name", { updateMethod: "updateName", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
64
|
+
["credits", { updateMethod: "updateCredits", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
65
|
+
["min", { updateMethod: "updateMin", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
66
|
+
["max", { updateMethod: "updateMax", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
67
|
+
["status", { updateMethod: "updateStatus", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
68
|
+
["tags", { updateMethod: "updateTags", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
69
|
+
["links", { updateMethod: "updateContributors", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
70
|
+
["startDate", { updateMethod: "updateStartDate", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
71
|
+
["endDate", { updateMethod: "updateEndDate", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
72
|
+
["milestone", { updateMethod: "updateMilestone", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }],
|
|
73
|
+
["importance", { updateMethod: "updateImportance", schemaConstant: "VIRTUAL_ACTION_EDITABLE" }]
|
|
74
|
+
] as const);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Champs présents dans le schéma de création (donc writable dans le draft) mais
|
|
78
|
+
* **non modifiables après création** — soit pas exposés en update backend, soit
|
|
79
|
+
* gérés par un autre flow.
|
|
80
|
+
*
|
|
81
|
+
* Si un caller modifie un de ces champs après get() puis save(), `_update` throw
|
|
82
|
+
* avec un message d'aide indiquant la voie alternative. Sans cette garde, le
|
|
83
|
+
* changement serait silencieusement ignoré.
|
|
84
|
+
*/
|
|
85
|
+
private static CREATE_ONLY_FIELDS: Record<string, string> = {
|
|
86
|
+
mentions: "Utilise joinContributor/leaveContributor/updateContributors à la place.",
|
|
87
|
+
is_contributor: "Flag de création one-shot (auto-ajoute le créateur comme admin contributor).",
|
|
88
|
+
assign: "Flag de création one-shot (assigne un user comme admin contributor à la création).",
|
|
89
|
+
urls: "Non modifiable en update (à confirmer empiriquement avec le backend).",
|
|
90
|
+
idParentRoom: "Auto-géré par le backend (Room::getOrCreateActionRoom).",
|
|
91
|
+
timeSpent: "Modifié implicitement par le flow status=done (auto-injecte updateStatus[N].timeSpent côté backend)."
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Slot UPDATE_BLOCKS vide pour l'instant : aucun endpoint dédié côté backend.
|
|
96
|
+
* Préservé pour cohérence structurelle avec Organization/Project — quand un endpoint
|
|
97
|
+
* `UPDATE_BLOCK_ACTION_XXX` arrivera, il s'ajoutera ici sans toucher au flow.
|
|
98
|
+
*/
|
|
99
|
+
static UPDATE_BLOCKS = new Map<string, string>([]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `parentType` est injecté au payload de création (required par le schéma
|
|
103
|
+
* COSTUM_PROJECT_ACTION_REQUEST_NEW). `_add` ne filtre pas avec removeFields,
|
|
104
|
+
* donc il passe bien à la requête. Reste défensif si AJV `useDefaults` change.
|
|
105
|
+
*/
|
|
30
106
|
override defaultFields: Record<string, any> = {
|
|
31
107
|
parentType: "projects"
|
|
32
108
|
};
|
|
33
109
|
|
|
34
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Champs techniques exclus du draft writable (set au moment de la création,
|
|
112
|
+
* jamais modifiables ensuite par le caller). Restent accessibles en lecture
|
|
113
|
+
* via `action.serverData.parentId` / `.parentType`.
|
|
114
|
+
*/
|
|
115
|
+
override removeFields: string[] = ["parentType", "parentId"];
|
|
35
116
|
|
|
36
117
|
override _add = async (payload: Record<string, any>): Promise<void> => {
|
|
37
118
|
if (!this._calledFromSave) {
|
|
@@ -46,6 +127,19 @@ export class Action extends BaseEntity<ActionItemNormalized> {
|
|
|
46
127
|
throw new ApiError(`Une Action ne peut être créée que depuis un Project (parent reçu: ${this.parent.getEntityType()}).`, 400);
|
|
47
128
|
}
|
|
48
129
|
|
|
130
|
+
// Validation milestone côté SDK : si fourni, doit référencer un milestone existant
|
|
131
|
+
// dans le projet parent (`project.serverData.oceco.milestones[]`).
|
|
132
|
+
const milestoneId = (payload.milestone as { milestoneId?: string } | undefined)?.milestoneId;
|
|
133
|
+
if (milestoneId) {
|
|
134
|
+
const parent = this.parent as { hasMilestone?: (id: string) => boolean };
|
|
135
|
+
if (typeof parent.hasMilestone === "function" && !parent.hasMilestone(milestoneId)) {
|
|
136
|
+
throw new ApiError(
|
|
137
|
+
`Milestone "${milestoneId}" introuvable dans project.serverData.oceco.milestones — refresh le projet ou utilise un milestoneId valide.`,
|
|
138
|
+
400
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
49
143
|
payload.parentId = this.parent.id;
|
|
50
144
|
|
|
51
145
|
for (const [constant, methodName] of Array.from(Action.ADD_BLOCKS)) {
|
|
@@ -73,4 +167,441 @@ export class Action extends BaseEntity<ActionItemNormalized> {
|
|
|
73
167
|
async addAction(data: Partial<CostumProjectActionRequestNewData> = {}): Promise<unknown> {
|
|
74
168
|
return this.callIsConnected(() => this.endpointApi.costumProjectActionRequestNew(data as CostumProjectActionRequestNewData));
|
|
75
169
|
}
|
|
170
|
+
|
|
171
|
+
override _update = async (payload: Record<string, any>): Promise<boolean> => {
|
|
172
|
+
if (!this._calledFromSave) {
|
|
173
|
+
throw new ApiError("utilisation invalide de _update, utilisez save", 400);
|
|
174
|
+
}
|
|
175
|
+
if (!this.id) {
|
|
176
|
+
throw new ApiError("Action sans id, impossible de mettre à jour.", 400);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Cohérent avec la création (Project.action) : seul un admin du projet parent peut modifier.
|
|
180
|
+
const parent = this.parent;
|
|
181
|
+
if (!parent || typeof (parent as any).isAdmin !== "function" || !(parent as any).isAdmin()) {
|
|
182
|
+
throw new ApiError("Vous n'avez pas les droits pour modifier cette action", 403);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (payload.id) delete payload.id;
|
|
186
|
+
|
|
187
|
+
// Garde anti-piège silencieux : champs présents dans le schéma de création mais
|
|
188
|
+
// non modifiables après création (mentions, is_contributor, assign, urls,
|
|
189
|
+
// idParentRoom, timeSpent). Sans cette garde, le draft accepte l'écriture mais
|
|
190
|
+
// l'update ne dispatch rien — le caller pense que ça marche.
|
|
191
|
+
for (const [field, helpMsg] of Object.entries(Action.CREATE_ONLY_FIELDS)) {
|
|
192
|
+
if (this._hasFieldChanged(field)) {
|
|
193
|
+
throw new ApiError(
|
|
194
|
+
`"${field}" est create-only, non modifiable après création. ${helpMsg}`,
|
|
195
|
+
400
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let hasChanged = false;
|
|
201
|
+
|
|
202
|
+
// 1. Custom field handlers : chaque champ modifié dispatch vers sa méthode dédiée.
|
|
203
|
+
// Exécution séquentielle (cohérent avec Organization) — chaque méthode fait son
|
|
204
|
+
// propre UPDATE_PATH_VALUE. Si un échec survient, on s'arrête (pas de revert,
|
|
205
|
+
// état partiel possible — hérité de l'architecture multi-call).
|
|
206
|
+
const customHandlers = Action.CUSTOM_FIELD_HANDLERS;
|
|
207
|
+
if (customHandlers) {
|
|
208
|
+
const processedFields = new Set<string>();
|
|
209
|
+
for (const [fieldName, config] of customHandlers) {
|
|
210
|
+
if (fieldName in payload && this._hasFieldChanged(fieldName)) {
|
|
211
|
+
await this._invokeCustomFieldHandler(config.updateMethod, payload[fieldName], fieldName);
|
|
212
|
+
processedFields.add(fieldName);
|
|
213
|
+
hasChanged = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
processedFields.forEach(f => delete payload[f]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2. Block updates dédiés (placeholder pour endpoints futurs : aucun pour l'instant).
|
|
220
|
+
for (const [constant, methodName] of Array.from(Action.UPDATE_BLOCKS)) {
|
|
221
|
+
const blockData = this._extractChangedFieldsFromSchema(
|
|
222
|
+
this.apiClient,
|
|
223
|
+
constant,
|
|
224
|
+
{ ...payload, ...this.defaultFields },
|
|
225
|
+
() => this.initialDraftData,
|
|
226
|
+
this.removeFields
|
|
227
|
+
);
|
|
228
|
+
if (blockData && Object.keys(blockData).length > 0) {
|
|
229
|
+
await this._invokeBlockMethod(Action.UPDATE_BLOCKS, methodName, blockData);
|
|
230
|
+
hasChanged = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return hasChanged;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Méthodes publiques par champ : utilisables via save() OU directement par le caller.
|
|
239
|
+
// Toutes délèguent à _updatePath qui encapsule UPDATE_PATH_VALUE.
|
|
240
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async updateName(value: string): Promise<unknown> {
|
|
243
|
+
const result = await this._updatePath("name", value);
|
|
244
|
+
await this._refreshIfDirect();
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Met à jour `credits` (entier). `setType: "int"` force le cast côté backend
|
|
250
|
+
* (form-urlencoded transmet en string sinon).
|
|
251
|
+
*/
|
|
252
|
+
async updateCredits(value: number): Promise<unknown> {
|
|
253
|
+
this._assertInt("credits", value);
|
|
254
|
+
const result = await this._updatePath("credits", value, "int");
|
|
255
|
+
await this._refreshIfDirect();
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Met à jour `min` (entier ≥ 0). `setType: "int"`. */
|
|
260
|
+
async updateMin(value: number): Promise<unknown> {
|
|
261
|
+
this._assertInt("min", value);
|
|
262
|
+
const result = await this._updatePath("min", value, "int");
|
|
263
|
+
await this._refreshIfDirect();
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Met à jour `max` (entier ≥ 0). `setType: "int"`. */
|
|
268
|
+
async updateMax(value: number): Promise<unknown> {
|
|
269
|
+
this._assertInt("max", value);
|
|
270
|
+
const result = await this._updatePath("max", value, "int");
|
|
271
|
+
await this._refreshIfDirect();
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Change le statut de l'action via l'endpoint dédié backend `set_status`.
|
|
277
|
+
*
|
|
278
|
+
* **PAS via UPDATE_PATH_VALUE** : utiliser cet endpoint préserve la logique métier
|
|
279
|
+
* critique :
|
|
280
|
+
* - Ajoute une entrée dans `updateStatus[]` (historique des changements)
|
|
281
|
+
* - Auto endDate=now si status="done", auto startDate=now si "tracking"
|
|
282
|
+
* - Auto-purge des tags discuter/totest/next à la transition
|
|
283
|
+
* - Auto-set du tag pour discuter/next/totest (alias de "todo")
|
|
284
|
+
* - Notification Rocket.Chat au projet parent
|
|
285
|
+
*
|
|
286
|
+
* Statuses supportés côté backend : todo, done, tracking, discuter, next, totest, disabled, closed.
|
|
287
|
+
*/
|
|
288
|
+
async updateStatus(value: "todo" | "done" | "tracking" | "discuter" | "next" | "totest" | "disabled" | "closed"): Promise<unknown> {
|
|
289
|
+
const result = await this.callIsConnected(() =>
|
|
290
|
+
this.endpointApi.costumProjectActionRequestSetStatus({
|
|
291
|
+
id: this.id!,
|
|
292
|
+
status: value
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
await this._refreshIfDirect();
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async updateTags(value: string[]): Promise<unknown> {
|
|
300
|
+
const result = await this._updatePath("tags", value);
|
|
301
|
+
await this._refreshIfDirect();
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Met à jour `importance` ("low" / "medium" / "high" / autres valeurs custom).
|
|
307
|
+
* Champ simple sans effets de bord backend connus.
|
|
308
|
+
*/
|
|
309
|
+
async updateImportance(value: string): Promise<unknown> {
|
|
310
|
+
if (typeof value !== "string") {
|
|
311
|
+
throw new ApiError(`importance: attendu string. Reçu: ${JSON.stringify(value)}`, 400);
|
|
312
|
+
}
|
|
313
|
+
const result = await this._updatePath("importance", value);
|
|
314
|
+
await this._refreshIfDirect();
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Remplace la liste complète des contributeurs via l'endpoint dédié `set_contributors`.
|
|
320
|
+
*
|
|
321
|
+
* **Sécurité** : touche plusieurs users → réservé aux admins du projet parent.
|
|
322
|
+
*
|
|
323
|
+
* Avantages vs UPDATE_PATH_VALUE :
|
|
324
|
+
* - Validation min/max côté backend (refus si hors bornes)
|
|
325
|
+
* - Notification socket aux autres clients (.set-contributors, .cd-int-contributors)
|
|
326
|
+
* - Réponse enrichie avec name/image des contributeurs
|
|
327
|
+
*
|
|
328
|
+
* @param value objet `{ contributors: { userId: {...} } }` — on extrait juste les keys (userIds).
|
|
329
|
+
* Les méta (type, isAdmin, name) sont gérées côté backend (type="citoyens" par défaut).
|
|
330
|
+
* Pour vider : passer `{ contributors: {} }`.
|
|
331
|
+
*/
|
|
332
|
+
async updateContributors(value: { contributors?: Record<string, unknown> }): Promise<unknown> {
|
|
333
|
+
this._assertParentAdminForContributorMutation("updateContributors");
|
|
334
|
+
const userIds = Object.keys(value?.contributors ?? {});
|
|
335
|
+
const result = await this.callIsConnected(() =>
|
|
336
|
+
this.endpointApi.costumProjectActionRequestSetContributors({
|
|
337
|
+
action: this.id!,
|
|
338
|
+
contributors: userIds
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
await this._refreshIfDirect();
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Ajoute un user comme contributeur de l'action (mode "join").
|
|
347
|
+
* Préserve les autres contributeurs existants.
|
|
348
|
+
*
|
|
349
|
+
* **Sécurité** :
|
|
350
|
+
* - Sans argument (ou userId = soi-même) → autorisé pour tout user connecté ("je participe")
|
|
351
|
+
* - Avec un autre userId → réservé aux admins du projet parent ("j'ajoute X")
|
|
352
|
+
*
|
|
353
|
+
* @param userId userId à ajouter. Par défaut : l'utilisateur connecté.
|
|
354
|
+
*/
|
|
355
|
+
async joinContributor(userId?: string): Promise<unknown> {
|
|
356
|
+
const id = userId ?? this.userId;
|
|
357
|
+
if (!id) throw new ApiError("userId requis pour joinContributor (utilisateur non connecté ou paramètre absent)", 400);
|
|
358
|
+
|
|
359
|
+
const isSelf = id === this.userId;
|
|
360
|
+
if (!isSelf) {
|
|
361
|
+
this._assertParentAdminForContributorMutation("joinContributor", id);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const result = await this.callIsConnected(() =>
|
|
365
|
+
this.endpointApi.costumProjectActionRequestSetContributors({
|
|
366
|
+
action: this.id!,
|
|
367
|
+
contributor: id,
|
|
368
|
+
participate: 1
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
await this._refreshIfDirect();
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Retire un user de la liste des contributeurs (mode "quit").
|
|
377
|
+
* Préserve les autres contributeurs existants.
|
|
378
|
+
*
|
|
379
|
+
* **Sécurité** :
|
|
380
|
+
* - Sans argument (ou userId = soi-même) → autorisé pour tout user connecté ("je quitte")
|
|
381
|
+
* - Avec un autre userId → réservé aux admins du projet parent ("je retire X")
|
|
382
|
+
*
|
|
383
|
+
* @param userId userId à retirer. Par défaut : l'utilisateur connecté.
|
|
384
|
+
*/
|
|
385
|
+
async leaveContributor(userId?: string): Promise<unknown> {
|
|
386
|
+
const id = userId ?? this.userId;
|
|
387
|
+
if (!id) throw new ApiError("userId requis pour leaveContributor (utilisateur non connecté ou paramètre absent)", 400);
|
|
388
|
+
|
|
389
|
+
const isSelf = id === this.userId;
|
|
390
|
+
if (!isSelf) {
|
|
391
|
+
this._assertParentAdminForContributorMutation("leaveContributor", id);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const result = await this.callIsConnected(() =>
|
|
395
|
+
this.endpointApi.costumProjectActionRequestSetContributors({
|
|
396
|
+
action: this.id!,
|
|
397
|
+
contributor: id,
|
|
398
|
+
participate: 0
|
|
399
|
+
})
|
|
400
|
+
);
|
|
401
|
+
await this._refreshIfDirect();
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Vérifie que l'utilisateur connecté est admin du projet parent — requis pour les
|
|
407
|
+
* mutations affectant des tiers (replace bulk ou agir sur un autre userId).
|
|
408
|
+
*/
|
|
409
|
+
private _assertParentAdminForContributorMutation(method: string, targetUserId?: string): void {
|
|
410
|
+
const parent = this.parent as { isAdmin?: () => boolean } | null;
|
|
411
|
+
if (!parent || typeof parent.isAdmin !== "function" || !parent.isAdmin()) {
|
|
412
|
+
const target = targetUserId ? ` (cible: "${targetUserId}")` : "";
|
|
413
|
+
throw new ApiError(
|
|
414
|
+
`${method}${target} : seul un admin du projet parent peut effectuer cette opération.`,
|
|
415
|
+
403
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Met à jour ou efface le milestone d'une action.
|
|
422
|
+
* - `{ milestoneId }` : attache (validé via `Project.hasMilestone`).
|
|
423
|
+
* - `null` : détache via `$unset` Mongo côté backend.
|
|
424
|
+
*
|
|
425
|
+
* **Détail technique de l'effacement** :
|
|
426
|
+
* Le backend (`Element::updatePathValue`) fait `$verb = '$unset'` quand `$value === null`.
|
|
427
|
+
* Notre SDK strip les `null` avant l'envoi (`stripNullsInPlace`), donc on contourne en
|
|
428
|
+
* envoyant la chaîne vide `""` : la coercion PHP `(!empty($value) || $value == "0" || $value === false)`
|
|
429
|
+
* la transforme en `null` → `$unset`. Bonus : évite aussi le bug backend
|
|
430
|
+
* `"text" => $_POST["value"]` (accès direct, crash si value absent).
|
|
431
|
+
*
|
|
432
|
+
* NOTE: changer/effacer le milestone peut avoir des effets de bord côté backend non
|
|
433
|
+
* gérés (compteurs par milestone, idParentRoom, statuts dérivés). À utiliser avec discernement.
|
|
434
|
+
*/
|
|
435
|
+
async updateMilestone(value: { milestoneId: string } | null): Promise<unknown> {
|
|
436
|
+
let result: unknown;
|
|
437
|
+
if (value === null) {
|
|
438
|
+
// Hack: envoyer "" (string vide) au lieu de null. Côté PHP, la coercion
|
|
439
|
+
// `!empty($value)` la transforme en null → backend fait $unset.
|
|
440
|
+
result = await this._updatePath("milestone", "");
|
|
441
|
+
} else {
|
|
442
|
+
const milestoneId = value?.milestoneId;
|
|
443
|
+
if (typeof milestoneId !== "string" || milestoneId.length === 0) {
|
|
444
|
+
throw new ApiError("milestone: attendu objet { milestoneId: string non-vide } ou null pour effacer.", 400);
|
|
445
|
+
}
|
|
446
|
+
const parent = this.parent as { hasMilestone?: (id: string) => boolean };
|
|
447
|
+
if (typeof parent.hasMilestone === "function" && !parent.hasMilestone(milestoneId)) {
|
|
448
|
+
throw new ApiError(
|
|
449
|
+
`Milestone "${milestoneId}" introuvable dans project.serverData.oceco.milestones — refresh le projet ou utilise un milestoneId valide.`,
|
|
450
|
+
400
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
result = await this._updatePath("milestone", { milestoneId });
|
|
454
|
+
}
|
|
455
|
+
await this._refreshIfDirect();
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Met à jour startDate via l'endpoint dédié `set_date`.
|
|
461
|
+
*
|
|
462
|
+
* Utilise `set_date` plutôt qu'UPDATE_PATH_VALUE car :
|
|
463
|
+
* - Support natif de l'effacement : `null` → envoyer "" → backend fait `$unset`
|
|
464
|
+
* - Parser de date robuste côté backend (vs notre validation client-side)
|
|
465
|
+
*
|
|
466
|
+
* @param value chaîne ISO 8601 ("2026-05-20T08:00:00.000Z") ou null pour effacer.
|
|
467
|
+
*/
|
|
468
|
+
async updateStartDate(value: string | null): Promise<unknown> {
|
|
469
|
+
this._assertIsoDate("startDate", value);
|
|
470
|
+
const result = await this.callIsConnected(() =>
|
|
471
|
+
this.endpointApi.costumProjectActionRequestSetDate({
|
|
472
|
+
id: this.id!,
|
|
473
|
+
startDate: value === null ? "" : value
|
|
474
|
+
})
|
|
475
|
+
);
|
|
476
|
+
await this._refreshIfDirect();
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Met à jour endDate via l'endpoint dédié `set_date`.
|
|
482
|
+
* @param value chaîne ISO 8601 ou null pour effacer.
|
|
483
|
+
*/
|
|
484
|
+
async updateEndDate(value: string | null): Promise<unknown> {
|
|
485
|
+
this._assertIsoDate("endDate", value);
|
|
486
|
+
const result = await this.callIsConnected(() =>
|
|
487
|
+
this.endpointApi.costumProjectActionRequestSetDate({
|
|
488
|
+
id: this.id!,
|
|
489
|
+
endDate: value === null ? "" : value
|
|
490
|
+
})
|
|
491
|
+
);
|
|
492
|
+
await this._refreshIfDirect();
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Vérifie si l'utilisateur connecté est contributeur de CETTE action.
|
|
498
|
+
*
|
|
499
|
+
* Override la version de BaseEntity car la sémantique diffère :
|
|
500
|
+
* - Project : regarde `user.serverData.links.projects[projectId]` (sens user→project)
|
|
501
|
+
* - Action : regarde `action.serverData.links.contributors[userId]` (sens inverse)
|
|
502
|
+
*
|
|
503
|
+
* Pour modifier des données de l'action, c'est `parent.isAdmin()` qui compte
|
|
504
|
+
* (admin du projet parent) — pas cette méthode.
|
|
505
|
+
*
|
|
506
|
+
* @param options.silent si `false`, throw en cas de précondition manquante. Par défaut `true`.
|
|
507
|
+
*/
|
|
508
|
+
override isContributor(options?: { silent?: boolean }): boolean {
|
|
509
|
+
const silent = options?.silent ?? true;
|
|
510
|
+
try {
|
|
511
|
+
if (!this.userId) throw new ApiError("Utilisateur non connecté.", 401);
|
|
512
|
+
if (!this.id) throw new ApiError("Action non enregistrée.", 404);
|
|
513
|
+
if (!this.serverData) throw new ApiError("Aucune donnée serveur disponible.", 404);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
if (silent) return false;
|
|
516
|
+
throw e;
|
|
517
|
+
}
|
|
518
|
+
return !!this.serverData.links?.contributors?.[this.userId];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Annule l'action via l'endpoint dédié `cancel`.
|
|
523
|
+
* Effets : status=closed + unset tracking + retire le tag "totest".
|
|
524
|
+
*/
|
|
525
|
+
async cancel(): Promise<unknown> {
|
|
526
|
+
if (!this.id) throw new ApiError("Action sans id, impossible d'annuler.", 400);
|
|
527
|
+
const parent = this.parent as { isAdmin?: () => boolean } | null;
|
|
528
|
+
if (!parent || typeof parent.isAdmin !== "function" || !parent.isAdmin()) {
|
|
529
|
+
throw new ApiError("Vous n'avez pas les droits pour annuler cette action", 403);
|
|
530
|
+
}
|
|
531
|
+
const result = await this.callIsConnected(() =>
|
|
532
|
+
this.endpointApi.costumProjectActionRequestCancel({ id: this.id! })
|
|
533
|
+
);
|
|
534
|
+
await this._refreshIfDirect();
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Archive l'action via l'endpoint dédié `archive`.
|
|
540
|
+
* Effets : status=disabled + unset tracking.
|
|
541
|
+
*/
|
|
542
|
+
async archive(): Promise<unknown> {
|
|
543
|
+
if (!this.id) throw new ApiError("Action sans id, impossible d'archiver.", 400);
|
|
544
|
+
const parent = this.parent as { isAdmin?: () => boolean } | null;
|
|
545
|
+
if (!parent || typeof parent.isAdmin !== "function" || !parent.isAdmin()) {
|
|
546
|
+
throw new ApiError("Vous n'avez pas les droits pour archiver cette action", 403);
|
|
547
|
+
}
|
|
548
|
+
const result = await this.callIsConnected(() =>
|
|
549
|
+
this.endpointApi.costumProjectActionRequestArchive({ id: this.id! })
|
|
550
|
+
);
|
|
551
|
+
await this._refreshIfDirect();
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
556
|
+
// Privés
|
|
557
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Helper privé : envoie un UPDATE_PATH_VALUE pour un path donné.
|
|
561
|
+
*/
|
|
562
|
+
private async _updatePath(path: string, value: unknown, setType?: string): Promise<unknown> {
|
|
563
|
+
return this.endpointApi.updatePathValue({
|
|
564
|
+
id: this.id!,
|
|
565
|
+
collection: "actions",
|
|
566
|
+
path,
|
|
567
|
+
value: value as { [k: string]: unknown },
|
|
568
|
+
...(setType ? { setType } : {})
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Validation stricte int (refus float, NaN, etc.).
|
|
574
|
+
*/
|
|
575
|
+
private _assertInt(field: string, value: unknown): void {
|
|
576
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
577
|
+
throw new ApiError(
|
|
578
|
+
`${field}: attendu entier (Number.isInteger). Reçu: ${JSON.stringify(value)}`,
|
|
579
|
+
400
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Validation stricte ISO 8601 (refus DD/MM/YYYY, "5 août", etc.). `null` accepté (clear).
|
|
586
|
+
*/
|
|
587
|
+
private _assertIsoDate(field: string, value: unknown): void {
|
|
588
|
+
if (value === null) return;
|
|
589
|
+
if (typeof value !== "string" || !this._isIsoDateTime(value)) {
|
|
590
|
+
throw new ApiError(
|
|
591
|
+
`${field}: attendu chaîne ISO 8601 ("2026-05-20T08:00:00.000Z") ou null. Reçu: ${JSON.stringify(value)}`,
|
|
592
|
+
400
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Vérifie un format ISO 8601 date-time strict (avec T et option timezone Z ou ±HH:MM).
|
|
599
|
+
*/
|
|
600
|
+
private _isIsoDateTime(value: string): boolean {
|
|
601
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/.test(value);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
override async form(): Promise<never> {
|
|
605
|
+
throw new ApiError(`form n'existe pas dans ${this.constructor.name}`, 501);
|
|
606
|
+
}
|
|
76
607
|
}
|
package/src/api/Answer.ts
CHANGED
|
@@ -65,5 +65,8 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
|
|
|
65
65
|
}
|
|
66
66
|
throw new ApiError(`Aucune réponse trouvée pour l'ID ${this.id}`, 404);
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
|
|
69
|
+
override async form(): Promise<never> {
|
|
70
|
+
throw new ApiError(`form n'existe pas dans ${this.constructor.name}`, 501);
|
|
71
|
+
}
|
|
69
72
|
}
|
package/src/api/Badge.ts
CHANGED
|
@@ -135,4 +135,9 @@ export class Badge extends BaseEntity<any> {
|
|
|
135
135
|
throw new ApiError(`news n'existe pas dans ${this.constructor.name}`, 501);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
|
|
139
|
+
override async form(): Promise<never> {
|
|
140
|
+
throw new ApiError(`form n'existe pas dans ${this.constructor.name}`, 501);
|
|
141
|
+
}
|
|
142
|
+
|
|
138
143
|
}
|