Source

db/firebase/firebase-services.js

const { GuildMember, } = require('discord.js');
const admin = require('firebase-admin');

/**
 * The firebase services module has firebase related helper functions.
 * @module FirebaseServices
 */

/**
 * All the firebase apps in play stored by their name.
 * @type {Map<String, admin.app.App>}
 */
const apps = new Map();
module.exports.apps = apps;

/**
 * Will start an admin connection with the given name
 * @param {String} name - name of the connection
 * @param {JSON} adminSDK - the JSON file with admin config
 * @param {String} databaseURL - the database URL
 */
function initializeFirebaseAdmin(name, adminSDK, databaseURL) {
    let app = admin.initializeApp({
        credential: admin.credential.cert(adminSDK),
        databaseURL: databaseURL,
    }, name);

    apps.set(name, app);

}
module.exports.initializeFirebaseAdmin = initializeFirebaseAdmin;



/**
 * @typedef UserType
 * @property {String} type
 * @property {Boolean} isVerified
 * @property {Date} timestamp
 */

/**
 * @typedef FirebaseUser
 * @property {String} email
 * @property {String} discordId
 * @property {UserType[]} types
 */

/**
 * Retrieves a question from the db that has not already been asked at the Discord Contests, then marks the question as having been 
 * asked in the db.
 * @param {String} guildId - the id of the guild
 * @returns {Object | null} - the data object of a question or null if no more questions
 */
async function getQuestion(guildId) {
    //checks that the question has not been asked
    let questionReference = apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('questions').where('asked', '==', false).limit(1);
    let question = (await questionReference.get()).docs[0];
    //if there exists an unasked question, change its status to asked
    if (question != undefined) {
        question.ref.update({
            'asked': true,
        });
        return question.data();
    }
    return null;
}
module.exports.getQuestion = getQuestion;

/**
 * Retrieves self-care reminder from the db that has not already been sent, 
 * then marks the reminder as having been asked in the db.
 * @param {String} guildId - the guild id
 * @returns {Object | null} - the data object of a reminder or null if no more reminders
 */
async function getReminder(guildId) {
    //checks that the reminder has not been sent
    var qref = apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('reminders').where('sent', '==', false).limit(1);
    var reminder = (await qref.get()).docs[0];
    //if there reminder unsent, change its status to asked
    if (reminder != undefined) {
        reminder.ref.update({
            'sent' : true,
        });
        return reminder.data();
    }
    return null;
}
module.exports.getReminder = getReminder;


/**
 * @typedef {Object} Member
 * @property {String} email - the email of the member
 * @property {Boolean} isVerified - whether member has already verified
 * @property {String} type - role a member has in the server
 */

/**
 * Checks to see if the input email matches or is similar to emails in the database
 * Returns an array of objects containing emails that match or are similar, along with the verification status of each, 
 * and returns empty array if none match
 * @param {String} email - email to check
 * @param {String} guildId - the guild id
 * @returns {Promise<Array<Member>>} - array of members with similar emails to parameter email
 */
async function checkEmail(email, guildId) {
    const snapshot = (await apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('members').get()).docs; // retrieve snapshot as an array of documents in the Firestore
    var foundEmails = [];
    snapshot.forEach(memberDoc => {
        // compare each member's email with the given email
        if (memberDoc.get('email') != null) {
            let compare = memberDoc.get('email');
            // if the member's emails is similar to the given email, retrieve and add the email, verification status, and member type of
            // the member as an object to the array
            if (compareEmails(email.split('@')[0], compare.split('@')[0])) {
                foundEmails.push({
                    email: compare,
                    types: memberDoc.get('types').map(type => type.type),
                });
            }
        }
    });
    return foundEmails;
}
module.exports.checkEmail = checkEmail;

/**
 * Uses Levenshtein Distance to determine whether two emails are within 5 Levenshtein Distance
 * @param {String} searchEmail - email to search for similar emails for
 * @param {String} dbEmail - email from db to compare to searchEmail
 * @returns {Boolean} - Whether the two emails are similar
 * @private
 */
