Skip to content

Commit

Permalink
Merge pull request #517 from brown-ccv/add-CLI-register
Browse files Browse the repository at this point in the history
Add cli register
  • Loading branch information
YUUU23 authored Aug 15, 2024
2 parents b969b3d + 58fceba commit 524fa8a
Show file tree
Hide file tree
Showing 3 changed files with 219 additions and 41 deletions.
220 changes: 195 additions & 25 deletions cli.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { checkbox, confirm, expand, input, select } from "@inquirer/prompts";
import fsExtra from "fs-extra";

import { cert, initializeApp } from "firebase-admin/app"; // eslint-disable-line import/no-unresolved
import { getFirestore } from "firebase-admin/firestore"; // eslint-disable-line import/no-unresolved
import { cert, initializeApp } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
import { Command } from "commander";

/** -------------------- GLOBALS -------------------- */

Expand All @@ -17,16 +18,86 @@ let OUTPUT_ROOT; // The root in which data is saved
const INVALID_ACTION_ERROR = new Error("Invalid action: " + ACTION);
const INVALID_DEPLOYMENT_ERROR = new Error("Invalid deployment: " + DEPLOYMENT);

/** -------------------- COMMANDER -------------------- */
const commander = new Command();
// default: [download | delete | register ] not provided, run main() as usual continuing with prompting
commander.action(() => {});

// download: optional argument studyID and participantID skips relative prompts
commander
.command(`download`)
.argument(`[studyID]`)
.argument(`[participantID]`)
.description(`Download experiment data from Firebase provided study ID and participant ID`)
.action((studyID, participantID) => {
ACTION = "download";
STUDY_ID = studyID;
PARTICIPANT_ID = participantID;
});

// delete: optional argument studyID and participantID skips relative prompts
commander
.command(`delete`)
.argument(`[studyID]`)
.argument(`[participantID]`)
.description(`Delete experiment data from Firebase provided study ID and participant ID`)
.action((studyID, participantID) => {
ACTION = "delete";
STUDY_ID = studyID;
PARTICIPANT_ID = participantID;
});

// register: optional argument studyID and participantID skips relative prompts
commander
.command(`register`)
.argument(`[studyID]`)
.argument(`[participantID]`)
.description(
`Register new partipant under study provided a partipantID and studyID; new study will be created if not found`
)
.action((studyID, participantID) => {
ACTION = "register";
STUDY_ID = studyID;
PARTICIPANT_ID = participantID;
});

/** -------------------- MAIN -------------------- */

// TODO @brown-ccv #289: Pass CLI arguments with commander (especially for action)
async function main() {
ACTION = await actionPrompt();
commander.parse();
// print message if download or delete provided, along with optional args provided
if (ACTION != undefined) {
console.log(
`${ACTION} data from Firebase ${STUDY_ID === undefined ? "" : `given study ID: ${STUDY_ID}`} ${PARTICIPANT_ID === undefined ? "" : `and participant ID: ${PARTICIPANT_ID}`}`
);
}

if (ACTION === undefined) {
ACTION = await actionPrompt();
}
DEPLOYMENT = await deploymentPrompt();
// TODO @brown-ccv #291: Enable downloading all study data at once
STUDY_ID = await studyIDPrompt();
if (STUDY_ID === undefined) {
STUDY_ID = await studyIDPrompt();
} else {
// when args directly passed in through CLI, check if study is valid
const studyCollection = await validateStudyFirebase(STUDY_ID);
if (!studyCollection && ACTION !== "register") {
console.error("Please enter a valid study from your Firestore database");
process.exit(1);
}
}
// TODO @brown-ccv #291: Enable downloading all participant data at once
PARTICIPANT_ID = await participantIDPrompt();
if (PARTICIPANT_ID === undefined) {
PARTICIPANT_ID = await participantIDPrompt();
} else {
// when args directly passed in through CLI, check if participant is valid
const participantCollection = await validateParticipantFirebase(PARTICIPANT_ID);
if (!participantCollection && ACTION !== "register") {
console.error(`Please enter a valid participant on the study "${STUDY_ID}"`);
process.exit(1);
}
}
EXPERIMENT_IDS = await experimentIDPrompt();

switch (ACTION) {
Expand All @@ -49,6 +120,15 @@ async function main() {
throw INVALID_DEPLOYMENT_ERROR;
}
break;
case "register":
switch (DEPLOYMENT) {
case "firebase":
await registerDataFirebase(STUDY_ID, PARTICIPANT_ID);
break;
default:
throw INVALID_DEPLOYMENT_ERROR;
}
break;
default:
throw INVALID_ACTION_ERROR;
}
Expand Down Expand Up @@ -140,6 +220,24 @@ async function deleteDataFirebase() {
} else console.log("Skipping deletion");
}

