This repository has been archived on 2025-11-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
parandum/functions/index.js

706 lines
23 KiB
JavaScript

/* eslint-disable indent */
/* eslint-disable no-tabs */
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const { ChatSharp } = require("@material-ui/icons");
admin.initializeApp();
const db = admin.firestore();
// TODO: set up App Check https://firebase.google.com/docs/app-check/cloud-functions
/**
* Randomises the items in an array.
* @param {object} array The array to randomise.
* @return {object} The randomised array.
*/
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Adds extra user info when new user created.
* NOTE: Can't be unit tested.
* @return {promise} Promise from database write.
*/
exports.userCreated = functions.auth.user().onCreate(async (user) => {
return await admin.auth().setCustomUserClaims(user.uid, {
admin: false,
}).then(() => {
return db.collection("users").doc(user.uid).set({
sound: true,
theme: "default",
});
});
});
/**
* Cleans up database when user deleted. Progress documents are kept as they may provide useful metrics.
* NOTE: Can't be unit tested.
* @return {promise} Promise from database delete.
*/
exports.userDeleted = functions.auth.user().onDelete(async (user) => {
return db.collection("users").doc(user.uid).delete();
});
/**
* Creates new progress document.
* @param {object} data The data passed to the function.
* @param {string} data.set_id The ID of the desired set.
* @param {boolean} data.switch_language Whether or not the languages should be reversed.
* @param {boolean} data.mode The mode to be tested in. Valid options are "questions" and "lives".
* @param {boolean} data.limit The maximum number of lives/questions for the test.
* @return {string} The ID of the created progress document.
*/
exports.createProgress = functions.https.onCall((data, context) => {
// const uid = context.auth.uid;
const uid = "user_01";
const setId = data.set_id;
const setDocId = db.collection("sets").doc(setId);
return db.runTransaction((transaction) => {
return transaction.get(setDocId).then((setDoc) => {
if (!setDoc.exists) {
throw new functions.https.HttpsError("not-found", "Set doesn't exist");
} else if (!setDoc.data().public && setDoc.data().owner !== uid) {
throw new functions.https.HttpsError("permission-denied", "Insufficient permissions to access set");
} else {
const switchLanguage = data.switch_language;
const mode = data.mode;
const limit = data.limit;
const setTitle = setDoc.data().title;
const setOwner = setDoc.data().owner;
const correct = [];
const incorrect = [];
const currentCorrect = [];
const progress = 0;
const startTime = Date.now();
const setVocabCollectionId = db
.collection("sets").doc(setId)
.collection("vocab");
const progressDocId = db
.collection("progress").doc();
return transaction.get(setVocabCollectionId).then((setVocab) => {
let dataToSet = {
questions: [],
correct: correct,
current_correct: currentCorrect,
incorrect: incorrect,
progress: progress,
start_time: startTime,
set_title: setTitle,
uid: uid,
switch_language: switchLanguage,
duration: null,
set_owner: setOwner,
mode: mode,
}
shuffleArray(setVocab).forEach((doc, index, array) => {
const vocabId = doc.id;
const terms = {
"item": doc.data().term,
"sound": doc.data().sound,
};
const definitions = {
"item": doc.data().definition,
};
dataToSet.questions.push(vocabId);
transaction.set(
progressDocId.collection("terms").doc(vocabId),
terms
);
transaction.set(
progressDocId.collection("definitions").doc(vocabId),
definitions
);
if (mode == "questions" && index >= limit - 1) {
array.length = index + 1;
}
});
if (mode === "lives") dataToSet.lives = limit;
transaction.set(
progressDocId,
dataToSet
);
return progressDocId.id;
});
}
});
});
});
/**
* Processes a response to a question in a vocab set.
* @param {string} progressId The ID of the progress file to retrieve the prompt from.
* @return {string} item The term/definition prompt for the next question.
* @return {string} sound The file ID for the next question's sound file. Null if language is switched.
*//*
exports.getPrompt = functions.https.onCall((data, context) => {
// const uid = context.auth.uid;
const uid = "user_01";
const progressId = data;
const progressDocId = db
.collection("progress").doc(progressId);
return db.runTransaction((transaction) => {
return transaction.get(progressDocId).then((progressDoc) => {
if (uid !== progressDoc.data().uid) {
throw new functions.https.HttpsError("permission-denied", "Wrong user's progress");
} else if (progressDoc.data().progress >= progressDoc.data().questions.length) {
throw new functions.https.HttpsError("permission-denied", "Progress already completed")
} else {
nextIndex = progressDoc.data().progress;
nextVocabId = progressDoc.data().questions[nextIndex];
if (progressDoc.data().switch_language) {
const promptDocId = progressDocId
.collection("definitions").doc(nextVocabId);
const sound = null;
return transaction.get(promptDocId).then((promptDoc) => {
return {
item: promptDoc.data().item,
sound: sound,
}
});
} else {
const promptDocId = progressDocId
.collection("terms").doc(nextVocabId);
return transaction.get(promptDocId).then((promptDoc) => {
const sound = promptDoc.data().sound;
return {
item: promptDoc.data().item,
sound: sound,
}
});
}
}
});
});
});*/
/**
* Checks whether two arrays have the same members.
* @param {array} arr1 The first array to compare.
* @param {array} arr2 The second array to compare.
* @return {boolean} Whether or not the two arrays have the same members.
*/
function arraysHaveSameMembers(arr1, arr2) {
const set1 = new Set(arr1);
const set2 = new Set(arr2);
return arr1.every(item => set2.has(item)) &&
arr2.every(item => set1.has(item));
}
/**
* Removes characters from terms & definitions that should be ignored.
* @param {string} item The term/definition to remove the characters that should be ignored from.
* @return {string} The original string with the unwanted characters removed.
*/
function cleanseVocabString(item) {
const chars = " .,()-_'\"";
let newString = item;
chars.split("").forEach((char) => {
newString = newString.replace(char, "");
});
return newString;
}
/**
* Processes a response to a question in a vocab set.
* @param {object} data The data passed to the function.
* @param {string} data.progressId The ID of the progress document to update.
* @param {string} data.answer The answer given by the user to the current prompt.
* @return {boolean} correct Whether the provided answer was correct.
* @return {array} correctAnswers An array of correct answers for the question just answered. If not all correct
* answers have yet been given, and the current answer is correct, this only contains the correct
* answers given so far.
* @return {integer} lives Total number of lives available in this test. Only returned if mode is "lives".
* @return {boolean} moreAnswers Whether or not there are more answers required for the current prompt.
* @return {object} nextPrompt Details of the next prompt, if relevant. Null if last question has been answered.
* @return {string} nextPrompt.item The term/definition prompt for the next question.
* @return {string} nextPrompt.sound The file ID for the next question's sound file. Null if language is switched.
* @return {integer} progress Total number of questions answered so far.
* @return {integer} totalQuestions Total number of questions in the set (including duplicates after incorrect answers).
* @return {integer} totalCorrect Total number of correct answers so far.
* @return {integer} totalIncorrect Total number of incorrect answers so far.
*/
exports.processAnswer = functions.https.onCall((data, context) => {
// const uid = context.auth.uid;
const uid = "user_01";
const progressId = data.progressId;
const inputAnswer = data.answer;
const progressDocId = db
.collection("progress").doc(progressId);
return db.runTransaction((transaction) => {
return transaction.get(progressDocId).then((progressDoc) => {
if (!progressDoc.exists) {
throw new functions.https.HttpsError("not-found", "Progress record " + progressId + " doesn't exist")
} else if (uid !== progressDoc.data().uid) {
throw new functions.https.HttpsError("permission-denied", "Wrong user's progress");
} else if (progressDoc.data().progress >= progressDoc.data().questions.length || (progressDoc.data().mode === "lives" && progressDoc.data().incorrect.length >= progressDoc.data().lives)) {
throw new functions.https.HttpsError("permission-denied", "Progress already completed")
} else {
currentIndex = progressDoc.data().progress;
currentVocab = progressDoc.data().questions[currentIndex];
let answerDocId;
if (progressDoc.data().switch_language) {
answerDocId = progressDocId
.collection("terms").doc(currentVocab);
} else {
answerDocId = progressDocId
.collection("definitions").doc(currentVocab);
}
return transaction.get(answerDocId).then((answerDoc) => {
const docData = progressDoc.data();
const mode = docData.mode;
const correctAnswers = answerDoc.data().item;
const splitCorrectAnswers = correctAnswers.split("/");
const cleansedSplitCorrectAnswers = cleanseVocabString(correctAnswers).split("/");
const cleansedInputAnswer = cleanseVocabString(inputAnswer);
let isCorrectAnswer = false;
let correctAnswerIndex;
cleansedSplitCorrectAnswers.forEach((answer, index, array) => {
if (answer === cleansedInputAnswer) {
isCorrectAnswer = true;
correctAnswerIndex = index;
array.length = index + 1;
}
});
let prevCorrect = progressDoc.data().current_correct;
var returnData = {
mode: mode,
correct: isCorrectAnswer,
correctAnswers: splitCorrectAnswers,
moreAnswers: false,
nextPrompt: null,
progress: docData.progress,
totalQuestions: docData.questions.length,
totalCorrect: docData.correct.length,
totalIncorrect: docData.incorrect.length,
}
if (isCorrectAnswer) {
if (!prevCorrect) {
docData.current_correct = [splitCorrectAnswers[correctAnswerIndex]];
} else if (!prevCorrect.includes(splitCorrectAnswers[correctAnswerIndex])) {
docData.current_correct.push(splitCorrectAnswers[correctAnswerIndex]);
}
if (docData.current_correct.length === splitCorrectAnswers.length) {
docData.progress++;
returnData.progress = docData.progress;
docData.current_correct = [];
docData.correct.push(currentVocab);
returnData.totalCorrect = docData.correct.length;
} else {
returnData.moreAnswers = true;
returnData.correctAnswers = docData.current_correct;
}
} else {
docData.progress++;
returnData.progress = docData.progress;
docData.incorrect.push(currentVocab);
docData.questions.push(currentVocab);
const doneQuestions = docData.questions.slice(0, docData.progress);
const notDoneQuestions = docData.questions.slice(docData.progress);
docData.current_correct = [];
docData.questions = doneQuestions.concat(shuffleArray(notDoneQuestions));
returnData.totalQuestions = docData.questions.length;
returnData.totalIncorrect = docData.incorrect.length;
}
if (mode === "lives") returnData.lives = docData.lives;
if (!returnData.moreAnswers) {
if (docData.progress >= docData.questions.length || (mode === "lives" && docData.incorrect.length >= docData.lives)) {
const duration = Date.now() - docData.start_time;
docData.duration = duration;
returnData.duration = duration;
transaction.set(progressDocId, docData);
return returnData;
} else {
const nextVocabId = docData.questions[docData.progress];
if (docData.switch_language) {
const promptDocId = progressDocId
.collection("definitions").doc(nextVocabId);
const sound = null;
return transaction.get(promptDocId).then((promptDoc) => {
returnData.nextPrompt = {
item: promptDoc.data().item,
sound: sound,
}
transaction.set(progressDocId, docData);
return returnData;
});
} else {
const promptDocId = progressDocId
.collection("terms").doc(nextVocabId);
return transaction.get(promptDocId).then((promptDoc) => {
const sound = promptDoc.data().sound;
returnData.nextPrompt = {
item: promptDoc.data().item,
sound: sound,
}
transaction.set(progressDocId, docData);
return returnData;
});
}
}
} else {
transaction.set(progressDocId, docData);
return returnData;
}
});
}
});
});
});
/**
* Sets the admin state of a user (excluding the authenticated user), if the authenticated
* user is an admin themselves.
* @param {object} data The data passed to the function.
* @param {string} data.targetUser The ID of the user whose admin state should be changed.
* @param {boolean} data.adminState The target admin state.
* @return {promise} The promise from setting the target user's admin custom auth claim.
*/
exports.setAdmin = functions.https.onCall(async (data, context) => {
// const uid = context.auth.uid;
// const isAdmin = context.auth.tokens.admin;
const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
const isAdmin = true;
const targetUser = data.targetUser;
const adminState = data.adminState;
if (isAdmin) {
if (uid !== targetUser) {
return await admin.auth().setCustomUserClaims(targetUser, {
admin: adminState,
});
} else {
throw new functions.https.HttpsError("permission-denied", "Cannot change admin status of authenticated user");
}
} else {
throw new functions.https.HttpsError("permission-denied", "Must be an admin to change other users' admin states");
}
});
/**
* Adds an existing vocab set to an existing group.
* @param {object} data The data passed to the function.
* @param {string} data.groupId The ID of the group to which the set should be added.
* @param {boolean} data.setId The ID of the set that should be added to the group.
* @return {promise} The promise from setting the group's updated data.
*/
exports.addSetToGroup = functions.https.onCall((data, context) => {
// const uid = context.auth.uid;
// const isAdmin = context.auth.token.admin;
// const auth = context.auth;
const uid = "user_01";
const isAdmin = false;
const auth = { uid: uid };
const groupId = data.groupId;
const setId = data.setId;
const setDocId = db.collection("sets").doc(setId);
const userGroupDocId = db.collection("users").doc(uid).collection("groups").doc(groupId);
const groupDocId = db.collection("groups").doc(groupId);
return db.runTransaction((transaction) => {
return transaction.get(setDocId).then((setDoc) => {
return transaction.get(userGroupDocId).then((userGroupDoc) => {
const userRole = userGroupDoc.data().role;
if (auth && (setDoc.data().public || setDoc.data().owner == uid) && (userRole == "contributor" || userRole == "owner" || isAdmin)) {
let setDocData = setDoc.data();
if (setDocData.groups != null && setDocData.groups.includes(groupId)) {
throw new functions.https.HttpsError("permission-denied", "Set is already part of group");
} else {
return transaction.get(groupDocId).then((groupDoc) => {
let groupDocData = groupDoc.data();
if (setDocData.groups == null) {
setDocData.groups = [];
}
if (groupDocData.sets == null) {
groupDocData.sets = [];
}
setDocData.groups.push(groupId);
groupDocData.sets.push(setId);
transaction.set(
setDocId,
setDocData,
);
return transaction.set(
groupDocId,
groupDocData,
);
});
}
} else {
throw new functions.https.HttpsError("permission-denied", "Insufficient permisisons to add set to group")
}
});
});
});
});
/**
* Removes an existing vocab set from an existing group.
* @param {object} data The data passed to the function.
* @param {string} data.groupId The ID of the group from which the set should be removed.
* @param {boolean} data.setId The ID of the set that should be removed from the group.
* @return {promise} The promise from setting the group's updated data.
*/
exports.removeSetFromGroup = functions.https.onCall((data, context) => {
// const uid = context.auth.uid;
// const isAdmin = context.auth.token.admin;
// const auth = context.auth;
const uid = "user_01";
const isAdmin = false;
const auth = { uid: uid };
const groupId = data.groupId;
const setId = data.setId;
const setDocId = db.collection("sets").doc(setId);
const userGroupDocId = db.collection("users").doc(uid).collection("groups").doc(groupId);
const groupDocId = db.collection("groups").doc(groupId);
return db.runTransaction((transaction) => {
return transaction.get(setDocId).then((setDoc) => {
return transaction.get(userGroupDocId).then((userGroupDoc) => {
const userRole = userGroupDoc.data().role;
if (auth && (userRole == "owner" || isAdmin)) {
let setDocData = setDoc.data();
if (setDocData.groups == null || !setDocData.groups.includes(groupId)) {
throw new functions.https.HttpsError("permission-denied", "Set is not part of group");
} else {
return transaction.get(groupDocId).then((groupDoc) => {
setDocData.groups = setDocData.groups.filter(item => item !== groupId);
let groupDocData = groupDoc.data();
groupDocData.sets = groupDocData.sets.filter(item => item !== setId);
transaction.set(
setDocId,
setDocData,
);
return transaction.set(
groupDocId,
groupDocData,
);
});
}
} else {
throw new functions.https.HttpsError("permission-denied", "Insufficient permisisons to remove set from group")
}
});
});
});
});
/**
* Changes an existing user's membership status of a group in the groups collection
* in Firestore, after it has been changed in the users collection.
* NOTE: Can't be unit tested.
* @return {promise} The promise from setting the group's updated data.
*/
exports.userGroupRoleChanged = functions.firestore.document("users/{userId}/groups/{groupId}")
.onWrite((change, context) => {
return db.runTransaction((transaction) => {
const groupDocId = db.collection("groups").doc(context.params.groupId);
return transaction.get(groupDocId).then((groupDoc) => {
let groupData = groupDoc.data();
if (typeof groupData === "undefined") {
throw new functions.https.HttpsError("not-found", "Group doesn't exist");
}
if (typeof groupData.users === "undefined") {
groupData.users = {};
}
if (change.after.data().role) {
groupData.users[context.params.userId] = change.after.data().role;
} else {
delete groupData.users[context.params.userId];
}
return transaction.set(
groupDocId,
groupData
);
});
});
});
/**
* Generates a random, unused group join code.
* @return {string} The join code.
*/
async function generateJoinCode() {
const joinCode = String(Math.random().toString(36).substring(5));
const snapshot = await db.collection("join_codes").doc(joinCode).get();
if (snapshot.exists) {
return generateJoinCode();
} else {
return joinCode;
}
}
/**
* Creates a new group.
* @param {string} data The display name for the new group.
* @return {string} The ID of the new group's document in the groups collection.
*/
exports.createGroup = functions.https.onCall(async (data, context) => {
// const uid = context.auth.uid;
const uid = "user_01";
const joinCode = await generateJoinCode();
const groupDoc = await db.collection("groups").add({
display_name: data,
sets: [],
users: {},
join_code: joinCode,
});
await db.collection("users").doc(uid).collection("groups").doc(groupDoc.id).set({
role: "owner",
});
return groupDoc.id;
});
/**
* Cleans up database after group is deleted - removes group references from user groups collections.
* NOTE: Can't be unit tested.
* @return {promise} The promise from deleting the user's group data.
*/
exports.groupDeleted = functions.firestore.document("groups/{groupId}")
.onDelete(async (snap, context) => {
let batch = db.batch();
const users = snap.data().users;
const joinCode = snap.data().join_code;
let counter = 0;
for (let [userId, role] of Object.entries(users)) {
batch.delete(
db.collection("users").doc(userId).collection("groups").doc(context.params.groupId)
);
counter++;
if (counter >= 19) {
batch.commit();
batch = db.batch();
}
}
batch.delete(db.collection("join_codes").doc(joinCode));
return await batch.commit();
});
/**
* Cleans up database after group is deleted - removes group references from user groups collections.
* NOTE: Can't be unit tested.
* @return {boolean} Returns true on completion.
*/
exports.progressDeleted = functions.firestore.document("progress/{progressId}")
.onDelete((snap, context) => {
deleteCollection(
db,
"/progress/" + context.params.progressId + "/terms",
500
);
deleteCollection(
db,
"/progress/" + context.params.progressId + "/definitions",
500
);
return true;
});
/**
* Deletes a Firestore collection.
* @param {FirebaseFirestore.Firestore} db The database object from which the collection should be deleted.
* @param {string} collectionPath The path of the collection to be deleted.
* @param {integer} batchSize The maximum batch size.
* @return {promise} A promise with the result of the deleteQueryBatch function.
*/
async function deleteCollection(db, collectionPath, batchSize) {
const collectionRef = db.collection(collectionPath);
const query = collectionRef.orderBy('__name__').limit(batchSize);
return new Promise((resolve, reject) => {
deleteQueryBatch(db, query, resolve).catch(reject);
});
}
/**
* Deletes a batch of Firestore documents.
* @param {FirebaseFirestore.Firestore} db The database object from which the collection should be deleted.
* @param {string} query The delete query.
* @param {integer} resolve The resolve object from a generated promise.
*/
async function deleteQueryBatch(db, query, resolve) {
const snapshot = await query.get();
const batchSize = snapshot.size;
if (batchSize === 0) {
// When there are no documents left, we are done
resolve();
return;
}
// Delete documents in a batch
const batch = db.batch();
snapshot.docs.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
// Recurse on the next process tick, to avoid
// exploding the stack.
process.nextTick(() => {
deleteQueryBatch(db, query, resolve);
});
}