Sherlock-backend / components / notifications.js
notifications.js
Raw
const {Expo, ExpoPushMessage} = require('expo-server-sdk')
const db = require('./db')
const schedule = require('node-schedule');

let expo = new Expo({
    accessToken: process.env.EXPO_ACCESS_TOKEN,
    useFcmV1: true
})

//Priority levels.
const priority = {
    low:1,
    medium:2,
    high:3
}

/**
 * Verifies validity of expo token
 * @param {string} token expo push token
 * @returns boolean
 */
function isValidExpoPushToken(token){
    return Expo.isExpoPushToken(token)
}

/**
 * Makes a list in an oxford comma style
 * @param {string[]} arr array of strings
 * @param {string} conjunction conjunction to be used "and/or/etc"
 * @param {string} ifempty what you want to be returned in event the array is empty
 * @returns formatted string
 */
function oxfordComma(arr, conjunction, ifempty){
    let l = arr.length;
    if (!l) return ifempty;
    if (l<2) return arr[0];
    if (l<3) return arr.join(` ${conjunction} `);
    arr = arr.slice();
    arr[l-1] = `${conjunction} ${arr[l-1]}`;
    return arr.join(", ");
}

/**
 * Sends notifications to all message objects.
 * @param {ExpoPushMessage[]} messages array of message objects
 * @note This also schedules a reciept job 15 minutes after notifications have been sent.
 */
async function sendPush(messages){
    let chunks = expo.chunkPushNotifications(messages);
    let tickets = [];

    //Sends chunks to Expo
    for(let chunk of chunks){
		try {
			let ticketChunk = await expo.sendPushNotificationsAsync(chunk);

			for (let i = 0; i < ticketChunk.length; i++) {
				const ticket = ticketChunk[i];
				const message = chunk[i];
				if (ticket.status === "error") {
					console.error(`[-]Error sending push for token: ${message.to}`);
					if (ticket.details.error === "DeviceNotRegistered") {
						db.deleteDeviceAdmin(message.to).then((res)=>{
                            console.log(res,message.to)
                        }).catch((e)=>{
                            console.error("[#]Error deleting unregistered device:",message.to,e)
                        })
					}
				}
			}

			tickets.push(...ticketChunk);
		} catch (error) {
			console.error("[-]Push Notifications", error);
            //Try again in 2 seconds
            setTimeout(()=>{
                sendPush(messages)
            },5000) 
		}

        const tokenAndTickets = tickets.map((ticket, index)=>({
            ticket,
            token: messages[index].to
        }))

		//Creates array of ids to fetch receipts.
		let receiptIds = [];
		for (let ticket of tokenAndTickets) {
			if (ticket.ticket.status === "ok") {
				receiptIds.push(ticket.ticket.id);
			}
		}

		let receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds);

		//Schedules job 15 minutes from current date
		const date = new Date(Date.now() + 15 * 60 * 1000);
		const receiptJob = schedule.scheduleJob(date, function () {
			pushCleanup(receiptIdChunks, tokenAndTickets);
		});
	}
}

/**
 * Handles responses from push clients(Google, Apple)
 * @param {[][]} receiptChunks matrix of receipt ids
 * @param {{}[]} tokenAndTickets array of objects with token and ticket
 */