/** -------------------- REGISTER ACTION -------------------- */

/** Register new data, write to Firestore */
async function registerDataFirebase(studyID, participantID) {
const confirmation = await confirmRegisterPrompt(studyID, participantID);
if (confirmation) {
try {
await addStudyAndParticipant(studyID, participantID);
} catch (error) {
console.error(
`Unable to register new participant with participantID ${participantID} under studyID ${studyID}: ` +
error
);
}
} else console.log("Skipping registration");
return true;
}

/** -------------------- PROMPTS -------------------- */

/** Prompt the user for the action they are trying to complete */
Expand All @@ -151,6 +249,10 @@ async function actionPrompt() {
name: "Download data",
value: "download",
},
{
name: "Register new participant under study",
value: "register",
},
{
name: "Delete data",
value: "delete",
Expand Down Expand Up @@ -183,49 +285,43 @@ async function deploymentPrompt() {
return response;
}

/** Prompt the user to enter the ID of a study */
async function studyIDPrompt() {
const invalidMessage = "Please enter a valid study from your Firestore database";
const validateStudyFirebase = async (input) => {
// subcollection is programmatically generated, if it doesn't exist then input must not be a valid studyID
const studyIDCollections = await getStudyRef(input).listCollections();
return studyIDCollections.find((c) => c.id === PARTICIPANTS_COL) ? true : invalidMessage;
};

return await input({
message: "Select a study:",
validate: async (input) => {
if (!input) return invalidMessage;

if (ACTION === "register") {
STUDY_ID = input;
return true;
}
switch (DEPLOYMENT) {
case "firebase":
return validateStudyFirebase(input);
const studyCollection = await validateStudyFirebase(input);
return !studyCollection ? invalidMessage : true;
default:
throw INVALID_DEPLOYMENT_ERROR;
}
},
});
}

/** Prompt the user to enter the ID of a participant on the STUDY_ID study */
async function participantIDPrompt() {
const invalidMessage = `Please enter a valid participant on the study "${STUDY_ID}"`;
const validateParticipantFirebase = async (input) => {
// subcollection is programmatically generated, if it doesn't exist then input must not be a valid participantID
const studyIDCollections = await getParticipantRef(STUDY_ID, input).listCollections();
return studyIDCollections.find((c) => c.id === DATA_COL) ? true : invalidMessage;
};

return await input({
message: "Select a participant:",
message: ACTION === "register" ? "Enter a new participant:" : "Select a participant:",
validate: async (input) => {
const invalid = "Please enter a valid participant from your Firestore database";
if (!input) return invalid;
else if (input === "*") return true;

if (ACTION === "register") {
PARTICIPANT_ID = input;
return true;
}
switch (DEPLOYMENT) {
case "firebase":
return validateParticipantFirebase(input);
const participantCollection = await validateParticipantFirebase(input);
return !participantCollection ? invalidMessage : true;
default:
throw INVALID_DEPLOYMENT_ERROR;
}
Expand All @@ -235,6 +331,11 @@ async function participantIDPrompt() {

/** Prompt the user to select one or more experiments of the PARTICIPANT_ID on STUDY_ID */
async function experimentIDPrompt() {
// register: adding/checking for existing new studies will be done in function
if (ACTION === "register") {
return;
}

const dataSnapshot = await getDataRef(STUDY_ID, PARTICIPANT_ID).get();

// Sort experiment choices by most recent first
Expand Down Expand Up @@ -278,6 +379,20 @@ async function confirmDeletionPrompt() {
});
}

async function confirmRegisterPrompt(studyID, participantID) {
const currentParticipants = await getRegisteredParticipantArr(studyID);
const currentParticipantMessage =
currentParticipants.length === 0
? "Currently, there are no participants under this study\n"
: `Currently, the participants under this study include: \n${currentParticipants.join("\n")}\n`;
return confirm({
message:
currentParticipantMessage +
`Continue? adding study with studyID: ${studyID} and participant ID: ${participantID}`,
default: false,
});
}

/**
* Prompts the user to confirm continuation of the CLI, including future conflicts
* @param {string} outputFile
Expand Down Expand Up @@ -312,9 +427,25 @@ async function confirmOverwritePrompt(file, overwriteAll) {
return answer;
}

/** -------------------- FIRESTORE VALIDATIONS -------------------- */
/** helper to check if the given study (input) is in firestore */
async function validateStudyFirebase(input) {
// subcollection is programmatically generated, if it doesn't exist then input must not be a valid studyID
const studyIDCollections = await getStudyRef(input).listCollections();
return studyIDCollections.find((c) => c.id === PARTICIPANTS_COL);
}

/** helper to check if the given participant (input) is in firestore under study */
async function validateParticipantFirebase(input) {
// subcollection is programmatically generated, if it doesn't exist then input must not be a valid participantID
const studyIDCollections = await getParticipantRef(STUDY_ID, input).listCollections();
return studyIDCollections.find((c) => c.id === DATA_COL);
}

/** -------------------- FIRESTORE HELPERS -------------------- */

const RESPONSES_COL = "participant_responses";
const REG_STUDY_COL = "registered_studies";
const PARTICIPANTS_COL = "participants";
const DATA_COL = "data";
const TRIALS_COL = "trials";
Expand All @@ -333,3 +464,42 @@ const getDataRef = (studyID, participantID) =>
// Get a reference to a participant's specific experiment data document in Firestore
const getExperimentRef = (studyID, participantID, experimentID) =>
getDataRef(studyID, participantID).doc(experimentID);

// Get a reference to a registered study
const getRegisteredfStudyRef = (studyID) => FIRESTORE.collection(REG_STUDY_COL).doc(studyID);

// Get current registered participant array under the StudyID
const getRegisteredParticipantArr = async (studyID) => {
const data = await getRegisteredfStudyRef(studyID).get();
if (data["_fieldsProto"] !== undefined) {
// get array of registered participant under study
return data["_fieldsProto"]["registered_participants"]["arrayValue"]["values"]
.filter((item) => item.valueType === "stringValue")
.map((item) => item.stringValue);
} else {
// return empty array when no participant found
return [];
}
};

// Register new participantID under the provided studyID
const registerNewParticipant = async (studyID, participantID) => {
const currParticipants = await getRegisteredParticipantArr(studyID);
const newParticipantArray = [...currParticipants, participantID];
const newData = { registered_participants: newParticipantArray };
getRegisteredfStudyRef(studyID).update(newData);
console.log(
`Successfully added study and participant. Current participantIDs under study ${studyID}: \n${newParticipantArray.join("\n")}`
);
};

// Add new participantID under studyID to Firestore under registered_studies,
// creates new study if studyID doesn't exist
const addStudyAndParticipant = async (studyID, participantID) => {
const data = await getRegisteredfStudyRef(studyID).get();
if (data["_fieldsProto"] === undefined) {
// study not initiated yet
await getRegisteredfStudyRef(studyID).set({ registered_participants: [] });
}
await registerNewParticipant(studyID, participantID);
};
39 changes: 23 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 524fa8a

Please sign in to comment.