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 { 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 }