diff --git a/schema.graphqls b/schema.graphqls index f874943..6db52e7 100644 --- a/schema.graphqls +++ b/schema.graphqls @@ -66,6 +66,7 @@ type Query { system(id: ID): System isNotificationScheduled(input: NotificationInput!): Boolean + secondsThresholdForNotification(input: NotificationInput!): Int } # Mutations @@ -78,6 +79,7 @@ input NotificationInput { deviceId: ID! shuttleId: ID! stopId: ID! + secondsThreshold: Int } type NotificationResponse { @@ -90,4 +92,5 @@ type Notification { deviceId: ID! shuttleId: ID! stopId: ID! + secondsThreshold: Int } diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 4ccf110..8789cdd 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -3,14 +3,26 @@ import { TupleKey } from "../../types/TupleKey"; import { IEta } from "../../entities/entities"; import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender"; -export interface ScheduledNotificationData { +export interface NotificationLookupArguments { deviceId: string; shuttleId: string; stopId: string; } +export interface NotificationSchedulingArguments extends NotificationLookupArguments { + /** + * Value which specifies the ETA of the shuttle for when + * the notification should fire. + * For example, a secondsThreshold of 180 would mean that the notification + * fires when the ETA drops below 3 minutes. + */ + secondsThreshold: number; +} + +type DeviceIdSecondsThresholdAssociation = { [key: string]: number }; + export class ETANotificationScheduler { - public readonly secondsThresholdForNotificationToFire = 180; + public static readonly defaultSecondsThresholdForNotificationToFire = 180; constructor(private repository: GetterRepository, private appleNotificationSender = new AppleNotificationSender() @@ -26,11 +38,12 @@ export class ETANotificationScheduler { * 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]: Set } = {} + private deviceIdsToDeliverTo: { [key: string]: DeviceIdSecondsThresholdAssociation } = {} - private async sendEtaNotificationImmediately(notificationData: ScheduledNotificationData): Promise { + private async sendEtaNotificationImmediately(notificationData: NotificationSchedulingArguments): Promise { const { deviceId, shuttleId, stopId } = notificationData; const shuttle = await this.repository.getShuttleById(shuttleId); @@ -61,33 +74,37 @@ export class ETANotificationScheduler { private async etaSubscriberCallback(eta: IEta) { const tuple = new TupleKey(eta.shuttleId, eta.stopId); - if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { + const tupleKey = tuple.toString(); + if (this.deviceIdsToDeliverTo[tupleKey] === undefined) { return; } const deviceIdsToRemove = new Set(); - for (let deviceId of this.deviceIdsToDeliverTo[tuple.toString()].values()) { - const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); + for (let deviceId of Object.keys(this.deviceIdsToDeliverTo[tupleKey])) { + const scheduledNotificationData: NotificationSchedulingArguments = { + 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) => { - this.deviceIdsToDeliverTo[tuple.toString()].delete(deviceId); + delete this.deviceIdsToDeliverTo[tupleKey][deviceId] }); } - private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { - if (eta.secondsRemaining > this.secondsThresholdForNotificationToFire) { + private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(notificationObject: NotificationSchedulingArguments, etaSecondsRemaining: number) { + if (etaSecondsRemaining > notificationObject.secondsThreshold) { return false; } - return await this.sendEtaNotificationImmediately({ - deviceId, - shuttleId: eta.shuttleId, - stopId: eta.stopId, - }); + return await this.sendEtaNotificationImmediately(notificationObject); } /** @@ -95,13 +112,16 @@ export class ETANotificationScheduler { * @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 }: ScheduledNotificationData) { + public async scheduleNotification({ deviceId, shuttleId, stopId, secondsThreshold }: NotificationSchedulingArguments) { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { - this.deviceIdsToDeliverTo[tuple.toString()] = new Set(); + this.deviceIdsToDeliverTo[tuple.toString()] = {}; } - this.deviceIdsToDeliverTo[tuple.toString()].add(deviceId); + + this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = secondsThreshold; this.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); @@ -113,49 +133,52 @@ export class ETANotificationScheduler { * @param shuttleId Shuttle ID of the ETA object. * @param stopId Stop ID of the ETA object. */ - public async cancelNotificationIfExists({ deviceId, shuttleId, stopId }: ScheduledNotificationData) { + public async cancelNotificationIfExists({ deviceId, shuttleId, stopId }: NotificationLookupArguments) { const tupleKey = new TupleKey(shuttleId, stopId); if ( this.deviceIdsToDeliverTo[tupleKey.toString()] === undefined - || !this.deviceIdsToDeliverTo[tupleKey.toString()].has(deviceId) + || !(deviceId in this.deviceIdsToDeliverTo[tupleKey.toString()]) ) { return; } - this.deviceIdsToDeliverTo[tupleKey.toString()].delete(deviceId); + delete this.deviceIdsToDeliverTo[tupleKey.toString()][deviceId]; } /** * Check whether the notification is scheduled. - * @param deviceId - * @param shuttleId - * @param stopId */ - public isNotificationScheduled({ deviceId, shuttleId, stopId }: ScheduledNotificationData): boolean { + 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 false; + return null; } - return this.deviceIdsToDeliverTo[tuple.toString()].has(deviceId); + 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: ScheduledNotificationData[] = []; + public async getAllScheduledNotificationsForDevice(deviceId: string): Promise { + const scheduledNotifications: NotificationSchedulingArguments[] = []; for (const key of Object.keys(this.deviceIdsToDeliverTo)) { - if (this.deviceIdsToDeliverTo[key].has(deviceId)) { + 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, }); } } diff --git a/src/notifications/senders/AppleNotificationSender.ts b/src/notifications/senders/AppleNotificationSender.ts index 80c6f2a..d64df98 100644 --- a/src/notifications/senders/AppleNotificationSender.ts +++ b/src/notifications/senders/AppleNotificationSender.ts @@ -96,7 +96,7 @@ export class AppleNotificationSender { req.setEncoding('utf8'); await new Promise((resolve, reject) => { - req.on('response', (headers, flags) => { + req.on('response', (headers, _flags) => { if (headers[":status"] !== 200) { reject(`APNs request failed with status ${headers[":status"]}`); } @@ -106,6 +106,7 @@ export class AppleNotificationSender { req.write(JSON.stringify({ aps: { alert: notificationAlertArguments, + sound: "default" } })); req.end(); @@ -130,12 +131,11 @@ export class AppleNotificationSender { const path = "/3/device/" + deviceId; const fullUrl = hostToUse + path; - const constructedObject = { + return { fullUrl, host: hostToUse, path, - } - return constructedObject; + }; } } diff --git a/src/resolvers/MutationResolvers.ts b/src/resolvers/MutationResolvers.ts index c1a4ce3..ac8e1ea 100644 --- a/src/resolvers/MutationResolvers.ts +++ b/src/resolvers/MutationResolvers.ts @@ -1,5 +1,9 @@ -import { NotificationInput, NotificationResponse, Resolvers } from "../generated/graphql"; +import { NotificationResponse, Resolvers } from "../generated/graphql"; import { ServerContext } from "../ServerContext"; +import { + ETANotificationScheduler, + NotificationSchedulingArguments +} from "../notifications/schedulers/ETANotificationScheduler"; export const MutationResolvers: Resolvers = { Mutation: { @@ -19,7 +23,14 @@ export const MutationResolvers: Resolvers = { } } - await context.notificationService.scheduleNotification(args.input); + const notificationData: NotificationSchedulingArguments = { + ...args.input, + secondsThreshold: typeof args.input.secondsThreshold === 'number' + ? args.input.secondsThreshold + : ETANotificationScheduler.defaultSecondsThresholdForNotificationToFire, + } + + await context.notificationService.scheduleNotification(notificationData); const response: NotificationResponse = { message: "Notification scheduled", diff --git a/src/resolvers/QueryResolvers.ts b/src/resolvers/QueryResolvers.ts index 98c8965..725ae8d 100644 --- a/src/resolvers/QueryResolvers.ts +++ b/src/resolvers/QueryResolvers.ts @@ -3,10 +3,10 @@ import { Resolvers } from "../generated/graphql"; export const QueryResolvers: Resolvers = { Query: { - systems: async (parent, args, contextValue, info) => { + systems: async (_parent, args, contextValue, _info) => { return await contextValue.repository.getSystems(); }, - system: async (parent, args, contextValue, info) => { + system: async (_parent, args, contextValue, _info) => { if (!args.id) return null; const system = await contextValue.repository.getSystemById(args.id); if (system === null) return null; @@ -19,6 +19,10 @@ export const QueryResolvers: Resolvers = { isNotificationScheduled: async (_parent, args, contextValue, _info) => { const notificationData = args.input; return contextValue.notificationService.isNotificationScheduled(notificationData); - } + }, + secondsThresholdForNotification: async (_parent, args, contextValue, _info) => { + const notificationData = args.input; + return contextValue.notificationService.getSecondsThresholdForScheduledNotification(notificationData); + }, }, } diff --git a/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts b/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts index 60262c8..df528b9 100644 --- a/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts +++ b/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts @@ -65,10 +65,12 @@ describe("ETANotificationScheduler", () => { deviceId: "1", shuttleId: eta.shuttleId, stopId: eta.stopId, + secondsThreshold: 240, } const notificationData2 = { ...notificationData1, deviceId: "2", + secondsThreshold: 180, } return { eta, notificationData1, notificationData2 }; } @@ -79,7 +81,8 @@ describe("ETANotificationScheduler", () => { const notificationData = { deviceId: "1", shuttleId: "1", - stopId: "1" + stopId: "1", + secondsThreshold: 120, }; await notificationService.scheduleNotification(notificationData); @@ -117,7 +120,7 @@ describe("ETANotificationScheduler", () => { const shuttle = await addMockShuttleToRepository(repository, "1"); const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); - eta.secondsRemaining = notificationService.secondsThresholdForNotificationToFire + 100; + notificationData1.secondsThreshold = eta.secondsRemaining - 10; // Act await notificationService.scheduleNotification(notificationData1); diff --git a/test/resolvers/MutationResolverTests.test.ts b/test/resolvers/MutationResolverTests.test.ts index d4f5318..73eaae5 100644 --- a/test/resolvers/MutationResolverTests.test.ts +++ b/test/resolvers/MutationResolverTests.test.ts @@ -53,6 +53,33 @@ describe("MutationResolvers", () => { const shuttle = await addMockShuttleToRepository(context.repository, system.id); const stop = await addMockStopToRepository(context.repository, system.id); + const notificationInput = { + deviceId: "1", + shuttleId: shuttle.id, + stopId: stop.id, + secondsThreshold: 240, + }; + const response = await getServerResponse(query, notificationInput); + + assert(response.body.kind === "single"); + expect(response.body.singleResult.errors).toBeUndefined(); + + const expectedNotificationData: any = { + ...notificationInput, + } + delete expectedNotificationData.secondsThreshold; + const notificationResponse = response.body.singleResult.data?.scheduleNotification as any; + expect(notificationResponse?.success).toBe(true); + expect(notificationResponse?.data).toEqual(expectedNotificationData); + + expect(context.notificationService.getSecondsThresholdForScheduledNotification(expectedNotificationData)).toBe(240); + }); + + it("adds a notification with the default seconds threshold if none is provided", async () => { + const system = await addMockSystemToRepository(context.repository); + const shuttle = await addMockShuttleToRepository(context.repository, system.id); + const stop = await addMockStopToRepository(context.repository, system.id); + const notificationInput = { deviceId: "1", shuttleId: shuttle.id, @@ -65,9 +92,8 @@ describe("MutationResolvers", () => { const notificationResponse = response.body.singleResult.data?.scheduleNotification as any; expect(notificationResponse?.success).toBe(true); - expect(notificationResponse?.data).toEqual(notificationInput); - expect(context.notificationService.isNotificationScheduled(notificationInput)).toBe(true); + expect(context.notificationService.getSecondsThresholdForScheduledNotification(notificationInput)).toBe(180); }); it("fails if the shuttle ID doesn't exist", async () => { @@ -118,23 +144,29 @@ describe("MutationResolvers", () => { const shuttle = await addMockShuttleToRepository(context.repository, system.id); const stop = await addMockStopToRepository(context.repository, system.id); - const notificationInput = { + const notificationInput: any = { deviceId: "1", shuttleId: shuttle.id, stopId: stop.id, + secondsThreshold: 180, } await context.notificationService.scheduleNotification(notificationInput); - const response = await getServerResponse(query, notificationInput); + const notificationLookup = { + ...notificationInput + } + delete notificationLookup.secondsThreshold; + + const response = await getServerResponse(query, notificationLookup); assert(response.body.kind === "single"); expect(response.body.singleResult.errors).toBeUndefined(); const notificationResponse = response.body.singleResult.data?.cancelNotification as any; expect(notificationResponse.success).toBe(true); - expect(notificationResponse.data).toEqual(notificationInput); + expect(notificationResponse.data).toEqual(notificationLookup); - expect(context.notificationService.isNotificationScheduled(notificationInput)).toBe(false); + expect(context.notificationService.isNotificationScheduled(notificationLookup)).toBe(false); }); it("fails if the notification doesn't exist", async () => { diff --git a/test/resolvers/QueryResolverTests.test.ts b/test/resolvers/QueryResolverTests.test.ts index 8b0b60e..00007a7 100644 --- a/test/resolvers/QueryResolverTests.test.ts +++ b/test/resolvers/QueryResolverTests.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { generateMockSystems } from "../testHelpers/mockDataGenerators"; import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers"; import assert = require("node:assert"); -import { ScheduledNotificationData } from "../../src/notifications/schedulers/ETANotificationScheduler"; +import { NotificationSchedulingArguments } from "../../src/notifications/schedulers/ETANotificationScheduler"; import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; // See Apollo documentation for integration test guide @@ -96,32 +96,37 @@ describe("QueryResolvers", () => { }); }); - describe("isNotificationScheduled", () => { + describe("isNotificationScheduled and secondsThresholdForNotification", () => { const query = ` query IsNotificationScheduled($input: NotificationInput!) { isNotificationScheduled(input: $input) + secondsThresholdForNotification(input: $input) } - ` + `; - it("returns true if the notification is scheduled", async () => { + it("returns correct data if the notification is scheduled", async () => { // Arrange const shuttle = await addMockShuttleToRepository(context.repository, "1"); const stop = await addMockStopToRepository(context.repository, "1") - const notification: ScheduledNotificationData = { + const notification: NotificationSchedulingArguments = { shuttleId: shuttle.id, stopId: stop.id, deviceId: "1", + secondsThreshold: 240, }; await context.notificationService.scheduleNotification(notification); + const notificationLookup: any = { + ...notification, + } + delete notificationLookup.secondsThreshold; + // Act const response = await holder.testServer.executeOperation({ query, variables: { - input: { - ...notification, - }, + input: notificationLookup, } }, { contextValue: context, @@ -130,10 +135,11 @@ describe("QueryResolvers", () => { // Assert assert(response.body.kind === "single"); expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.secondsThresholdForNotification).toEqual(240); expect(response.body.singleResult.data?.isNotificationScheduled).toBe(true); }); - it("returns false if the notification isn't scheduled", async () => { + it("returns false/null data if the notification isn't scheduled", async () => { // Act const response = await holder.testServer.executeOperation({ query, @@ -152,6 +158,7 @@ describe("QueryResolvers", () => { assert(response.body.kind === "single"); expect(response.body.singleResult.errors).toBeUndefined(); expect(response.body.singleResult.data?.isNotificationScheduled).toBe(false); + expect(response.body.singleResult.data?.secondsThresholdForNotification).toBe(null); }); }); });