function compareEmails(searchEmail, dbEmail) {
    // matrix to track Levenshtein Distance with
    var matrix = new Array(searchEmail.length);
    var searchEmailChars = searchEmail.split('');
    var dbEmailChars = dbEmail.split('');
    // initialize second dimension of matrix and set all elements to 0
    for (let i = 0; i < matrix.length; i++) {
        matrix[i] = new Array(dbEmail.length);
        for (let j = 0; j < matrix[i].length; j++) {
            matrix[i][j] = 0;
        }
    }
    // set all elements in the top row and left column to increment by 1
    for (let i = 1; i < searchEmail.length; i++) {
        matrix[i][0] = i;
    }
    for (let j = 1; j < dbEmail.length; j++) {
        matrix[0][j] = j;
    }
    // increment Levenshtein Distance by 1 if there is a letter inserted, deleted, or swapped; store the running tally in the corresponding
    // element of the matrix
    let substitutionCost;
    for (let j = 1; j < dbEmail.length; j++) {
        for (let i = 1; i < searchEmail.length; i++) {
            if (searchEmailChars[i] === dbEmailChars[j]) {
                substitutionCost = 0;
            } else {
                substitutionCost = 1;
            }
            matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + substitutionCost);
        }
    }
    return matrix[searchEmail.length - 1][dbEmail.length - 1] <= (Math.min(searchEmail.length, dbEmail.length) / 2);
}

/**
 * Finds the email of user with given first and last names 
 * @param {String} firstName - first name of member to match with database
 * @param {String} lastName - last name of member to match with database
 * @param {String} guildId - the guild id
 * @returns {Promise<String>} - email of given member
 * @private
 */
async function checkName(firstName, lastName, guildId) {
    const snapshot = (await apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('members').get()).docs; // snapshot of Firestore as array of documents
    snapshot.forEach(memberDoc => {
        if (memberDoc.get('firstName') != null && memberDoc.get('lastName') != null && memberDoc.get('firstName').toLowerCase() === firstName.toLowerCase()
            && memberDoc.get('lastName').toLowerCase() === lastName.toLowerCase()) { // for each document, check if first and last names match given names
            return memberDoc.get('email');
        }
    });
    return null;
}
module.exports.checkName = checkName;

/**
 * Adds a new guild member to the guild's member collection. Email is used as ID, there can be no duplicates.
 * @param {String} email - email of member verified
 * @param {String[]} types - types this user might verify for
 * @param {String} guildId - the guild id
 * @param {GuildMember} [member={}] - member verified
 * @param {String} [firstName=''] - users first name
 * @param {String} [lastName=''] - users last name
 * @async
 */
async function addUserData(email, types, guildId, member = {}, firstName = '', lastName = '') {
    var newDocument = apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('members').doc(email.toLowerCase());

    /** @type {FirebaseUser} */
    let data = {
        email: email.toLowerCase(),
        discordId: member?.id || null,
        types: types.map((type, index, array) => {
            /** @type {UserType} */
            let userType = {
                type: type,
                isVerified: false,
            };
            return userType;
        }),
        firstName: firstName,
        lastName: lastName,
    };

    await newDocument.set(data);
}
module.exports.addUserData = addUserData;

/**
 * Verifies the any event member via their email.
 * @param {String} email - the user email
 * @param {String} id - the user's discord snowflake
 * @param {String} guildId - the guild id
 * @returns {Promise<String[]>} - the types this user is verified
 * @async
 * @throws Error if the email provided was not found.
 */
async function verify(email, id, guildId) {
    let emailLowerCase = email.toLowerCase();
    var userRef = apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('members').where('email', '==', emailLowerCase).limit(1);
    var user = (await userRef.get()).docs[0];
    if (user) {
        let returnTypes = [];

        /** @type {FirebaseUser} */
        var data = user.data();

        data.types.forEach((value, index, array) => {
            if (!value.isVerified) {
                value.isVerified = true;
                value.VerifiedTimestamp = admin.firestore.Timestamp.now();
                returnTypes.push(value.type);
            }
        });

        data.discordId = id;

        user.ref.update(data);

        return returnTypes;
    } else {
        throw new Error('The email provided was not found!');
    }
}
module.exports.verify = verify;

/**
 * Attends the user via their discord id
 * @param {String} id - the user's discord snowflake
 * @param {String} guildId - the guild id
 * @returns {Promise<String[]>} - the types this user is verified
 * @async
 * @throws Error if the email provided was not found.
 */
async function attend(id, guildId) {
    var userRef = apps.get('nwPlusBotAdmin').firestore().collection('guilds').doc(guildId).collection('members').where('discordId', '==', id).limit(1);
    var user = (await userRef.get()).docs[0];

    if (user) {
        /** @type {FirebaseUser} */
        var data = user.data();

        data.types.forEach((value, index, array) => {
            if (value.isVerified) {
                value.isAttending = true;
                value.AttendingTimestamp = admin.firestore.Timestamp.now();
            }
        });

        user.ref.update(data);
    } else {
        throw new Error('The discord id provided was not found!');
    }
}
module.exports.attend = attend;