Skip to content

Commit

Permalink
refactor: move notification subscribers check to the client
Browse files Browse the repository at this point in the history
  • Loading branch information
czabaj committed Nov 4, 2024
1 parent 965e7fb commit 4195460
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 126 deletions.
31 changes: 13 additions & 18 deletions functions/src/__tests__/online.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
95 changes: 64 additions & 31 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -186,28 +185,52 @@ export const updateNotificationToken = onCall<UpdateDeviceTokenMessage>(
}
);

const getRegistrationTokensFormEvent = async (
placeDoc: DocumentReference<Place>,
event: NotificationEvent
const getRegistrationTokens = async (
firestore: FirebaseFirestore.Firestore,
subscribedAccounts: string[]
): Promise<string[]> => {
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<Place>;
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(
`not-found`,
`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 (
Expand All @@ -225,7 +248,7 @@ const getUserFamiliarName = async (
/**
*
*/
export const dispatchNotification = onCall<FreeTableMessage | FreshKegMessage>(
export const dispatchNotification = onCall<NotificationEventMessages>(
{ cors: CORS, region: REGION },
async (request) => {
const uid = request.auth?.uid;
Expand All @@ -234,7 +257,7 @@ export const dispatchNotification = onCall<FreeTableMessage | FreshKegMessage>(
}
const db = getFirestore();
const messaging = getMessaging();
switch (request.data.tag) {
switch (request.data.TAG) {
default:
throw new HttpsError(
`invalid-argument`,
Expand All @@ -244,11 +267,16 @@ export const dispatchNotification = onCall<FreeTableMessage | FreshKegMessage>(
);
case NotificationEvent.freeTable: {
const placeDoc = db.doc(request.data.place) as DocumentReference<Place>;
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;
}
Expand Down Expand Up @@ -279,11 +307,16 @@ export const dispatchNotification = onCall<FreeTableMessage | FreshKegMessage>(
);
}
const placeDoc = kegDoc.parent.parent as DocumentReference<Place>;
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;
}
Expand Down
32 changes: 0 additions & 32 deletions src/backend/NotificationEvents.res
Original file line number Diff line number Diff line change
Expand Up @@ -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<FirestoreModels.place>) =>
dispatchNotification({tag: FreeTable, place: placeRef.path})
}

let useDispatchFreshKegNotification = () => {
let functions = Reactfire.useFunctions()
let dispatchNotification = Firebase.Functions.httpsCallable(functions, "dispatchNotification")
(kegRef: Firebase.documentReference<FirestoreModels.keg>) =>
dispatchNotification({tag: FreshKeg, keg: kegRef.path})
}

let useUpdateNotificationToken = () => {
let functions = Reactfire.useFunctions()
let updateDeviceToken = Firebase.Functions.httpsCallable(functions, "updateNotificationToken")
(deviceToken: string) => updateDeviceToken({deviceToken: deviceToken})
}
14 changes: 0 additions & 14 deletions src/backend/NotificationEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
10 changes: 10 additions & 0 deletions src/backend/NotificationHooks.gen.tsx
Original file line number Diff line number Diff line change
@@ -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 };
71 changes: 71 additions & 0 deletions src/backend/NotificationHooks.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
@genType
type notificationEventMessages =
| @as(1) /* FreeTable */ FreeTableMessage({place: string, users: array<string>})
| @as(2) /* FreshKeg */ FreshKegMessage({keg: string, users: array<string>})

@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})
}
2 changes: 1 addition & 1 deletion src/components/FcmTokenSync/FcmTokenSync.res
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 4195460

Please sign in to comment.