@bdelab/roar-firekit
Version:
A library to facilitate Firebase authentication and Cloud Firestore interaction for ROAR apps
467 lines (466 loc) • 27.8 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 __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;