import { GetterRepository } from "../repositories/GetterRepository"; import jwt from "jsonwebtoken"; import fs from "fs"; import { TupleKey } from "../types/TupleKey"; import { IEta } from "../entities/entities"; export interface ScheduledNotificationData { deviceId: string; shuttleId: string; stopId: string; } export class NotificationService { private apnsToken: string | undefined = undefined; private _lastRefreshedTimeMs: number | undefined = undefined; get lastRefreshedTimeMs() { return this._lastRefreshedTimeMs; } constructor(private repository: GetterRepository) { this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this); this.reloadAPNsTokenIfTimePassed = this.reloadAPNsTokenIfTimePassed.bind(this); this.lastReloadedTimeForAPNsIsTooRecent = this.lastReloadedTimeForAPNsIsTooRecent.bind(this); this.sendEtaNotificationImmediately = this.sendEtaNotificationImmediately.bind(this); this.getAPNsFullUrlToUse = this.getAPNsFullUrlToUse.bind(this); this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this); this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold = this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold.bind(this); this.scheduleNotification = this.scheduleNotification.bind(this); } /** * An object of device ID arrays to deliver notifications to. * The key should be a combination of the shuttle ID and * stop ID, which can be generated using `TupleKey`. * @private */ private deviceIdsToDeliverTo: { [key: string]: string[] } = {} public reloadAPNsTokenIfTimePassed() { if (this.lastReloadedTimeForAPNsIsTooRecent()) { return; } const keyId = process.env.APNS_KEY_ID; const teamId = process.env.APNS_TEAM_ID; const privateKeyPath = process.env.APNS_KEY_PATH; if (!privateKeyPath) return; const privateKey = fs.readFileSync(privateKeyPath); const tokenHeader = { alg: "ES256", "kid": keyId, }; const now = Date.now(); const claimsPayload = { "iss": teamId, "iat": now, }; this.apnsToken = jwt.sign(claimsPayload, privateKey, { algorithm: "ES256", header: tokenHeader }); this._lastRefreshedTimeMs = now; } private lastReloadedTimeForAPNsIsTooRecent() { const thirtyMinutesMs = 1800000; return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs; } private async sendEtaNotificationImmediately({ deviceId, shuttleId, stopId }: ScheduledNotificationData): Promise { this.reloadAPNsTokenIfTimePassed(); const url = this.getAPNsFullUrlToUse(deviceId); const shuttle = await this.repository.getShuttleById(shuttleId); const stop = await this.repository.getStopById(stopId); const eta = await this.repository.getEtaForShuttleAndStopId(shuttleId, stopId); // TODO: add more specific errors if (!shuttle) { throw new Error("The shuttle given by the provided shuttleID doesn't exist."); } if (!stop) { throw new Error("The shuttle given by the provided stopId doesn't exist."); } // TODO: account for cases where ETA may not exist due to "data race" with ApiBasedRepositoryLoader if (!eta) { throw new Error("There is no ETA for this shuttle/stop."); } // Send the fetch request const bundleId = process.env.APNS_BUNDLE_ID; if (typeof bundleId !== "string") { throw new Error("APNS_BUNDLE_ID environment variable is not set correctly"); } const headers = { authorization: `bearer ${this.apnsToken}`, "apns-push-type": "alert", "apns-expiration": "0", "apns-priority": "10", "apns-topic": bundleId, }; const response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ aps: { alert: { title: "Shuttle is arriving", body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` } } }), }); const json = await response.json(); if (response.status !== 200) { console.error(`Notification failed for device ${deviceId}:`, json.reason); return false; } return true; } private getAPNsFullUrlToUse(deviceId: string) { // Construct the fetch request const devBaseUrl = "https://api.sandbox.push.apple.com" const prodBaseUrl = "https://api.push.apple.com" const path = "/3/device/" + deviceId; let urlToUse = prodBaseUrl; if (process.env.NODE_ENV !== "production") { urlToUse = devBaseUrl + path; } return urlToUse; } private async etaSubscriberCallback(eta: IEta) { const tuple = new TupleKey(eta.shuttleId, eta.stopId); // TODO: move device IDs object (with TupleKey based string) to its own class if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return; } const indicesToRemove = new Set(); await Promise.all(this.deviceIdsToDeliverTo[tuple.toString()].map(async (deviceId, index) => { const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); if (deliveredSuccessfully) { indicesToRemove.add(index); } })); this.deviceIdsToDeliverTo[tuple.toString()] = this.deviceIdsToDeliverTo[tuple.toString()].filter((_, index) => !indicesToRemove.has(index)); } private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { const secondsThresholdForNotificationToFire = 300; if (eta.secondsRemaining > secondsThresholdForNotificationToFire) { return false; } return await this.sendEtaNotificationImmediately({ deviceId, shuttleId: eta.shuttleId, stopId: eta.stopId, }); } /** * Queue a notification to be sent. * @param deviceId The device ID to send the notification to. * @param shuttleId Shuttle ID of ETA object to check. * @param stopId Stop ID of ETA object to check. */ public async scheduleNotification({ deviceId, shuttleId, stopId }: ScheduledNotificationData) { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { this.deviceIdsToDeliverTo[tuple.toString()] = [deviceId]; } else { this.deviceIdsToDeliverTo[tuple.toString()].push(deviceId); } this.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); } /** * Cancel a pending notification. * @param deviceId The device ID of the notification. * @param shuttleId Shuttle ID of the ETA object. * @param stopId Stop ID of the ETA object. */ public async cancelNotification({ deviceId, shuttleId, stopId }: ScheduledNotificationData) { } /** * Check whether the notification is scheduled. * @param deviceId * @param shuttleId * @param stopId */ public isNotificationScheduled({ deviceId, shuttleId, stopId }: ScheduledNotificationData): boolean { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return false; } return this.deviceIdsToDeliverTo[tuple.toString()].includes(deviceId); } public cancelAllNotifications(deviceId: string) { } }