@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.
@@ -0,0 +1,238 @@
1
+ import { type UserInfo } from '@memberjunction/core';
2
+ import type { MJCompanyIntegrationEntity } from '@memberjunction/core-entities';
3
+ import { BaseRESTIntegrationConnector, type RESTAuthContext, type RESTResponse, type PaginationState, type PaginationType, type ConnectionTestResult, type FetchContext, type FetchBatchResult, type CreateRecordContext, type UpdateRecordContext, type DeleteRecordContext, type CRUDResult, type SourceSchemaInfo } from '@memberjunction/integration-engine';
4
+ /** Resolved connection settings (secret token + per-tenant base URL). NO tenant constants in code. */
5
+ interface TotaraConfig {
6
+ /** The per-user Web Service token (wstoken) — injected as a request PARAM, never a header. */
7
+ Token: string;
8
+ /** The tenant's site base URL (e.g. https://learn.example.org). */
9
+ BaseURL: string;
10
+ }
11
+ /** Auth context threaded through every request. Carries the token + the resolved RPC endpoint. */
12
+ export interface TotaraAuthContext extends RESTAuthContext {
13
+ /** wstoken — injected as a urlencoded body param by MakeHTTPRequest. */
14
+ Token: string;
15
+ /** Fully-resolved RPC endpoint: `{base_url}/webservice/rest/server.php`. */
16
+ Endpoint: string;
17
+ }
18
+ /**
19
+ * The structured RPC request handed to {@link TotaraConnector.MakeHTTPRequest} as its `body`.
20
+ * MakeHTTPRequest is where the urlencoded form (wstoken + moodlewsrestformat + wsfunction + params) is
21
+ * actually built — so a test that mocks MakeHTTPRequest captures the meaningful wire intent (function +
22
+ * params). `Params` keys are already Moodle-shaped (flat for reads; bracket-notation for write arrays).
23
+ */
24
+ export interface MoodleRPCRequest {
25
+ /** The wsfunction operation selector (e.g. core_course_get_courses). */
26
+ WsFunction: string;
27
+ /** Flat urlencoded params. wstoken/moodlewsrestformat/wsfunction are added by MakeHTTPRequest. */
28
+ Params: Record<string, string | number>;
29
+ }
30
+ export declare class TotaraConnector extends BaseRESTIntegrationConnector {
31
+ /** Resolved auth per CompanyIntegration.ID — avoids re-loading the credential every fetch/CRUD call. */
32
+ protected authCache: Map<string, TotaraAuthContext>;
33
+ /** Verbatim MJ: Integrations.Name (three-way identity invariant). */
34
+ get IntegrationName(): string;
35
+ /** Create is wired (courses/users/cohorts/groups/groupings/notes/categories + association adds). */
36
+ get SupportsCreate(): boolean;
37
+ /** Update is wired for the objects that expose an update_* wsfunction (courses/users/cohorts/…/notes). */
38
+ get SupportsUpdate(): boolean;
39
+ /** Delete is wired for the objects that expose a delete/unenrol/remove wsfunction. */
40
+ get SupportsDelete(): boolean;
41
+ /**
42
+ * core_webservice_get_site_info enumerates the functions ENABLED FOR THE CALLING TOKEN (role/capability
43
+ * gated), NOT a complete-gamut describe of the site — so keep the base default false: a
44
+ * comprehensive-refresh must never deactivate a Declared IO/IOF because one token can't see it.
45
+ */
46
+ get DiscoveryIsAuthoritative(): boolean;
47
+ /**
48
+ * Keyset/no-watermark resume hint — returns the IO's declared `Configuration.stableOrderingKey`
49
+ * (usually the record's `id`), or null when the object declares none. Read from the frozen metadata,
50
+ * never guessed.
51
+ */
52
+ StableOrderingKey(objectName: string): string | null;
53
+ /**
54
+ * Verifies the wstoken + endpoint. The primary probe is core_webservice_get_site_info (it also carries
55
+ * the site name / release), BUT some Totara instances / token-service configurations throw a NON-auth
56
+ * "No service found in get_site_info" codingerror on it even for a fully valid token (verified live
57
+ * against a real instance). So a non-auth failure on site-info FALLS BACK to a lightweight real read
58
+ * (core_course_get_categories): if that returns a record array, the token is valid and the connection
59
+ * works. Only a genuine auth error (invalid/expired token, access denied) — or a failing fallback read —
60
+ * reports failure.
61
+ */
62
+ TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
63
+ /** A genuine credential / authorization failure — terminal; never fall back on these. */
64
+ private isTotaraAuthError;
65
+ /**
66
+ * Fallback connection check: a valid wstoken that returns a record array from a lightweight read
67
+ * (core_course_get_categories) proves the connection even when site-info is unavailable on the instance.
68
+ */
69
+ private verifyConnectionViaRead;
70
+ /** Resolves the wstoken + endpoint from the credential store / Configuration. Cached per connection. */
71
+ protected Authenticate(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<TotaraAuthContext>;
72
+ /**
73
+ * Content-type headers for a Moodle REST-RPC POST. The wstoken is NOT a header — it is injected as a
74
+ * urlencoded body PARAM in {@link MakeHTTPRequest} from the auth context.
75
+ */
76
+ protected BuildHeaders(_auth: RESTAuthContext): Record<string, string>;
77
+ /** The fully-resolved RPC endpoint (`{base_url}/webservice/rest/server.php`) from the auth context. */
78
+ protected GetBaseURL(_companyIntegration: MJCompanyIntegrationEntity, auth: RESTAuthContext): string;
79
+ /**
80
+ * The Moodle REST-RPC transport boundary. `body` MUST be a {@link MoodleRPCRequest}; this builds the
81
+ * urlencoded form — `wstoken` (from the auth context) + `moodlewsrestformat=json` + `wsfunction=<fn>` +
82
+ * every entry of `Params` (already Moodle-shaped) — POSTs it, and parses the JSON response. Test
83
+ * subclasses override this to capture the request and return canned bodies.
84
+ */
85
+ protected MakeHTTPRequest(auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
86
+ /**
87
+ * Extracts the record array from a Moodle response, DETECTING the exception envelope first: Moodle
88
+ * signals errors via a 200-status body `{exception, errorcode, message, debuginfo}` — this throws an
89
+ * ERROR carrying the errorcode rather than returning a silent empty (frozen contract ErrorResponseShape).
90
+ * `responseDataKey` is the wrapping envelope key (e.g. `users`, `items`, `sitenotes`); null → the body is
91
+ * a bare top-level array. A single wrapped object is returned as a one-element array.
92
+ */
93
+ protected NormalizeResponse(rawBody: unknown, responseDataKey: string | null): Record<string, unknown>[];
94
+ /**
95
+ * Moodle list functions carry no envelope-level `HasMore`; termination is inferred from a full page
96
+ * (record count == page size). Hierarchy `*_index` functions DO return `{page, pages, total}` metadata —
97
+ * when present that is used for an exact stop. `None` never paginates.
98
+ */
99
+ protected ExtractPaginationInfo(rawBody: unknown, paginationType: PaginationType, currentPage: number, currentOffset: number, pageSize: number): PaginationState;
100
+ /**
101
+ * Never-shrink SAMPLE-UNION: enrich each object's DECLARED (docs) field set with fields observed by live
102
+ * SAMPLING ({@link DiscoverFieldsViaFetch}) so a tenant's custom user/course fields reach the schema
103
+ * without ever losing or narrowing a declared field. Per object, parallel + best-effort — a sampling
104
+ * failure (e.g. a scope-requiring function) leaves that object's declared fields authoritative. Wire at
105
+ * IntrospectSchema, NEVER DiscoverFields (DiscoverFieldsViaFetch falls back to DiscoverFields → recursion).
106
+ */
107
+ IntrospectSchema(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<SourceSchemaInfo>;
108
+ /**
109
+ * Reads one object via its `Configuration.wsfunction`. Applies Offset (`limitfrom`/`limitnum(ber)`) or
110
+ * PageNumber (`page`/`perpage`) pagination from the object's declared `paginationParams`, merges any
111
+ * declared scope args, and unions every record collection the envelope exposes (single `responseEnvelopeKey`
112
+ * OR multi-collection `recordCollectionKeys` — e.g. Notes' sitenotes+coursenotes+personalnotes). Fetches
113
+ * ONE page per call; the engine loops on HasMore. Full-record pass-through: every source key reaches Fields.
114
+ */
115
+ FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
116
+ /**
117
+ * Parent-scoped RPC fetch. Some Moodle read functions REQUIRE a parent id param — e.g.
118
+ * core_enrol_get_enrolled_users / core_course_get_contents / core_enrol_get_course_enrolment_methods all
119
+ * need a `courseid`. Declared via `Configuration.parentScope = { parentWsFunction, paramName, parentIdField? }`.
120
+ * The connector loads the parent ids from the parent's own list wsfunction, then fires ONE request per
121
+ * parent — keyset-resumable over the parent ids (ctx.AfterKeyValue), bounded per call (engine loops until
122
+ * HasMore=false), concurrency + rate-limit governed by the engine hooks. A per-parent failure (e.g. an
123
+ * accessexception on one course) is surfaced as a warning, never fatal to the whole batch.
124
+ */
125
+ private fetchParentScoped;
126
+ /** Bounded-concurrency runner (the base's RunBounded is private). Single-threaded async → array pushes are safe. */
127
+ private runParentBounded;
128
+ /**
129
+ * Create via the object's `Configuration.writeFunctions.create` wsfunction, encoding the record as a
130
+ * Moodle bracket-notation array (`<param>[0][field]=...`). The new id is read from the response per
131
+ * `createResponseIDField`; association creates (no server id) synthesize a deterministic identity from the
132
+ * sent attributes. EITHER way the result routes through {@link BuildCreatedResult} so an empty id fails
133
+ * LOUDLY — never a hand-built `{Success:true, ExternalID:''}`.
134
+ */
135
+ CreateRecord(ctx: CreateRecordContext): Promise<CRUDResult>;
136
+ /**
137
+ * Update via `Configuration.writeFunctions.update`, injecting the target ExternalID under the object's PK
138
+ * field name inside the bracket-notation array body.
139
+ */
140
+ UpdateRecord(ctx: UpdateRecordContext): Promise<CRUDResult>;
141
+ /**
142
+ * Delete via `Configuration.writeFunctions.delete` (verb NOT assumed — some are unenrol/remove), sending
143
+ * the target ExternalID in the Moodle ids array (`<param>ids[0]=<id>`).
144
+ */
145
+ DeleteRecord(ctx: DeleteRecordContext): Promise<CRUDResult>;
146
+ /** Shared write POST: authenticate + dispatch the wsfunction with the given (already-bracketed) params. */
147
+ private postWrite;
148
+ /** Builds the read request params: declared scope args + pagination (Offset limitfrom / PageNumber page). */
149
+ private buildReadParams;
150
+ /**
151
+ * Merges declared scope args for functions that require a parent scope (e.g. a courseid / userid /
152
+ * groupids). Two sources, both metadata-driven (never guessed): the IO's own `Configuration.defaultArgs`,
153
+ * and a per-connection `Configuration.objectArgs["<ObjectName>"]` override. Absent → nothing added; a
154
+ * scope-requiring function then returns a Moodle exception, surfaced (not swallowed) by NormalizeResponse.
155
+ */
156
+ private applyScopeArgs;
157
+ /**
158
+ * Offset pagination. `paginationParams` = [fromName, countName]. A dotted name (`options.limitfrom`) is a
159
+ * Moodle options-array param → emitted as `options[i][name]=limitfrom&options[i][value]=<n>`; a flat name
160
+ * is emitted directly. `countName` may carry alternates (`limitnum|limitnumber`) — the first is used.
161
+ */
162
+ private applyOffsetPagination;
163
+ /** PageNumber pagination. `paginationParams` = [pageName, sizeName?]; sizeName is optional (`page` alone). */
164
+ private applyPageNumberPagination;
165
+ /**
166
+ * The Moodle array-parameter name for a write body. Resolution (metadata-driven, never a baked catalog):
167
+ * (1) an explicit `Configuration.writeFunctions.arrayParam`; (2) the bracket prefix of a declared
168
+ * `updateIDField`/`deleteIDField` (e.g. `courses[0][id]` → `courses`); (3) the trailing plural token of
169
+ * the write function name (`core_user_create_users` → `users`). Genuinely-idiosyncratic association params
170
+ * (enrolments, members) should carry an explicit `arrayParam` override — see CODE_REPORT.md.
171
+ */
172
+ private resolveWriteArrayParam;
173
+ /** The Moodle ids-array parameter for a delete/unenrol/remove call (e.g. `courseids`). */
174
+ private resolveDeleteIdsParam;
175
+ /** Trailing plural token of a Moodle wsfunction name (`core_user_create_users` → `users`). */
176
+ private deriveArrayParamFromFunction;
177
+ /** The `[0][id]` prefix of a bracketed id-field path (`courses[0][id]` → `courses`; `courseids[0]` → `courseids`). */
178
+ private bracketPrefix;
179
+ /** The field name the ExternalID goes under on update (from a declared `updateIDField`, else the PK, else `id`). */
180
+ private updateIdFieldName;
181
+ /** Recursively renders a record into Moodle bracket-notation params under `<arrayParam>[0]`. */
182
+ private bracketEncodeRecord;
183
+ private bracketEncode;
184
+ /** Drops IsReadOnly source fields from a write body (respects the read-only constraint). */
185
+ private filterWritable;
186
+ /**
187
+ * Collection keys to extract from a read response, resolved in priority order:
188
+ * 1. `Configuration.recordCollectionKeys` — multi-collection union (e.g. Notes' sitenotes+coursenotes+
189
+ * personalnotes), when the frozen metadata declares it.
190
+ * 2. The first-class `ResponseDataKey` COLUMN — the canonical "where the return wraps records" slot
191
+ * (connector-code-conventions §4 / frozen-contract requirement). This is authoritative: the current
192
+ * Totara metadata carries the envelope key HERE (`users`, `statuses`, `items`, …) with
193
+ * `Configuration.responseEnvelopeKey` null, so the column MUST be read or a `{users:[…]}` envelope
194
+ * would be mis-emitted as a single wrapper record instead of the N wrapped records.
195
+ * 3. `Configuration.responseEnvelopeKey` — backward-compatible fallback for older metadata shapes.
196
+ * A resolved `null` means the body is a bare top-level array.
197
+ */
198
+ private recordCollectionKeys;
199
+ private toRecordArray;
200
+ /**
201
+ * §4 identity: the declared PK when EVERY component is present + non-empty, else a deterministic content
202
+ * hash (so PK-less / partial-key records stay syncable + dedupable). Full-record pass-through: Fields
203
+ * carries the COMPLETE source record (with the synthetic id stamped into a single empty PK column).
204
+ */
205
+ private buildExternalRecord;
206
+ private primaryKeyFieldNames;
207
+ /** Pulls the created record's id from a Moodle create response (bare array of created / wrapped collection). */
208
+ private extractCreatedId;
209
+ /** Deterministic identity for an association create with no server id (matches the §4 content-hash path). */
210
+ private synthesizeAssociationId;
211
+ private detectMoodleError;
212
+ /** Throws when the body is a Moodle exception envelope (surfaces the errorcode) — never a silent empty. */
213
+ private assertNoMoodleError;
214
+ private buildCRUDError;
215
+ /** Resolves the wstoken + base_url from the credential store (secrets) + Configuration JSON (overrides). */
216
+ protected ParseConfig(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<TotaraConfig>;
217
+ private loadCredentialValues;
218
+ private normalizeConfig;
219
+ /** `{base_url}/webservice/rest/server.php`, tolerant of a base_url that already includes the suffix. */
220
+ private buildEndpoint;
221
+ private parseConnectionConfig;
222
+ private readIOConfig;
223
+ private readWriteFunctions;
224
+ private readConfigString;
225
+ private readConfigStringArray;
226
+ private readConfigObject;
227
+ private parseJson;
228
+ private headersToObject;
229
+ /** The first record object in a response (bare array → [0]; wrapped → first array's [0]; object → itself). */
230
+ private firstRecord;
231
+ private firstArrayValue;
232
+ private recordCount;
233
+ private deepFindKey;
234
+ private asNumber;
235
+ }
236
+ /** Tree-shaking prevention — import and call from the package entry point. */
237
+ export declare function LoadTotaraConnector(): void;
238
+ export {};