From 8874704acbcee22022026883b06aeae4302e905b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 14:55:34 -0700 Subject: [PATCH 01/12] add optional secondsThreshold value --- .../schedulers/ETANotificationScheduler.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 4ccf110..480fc2b 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -7,6 +7,17 @@ export interface ScheduledNotificationData { deviceId: string; shuttleId: string; stopId: string; + + /** + * 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. + * + * Optional to retain backwards compatibility with devices on the + * older API version. + */ + secondsThreshold?: number; } export class ETANotificationScheduler { From 07b80f2012288cf0dc971ce892786021a1f9a848 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 14:59:03 -0700 Subject: [PATCH 02/12] update tests to include the secondsThreshold argument --- .../schedulers/ETANotificationSchedulerTests.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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); From 50636e558241cb356167bb9bd2098cc14622d49b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:09:32 -0700 Subject: [PATCH 03/12] change set of device IDs to object of device IDs to seconds threshold --- .../schedulers/ETANotificationScheduler.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 480fc2b..76c6027 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -20,8 +20,10 @@ export interface ScheduledNotificationData { secondsThreshold?: number; } +type DeviceIdSecondsThresholdAssociation = { [key: string]: number }; + export class ETANotificationScheduler { - public readonly secondsThresholdForNotificationToFire = 180; + public readonly defaultSecondsThresholdForNotificationToFire = 180; constructor(private repository: GetterRepository, private appleNotificationSender = new AppleNotificationSender() @@ -37,9 +39,10 @@ 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 { const { deviceId, shuttleId, stopId } = notificationData; @@ -77,7 +80,7 @@ export class ETANotificationScheduler { } const deviceIdsToRemove = new Set(); - for (let deviceId of this.deviceIdsToDeliverTo[tuple.toString()].values()) { + for (let deviceId of Object.keys(this.deviceIdsToDeliverTo[tuple.toString()])) { const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); if (deliveredSuccessfully) { deviceIdsToRemove.add(deviceId); @@ -85,12 +88,12 @@ export class ETANotificationScheduler { } deviceIdsToRemove.forEach((deviceId) => { - this.deviceIdsToDeliverTo[tuple.toString()].delete(deviceId); + delete this.deviceIdsToDeliverTo[tuple.toString()][deviceId] }); } private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { - if (eta.secondsRemaining > this.secondsThresholdForNotificationToFire) { + if (eta.secondsRemaining > this.defaultSecondsThresholdForNotificationToFire) { return false; } @@ -106,13 +109,20 @@ 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 }: ScheduledNotificationData) { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { - this.deviceIdsToDeliverTo[tuple.toString()] = new Set(); + this.deviceIdsToDeliverTo[tuple.toString()] = {}; + } + + if (secondsThreshold !== undefined) { + this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = secondsThreshold; + } else { + this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = this.defaultSecondsThresholdForNotificationToFire; } - this.deviceIdsToDeliverTo[tuple.toString()].add(deviceId); this.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); @@ -128,12 +138,12 @@ export class ETANotificationScheduler { 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]; } /** @@ -147,7 +157,7 @@ export class ETANotificationScheduler { if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return false; } - return this.deviceIdsToDeliverTo[tuple.toString()].has(deviceId); + return deviceId in this.deviceIdsToDeliverTo[tuple.toString()]; } /** @@ -158,7 +168,7 @@ export class ETANotificationScheduler { const scheduledNotifications: ScheduledNotificationData[] = []; 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]; From 1f8d4bfbd4d0521a61b2d4fe1e1c450c6e099d1b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:11:47 -0700 Subject: [PATCH 04/12] extract tuple.toString to variable --- src/notifications/schedulers/ETANotificationScheduler.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 76c6027..4e0227a 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -75,12 +75,13 @@ 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 Object.keys(this.deviceIdsToDeliverTo[tuple.toString()])) { + for (let deviceId of Object.keys(this.deviceIdsToDeliverTo[tupleKey])) { const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); if (deliveredSuccessfully) { deviceIdsToRemove.add(deviceId); @@ -88,7 +89,7 @@ export class ETANotificationScheduler { } deviceIdsToRemove.forEach((deviceId) => { - delete this.deviceIdsToDeliverTo[tuple.toString()][deviceId] + delete this.deviceIdsToDeliverTo[tupleKey][deviceId] }); } From fb49577cf6f783583fe9873369f4e021c13cebc2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:19:09 -0700 Subject: [PATCH 05/12] add logic to send notification based on a provided threshold --- .../schedulers/ETANotificationScheduler.ts | 50 +++++++++---------- src/resolvers/MutationResolvers.ts | 11 +++- test/resolvers/QueryResolverTests.test.ts | 4 +- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 4e0227a..234b310 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -3,27 +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. - * - * Optional to retain backwards compatibility with devices on the - * older API version. */ - secondsThreshold?: number; + secondsThreshold: number; } type DeviceIdSecondsThresholdAssociation = { [key: string]: number }; export class ETANotificationScheduler { - public readonly defaultSecondsThresholdForNotificationToFire = 180; + public static readonly defaultSecondsThresholdForNotificationToFire = 180; constructor(private repository: GetterRepository, private appleNotificationSender = new AppleNotificationSender() @@ -44,7 +43,7 @@ export class ETANotificationScheduler { */ 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); @@ -82,7 +81,14 @@ export class ETANotificationScheduler { const deviceIdsToRemove = new Set(); for (let deviceId of Object.keys(this.deviceIdsToDeliverTo[tupleKey])) { - const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); + 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); } @@ -93,16 +99,12 @@ export class ETANotificationScheduler { }); } - private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { - if (eta.secondsRemaining > this.defaultSecondsThresholdForNotificationToFire) { + 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); } /** @@ -113,17 +115,13 @@ export class ETANotificationScheduler { * @param secondsThreshold Value which specifies the ETA of the shuttle for when * the notification should fire. */ - public async scheduleNotification({ deviceId, shuttleId, stopId, secondsThreshold }: 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()] = {}; } - if (secondsThreshold !== undefined) { - this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = secondsThreshold; - } else { - this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = this.defaultSecondsThresholdForNotificationToFire; - } + this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = secondsThreshold; this.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); @@ -135,7 +133,7 @@ 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 @@ -153,7 +151,7 @@ export class ETANotificationScheduler { * @param shuttleId * @param stopId */ - public isNotificationScheduled({ deviceId, shuttleId, stopId }: ScheduledNotificationData): boolean { + public isNotificationScheduled({ deviceId, shuttleId, stopId }: NotificationLookupArguments): boolean { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return false; @@ -165,19 +163,21 @@ export class ETANotificationScheduler { * 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 (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/resolvers/MutationResolvers.ts b/src/resolvers/MutationResolvers.ts index c1a4ce3..93ae763 100644 --- a/src/resolvers/MutationResolvers.ts +++ b/src/resolvers/MutationResolvers.ts @@ -1,5 +1,9 @@ import { NotificationInput, NotificationResponse, Resolvers } from "../generated/graphql"; import { ServerContext } from "../ServerContext"; +import { + ETANotificationScheduler, + NotificationSchedulingArguments +} from "../notifications/schedulers/ETANotificationScheduler"; export const MutationResolvers: Resolvers = { Mutation: { @@ -19,7 +23,12 @@ export const MutationResolvers: Resolvers = { } } - await context.notificationService.scheduleNotification(args.input); + const notificationData: NotificationSchedulingArguments = { + ...args.input, + secondsThreshold: ETANotificationScheduler.defaultSecondsThresholdForNotificationToFire, + } + + await context.notificationService.scheduleNotification(notificationData); const response: NotificationResponse = { message: "Notification scheduled", diff --git a/test/resolvers/QueryResolverTests.test.ts b/test/resolvers/QueryResolverTests.test.ts index 8b0b60e..c35be13 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 @@ -108,7 +108,7 @@ describe("QueryResolvers", () => { 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", From 1321ff1347eed54e04d0abde84e929d09e2724ff Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:29:31 -0700 Subject: [PATCH 06/12] update graphql schema with updated arguments --- schema.graphqls | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/schema.graphqls b/schema.graphqls index f874943..0dcd118 100644 --- a/schema.graphqls +++ b/schema.graphqls @@ -65,21 +65,29 @@ type Query { systems: [System!]! system(id: ID): System - isNotificationScheduled(input: NotificationInput!): Boolean + isNotificationScheduled(input: NotificationLookupArguments!): Boolean + secondsThresholdForNotification(input: NotificationLookupArguments!): Int } # Mutations type Mutation { - scheduleNotification(input: NotificationInput!): NotificationResponse! - cancelNotification(input: NotificationInput!): NotificationResponse! + scheduleNotification(input: NotificationSchedulingArguments!): NotificationResponse! + cancelNotification(input: NotificationLookupArguments!): NotificationResponse! } -input NotificationInput { +input NotificationLookupArguments { deviceId: ID! shuttleId: ID! stopId: ID! } +input NotificationSchedulingArguments { + deviceId: ID! + shuttleId: ID! + stopId: ID! + secondsThreshold: Int +} + type NotificationResponse { success: Boolean! message: String! @@ -90,4 +98,5 @@ type Notification { deviceId: ID! shuttleId: ID! stopId: ID! + secondsThreshold: Int } From 14a3738fba1fbe5e20650ccf4658181357bf3055 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:39:17 -0700 Subject: [PATCH 07/12] add a public method to get the seconds threshold for a scheduled notification --- .../schedulers/ETANotificationScheduler.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 234b310..8789cdd 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -147,16 +147,17 @@ export class ETANotificationScheduler { /** * Check whether the notification is scheduled. - * @param deviceId - * @param shuttleId - * @param stopId */ - public isNotificationScheduled({ deviceId, shuttleId, stopId }: NotificationLookupArguments): 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 deviceId in this.deviceIdsToDeliverTo[tuple.toString()]; + return this.deviceIdsToDeliverTo[tuple.toString()][deviceId]; } /** From 96e7e0297b9ce24516263eee629134ded041a446 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:42:42 -0700 Subject: [PATCH 08/12] update tests for mutation resolvers --- src/resolvers/MutationResolvers.ts | 2 +- test/resolvers/MutationResolverTests.test.ts | 52 ++++++++++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/resolvers/MutationResolvers.ts b/src/resolvers/MutationResolvers.ts index 93ae763..90dd4c8 100644 --- a/src/resolvers/MutationResolvers.ts +++ b/src/resolvers/MutationResolvers.ts @@ -1,4 +1,4 @@ -import { NotificationInput, NotificationResponse, Resolvers } from "../generated/graphql"; +import { NotificationResponse, Resolvers } from "../generated/graphql"; import { ServerContext } from "../ServerContext"; import { ETANotificationScheduler, diff --git a/test/resolvers/MutationResolverTests.test.ts b/test/resolvers/MutationResolverTests.test.ts index d4f5318..83eb1ef 100644 --- a/test/resolvers/MutationResolverTests.test.ts +++ b/test/resolvers/MutationResolverTests.test.ts @@ -6,7 +6,7 @@ import { addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers"; import assert = require("node:assert"); -import { NotificationInput } from "../../src/generated/graphql"; +import { NotificationSchedulingArguments } from "../../src/generated/graphql"; describe("MutationResolvers", () => { const holder = setupTestServerHolder() @@ -25,7 +25,7 @@ describe("MutationResolvers", () => { describe("scheduleNotification", () => { const query = ` - mutation ScheduleNotification($input: NotificationInput!) { + mutation ScheduleNotification($input: NotificationSchedulingArguments!) { scheduleNotification(input: $input) { success message @@ -38,7 +38,7 @@ describe("MutationResolvers", () => { } ` - function assertFailedResponse(response: any, notificationInput: NotificationInput) { + function assertFailedResponse(response: any, notificationInput: NotificationSchedulingArguments) { assert(response.body.kind === "single"); expect(response.body.singleResult.errors).toBeUndefined(); const notificationResponse = response.body.singleResult.data?.scheduleNotification as any; @@ -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 () => { @@ -100,7 +126,7 @@ describe("MutationResolvers", () => { describe("cancelNotification", () => { const query = ` - mutation CancelNotification($input: NotificationInput!) { + mutation CancelNotification($input: NotificationLookupArguments!) { cancelNotification(input: $input) { success message @@ -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 () => { From 99672e749f05d1fae3b933d7b4bddb5ba8bbf186 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 15:48:42 -0700 Subject: [PATCH 09/12] use seconds threshold provided in argument inputs --- src/resolvers/MutationResolvers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resolvers/MutationResolvers.ts b/src/resolvers/MutationResolvers.ts index 90dd4c8..ac8e1ea 100644 --- a/src/resolvers/MutationResolvers.ts +++ b/src/resolvers/MutationResolvers.ts @@ -25,7 +25,9 @@ export const MutationResolvers: Resolvers = { const notificationData: NotificationSchedulingArguments = { ...args.input, - secondsThreshold: ETANotificationScheduler.defaultSecondsThresholdForNotificationToFire, + secondsThreshold: typeof args.input.secondsThreshold === 'number' + ? args.input.secondsThreshold + : ETANotificationScheduler.defaultSecondsThresholdForNotificationToFire, } await context.notificationService.scheduleNotification(notificationData); From 717575e004839b491586258113ef003cf5e9fd05 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 16:00:13 -0700 Subject: [PATCH 10/12] update query resolvers to add the seconds threshold --- src/resolvers/QueryResolvers.ts | 10 +++++++--- test/resolvers/QueryResolverTests.test.ts | 23 +++++++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) 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/resolvers/QueryResolverTests.test.ts b/test/resolvers/QueryResolverTests.test.ts index c35be13..ba00f5b 100644 --- a/test/resolvers/QueryResolverTests.test.ts +++ b/test/resolvers/QueryResolverTests.test.ts @@ -96,14 +96,15 @@ describe("QueryResolvers", () => { }); }); - describe("isNotificationScheduled", () => { + describe("isNotificationScheduled and secondsThresholdForNotification", () => { const query = ` - query IsNotificationScheduled($input: NotificationInput!) { + query IsNotificationScheduled($input: NotificationLookupArguments!) { 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") @@ -112,16 +113,20 @@ describe("QueryResolvers", () => { 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); }); }); }); From 4d74027b0e9e31172e3661fb843c7cb971aee29e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 16:16:18 -0700 Subject: [PATCH 11/12] update schema types for backwards compatibility with current schema --- schema.graphqls | 16 +++++----------- test/resolvers/MutationResolverTests.test.ts | 8 ++++---- test/resolvers/QueryResolverTests.test.ts | 2 +- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/schema.graphqls b/schema.graphqls index 0dcd118..6db52e7 100644 --- a/schema.graphqls +++ b/schema.graphqls @@ -65,23 +65,17 @@ type Query { systems: [System!]! system(id: ID): System - isNotificationScheduled(input: NotificationLookupArguments!): Boolean - secondsThresholdForNotification(input: NotificationLookupArguments!): Int + isNotificationScheduled(input: NotificationInput!): Boolean + secondsThresholdForNotification(input: NotificationInput!): Int } # Mutations type Mutation { - scheduleNotification(input: NotificationSchedulingArguments!): NotificationResponse! - cancelNotification(input: NotificationLookupArguments!): NotificationResponse! + scheduleNotification(input: NotificationInput!): NotificationResponse! + cancelNotification(input: NotificationInput!): NotificationResponse! } -input NotificationLookupArguments { - deviceId: ID! - shuttleId: ID! - stopId: ID! -} - -input NotificationSchedulingArguments { +input NotificationInput { deviceId: ID! shuttleId: ID! stopId: ID! diff --git a/test/resolvers/MutationResolverTests.test.ts b/test/resolvers/MutationResolverTests.test.ts index 83eb1ef..73eaae5 100644 --- a/test/resolvers/MutationResolverTests.test.ts +++ b/test/resolvers/MutationResolverTests.test.ts @@ -6,7 +6,7 @@ import { addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers"; import assert = require("node:assert"); -import { NotificationSchedulingArguments } from "../../src/generated/graphql"; +import { NotificationInput } from "../../src/generated/graphql"; describe("MutationResolvers", () => { const holder = setupTestServerHolder() @@ -25,7 +25,7 @@ describe("MutationResolvers", () => { describe("scheduleNotification", () => { const query = ` - mutation ScheduleNotification($input: NotificationSchedulingArguments!) { + mutation ScheduleNotification($input: NotificationInput!) { scheduleNotification(input: $input) { success message @@ -38,7 +38,7 @@ describe("MutationResolvers", () => { } ` - function assertFailedResponse(response: any, notificationInput: NotificationSchedulingArguments) { + function assertFailedResponse(response: any, notificationInput: NotificationInput) { assert(response.body.kind === "single"); expect(response.body.singleResult.errors).toBeUndefined(); const notificationResponse = response.body.singleResult.data?.scheduleNotification as any; @@ -126,7 +126,7 @@ describe("MutationResolvers", () => { describe("cancelNotification", () => { const query = ` - mutation CancelNotification($input: NotificationLookupArguments!) { + mutation CancelNotification($input: NotificationInput!) { cancelNotification(input: $input) { success message diff --git a/test/resolvers/QueryResolverTests.test.ts b/test/resolvers/QueryResolverTests.test.ts index ba00f5b..00007a7 100644 --- a/test/resolvers/QueryResolverTests.test.ts +++ b/test/resolvers/QueryResolverTests.test.ts @@ -98,7 +98,7 @@ describe("QueryResolvers", () => { describe("isNotificationScheduled and secondsThresholdForNotification", () => { const query = ` - query IsNotificationScheduled($input: NotificationLookupArguments!) { + query IsNotificationScheduled($input: NotificationInput!) { isNotificationScheduled(input: $input) secondsThresholdForNotification(input: $input) } From 7199adf20e0bfe25ddf6932828c2b59906068cb3 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 25 Mar 2025 16:20:55 -0700 Subject: [PATCH 12/12] play the default sound when the notification is sent --- src/notifications/senders/AppleNotificationSender.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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; + }; } }