mirror of
https://github.com/brendan-ch/project-inter-server.git
synced 2026-04-17 16:00:32 +00:00
217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
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<boolean> {
|
|
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) {
|
|
|
|
}
|
|
}
|