async function pushCleanup(receiptChunks, tokenAndTickets){
    let failed = 0;
    let success = 0;
    for(let i=0;i<receiptChunks.length;i++){
        let chunk = receiptChunks[i];
        try{
            let receipts = await expo.getPushNotificationReceiptsAsync(chunk)

            for(let j=0;j<chunk.length;j++){
                let receiptId = chunk[j];
                let {status, message, details} = receipts[receiptId]

                if(status==='ok'){
                    success++
                    continue
                }else if(status==='error'){
                    if(details && details.error){
                        switch(details.error){
                            case "DeviceNotRegistered":
                                if(tokenAndTickets[i*chunk.length + j].ticket.id == receiptId){
									//If the device is not registered, delete it from the database
									await db.deleteDeviceAdmin(tokenAndTickets[i*chunk.length + j].token)
										.then((res) => {
                                            failed++
										})
										.catch((e) => {
											console.error("Error deleting unregistered device:", tokenAndTickets[i*chunk.length+j], e);
										});
								}
                                break;
                            case "InvalidCredentials":
                                console.error("Invalid Credentials on receipt.")
                                break;
                            case "MessageTooBig":
                                console.warn("Notification message too big.")
                                break;
                            case "MessageRateExceeded":
                                console.warn("Notification rate exceeded.")
                                break;
                            case "ExpoError":
                                console.warn("Notification error: Expo failure.")
                                break;
                            case "ProviderError":
                                console.warn("Notification error: Provider failure.")
                                break
                            default:
                                console.warn("Notification error:",details.error)

                        }
                    
                    }
                }
            }

            console.log(`Push notification cleanup complete. ${success} successful, ${failed} failed.`)
        }catch(e){
            console.error("Error cleaning up receipts:",e)
        }
    }
}

/**
 * Notifies all users subscribed to crime topics.
 * @param {{}} counts object of counts of each class in last parse
 * @returns success message on success
 */
function notifyKPD(counts){
    return new Promise((resolve, reject)=>{
        //Splits counts into low, medium, high
        const groupCounts = {
            all: counts[1]+counts[2]+counts[3]+counts[4]+counts[5],
            high: counts[4]+counts[5],
            medium: counts[2]+counts[3],
            low: counts[1]
        }

        function totalForUser(topics, groupCounts){
            //Removes crime_ prefix in a topic for ease of use
            const normalizedTopics = topics.map(topic => topic.replace(/^crime_/, ''))

            //Sets total crime to sum of them all
            return normalizedTopics.map(sev => groupCounts[sev] || 0).reduce((sum, count)=>sum+count,0);
        }

        let messages = []

        db.getUsersByTopics(['crime_all','crime_high','crime_medium','crime_low']).then((users)=>{
            for(const user of users){
                const { tokens, topics } = user;

                if(topics.includes('crime_all')){
                    for(const token of tokens){
                        messages.push({
                            to:token,
                            sound: 'default',
                            title:`๐Ÿ” ${groupCounts.all} New KPD Reports!`,
                            body:'Tap to check these suckers out.๐Ÿ‘ฃ'
                        })
                    }
                }else{
                    let count = totalForUser(topics, groupCounts);
                    let normalizedTopics = topics.map(topic => topic.replace(/^crime_/, ''))
                    normalizedTopics = normalizedTopics.filter(item=>groupCounts[item]>0)

                    //Sort topics by priority
                    normalizedTopics.sort((a,b)=>{
                        return priority[b]-priority[a];
                    })

                    //Creates array of strings like "3 high", "2 medium"
                    let conjoin = normalizedTopics.map((item)=>{
                        return `${groupCounts[item]} ${item}`
                    })

                    //If there are no crimes for the topics the user is 
                    //subscrbibed to, move on
                    if(count == 0) continue;

                    for(const token of tokens){
                        messages.push({
                            to:token,
                            sound:'default',
                            title:`๐Ÿ” ${groupCounts.all} New KPD Reports!`,
                            body:`${oxfordComma(conjoin, 'and', `I don't know why you got this. None are relevant for you.`)}`,
                        })
                    }
                }
            }

            sendPush(messages)
            db.updateTopicLastNotified(['crime_all','crime_high','crime_medium','crime_low']).catch((error)=>{
                console.error("Error updating last notified for KPD topics:",error)
            })

            // console.log("TICKETS:",tickets.length)

            console.log("Notified",users.length,"user(s) and",messages.length,"devices.")
            resolve("Notifications successfully sent for KPD CIP")
        }).catch((error)=>{
            console.log("KPD Notify error",error)
            reject(error)
        })

    })
}

module.exports = {
    isValidExpoPushToken,
    notifyKPD,
    sendPush,
    oxfordComma
}