UNPKG

@levante-framework/firekit

Version:

A library to facilitate Firebase authentication and Firestore interaction for LEVANTE apps

428 lines (427 loc) 19.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RoarRun = exports.convertTrialToFirestore = void 0; const firestore_1 = require("firebase/firestore"); const intersection_1 = __importDefault(require("lodash/intersection")); const mapValues_1 = __importDefault(require("lodash/mapValues")); const pick_1 = __importDefault(require("lodash/pick")); const set_1 = __importDefault(require("lodash/set")); const dot_object_1 = __importDefault(require("dot-object")); const util_1 = require("../util"); const util_2 = require("@firebase/util"); /** * Convert a trial data to allow storage on Cloud Firestore. * * This function leaves all other trial data intact but converts * any URL object to a string. * * @function * @param {Object} trialData - Trial data to convert * @returns {Object} Converted trial data */ const convertTrialToFirestore = (trialData) => { return (0, util_1.removeUndefined)(Object.fromEntries(Object.entries(trialData).map(([key, value]) => { if (value instanceof URL) { return [key, value.toString()]; } else if (typeof value === 'object' && value !== null) { return [key, (0, exports.convertTrialToFirestore)(value)]; } else { return [key, value]; } }))); }; exports.convertTrialToFirestore = convertTrialToFirestore; const requiredTrialFields = ['assessment_stage', 'correct']; const castToTheta = (value) => { if (value === undefined || value === null) { return null; } return value; }; /** * Class representing a ROAR run. * * A run is a globally unique collection of successive trials that constitute * one user "running" through a single assessment one time. */ class RoarRun { /** Create a ROAR run * @param {RunInput} input * @param {RoarAppUser} input.user - The user running the task * @param {RoarTaskVariant} input.task - The task variant being run * @param {OrgLists} input.assigningOrgs - The IDs of the orgs to which this run belongs * @param {OrgLists} input.readOrgs - The IDs of the orgs which can read this run * @param {string} input.assignmentId = The ID of the assignment * @param {string} input.runId = The ID of the run. If undefined, a new run will be created. * @param {string} input.testData = Boolean flag indicating test data * @param {string} input.demoData = Boolean flag indicating demo data */ constructor({ user, task, assigningOrgs, readOrgs, assignmentId, runId, testData = false, demoData = false, }) { this.user = user; this.task = task; this.assigningOrgs = assigningOrgs; this.readOrgs = readOrgs ?? assigningOrgs; this.assignmentId = assignmentId; this.testData = testData; this.demoData = demoData; if (runId) { this.runRef = (0, firestore_1.doc)(this.user.userRef, 'runs', runId); } else { this.runRef = (0, firestore_1.doc)((0, firestore_1.collection)(this.user.userRef, 'runs')); } if (!this.task.taskRef) { throw new Error('Task refs not set. Please use the task.setRefs method first.'); } this.started = false; this.completed = false; this.aborted = false; this.scores = { raw: {}, computed: {}, }; } /** * Create a new run on Firestore * @method * @async */ async startRun(additionalRunMetadata) { await this.user.checkUserExists(); if (!this.task.variantRef) { await this.task.toFirestore(); } const userDocSnap = await (0, firestore_1.getDoc)(this.user.userRef); if (!userDocSnap.exists()) { // This should never happen because of ``this.user.checkUserExists`` above. But just in case: throw new Error('User does not exist'); } if (this.assigningOrgs) { const userDocData = userDocSnap.data(); const userOrgs = (0, pick_1.default)(userDocData, Object.keys(this.assigningOrgs)); for (const orgName of Object.keys(userOrgs)) { this.assigningOrgs[orgName] = (0, intersection_1.default)(userOrgs[orgName]?.current, this.assigningOrgs[orgName]); if (this.readOrgs) { this.readOrgs[orgName] = (0, intersection_1.default)(userOrgs[orgName]?.current, this.readOrgs[orgName]); } } } const userDocData = (0, pick_1.default)(userDocSnap.data(), [ 'grade', 'assessmentPid', 'assessmentUid', 'birthMonth', 'birthYear', 'schoolLevel', ]); // Grab the testData and demoData flags from the user document. const { testData: isTestUser, demoData: isDemoUser } = userDocSnap.data(); // Update testData and demoData for this instance based on the test/demo // flags for the user and task. // Explanation: The constructor input flags could be passed in for a normal // (non-test) user who is just taking a test assessment. But if the entire user // is a test or demo user, then we want those flags to propagate to ALL // of their runs, regardless of what the constructor input flags were. // Likewise for the test and demo flags for the task. // We also want to update the internal state because we will use it later in // the `writeTrial` method. if (isTestUser) this.testData = true; if (isDemoUser) this.demoData = true; const runData = { ...additionalRunMetadata, id: this.runRef.id, assignmentId: this.assignmentId ?? null, assigningOrgs: this.assigningOrgs ?? null, readOrgs: this.readOrgs ?? null, taskId: this.task.taskId, taskVersion: this.task.taskVersion, variantId: this.task.variantId, completed: false, timeStarted: (0, firestore_1.serverTimestamp)(), timeFinished: null, reliable: false, userData: userDocData, // Use conditional spreading to add the testData flag only if it exists on // the userDoc and is true. // Explaination: We use the && operator to return the object only when // condition is true. If the object is returned then it will be spread // into runData. ...(this.testData && { testData: true }), // Same for demoData ...(this.demoData && { demoData: true }), }; const batch = (0, firestore_1.writeBatch)(this.user.db); batch.set(this.runRef, (0, util_1.removeUndefined)(runData)); batch.update(this.user.userRef, { tasks: (0, firestore_1.arrayUnion)(this.task.taskId), variants: (0, firestore_1.arrayUnion)(this.task.variantId), lastUpdated: (0, firestore_1.serverTimestamp)(), }); await (0, util_1.retryOperation)(() => batch.commit(), { operationName: 'startRun batch commit' }); this.started = true; } /** * Add engagement flags to a run. * @method * @async * @param {string[]} engagementFlags - Engagement flags to add to the run * @param {boolean} markAsReliable - Whether or not to mark the run as reliable, defaults to false * @param {Object} reliableByBlock - Stores the reliability of the run by block * This is an optional parameter that needs only to be passed in for block scoped tasks * For Example: {DEL: false, FSM: true, LSM: false} * * Please note that calling this function with a new set of engagement flags will * overwrite the previous set. */ async addEngagementFlags(engagementFlags, markAsReliable = false, reliableByBlock = undefined) { if (!this.started) { throw new Error('Run has not been started yet. Use the startRun method first.'); } const engagementObj = engagementFlags.reduce((acc, flag) => { acc[flag] = true; return acc; }, {}); if (!this.aborted) { // In cases that the run is non-block-scoped and should only have the reliable attribute stored if (reliableByBlock === undefined) { return await (0, firestore_1.updateDoc)(this.runRef, { engagementFlags: engagementObj, reliable: markAsReliable }); } // In cases we want to store reliability by block, we need to store the reliable attribute as well as the reliableByBlock attribute else { return await (0, firestore_1.updateDoc)(this.runRef, { engagementFlags: engagementObj, reliable: markAsReliable, reliableByBlock: reliableByBlock, }); } } else { throw new Error('Run has already been aborted.'); } } /** * Mark this run as complete on Firestore * @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. */ async finishRun(finishingMetaData = {}) { if (!this.started) { throw new Error('Run has not been started yet. Use the startRun method first.'); } if (!this.aborted) { const finishingData = { ...finishingMetaData, completed: true, timeFinished: (0, firestore_1.serverTimestamp)(), }; try { await (0, firestore_1.updateDoc)(this.runRef, finishingData); this.completed = true; return true; } catch (error) { console.log('Error finishing run:', error); throw error; } } } /** * Abort this run, preventing it from completing * @method */ abortRun() { if (!this.started) { throw new Error('Run has not been started yet. Use the startRun method first.'); } this.aborted = true; } /** * Add a new trial to this run on Firestore * @method * @async * @param {*} trialData - An object containing trial data. */ async writeTrial(trialData, computedScoreCallback) { if (!this.started) { throw new Error('Run has not been started yet. Use the startRun method first.'); } if (this.aborted) { return; } // Check that the trial has all of the required reserved keys if (!requiredTrialFields.every((key) => { return key in trialData && trialData[key] != undefined; })) { throw new Error('All ROAR trials saved to Firestore must have the following reserved keys: ' + `${requiredTrialFields}.` + 'The current trial is missing the following required keys: ' + `${requiredTrialFields.filter((key) => !(key in trialData))}.`); } const trialRef = (0, firestore_1.doc)((0, firestore_1.collection)(this.runRef, 'trials')); const trialDoc = { ...(0, exports.convertTrialToFirestore)(trialData), taskId: this.task.taskId, ...(this.testData && { testData: true }), ...(this.demoData && { demoData: true }), serverTimestamp: (0, firestore_1.serverTimestamp)(), createdAt: (0, firestore_1.serverTimestamp)(), // Trial documents are never updated, but for standardization, adding this field. updatedAt: (0, firestore_1.serverTimestamp)(), }; // Only update scores if the trial was a test or a practice response. const shouldUpdateScores = trialData.assessment_stage === 'test_response' || trialData.assessment_stage === 'practice_response'; if (!shouldUpdateScores) { // Just write the trial, no score updates needed try { await (0, firestore_1.setDoc)(trialRef, trialDoc); } catch (error) { console.error('Error writing trial to Firestore:', { error, trialRefPath: trialRef.path, trialData: trialDoc, }); throw error; } return; } // Here we update the scores for this run. We create scores for each subtask in the task. // E.g., ROAR-PA has three subtasks: FSM, LSM, and DEL. Each subtask has its own score. // Conversely, ROAR-SWR has no subtasks. It's scores are stored in the 'composite' score field. // If no subtask is specified, the scores for the 'composite' subtask will be updated. const defaultSubtask = 'composite'; const subtask = (trialData.subtask || defaultSubtask); const stage = trialData.assessment_stage.split('_')[0]; const isCorrect = Boolean(trialData.correct); // Extract theta values const thetaEstimate = castToTheta(trialData.thetaEstimate); const thetaSE = castToTheta(trialData.thetaSE); // Helper function to update composite scores const updateCompositeScores = (isInitializing) => { if (subtask === defaultSubtask) return {}; if (isInitializing) { (0, set_1.default)(this.scores.raw, [defaultSubtask, stage], { numAttempted: 1, numCorrect: isCorrect ? 1 : 0, numIncorrect: isCorrect ? 0 : 1, thetaEstimate: null, thetaSE: null, }); return { [`scores.raw.${defaultSubtask}.${stage}.numAttempted`]: 1, [`scores.raw.${defaultSubtask}.${stage}.numCorrect`]: isCorrect ? 1 : 0, [`scores.raw.${defaultSubtask}.${stage}.numIncorrect`]: isCorrect ? 0 : 1, [`scores.raw.${defaultSubtask}.${stage}.thetaEstimate`]: null, [`scores.raw.${defaultSubtask}.${stage}.thetaSE`]: null, }; } else { this.scores.raw[defaultSubtask][stage] = { numAttempted: (this.scores.raw[defaultSubtask][stage]?.numAttempted || 0) + 1, numCorrect: (this.scores.raw[defaultSubtask][stage]?.numCorrect || 0) + +isCorrect, numIncorrect: (this.scores.raw[defaultSubtask][stage]?.numIncorrect || 0) + +!isCorrect, thetaEstimate: null, thetaSE: null, }; return { [`scores.raw.${defaultSubtask}.${stage}.numAttempted`]: (0, firestore_1.increment)(1), [`scores.raw.${defaultSubtask}.${stage}.numCorrect`]: isCorrect ? (0, firestore_1.increment)(1) : undefined, [`scores.raw.${defaultSubtask}.${stage}.numIncorrect`]: isCorrect ? undefined : (0, firestore_1.increment)(1), }; } }; let scoreUpdate = {}; if (subtask in this.scores.raw) { // Then this subtask has already been added to this run. // Simply update the block's scores. this.scores.raw[subtask][stage] = { thetaEstimate, thetaSE, numAttempted: (this.scores.raw[subtask][stage]?.numAttempted || 0) + 1, numCorrect: (this.scores.raw[subtask][stage]?.numCorrect || 0) + +isCorrect, numIncorrect: (this.scores.raw[subtask][stage]?.numIncorrect || 0) + +!isCorrect, }; // Populate the score update for Firestore. scoreUpdate = { [`scores.raw.${subtask}.${stage}.thetaEstimate`]: thetaEstimate, [`scores.raw.${subtask}.${stage}.thetaSE`]: thetaSE, [`scores.raw.${subtask}.${stage}.numAttempted`]: (0, firestore_1.increment)(1), [`scores.raw.${subtask}.${stage}.numCorrect`]: isCorrect ? (0, firestore_1.increment)(1) : undefined, [`scores.raw.${subtask}.${stage}.numIncorrect`]: isCorrect ? undefined : (0, firestore_1.increment)(1), ...updateCompositeScores(false), }; } else { // This is the first time this subtask has been added to this run. // Initialize the subtask scores. (0, set_1.default)(this.scores.raw, [subtask, stage], { thetaEstimate, thetaSE, numAttempted: 1, numCorrect: isCorrect ? 1 : 0, numIncorrect: isCorrect ? 0 : 1, }); // Populate the score update for Firestore. scoreUpdate = { [`scores.raw.${subtask}.${stage}.thetaEstimate`]: thetaEstimate, [`scores.raw.${subtask}.${stage}.thetaSE`]: thetaSE, [`scores.raw.${subtask}.${stage}.numAttempted`]: 1, [`scores.raw.${subtask}.${stage}.numCorrect`]: isCorrect ? 1 : 0, [`scores.raw.${subtask}.${stage}.numIncorrect`]: isCorrect ? 0 : 1, ...updateCompositeScores(true), }; } if (computedScoreCallback) { // Use the user-provided callback to compute the computed scores. this.scores.computed = await computedScoreCallback(this.scores.raw); } else { // If no computedScoreCallback is provided, we default to // numCorrect - numIncorrect for each subtask. this.scores.computed = (0, mapValues_1.default)(this.scores.raw, (subtaskScores) => { const numCorrect = subtaskScores.test?.numCorrect || 0; const numIncorrect = subtaskScores.test?.numIncorrect || 0; return numCorrect - numIncorrect; }); } // Use dot-object to convert the computed scores into dotted-key/value pairs. const fullUpdatePath = { scores: { computed: this.scores.computed, }, }; scoreUpdate = { ...scoreUpdate, ...dot_object_1.default.dot(fullUpdatePath), }; const batch = (0, firestore_1.writeBatch)(this.user.db); batch.set(trialRef, trialDoc); batch.update(this.runRef, (0, util_1.removeUndefined)(scoreUpdate)); batch.update(this.user.userRef, { lastUpdated: (0, firestore_1.serverTimestamp)() }); try { await batch.commit(); } catch (error) { // Catch the "Unsupported field value: undefined" error and // provide a more helpful error message to the ROAR app developer. if (error instanceof util_2.FirebaseError && error.message.toLowerCase().includes('unsupported field value: undefined')) { throw new Error('The computed or normed scores that you provided contained an undefined value. ' + 'Firestore does not support storing undefined values. ' + 'Please remove this value or convert it to ``null``.'); } throw error; } } } exports.RoarRun = RoarRun;