🖥️
Full stack Typescript
  • Introduction
  • Environment setup
    • Workspace setup
    • Firebase project setup
    • Firebase authentication
    • Firestore database
    • Firebase hosting
  • Getting started with the Full Stack Typescript repository
  • Workflows
    • Development workflow
      • Component workflow
        • Application Toolbar component example
      • Feature workflow
        • User Account feature example
      • Function workflow
        • Update User Account callable function example
      • Web3 function workflow
        • Query Ethereum balance callable function example
    • Icon workflow
    • Push notification workflow
    • PWA workflow
      • Making your PWA Google Play Store ready
    • Secret Manager API workflow
  • Styleguide
    • Architecture overview
    • Naming conventions
    • Single-responsibility principle
  • Change Requests
    • Request for changes
      • Change pattern proposal template
      • New pattern proposal template
Powered by GitBook
On this page
  • Update the User interface with a tokens property
  • Update the project's Web Push certificates
  • Update app manifest
  • Add the Firebase Messaging service worker
  • Update project.json
  • Create the Firebase Messaging service
  • Prepare the Account state for managing the tokens
  • Update Account actions
  • Update Account reducer
  • Update Account selectors
  • Update Account facade
  • Update Account effects
  • Update Account service
  • Create Cloud Function to manage new tokens
  • Decide when to request and manage tokens
  • Update the App module
  • Update the App component
  • Update the App template
  • Deploy latest changes
  • Subscribe to push notifications
  • Test push notifications
  1. Workflows

Push notification workflow

Implement Web Push Notifications using the service worker and Firebase Messaging

PreviousIcon workflowNextPWA workflow

Last updated 2 years ago

These are the exact same native notifications received on mobile phones, home screen, or desktop but they are triggered via a web application. Your application must have the Service Worker installed to implement push notification. See the for implementing the Service Worker.

Update the User interface with a tokens property

File: libs\models\src\lib\user\user.interface.ts

export interface User {
    ...
    fcmTokens?: { [token: string]: true };
    ...
}

In order to send push notifications, it is required to have a User document available in our Firestore database that matches the User's uid .

When a User subscribes to push notifications, the User document will be updated with the token.

Update the project's Web Push certificates

Select the Cloud Messaging tab.

Generate a key pair.

The public key is used as a unique server identifier for subscribing the user to notifications sent by that server.

The private key needs to be kept secret (unlike the public key) and its used by the application server to sign messages, before sending them to the Push Service for delivery.

Once we have the keys generated we can now use the public key to subscribe to Push Notifications using the Service Worker.

Update app manifest

The application manifest file needs to be updated with the new messaging id.

Manifest file: apps\{{project}}\src\manifest.webmanifest

Update to include the following:

{
  "name": "{{project}}",
  ...
  "gcm_sender_id": "103953800507" // Add this exact ID
  ...
}

Add the Firebase Messaging service worker

The Firebase Messaging service worker listens for messages to act upon.

Create the file: apps\{{project}}\src\firebase-messaging-sw.js

Note: the file must be named exactly as above.

Add the following code to the file:

/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-undef */
// Give the service worker access to Firebase Messaging.
// Note that you can only use Firebase Messaging here, other Firebase libraries
// are not available in the service worker.
importScripts('https://www.gstatic.com/firebasejs/9.2.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.2.0/firebase-messaging-compat.js');

// Initialize the Firebase app in the service worker by passing in the messagingSenderId.
firebase.initializeApp({
    'apiKey': '{{project api key}}',
    'appId': '{{project app id}}',
    'projectId': '{{project id}}',
    'messagingSenderId': '{{project sender id}}'
});

// Retrieve an instance of Firebase Messaging so that it can handle background messages.
const messaging = firebase.messaging();

Update messagingSenderId with the project's Sender ID, found in the Firebase Console project setting's Cloud Messaging tab.

Update project.json

Update apps\{{project}}\project.json to include the new service worker file asset.

        "assets": [
          ...
          "apps/{{project}}/src/firebase-messaging-sw.js",
          ...
        ],

