src/app/features/notification/notification.service.ts
Status of a backend feature, as reported by the /actuator/features endpoint.
import { computed, inject, Injectable, resource, signal } from "@angular/core";
import { Logging } from "app/core/logging/logging.service";
import { HttpClient } from "@angular/common/http";
import { KeycloakAuthService } from "app/core/session/auth/keycloak/keycloak-auth.service";
import { AngularFireMessaging } from "@angular/fire/compat/messaging";
import { firstValueFrom, mergeMap, Subscription } from "rxjs";
import { environment } from "../../../environments/environment";
import { AlertService } from "../../core/alerts/alert.service";
import { catchError, map } from "rxjs/operators";
import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service";
import { NotificationConfig } from "./model/notification-config";
import { SessionSubject } from "../../core/session/auth/session-info";
import { SyncedPouchDatabase } from "../../core/database/pouchdb/synced-pouch-database";
import { NotificationEvent } from "./model/notification-event";
import { DatabaseResolverService } from "../../core/database/database-resolver.service";
/**
* Status of a backend feature, as reported by the `/actuator/features` endpoint.
*/
interface FeatureFlag {
enabled: boolean;
}
/**
* Feature flags from the `/actuator/features` endpoint.
* Sub-features are nested under their parent feature (e.g. `notification.email`).
*/
type FeatureFlags = Record<string, FeatureFlag> & {
notification?: FeatureFlag & { email?: FeatureFlag };
};
/**
* Handles the interaction with Cloud Messaging.
* It manages the retrieval of Cloud Messaging Notification token, listens for incoming messages, and sends notifications
* to users. The service also provides methods to create cloud messaging payloads and communicate with the
* cloud messaging HTTP API for sending notifications.
*/
@Injectable({
providedIn: "root",
})
export class NotificationService {
private readonly firebaseMessaging = inject(AngularFireMessaging);
private readonly httpClient = inject(HttpClient);
private readonly authService = inject(KeycloakAuthService);
private readonly alertService = inject(AlertService);
private readonly entityMapper = inject(EntityMapperService);
private readonly sessionInfo = inject(SessionSubject);
private readonly databaseResolver = inject(DatabaseResolverService);
private tokenSubscription: Subscription | undefined = undefined;
private readonly featureFlagsResource = resource({
loader: async () => {
try {
return await firstValueFrom(
this.httpClient.get<FeatureFlags>(
environment.API_PROXY_PREFIX + "/actuator/features",
),
);
} catch (err) {
Logging.debug("Notification API not available", err);
return {} as FeatureFlags;
}
},
});
readonly isNotificationServerEnabled = computed(
() => this.featureFlagsResource.value()?.["notification"]?.enabled ?? false,
);
readonly isEmailNotificationEnabled = computed(
() =>
this.featureFlagsResource.value()?.["notification"]?.email?.enabled ??
false,
);
private readonly NOTIFICATION_API_URL =
environment.API_PROXY_PREFIX + "/v1/notification";
constructor() {
// init listening to push messages once the session (with userId) is ready
this.sessionInfo.subscribe((sessionInfo) => this.init());
}
async init() {
if (await this.isDeviceRegistered()) {
this.listenForMessages();
}
}
async loadNotificationConfig(userId: string): Promise<NotificationConfig> {
return this.entityMapper.load<NotificationConfig>(
NotificationConfig,
userId,
);
}
/**
* Request a token device from firebase and register it in aam-backend
*/
registerDevice(): void {
this.tokenSubscription?.unsubscribe();
this.tokenSubscription = undefined;
this.tokenSubscription = this.firebaseMessaging.requestToken.subscribe({
next: (token) => {
if (!token) {
Logging.error("Could not get token for device.");
this.alertService.addInfo(
$localize`Please enable notification permissions to receive important updates.`,
);
return;
}
this.registerNotificationToken(token)
.then(() => {
Logging.log("Device registered in aam-digital backend.");
this.alertService.addInfo(
$localize`Device registered for push notifications.`,
);
this.listenForMessages();
})
.catch((err) => {
Logging.error(
"Could not register device in aam-digital backend. Push notifications will not work.",
err,
);
this.alertService.addInfo(
$localize`Could not register device in aam-digital backend. Push notifications will not work. Please try to disable and enable again.`,
);
});
},
error: (err) => {
this.tokenSubscription?.unsubscribe();
this.tokenSubscription = undefined;
if (err.code === 20) {
this.registerDevice();
} else {
this.alertService.addInfo(
$localize`User has rejected the authorisation request.`,
);
Logging.error("User has rejected the authorisation request.", err);
}
},
});
}
isDeviceRegistered(): Promise<boolean> {
return firstValueFrom(
this.firebaseMessaging.getToken
.pipe(
mergeMap((token) => {
if (!token) {
return Promise.resolve(false);
}
const headers = {};
this.authService.addAuthHeader(headers);
return this.httpClient
.get(this.NOTIFICATION_API_URL + "/device/" + token, {
headers,
})
.pipe(
map((value) => {
return value !== null;
}),
);
}),
)
.pipe(
catchError((err, caught) => {
return Promise.resolve(false);
}),
),
);
}
/**
* Unregister a device from firebase, this will disable push notifications.
*/
unregisterDevice(): void {
let tempToken = null;
this.firebaseMessaging.getToken
.pipe(
mergeMap((token) => {
tempToken = token;
return this.firebaseMessaging.deleteToken(token);
}),
)
.subscribe({
next: (success: boolean) => {
if (!success) {
this.alertService.addInfo(
$localize`Could not unregister device from firebase.`,
);
Logging.error("Could not unregister device from firebase.");
return;
}
this.unRegisterNotificationToken(tempToken).catch((err) => {
Logging.error("Could not unregister device from aam-backend.", err);
});
this.alertService.addInfo(
$localize`Device un-registered for push notifications.`,
);
},
error: (err) => {
Logging.error("Could not unregister device from firebase.", err);
},
});
}
/**
* Registers the device with the backend using the FCM token.
* @param notificationToken - The FCM token for the device.
* @param deviceName - The name of the device.
*/
registerNotificationToken(
notificationToken: string,
deviceName: string = "web", // todo something useful here
): Promise<Object> {
const payload = { deviceToken: notificationToken, deviceName };
const headers = {};
this.authService.addAuthHeader(headers);
return firstValueFrom(
this.httpClient.post(this.NOTIFICATION_API_URL + "/device", payload, {
headers,
}),
);
}
/**
* Unregister the device with the backend using the FCM token.
* @param notificationToken - The FCM token for the device.
*/
unRegisterNotificationToken(notificationToken: string): Promise<Object> {
const headers = {};
this.authService.addAuthHeader(headers);
return firstValueFrom(
this.httpClient.delete(
this.NOTIFICATION_API_URL + "/device/" + notificationToken,
{
headers,
},
),
);
}
testNotification(): Promise<Object> {
const headers = {};
this.authService.addAuthHeader(headers);
return firstValueFrom(
this.httpClient
.post(this.NOTIFICATION_API_URL + "/message/device-test", null, {
headers,
})
.pipe(
catchError((err) => {
this.alertService.addWarning(
$localize`Error trying to send test notification. If this error persists, please try to disable and enable "push notifications" again.`,
);
throw err;
}),
),
);
}
/**
* Listens for incoming Firebase Cloud Messages (FCM) in real time.
* Displays a browser notification when a message is received.
*
* This listener creates system notifications while the app is running
* (If app is not running, the firebase-messaging-sw is listening)
*/
listenForMessages(): void {
Logging.debug("Starting to listen for Push Messages");
this.firebaseMessaging.messages.subscribe({
next: (payload) => {
Logging.debug("Received Push Message", payload);
// trigger immediate sync
const db = this.databaseResolver.getDatabase(
NotificationEvent.DATABASE,
);
if (db instanceof SyncedPouchDatabase) {
db.sync().catch((err) =>
Logging.warn("Failed sync notifications db upon push message", err),
);
}
let notification = new Notification(payload.notification.title, {
body: payload.notification.body,
icon: "/assets/icons/favicon.png",
data: {
url: window.location.protocol + "//" + window.location.hostname,
// "/foo-bar/123", // todo: deep link here
},
});
notification.onclick = (event) => {
let url = event.target["data"]?.["url"];
event.preventDefault();
if (url) {
window.open(url, "_self");
}
};
},
error: (err) => {
Logging.error("Error while listening for messages.", err);
},
});
}
readonly isPushNotificationSupported = signal("Notification" in window);
/**
* user given the notification permission to browser or not
* @returns boolean
*/
readonly hasNotificationPermissionGranted = computed(
() =>
this.isPushNotificationSupported() &&
Notification.permission === "granted",
);
}