UNPKG

@bdelab/roar-firekit

Version:

A library to facilitate Firebase authentication and Cloud Firestore interaction for ROAR apps

349 lines (348 loc) 15 kB
"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;