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
}