Create the Firebase Messaging service

Run the below command to create the Firebase Messaging service library:

nx g lib --name=firebase-messaging --directory=services

The following files will be generated:

libs
  - services
    ...
    - firebase-messaging
      - src
        - lib
          - services-firebase-messaging.module.ts
        - index.ts
        - test-setup.ts
      - .eslintrc.json
      - jest.config.ts
      - project.json
      - README.md
      - tsconfig.json|lib|spec

Delete the services-firebase-messaging.module.ts file.

Update the index.ts file to remove the module export.

Run the below command to create the Firebase Messaging Service

nx g service --name=firebase-messaging --path=libs/services/firebase-messaging/src/lib --project=services-firebase-messaging

Update the index.ts file to export the firebase messaging service file.

export * from './lib/firebase-messaging.service';

Update the service with the following code:

import { Injectable } from '@angular/core';
import { AngularFireMessaging } from '@angular/fire/compat/messaging';
import { AccountFacade } from '@full-stack-typescript/features/account';
import { getMessaging } from 'firebase/messaging';
import { BehaviorSubject } from 'rxjs';
/* eslint-disable @typescript-eslint/no-non-null-assertion */

@Injectable({
	providedIn: 'root'
})
export class FirebaseMessagingService {

	private messaging = getMessaging();

	currentMessage = new BehaviorSubject<any>(null);

	constructor(
		private accountFacade: AccountFacade,
		private angularFireMessaging: AngularFireMessaging
	) {}

	// receiveMessage listens for new messages and updates the currentMessage
	// with the newest notification received
	receiveMessage () {

		this.angularFireMessaging.messages.subscribe((message: any) => {

			console.log('New Message Received:::', message);
			this.currentMessage.next(message);

		});

	}

	// requestPermission provides the latest token the user has subscribed to
	// and dispatches an action to update the user's account with the token
	requestPermission () {

		this.angularFireMessaging.requestToken.subscribe((token) => {

			console.log('TOKEN:::', token);
			this.accountFacade.updateAccountTokens(token!);

		}),
		(error: any) => {

			console.log('Request Permission ERROR:::', error);

		}

	}

}

Prepare the Account state for managing the tokens

User tokens will be managed on the User document in the Firestore database. Therefore, we will need to update the AccountState to handle the new token management process.

Update Account actions

File: libs\features\account\src\lib+state\account.actions.ts

New actions:

export const updateAccountTokens = createAction(
  '[Account] Update account tokens',
  props<{ token: string }>()
);

export const updateAccountTokensFailure = createAction(
  '[Account] Update account tokens Failure',
  props<{ error: any }>()
);

export const updateAccountTokensSuccess = createAction(
  '[Account] Update account tokens Success'
);

Update Account reducer

File: libs\features\account\src\lib+state\account.reducer.ts

Add the updateAccountTokensFailure

    on(
        accountActions.loadAccountFailure,
        accountActions.updateAccountFailure,
        accountActions.updateAccountTokensFailure, // New
        (state, { error }) => ({
            ...state,
            error,
            isLoaded: false,
            isLoading: false
    })),

Update Account selectors

File: libs\features\account\src\lib+state\account.selectors.ts

New selector:

export const getAccountTokens = createSelector(
    getAccountState,
    (state: AccountState) => state.fcmTokens
);

Update Account facade

File: libs\features\account\src\lib+state\account.facade.ts

    tokens$ = this.store.pipe(select(accountSelectors.getAccountTokens));
    ...
    updateAccountTokens(token: string) {
        this.store.dispatch(updateAccountTokens({ token }));
    }

Update Account effects

File: libs\features\account\src\lib+state\account.effects.ts

