import { ShuttleGetterRepository } from "../../repositories/ShuttleGetterRepository"; import { TupleKey } from "../../types/TupleKey"; import { IEta } from "../../entities/entities"; import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender"; import { NotificationLookupArguments, ScheduledNotification } from "../../repositories/NotificationRepository"; type DeviceIdSecondsThresholdAssociation = { [key: string]: number }; export class ETANotificationScheduler { public static readonly defaultSecondsThresholdForNotificationToFire = 180; constructor(private shuttleRepository: ShuttleGetterRepository, private appleNotificationSender = new AppleNotificationSender() ) { this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this); this.sendEtaNotificationImmediately = this.sendEtaNotificationImmediately.bind(this); this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this); this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold = this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold.bind(this); this.scheduleNotification = this.scheduleNotification.bind(this); this.cancelNotificationIfExists = this.cancelNotificationIfExists.bind(this); this.isNotificationScheduled = this.isNotificationScheduled.bind(this); this.getSecondsThresholdForScheduledNotification = this.getSecondsThresholdForScheduledNotification.bind(this); this.getAllScheduledNotificationsForDevice = this.getAllScheduledNotificationsForDevice.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`. * The value is a dictionary of the device ID to the stored seconds threshold. * @private */ private deviceIdsToDeliverTo: { [key: string]: DeviceIdSecondsThresholdAssociation } = {} private async sendEtaNotificationImmediately(notificationData: ScheduledNotification): Promise { const { deviceId, shuttleId, stopId } = notificationData; const shuttle = await this.shuttleRepository.getShuttleById(shuttleId); const stop = await this.shuttleRepository.getStopById(stopId); const eta = await this.shuttleRepository.getEtaForShuttleAndStopId(shuttleId, stopId); if (!shuttle) { console.warn(`Notification ${notificationData} fell through; no associated shuttle`); return false; } if (!stop) { console.warn(`Notification ${notificationData} fell through; no associated stop`); return false; } // Notification may not be sent if ETA is unavailable at the moment; // this is fine because it will be sent again when ETA becomes available if (!eta) { console.warn(`Notification ${notificationData} fell through; no associated ETA`); return false; } const notificationAlertArguments: NotificationAlertArguments = { title: "Shuttle is arriving", body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.`, } return this.appleNotificationSender.sendNotificationImmediately(deviceId, notificationAlertArguments); } private async etaSubscriberCallback(eta: IEta) { const tuple = new TupleKey(eta.shuttleId, eta.stopId); const tupleKey = tuple.toString(); if (this.deviceIdsToDeliverTo[tupleKey] === undefined) { return; } const deviceIdsToRemove = new Set(); for (let deviceId of Object.keys(this.deviceIdsToDeliverTo[tupleKey])) { const scheduledNotificationData: ScheduledNotification = { deviceId, secondsThreshold: this.deviceIdsToDeliverTo[tupleKey][deviceId], shuttleId: eta.shuttleId, stopId: eta.stopId, } const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(scheduledNotificationData, eta.secondsRemaining); if (deliveredSuccessfully) { deviceIdsToRemove.add(deviceId); } } deviceIdsToRemove.forEach((deviceId) => { delete this.deviceIdsToDeliverTo[tupleKey][deviceId] }); } private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(notificationObject: ScheduledNotification, etaSecondsRemaining: number) { if (etaSecondsRemaining > notificationObject.secondsThreshold) { return false; } return await this.sendEtaNotificationImmediately(notificationObject); } /** * 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. * @param secondsThreshold Value which specifies the ETA of the shuttle for when * the notification should fire. */ public async scheduleNotification({ deviceId, shuttleId, stopId, secondsThreshold }: ScheduledNotification) { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { this.deviceIdsToDeliverTo[tuple.toString()] = {}; } this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = secondsThreshold; this.shuttleRepository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.shuttleRepository.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 cancelNotificationIfExists({ deviceId, shuttleId, stopId }: NotificationLookupArguments) { const tupleKey = new TupleKey(shuttleId, stopId); if ( this.deviceIdsToDeliverTo[tupleKey.toString()] === undefined || !(deviceId in this.deviceIdsToDeliverTo[tupleKey.toString()]) ) { return; } delete this.deviceIdsToDeliverTo[tupleKey.toString()][deviceId]; } /** * Check whether the notification is scheduled. */ public isNotificationScheduled(lookupArguments: NotificationLookupArguments): boolean { return this.getSecondsThresholdForScheduledNotification(lookupArguments) != null; } public getSecondsThresholdForScheduledNotification({ deviceId, shuttleId, stopId }: NotificationLookupArguments): number | null { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return null; } return this.deviceIdsToDeliverTo[tuple.toString()][deviceId]; } /** * Return all scheduled notification for the given device ID. * @param deviceId */ public async getAllScheduledNotificationsForDevice(deviceId: string): Promise { const scheduledNotifications: ScheduledNotification[] = []; for (const key of Object.keys(this.deviceIdsToDeliverTo)) { if (deviceId in this.deviceIdsToDeliverTo[key]) { const tupleKey = TupleKey.fromExistingStringKey(key); const shuttleId = tupleKey.tuple[0] const stopId = tupleKey.tuple[1]; const secondsThreshold = this.deviceIdsToDeliverTo[key][deviceId]; scheduledNotifications.push({ shuttleId, stopId, deviceId, secondsThreshold, }); } } return scheduledNotifications; } }