/**
*
* @class Provides various methods to access the server-side data API.
* ## Specifying Column Names
* **Many server calls support filtering, grouping and sorting. In order to be compatible with the API's data Sieve, best-practice is to wrap any variable names in braces: <code>{var}</code>.**
*
* ## Filter format
* The <code>filter</code> argument is a string that supports the same expressions as the underlying database engine's <code>WHERE</code> clause.
* Note that the specific keyword format depends on the SQL dialect support by the backend engine.
* The API includes some helper methods to make this easier e.g. [<code>getSqlDateFormat</code>](#getSqlDateFormat)
*
* ```sql
* {var1} > 5 AND {var2} BETWEEN 1 AND 5
* ```
*
* ## Sort format
* The <code>sort</code> argument is a string that supports the same expressions as the underlying database engine's <code>ORDER BY</code> clause.
* The default sort order is <code>ASC</code>.
*
* ```sql
* {var1}, {var2} DESC, {var3} ASC
* ```
*
* ## Group format
* A comma-delimited set of variable names to group the result by.
* Generating sub-total results from groupings is controlled via the <code>rollup</code> and <code>cube</code> options.
*
* ```sql
* {var1},{var2}
* ```
*
* @example
* const server = new Server("api/v1");
*
* @param {string} api Path to the API handler's relative path, excluding the server's host name.
*
* @returns A new Server instance.
*
* @copyright (c) 2021 TLF Research Ltd.
*
*/
function Server(api) {
const self = this;
this.debugger = null;
/**
* When used on a method that supports it will disable the client-side cache.
*
* @memberof Server
* @constant {bool} NO_CACHE
*/
Server.NO_CACHE = false;
/**
* Path to the API handler's relative path, excluding the server's host name (as provided to the Server's constructor).
*
* @memberof Server
* @constant {string} API_ENDPOINT
*/
Server.API_ENDPOINT = api;
/**
* Store for any Global objects
* @type {object}
*/
this.Globals = {};
this.unauthorizedhandler = function () { };
/**
* Sets a handler that is triggered if an API call is unauthorized.
* May be used to update the UI to indicate to the user that the API is not available.
* Set to null to disable.
*
* @param {function|null} callback Function to be invoked if the IDP rejects an API call
*/
this.setUnauthorizedHandler = function (callback) {
if (typeof callback !== null && typeof callback !== "function") {
console.error("Error handler must be a function or null");
return;
}
this.unauthorizedhandler = callback;
};
/**
* Asynchronously fetches metadata about the API server, including the maximum number of result rows that will be returned,
* the backend database engine used, and a list of supported aggregator functions that may be used with
* the [getResponsesAggregate](#getResponsesAggregate) method.
*
* @example
* server.getInfo().done(info => {
* // info in here
* }}.catch(function() {
* // something went wrong
* })
*
* @example
* info = {
* "databaseType": "sqlite3",
* "maxResults": 10000,
* "aggregators": [
* {
* "name": "Average",
* "function": "avg",
* "type": "float",
* "description": "Calculates the average for each of the given columns"
* },
* {
* "name": "Count",
* "function": "count",
* "type": "int",
* "description": "Counts all non-null responses for the given columns"
* },
* ...
* ]};
* });
*
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching by the client.
*
* @returns {jQuery.Deferred}
*/
this.getInfo = function (cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/server",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches information about the currently logged-in user, including name, email, groups, roles and visible projects.
*
* @example
* server.getUser().done(user => {
* // user in here
* }}.catch(function() {
* // something went wrong
* })
*
* @example
* user = {
* "username": "demouser@example.com",
* "name": "Demo User",
* "given_name": "Demo",
* "family_name": "User",
* "email": "demouser@example.com",
* "email_verified": true,
* "o": "TLF Research",
* "access_control": "none",
* "groups": [
* "/TLF Research"
* ],
* "roles": [
* "manage_users_basic",
* "manage_users",
* "offline_access",
* "uma_authorization"
* ],
* "projects": [
* {
* "client": "Building Client",
* "id": 1,
* "name": "Building Demo",
* "stopwords": "Building"
* },
* {
* "client": "Catalyst Perception",
* "id": 2,
* "name": "Catalyst Perception",
* "stopwords": ""
* }]
* };
*
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getUser = function (cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/user",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches metadata about one or more Projects.
* The response also includes information about all the Variables associated with the Project.
*
* @example
* server.getProjects([1]).done(projects => {
* // projects in here
* }).catch(function() {
* // something went wrong
* });
*
* @example
* projects = {
* "client": "Project Client",
* "id": 1,
* "name": "Project Demo",
* "stopwords": "Project,Demo",
* "vars": {
* "var1": {
* "categories": [
* "respondent"
* ],
* "comments": "",
* "exportable": true,
* "falsevalue": "No",
* "id": 1,
* "label": "Project Variable 1",
* "name": "var1",
* "project": 1,
* "truevalue": "Yes",
* "type": "int"
* },
* ...
* ]
* };
* });
*
* @param {number[]} ids IDs of the Project(s) to fetch
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getProjects = function (ids, cache) {
cache = cache !== false;
const requests = ids.reduce(function (acc, id) {
acc.push(
jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + id,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
})
);
return acc;
}, []);
return $.when.apply($, requests);
};
/**
* Asynchronously fetches information about any available AI models.
*
* @example
* server.getAIModels().done(models => {
* // models in here
* }).catch(function() {
* // something went wrong
* });
*
* @example
* models = "[
* {
* "ModelArn": "arn:aws:bedrock:eu-west-2::foundation-model/amazon.titan-text-lite-v1:0:4k",
* "ModelId": "amazon.titan-text-lite-v1:0:4k",
* "CustomizationsSupported": [],
* "InferenceTypesSupported": [
* "PROVISIONED"
* ],
* "InputModalities": [
* "TEXT"
* ],
* "ModelLifecycle": {
* "Status": "ACTIVE"
* },
* "ModelName": "Titan Text G1 - Lite",
* "OutputModalities": [
* "TEXT"
* ],
* "ProviderName": "Amazon",
* "ResponseStreamingSupported": true
* },
* ...
* ];
*
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getAIModels = function (cache = true) {
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/ai/models",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Fetches a Retrieval Augmentation Generation (RAG) response from a model via a knowledge base.
*
* @example
* server.getAIRAGResponse('my-model', 'my-knowledge-base', 'hello world', 0.6).done(resp => {
* // resp in here
* }).catch(function() {
* // something went wrong
* });
*
* @example
* resp = ""
*
* @param {string} kbID The ID of the knowledge base to query.
* @param {string} modelID The ID of the foundation model used to process the RAG request.
* @param {string} prompt The prompt to use.
* @param {number} temperature The LLM temperature.
* @param {string} sessionID The chat session ID (used to maintain context).
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getAIRAGResponse = function (kbID, modelID, prompt, temperature = null, sessionID = null, cache = true) {
modelID = encodeURIComponent(modelID);
kbID = encodeURIComponent(kbID);
prompt = encodeURIComponent(prompt);
const session = sessionID ? `/session/${encodeURIComponent(sessionID)}` : '';
const url = temperature ? `/ai/kb/${kbID}/model/${modelID}/temp/${temperature}${session}/prompt/${prompt}` : `/ai/kb/${kbID}/model/${modelID}/prompt/${prompt}`;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + url,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Fetches a response from an LLM using a POST request via the native fetch API.
* Note: This returns the EventSource instance directly, allowing the caller to manage it.
*
* @param {string} prompt The prompt to send in the request body.
* @param {object} promptData Contains configuration for the request.
* @param {string[]} [promptData.attachments] IDs of files attached to this turn, from postAIAttachment.
* Only new attachments need listing - those sent on earlier turns stay bound to the session.
* @param {object} options An object containing callbacks and an optional AbortSignal.
* @param {function(object)} options.onMessage Called for each data chunk received.
* @param {function(Error)} options.onError Called if an error occurs.
* @param {function()} options.ondone Called when the stream is successfully closed by the server.
* @param {AbortSignal} [options.signal] An optional signal to abort the request.
* @param {string} sessionID The chat session ID.
* @param {boolean} [cache=true] If false, the query will include a timestamp to override caching.
*
* @returns {EventSource} The EventSource instance. The caller must add event listeners.
*/
this.postAIResponseStream = async function (prompt, promptData, options, sessionID = null, cache = true) {
const {
onmessage,
onerror = (err) => console.error("AI stream error:", err),
ondone = () => { },
signal
} = options || {};
if (typeof onmessage !== 'function') {
throw new Error("The 'options' object must include an 'onmessage' function.");
}
if (!promptData || !promptData.project) {
throw new Error("Missing 'project' in promptData");
}
const { project, lightweightModel, tools, config, attachments } = promptData;
if (!project.id) {
throw new Error("Missing 'project.id' in promptData");
}
const params = new URLSearchParams();
if (sessionID) {
params.append('sessionID', sessionID);
}
params.append('lightweightModel', lightweightModel || false);
params.append('tools', tools !== false);
if (!cache) {
params.append('t', Date.now());
}
const url = `${Server.API_ENDPOINT}/ai/stream/project/${encodeURIComponent(project.id)}?${params.toString()}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, config, attachments }),
signal: signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error ${response.status}: ${errorText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break; // Stream finished
}
buffer += decoder.decode(value, { stream: true });
// SSE messages are separated by a blank line
const parts = buffer.split(/\r?\n\r?\n/);
buffer = parts.pop(); // The last part might be incomplete, keep it in the buffer
for (const part of parts) {
// Take the data: lines only - comments, event: lines and stray
// leading newlines must not throw the whole message away
const dataString = part
.split(/\r?\n/)
.filter(line => line.startsWith('data:'))
.map(line => line.substring(5).trim())
.join('\n')
.trim();
if (!dataString) continue;
try {
onmessage(JSON.parse(dataString));
} catch (e) {
console.error("Failed to parse JSON from stream chunk:", dataString, e);
onerror(e);
}
}
}
ondone(); // Signal that the stream has completed successfully
} catch (err) {
onerror(err);
}
};
/**
* Fetches a non-streaming AI response for a project prompt.
* Responses are cached server-side per project, tier, and prompt.
*
* @example
* server.getAIResponse(123, 'Summarise this project').done(resp => {
* console.log(resp);
* }).catch(function() {
* // something went wrong
* });
*
* @param {number} projectID The project ID.
* @param {string} prompt The prompt to send.
* @param {string} [tier='balanced'] Model tier: 'fast', 'balanced', or 'powerful'.
* @param {bool} [cache=true] If false, the query will include a timestamp to bypass caching.
*
* @returns {jQuery.Deferred}
*/
this.getAIResponse = function (projectID, prompt, tier = 'balanced', cache = true) {
const params = new URLSearchParams({ prompt, tier });
if (!cache) {
params.append('nocache', '1');
}
return jQuery.get({
dataType: "json",
cache: cache,
url: `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(projectID)}?${params.toString()}`,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Restores a chat history for a given project.
*
* @param {object} project
* @param {object} history
* @returns
*/
this.restoreChat = function (project, history) {
try {
const h = JSON.stringify(history);
return jQuery.post({
url: `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/restore-chat`,
data: `{"history": ${h}}`,
contentType: "application/json",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
} catch (err) {
return $.Deferred().reject(new Error(`Failed to restore chat: ${err.message}`));
}
};
/**
* Fetches what the AI assistant will accept as an attachment, and the limits that apply.
*
* Use this to build a file dialog's accept filter and to refuse a hopeless file before
* uploading it. The 'vision' and 'documents' flags depend on the model in use, and the accept
* lists are filtered to match - a model that cannot read images returns an empty accept.image.
*
* When 'enabled' is false, attachments are switched off on the server and no attach control
* should be offered at all.
*
* @example
* server.getAIAssistantCapabilities().done(caps => {
* if (!caps.enabled) return;
* input.accept = [...caps.accept.image, ...caps.accept.document, ...caps.accept.text].join(',');
* });
*
* @param {string} [tier] Model tier to report on - 'fast', 'balanced' or 'powerful'.
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getAIAssistantCapabilities = function (tier = null, cache = true) {
const params = new URLSearchParams();
if (tier) {
params.append('tier', tier);
}
const query = params.toString();
return jQuery.get({
dataType: "json",
cache: cache,
url: `${Server.API_ENDPOINT}/ai/capabilities${query ? '?' + query : ''}`,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Uploads a file to attach to an AI chat session and resolves with its metadata.
*
* The returned 'id' is passed in the attachments array of postAIResponseStream. The 'inline'
* flag says whether the file's bytes will travel with the turn or whether the model will read
* it from the server a portion at a time, which is worth showing to the user: it explains why
* a large spreadsheet behaves differently from a screenshot.
*
* Uses fetch rather than jQuery so that upload progress and cancellation are available. Note
* that 'credentials: include' is required here: fetch does not honour the xhrFields setting
* that the jQuery calls in this file rely on.
*
* @example
* const controller = new AbortController();
* const meta = await server.postAIAttachment(project, file, {signal: controller.signal});
*
* @param {object} project The project the chat session belongs to.
* @param {File} file The file to upload.
* @param {object} [options] Optional settings.
* @param {AbortSignal} [options.signal] Signal to abort the upload.
*
* @returns {Promise<object>} Resolves with {id, name, mime, size, kind, inline, readable}.
*/
this.postAIAttachment = async function (project, file, options = {}) {
if (!project || !project.id) {
throw new Error("Missing 'project.id'");
}
if (!file) {
throw new Error("No file to attach");
}
const body = new FormData();
body.append('file', file, file.name);
const response = await fetch(`${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/attachment`, {
method: 'POST',
body: body,
credentials: 'include',
headers: {
"X-Requested-With": "XMLHttpRequest",
},
signal: options.signal,
});
if (!response.ok) {
// Apache turns an over-sized upload into a 502 rather than passing the 413 through, so
// both mean the same thing to the person who chose the file.
if (response.status === 413 || response.status === 502) {
throw new Error('That file is too large to upload.');
}
// The server's rejections here are written for the user - the wrong file type, past
// their allowance - so prefer its message to a generic one. SendJSONError shapes these
// as {status, message}.
let message = `Upload failed (${response.status})`;
try {
const problem = await response.json();
if (problem && problem.message) {
message = problem.message;
}
} catch (err) {
// No JSON body; the status-based message above will have to do.
}
throw new Error(message);
}
return response.json();
};
/**
* Removes a chat attachment.
*
* Safe to call more than once, and safe to call for something already expired: the server
* answers 204 either way. Nothing depends on this being called - abandoned attachments are
* collected automatically - it simply frees the space sooner.
*
* @param {object} project The project the chat session belongs to.
* @param {string} id The attachment ID.
* @param {object} [options] Optional settings.
* @param {bool} [options.keepalive=false] Set true to let the request outlive the page, for
* cleanup fired from a pagehide handler.
*
* @returns {Promise<void>}
*/
this.deleteAIAttachment = async function (project, id, options = {}) {
if (!project || !project.id || !id) {
return;
}
const url = `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/attachment/${encodeURIComponent(id)}`;
await fetch(url, {
method: 'DELETE',
credentials: 'include',
headers: {
"X-Requested-With": "XMLHttpRequest",
},
keepalive: !!options.keepalive,
});
};
/**
* Fetches a chat attachment's content as a Blob, for previewing it.
*
* Deliberately a fetch rather than a URL to point an <img> or <object> at. The portal is served
* from a different origin to the API, so an element load would only send the API session cookie
* if it were marked SameSite=None; and when a login has lapsed the API answers with a redirect,
* which an element load reports as nothing more than a broken image. Fetching sends credentials
* explicitly and lets an expired attachment be told apart from an expired session.
*
* @param {object} project The project the chat session belongs to.
* @param {string} id The attachment ID.
* @param {object} [options] Optional settings.
* @param {bool} [options.preview=false] If true, a large text attachment is capped at the first
* 64 KB - all a preview shows in any case.
* @param {AbortSignal} [options.signal] Signal to abort the fetch.
*
* @returns {Promise<Blob>}
*
* @throws {Error} With 'expired' set to true when the attachment is no longer available.
*/
this.getAIAttachmentContent = async function (project, id, options = {}) {
if (!project || !project.id || !id) {
throw new Error("Missing project or attachment ID");
}
const params = options.preview ? '?preview=1' : '';
const url = `${Server.API_ENDPOINT}/ai/project/${encodeURIComponent(project.id)}/attachment/${encodeURIComponent(id)}/content${params}`;
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
"X-Requested-With": "XMLHttpRequest",
},
signal: options.signal,
});
if (!response.ok) {
// 410 is the server saying the ID was valid but the file has been cleaned up, which is
// the ordinary end of an attachment's life rather than an error worth apologising for.
if (response.status === 410) {
const expired = new Error('This attachment has expired.');
expired.expired = true;
throw expired;
}
throw new Error(`Could not load the attachment (${response.status})`);
}
return response.blob();
};
/**
* Returns the value of the given variable or undefined if it doesn't exist.
*
* @param {Object} varName Project to search.
* @param {string} varName Name of the date variable to format
*
* @return {any|undefined} Value of requested variable.
*/
this.getProjectVar = function (project, varName) {
return getProjVar(project, varName);
};
/**
* Asynchronously fetches an array of Survey Responses.
*
* @example
* server.getResponses(proj).done(function(responses) {
* // Response data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* responses = {
* "total": 12345,
* "rows": [
* {
* "accountnumber": 1,
* "allowcontact": 1,
* "branch": "Branch 1",
* "comm1": "A Comment",
* "comm2": null,
* "comm3": "Another Comment",
* "hadproblem": 1,
* "id": 1,
* "imp1": 3,
* "imp2": 7,
* "imp3": 8,
* "merchant": "Company Ltd.",
* "ovsat": 10,
* "purchased": 0,
* "region": "A Region",
* "respondentcompany": "Respondent Ltd.",
* "respondentname": "John Smith",
* "sat1": 7,
* "sat2": 7,
* "sat3": 8,
* "satindex": 68.90479780129151,
* "touchpointdate": "2020-01-18 06:00:37"
* },
* ...
* ],
* "truncated": false
* };
*
* @param {object} project Project to inspect
* @param {string[]} [props=null] Array of properties to return in each Response. Default is to fetch all properties.
* @param {string} [sort=null] Order in which to return the Responses
* @param {number} [offset=0] Offset of first Response to fetch (for server-side paging)
* @param {number} [limit=Number.MAX_SAFE_INTEGER] Maximum number of Responses to return (for server-side paging)
* @param {number} [sample=0] Request a random sample of Responses
* @param {string} [filter] Filter to apply to the Responses
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
*
* key | type |description
* --- | --- | ---
* <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
* <code>total</code> | number | Total number of records in the dataset - required by some UI widgets to determine paging. **Not** necessarily the number of rows returned.
* <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
* <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
*/
this.getResponses = function (project, props, sort, offset, limit, sample, filter, cache) {
cache = cache !== false;
const params = [];
addPropsParam(params, props);
addSortParam(params, sort);
addPagingParam(params, offset, limit);
addSampleParam(params, sample);
addFilterParam(params, filter);
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/responses?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches the coding categories of every coded Response.
*
* This method is a special-case of the [<code>getResponses</code>](#getResponses) method.
* The column set is fixed server-side, so the coding variable can stay marked as not exportable -
* keeping it out of <code>getResponses</code> and every CSV export - while still being readable here.
*
* @example
* server.getProjectCategories(proj).done(function(responses) {
* // Category data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* responses = {
* "total": 1234,
* "rows": [
* {
* "coding": "{\"comm1\":{\"+Staff were helpful\":{}}}",
* "id": 1,
* "month": "2026-04-18 06:00:37"
* },
* ...
* ],
* "truncated": false
* };
*
* @param {object} project Project to inspect
* @param {number} [offset=0] Offset of first Response to fetch (for server-side paging)
* @param {number} [limit=Number.MAX_SAFE_INTEGER] Maximum number of Responses to return (for server-side paging)
* @param {string} [filter] Filter to apply to the Responses. This can only ever narrow the resultset - the server ANDs it onto its own clauses rather than replacing them.
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler has the same keys as
* [<code>getResponses</code>](#getResponses). Each row holds the coding variable and the
* Project's own date variable - whichever variable the Project places in the
* <code>response_date</code> category - so callers never need to name either column.
*/
this.getProjectCategories = function (project, offset, limit, filter, cache) {
cache = cache !== false;
const params = [];
addPagingParam(params, offset, limit);
addFilterParam(params, filter);
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/categories?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches one of more Surveys from Surveys 6.
*
* @example
* server.getSurveys(surveyID).done(function(responses) {
* // Summary data in here
* }).fail(function() {
* // Something went wrong
* });
*
* @example
* {
* Survey Object
* },
*
* @param {array} surveyID Encrypted ID of the survey to fetch.
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getSurvey = function (surveyID, cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + `/s6/surveys/${surveyID}`,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches a summary of the Survey Response rates from Surveys 6.
*
* @example
* server.getSurveyResponsesSummary(proj).done(function(responses) {
* // Summary data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* {
* "results": [
* {
* "completed": 212,
* "notStarted": 1339,
* "paused": 73,
* "site": "Not Known",
* "total": 1624,
* "usablePaused": 0
* },
* "totals": [
* {
* "completed": 212,
* "notStarted": 1339,
* "paused": 73,
* "total": 1624,
* "usablePaused": 0
* }
* ],
* },
*
* @param {string} surveyID ID of the survey to inspect (encrypted)
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getSurveyResponsesSummary = function (surveyID, cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + `/s6/surveys/${surveyID}/responses/rates`,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches a summary of the Survey Response rates from Surveys 6.
*
* @example
* server.getSurveyCompletesByDay(proj).done(function(responses) {
* // Summary data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* [
* {
* "count": 47,
* "date": "2024-11-14"
* },
* {
* "count": 41,
* "date": "2024-11-15"
* },
* {
* "count": 15,
* "date": "2024-11-16"
* },
* ...
* ],
*
* @param {string} surveyID ID of the survey to inspect (encrypted)
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getSurveyCompletesByDay = function (surveyID, cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + `/s6/surveys/${surveyID}/completes-by-day`,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches an array of distinct response values for a given variable.
* This is often used to dynamically populate a dropdown.
*
* @example
* server.getResponsesDistinct(proj, "region").done(function(responses) {
* // Response data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* responses = {
* "total": 4,
* "rows": [
* {
* "region": "West Region"
* },
* {
* "region": "North Region"
* },
* {
* "region": "South Region"
* },
* {
* "region": "East Region"
* }
* ],
* "truncated": false
* };
*
* @param {object} project Project to use
* @param {string} varName Survey variable to inspect
* @param {string} [filter=null] Filter to apply to the Responses
* @param {array|string} [groups=null] Group by expressions
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
*
* key | type |description
* --- | --- | ---
* <code>total</code> | number | Total number of records in the dataset - required by some UI widgets to determine paging. **Not** necessarily the number of rows returned.
* <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
* <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
*/
this.getResponsesDistinct = function (project, varName, filter, groups, cache) {
cache = cache !== false;
const projVar = getProjVar(project, varName);
if (!projVar) {
console.error(`Missing variable ${varName} in project ${project.name}`);
return;
}
const params = [];
addFilterParam(params, filter);
addGroupParam(params, groups);
return jQuery.get({
dataType: "json",
cache: cache,
url:
Server.API_ENDPOINT +
"/project/" +
project.id +
"/responses/distinct/" +
projVar.id +
"?" +
params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches the result of applying an aggregate function, like AVG or COUNT, to one or more Variables.
* Different backend database engines support different aggregators.
* The [<code>getInfo</code>](#getInfo) method returns a list of supported aggregators.
* The <code>function</code> property of each supported aggregator from <code>getInfo</code> may be provided in the <code>aggregator</code> property here.
*
* @example
* server.getResponsesAggregate("myquery", proj, "avg", "ovsat", null, "region").done(function(responses) {
* // Response data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* responses = {
* "name": "avg",
* "tag": "myquery",
* "vars": "ovsat",
* "group": "region",
* "truncated": false,
* "rows": [
* {
* "ovsat": 6.925230769230769,
* "region": "East Region"
* },
* {
* "ovsat": 8.563069685386651,
* "region": "North Region"
* },
* {
* "ovsat": 7.721746031746032,
* "region": "South Region"
* },
* {
* "ovsat": 7.334545454545455,
* "region": "West Region"
* }
* ]
* };
*
* @param {string} name Name of the dataset (included in the returned results in the response's <code>tag</code> key)
* @param {object} project Project to use
* @param {string} aggregator Aggregator function to apply (if the aggregator requires a value then append it after a ':' e.g. <code>lte:4</code>)
* @param {string|array} [varNames=null] Survey variable(s) to inspect
* @param {string} [filter=null] Filter to apply to the Responses
* @param {string|array} [groups=null] Survey variable(s) to group by
* @param {string} [sort=null] Sort order to apply
* @param {boolean} [rollup=false] When true, and we are grouping, then the results will include summary rows
* @param {boolean} [cube=false] When true, and we are grouping, then the results will include all combinations of summary rows
* @param {string} [having=null] Adds an SQL <code>HAVING</code> clause to the resultset
* @param {string} [rolling=null] Rolling period of the form <em>number</em>[d|w|m|y](<em>varName</em>) e.g. <code>3m(date)</code> or <code>ytd(<em>varName</em>,<em>startMonth</em>)</code>
* @param {string} [base=null] Base expression to to be used (only applicable to the <code>prop</code> aggregator) e.g. <code>var1=1 AND var2=1</code>
* @param {string} [splits=null] Split ranges to to be used (only applicable to the <code>split</code> aggregator) e.g. <code>25,50,75,100</code> to create 4 ranges: 0-25, 25-50, 50-75, 75-100
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
* @param {bool} [anonymize=false] If true, and the Sieve is in Auto mode, the Sieve will always be switched OFF for aggregator queries, but results outside the scope of the Sieve will be anonymized.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
*
* key | type |description
* --- | --- | ---
* <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
* <code>name</code> | string | The name of the aggregator function.
* <code>tag</code> | string | The value of the <code>name</code> parameter.
* <code>vars</code> | string | CSV of the names of the variables that were inspected, as provided on the <code>varNames</code> parameter.
* <code>group</code> | string | CSV of the names of the variables that were grouped, as provided on the <code>group</code> parameter.
* <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
* <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
* <code>anonymize</code> | boolean | If <code>true</code> the Sieve will NOT anonymize responses, but any Sieved fields will be set to blank.
*/
this.getResponsesAggregate = function (
name,
project,
aggregator,
varNames,
filter,
groups,
sort,
rollup,
cube,
having,
rolling,
base,
splits,
cache,
anonymize
) {
cache = cache !== false;
if (!project) {
return $.Deferred().reject(new Error("Missing aggregator project"));
}
if (!aggregator) {
return $.Deferred().reject(new Error("Missing aggregate function"));
}
// Most aggregators require at least one variable
if (aggregator !== "inspect" && aggregator !== "count" && varNames.length === 0) {
return $.Deferred().reject(new Error('Missing variable(s) for "' + aggregator + '" aggregator'));
}
// The 'prop' aggregator requires a base expression
if (aggregator === "prop" && !base) {
return $.Deferred().reject(new Error('Missing base for "prop" aggregator'));
}
// The 'split' aggregator requires a base expression
if (aggregator === "split" && !splits) {
return $.Deferred().reject(new Error('Missing splits for "split" aggregator'));
}
// The 'split' aggregator requires a base expression
if (aggregator === "split" && !splits) {
return $.Deferred().reject(new Error('Missing splits for "split" aggregator'));
}
// Need some project variables
if (!project.vars) {
return $.Deferred().reject(new Error("No project variables"));
}
varNames = normalizeToArray(varNames);
// Build the API query parameters
const params = ["tag=" + name];
addFilterParam(params, filter);
addGroupParam(params, groups);
addSortParam(params, sort);
addRollupParam(params, rollup);
addCubeParam(params, cube);
addHavingParam(params, having);
addRollingParam(params, rolling);
addAnonymizeParam(params, anonymize);
// Convert the array of variable names to variable IDs
const ids = varNames.map(function (varName) {
const v = getProjVar(project, varName);
if (typeof v === "undefined") {
console.error("Missing variable", varName);
return null;
} else {
return v.id;
}
});
const connector = ids.length ? "/" : "";
const aggr = aggregator.split(":");
const aggrValue = aggr.length > 1 ? "/" + aggr[1] : "";
let url =
Server.API_ENDPOINT +
"/project/" +
project.id +
"/responses/" +
aggr[0] +
aggrValue +
connector +
ids.join(",");
if (aggr[0] === "prop") {
url += "/" + encodeURIComponent(base);
}
if (aggr[0] === "split") {
url += "/" + encodeURIComponent(splits);
}
url += "?" + params.join("&");
return jQuery.get({
dataType: "json",
cache: cache,
url: url,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches a list of word counts by combining all the given text-based Project Response variables (typically comment responses).
* The analysis will exclude any stopwords defined in the Project.
*
* @example
* server.getResponsesWordCount("myquery", proj, ["comm1", "comm2"]).done(function(wordcounts) {
* // Word count data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* wordcounts = {
* "name": "wc",
* "tag": "myquery",
* "vars": "comm1,comm2",
* "group": "",
* "truncated": false,
* "rows": [
* {
* "foo": 45,
* "bar": 5,
* "baz": 271,
* }
* ]
* };
*
* @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
* @param {object} project Project to use
* @param {string|array} varNames Survey variable(s) to inspect
* @param {string} [filter=null] Filter to apply to the Responses
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
*
* key | type |description
* --- | --- | ---
* <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
* <code>name</code> | string | The name of the aggregator function - always <code>wc</code>.
* <code>tag</code> | string | The value of the <code>name</code> parameter.
* <code>vars</code> | string | CSV of the names of the variables that were inspected, as provided on the <code>varNames</code> parameter.
* <code>group</code> | string | CSV of the names of the variables that were grouped, as provided on the <code>group</code> parameter.
* <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
* <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
*/
this.getResponsesWordCount = function (name, project, varNames, filter, cache) {
cache = cache !== false;
if (!project) {
return $.Deferred().reject(new Error("Missing aggregator project"));
}
// Require at least one variable
if (varNames.length === 0) {
return $.Deferred().resolve(new Error("Missing variable(s) for word count"));
}
varNames = normalizeToArray(varNames);
// Build the API query parameters
const params = ["tag=" + name];
addFilterParam(params, filter);
// Convert the array of variable names to variable IDs
const ids = varNames.map(function (varName) {
const v = getProjVar(project, varName);
if (v === "undefined") {
console.error("Missing variable", varName);
return null;
} else {
return v.id;
}
});
return jQuery.get({
dataType: "json",
cache: cache,
url:
Server.API_ENDPOINT +
"/project/" +
project.id +
"/responses/wc/" +
ids.join(",") +
"?" +
params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches a list of themes by combining all the given text-based Project Response variables (typically comment responses).
* Returns a list of themes and the IDs of the associated responses.
*
* @example
* server.getResponsesThemesByFrequency("myquery", proj).done(function(themes) {
* // Themes data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* themes = 'These are the main themes contained in the comments.'};
*
* @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
* @param {object} project Project to use
* @param {string} [filter=null] Filter to apply to the responses before determining the themes
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
*/
this.getResponsesThemesByFrequency = function (name, project, filter, cache) {
cache = cache !== false;
if (!project) {
return $.Deferred().reject(new Error("Missing aggregator project"));
}
// Build the API query parameters
const params = ["tag=" + name];
addFilterParam(params, filter);
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/responses/themes/frequency?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches a list of themes by combining all the given text-based Project Response variables (typically comment responses).
* Returns a list of themes, the number of Fairly/Very Satisifed responses, and the IDs of the associated responses.
*
* @example
* server.getResponsesThemesByImpact("myquery", proj).done(function(themes) {
* // Themes data in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* themes = 'These are the main themes contained in the comments.'};
*
* @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
* @param {object} project Project to use
* @param {string} varName Overall sat variable to use to calculate the impact
* @param {string} [filter=null] Filter to apply to the responses before determining the themes
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
*/
this.getResponsesThemesByImpact = function (name, project, varName, filter, cache) {
cache = cache !== false;
if (!project) {
return $.Deferred().reject(new Error("Missing aggregator project"));
}
// Build the API query parameters
const params = ["tag=" + name];
addFilterParam(params, filter);
// Convert the array of variable names to variable IDs
const v = getProjVar(project, varName);
if (v === "undefined") {
console.error("Missing variable", varName);
return null;
}
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/responses/themes/impact/" + v.id + "?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches an array of responses that triggered a Hot Alert.
* Requires a single <code>bool</code> Variable in the dataset that has been assigned to the <code>hotalert</code> category.
* This method returns responses that have the Variable set to <code>true</code> (<code>1</code> in MySQL).
*
* This method is a special-case of the [<code>getResponses</code>](#getResponses) method.
* It exists because its API endpoint is assigned to the <code>hotalert</code> API category, rather than the more generic <code>responses</code> category.
* Also it limits the returned response data to those variables that have no assigned category, or are in one or more of the following categories: <code>response_date</code>, <code>respondent</code>, <code>hotalert</code>.
*
* So a user could be allowed to call it to see HotAlert data, but *not* have access to the full <code>getResponses</code> endpoint's data.
*
* @example
* server.getHotAlertResponses("myquery", proj).done(function(hotalerts) {
* // Hot alerts in here
* }).catch(function() {
* // Something went wrong
* });
*
* @example
* hotalerts = {
* "total": "12345",
* "truncated": false,
* "rows": [
* {
* "foo": 45,
* "bar": "Some hotalert data",
* "baz": 271,
* "sat1": 8,
* "status": "Open|Resolved|Closed",
* "status_changed": "2021-02-19 12:02:04.25409345+00:00|2021-02-19 12:01:16.679823054+00:00|2021-02-19 12:02:10.119221324+00:00"
* }
* ]
* };
*
* @param {string} name Name of the dataset
* @param {object} project Project to use
* @param {string} [filter=null] Filter to apply to the Responses
* @param {array|string} [groups=null] Group by expressions
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a JSON object that contains the following keys:
*
* key | type |description
* --- | --- | ---
* <code>sql</code> | string | If the server is running in debug mode this shows the SQL that was used to generate the resultset.
* <code>total</code> | number | Total number of records in the dataset - required by some UI widgets to determine paging. **Not** necessarily the number of rows returned.
* <code>group</code> | string | CSV of the names of the variables that were grouped, as provided on the <code>group</code> parameter.
* <code>rows</code> | object | An array of objects. Each object repressnts a single response. The response properties are in alphabetical order.
* <code>truncated</code> | boolean | If <code>true</code> the number of rows generated exceeded the maximum number of rows that can be send by the server. In this case the <code>rows</code> key contains the maximum allowed, and this key is set to <code>true</code>.
*/
this.getHotAlertResponses = function (name, project, filter, groups, cache) {
cache = cache !== false;
const params = ["tag=" + name];
addFilterParam(params, filter);
addGroupParam(params, groups);
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/hotalerts?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously saves a new Note in the given Project that is associated with the given Response.
*
* A Note is an object with the following structure:
*
* key | type |description
* --- | --- | ---
* <code>author</code> | string | The Note author's full name.
* <code>value</code> | string | The body text of the Note.
* <code>type</code> | string | The Note's type. Must be one of <code>comment</code> or <code>status-change</code>.
* <code>status</code> | number | The current status of the Note. Must be the ID of a Note Status as defined in the <code>note_status</code> table in the database.
*
* @example
* const note = {
* author: "Joe Bloggs",
* value: "This is a new note",
* type: "comment"
* status: 3, // Resolved
* };
*
* @example
* server.saveNote('new-note', proj, participantID, note).done(function () {
* // Dispatch a custom event to tell other widgets that a new Note is available
* window.dispatchEvent(new CustomEvent('new-note', {detail: note}));
* }).catch(function() {
* // Something went wrong
* });;
*
* @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
* @param {object} project Project to use
* @param {number} participantID ID of the Participant to associate with this Note
* @param {object} note Note to be created
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.saveNote = function (name, project, participantID, note) {
return jQuery.post({
url: Server.API_ENDPOINT + "/project/" + project.id + "/response/" + participantID + "/note",
data: note,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Gets the debug mode of the API.
*
* @param {bool} debug Whe true the server API is in debug mode.
*
* @returns {jQuery.Deferred}
*/
this.getAPIDebugMode = function () {
return jQuery.get({
url: Server.API_ENDPOINT + "/debug",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Sets the debug mode of the API.
*
* @param {bool} debug When set to '1' the API's debug mode will be switched on; if '0' it's switched off.
*
* @returns {jQuery.Deferred}
*/
this.setAPIDebugMode = function (debug) {
return jQuery.post({
url: Server.API_ENDPOINT + "/debug/" + (debug ? "1" : "0"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches the PFI associated with the given Project for the logged-in User.
* PFIs are returned as a single object whose value key is a collection of PFIs separated by pipe characters.
*
* @example
* server.getPFI(project).done(function (data) {
* // PFI in here
* }).catch(function() {
* // Something went wrong
* });;
*
* @example
* data = {
* "pfi": {
* "participant_id": "user@example.com",
* "user": "user@example.com",
* "type": "pfi",
* "value": "This is PFI one|This is PFI 2|This is PFI three!",
* "status_id": 0, // This is always zero for a PFI
* "created": "2023-09-28T14:40:00Z"
* },
* "business_unit": "My Business Unit",
* "operating_company": "My Company",
* "job_title": "My Job Title"
* },
*
* @param {object} project Project to use
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a simple JSON object containing the User's PFIs for this Project.
*/
this.getPFI = function (project, cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/pfi",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches *all* PFIs in the system.
* PFIs are returned as an array of PFI objects.
*
* @example
* server.getPFI(project).done(function (pfis) {
* // PFIs in here
* }).catch(function() {
* // Something went wrong
* });;
*
* @example
* pfis = [
* {
* "participant_id": "user@example.com",
* "user": "user@example.com",
* "type": "pfi",
* "value": "This is PFI one|This is PFI 2|This is PFI three!",
* "status_id": 0, // This is always zero for a PFI
* "created": "2023-09-28T14:40:00Z"
* }.
* {
* "participant_id": "user2@example.com",
* "user": "user2@example.com",
* "type": "pfi",
* "value": "This is my action|This is anothe one",
* "status_id": 0, // This is always zero for a PFI
* "created": "2023-09-29T17:40:23Z"
* }.
* ],
*
* @param {object} project Project to use
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a simple JSON object containing the User's PFIs for this Project.
*/
this.getPFIs = function (project, cache) {
cache = cache !== false;
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/pfis",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously updates the PFIs associated with the given Project for the logged-in User.
* PFIs are encoded as a single string that is a collection of PFIs separated by pipe characters.
*
* @example
* server.updatePFIs(project, "This is PFI one|This is PFI 2|This is PFI three!");
*
* @param {object} project Project to use
*
* @returns {jQuery.Deferred}
*
*/
this.updatePFIs = function (project, pfis) {
return jQuery.post({
url: Server.API_ENDPOINT + "/project/" + project.id + "/pfi",
dataType: "json",
data: { value: pfis },
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches an array of notes associated with the given Participant.
*
* @example
* server.getNotes('myquery', proj, participantID).done(function (notes) {
* // Notes in here
* }).catch(function() {
* // Something went wrong
* });;
*
* @example
* notes = [
* {
* "id": 1,
* "participant_id": 1,
* "user": "Joe Bloggs",
* "type": "comment",
* "value": "test",
* "status_id": 10,
* "created": "2021-02-19T12:01:13.280330725Z"
* },
* {
* "id": 2,
* "participant_id": 1,
* "user": "Joe Bloggs",
* "type": "status-change",
* "value": "Open",
* "status_id": 11,
* "created": "2021-02-19T12:01:16.679823054Z"
* }
* ...
* ];
*
* @param {string} name Name of the dataset
* @param {object} project Project to use
* @param {number} participantID ID of the Participant to inspect
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*
* The response object passed to the <code>done</code> handler is a simple JSON array of Note objects.
*/
this.getNotes = function (name, project, participantID, cache, category) {
cache = cache !== false;
const params = [];
if (category) {
params.push("category=" + encodeURIComponent(category));
}
const queryString = params.length ? "?" + params.join("&") : "";
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/participant/" + participantID + "/notes" + queryString,
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches an array of Note Statuses that apply to the given Project.
*
* @example
* server.getNoteStatuses('myquery', proj).done(function (noteStatuses) {
* // noteStatuses in here
* }).catch(function() {
* // Something went wrong
* });;
*
* @example
* noteStatuses = [
* {
* "id": 29,
* "project_id": 1,
* "value": "Open",
* "seq": 0
* },
* {
* "id": 30,
* "project_id": 1,
* "value": "Pending",
* "seq": 1
* },
* {
* "id": 31,
* "project_id": 1,
* "value": "Resolved",
* "seq": 2
* },
* {
* "id": 32,
* "project_id": 1,
* "value": "Closed",
* "seq": 3
* }
* ];
*
* @param {string} name Name of the dataset
* @param {object} project Project to use
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
* The response object passed to the <code>done</code> handler is a JSON arry of NoteStatus objects containing the following keys:
*
* key | type |description
* --- | --- | ---
* <code>id</code> | number | ID of the NoteStatus
* <code>project_id</code> | number | ID of the related Project
* <code>value</code> | number | The text label associated with the NoteStatus
* <code>seq</code> | number | A sequence number that indicates the logical progression of a Note's status. Dropdowns should display statuses in ascending order of this sequence.
*/
this.getNoteStatuses = function (name, project, cache) {
cache = cache !== false;
const params = ["tag=" + name];
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/notestatus",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches stats about the Hot Alerts pertaining to the given Project.
*
* @example
* server.getHotalertStats('myquery', proj).done(function (hotalertStats) {
* // hotalertStats in here
* }).catch(function() {
* // Something went wrong
* });;
*
* @example
* hotalertStats = [
* {
* "category": "null",
* "statusID": 0,
* "total": 40
* },
* {
* "category": "Pending",
* "statusID": 0,
* "total": 16
* },
* {
* "category": "Resolved",
* "statusID": 0,
* "total": 12
* },
* {
* "category": "Closed",
* "statusID": 0,
* "total": 16
* }
* ];
*
* @param {string} name Name of the dataset
* @param {object} project Project to use
* @param {string} [filter] Filter to apply to the Responses
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
* The response object passed to the <code>done</code> handler is a JSON arry of Hotalert Stats objects containing the following keys:
*
* key | type |description
* --- | --- | ---
* <code>category</code> | string | Each possible Hotalert Status appears in its own stats Category.
* <code>pstatusID</code> | number | ID of the related Hotalert Status
* <code>total</code> | number | The total number of Notes in this stats Category,
*/
this.getHotalertStats = function (name, project, filter, cache, category) {
cache = cache !== false;
const params = [];
addFilterParam(params, filter);
if (category) {
params.push("category=" + encodeURIComponent(category));
}
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/hotalertstats?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches stats about the Hot Alerts status history pertaining to the given Project.
* Requires a single <code>bool</code> Variable in the dataset that has been assigned to the <code>hotalert</code> category.
*
* @example
* server.getHotalertHistory('myquery', proj).done(function (hotalertHistory) {
* // hotalertHistory in here
* }).catch(function() {
* // Something went wrong
* });;
*
* @example
* hotalertHistory = {
* "dates": [
* "2021-03-01",
* "2021-03-02",
* "2021-03-03",
* ],
* "history": {
* "Open": [
* 100,
* 99,
* 97
* ],
* "Pending": [
* 0,
* 1,
* 1
* ],
* "Closed": [
* 0,
* 0,
* 2
* ]
* }
* };
*
* @param {string} name Name of the dataset
* @param {object} project Project to use
* @param {string} [filter] Filter to apply
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
* The response object passed to the <code>done</code> handler is a JSON object of a Hotalert History object containing the following keys:
*
* key | type |description
* --- | --- | ---
* <code>dates</code> | array | An array of dates that contain HotAlerts.
* <code>history</code> | object | An object whose keys match the possible statuses of a HotAlert for this Project. Each key's value consists of an array of counts, one for each of the dates in the dates array.
*/
this.getHotalertHistory = function (name, project, filter, cache) {
cache = cache !== false;
const params = [];
addFilterParam(params, filter);
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/hahistory?" + params.join("&"),
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Asynchronously fetches stats about Users' Notes pertaining to the given Project.
*
* @param {string} name Name of the dataset (included in the <code>tag</code> key in the returned results)
* @param {object} project Project to use
* @param {bool} [cache=true] If false, the query will include a timestamp to override any server-side caching.
*
* @returns {jQuery.Deferred}
*/
this.getUserNoteStats = function (name, project, cache) {
cache = cache !== false;
const params = ["tag=" + name];
return jQuery.get({
dataType: "json",
cache: cache,
url: Server.API_ENDPOINT + "/project/" + project.id + "/usernotestats",
crossDomain: true,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
xhrFields: {
withCredentials: true,
},
error: this.errorhandler,
statusCode: {
401: function () {
self.unauthorizedhandler();
},
},
});
};
/**
* Formats an object containing query parameters into a string that can be appended to a URL.
*
* @param {object} query
*
* @return {string}
*/
this.formatQuery = function (query) {
const formattedQuery = [];
Object.keys(query).forEach(function (key) {
// Ignore keys that start with an underscore
if (key.length === 1 || key.substr(0, 1) !== "_") {
let val;
if (Array.isArray(query[key])) {
val = query[key].join(",");
} else if (typeof query[key] === "object") {
const keys = [];
Object.keys(query[key]).forEach(function (k) {
keys.push(k + "=" + query[key][k]);
});
val = keys.join(",");
} else {
val = query[key];
}
if (typeof val !== "undefined") {
formattedQuery.push(key + "=" + encodeURIComponent(val));
}
}
});
return formattedQuery.join("&");
};
/**
* Returns the given parameter from the URL query string (as IE11 doesn't support URLSearchParams)
*
* @param {string} param
*
* @returns {string}
*/
this.getQueryParameter = function (param) {
let result = null;
let tmp = [];
window.location.search
.substr(1)
.split("&")
.forEach(function (item) {
tmp = item.split("=");
if (tmp[0] === param) {
result = decodeURIComponent(tmp[1]);
}
});
return result;
};
/**
* Scans the given server response rows and converts any numeric strings to actual numbers.
*
* @param response
*
* @return {object}
*/
this.parseResponse = function (response) {
for (let i = 0; i < response.rows.length; i++) {
const row = response.rows[i];
Object.keys(row).forEach(function (datum) {
if (typeof row[datum] === "string" && row[datum] && !isNaN(row[datum])) {
row[datum] = Number(row[datum]);
}
});
}
return response;
};
/**
* Returns the SQL needed to format the given date column.
*
* @param {string} varName Name of the date variable to format
* @param {string} format Format string
*
* @return {string|null} SQL function needed to format the given variable
*/
this.getSqlDateFormat = function (varName, format) {
if (!server.Globals.databaseType) {
console.error("Missing database type");
return null;
}
return server.Globals.databaseType === "mysql"
? "DATE_FORMAT({" + varName + '}, "' + format + '")'
: 'strftime("' + format + '", datetime({' + varName + '}, "localtime"))';
};
// Inject the debugger if needed
if (this.getQueryParameter("debug")) {
this.debugger = new Debugger();
this.debugger.init(this);
}
/**
* Returns the requested presentation template
*
* @param {string} name Name of the template to fetch
*
* @return {object} Template object
*/
this.getPresentationTemplate = async function (name) {
const onStaging = window.location.origin.includes(".wr3s.");
const rootURL = onStaging ? "https://static.wr3s.leadershipfactor.com" : "https://static.leadershipfactor.com";
return await import(`${rootURL}/js/lib/ppt-templates/${name}.js`);
};
// Inject the debugger if needed
if (this.getQueryParameter("debug")) {
this.debugger = new Debugger();
this.debugger.init(this);
}
/**
* Adds the given properties to the URL
*
* @private
*
* @param {array} params
* @param {string|array} props
*
*/
function addPropsParam(params, props) {
if (props) {
props = normalizeToArray(props);
if (props.length) {
params.push("props=" + encodeURIComponent(props.join(",")));
}
}
}
/**
* Adds the given sample to the URL
*
* @private
*
* @param {array} params
* @param {string|array} sample
*
*/
function addSampleParam(params, sample) {
if (sample) {
params.push("sample=" + encodeURIComponent(sample));
}
}
/**
* Adds the given filter to the URL
*
* @private
*
* @param {array} params
* @param {string|array} filter
*
*/
function addFilterParam(params, filter) {
if (filter) {
params.push("filter=" + encodeURIComponent(filter));
}
}
/**
* Adds the given sort to the URL
*
* @private
*
* @param {array} params
* @param {string|array} sort
*
*/
function addSortParam(params, sort) {
if (sort) {
params.push("sort=" + encodeURIComponent(sort));
}
}
/**
* Adds the given having to the URL
*
* @private
*
* @param {array} params
* @param {string|array} having
*
*/
function addHavingParam(params, having) {
if (having) {
params.push("having=" + encodeURIComponent(having));
}
}
/**
* Adds the given rolling to the URL
*
* @private
*
* @param {array} params
* @param {string|array} rolling
*
*/
function addRollingParam(params, rolling) {
if (rolling) {
params.push("rolling=" + encodeURIComponent(rolling));
}
}
/**
* Adds the given rollup to the URL
*
* @private
*
* @param {array} params
* @param {string|array} rollup
*
*/
function addRollupParam(params, rollup) {
if (rollup) {
params.push("rollup=1");
}
}
/**
* Adds the given cube to the URL
*
* @private
*
* @param {array} params
* @param {string|array} cube
*
*/
function addCubeParam(params, cube) {
if (cube) {
params.push("cube=1");
}
}
/**
* Adds the given anonymize to the URL
*
* @private
*
* @param {array} params
* @param {string|array} cube
*
*/
function addAnonymizeParam(params, cube) {
if (cube) {
params.push("anonymize=1");
}
}
/**
* Adds the given groups to the URL
*
* @private
*
* @param {array} params
* @param {string|array} groups
*
*/
function addGroupParam(params, groups) {
groups = normalizeToArray(groups);
if (groups.length) {
params.push("group=" + encodeURIComponent(groups.join(",")));
}
}
/**
* Add server-side paging to the given parameter list.
*
* @private
*
* @param params
* @param offset
* @param limit
*/
function addPagingParam(params, offset, limit) {
if (typeof offset !== "undefined" && typeof limit !== "undefined") {
params.push("offset=" + encodeURIComponent(offset));
params.push("limit=" + encodeURIComponent(limit));
}
}
/**
* Normalizes the given value to an array.
*
* @private
*
* @param {*} val
* @return {Array}
*/
function normalizeToArray(val) {
if (typeof val === "undefined" || val === null) {
return [];
}
return Array.isArray(val) ? val : [val];
}
/**
* Returns the value of the given variable or undefined if it doesn't exist.
*
* @param {Object} project Project to search.
* @param {string} varName Name of the date variable to format
*
* @return {any|undefined} Value of requested variable.
*/
function getProjVar(project, varName) {
if (typeof project !== 'object') {
console.error('Project must be an object');
return undefined;
}
if (typeof varName !== 'string') {
console.error('Variable name must be a string');
return undefined;
}
if (project.vars[varName]) {
return project.vars[varName];
}
const varNames = Object.keys(project.vars);
for (let i = 0; i < varNames.length; i++) {
const v = varNames[i];
if (v.toLowerCase() === varName.toLowerCase()) {
return project.vars[v];
}
}
return undefined;
}
}