@bdelab/roar-firekit
Version:
A library to facilitate Firebase authentication and Cloud Firestore interaction for ROAR apps
349 lines (348 loc) • 15 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RoarAppkit = void 0;
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const auth_1 = require("firebase/auth");
const firestore_1 = require("firebase/firestore");
const storage_1 = require("firebase/storage");
const run_1 = require("./run");
const task_1 = require("./task");
const user_1 = require("./user");
const util_1 = require("../util");
const _2020_1 = __importDefault(require("ajv/dist/2020"));
const ajv_errors_1 = __importDefault(require("ajv-errors"));
/**
* The RoarAppkit class is the main entry point for ROAR apps using the ROAR
* Firestore API. It represents multiple linked Firestore documents and
* provides methods for interacting with them.
*/
class RoarAppkit {
/**
* Create a RoarAppkit.
*
* @param {AppkitInput} input
* @param {UserInfo} input.userInfo - The user input object
* @param {TaskVariantInfo} input.taskInfo - The task input object
* @param {OrgLists} input.assigningOrgs - The IDs of the orgs to which this run belongs
* @param {OrgLists} input.readOrgs - The IDs of the orgs that can read this run
* @param {string} input.assignmentId - The ID of the assignment this run belongs to
* @param {string} input.runId - The ID of the run. If undefined, a new run will be created.
* @param {DataFlags} input.testData - Boolean flags indicating whether the user, task, or run are test data
* @param {DataFlags} input.demoData - Boolean flags indicating whether the user, task, or run are demo data
*/
constructor({ firebaseProject, firebaseConfig, userInfo, taskInfo, assigningOrgs, readOrgs, assignmentId, runId, testData, demoData, }) {
if (!firebaseProject && !firebaseConfig) {
throw new Error('You must provide either a firebaseProjectKit or firebaseConfig');
}
if (firebaseProject && firebaseConfig) {
throw new Error('You must provide either a firebaseProjectKit or firebaseConfig, not both');
}
this.firebaseConfig = firebaseConfig;
this.firebaseProject = firebaseProject;
this._userInfo = userInfo;
this._taskInfo = taskInfo;
this._assigningOrgs = assigningOrgs;
this._readOrgs = readOrgs !== null && readOrgs !== void 0 ? readOrgs : assigningOrgs;
this._assignmentId = assignmentId;
this._runId = runId;
this.testData = testData !== null && testData !== void 0 ? testData : { user: false, task: false, run: false };
this.demoData = demoData !== null && demoData !== void 0 ? demoData : { user: false, task: false, run: false };
this._authenticated = false;
this._initialized = false;
this._started = false;
}
_init() {
return __awaiter(this, void 0, void 0, function* () {
if (this.firebaseConfig) {
this.firebaseProject = yield (0, util_1.initializeFirebaseProject)(this.firebaseConfig, 'assessmentApp');
}
(0, auth_1.onAuthStateChanged)(this.firebaseProject.auth, (user) => {
this._authenticated = Boolean(user);
});
this.user = new user_1.RoarAppUser(Object.assign(Object.assign(Object.assign(Object.assign({}, this._userInfo), { db: this.firebaseProject.db }), (this.testData.user && { testData: true })), (this.demoData.user && { demoData: true })));
this.task = new task_1.RoarTaskVariant(Object.assign(Object.assign({
// Define testData and demoData first so that spreading this._taskInfo can
// overwrite them.
testData: {
task: this.testData.task,
variant: this.testData.variant,
}, demoData: {
task: this.demoData.task,
variant: this.demoData.variant,
} }, this._taskInfo), { db: this.firebaseProject.db }));
this.run = new run_1.RoarRun({
user: this.user,
task: this.task,
assigningOrgs: this._assigningOrgs,
readOrgs: this._readOrgs,
assignmentId: this._assignmentId,
runId: this._runId,
testData: this.testData.run,
demoData: this.demoData.run,
});
yield this.user.init();
this._initialized = true;
});
}
get authenticated() {
return this._authenticated;
}
/**
* Update the user's data (both locally and in Firestore).
* @param {object} input
* @param {string[]} input.tasks - The tasks to be added to the user doc
* @param {string[]} input.variants - The variants to be added to the user doc
* @param {string} input.assessmentPid - The assessment PID of the user
* @param {*} input.userMetadata - Any additional user metadata
* @method
* @async
*/
updateUser(_a) {
var { tasks, variants, assessmentPid } = _a, userMetadata = __rest(_a, ["tasks", "variants", "assessmentPid"]);
return __awaiter(this, void 0, void 0, function* () {
if (!this._initialized) {
yield this._init();
}
if (!this.authenticated) {
throw new Error('User must be authenticated to update their own data.');
}
return this.user.updateUser(Object.assign({ tasks, variants, assessmentPid }, userMetadata));
});
}
/**
* Start the ROAR run. Push the task and run info to Firestore.
* Call this method before starting the jsPsych experiment.
* @method
* @async
*/
startRun(additionalRunMetadata) {
return __awaiter(this, void 0, void 0, function* () {
if (!this._initialized) {
yield this._init();
}
if (!this.authenticated) {
throw new Error('User must be authenticated to start a run.');
}
return this.run.startRun(additionalRunMetadata).then(() => (this._started = true));
});
}
/**
* Validate the task variant parameters against a given JSON schema.
*
* This method uses the AJV library to validate the `variantParams` from the task information
* against the provided JSON schema. If the parameters are invalid, it throws an error with
* detailed messages for each validation error.
*
* @param {JSONSchemaType<unknown>} parameterSchema - The JSON schema to validate the parameters against.
* @throws {Error} Throws an error if the parameters are invalid, including detailed validation error messages.
*/
validateParameters(parameterSchema) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
// This version of ajv is not compatible with other JSON schema versions.
const ajv = new _2020_1.default({ allErrors: true, verbose: true });
(0, ajv_errors_1.default)(ajv);
const validate = ajv.compile(parameterSchema);
const variantParams = this._taskInfo.variantParams;
const valid = validate(variantParams);
if (!valid) {
const errorMessages = (_a = validate.errors) === null || _a === void 0 ? void 0 : _a.map((error) => {
return `Error in parameter "${error.instancePath}": ${error.message}`;
}).join('\n');
throw new Error(`Detected invalid game parameters. \n\n${errorMessages}`);
}
else {
console.log('Parameters successfully validated.');
}
});
}
/**
* Update the ROAR task's game parameters.
* This must be called after the startRun() method.
*
* @method
* @async
*/
updateTaskParams(newParams) {
return __awaiter(this, void 0, void 0, function* () {
if (this._started) {
const oldVariantId = this.task.variantId;
return this.task.updateTaskParams(newParams)
.then(() => {
return (0, firestore_1.updateDoc)(this.user.userRef, { variants: (0, firestore_1.arrayRemove)(oldVariantId) });
})
.then(() => {
return (0, firestore_1.updateDoc)(this.user.userRef, { variants: (0, firestore_1.arrayUnion)(this.task.variantId) });
})
.then(() => {
return (0, firestore_1.updateDoc)(this.run.runRef, { variantId: this.task.variantId });
});
}
else {
throw new Error('This run has not started. Use the startRun method first.');
}
});
}
/**
* Update the engagement flags for the current run.
*
* @param {string[]} flagNames - The names of the engagement flags to add.
* @param {boolean} markAsReliable - Whether or not to mark the run as reliable, defaults to false
* @method
* @async
*
* Please note that calling this function with a new set of engagement flags will
* overwrite the previous set.
*/
updateEngagementFlags(flagNames, markAsReliable = false, reliableByBlock = undefined) {
return __awaiter(this, void 0, void 0, function* () {
if (this._started) {
return this.run.addEngagementFlags(flagNames, markAsReliable, reliableByBlock);
}
else {
throw new Error('This run has not started. Use the startRun method first.');
}
});
}
/**
* Finish the ROAR run by marking it as finished in Firestore.
* Call this method after the jsPsych experiment finishes. For example:
*
* ```javascript
* jsPsych.init({
* timeline: exp,
* on_finish: function(data) {
* firekit.finishRun();
* }
* });
* ```
* @method
* @async
* @param {Object} [finishingMetaData={}] - Optional metadata to include when marking the run as complete.
* @returns {Promise<boolean | undefined>} - Resolves when the run has been marked as complete.
* @throws {Error} - Throws an error if the run has not been started yet.
*/
finishRun(finishingMetaData = {}) {
return __awaiter(this, void 0, void 0, function* () {
if (this._started) {
return this.run.finishRun(finishingMetaData);
}
else {
throw new Error('This run has not started. Use the startRun method first.');
}
});
}
/**
* Abort the ROAR run, preventing any further writes to Firestore.
* @method
*/
abortRun() {
if (this._started) {
this.run.abortRun();
}
else {
throw new Error('This run has not started. Use the startRun method first.');
}
}
/**
* Add new trial data to this run on Firestore.
*
* ROAR expects certain data to be added to each trial:
* - assessment_stage: string, either practice_response or test_response
* - correct: boolean, whether the correct answer was correct
* - subtask: string (optional), the name of the subtask
* - thetaEstimate: number (optional), the ability estimate for adaptive assessments
* - thetaSE: number (optional), the standard error of the ability estimate for adaptive assessments
*
* This method can be added to individual jsPsych trials by calling it from
* the `on_finish` function, like so:
*
* ```javascript
* var trial = {
* type: 'image-keyboard-response',
* stimulus: 'imgA.png',
* on_finish: function(data) {
* firekit.addTrialData(data);
* }
* };
* ```
*
* Or you can call it from all trials in a jsPsych
* timeline by calling it from the `on_data_update` callback. In the latter
* case, you can avoid saving extraneous trials by conditionally calling
* this method based on the data. For example:
*
* ```javascript
* const timeline = [
* // A fixation trial; don't save to Firestore
* {
* type: htmlKeyboardResponse,
* stimulus: '<div style="font-size:60px;">+</div>',
* choices: "NO_KEYS",
* trial_duration: 500,
* },
* // A stimulus and response trial; save to Firestore
* {
* type: imageKeyboardResponse,
* stimulus: 'imgA.png',
* data: { save: true },
* }
* ]
* jsPsych.init({
* timeline: timeline,
* on_data_update: function(data) {
* if (data.save) {
* firekit.addTrialData(data);
* }
* }
* });
* ```
*
* @method
* @async
* @param {*} trialData - An object containing trial data.
*/
writeTrial(trialData, computedScoreCallback) {
return __awaiter(this, void 0, void 0, function* () {
if (this._started) {
return this.run.writeTrial(trialData, computedScoreCallback);
}
else {
throw new Error('This run has not started. Use the startRun method first.');
}
});
}
getStorageDownloadUrl(filePath) {
return __awaiter(this, void 0, void 0, function* () {
if (!this._initialized) {
yield this._init();
}
const storageRef = (0, storage_1.ref)(this.firebaseProject.storage, filePath);
return (0, storage_1.getDownloadURL)(storageRef);
});
}
}
exports.RoarAppkit = RoarAppkit;