UNPKG

@bdelab/roar-firekit

Version:

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

467 lines (466 loc) 27.8 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 __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 isEmpty_1 = __importDefault(require("lodash/isEmpty")); const dot_object_1 = __importDefault(require("dot-object")); const util_1 = require("../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 !== null && readOrgs !== void 0 ? 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: {}, }; this.trialInteractions = { blur: [], focus: [], fullscreenenter: [], fullscreenexit: [], }; this.runInteractionIncrements = { blur: false, focus: false, fullscreenenter: false, fullscreenexit: false, }; } /** * Reset the interaction data for the current trial */ resetInteractions() { this.trialInteractions = { blur: [], focus: [], fullscreenenter: [], fullscreenexit: [], }; this.runInteractionIncrements = { blur: false, focus: false, fullscreenenter: false, fullscreenexit: false, }; } /** * 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 */ addInteraction(interaction) { var _a; // First add the interaction time to the trial level interaction summary. if (interaction.event in this.trialInteractions) { (_a = this.trialInteractions[interaction.event]) === null || _a === void 0 ? void 0 : _a.push(interaction.time); } // blur[], focus[], fullscreenenter[], fullscreenexit[] // We want to update the increments for each event type only once if they happened. // So we iterate through the event types (i.e., the keys of trialInteractions) // and if that event type occurred, set the increment to 1. this.runInteractionIncrements = (0, mapValues_1.default)(this.trialInteractions, (_value, key) => this.runInteractionIncrements[key] || interaction.event === key); // blur: boolean, focus: boolean, fullscreenenter: boolean, fullscreenexit: boolean } /** * Writes interaction data for the current trial and updates run-level counters. * * This method saves the interaction data collected during the current trial to Firestore. * It performs two writes: * 1. Updates the trial document with detailed interaction data (e.g., timestamps). * 2. Increments counters on the run document to track how many times each interaction type occurred * (e.g., blur, focus) during a specific assessment stage (e.g., practice, test). * * After writing, the interaction data is reset for the next trial. * * @param assessmentStage - The current stage of the assessment (e.g., "practice", "test") * @param trialDocRef - Reference to the Firestore document representing the current trial */ writeInteractions(assessmentStage, trialDocRef) { return __awaiter(this, void 0, void 0, function* () { // Rename interaction keys (e.g., blur → interaction_blur) const renamedInteractions = {}; for (const [key, value] of Object.entries(this.trialInteractions)) { if (Array.isArray(value)) { renamedInteractions[`interaction_${key}`] = value; } } // Write the detailed interaction data for the current trial yield (0, firestore_1.updateDoc)(trialDocRef, renamedInteractions); // Prepare an update object to increment run-level interaction counters const updateObj = {}; for (const event of Object.keys(this.runInteractionIncrements)) { if (this.runInteractionIncrements[event]) { const fieldPath = `interactions.${assessmentStage}.${event}`; updateObj[fieldPath] = (0, firestore_1.increment)(1); } } // Only update the run document if there are increments to apply if (!(0, isEmpty_1.default)(updateObj)) { yield (0, firestore_1.updateDoc)(this.runRef, updateObj); } // Reset interaction tracking for the next trial this.resetInteractions(); }); } /** * Create a new run on Firestore * @method * @async */ startRun(additionalRunMetadata) { var _a, _b, _c, _d, _e; return __awaiter(this, void 0, void 0, function* () { yield this.user.checkUserExists(); if (!this.task.variantRef) { yield this.task.toFirestore(); } const userDocSnap = yield (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)((_a = userOrgs[orgName]) === null || _a === void 0 ? void 0 : _a.current, this.assigningOrgs[orgName]); if (this.readOrgs) { this.readOrgs[orgName] = (0, intersection_1.default)((_b = userOrgs[orgName]) === null || _b === void 0 ? void 0 : _b.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(); const isTestTask = this.task.testData.task; const isDemoTask = this.task.demoData.task; const isTestVariant = this.task.testData.variant; const isDemoVariant = this.task.demoData.variant; // 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 || isTestTask || isTestVariant) this.testData = true; if (isDemoUser || isDemoTask || isDemoVariant) this.demoData = true; const runData = Object.assign(Object.assign(Object.assign(Object.assign({}, additionalRunMetadata), { id: this.runRef.id, assignmentId: (_c = this.assignmentId) !== null && _c !== void 0 ? _c : null, assigningOrgs: (_d = this.assigningOrgs) !== null && _d !== void 0 ? _d : null, readOrgs: (_e = this.readOrgs) !== null && _e !== void 0 ? _e : 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 }), (this.testData && { testData: true })), (this.demoData && { demoData: true })); yield (0, firestore_1.setDoc)(this.runRef, (0, util_1.removeUndefined)(runData)) .then(() => { return (0, firestore_1.updateDoc)(this.user.userRef, { tasks: (0, firestore_1.arrayUnion)(this.task.taskId), variants: (0, firestore_1.arrayUnion)(this.task.variantId), }); }) .then(() => this.user.updateFirestoreTimestamp()); 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. */ addEngagementFlags(engagementFlags, markAsReliable = false, reliableByBlock = undefined) { return __awaiter(this, void 0, void 0, function* () { 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 (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 (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. */ finishRun(finishingMetaData = {}) { return __awaiter(this, void 0, void 0, function* () { if (!this.started) { throw new Error('Run has not been started yet. Use the startRun method first.'); } if (!this.aborted) { const finishingData = Object.assign(Object.assign({}, finishingMetaData), { completed: true, timeFinished: (0, firestore_1.serverTimestamp)() }); return (0, firestore_1.updateDoc)(this.runRef, finishingData) .then(() => this.user.updateFirestoreTimestamp()) .then(() => (this.completed = true)); } }); } /** * 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. */ writeTrial(trialData, computedScoreCallback) { return __awaiter(this, void 0, void 0, function* () { if (!this.started) { throw new Error('Run has not been started yet. Use the startRun method first.'); } if (!this.aborted) { // 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')); return (0, firestore_1.setDoc)(trialRef, Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (0, exports.convertTrialToFirestore)(trialData)), { taskId: this.task.taskId }), (this.testData && { testData: true })), (this.demoData && { demoData: true })), { serverTimestamp: (0, firestore_1.serverTimestamp)() })) .then(() => __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p; yield this.writeInteractions(trialData.assessment_stage, trialRef); // Only update scores if the trial was a test or a practice response. if (trialData.assessment_stage === 'test_response' || trialData.assessment_stage === 'practice_response') { // 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 'total' score field. // If no subtask is specified, the scores for the 'total' subtask will be updated. const defaultSubtask = 'composite'; const subtask = (trialData.subtask || defaultSubtask); const stage = trialData.assessment_stage.split('_')[0]; 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: castToTheta(trialData.thetaEstimate), thetaSE: castToTheta(trialData.thetaSE), numAttempted: (((_a = this.scores.raw[subtask][stage]) === null || _a === void 0 ? void 0 : _a.numAttempted) || 0) + 1, // For the next two, use the unary + operator to convert the boolean value to 0 or 1. numCorrect: (((_b = this.scores.raw[subtask][stage]) === null || _b === void 0 ? void 0 : _b.numCorrect) || 0) + +Boolean(trialData.correct), numIncorrect: (((_c = this.scores.raw[subtask][stage]) === null || _c === void 0 ? void 0 : _c.numIncorrect) || 0) + +!trialData.correct, }; // And populate the score update for Firestore. scoreUpdate = { [`scores.raw.${subtask}.${stage}.thetaEstimate`]: castToTheta(trialData.thetaEstimate), [`scores.raw.${subtask}.${stage}.thetaSE`]: castToTheta(trialData.thetaSE), [`scores.raw.${subtask}.${stage}.numAttempted`]: (0, firestore_1.increment)(1), [`scores.raw.${subtask}.${stage}.numCorrect`]: trialData.correct ? (0, firestore_1.increment)(1) : undefined, [`scores.raw.${subtask}.${stage}.numIncorrect`]: trialData.correct ? undefined : (0, firestore_1.increment)(1), }; if (subtask !== defaultSubtask) { this.scores.raw[defaultSubtask][stage] = { numAttempted: (((_d = this.scores.raw[defaultSubtask][stage]) === null || _d === void 0 ? void 0 : _d.numAttempted) || 0) + 1, // For the next two, use the unary + operator to convert the boolean value to 0 or 1. numCorrect: (((_e = this.scores.raw[defaultSubtask][stage]) === null || _e === void 0 ? void 0 : _e.numCorrect) || 0) + +Boolean(trialData.correct), numIncorrect: (((_f = this.scores.raw[defaultSubtask][stage]) === null || _f === void 0 ? void 0 : _f.numIncorrect) || 0) + +!trialData.correct, thetaEstimate: castToTheta((_g = trialData.thetas) === null || _g === void 0 ? void 0 : _g[defaultSubtask]), thetaSE: castToTheta((_h = trialData.thetaSEs) === null || _h === void 0 ? void 0 : _h[defaultSubtask]), }; scoreUpdate = Object.assign(Object.assign({}, scoreUpdate), { [`scores.raw.${defaultSubtask}.${stage}.numAttempted`]: (0, firestore_1.increment)(1), [`scores.raw.${defaultSubtask}.${stage}.numCorrect`]: trialData.correct ? (0, firestore_1.increment)(1) : undefined, [`scores.raw.${defaultSubtask}.${stage}.numIncorrect`]: trialData.correct ? undefined : (0, firestore_1.increment)(1), [`scores.raw.${defaultSubtask}.${stage}.thetaEstimate`]: castToTheta((_j = trialData.thetas) === null || _j === void 0 ? void 0 : _j[defaultSubtask]), [`scores.raw.${defaultSubtask}.${stage}.thetaSE`]: castToTheta((_k = trialData.thetaSEs) === null || _k === void 0 ? void 0 : _k[defaultSubtask]) }); } } 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: castToTheta(trialData.thetaEstimate), thetaSE: castToTheta(trialData.thetaSE), numAttempted: 1, numCorrect: trialData.correct ? 1 : 0, numIncorrect: trialData.correct ? 0 : 1, }); // And populate the score update for Firestore. scoreUpdate = { [`scores.raw.${subtask}.${stage}.thetaEstimate`]: castToTheta(trialData.thetaEstimate), [`scores.raw.${subtask}.${stage}.thetaSE`]: castToTheta(trialData.thetaSE), [`scores.raw.${subtask}.${stage}.numAttempted`]: 1, [`scores.raw.${subtask}.${stage}.numCorrect`]: trialData.correct ? 1 : 0, [`scores.raw.${subtask}.${stage}.numIncorrect`]: trialData.correct ? 0 : 1, }; if (subtask !== defaultSubtask) { (0, set_1.default)(this.scores.raw, [defaultSubtask, stage], { numAttempted: 1, numCorrect: trialData.correct ? 1 : 0, numIncorrect: trialData.correct ? 0 : 1, thetaEstimate: castToTheta((_l = trialData.thetas) === null || _l === void 0 ? void 0 : _l[defaultSubtask]), thetaSE: castToTheta((_m = trialData.thetaSEs) === null || _m === void 0 ? void 0 : _m[defaultSubtask]), }); scoreUpdate = Object.assign(Object.assign({}, scoreUpdate), { [`scores.raw.${defaultSubtask}.${stage}.numAttempted`]: (0, firestore_1.increment)(1), [`scores.raw.${defaultSubtask}.${stage}.numCorrect`]: trialData.correct ? (0, firestore_1.increment)(1) : undefined, [`scores.raw.${defaultSubtask}.${stage}.numIncorrect`]: trialData.correct ? undefined : (0, firestore_1.increment)(1), [`scores.raw.${defaultSubtask}.${stage}.thetaEstimate`]: castToTheta(((_o = trialData.thetas) !== null && _o !== void 0 ? _o : {})[defaultSubtask]), [`scores.raw.${defaultSubtask}.${stage}.thetaSE`]: castToTheta((_p = trialData.thetaSEs) === null || _p === void 0 ? void 0 : _p[defaultSubtask]) }); } } if (computedScoreCallback) { // Use the user-provided callback to compute the computed scores. this.scores.computed = yield 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) => { var _a, _b; const numCorrect = ((_a = subtaskScores.test) === null || _a === void 0 ? void 0 : _a.numCorrect) || 0; const numIncorrect = ((_b = subtaskScores.test) === null || _b === void 0 ? void 0 : _b.numIncorrect) || 0; return numCorrect - numIncorrect; }); } // And use dot-object to convert the computed scores into dotted-key/value pairs. // First nest the computed scores into `scores.computed` so that they get updated // in the correct location. const fullUpdatePath = { scores: { computed: this.scores.computed, }, }; scoreUpdate = Object.assign(Object.assign({}, scoreUpdate), dot_object_1.default.dot(fullUpdatePath)); return (0, firestore_1.updateDoc)(this.runRef, (0, util_1.removeUndefined)(scoreUpdate)).catch((error) => { // Catch the "Unsupported field value: undefined" error and // provide a more helpful error message to the ROAR app developer. if (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; }); } })) .then(() => { this.user.updateFirestoreTimestamp(); }); } }); } } exports.RoarRun = RoarRun;