New effect:

	updateAccountTokens$ = createEffect(() =>
		this.actions$.pipe(
			ofType(accountActions.updateAccountTokens),
			concatMap((action) =>
				of(action).pipe(withLatestFrom(this.account.pipe(select(getAccountTokens))))
			),
			exhaustMap(([{ token }, accountTokens = {}]) => {

				const currentTokens = Object.keys(accountTokens);
				const tokenExists = currentTokens.includes(token);

				if (tokenExists) {

					return of(accountActions.updateAccountTokensSuccess());

				} else {

					return this.accountService.updateAccountToken(token).pipe(
						map(() => {
	
							return accountActions.updateAccountTokensSuccess();
	
						}),
						catchError((error) => of(accountActions.updateAccountTokensFailure({ error })))
					);

				}

			})
		)
	);

The updateAccountTokens$ effect manages whether or not we should send a request to the backend to update the User's tokens.

If the token already exists in the User document, a request will not be sent. However, if the User has created a new subscription (could be a new device, refreshed token subscription, or they uninstalled/reinstalled the app) a request will be made to the backend to update the User document.

This is an important step to reduce costs.

Update Account service

File: libs\services\account\src\lib\account.service.ts

Add the new method that will interact with the Cloud Function that updates the User document with the latest tokens. Interacting with a Cloud Function, rather than allowing the client to update documents in the database directly, is the preferred and secure way to interact with the database.

	updateAccountToken(token: string): Observable<string> {

		const callable = this.fns.httpsCallable('onUpdateAccountTokens');
		const result = callable(token);

		return result as Observable<string>;

	}

Create Cloud Function to manage new tokens

Since the tokens are stored in the User document, a new Account Cloud Function is needed to update the document.

In the account functions folder, create a on-update-account-tokens.ts file.

Location: apps\full-stack-typescript-api\src\functions\account

import * as admin from 'firebase-admin';
import { getDocData } from '../../utils/get-doc-data';

const firestoreInstance = admin.firestore();

export async function onUpdateAccountTokens(data: string, context) {

    const batch = firestoreInstance.batch(); // Get a new write batch
    const serverTimestamp = admin.firestore.Timestamp.now();
    const token = data;
    const uid = context.auth.uid;

    const userDoc = firestoreInstance.collection('users').doc(uid);
    const userDocData = await getDocData(userDoc);
    const currentTokens = userDocData.fcmTokens || {};
    const updatedTokens = { ...currentTokens, [token]: true };

    batch.set(userDoc, {
        ...userDocData,
        fcmTokens: updatedTokens,
        updatedAt: serverTimestamp
    }, { merge: true });

    return batch.commit();

};

Update the index.ts file to export the new function.

File: apps\full-stack-typescript-api\src\functions\account\index.ts

export * from './on-update-account';
export * from './on-update-account-tokens';

Update the main.ts file to include the new function.

File: apps\full-stack-typescript-api\src\main.ts

...
export const onUpdateAccountTokens = functions.https.onCall((data, context) => {
    return accountFunctions.onUpdateAccountTokens(data, context);
});

Decide when to request and manage tokens

Push notifications are only sent when we have an Authenticated user and that user's document (account details) has been loaded. There are many strategies to decide on when to manage the user tokens.

The Full Stack Typescript repository uses the app-toolbar as a strategy to help manage this process. The app-toolbar receives authentication and account inputs to emit output events for loading and managing account information. It is achieved by the following:

File: libs\components\app-toolbar\src\lib\app-toolbar.component.ts

export class AppToolbarComponent implements OnChanges {

	@Input() isAccountLoaded: boolean | null = false;
	@Input() isAuthenticated: boolean | null = false;

	@Output() isAuthed = new EventEmitter();
	@Output() isAccountDetailsLoaded = new EventEmitter();

	ngOnChanges (changes: SimpleChanges) {

		const authChanges = changes['isAuthenticated'];
		const accountLoadedChanges = changes['isAccountLoaded'];
		
		// If there are authentication changes, check to see if the user
		// has just logged in
		if (authChanges) {

			this.authenticated(authChanges);

		}

		// If there are account loaded changes, check to see if the user
		// has just loaded their account
		if (accountLoadedChanges) {

			this.accountLoaded(accountLoadedChanges)

		}

	}

