Add database cloud functions
This commit is contained in:
601
functions/index.js
Normal file
601
functions/index.js
Normal file
@@ -0,0 +1,601 @@
|
||||
/* eslint-disable indent */
|
||||
/* eslint-disable no-tabs */
|
||||
const functions = require("firebase-functions");
|
||||
const admin = require("firebase-admin");
|
||||
admin.initializeApp();
|
||||
const db = admin.firestore();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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.
|
||||
* @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.
|
||||
* @return {string} progressId The ID of the created progress document.
|
||||
*/
|
||||
exports.createProgress = functions.https.onCall((data, context) => {
|
||||
// const uid = context.auth.uid;
|
||||
const uid = "W3eFM5CcDqtocwQ37QGOQSCaWFFj";
|
||||
|
||||
const switchLanguage = data.switch_language;
|
||||
const setId = data.set_id;
|
||||
|
||||
const correct = [];
|
||||
const incorrect = [];
|
||||
const mark = 0;
|
||||
const progress = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
const setDocId = db.collection("sets").doc(setId);
|
||||
const setVocabCollectionId = db
|
||||
.collection("sets").doc(setId)
|
||||
.collection("vocab");
|
||||
const progressDocId = db
|
||||
.collection("progress").doc();
|
||||
|
||||
|
||||
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 {
|
||||
const setTitle = setDoc.data().title;
|
||||
|
||||
return transaction.get(setVocabCollectionId).then((setVocab) => {
|
||||
let questions = [];
|
||||
|
||||
setVocab.forEach((doc) => {
|
||||
const vocabId = doc.id;
|
||||
|
||||
const terms = {
|
||||
"item": doc.data().term,
|
||||
"sound": doc.data().sound,
|
||||
};
|
||||
const definitions = {
|
||||
"item": doc.data().definition,
|
||||
};
|
||||
|
||||
questions.push(vocabId);
|
||||
|
||||
transaction.set(
|
||||
progressDocId.collection("terms").doc(vocabId),
|
||||
terms
|
||||
);
|
||||
transaction.set(
|
||||
progressDocId.collection("definitions").doc(vocabId),
|
||||
definitions
|
||||
);
|
||||
});
|
||||
|
||||
transaction.set(
|
||||
progressDocId,
|
||||
{
|
||||
questions: shuffleArray(questions),
|
||||
correct: correct,
|
||||
incorrect: incorrect,
|
||||
mark: mark,
|
||||
progress: progress,
|
||||
start_time: startTime,
|
||||
set_title: setTitle,
|
||||
uid: uid,
|
||||
switch_language: switchLanguage,
|
||||
duration: null,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
progressId: 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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 = "W3eFM5CcDqtocwQ37QGOQSCaWFFj";
|
||||
|
||||
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 (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 {
|
||||
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) => {
|
||||
// TODO: rename due to conflict with var passed to cloud fn
|
||||
const data = progressDoc.data();
|
||||
const correctAnswers = answerDoc.data().item;
|
||||
const splitCorrectAnswers = correctAnswers.replace(" ", "").split("/");
|
||||
const isCorrectAnswer = splitCorrectAnswers.includes(inputAnswer.replace(" ", ""));
|
||||
|
||||
data.progress++;
|
||||
|
||||
if (isCorrectAnswer) {
|
||||
data.correct.push(currentVocab);
|
||||
} else {
|
||||
data.incorrect.push(currentVocab);
|
||||
data.questions.push(currentVocab);
|
||||
const doneQuestions = data.questions.slice(0,data.progress);
|
||||
const notDoneQuestions = data.questions.slice(data.progress);
|
||||
data.questions = doneQuestions.concat(shuffleArray(notDoneQuestions));
|
||||
}
|
||||
|
||||
var returnData = {
|
||||
correct: isCorrectAnswer,
|
||||
correctAnswers: splitCorrectAnswers,
|
||||
nextPrompt: null,
|
||||
progress: data.progress,
|
||||
totalQuestions: data.questions.length,
|
||||
totalCorrect: data.correct.length,
|
||||
totalIncorrect: data.incorrect.length,
|
||||
}
|
||||
|
||||
if (data.progress >= data.questions.length) {
|
||||
const duration = Date.now() - data.start_time;
|
||||
data.duration = duration;
|
||||
returnData.duration = duration;
|
||||
console.log("duration: " + data.duration + " // start time: " + data.start_time);
|
||||
transaction.set(progressDocId, data);
|
||||
return returnData;
|
||||
} else {
|
||||
const nextVocabId = data.questions[data.progress];
|
||||
|
||||
if (data.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, data);
|
||||
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, data);
|
||||
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 admin = context.auth.tokens.admin;
|
||||
const uid = "W3eFM5CcDqtocwQ37QGOQSCaWFFj";
|
||||
const isAdmin = false;
|
||||
|
||||
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 = "W3eFM5CcDqtocwQ37QGOQSCaWFFj";
|
||||
const isAdmin = true;
|
||||
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 = "W3eFM5CcDqtocwQ37QGOQSCaWFFj";
|
||||
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;
|
||||
console.log(context.auth);
|
||||
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.
|
||||
* @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 {
|
||||
console.log("RETURNING");
|
||||
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 = "W3eFM5CcDqtocwQ37QGOQSCaWFFj";
|
||||
|
||||
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.
|
||||
* @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.
|
||||
* @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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user