@memberjunction/connector-totara 0.2.0
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/TotaraConnector.d.ts +238 -0
- package/dist/TotaraConnector.js +962 -0
- package/dist/TotaraConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* TotaraConnector — Totara LMS via the Moodle-inherited Web Services REST-RPC surface.
|
|
9
|
+
*
|
|
10
|
+
* Totara Web Services is a Moodle-derived layer: NOT resource-path REST and NOT OpenAPI/GraphQL. Every
|
|
11
|
+
* operation is a POST to the SAME per-tenant endpoint (`{base_url}/webservice/rest/server.php`); the
|
|
12
|
+
* operation identity lives entirely in the `wsfunction=` request parameter, and auth is a single opaque
|
|
13
|
+
* `wstoken` passed as a REQUEST PARAMETER (never an Authorization header). This connector extends
|
|
14
|
+
* BaseRESTIntegrationConnector (the ONLY protocol bases the engine exports are BaseIntegrationConnector +
|
|
15
|
+
* BaseRESTIntegrationConnector — there is no Moodle/RPC base) and implements the REST-RPC protocol over the
|
|
16
|
+
* base's HTTP seam.
|
|
17
|
+
*
|
|
18
|
+
* WHY the read + write paths are overridden (genuinely idiosyncratic per the CRUD-routing rule):
|
|
19
|
+
* - The base's generic GET-to-a-resource-path read loop cannot express "POST wsfunction=... to one shared
|
|
20
|
+
* endpoint, token-as-param, Moodle `limitfrom/limitnum(ber)` / `page` pagination, multi-collection
|
|
21
|
+
* envelopes (Notes: sitenotes+coursenotes+personalnotes; Contacts: online+offline+strangers), and an
|
|
22
|
+
* error signalled as a 200-status body ENVELOPE ({exception,errorcode,message})". So FetchChanges is
|
|
23
|
+
* overridden to drive the RPC read.
|
|
24
|
+
* - Writes use Moodle BRACKET-NOTATION urlencoded ARRAY bodies (e.g. `users[0][username]=...`), not a
|
|
25
|
+
* flat/wrapped JSON body — `CreateBodyShape/UpdateBodyShape='literal'` in the frozen metadata is the
|
|
26
|
+
* explicit "the connector builds the body" signal. CreateRecord/UpdateRecord/DeleteRecord are
|
|
27
|
+
* overridden for that encoding, and create STILL routes through {@link BuildCreatedResult} so a 2xx
|
|
28
|
+
* with no usable id fails LOUDLY (never a silent record-loss / duplicate-create-on-next-sync).
|
|
29
|
+
*
|
|
30
|
+
* The per-object `wsfunction`, response envelope key(s), pagination param names, stable ordering key, and
|
|
31
|
+
* write function names are all read from each IntegrationObject's `Configuration` JSON — the catalog is
|
|
32
|
+
* NEVER baked into this code (connector-code-conventions § "NEVER bake a catalog"). The per-tenant
|
|
33
|
+
* `base_url` + `wstoken` are resolved from the credential store / CompanyIntegration.Configuration at
|
|
34
|
+
* request time — ZERO tenant constants live in this file.
|
|
35
|
+
*
|
|
36
|
+
* DiscoveryIsAuthoritative stays false: the runtime introspection function (core_webservice_get_site_info)
|
|
37
|
+
* enumerates the functions ENABLED FOR THE CALLING TOKEN — a role/capability-gated slice, NOT a complete
|
|
38
|
+
* describe of the site — so absence in one token's view must never deactivate a Declared IO/IOF.
|
|
39
|
+
*/
|
|
40
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
41
|
+
import { Metadata } from '@memberjunction/core';
|
|
42
|
+
import { z } from 'zod';
|
|
43
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, computeContentHash, serializeKeyValue, } from '@memberjunction/integration-engine';
|
|
44
|
+
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
45
|
+
import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
|
|
46
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
47
|
+
const REST_ENDPOINT_SUFFIX = '/webservice/rest/server.php';
|
|
48
|
+
const RESTFORMAT_PARAM = 'moodlewsrestformat';
|
|
49
|
+
const RESTFORMAT_VALUE = 'json';
|
|
50
|
+
const WSFUNCTION_PARAM = 'wsfunction';
|
|
51
|
+
const WSTOKEN_PARAM = 'wstoken';
|
|
52
|
+
const SITE_INFO_FUNCTION = 'core_webservice_get_site_info';
|
|
53
|
+
/** Zod schema for the resolved connection config. */
|
|
54
|
+
const ConfigSchema = z.object({
|
|
55
|
+
Token: z.string().min(1, 'Totara wstoken is required'),
|
|
56
|
+
BaseURL: z.string().min(1, 'Totara base_url is required').refine(v => /^https?:\/\//i.test(v), 'Totara base_url must be an absolute http(s) URL'),
|
|
57
|
+
});
|
|
58
|
+
// ─── Connector ─────────────────────────────────────────────────────────
|
|
59
|
+
let TotaraConnector = class TotaraConnector extends BaseRESTIntegrationConnector {
|
|
60
|
+
constructor() {
|
|
61
|
+
super(...arguments);
|
|
62
|
+
/** Resolved auth per CompanyIntegration.ID — avoids re-loading the credential every fetch/CRUD call. */
|
|
63
|
+
this.authCache = new Map();
|
|
64
|
+
}
|
|
65
|
+
// ── Identity + capabilities ──────────────────────────────────────
|
|
66
|
+
/** Verbatim MJ: Integrations.Name (three-way identity invariant). */
|
|
67
|
+
get IntegrationName() { return 'totara'; }
|
|
68
|
+
/** Create is wired (courses/users/cohorts/groups/groupings/notes/categories + association adds). */
|
|
69
|
+
get SupportsCreate() { return true; }
|
|
70
|
+
/** Update is wired for the objects that expose an update_* wsfunction (courses/users/cohorts/…/notes). */
|
|
71
|
+
get SupportsUpdate() { return true; }
|
|
72
|
+
/** Delete is wired for the objects that expose a delete/unenrol/remove wsfunction. */
|
|
73
|
+
get SupportsDelete() { return true; }
|
|
74
|
+
/**
|
|
75
|
+
* core_webservice_get_site_info enumerates the functions ENABLED FOR THE CALLING TOKEN (role/capability
|
|
76
|
+
* gated), NOT a complete-gamut describe of the site — so keep the base default false: a
|
|
77
|
+
* comprehensive-refresh must never deactivate a Declared IO/IOF because one token can't see it.
|
|
78
|
+
*/
|
|
79
|
+
get DiscoveryIsAuthoritative() { return false; }
|
|
80
|
+
/**
|
|
81
|
+
* Keyset/no-watermark resume hint — returns the IO's declared `Configuration.stableOrderingKey`
|
|
82
|
+
* (usually the record's `id`), or null when the object declares none. Read from the frozen metadata,
|
|
83
|
+
* never guessed.
|
|
84
|
+
*/
|
|
85
|
+
StableOrderingKey(objectName) {
|
|
86
|
+
const integ = IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName);
|
|
87
|
+
if (!integ)
|
|
88
|
+
return null;
|
|
89
|
+
const obj = IntegrationEngineBase.Instance.GetIntegrationObject(integ.ID, objectName);
|
|
90
|
+
if (!obj)
|
|
91
|
+
return null;
|
|
92
|
+
return this.readConfigString(this.readIOConfig(obj), 'stableOrderingKey');
|
|
93
|
+
}
|
|
94
|
+
// ── TestConnection ────────────────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Verifies the wstoken + endpoint. The primary probe is core_webservice_get_site_info (it also carries
|
|
97
|
+
* the site name / release), BUT some Totara instances / token-service configurations throw a NON-auth
|
|
98
|
+
* "No service found in get_site_info" codingerror on it even for a fully valid token (verified live
|
|
99
|
+
* against a real instance). So a non-auth failure on site-info FALLS BACK to a lightweight real read
|
|
100
|
+
* (core_course_get_categories): if that returns a record array, the token is valid and the connection
|
|
101
|
+
* works. Only a genuine auth error (invalid/expired token, access denied) — or a failing fallback read —
|
|
102
|
+
* reports failure.
|
|
103
|
+
*/
|
|
104
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
105
|
+
let auth;
|
|
106
|
+
try {
|
|
107
|
+
auth = await this.Authenticate(companyIntegration, contextUser);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return { Success: false, Message: `Totara connection error: ${err instanceof Error ? err.message : String(err)}` };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const request = { WsFunction: SITE_INFO_FUNCTION, Params: {} };
|
|
114
|
+
const response = await this.MakeHTTPRequest(auth, auth.Endpoint, 'POST', this.BuildHeaders(auth), request);
|
|
115
|
+
this.assertNoMoodleError(response.Body);
|
|
116
|
+
const info = (response.Body ?? {});
|
|
117
|
+
const siteName = typeof info.sitename === 'string' ? info.sitename : 'Totara site';
|
|
118
|
+
const release = typeof info.release === 'string' ? info.release : undefined;
|
|
119
|
+
return {
|
|
120
|
+
Success: true,
|
|
121
|
+
Message: `Connected to ${siteName} (Totara/Moodle Web Services)`,
|
|
122
|
+
ServerVersion: release,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
127
|
+
if (this.isTotaraAuthError(message)) {
|
|
128
|
+
return { Success: false, Message: `Totara authentication failed: ${message}` };
|
|
129
|
+
}
|
|
130
|
+
// Non-auth site-info failure (e.g. the "No service found" codingerror some instances throw) —
|
|
131
|
+
// verify the connection with a real read instead of reporting a false failure.
|
|
132
|
+
return await this.verifyConnectionViaRead(auth, message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A genuine credential / authorization failure — terminal; never fall back on these. */
|
|
136
|
+
isTotaraAuthError(message) {
|
|
137
|
+
return /invalidtoken|invalid_token|accessexception|access ?control|unauthor|permission|denied|expired|forbidden/i.test(message);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Fallback connection check: a valid wstoken that returns a record array from a lightweight read
|
|
141
|
+
* (core_course_get_categories) proves the connection even when site-info is unavailable on the instance.
|
|
142
|
+
*/
|
|
143
|
+
async verifyConnectionViaRead(auth, siteInfoMessage) {
|
|
144
|
+
try {
|
|
145
|
+
const request = { WsFunction: 'core_course_get_categories', Params: {} };
|
|
146
|
+
const response = await this.MakeHTTPRequest(auth, auth.Endpoint, 'POST', this.BuildHeaders(auth), request);
|
|
147
|
+
this.assertNoMoodleError(response.Body);
|
|
148
|
+
if (Array.isArray(response.Body)) {
|
|
149
|
+
return { Success: true, Message: 'Connected to Totara/Moodle Web Services (verified via a read; site-info unavailable on this instance)' };
|
|
150
|
+
}
|
|
151
|
+
return { Success: false, Message: `Totara connection error: ${siteInfoMessage}` };
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
155
|
+
return {
|
|
156
|
+
Success: false,
|
|
157
|
+
Message: this.isTotaraAuthError(message) ? `Totara authentication failed: ${message}` : `Totara connection error: ${message}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ── Auth (wstoken as a request PARAM, not a header) ───────────────
|
|
162
|
+
/** Resolves the wstoken + endpoint from the credential store / Configuration. Cached per connection. */
|
|
163
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
164
|
+
const cached = this.authCache.get(companyIntegration.ID);
|
|
165
|
+
if (cached)
|
|
166
|
+
return cached;
|
|
167
|
+
const config = await this.ParseConfig(companyIntegration, contextUser);
|
|
168
|
+
const auth = {
|
|
169
|
+
Token: config.Token,
|
|
170
|
+
Endpoint: this.buildEndpoint(config.BaseURL),
|
|
171
|
+
};
|
|
172
|
+
this.authCache.set(companyIntegration.ID, auth);
|
|
173
|
+
return auth;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Content-type headers for a Moodle REST-RPC POST. The wstoken is NOT a header — it is injected as a
|
|
177
|
+
* urlencoded body PARAM in {@link MakeHTTPRequest} from the auth context.
|
|
178
|
+
*/
|
|
179
|
+
BuildHeaders(_auth) {
|
|
180
|
+
return {
|
|
181
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
|
182
|
+
'Accept': 'application/json',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The fully-resolved RPC endpoint (`{base_url}/webservice/rest/server.php`) from the auth context. */
|
|
186
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
187
|
+
return auth.Endpoint;
|
|
188
|
+
}
|
|
189
|
+
// ── HTTP transport — urlencoded REST-RPC POST ─────────────────────
|
|
190
|
+
/**
|
|
191
|
+
* The Moodle REST-RPC transport boundary. `body` MUST be a {@link MoodleRPCRequest}; this builds the
|
|
192
|
+
* urlencoded form — `wstoken` (from the auth context) + `moodlewsrestformat=json` + `wsfunction=<fn>` +
|
|
193
|
+
* every entry of `Params` (already Moodle-shaped) — POSTs it, and parses the JSON response. Test
|
|
194
|
+
* subclasses override this to capture the request and return canned bodies.
|
|
195
|
+
*/
|
|
196
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
197
|
+
const request = body;
|
|
198
|
+
if (!request || typeof request.WsFunction !== 'string') {
|
|
199
|
+
throw new Error('TotaraConnector.MakeHTTPRequest requires a MoodleRPCRequest body (WsFunction + Params)');
|
|
200
|
+
}
|
|
201
|
+
const token = auth.Token ?? '';
|
|
202
|
+
const form = new URLSearchParams();
|
|
203
|
+
form.append(WSTOKEN_PARAM, token);
|
|
204
|
+
form.append(RESTFORMAT_PARAM, RESTFORMAT_VALUE);
|
|
205
|
+
form.append(WSFUNCTION_PARAM, request.WsFunction);
|
|
206
|
+
for (const [key, value] of Object.entries(request.Params)) {
|
|
207
|
+
form.append(key, String(value));
|
|
208
|
+
}
|
|
209
|
+
const httpResponse = await fetch(url, { method, headers, body: form.toString() });
|
|
210
|
+
const text = await httpResponse.text();
|
|
211
|
+
return {
|
|
212
|
+
Status: httpResponse.status,
|
|
213
|
+
Body: this.parseJson(text),
|
|
214
|
+
Headers: this.headersToObject(httpResponse.headers),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// ── NormalizeResponse — exception-envelope detection + record extraction ──
|
|
218
|
+
/**
|
|
219
|
+
* Extracts the record array from a Moodle response, DETECTING the exception envelope first: Moodle
|
|
220
|
+
* signals errors via a 200-status body `{exception, errorcode, message, debuginfo}` — this throws an
|
|
221
|
+
* ERROR carrying the errorcode rather than returning a silent empty (frozen contract ErrorResponseShape).
|
|
222
|
+
* `responseDataKey` is the wrapping envelope key (e.g. `users`, `items`, `sitenotes`); null → the body is
|
|
223
|
+
* a bare top-level array. A single wrapped object is returned as a one-element array.
|
|
224
|
+
*/
|
|
225
|
+
NormalizeResponse(rawBody, responseDataKey) {
|
|
226
|
+
this.assertNoMoodleError(rawBody);
|
|
227
|
+
if (rawBody == null)
|
|
228
|
+
return [];
|
|
229
|
+
let target = rawBody;
|
|
230
|
+
if (responseDataKey) {
|
|
231
|
+
if (rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)) {
|
|
232
|
+
target = rawBody[responseDataKey];
|
|
233
|
+
if (target === undefined)
|
|
234
|
+
return []; // declared envelope key absent → no records
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return this.toRecordArray(target);
|
|
241
|
+
}
|
|
242
|
+
// ── ExtractPaginationInfo — Offset (limitfrom) / PageNumber (page) ──
|
|
243
|
+
/**
|
|
244
|
+
* Moodle list functions carry no envelope-level `HasMore`; termination is inferred from a full page
|
|
245
|
+
* (record count == page size). Hierarchy `*_index` functions DO return `{page, pages, total}` metadata —
|
|
246
|
+
* when present that is used for an exact stop. `None` never paginates.
|
|
247
|
+
*/
|
|
248
|
+
ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, pageSize) {
|
|
249
|
+
if (paginationType === 'None')
|
|
250
|
+
return { HasMore: false };
|
|
251
|
+
// Exact stop when the response reports page/pages metadata (hierarchy_*_index shape).
|
|
252
|
+
if (rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)) {
|
|
253
|
+
const rec = rawBody;
|
|
254
|
+
const pages = this.asNumber(rec.pages);
|
|
255
|
+
const page = this.asNumber(rec.page) ?? currentPage;
|
|
256
|
+
if (pages != null) {
|
|
257
|
+
const hasMore = page < pages;
|
|
258
|
+
return paginationType === 'Offset'
|
|
259
|
+
? { HasMore: hasMore, NextOffset: currentOffset + this.recordCount(rawBody) }
|
|
260
|
+
: { HasMore: hasMore, NextPage: page + 1 };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const count = this.recordCount(rawBody);
|
|
264
|
+
const hasMore = pageSize > 0 && count >= pageSize;
|
|
265
|
+
if (paginationType === 'Offset') {
|
|
266
|
+
return { HasMore: hasMore, NextOffset: currentOffset + count };
|
|
267
|
+
}
|
|
268
|
+
return { HasMore: hasMore, NextPage: currentPage + 1 };
|
|
269
|
+
}
|
|
270
|
+
// ── IntrospectSchema — declared + sampled field union (connector standard) ──
|
|
271
|
+
/**
|
|
272
|
+
* Never-shrink SAMPLE-UNION: enrich each object's DECLARED (docs) field set with fields observed by live
|
|
273
|
+
* SAMPLING ({@link DiscoverFieldsViaFetch}) so a tenant's custom user/course fields reach the schema
|
|
274
|
+
* without ever losing or narrowing a declared field. Per object, parallel + best-effort — a sampling
|
|
275
|
+
* failure (e.g. a scope-requiring function) leaves that object's declared fields authoritative. Wire at
|
|
276
|
+
* IntrospectSchema, NEVER DiscoverFields (DiscoverFieldsViaFetch falls back to DiscoverFields → recursion).
|
|
277
|
+
*/
|
|
278
|
+
async IntrospectSchema(companyIntegration, contextUser) {
|
|
279
|
+
const info = await super.IntrospectSchema(companyIntegration, contextUser);
|
|
280
|
+
await Promise.all(info.Objects.map(async (obj) => {
|
|
281
|
+
try {
|
|
282
|
+
const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
|
|
283
|
+
obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
|
|
284
|
+
}
|
|
285
|
+
catch { /* best-effort — declared fields remain authoritative on a sampling failure */ }
|
|
286
|
+
}));
|
|
287
|
+
return info;
|
|
288
|
+
}
|
|
289
|
+
// ── FetchChanges — Moodle REST-RPC read ───────────────────────────
|
|
290
|
+
/**
|
|
291
|
+
* Reads one object via its `Configuration.wsfunction`. Applies Offset (`limitfrom`/`limitnum(ber)`) or
|
|
292
|
+
* PageNumber (`page`/`perpage`) pagination from the object's declared `paginationParams`, merges any
|
|
293
|
+
* declared scope args, and unions every record collection the envelope exposes (single `responseEnvelopeKey`
|
|
294
|
+
* OR multi-collection `recordCollectionKeys` — e.g. Notes' sitenotes+coursenotes+personalnotes). Fetches
|
|
295
|
+
* ONE page per call; the engine loops on HasMore. Full-record pass-through: every source key reaches Fields.
|
|
296
|
+
*/
|
|
297
|
+
async FetchChanges(ctx) {
|
|
298
|
+
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
299
|
+
const fields = this.GetCachedFields(obj.ID);
|
|
300
|
+
const cfg = this.readIOConfig(obj);
|
|
301
|
+
const wsfunction = this.readConfigString(cfg, 'wsfunction');
|
|
302
|
+
if (!wsfunction) {
|
|
303
|
+
return {
|
|
304
|
+
Records: [],
|
|
305
|
+
HasMore: false,
|
|
306
|
+
Warnings: [{
|
|
307
|
+
Code: 'NO_WSFUNCTION',
|
|
308
|
+
Message: `"${obj.Name}": no Configuration.wsfunction — cannot dispatch a Moodle read.`,
|
|
309
|
+
Data: { object: obj.Name },
|
|
310
|
+
}],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// Parent-scoped RPC objects (e.g. core_enrol_get_enrolled_users / core_course_get_contents need a
|
|
314
|
+
// `courseid`) iterate ONE request per parent — declared via Configuration.parentScope. Delegate
|
|
315
|
+
// before the single-call path so these objects sync instead of failing with [invalidparameter].
|
|
316
|
+
const parentScope = this.readConfigObject(cfg, 'parentScope');
|
|
317
|
+
if (parentScope) {
|
|
318
|
+
return this.fetchParentScoped(obj, cfg, ctx, wsfunction, parentScope);
|
|
319
|
+
}
|
|
320
|
+
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
321
|
+
const page = ctx.CurrentPage ?? 1;
|
|
322
|
+
const offset = ctx.CurrentOffset ?? 0;
|
|
323
|
+
const pageSize = obj.DefaultPageSize && obj.DefaultPageSize > 0 ? obj.DefaultPageSize : Math.max(1, ctx.BatchSize);
|
|
324
|
+
const params = this.buildReadParams(obj, cfg, ctx, page, offset, pageSize);
|
|
325
|
+
const request = { WsFunction: wsfunction, Params: params };
|
|
326
|
+
const response = await this.MakeHTTPRequest(auth, auth.Endpoint, 'POST', this.BuildHeaders(auth), request);
|
|
327
|
+
// Extract across every declared record collection (multi-collection envelopes → one record stream).
|
|
328
|
+
const collectionKeys = this.recordCollectionKeys(obj, cfg);
|
|
329
|
+
const rawRecords = [];
|
|
330
|
+
for (const key of collectionKeys) {
|
|
331
|
+
rawRecords.push(...this.NormalizeResponse(response.Body, key));
|
|
332
|
+
}
|
|
333
|
+
const pkFieldNames = this.primaryKeyFieldNames(fields);
|
|
334
|
+
const records = rawRecords.map(r => this.buildExternalRecord(this.applyTransformPreservingKeys(r, obj, fields), ctx.ObjectName, pkFieldNames));
|
|
335
|
+
const pagination = this.ExtractPaginationInfo(response.Body, obj.PaginationType, page, offset, pageSize);
|
|
336
|
+
return {
|
|
337
|
+
Records: records,
|
|
338
|
+
HasMore: pagination.HasMore,
|
|
339
|
+
NextPage: pagination.NextPage,
|
|
340
|
+
NextOffset: pagination.NextOffset,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Parent-scoped RPC fetch. Some Moodle read functions REQUIRE a parent id param — e.g.
|
|
345
|
+
* core_enrol_get_enrolled_users / core_course_get_contents / core_enrol_get_course_enrolment_methods all
|
|
346
|
+
* need a `courseid`. Declared via `Configuration.parentScope = { parentWsFunction, paramName, parentIdField? }`.
|
|
347
|
+
* The connector loads the parent ids from the parent's own list wsfunction, then fires ONE request per
|
|
348
|
+
* parent — keyset-resumable over the parent ids (ctx.AfterKeyValue), bounded per call (engine loops until
|
|
349
|
+
* HasMore=false), concurrency + rate-limit governed by the engine hooks. A per-parent failure (e.g. an
|
|
350
|
+
* accessexception on one course) is surfaced as a warning, never fatal to the whole batch.
|
|
351
|
+
*/
|
|
352
|
+
async fetchParentScoped(obj, cfg, ctx, wsfunction, parentScope) {
|
|
353
|
+
const parentWsFn = this.readConfigString(parentScope, 'parentWsFunction');
|
|
354
|
+
const paramName = this.readConfigString(parentScope, 'paramName');
|
|
355
|
+
const parentIdField = this.readConfigString(parentScope, 'parentIdField') ?? 'id';
|
|
356
|
+
if (!parentWsFn || !paramName) {
|
|
357
|
+
return { Records: [], HasMore: false, Warnings: [{ Code: 'PARENT_SCOPE_INCOMPLETE',
|
|
358
|
+
Message: `"${obj.Name}": Configuration.parentScope requires parentWsFunction + paramName.`, Data: { object: obj.Name } }] };
|
|
359
|
+
}
|
|
360
|
+
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
361
|
+
const fields = this.GetCachedFields(obj.ID);
|
|
362
|
+
const pkFieldNames = this.primaryKeyFieldNames(fields);
|
|
363
|
+
// 1) Load parent ids from the parent's list wsfunction (bare array or single-collection envelope).
|
|
364
|
+
const parentResp = await this.MakeHTTPRequest(auth, auth.Endpoint, 'POST', this.BuildHeaders(auth), { WsFunction: parentWsFn, Params: {} });
|
|
365
|
+
const parentRecords = this.NormalizeResponse(parentResp.Body, null);
|
|
366
|
+
const allParentIDs = Array.from(new Set(parentRecords
|
|
367
|
+
.map(r => r[parentIdField]).filter(v => v != null && String(v).length > 0).map(String)))
|
|
368
|
+
.sort((a, b) => a.localeCompare(b));
|
|
369
|
+
if (allParentIDs.length === 0) {
|
|
370
|
+
return { Records: [], HasMore: false, Warnings: [{ Code: 'ZERO_PARENTS',
|
|
371
|
+
Message: `"${obj.Name}": ${parentWsFn} returned no parent ids to iterate ${paramName} over — sync the parent first.`, Data: { object: obj.Name } }] };
|
|
372
|
+
}
|
|
373
|
+
// 2) Keyset-resume + bounded batch over the parent ids.
|
|
374
|
+
const after = ctx.AfterKeyValue ?? null;
|
|
375
|
+
const remaining = after != null ? allParentIDs.filter(id => id.localeCompare(after) > 0) : allParentIDs;
|
|
376
|
+
const batch = remaining.slice(0, Math.max(1, this.TemplateVarParentBatchSize()));
|
|
377
|
+
// 3) One request per parent (bounded concurrency + adaptive rate-limit via the engine hooks).
|
|
378
|
+
const out = [];
|
|
379
|
+
const warnings = [];
|
|
380
|
+
const collectionKeys = this.recordCollectionKeys(obj, cfg);
|
|
381
|
+
await this.runParentBounded(batch, Math.max(1, ctx.MaxConcurrency ?? 1), async (parentID) => {
|
|
382
|
+
if (ctx.RateLimitAcquire)
|
|
383
|
+
await ctx.RateLimitAcquire();
|
|
384
|
+
try {
|
|
385
|
+
const resp = await this.MakeHTTPRequest(auth, auth.Endpoint, 'POST', this.BuildHeaders(auth), { WsFunction: wsfunction, Params: { [paramName]: parentID } });
|
|
386
|
+
for (const key of collectionKeys) {
|
|
387
|
+
for (const raw of this.NormalizeResponse(resp.Body, key)) {
|
|
388
|
+
// tag the child with the parent FK so its row links back to the parent record
|
|
389
|
+
const tagged = raw[paramName] != null ? raw : { ...raw, [paramName]: parentID };
|
|
390
|
+
out.push(this.buildExternalRecord(this.applyTransformPreservingKeys(tagged, obj, fields), ctx.ObjectName, pkFieldNames));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
ctx.RateLimitReport?.();
|
|
394
|
+
}
|
|
395
|
+
catch (e) {
|
|
396
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
397
|
+
if (/429|rate.?limit|Retry-After/i.test(msg)) {
|
|
398
|
+
ctx.RateLimitReport?.(e);
|
|
399
|
+
throw e;
|
|
400
|
+
} // rate-limit → propagate for backoff
|
|
401
|
+
warnings.push({ Code: 'PARENT_FETCH_ERROR', Message: `"${obj.Name}" fetch for ${paramName}=${parentID}: ${msg}`, Data: { object: obj.Name, [paramName]: parentID } });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
const hasMore = remaining.length > batch.length;
|
|
405
|
+
return { Records: out, HasMore: hasMore, NextAfterKeyValue: hasMore ? batch[batch.length - 1] : undefined, Warnings: warnings.length ? warnings : undefined };
|
|
406
|
+
}
|
|
407
|
+
/** Bounded-concurrency runner (the base's RunBounded is private). Single-threaded async → array pushes are safe. */
|
|
408
|
+
async runParentBounded(items, concurrency, worker) {
|
|
409
|
+
let i = 0;
|
|
410
|
+
const lanes = Array.from({ length: Math.min(Math.max(1, concurrency), Math.max(1, items.length)) }, async () => {
|
|
411
|
+
while (i < items.length) {
|
|
412
|
+
const idx = i++;
|
|
413
|
+
await worker(items[idx]);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
await Promise.all(lanes);
|
|
417
|
+
}
|
|
418
|
+
// ── CRUD — Moodle bracket-notation urlencoded array bodies ────────
|
|
419
|
+
/**
|
|
420
|
+
* Create via the object's `Configuration.writeFunctions.create` wsfunction, encoding the record as a
|
|
421
|
+
* Moodle bracket-notation array (`<param>[0][field]=...`). The new id is read from the response per
|
|
422
|
+
* `createResponseIDField`; association creates (no server id) synthesize a deterministic identity from the
|
|
423
|
+
* sent attributes. EITHER way the result routes through {@link BuildCreatedResult} so an empty id fails
|
|
424
|
+
* LOUDLY — never a hand-built `{Success:true, ExternalID:''}`.
|
|
425
|
+
*/
|
|
426
|
+
async CreateRecord(ctx) {
|
|
427
|
+
const ci = ctx.CompanyIntegration;
|
|
428
|
+
const contextUser = ctx.ContextUser;
|
|
429
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
430
|
+
if (!obj.CreateAPIPath || !obj.CreateMethod) {
|
|
431
|
+
throw new Error(`CreateRecord not supported for "${ctx.ObjectName}": CreateAPIPath / CreateMethod not configured on IntegrationObject.`);
|
|
432
|
+
}
|
|
433
|
+
const cfg = this.readIOConfig(obj);
|
|
434
|
+
const wf = this.readWriteFunctions(cfg);
|
|
435
|
+
const action = this.readConfigString(wf, 'create');
|
|
436
|
+
if (!action) {
|
|
437
|
+
return { Success: false, StatusCode: 400, ErrorMessage: `CreateRecord for "${ctx.ObjectName}": Configuration.writeFunctions.create not configured.` };
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const fields = this.GetCachedFields(obj.ID);
|
|
441
|
+
const attrs = this.filterWritable(ctx.Attributes, fields);
|
|
442
|
+
const arrayParam = this.resolveWriteArrayParam(cfg, wf, action);
|
|
443
|
+
const params = this.bracketEncodeRecord(arrayParam, attrs);
|
|
444
|
+
const response = await this.postWrite(ci, contextUser, action, params);
|
|
445
|
+
const inBandError = this.detectMoodleError(response.Body);
|
|
446
|
+
if (inBandError) {
|
|
447
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `CreateRecord failed for ${ctx.ObjectName}: ${inBandError}` };
|
|
448
|
+
}
|
|
449
|
+
const idField = this.readConfigString(wf, 'createResponseIDField');
|
|
450
|
+
const externalID = idField
|
|
451
|
+
? this.extractCreatedId(response.Body, idField)
|
|
452
|
+
: this.synthesizeAssociationId(attrs); // association create (no server id) → deterministic identity
|
|
453
|
+
return this.BuildCreatedResult(externalID, response.Status, ctx.ObjectName);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
return this.buildCRUDError(err, 'CreateRecord', ctx.ObjectName);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Update via `Configuration.writeFunctions.update`, injecting the target ExternalID under the object's PK
|
|
461
|
+
* field name inside the bracket-notation array body.
|
|
462
|
+
*/
|
|
463
|
+
async UpdateRecord(ctx) {
|
|
464
|
+
const ci = ctx.CompanyIntegration;
|
|
465
|
+
const contextUser = ctx.ContextUser;
|
|
466
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
467
|
+
if (!obj.UpdateAPIPath || !obj.UpdateMethod) {
|
|
468
|
+
throw new Error(`UpdateRecord not supported for "${ctx.ObjectName}": UpdateAPIPath / UpdateMethod not configured on IntegrationObject.`);
|
|
469
|
+
}
|
|
470
|
+
const cfg = this.readIOConfig(obj);
|
|
471
|
+
const wf = this.readWriteFunctions(cfg);
|
|
472
|
+
const action = this.readConfigString(wf, 'update');
|
|
473
|
+
if (!action) {
|
|
474
|
+
return { Success: false, StatusCode: 400, ErrorMessage: `UpdateRecord for "${ctx.ObjectName}": Configuration.writeFunctions.update not configured.` };
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const fields = this.GetCachedFields(obj.ID);
|
|
478
|
+
const attrs = this.filterWritable(ctx.Attributes, fields);
|
|
479
|
+
const pkName = this.updateIdFieldName(wf, fields);
|
|
480
|
+
if (attrs[pkName] === undefined)
|
|
481
|
+
attrs[pkName] = ctx.ExternalID;
|
|
482
|
+
const arrayParam = this.resolveWriteArrayParam(cfg, wf, action);
|
|
483
|
+
const params = this.bracketEncodeRecord(arrayParam, attrs);
|
|
484
|
+
const response = await this.postWrite(ci, contextUser, action, params);
|
|
485
|
+
const inBandError = this.detectMoodleError(response.Body);
|
|
486
|
+
if (inBandError) {
|
|
487
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `UpdateRecord failed for ${ctx.ObjectName}: ${inBandError}` };
|
|
488
|
+
}
|
|
489
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
return this.buildCRUDError(err, 'UpdateRecord', ctx.ObjectName);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Delete via `Configuration.writeFunctions.delete` (verb NOT assumed — some are unenrol/remove), sending
|
|
497
|
+
* the target ExternalID in the Moodle ids array (`<param>ids[0]=<id>`).
|
|
498
|
+
*/
|
|
499
|
+
async DeleteRecord(ctx) {
|
|
500
|
+
const ci = ctx.CompanyIntegration;
|
|
501
|
+
const contextUser = ctx.ContextUser;
|
|
502
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
503
|
+
if (!obj.DeleteAPIPath || !obj.DeleteMethod) {
|
|
504
|
+
throw new Error(`DeleteRecord not supported for "${ctx.ObjectName}": DeleteAPIPath / DeleteMethod not configured on IntegrationObject.`);
|
|
505
|
+
}
|
|
506
|
+
const cfg = this.readIOConfig(obj);
|
|
507
|
+
const wf = this.readWriteFunctions(cfg);
|
|
508
|
+
const action = this.readConfigString(wf, 'delete');
|
|
509
|
+
if (!action) {
|
|
510
|
+
return { Success: false, StatusCode: 400, ErrorMessage: `DeleteRecord for "${ctx.ObjectName}": Configuration.writeFunctions.delete not configured.` };
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const idsParam = this.resolveDeleteIdsParam(wf, action);
|
|
514
|
+
const params = { [`${idsParam}[0]`]: ctx.ExternalID };
|
|
515
|
+
const response = await this.postWrite(ci, contextUser, action, params);
|
|
516
|
+
const inBandError = this.detectMoodleError(response.Body);
|
|
517
|
+
if (inBandError) {
|
|
518
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `DeleteRecord failed for ${ctx.ObjectName}: ${inBandError}` };
|
|
519
|
+
}
|
|
520
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
return this.buildCRUDError(err, 'DeleteRecord', ctx.ObjectName);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Shared write POST: authenticate + dispatch the wsfunction with the given (already-bracketed) params. */
|
|
527
|
+
async postWrite(ci, contextUser, action, params) {
|
|
528
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
529
|
+
const request = { WsFunction: action, Params: params };
|
|
530
|
+
return this.MakeHTTPRequest(auth, auth.Endpoint, 'POST', this.BuildHeaders(auth), request);
|
|
531
|
+
}
|
|
532
|
+
// ── Read-param construction ───────────────────────────────────────
|
|
533
|
+
/** Builds the read request params: declared scope args + pagination (Offset limitfrom / PageNumber page). */
|
|
534
|
+
buildReadParams(obj, cfg, ctx, page, offset, pageSize) {
|
|
535
|
+
const params = {};
|
|
536
|
+
this.applyScopeArgs(params, obj, cfg, ctx.CompanyIntegration);
|
|
537
|
+
if (!obj.SupportsPagination || obj.PaginationType === 'None')
|
|
538
|
+
return params;
|
|
539
|
+
const pagParams = this.readConfigStringArray(cfg, 'paginationParams') ?? [];
|
|
540
|
+
if (obj.PaginationType === 'Offset') {
|
|
541
|
+
this.applyOffsetPagination(params, pagParams, offset, pageSize);
|
|
542
|
+
}
|
|
543
|
+
else if (obj.PaginationType === 'PageNumber') {
|
|
544
|
+
this.applyPageNumberPagination(params, pagParams, page, pageSize);
|
|
545
|
+
}
|
|
546
|
+
return params;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Merges declared scope args for functions that require a parent scope (e.g. a courseid / userid /
|
|
550
|
+
* groupids). Two sources, both metadata-driven (never guessed): the IO's own `Configuration.defaultArgs`,
|
|
551
|
+
* and a per-connection `Configuration.objectArgs["<ObjectName>"]` override. Absent → nothing added; a
|
|
552
|
+
* scope-requiring function then returns a Moodle exception, surfaced (not swallowed) by NormalizeResponse.
|
|
553
|
+
*/
|
|
554
|
+
applyScopeArgs(params, obj, cfg, companyIntegration) {
|
|
555
|
+
const merge = (src) => {
|
|
556
|
+
if (!src)
|
|
557
|
+
return;
|
|
558
|
+
for (const [k, v] of Object.entries(src)) {
|
|
559
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
560
|
+
params[k] = v;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
merge(this.readConfigObject(cfg, 'defaultArgs'));
|
|
564
|
+
const connCfg = this.parseConnectionConfig(companyIntegration);
|
|
565
|
+
const objectArgs = this.readConfigObject(connCfg, 'objectArgs');
|
|
566
|
+
if (objectArgs)
|
|
567
|
+
merge(this.readConfigObject(objectArgs, obj.Name));
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Offset pagination. `paginationParams` = [fromName, countName]. A dotted name (`options.limitfrom`) is a
|
|
571
|
+
* Moodle options-array param → emitted as `options[i][name]=limitfrom&options[i][value]=<n>`; a flat name
|
|
572
|
+
* is emitted directly. `countName` may carry alternates (`limitnum|limitnumber`) — the first is used.
|
|
573
|
+
*/
|
|
574
|
+
applyOffsetPagination(params, pagParams, offset, pageSize) {
|
|
575
|
+
const fromRaw = pagParams[0] ?? 'limitfrom';
|
|
576
|
+
const countRaw = (pagParams[1] ?? 'limitnum').split('|')[0];
|
|
577
|
+
if (fromRaw.includes('.')) {
|
|
578
|
+
const prefix = fromRaw.split('.')[0];
|
|
579
|
+
const fromName = fromRaw.slice(prefix.length + 1);
|
|
580
|
+
const countName = countRaw.includes('.') ? countRaw.slice(countRaw.indexOf('.') + 1) : countRaw;
|
|
581
|
+
params[`${prefix}[0][name]`] = fromName;
|
|
582
|
+
params[`${prefix}[0][value]`] = offset;
|
|
583
|
+
params[`${prefix}[1][name]`] = countName;
|
|
584
|
+
params[`${prefix}[1][value]`] = pageSize;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
params[fromRaw] = offset;
|
|
588
|
+
params[countRaw] = pageSize;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** PageNumber pagination. `paginationParams` = [pageName, sizeName?]; sizeName is optional (`page` alone). */
|
|
592
|
+
applyPageNumberPagination(params, pagParams, page, pageSize) {
|
|
593
|
+
const pageName = pagParams[0] ?? 'page';
|
|
594
|
+
params[pageName] = page;
|
|
595
|
+
const sizeName = pagParams[1];
|
|
596
|
+
if (sizeName)
|
|
597
|
+
params[sizeName] = pageSize;
|
|
598
|
+
}
|
|
599
|
+
// ── Write-body encoding helpers ───────────────────────────────────
|
|
600
|
+
/**
|
|
601
|
+
* The Moodle array-parameter name for a write body. Resolution (metadata-driven, never a baked catalog):
|
|
602
|
+
* (1) an explicit `Configuration.writeFunctions.arrayParam`; (2) the bracket prefix of a declared
|
|
603
|
+
* `updateIDField`/`deleteIDField` (e.g. `courses[0][id]` → `courses`); (3) the trailing plural token of
|
|
604
|
+
* the write function name (`core_user_create_users` → `users`). Genuinely-idiosyncratic association params
|
|
605
|
+
* (enrolments, members) should carry an explicit `arrayParam` override — see CODE_REPORT.md.
|
|
606
|
+
*/
|
|
607
|
+
resolveWriteArrayParam(cfg, wf, action) {
|
|
608
|
+
const explicit = this.readConfigString(wf, 'arrayParam') ?? this.readConfigString(cfg, 'writeArrayParam');
|
|
609
|
+
if (explicit)
|
|
610
|
+
return explicit;
|
|
611
|
+
const fromField = this.bracketPrefix(this.readConfigString(wf, 'updateIDField'));
|
|
612
|
+
if (fromField)
|
|
613
|
+
return fromField;
|
|
614
|
+
return this.deriveArrayParamFromFunction(action);
|
|
615
|
+
}
|
|
616
|
+
/** The Moodle ids-array parameter for a delete/unenrol/remove call (e.g. `courseids`). */
|
|
617
|
+
resolveDeleteIdsParam(wf, action) {
|
|
618
|
+
const fromField = this.bracketPrefix(this.readConfigString(wf, 'deleteIDField'));
|
|
619
|
+
if (fromField)
|
|
620
|
+
return fromField;
|
|
621
|
+
const explicit = this.readConfigString(wf, 'idsParam');
|
|
622
|
+
if (explicit)
|
|
623
|
+
return explicit;
|
|
624
|
+
// e.g. core_course_delete_courses → courses → courseids (Moodle's delete-id array naming).
|
|
625
|
+
const plural = this.deriveArrayParamFromFunction(action);
|
|
626
|
+
return plural.endsWith('s') ? `${plural.slice(0, -1)}ids` : `${plural}ids`;
|
|
627
|
+
}
|
|
628
|
+
/** Trailing plural token of a Moodle wsfunction name (`core_user_create_users` → `users`). */
|
|
629
|
+
deriveArrayParamFromFunction(action) {
|
|
630
|
+
const m = action.match(/_(?:create|update|add|enrol|unenrol|delete)_(.+)$/);
|
|
631
|
+
if (m) {
|
|
632
|
+
const tail = m[1];
|
|
633
|
+
// The bracket param is the last underscore-segment for the common record objects.
|
|
634
|
+
const seg = tail.split('_').pop();
|
|
635
|
+
return seg ?? tail;
|
|
636
|
+
}
|
|
637
|
+
return 'records';
|
|
638
|
+
}
|
|
639
|
+
/** The `[0][id]` prefix of a bracketed id-field path (`courses[0][id]` → `courses`; `courseids[0]` → `courseids`). */
|
|
640
|
+
bracketPrefix(idField) {
|
|
641
|
+
if (!idField)
|
|
642
|
+
return null;
|
|
643
|
+
const idx = idField.indexOf('[');
|
|
644
|
+
const prefix = idx > 0 ? idField.slice(0, idx) : idField;
|
|
645
|
+
return prefix.trim().length > 0 ? prefix.trim() : null;
|
|
646
|
+
}
|
|
647
|
+
/** The field name the ExternalID goes under on update (from a declared `updateIDField`, else the PK, else `id`). */
|
|
648
|
+
updateIdFieldName(wf, fields) {
|
|
649
|
+
const declared = this.readConfigString(wf, 'updateIDField');
|
|
650
|
+
if (declared) {
|
|
651
|
+
const m = declared.match(/\[([^\]]+)\]\s*$/); // last bracket segment: courses[0][id] → id
|
|
652
|
+
if (m)
|
|
653
|
+
return m[1];
|
|
654
|
+
}
|
|
655
|
+
const pk = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence)[0];
|
|
656
|
+
return pk ? pk.Name : 'id';
|
|
657
|
+
}
|
|
658
|
+
/** Recursively renders a record into Moodle bracket-notation params under `<arrayParam>[0]`. */
|
|
659
|
+
bracketEncodeRecord(arrayParam, attrs) {
|
|
660
|
+
const out = {};
|
|
661
|
+
this.bracketEncode(`${arrayParam}[0]`, attrs, out);
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
bracketEncode(prefix, value, out) {
|
|
665
|
+
if (value == null)
|
|
666
|
+
return;
|
|
667
|
+
if (Array.isArray(value)) {
|
|
668
|
+
value.forEach((v, i) => this.bracketEncode(`${prefix}[${i}]`, v, out));
|
|
669
|
+
}
|
|
670
|
+
else if (typeof value === 'object') {
|
|
671
|
+
for (const [k, v] of Object.entries(value)) {
|
|
672
|
+
this.bracketEncode(`${prefix}[${k}]`, v, out);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else if (typeof value === 'boolean') {
|
|
676
|
+
out[prefix] = value ? 1 : 0; // Moodle booleans are int 1/0
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
out[prefix] = value;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/** Drops IsReadOnly source fields from a write body (respects the read-only constraint). */
|
|
683
|
+
filterWritable(attrs, fields) {
|
|
684
|
+
const readOnly = new Set(fields.filter(f => f.IsReadOnly).map(f => f.Name));
|
|
685
|
+
if (readOnly.size === 0)
|
|
686
|
+
return { ...attrs };
|
|
687
|
+
return Object.fromEntries(Object.entries(attrs).filter(([k]) => !readOnly.has(k)));
|
|
688
|
+
}
|
|
689
|
+
// ── Response / record shaping ──────────────────────────────────────
|
|
690
|
+
/**
|
|
691
|
+
* Collection keys to extract from a read response, resolved in priority order:
|
|
692
|
+
* 1. `Configuration.recordCollectionKeys` — multi-collection union (e.g. Notes' sitenotes+coursenotes+
|
|
693
|
+
* personalnotes), when the frozen metadata declares it.
|
|
694
|
+
* 2. The first-class `ResponseDataKey` COLUMN — the canonical "where the return wraps records" slot
|
|
695
|
+
* (connector-code-conventions §4 / frozen-contract requirement). This is authoritative: the current
|
|
696
|
+
* Totara metadata carries the envelope key HERE (`users`, `statuses`, `items`, …) with
|
|
697
|
+
* `Configuration.responseEnvelopeKey` null, so the column MUST be read or a `{users:[…]}` envelope
|
|
698
|
+
* would be mis-emitted as a single wrapper record instead of the N wrapped records.
|
|
699
|
+
* 3. `Configuration.responseEnvelopeKey` — backward-compatible fallback for older metadata shapes.
|
|
700
|
+
* A resolved `null` means the body is a bare top-level array.
|
|
701
|
+
*/
|
|
702
|
+
recordCollectionKeys(obj, cfg) {
|
|
703
|
+
const multi = this.readConfigStringArray(cfg, 'recordCollectionKeys');
|
|
704
|
+
if (multi && multi.length > 0)
|
|
705
|
+
return multi;
|
|
706
|
+
const column = typeof obj.ResponseDataKey === 'string' && obj.ResponseDataKey.trim().length > 0
|
|
707
|
+
? obj.ResponseDataKey.trim()
|
|
708
|
+
: null;
|
|
709
|
+
return [column ?? this.readConfigString(cfg, 'responseEnvelopeKey')];
|
|
710
|
+
}
|
|
711
|
+
toRecordArray(target) {
|
|
712
|
+
if (target == null)
|
|
713
|
+
return [];
|
|
714
|
+
if (Array.isArray(target)) {
|
|
715
|
+
return target.filter(x => x != null && typeof x === 'object' && !Array.isArray(x));
|
|
716
|
+
}
|
|
717
|
+
if (typeof target === 'object')
|
|
718
|
+
return [target];
|
|
719
|
+
return [];
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* §4 identity: the declared PK when EVERY component is present + non-empty, else a deterministic content
|
|
723
|
+
* hash (so PK-less / partial-key records stay syncable + dedupable). Full-record pass-through: Fields
|
|
724
|
+
* carries the COMPLETE source record (with the synthetic id stamped into a single empty PK column).
|
|
725
|
+
*/
|
|
726
|
+
buildExternalRecord(raw, objectType, pkFieldNames) {
|
|
727
|
+
const allPkPresent = pkFieldNames.length > 0
|
|
728
|
+
&& pkFieldNames.every(name => raw[name] != null && serializeKeyValue(raw[name]).length > 0);
|
|
729
|
+
const resolvedID = allPkPresent
|
|
730
|
+
? pkFieldNames.map(name => serializeKeyValue(raw[name])).join('|')
|
|
731
|
+
: computeContentHash(raw);
|
|
732
|
+
let fields = raw;
|
|
733
|
+
if (!allPkPresent && pkFieldNames.length === 1
|
|
734
|
+
&& (raw[pkFieldNames[0]] == null || serializeKeyValue(raw[pkFieldNames[0]]).length === 0)) {
|
|
735
|
+
fields = { ...raw, [pkFieldNames[0]]: resolvedID };
|
|
736
|
+
}
|
|
737
|
+
return { ExternalID: resolvedID, ObjectType: objectType, Fields: fields };
|
|
738
|
+
}
|
|
739
|
+
primaryKeyFieldNames(fields) {
|
|
740
|
+
const pk = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
|
|
741
|
+
return pk.length > 0 ? pk : ['id'];
|
|
742
|
+
}
|
|
743
|
+
/** Pulls the created record's id from a Moodle create response (bare array of created / wrapped collection). */
|
|
744
|
+
extractCreatedId(body, idField) {
|
|
745
|
+
const first = this.firstRecord(body);
|
|
746
|
+
if (first && first[idField] != null)
|
|
747
|
+
return String(first[idField]);
|
|
748
|
+
const deep = this.deepFindKey(body, idField);
|
|
749
|
+
return deep == null ? undefined : String(deep);
|
|
750
|
+
}
|
|
751
|
+
/** Deterministic identity for an association create with no server id (matches the §4 content-hash path). */
|
|
752
|
+
synthesizeAssociationId(attrs) {
|
|
753
|
+
return computeContentHash(attrs);
|
|
754
|
+
}
|
|
755
|
+
detectMoodleError(body) {
|
|
756
|
+
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
|
757
|
+
const rec = body;
|
|
758
|
+
if (typeof rec.exception === 'string' && rec.exception.length > 0) {
|
|
759
|
+
const code = typeof rec.errorcode === 'string' && rec.errorcode.length > 0 ? rec.errorcode : rec.exception;
|
|
760
|
+
const message = typeof rec.message === 'string' ? rec.message : 'unknown Moodle Web Services error';
|
|
761
|
+
return `[${code}] ${message}`;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
/** Throws when the body is a Moodle exception envelope (surfaces the errorcode) — never a silent empty. */
|
|
767
|
+
assertNoMoodleError(body) {
|
|
768
|
+
const err = this.detectMoodleError(body);
|
|
769
|
+
if (err)
|
|
770
|
+
throw new Error(`Totara/Moodle Web Services error ${err}`);
|
|
771
|
+
}
|
|
772
|
+
buildCRUDError(err, operation, objectName) {
|
|
773
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
774
|
+
return { Success: false, ErrorMessage: `${operation} failed for ${objectName}: ${message}`, StatusCode: 500 };
|
|
775
|
+
}
|
|
776
|
+
// ── Config resolution ─────────────────────────────────────────────
|
|
777
|
+
/** Resolves the wstoken + base_url from the credential store (secrets) + Configuration JSON (overrides). */
|
|
778
|
+
async ParseConfig(companyIntegration, contextUser) {
|
|
779
|
+
const raw = {};
|
|
780
|
+
if (companyIntegration.CredentialID) {
|
|
781
|
+
Object.assign(raw, await this.loadCredentialValues(companyIntegration.CredentialID, contextUser));
|
|
782
|
+
}
|
|
783
|
+
if (companyIntegration.Configuration) {
|
|
784
|
+
try {
|
|
785
|
+
Object.assign(raw, JSON.parse(companyIntegration.Configuration));
|
|
786
|
+
}
|
|
787
|
+
catch { /* non-fatal — fall through to whatever the credential store provided */ }
|
|
788
|
+
}
|
|
789
|
+
return this.normalizeConfig(raw);
|
|
790
|
+
}
|
|
791
|
+
async loadCredentialValues(credentialID, contextUser) {
|
|
792
|
+
const md = new Metadata();
|
|
793
|
+
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
794
|
+
const loaded = await credential.Load(credentialID);
|
|
795
|
+
if (!loaded || !credential.Values)
|
|
796
|
+
return {};
|
|
797
|
+
try {
|
|
798
|
+
return JSON.parse(credential.Values);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return {};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
normalizeConfig(raw) {
|
|
805
|
+
const pick = (...keys) => {
|
|
806
|
+
for (const key of keys) {
|
|
807
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
808
|
+
if (k.toLowerCase() === key.toLowerCase() && typeof v === 'string' && v.length > 0)
|
|
809
|
+
return v;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return undefined;
|
|
813
|
+
};
|
|
814
|
+
const candidate = {
|
|
815
|
+
Token: pick('wstoken', 'token', 'wsToken', 'apiKey', 'api_key', 'apitoken') ?? '',
|
|
816
|
+
BaseURL: pick('base_url', 'baseUrl', 'baseURL', 'url', 'site', 'siteUrl', 'site_url', 'host') ?? '',
|
|
817
|
+
};
|
|
818
|
+
const parsed = ConfigSchema.safeParse(candidate);
|
|
819
|
+
if (!parsed.success) {
|
|
820
|
+
throw new Error(`Totara configuration invalid: ${parsed.error.issues.map(i => i.message).join('; ')}`);
|
|
821
|
+
}
|
|
822
|
+
return candidate;
|
|
823
|
+
}
|
|
824
|
+
/** `{base_url}/webservice/rest/server.php`, tolerant of a base_url that already includes the suffix. */
|
|
825
|
+
buildEndpoint(baseURL) {
|
|
826
|
+
const base = baseURL.replace(/\/+$/, '');
|
|
827
|
+
return base.endsWith(REST_ENDPOINT_SUFFIX) ? base : `${base}${REST_ENDPOINT_SUFFIX}`;
|
|
828
|
+
}
|
|
829
|
+
parseConnectionConfig(companyIntegration) {
|
|
830
|
+
if (!companyIntegration.Configuration)
|
|
831
|
+
return {};
|
|
832
|
+
try {
|
|
833
|
+
const parsed = JSON.parse(companyIntegration.Configuration);
|
|
834
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return {};
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// ── IO Configuration readers ───────────────────────────────────────
|
|
841
|
+
readIOConfig(obj) {
|
|
842
|
+
if (!obj.Configuration)
|
|
843
|
+
return {};
|
|
844
|
+
try {
|
|
845
|
+
const parsed = JSON.parse(obj.Configuration);
|
|
846
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return {};
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
readWriteFunctions(cfg) {
|
|
853
|
+
return this.readConfigObject(cfg, 'writeFunctions') ?? {};
|
|
854
|
+
}
|
|
855
|
+
readConfigString(cfg, key) {
|
|
856
|
+
const v = cfg[key];
|
|
857
|
+
return typeof v === 'string' && v.trim().length > 0 ? v.trim() : null;
|
|
858
|
+
}
|
|
859
|
+
readConfigStringArray(cfg, key) {
|
|
860
|
+
const v = cfg[key];
|
|
861
|
+
if (!Array.isArray(v))
|
|
862
|
+
return null;
|
|
863
|
+
const out = v.filter((x) => typeof x === 'string' && x.length > 0);
|
|
864
|
+
return out.length > 0 ? out : null;
|
|
865
|
+
}
|
|
866
|
+
readConfigObject(cfg, key) {
|
|
867
|
+
const v = cfg[key];
|
|
868
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
869
|
+
}
|
|
870
|
+
// ── Small utilities ────────────────────────────────────────────────
|
|
871
|
+
parseJson(text) {
|
|
872
|
+
const trimmed = text.trim();
|
|
873
|
+
if (trimmed.length === 0)
|
|
874
|
+
return null; // Moodle returns empty/`null` on some successful writes (e.g. delete)
|
|
875
|
+
try {
|
|
876
|
+
return JSON.parse(trimmed);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
return trimmed; // non-JSON body (rare) — hand back the raw text
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
headersToObject(headers) {
|
|
883
|
+
const out = {};
|
|
884
|
+
headers.forEach((value, key) => { out[key.toLowerCase()] = value; });
|
|
885
|
+
return out;
|
|
886
|
+
}
|
|
887
|
+
/** The first record object in a response (bare array → [0]; wrapped → first array's [0]; object → itself). */
|
|
888
|
+
firstRecord(body) {
|
|
889
|
+
if (Array.isArray(body)) {
|
|
890
|
+
const first = body.find(x => x != null && typeof x === 'object' && !Array.isArray(x));
|
|
891
|
+
return first ?? null;
|
|
892
|
+
}
|
|
893
|
+
if (body && typeof body === 'object') {
|
|
894
|
+
const arr = this.firstArrayValue(body);
|
|
895
|
+
if (arr) {
|
|
896
|
+
const first = arr.find(x => x != null && typeof x === 'object' && !Array.isArray(x));
|
|
897
|
+
if (first)
|
|
898
|
+
return first;
|
|
899
|
+
}
|
|
900
|
+
return body;
|
|
901
|
+
}
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
firstArrayValue(body) {
|
|
905
|
+
for (const value of Object.values(body)) {
|
|
906
|
+
if (Array.isArray(value))
|
|
907
|
+
return value;
|
|
908
|
+
if (value && typeof value === 'object') {
|
|
909
|
+
const nested = this.firstArrayValue(value);
|
|
910
|
+
if (nested)
|
|
911
|
+
return nested;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
recordCount(rawBody) {
|
|
917
|
+
if (Array.isArray(rawBody))
|
|
918
|
+
return rawBody.length;
|
|
919
|
+
if (rawBody && typeof rawBody === 'object') {
|
|
920
|
+
const arr = this.firstArrayValue(rawBody);
|
|
921
|
+
if (arr)
|
|
922
|
+
return arr.length;
|
|
923
|
+
return Object.keys(rawBody).length > 0 ? 1 : 0;
|
|
924
|
+
}
|
|
925
|
+
return 0;
|
|
926
|
+
}
|
|
927
|
+
deepFindKey(body, key) {
|
|
928
|
+
if (!body || typeof body !== 'object')
|
|
929
|
+
return undefined;
|
|
930
|
+
if (Array.isArray(body)) {
|
|
931
|
+
for (const item of body) {
|
|
932
|
+
const found = this.deepFindKey(item, key);
|
|
933
|
+
if (found !== undefined)
|
|
934
|
+
return found;
|
|
935
|
+
}
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
938
|
+
const rec = body;
|
|
939
|
+
if (key in rec)
|
|
940
|
+
return rec[key];
|
|
941
|
+
for (const value of Object.values(rec)) {
|
|
942
|
+
const found = this.deepFindKey(value, key);
|
|
943
|
+
if (found !== undefined)
|
|
944
|
+
return found;
|
|
945
|
+
}
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
asNumber(v) {
|
|
949
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
950
|
+
return v;
|
|
951
|
+
if (typeof v === 'string' && /^-?\d+$/.test(v))
|
|
952
|
+
return Number(v);
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
TotaraConnector = __decorate([
|
|
957
|
+
RegisterClass(BaseIntegrationConnector, 'TotaraConnector')
|
|
958
|
+
], TotaraConnector);
|
|
959
|
+
export { TotaraConnector };
|
|
960
|
+
/** Tree-shaking prevention — import and call from the package entry point. */
|
|
961
|
+
export function LoadTotaraConnector() { }
|
|
962
|
+
//# sourceMappingURL=TotaraConnector.js.map
|