From 4195460d4437cc2108b849f944183e17b47d1c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Gr=C3=B6hling?= Date: Mon, 4 Nov 2024 15:05:24 +0100 Subject: [PATCH] refactor: move notification subscribers check to the client --- functions/src/__tests__/online.test.ts | 31 +++---- functions/src/index.ts | 95 +++++++++++++------- src/backend/NotificationEvents.res | 32 ------- src/backend/NotificationEvents.ts | 14 --- src/backend/NotificationHooks.gen.tsx | 10 +++ src/backend/NotificationHooks.res | 71 +++++++++++++++ src/components/FcmTokenSync/FcmTokenSync.res | 2 +- src/pages/Place/Place.res | 41 +++------ src/utils/BitwiseUtils.res | 3 + 9 files changed, 173 insertions(+), 126 deletions(-) create mode 100644 src/backend/NotificationHooks.gen.tsx create mode 100644 src/backend/NotificationHooks.res create mode 100644 src/utils/BitwiseUtils.res diff --git a/functions/src/__tests__/online.test.ts b/functions/src/__tests__/online.test.ts index 79e6ec5..29025e4 100644 --- a/functions/src/__tests__/online.test.ts +++ b/functions/src/__tests__/online.test.ts @@ -16,12 +16,11 @@ import { personsIndex, place, } from "../../../src/backend/FirestoreModels.gen"; -import { - FreeTableMessage, - FreshKegMessage, - NotificationEvent, - UpdateDeviceTokenMessage, -} from "../../../src/backend/NotificationEvents"; +import { NotificationEvent } from "../../../src/backend/NotificationEvents"; +import type { + notificationEventMessages as NotificationEventMessages, + updateDeviceTokenMessage as UpdateDeviceTokenMessage, +} from "../../../src/backend/NotificationHooks.gen"; import { getNotificationTokensDoc, getPersonsIndexDoc, @@ -384,17 +383,15 @@ describe(`dispatchNotification`, () => { await wrapped({ auth: { uid: `user1` }, data: { + TAG: NotificationEvent.freeTable, place: placeDoc.path, - tag: NotificationEvent.freeTable, - } satisfies FreeTableMessage, + users: [`user2`], + } satisfies NotificationEventMessages, }); const messaging = getMessaging(); expect(messaging.sendEachForMulticast).toHaveBeenCalledTimes(1); const callArg = (messaging.sendEachForMulticast as any).mock.calls[0][0]; - expect(callArg.tokens).toEqual([ - `registrationToken1`, - `registrationToken2`, - ]); + expect(callArg.tokens).toEqual([`registrationToken2`]); expect(callArg.notification.body.startsWith(`Alice`)).toBe(true); }); it(`should dispatch notification for freshKeg message to subscribed users`, async () => { @@ -432,17 +429,15 @@ describe(`dispatchNotification`, () => { await wrapped({ auth: { uid: `user1` }, data: { + TAG: NotificationEvent.freshKeg, keg: kegDoc!.path, - tag: NotificationEvent.freshKeg, - } satisfies FreshKegMessage, + users: [`user2`], + } satisfies NotificationEventMessages, }); const messaging = getMessaging(); expect(messaging.sendEachForMulticast).toHaveBeenCalledTimes(1); const callArg = (messaging.sendEachForMulticast as any).mock.calls[0][0]; - expect(callArg.tokens).toEqual([ - `registrationToken1`, - `registrationToken2`, - ]); + expect(callArg.tokens).toEqual([`registrationToken2`]); expect(callArg.notification.body.includes(`Test Beer`)).toBe(true); }); }); diff --git a/functions/src/index.ts b/functions/src/index.ts index e56f6df..af6dc53 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -20,12 +20,11 @@ import type { place as Place, personsIndex as PersonsIndex, } from "../../src/backend/FirestoreModels.gen"; -import { - NotificationEvent, - type FreeTableMessage, - type FreshKegMessage, - type UpdateDeviceTokenMessage, -} from "../../src/backend/NotificationEvents"; +import { NotificationEvent } from "../../src/backend/NotificationEvents"; +import type { + notificationEventMessages as NotificationEventMessages, + updateDeviceTokenMessage as UpdateDeviceTokenMessage, +} from "../../src/backend/NotificationHooks.gen"; import { UserRole } from "../../src/backend/UserRoles"; import { getNotificationTokensDoc, @@ -186,10 +185,32 @@ export const updateNotificationToken = onCall( } ); -const getRegistrationTokensFormEvent = async ( - placeDoc: DocumentReference, - event: NotificationEvent +const getRegistrationTokens = async ( + firestore: FirebaseFirestore.Firestore, + subscribedAccounts: string[] ): Promise => { + const notificationTokensDoc = getNotificationTokensDoc(firestore); + const notificationTokens = (await notificationTokensDoc.get()).data()!.tokens; + return subscribedAccounts + .map((uid) => notificationTokens[uid]) + .filter(Boolean); +}; + +const validateRequest = async ({ + currentUserUid, + placeDoc, + subscribedUsers, +}: { + currentUserUid: string; + placeDoc: DocumentReference; + subscribedUsers: string[]; +}) => { + if (subscribedUsers.length === 0) { + throw new HttpsError( + `failed-precondition`, + `There are no subscribed users for the event.` + ); + } const place = await placeDoc.get(); if (!place.exists) { throw new HttpsError( @@ -197,17 +218,19 @@ const getRegistrationTokensFormEvent = async ( `Place "${placeDoc.path}" does not exist.` ); } - const subscribedAccounts = Object.entries(place.data()!.accounts).filter( - ([, [, subscribed]]) => subscribed & event - ); - if (!subscribedAccounts.length) { - return []; + const { accounts } = place.data()!; + if (!accounts[currentUserUid]) { + throw new HttpsError( + `permission-denied`, + `The current user is not associated with the place "${placeDoc.path}".` + ); + } + if (subscribedUsers.some((uid) => !accounts[uid])) { + throw new HttpsError( + `failed-precondition`, + `Some of the subscribed users are not associated with the place "${placeDoc.path}".` + ); } - const notificationTokensDoc = getNotificationTokensDoc(place.ref.firestore); - const notificationTokens = (await notificationTokensDoc.get()).data()!.tokens; - return subscribedAccounts - .map(([uid]) => notificationTokens[uid]) - .filter(Boolean); }; const getUserFamiliarName = async ( @@ -225,7 +248,7 @@ const getUserFamiliarName = async ( /** * */ -export const dispatchNotification = onCall( +export const dispatchNotification = onCall( { cors: CORS, region: REGION }, async (request) => { const uid = request.auth?.uid; @@ -234,7 +257,7 @@ export const dispatchNotification = onCall( } const db = getFirestore(); const messaging = getMessaging(); - switch (request.data.tag) { + switch (request.data.TAG) { default: throw new HttpsError( `invalid-argument`, @@ -244,11 +267,16 @@ export const dispatchNotification = onCall( ); case NotificationEvent.freeTable: { const placeDoc = db.doc(request.data.place) as DocumentReference; - const subscribedNotificationTokens = - await getRegistrationTokensFormEvent( - placeDoc, - NotificationEvent.freeTable - ); + const subscribedUsers = request.data.users; + await validateRequest({ + currentUserUid: uid, + placeDoc, + subscribedUsers, + }); + const subscribedNotificationTokens = await getRegistrationTokens( + db, + subscribedUsers + ); if (subscribedNotificationTokens.length === 0) { return; } @@ -279,11 +307,16 @@ export const dispatchNotification = onCall( ); } const placeDoc = kegDoc.parent.parent as DocumentReference; - const subscribedNotificationTokens = - await getRegistrationTokensFormEvent( - placeDoc, - NotificationEvent.freshKeg - ); + const subscribedUsers = request.data.users; + await validateRequest({ + currentUserUid: uid, + placeDoc, + subscribedUsers, + }); + const subscribedNotificationTokens = await getRegistrationTokens( + db, + subscribedUsers + ); if (subscribedNotificationTokens.length === 0) { return; } diff --git a/src/backend/NotificationEvents.res b/src/backend/NotificationEvents.res index cb02f15..c87b51c 100644 --- a/src/backend/NotificationEvents.res +++ b/src/backend/NotificationEvents.res @@ -11,35 +11,3 @@ let roleI18n = (notificationEvent: notificationEvent) => | FreeTable => "Prázdný stůl" | FreshKeg => "Čerstvý sud" } - -type _updateDeviceTokenMessage = {deviceToken: string} -@module("./NotificationEvents.ts") -external updateDeviceTokenMessage: _updateDeviceTokenMessage = "UpdateDeviceTokenMessage" - -type _freeTableMessage = {tag: notificationEvent, place: string} -@module("./NotificationEvents.ts") -external freeTableMessage: _freeTableMessage = "FreeTableMessage" - -type _freshKegMessage = {tag: notificationEvent, keg: string} -@module("./NotificationEvents.ts") -external freshKegMessage: _freshKegMessage = "FreshKegMessage" - -let useDispatchFreeTableNotification = () => { - let functions = Reactfire.useFunctions() - let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") - (placeRef: Firebase.documentReference) => - dispatchNotification({tag: FreeTable, place: placeRef.path}) -} - -let useDispatchFreshKegNotification = () => { - let functions = Reactfire.useFunctions() - let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") - (kegRef: Firebase.documentReference) => - dispatchNotification({tag: FreshKeg, keg: kegRef.path}) -} - -let useUpdateNotificationToken = () => { - let functions = Reactfire.useFunctions() - let updateDeviceToken = Firebase.Functions.httpsCallable(functions, "updateNotificationToken") - (deviceToken: string) => updateDeviceToken({deviceToken: deviceToken}) -} diff --git a/src/backend/NotificationEvents.ts b/src/backend/NotificationEvents.ts index 3c06e3f..d42f829 100644 --- a/src/backend/NotificationEvents.ts +++ b/src/backend/NotificationEvents.ts @@ -17,17 +17,3 @@ export enum NotificationEvent { */ freshKeg = 2, } - -export type FreeTableMessage = { - tag: NotificationEvent.freeTable; - place: string; -}; - -export type FreshKegMessage = { - tag: NotificationEvent.freshKeg; - keg: string; -}; - -export type UpdateDeviceTokenMessage = { - deviceToken: string; -}; diff --git a/src/backend/NotificationHooks.gen.tsx b/src/backend/NotificationHooks.gen.tsx new file mode 100644 index 0000000..56f22bc --- /dev/null +++ b/src/backend/NotificationHooks.gen.tsx @@ -0,0 +1,10 @@ +/* TypeScript file generated from NotificationHooks.res by genType. */ + +/* eslint-disable */ +/* tslint:disable */ + +export type notificationEventMessages = + { TAG: 1; readonly place: string; readonly users: string[] } + | { TAG: 2; readonly keg: string; readonly users: string[] }; + +export type updateDeviceTokenMessage = { readonly deviceToken: string }; diff --git a/src/backend/NotificationHooks.res b/src/backend/NotificationHooks.res new file mode 100644 index 0000000..e01c342 --- /dev/null +++ b/src/backend/NotificationHooks.res @@ -0,0 +1,71 @@ +@genType +type notificationEventMessages = + | @as(1) /* FreeTable */ FreeTableMessage({place: string, users: array}) + | @as(2) /* FreshKeg */ FreshKegMessage({keg: string, users: array}) + +@genType +type updateDeviceTokenMessage = {deviceToken: string} + +let useGetSubscibedUsers = ( + ~currentUserUid: string, + ~event: NotificationEvents.notificationEvent, + ~place: FirestoreModels.place, +) => { + React.useMemo3(() => { + place.accounts + ->Dict.toArray + ->Array.filterMap(((uid, (_, notificationSubscription))) => { + if ( + uid === currentUserUid || + BitwiseUtils.bitAnd(notificationSubscription, (event :> int)) === 0 + ) { + None + } else { + Some(uid) + } + }) + }, (currentUserUid, event, place)) +} + +let useDispatchFreeTableNotification = ( + ~currentUserUid: string, + ~place: FirestoreModels.place, + ~recentConsumptionsByUser, +) => { + let firestore = Reactfire.useFirestore() + let functions = Reactfire.useFunctions() + let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") + let subsciredUsers = useGetSubscibedUsers(~currentUserUid, ~event=FreeTable, ~place) + let freeTableSituation = React.useMemo2(() => { + subsciredUsers->Array.length > 0 && + recentConsumptionsByUser + ->Map.values + ->Array.fromIterator + ->Array.every(consumptions => consumptions->Array.length === 0) + }, (recentConsumptionsByUser, subsciredUsers)) + () => + if freeTableSituation { + let placeRef = Db.placeDocument(firestore, Db.getUid(place)) + dispatchNotification(FreeTableMessage({place: placeRef.path, users: subsciredUsers}))->ignore + } +} + +let useDispatchFreshKegNotification = (~currentUserUid: string, ~place: FirestoreModels.place) => { + let firestore = Reactfire.useFirestore() + let functions = Reactfire.useFunctions() + let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification") + let subsciredUsers = useGetSubscibedUsers(~currentUserUid, ~event=FreshKeg, ~place) + (keg: Db.kegConverted) => { + let freshKegSituation = subsciredUsers->Array.length > 0 && keg.consumptionsSum === 0 + if freshKegSituation { + let kegRef = Db.kegDoc(firestore, Db.getUid(place), Db.getUid(keg)) + dispatchNotification(FreshKegMessage({keg: kegRef.path, users: subsciredUsers}))->ignore + } + } +} + +let useUpdateNotificationToken = () => { + let functions = Reactfire.useFunctions() + let updateDeviceToken = Firebase.Functions.httpsCallable(functions, "updateNotificationToken") + (deviceToken: string) => updateDeviceToken({deviceToken: deviceToken}) +} diff --git a/src/components/FcmTokenSync/FcmTokenSync.res b/src/components/FcmTokenSync/FcmTokenSync.res index d94c1cb..d208c9a 100644 --- a/src/components/FcmTokenSync/FcmTokenSync.res +++ b/src/components/FcmTokenSync/FcmTokenSync.res @@ -22,7 +22,7 @@ let make = (~placeId) => { let auth = Reactfire.useAuth() let firestore = Reactfire.useFirestore() let messaging = Reactfire.useMessaging() - let updateNotificationToken = NotificationEvents.useUpdateNotificationToken() + let updateNotificationToken = NotificationHooks.useUpdateNotificationToken() let isStandaloneModeStatus = DomUtils.useIsStandaloneMode() let isSubscribedToNotifications = Reactfire.useObservable( ~observableId="isSubscribedToNotifications", diff --git a/src/pages/Place/Place.res b/src/pages/Place/Place.res index c58d406..6a024e6 100644 --- a/src/pages/Place/Place.res +++ b/src/pages/Place/Place.res @@ -120,11 +120,18 @@ let make = (~placeId) => { recentConsumptionsByUser, currentUser, ) => + let dispatchFreeTableNotification = NotificationHooks.useDispatchFreeTableNotification( + ~currentUserUid=currentUser.uid, + ~place, + ~recentConsumptionsByUser, + ) + let dispatchFreshKegNotification = NotificationHooks.useDispatchFreshKegNotification( + ~currentUserUid=currentUser.uid, + ~place, + ) let (currentUserRole, _) = place.accounts->Dict.get(currentUser.uid)->Option.getExn let isUserAuthorized = UserRoles.isAuthorized(currentUserRole, ...) let formatConsumption = BackendUtils.getFormatConsumption(place.consumptionSymbols) - let dispatchFreeTableNotification = NotificationEvents.useDispatchFreeTableNotification() - let dispatchFreshKegNotification = NotificationEvents.useDispatchFreshKegNotification()
@@ -178,34 +185,8 @@ let make = (~placeId) => { onSubmit={values => { let keg = tapsWithKegs->Dict.getUnsafe(values.tap) let kegRef = Db.kegDoc(firestore, placeId, Db.getUid(keg)) - let firstBeerFromKeg = keg.consumptions->Dict.keysToArray->Array.length === 0 - if firstBeerFromKeg { - dispatchFreshKegNotification(kegRef) - ->Promise.then(_ => Promise.resolve()) - ->Promise.catch(error => { - let exn = Js.Exn.asJsExn(error)->Option.getExn - LogUtils.captureException(exn) - Promise.resolve() - }) - ->ignore - } else { - let freeTable = - recentConsumptionsByUser - ->Map.values - ->Array.fromIterator - ->Array.every(consumptions => consumptions->Array.length === 0) - if freeTable { - dispatchFreeTableNotification(Db.placeDocument(firestore, placeId)) - ->Promise.then(_ => Promise.resolve()) - ->Promise.catch(error => { - let exn = Js.Exn.asJsExn(error)->Option.getExn - LogUtils.captureException(exn) - Promise.resolve() - }) - ->ignore - } - } - + dispatchFreeTableNotification() + dispatchFreshKegNotification(keg) Db.Keg.addConsumption( firestore, ~consumption={ diff --git a/src/utils/BitwiseUtils.res b/src/utils/BitwiseUtils.res new file mode 100644 index 0000000..9875b64 --- /dev/null +++ b/src/utils/BitwiseUtils.res @@ -0,0 +1,3 @@ +let bitAnd = (a: int, b: int): int => %raw(`a & b`) + +let bitOr = (a: int, b: int): int => %raw(`a | b`)