Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cli register #517

Merged
merged 13 commits into from
Aug 15, 2024
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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can they register to new participants AND new studies or just new participants on an existing study?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should automatically register the new study if the study is not found! I will update this description to make it more clear.

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) => {
eldu marked this conversation as resolved.
Show resolved Hide resolved
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
Loading