Add new Cloud Functions

This commit is contained in:
2021-09-01 17:34:17 +01:00
parent ef57d5ddce
commit 437c27bb00

View File

@@ -1,12 +1,11 @@
/* eslint-disable indent */ /* eslint-disable indent */
/* eslint-disable no-tabs */ /* eslint-disable no-tabs */
const functions = require("firebase-functions"); const functions = require("firebase-functions").region("europe-west2");//.region("europe-west2")
const admin = require("firebase-admin"); const admin = require("firebase-admin");
const { ChatSharp, DiscFull } = require("@material-ui/icons");
admin.initializeApp(); admin.initializeApp();
const db = admin.firestore(); const db = admin.firestore();
// TODO: set up App Check https://firebase.google.com/docs/app-check/cloud-functions const LOCAL_TESTING = false;
/** /**
* Randomises the items in an array. * Randomises the items in an array.
@@ -26,8 +25,8 @@ function shuffleArray(array) {
* NOTE: Can't be unit tested. * NOTE: Can't be unit tested.
* @return {promise} Promise from database write. * @return {promise} Promise from database write.
*/ */
exports.userCreated = functions.auth.user().onCreate(async (user) => { exports.userCreated = functions.auth.user().onCreate((user) => {
return await admin.auth().setCustomUserClaims(user.uid, { return admin.auth().setCustomUserClaims(user.uid, {
admin: false, admin: false,
}).then(() => { }).then(() => {
return db.collection("users").doc(user.uid).set({ return db.collection("users").doc(user.uid).set({
@@ -42,10 +41,101 @@ exports.userCreated = functions.auth.user().onCreate(async (user) => {
* NOTE: Can't be unit tested. * NOTE: Can't be unit tested.
* @return {promise} Promise from database delete. * @return {promise} Promise from database delete.
*/ */
exports.userDeleted = functions.auth.user().onDelete(async (user) => { exports.userDeleted = functions.auth.user().onDelete((user) => {
return db.collection("users").doc(user.uid).delete(); return db.collection("users").doc(user.uid).delete();
}); });
/**
* Retrieves the user IDs and display names of all users in the given group.
* @param {string} groupId The ID of the group whose users should be retrieved.
* @return {object} A dictionary of owners, contributors, and members of the group.
* @return {array} owners An array of objects, one for each user with the owner role for
* the specified set, containing the users' display names and user IDs.
* @return {string} owners[i].displayName The user's display name.
* @return {string} owners[i].uid The user's ID.
* @return {array} contributors An array of objects, one for each user with the contributor role
* for the specified set, containing the users' display names and user IDs.
* @return {string} contributors[i].displayName The user's display name.
* @return {string} contributors[i].uid The user's ID.
* @return {array} members An array of objects, one for each user with the member role for
* the specified set, containing the users' display names and user IDs.
* @return {string} members[i].displayName The user's display name.
* @return {string} members[i].uid The user's ID.
* NOTE: can't be unit tested
*/
exports.getGroupMembers = functions.https.onCall((data, context) => {
// const uid = context.auth.uid;
const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
if (typeof data.groupId !== "string") {
throw new functions.https.HttpsError("invalid-argument", "Group ID must be a string");
}
return db.collection("groups")
.doc(data.groupId)
.get()
.then((groupDoc) => {
if (!groupDoc.data() || !groupDoc.data().users) {
throw new functions.https.HttpsError("failed-precondition", "Group just created so users can't yet be retrieved - the only user is the group creator");
}
const groupUsers = groupDoc.data().users;
if (groupUsers[uid] !== "owner") {
throw new functions.https.HttpsError("permission-denied", "You must be a group owner to retrieve group members' data");
}
let groupOwners = [];
let groupContributors = [];
let groupMembers = [];
return Promise.all(Object.keys(groupUsers).map((userId) => {
return admin.auth()
.getUser(userId)
.then((userRecord) => {
if (groupUsers[userId] === "owner") {
groupOwners.push({
displayName: userRecord.displayName,
uid: userId,
});
} else if (groupUsers[userId] === "contributor") {
groupContributors.push({
displayName: userRecord.displayName,
uid: userId,
});
} else {
groupMembers.push({
displayName: userRecord.displayName,
uid: userId,
});
}
});
})).then(() => {
const sortArray = (arr) => arr.sort((a, b) => {
if (a.displayName < b.displayName) {
return -1;
}
if (a.displayName > b.displayName) {
return 1;
}
return 0;
});
return {
owners: sortArray(groupOwners),
contributors: sortArray(groupContributors),
members: sortArray(groupMembers),
};
});
});
})
/** /**
* Creates new progress document. * Creates new progress document.
* @param {object} data The data passed to the function. * @param {object} data The data passed to the function.
@@ -56,115 +146,135 @@ exports.userDeleted = functions.auth.user().onDelete(async (user) => {
* @return {string} The ID of the created progress document. * @return {string} The ID of the created progress document.
*/ */
exports.createProgress = functions.https.onCall((data, context) => { exports.createProgress = functions.https.onCall((data, context) => {
const uid = context.auth.uid; // const uid = context.auth.uid;
// const uid = "user_01"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
if (!data.sets || data.sets.length < 1) { if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
if (typeof data.sets !== "object" || data.sets.length < 1) {
throw new functions.https.HttpsError("invalid-argument", "At least one set must be provided"); throw new functions.https.HttpsError("invalid-argument", "At least one set must be provided");
} else if (Number.isInteger(data.limit) || data.limit < 1) { }
if (typeof data.limit !== "number" || !Number.isInteger(data.limit) || data.limit < 1) {
throw new functions.https.HttpsError("invalid-argument", "Limit must be an integer greater than 0") throw new functions.https.HttpsError("invalid-argument", "Limit must be an integer greater than 0")
} }
else {
return db.runTransaction( async (transaction) => {
const sets = data.sets;
const setsId = db.collection("sets");
let allSetTitles = [];
let allVocab = [];
async function asyncForEach(array, callback) { if (typeof data.switch_language !== "boolean") {
for (let index = 0; index < array.length; index++) { throw new functions.https.HttpsError("invalid-argument", "switch_language must be a boolean");
await callback(array[index], index, array); }
}
}
await asyncForEach(sets, async (setId) => { if (data.mode !== "questions" && data.mode !== "lives") {
return transaction.get(setsId.doc(setId)).then((setDoc) => { throw new functions.https.HttpsError("invalid-argument", "mode must be \"questions\" or \"lives\"");
if (!setDoc.exists) { }
throw new functions.https.HttpsError("not-found", "Set doesn't exist");
} else if (!setDoc.data().public && setDoc.data().owner !== uid) { return db.runTransaction(async (transaction) => {
throw new functions.https.HttpsError("permission-denied", "Insufficient permissions to access set"); const setsId = db.collection("sets");
} else { let allSetTitles = [];
const setVocabCollectionId = db let allVocab = [];
await Promise.all(data.sets.map((setId) => {
return transaction.get(setsId.doc(setId)).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 setVocabCollectionId = db
.collection("sets").doc(setId) .collection("sets").doc(setId)
.collection("vocab"); .collection("vocab");
return transaction.get(setVocabCollectionId).then((setVocab) => { return transaction.get(setVocabCollectionId).then((setVocab) => {
allSetTitles.push(setDoc.data().title); if (setVocab.docs.length < 1) throw new functions.https.HttpsError("failed-precondition", "Set must have at least one term/definition pair");
return setVocab.docs.map((vocabDoc) => { allSetTitles.push(setDoc.data().title);
let newVocabData = vocabDoc;
newVocabData.vocabId = setDoc.data().owner + "__" + vocabDoc.id; return setVocab.docs.map((vocabDoc) => {
allVocab.push(newVocabData); let newVocabData = vocabDoc;
}); newVocabData.vocabId = setDoc.data().owner + "__" + vocabDoc.id;
allVocab.push(newVocabData);
}); });
} });
});
});
const mode = data.mode;
const limit = data.limit;
const switchLanguage = data.switch_language;
const progressDocId = db
.collection("progress").doc();
let setTitle;
if (allSetTitles.length > 1) {
setTitle = allSetTitles.slice(0, -1).join(", ") + " & " + allSetTitles.slice(-1);
} else {
setTitle = allSetTitles[0];
}
let dataToSet = {
questions: [],
correct: [],
current_correct: [],
incorrect: [],
progress: 0,
start_time: Date.now(),
set_title: setTitle,
uid: uid,
switch_language: switchLanguage,
duration: null,
mode: mode,
}
shuffleArray(allVocab).forEach((doc, index, array) => {
const vocabId = doc.vocabId;
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; const mode = data.mode;
const limit = data.limit;
const switchLanguage = data.switch_language;
const progressDocId = db
.collection("progress").doc();
let setTitle;
if (allSetTitles.length > 1) {
setTitle = allSetTitles.slice(0, -1).join(", ") + " & " + allSetTitles.slice(-1);
} else {
setTitle = allSetTitles[0];
}
let dataToSet = {
questions: [],
correct: [],
current_correct: [],
incorrect: [],
progress: 0,
start_time: Date.now(),
set_title: setTitle,
uid: uid,
switch_language: switchLanguage,
duration: null,
mode: mode,
setIds: data.sets.sort((a, b) => {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}),
}
shuffleArray(allVocab).forEach((doc, index, array) => {
const vocabId = doc.vocabId;
const terms = {
"item": doc.data().term,
"sound": doc.data().sound,
};
const definitions = {
"item": doc.data().definition,
"sound": doc.data().sound,
};
dataToSet.questions.push(vocabId);
transaction.set( transaction.set(
progressDocId, progressDocId.collection("terms").doc(vocabId),
dataToSet terms
);
transaction.set(
progressDocId.collection("definitions").doc(vocabId),
definitions
); );
return progressDocId.id; if (mode == "questions" && index >= limit - 1) {
array.length = index + 1;
}
}); });
}
if (mode === "lives") dataToSet.lives = limit;
transaction.set(
progressDocId,
dataToSet
);
return progressDocId.id;
});
}); });
/** /**
@@ -175,7 +285,7 @@ exports.createProgress = functions.https.onCall((data, context) => {
*//* *//*
exports.getPrompt = functions.https.onCall((data, context) => { exports.getPrompt = functions.https.onCall((data, context) => {
// const uid = context.auth.uid; // const uid = context.auth.uid;
const uid = "user_01"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
const progressId = data; const progressId = data;
@@ -259,19 +369,28 @@ function cleanseVocabString(item) {
* @return {array} correctAnswers An array of correct answers for the question just answered. If not all 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 have yet been given, and the current answer is correct, this only contains the correct
* answers given so far. * answers given so far.
* @return {integer} lives Total number of lives available in this test. Only returned if mode is "lives". * @return {string} currentVocabId The vocab ID of the vocab item currently being evaluated.
* @return {integer} duration The time taken for the test to be completed. Only returned when the test is complete.
* @return {array} incorrectAnswers The vocab IDs of all incorrect answers given (including repeats for multiple incorrect answers). Only returned when the test is complete.
* @return {boolean} moreAnswers Whether or not there are more answers required for the current prompt. * @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 {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.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 {boolean} nextPrompt.sound Whether the next prompt has an associated sound file. Null if language is switched.
* @return {boolean} nextPrompt.set_owner User ID of the owner of the sound file associated with the next prompt. Null if there is no sound file.
* @return {integer} progress Total number of questions answered so far. * @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} 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} totalCorrect Total number of correct answers so far.
* @return {integer} totalIncorrect Total number of incorrect answers so far. * @return {integer} totalIncorrect Total number of incorrect answers so far.
*/ */
exports.processAnswer = functions.https.onCall((data, context) => { exports.processAnswer = functions.https.onCall((data, context) => {
const uid = context.auth.uid; // const uid = context.auth.uid;
// const uid = "user_01"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
const progressId = data.progressId; const progressId = data.progressId;
const inputAnswer = data.answer; const inputAnswer = data.answer;
@@ -288,8 +407,8 @@ exports.processAnswer = functions.https.onCall((data, context) => {
} else if (progressDoc.data().progress >= progressDoc.data().questions.length || (progressDoc.data().mode === "lives" && progressDoc.data().incorrect.length >= progressDoc.data().lives)) { } 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") throw new functions.https.HttpsError("permission-denied", "Progress already completed")
} else { } else {
currentIndex = progressDoc.data().progress; const currentIndex = progressDoc.data().progress;
currentVocab = progressDoc.data().questions[currentIndex]; const currentVocab = progressDoc.data().questions[currentIndex];
let answerDocId; let answerDocId;
@@ -326,6 +445,7 @@ exports.processAnswer = functions.https.onCall((data, context) => {
mode: mode, mode: mode,
correct: isCorrectAnswer, correct: isCorrectAnswer,
correctAnswers: splitCorrectAnswers, correctAnswers: splitCorrectAnswers,
currentVocabId: currentVocab,
moreAnswers: false, moreAnswers: false,
nextPrompt: null, nextPrompt: null,
progress: docData.progress, progress: docData.progress,
@@ -364,13 +484,12 @@ exports.processAnswer = functions.https.onCall((data, context) => {
returnData.totalIncorrect = docData.incorrect.length; returnData.totalIncorrect = docData.incorrect.length;
} }
if (mode === "lives") returnData.lives = docData.lives;
if (!returnData.moreAnswers) { if (!returnData.moreAnswers) {
if (docData.progress >= docData.questions.length || (mode === "lives" && docData.incorrect.length >= docData.lives)) { if (docData.progress >= docData.questions.length || (mode === "lives" && docData.incorrect.length >= docData.lives)) {
const duration = Date.now() - docData.start_time; const duration = Date.now() - docData.start_time;
docData.duration = duration; docData.duration = duration;
returnData.duration = duration; returnData.duration = duration;
returnData.incorrectAnswers = docData.incorrect;
transaction.set(progressDocId, docData); transaction.set(progressDocId, docData);
return returnData; return returnData;
@@ -427,10 +546,16 @@ exports.processAnswer = functions.https.onCall((data, context) => {
* @return {promise} The promise from setting the target user's admin custom auth claim. * @return {promise} The promise from setting the target user's admin custom auth claim.
*/ */
exports.setAdmin = functions.https.onCall(async (data, context) => { exports.setAdmin = functions.https.onCall(async (data, context) => {
const uid = context.auth.uid; // const uid = context.auth.uid;
const isAdmin = context.auth.tokens.admin; // const isAdmin = context.auth.tokens.admin;
// const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";//nobVRmshkZNkrPbwgmPqNYrk55v2
// const isAdmin = true; const isAdmin = true;
if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
const targetUser = data.targetUser; const targetUser = data.targetUser;
const adminState = data.adminState; const adminState = data.adminState;
@@ -453,15 +578,21 @@ exports.setAdmin = functions.https.onCall(async (data, context) => {
* @param {object} data The data passed to the function. * @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 {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. * @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. * @return {boolean} true, to show the function has succeeded.
*/ */
exports.addSetToGroup = functions.https.onCall((data, context) => { exports.addSetToGroup = functions.https.onCall((data, context) => {
const uid = context.auth.uid; // const uid = context.auth.uid;
const isAdmin = context.auth.token.admin; // const isAdmin = context.auth.token.admin;
const auth = context.auth; // const auth = context.auth;
// const uid = "user_01"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
// const isAdmin = false; const isAdmin = false;
// const auth = { uid: uid }; const auth = { uid: uid };
if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
const groupId = data.groupId; const groupId = data.groupId;
const setId = data.setId; const setId = data.setId;
@@ -473,6 +604,7 @@ exports.addSetToGroup = functions.https.onCall((data, context) => {
return transaction.get(setDocId).then((setDoc) => { return transaction.get(setDocId).then((setDoc) => {
return transaction.get(userGroupDocId).then((userGroupDoc) => { return transaction.get(userGroupDocId).then((userGroupDoc) => {
const userRole = userGroupDoc.data().role; const userRole = userGroupDoc.data().role;
if (auth && (setDoc.data().public || setDoc.data().owner == uid) && (userRole == "contributor" || userRole == "owner" || isAdmin)) { if (auth && (setDoc.data().public || setDoc.data().owner == uid) && (userRole == "contributor" || userRole == "owner" || isAdmin)) {
let setDocData = setDoc.data(); let setDocData = setDoc.data();
if (setDocData.groups != null && setDocData.groups.includes(groupId)) { if (setDocData.groups != null && setDocData.groups.includes(groupId)) {
@@ -489,14 +621,20 @@ exports.addSetToGroup = functions.https.onCall((data, context) => {
setDocData.groups.push(groupId); setDocData.groups.push(groupId);
groupDocData.sets.push(setId); groupDocData.sets.push(setId);
transaction.set( setDocData.public = true;
setDocId,
setDocData, return Promise.all(
); [
return transaction.set( transaction.set(
groupDocId, setDocId,
groupDocData, setDocData,
); ),
transaction.set(
groupDocId,
groupDocData,
)
]
).then(() => true);
}); });
} }
} else { } else {
@@ -515,12 +653,18 @@ exports.addSetToGroup = functions.https.onCall((data, context) => {
* @return {promise} The promise from setting the group's updated data. * @return {promise} The promise from setting the group's updated data.
*/ */
exports.removeSetFromGroup = functions.https.onCall((data, context) => { exports.removeSetFromGroup = functions.https.onCall((data, context) => {
const uid = context.auth.uid; // const uid = context.auth.uid;
const isAdmin = context.auth.token.admin; // const isAdmin = context.auth.token.admin;
const auth = context.auth; // const auth = context.auth;
// const uid = "user_01"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
// const isAdmin = false; const isAdmin = false;
// const auth = { uid: uid }; const auth = { uid: uid };
if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
const groupId = data.groupId; const groupId = data.groupId;
const setId = data.setId; const setId = data.setId;
@@ -542,14 +686,16 @@ exports.removeSetFromGroup = functions.https.onCall((data, context) => {
let groupDocData = groupDoc.data(); let groupDocData = groupDoc.data();
groupDocData.sets = groupDocData.sets.filter(item => item !== setId); groupDocData.sets = groupDocData.sets.filter(item => item !== setId);
transaction.set( return Promise.all([
setDocId, transaction.set(
setDocData, setDocId,
); setDocData,
return transaction.set( ),
groupDocId, transaction.set(
groupDocData, groupDocId,
); groupDocData,
)
]).then(() => true);
}); });
} }
} else { } else {
@@ -563,35 +709,59 @@ exports.removeSetFromGroup = functions.https.onCall((data, context) => {
/** /**
* Changes an existing user's membership status of a group in the groups collection * 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. * in Firestore, after it has been changed in the users collection.
* NOTE: Can't be unit tested. * @param {object} change The change object from the function trigger.
* @return {promise} The promise from setting the group's updated data. * @param {object} context The context object from the function trigger.
* @return {boolean} Returns true on completion.
*/ */
exports.userGroupRoleChanged = functions.firestore.document("users/{userId}/groups/{groupId}") async function updateUserGroupRole(snap, context) {
.onWrite((change, context) => { await db.runTransaction((transaction) => {
return db.runTransaction((transaction) => { const groupDocId = db.collection("groups").doc(context.params.groupId);
const groupDocId = db.collection("groups").doc(context.params.groupId); return transaction.get(groupDocId).then((groupDoc) => {
return transaction.get(groupDocId).then((groupDoc) => { let groupData = groupDoc.data();
let groupData = groupDoc.data(); if (typeof groupData === "undefined") {
if (typeof groupData === "undefined") { throw new functions.https.HttpsError("not-found", "Group doesn't exist");
throw new functions.https.HttpsError("not-found", "Group doesn't exist"); }
} if (typeof groupData.users === "undefined") {
if (typeof groupData.users === "undefined") { groupData.users = {};
groupData.users = {}; }
}
if (change.after.data().role) { if (typeof snap !== "undefined" && typeof snap.data() !== "undefined" && typeof snap.data().role !== "undefined") {
groupData.users[context.params.userId] = change.after.data().role; groupData.users[context.params.userId] = snap.data().role;
} else { } else {
delete groupData.users[context.params.userId]; delete groupData.users[context.params.userId];
} }
return transaction.set( return transaction.set(
groupDocId, groupDocId,
groupData groupData
); );
});
}); });
}); });
return true;
}
/**
* Changes an existing user's membership status of a group in the groups collection
* in Firestore, after it has been created in the users collection.
* NOTE: Can't be unit tested.
* @return {boolean} Returns true on completion.
*/
exports.userGroupRoleCreated = functions.firestore.document("users/{userId}/groups/{groupId}")
.onCreate(async (snap, context) => {
return updateUserGroupRole(snap, context);
});
/**
* Changes an existing user's membership status of a group in the groups collection
* in Firestore, after it has been updated in the users collection.
* NOTE: Can't be unit tested.
* @return {boolean} Returns true on completion.
*/
exports.userGroupRoleUpdated = functions.firestore.document("users/{userId}/groups/{groupId}")
.onUpdate(async (change, context) => {
return updateUserGroupRole(change.after, context);
});
/** /**
* Generates a random, unused group join code. * Generates a random, unused group join code.
* @return {string} The join code. * @return {string} The join code.
@@ -613,8 +783,14 @@ async function generateJoinCode() {
* @return {string} The ID of the new group's document in the groups collection. * @return {string} The ID of the new group's document in the groups collection.
*/ */
exports.createGroup = functions.https.onCall(async (data, context) => { exports.createGroup = functions.https.onCall(async (data, context) => {
const uid = context.auth.uid; // const uid = context.auth.uid;
// const uid = "user_01"; const uid = "M3JPrFRH6Fdo8XMUbF0l2zVZUCH3";
if (context.app == undefined && !LOCAL_TESTING) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called from an App Check verified app.");
}
const joinCode = await generateJoinCode(); const joinCode = await generateJoinCode();
@@ -625,26 +801,31 @@ exports.createGroup = functions.https.onCall(async (data, context) => {
join_code: joinCode, join_code: joinCode,
}); });
await db.collection("users").doc(uid).collection("groups").doc(groupDoc.id).set({ db.collection("users").doc(uid).collection("groups").doc(groupDoc.id).set({
role: "owner", role: "owner",
}); });
db.collection("join_codes").doc(joinCode).set({
group: groupDoc.id,
});
return groupDoc.id; return groupDoc.id;
}); });
/** /**
* Cleans up database after group is deleted - removes group references from user groups collections. * Cleans up database after group is deleted - removes group references from user groups collections.
* NOTE: Can't be unit tested. * NOTE: Can't be unit tested.
* @return {promise} The promise from deleting the user's group data. * @return {boolean} Returns true on completion.
*/ */
exports.groupDeleted = functions.firestore.document("groups/{groupId}") exports.groupDeleted = functions.firestore.document("groups/{groupId}")
.onDelete(async (snap, context) => { .onDelete(async (snap, context) => {
let batch = db.batch(); let batch = db.batch();
const users = snap.data().users; const users = snap.data().users;
const sets = snap.data().sets;
const joinCode = snap.data().join_code; const joinCode = snap.data().join_code;
let counter = 0; let counter = 0;
for (let [userId, role] of Object.entries(users)) { for (userId of Object.keys(users)) {
batch.delete( batch.delete(
db.collection("users").doc(userId).collection("groups").doc(context.params.groupId) db.collection("users").doc(userId).collection("groups").doc(context.params.groupId)
); );
@@ -657,26 +838,71 @@ exports.groupDeleted = functions.firestore.document("groups/{groupId}")
batch.delete(db.collection("join_codes").doc(joinCode)); batch.delete(db.collection("join_codes").doc(joinCode));
return await batch.commit(); await Promise.all([
batch.commit(),
Promise.all(sets.map((setId) => {
return db.runTransaction((transaction) => {
return transaction.get(
db.collection("sets")
.doc(setId)
)
.then((setDoc) => {
let data = setDoc.data();
if (!data.groups) {
data.groups = [];
} else {
data.groups.splice(data.groups.indexOf(context.params.groupId), 1);
}
transaction
.set(
db.collection("sets")
.doc(setId),
data
);
});
});
}))
]);
return true;
}); });
/** /**
* Cleans up database after group is deleted - removes group references from user groups collections. * Cleans up database after set is deleted - removes vocab subcollection.
* NOTE: Can't be unit tested.
* @return {promise} Returns true on completion.
*/
exports.setDeleted = functions.firestore.document("sets/{setId}")
.onDelete(async (snap, context) => {
await deleteCollection(
db,
"/sets/" + context.params.setId + "/vocab",
500
);
return true;
});
/**
* Cleans up database after progress record is deleted - removes vocab subcollections.
* NOTE: Can't be unit tested. * NOTE: Can't be unit tested.
* @return {boolean} Returns true on completion. * @return {boolean} Returns true on completion.
*/ */
exports.progressDeleted = functions.firestore.document("progress/{progressId}") exports.progressDeleted = functions.firestore.document("progress/{progressId}")
.onDelete((snap, context) => { .onDelete(async (snap, context) => {
deleteCollection( await Promise.all([
db, deleteCollection(
"/progress/" + context.params.progressId + "/terms", db,
500 "/progress/" + context.params.progressId + "/terms",
); 500
deleteCollection( ),
db, deleteCollection(
"/progress/" + context.params.progressId + "/definitions", db,
500 "/progress/" + context.params.progressId + "/definitions",
); 500
)
]);
return true; return true;
}); });