UNPKG

@bdelab/roar-firekit

Version:

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

504 lines (502 loc) 22.2 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 camelCase_1 = __importDefault(require("lodash/camelCase")); 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")); const bucket_urls_1 = require("../../constants/bucket-urls"); const upload_status_1 = require("../../constants/upload-status"); /** * 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; this._uploadQueue = []; // Initialize storage bucket eagerly if firebaseProject is available. // When firebaseConfig is provided instead, initialization is deferred to _init(). if (this.firebaseProject) { this._initStorageBucket(); } } /** * Initializes the storage bucket for recording uploads. * Resolves the bucket URL from the Firebase project ID using the BUCKET_URLS constant map. * @throws {Error} If the project ID does not map to a known storage bucket. */ _initStorageBucket() { var _a; if (this._storageBucket) return; // projectId is optional in the FirebaseOptions interface const projectId = (_a = this.firebaseProject) === null || _a === void 0 ? void 0 : _a.firebaseApp.options.projectId; if (!projectId) { throw new Error('Project ID is required to initialize storage bucket'); } const storageBucketKey = (0, camelCase_1.default)(projectId); if (storageBucketKey && storageBucketKey in bucket_urls_1.BUCKET_URLS) { this._storageBucket = (0, storage_1.getStorage)(this.firebaseProject.firebaseApp, bucket_urls_1.BUCKET_URLS[storageBucketKey]); } else { throw new Error('Storage bucket not found for project ID: ' + storageBucketKey); } } _init() { return __awaiter(this, void 0, void 0, function* () { if (this.firebaseConfig) { this.firebaseProject = yield (0, util_1.initializeFirebaseProject)(this.firebaseConfig, 'assessmentApp'); } // Initialize storage bucket if not already done (deferred from constructor when firebaseConfig path is used) this._initStorageBucket(); (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.'); } }); } /** * Add interaction data for the current trial * * This will keep a running log of interaction data for the current trial. * The log will be reset after each `writeTrial` call. * @param {InteractionEvent} interaction - interaction event * @method * @async */ addInteraction(interaction) { if (this._started) { return this.run.addInteraction(interaction); } 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); }); } /** * Generates a standardized file path for recordings. * @param {string} filename - The file name * @param {string} [assessmentPid] - Optional assessmentPid. Prioritizes assigned assessmentPid and defaults to assessmentUid * @returns Standardized file path for recordings */ generateFilePath({ filename, assessmentPid }) { var _a, _b; if (!this.authenticated) { throw new Error('User must be authenticated to generate file path.'); } else if (!this.run) { throw new Error('Run must be started in order to generate a file path.'); } const runId = this.run.runRef.id; const taskId = this.run.task.taskId; const uid = this.user.assessmentUid; const administrationId = (_a = this._assignmentId) !== null && _a !== void 0 ? _a : 'guest-administration'; let pid = ''; if ((_b = this.user) === null || _b === void 0 ? void 0 : _b.assessmentPid) { pid = this.user.assessmentPid; } else if (assessmentPid && assessmentPid.length > 0) { pid = assessmentPid; } else { pid = uid; } (0, util_1.validateFileExtension)(filename); return [taskId, uid, pid, administrationId, runId, filename].map((segment) => (0, util_1.sanitizeInput)(segment)).join('/'); } /** * Upload recordings to GCP using Firebase SDK. * The Firebase project and storage bucket are environment-specific. * Bucket format: "roar-assessment-recordings-{environment}". * @param {string} filename - The file name * @param {string} [assessmentPid] - Optional assessmentPid. * @param {File | Blob} fileOrBlob - The file or blob to upload * @param {Record<string, string>} [customMetadata] - Optional metadata to attach to the file (see SettableMetadata interface in Firebase docs) * @returns target storage url */ uploadFileOrBlobToStorage({ filename, assessmentPid, fileOrBlob, customMetadata, }) { 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 upload files to storage.'); } else if (!this.run) { throw new Error('No active run found.'); } else if (!filename || filename.trim().length === 0 || !fileOrBlob) { throw new Error('Both filename and file/blob are required'); } const filePath = this.generateFilePath({ filename, assessmentPid }); // Safe: _storageBucket is guaranteed to be set by _initStorageBucket(), called either // in the constructor (firebaseProject path) or in _init() above (firebaseConfig path). const storageRef = (0, storage_1.ref)(this._storageBucket, filePath); this._uploadQueue.push({ upload: () => (0, storage_1.uploadBytesResumable)(storageRef, fileOrBlob, { customMetadata }), filename, status: upload_status_1.UploadStatusEnum.PENDING, }); this.processUploadQueue(); return storageRef.toString(); }); } /** * Processes the next pending upload if under the concurrency limit of 3. * Called after enqueuing a new upload and after each upload completes or fails. */ processUploadQueue() { const totalUploadingTasks = this._uploadQueue.filter((task) => task.status === upload_status_1.UploadStatusEnum.UPLOADING).length; if (totalUploadingTasks >= 3) return; const nextTask = this._uploadQueue.find((task) => task.status === upload_status_1.UploadStatusEnum.PENDING); if (!nextTask) return; nextTask.status = upload_status_1.UploadStatusEnum.UPLOADING; const activeTask = nextTask.upload(); activeTask.on('state_changed', undefined, (error) => { console.error(`Upload error: ${nextTask.filename} [${error === null || error === void 0 ? void 0 : error.code}]`); nextTask.status = upload_status_1.UploadStatusEnum.FAILED; const idx = this._uploadQueue.indexOf(nextTask); if (idx !== -1) this._uploadQueue.splice(idx, 1); this.processUploadQueue(); }, () => { nextTask.status = upload_status_1.UploadStatusEnum.COMPLETED; const idx = this._uploadQueue.indexOf(nextTask); if (idx !== -1) this._uploadQueue.splice(idx, 1); this.processUploadQueue(); }); } } exports.RoarAppkit = RoarAppkit;