	authenticated (changes: SimpleChange) {

		const currentValue = changes.currentValue;
		const previousValue = !changes.previousValue;

		// If the user was not authenticated and has now become
		// authenticated, emit the isAuthed event
		if (previousValue && currentValue) {

			this.isAuthed.emit();

		}

	}

	accountLoaded (changes: SimpleChange) {

		const currentValue = changes.currentValue;
		const previousValue = !changes.previousValue;

		// If the account details were not loaded and have not become
		// loaded, emit the isAccountDetailsLoaded event
		if (previousValue && currentValue) {

			this.isAccountDetailsLoaded.emit();

		}

	}

}

Update the App module

The account feature should be moved to an eagerly loaded strategy in order to access the state immediately.

File: apps\full-stack-typescript\src\app\app.module.ts

Update the imports to include the AccountModule

  imports: [
    ...
    AccountModule,
    ...
  ],

Update the App component

The app.component manages the dispatching of actions and retrieving information from the store to communicate with the app-toolbar

File: apps\full-stack-typescript\src\app\app.component.ts

Update to include:

	isAccountLoaded$ = this.accountFacade.isLoaded$;
	isAuthenticated$ = this.authFacade.isAuthenticated$;

	constructor (
		private accountFacade: AccountFacade,
		private authFacade: AuthFacade,
		private firebaseMessagingService: FirebaseMessagingService
	) {};

	// When the user's account details are loaded (user document from the database
	// the app-toolbar emits an event to execute the isAccountDetailsLoaded
	// method which dispatches service calls to prompt the user to subscribe
	// to push notifications, retrieve the user's subscription token,
	// listen for new messages (notifications), and retrieve the current message
	isAccountDetailsLoaded () {

		this.firebaseMessagingService.requestPermission();
		this.firebaseMessagingService.receiveMessage();
		this.message = this.firebaseMessagingService.currentMessage;

	}

	// When a user becomes authenticated, the app-toolbar emits an event to
	// execute the isAuthed method which then dispatches an action to load
	// the user account details (User document from the database)
	isAuthed () {

		this.accountFacade.loadAccount();

	}

Update the App template

The app-toolbar emits events to communicate to the parent component when to dispatch actions such as loading the user account details, prompting the user to subscribe to notifications, and loading statuses.

File: apps\full-stack-typescript\src\app\app.component.html

            <full-stack-typescript-app-toolbar
                (isAccountDetailsLoaded)="isAccountDetailsLoaded()"
                (isAuthed)="isAuthed()"
                [isAccountLoaded]="isAccountLoaded$ | async"
                [isAuthenticated]="isAuthenticated$ | async"
            ></full-stack-typescript-app-toolbar>

Deploy latest changes

Push notifications can be tested through localhost . Build the latest Cloud Function updates and deploy to Firebase to test the push notifications.

Run: nx build {{api project}}

Example: nx build full-stack-typescript-api

After a successful build, run:

firebase deploy --only functions

Subscribe to push notifications

Run: nx serve {{project}}

Example: nx serve full-stack-typescript

Navigate to http://localhost:4200

Login as a user

When prompted click Allow to subscribe to push notifications

Test push notifications

Navigate to Messaging in the Firebase cloud console:

Click Create your first campaign

Select Firebase Notification messages and Click Create

Enter a Notification title and Notification text then Click Send test message

Add your subscription token and click Test

You should receive a push notification on the device you subscribed with.

Visit the Project Settings for your project in the Firebase Console:

The gcm_sender_id is used by some browsers to enable push notifications. It is the same for all projects, this is not your project's sender ID.

Verify the latest firebase package versions from the .

Firebase console:

https://console.firebase.google.com/
Reference
official example
https://console.firebase.google.com/
PWA workflow guide
Update the User interface with a tokens property
Update the project's Web Push certificates
Update app manifest
Add the Firebase Messaging service worker
Update project.json
Prepare the Account state for managing the tokens
Update Account actions
Update Account reducer
Update Account selectors
Update Account facade
Update Account effects
Update Account service
Create Cloud Function to manage new tokens
Decide when to request and manage tokens
Update the App module
Update the App component
Update the App template
Subscribe to push notifications
Test push notifications