Your profile:
Your user account is not linked to any record.
}
+
+
+
+
diff --git a/src/app/core/user/user-account/user-account.component.ts b/src/app/core/user/user-account/user-account.component.ts
index af44634c80..3d3c2cd25e 100644
--- a/src/app/core/user/user-account/user-account.component.ts
+++ b/src/app/core/user/user-account/user-account.component.ts
@@ -25,9 +25,10 @@ import { MatTooltipModule } from "@angular/material/tooltip";
import { MatInputModule } from "@angular/material/input";
import { AccountPageComponent } from "../../session/auth/keycloak/account-page/account-page.component";
import { CurrentUserSubject } from "../../session/current-user-subject";
-import { AsyncPipe, NgIf } from "@angular/common";
+import { AsyncPipe } from "@angular/common";
import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component";
import { SessionSubject } from "../../session/auth/session-info";
+import { NotificationSettingsComponent } from "../../../features/notification/notification-settings/notification-settings.component";
/**
* User account form to allow the user to view and edit information.
@@ -45,7 +46,7 @@ import { SessionSubject } from "../../session/auth/session-info";
AccountPageComponent,
AsyncPipe,
EntityBlockComponent,
- NgIf,
+ NotificationSettingsComponent,
],
standalone: true,
})
diff --git a/src/app/features/notification/close-only-submenu.ts b/src/app/features/notification/close-only-submenu.ts
new file mode 100644
index 0000000000..cf794a257c
--- /dev/null
+++ b/src/app/features/notification/close-only-submenu.ts
@@ -0,0 +1,12 @@
+import { MatMenuTrigger } from "@angular/material/menu";
+
+/**
+ * Close the mat-menu of the given menu trigger
+ * and stop propagation of the event to avoid closing parent menus as well.
+ * @param menu
+ * @param event
+ */
+export function closeOnlySubmenu(menu: MatMenuTrigger, event: MouseEvent) {
+ menu.closeMenu();
+ event.stopPropagation();
+}
diff --git a/src/app/features/notification/model/entity-notification-context.ts b/src/app/features/notification/model/entity-notification-context.ts
new file mode 100644
index 0000000000..2d5a9a912d
--- /dev/null
+++ b/src/app/features/notification/model/entity-notification-context.ts
@@ -0,0 +1,7 @@
+/**
+ * Details about an entity that triggered a notification.
+ */
+export interface EntityNotificationContext {
+ entityType: string;
+ entityId?: string;
+}
diff --git a/src/app/features/notification/model/notification-config.ts b/src/app/features/notification/model/notification-config.ts
new file mode 100644
index 0000000000..2132226970
--- /dev/null
+++ b/src/app/features/notification/model/notification-config.ts
@@ -0,0 +1,78 @@
+import { Entity } from "../../../core/entity/model/entity";
+import { DatabaseField } from "../../../core/entity/database-field.decorator";
+import { DatabaseEntity } from "../../../core/entity/database-entity.decorator";
+import { DataFilter } from "app/core/filter/filters/filters";
+
+/**
+ * This represents one specific notification config for one specific user,
+ */
+@DatabaseEntity("NotificationConfig")
+export class NotificationConfig extends Entity {
+ /**
+ * The default mode(s) through which all notifications are sent to the user.
+ *
+ * Can be overwritten for each notificationType to disable a channel for certain notifications.
+ * If the channel is not activated globally here, the individual override has no effect, however.
+ */
+ @DatabaseField() channels: { [key in NotificationChannel]?: boolean };
+
+ /**
+ * Specific rules to trigger notifications.
+ */
+ @DatabaseField() notificationRules: NotificationRule[];
+
+ /**
+ * The "id" must be the user account ID to which this config relates.
+ */
+ constructor(id: string) {
+ super(id);
+ }
+
+ /**
+ * Helper method to access the user ID for whom this config is.
+ */
+ getUserId(): string {
+ return this.getId();
+ }
+}
+
+/**
+ * Defines allowed notification channels.
+ */
+export type NotificationChannel = "push";
+
+/**
+ * Represents a specific notification type configuration.
+ */
+export class NotificationRule {
+ /** human-readable title for this notification rule */
+ @DatabaseField() label?: string;
+
+ /** The general type of notification (e.g. changes to entities, etc.) */
+ @DatabaseField() notificationType: NotificationType;
+
+ /** whether this notification is enabled or currently "paused" */
+ @DatabaseField() enabled: boolean;
+
+ /**
+ * override for the global notification channel(s).
+ * e.g. define here if this specific notification rule should not show as email/push notification
+ *
+ * (optional) If not set, the global channels are used.
+ */
+ @DatabaseField() channels?: { [key in NotificationChannel]?: boolean };
+
+ /** (for "entity_change" notifications only): type of entities that can trigger notification */
+ @DatabaseField() entityType?: string;
+
+ /** (for "entity_change" notifications only): type of document change that can trigger notification */
+ @DatabaseField() changeType?: ("created" | "updated")[];
+
+ /** (for "entity_change" notifications only): conditions which changes cause notifications */
+ @DatabaseField() conditions: DataFilter
;
+}
+
+/**
+ * Base type of notification rule.
+ */
+export type NotificationType = "entity_change";
diff --git a/src/app/features/notification/model/notification-event.ts b/src/app/features/notification/model/notification-event.ts
new file mode 100644
index 0000000000..41bae3c90b
--- /dev/null
+++ b/src/app/features/notification/model/notification-event.ts
@@ -0,0 +1,55 @@
+import { Entity } from "../../../core/entity/model/entity";
+import { DatabaseField } from "../../../core/entity/database-field.decorator";
+import { DatabaseEntity } from "../../../core/entity/database-entity.decorator";
+import { NotificationType } from "./notification-config";
+import { EntityNotificationContext } from "./entity-notification-context";
+
+/**
+ * This represents one specific notification event for one specific user,
+ * displayed in the UI through the notification indicator in the toolbar.
+ */
+@DatabaseEntity("NotificationEvent")
+export class NotificationEvent extends Entity {
+ /*
+ * The title of the notification.
+ */
+ @DatabaseField() title: string;
+
+ /*
+ * The body of the notification.
+ */
+ @DatabaseField() body: string;
+
+ /*
+ * The URL to redirect the user to when the notification is clicked.
+ */
+ @DatabaseField() actionURL?: string;
+
+ /*
+ * The user ID for whom the notification is intended
+ */
+ @DatabaseField() notificationFor: string;
+
+ /*
+ * The notification token to be used for the notification.
+ */
+ @DatabaseField() notificationToken: string;
+
+ /*
+ * The type of notification.
+ */
+ @DatabaseField() notificationType: NotificationType;
+
+ /*
+ * Additional context about the notification,
+ * like details about the entity that the notification is about.
+ *
+ * Introduce additional context interfaces for other NotificationTypes in the future.
+ */
+ @DatabaseField() context?: EntityNotificationContext;
+
+ /*
+ * The status of the notification.
+ */
+ @DatabaseField() readStatus: boolean;
+}
diff --git a/src/app/features/notification/notification-config.interface.ts b/src/app/features/notification/notification-config.interface.ts
new file mode 100644
index 0000000000..6d431e312a
--- /dev/null
+++ b/src/app/features/notification/notification-config.interface.ts
@@ -0,0 +1,12 @@
+/**
+ * This object contains the necessary settings for Firebase Cloud Messaging integration.
+ */
+
+export interface FirebaseConfiguration {
+ apiKey: string;
+ authDomain: string;
+ projectId: string;
+ storageBucket: string;
+ messagingSenderId: string;
+ appId: string;
+}
diff --git a/src/app/features/notification/notification-item/notification-item.component.html b/src/app/features/notification/notification-item/notification-item.component.html
new file mode 100644
index 0000000000..dfd147eea3
--- /dev/null
+++ b/src/app/features/notification/notification-item/notification-item.component.html
@@ -0,0 +1,48 @@
+
+
+
{{ notification.title }}
+
{{ notification.body }}
+
+ {{ notification.created?.at | notificationTime }}
+
+
+
+
+
+
+
+
+ @if (!notification.readStatus) {
+
+
+
+
+ } @else {
+
+
+
+
+ }
+
+
+
+
+
+
+
+
diff --git a/src/app/features/notification/notification-item/notification-item.component.scss b/src/app/features/notification/notification-item/notification-item.component.scss
new file mode 100644
index 0000000000..91acb2691f
--- /dev/null
+++ b/src/app/features/notification/notification-item/notification-item.component.scss
@@ -0,0 +1,58 @@
+@use "variables/colors";
+
+.notification-item-wrapper {
+ padding: 8px 10px 0px;
+}
+
+.notification-details {
+ flex: 1;
+ text-align: left;
+ cursor: pointer;
+}
+
+.notification-details:hover {
+ background-color: transparent;
+ cursor: pointer;
+}
+
+.notification-title {
+ font-weight: bold;
+}
+
+.notification-body {
+ font-size: 0.85em;
+}
+
+.notification-time {
+ color: colors.$inactive;
+ font-size: 12px;
+}
+
+.indicator {
+ height: 12px;
+ width: 12px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ display: inline-block;
+ margin-right: 8px;
+ cursor: pointer;
+}
+
+.unread-indicator {
+ @extend .indicator;
+ background-color: blue;
+}
+
+.read-indicator {
+ @extend .indicator;
+ background-color: transparent;
+ border: 2px solid colors.$inactive;
+}
+
+.menu-option {
+ margin-left: 12px;
+}
+
+.read-notification {
+ color: colors.$inactive;
+}
diff --git a/src/app/features/notification/notification-item/notification-item.component.spec.ts b/src/app/features/notification/notification-item/notification-item.component.spec.ts
new file mode 100644
index 0000000000..df81cc1ed6
--- /dev/null
+++ b/src/app/features/notification/notification-item/notification-item.component.spec.ts
@@ -0,0 +1,31 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NotificationItemComponent } from "./notification-item.component";
+import { NotificationEvent } from "../model/notification-event";
+import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+
+describe("NotificationItemComponent", () => {
+ let component: NotificationItemComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ NotificationItemComponent,
+ FontAwesomeTestingModule,
+ NoopAnimationsModule,
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NotificationItemComponent);
+ component = fixture.componentInstance;
+
+ component.notification = new NotificationEvent();
+
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/notification/notification-item/notification-item.component.ts b/src/app/features/notification/notification-item/notification-item.component.ts
new file mode 100644
index 0000000000..4b300c81f1
--- /dev/null
+++ b/src/app/features/notification/notification-item/notification-item.component.ts
@@ -0,0 +1,52 @@
+import { Component, EventEmitter, Input, Output } from "@angular/core";
+import { MatBadgeModule } from "@angular/material/badge";
+import { CommonModule } from "@angular/common";
+import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
+import { MatMenu, MatMenuItem, MatMenuTrigger } from "@angular/material/menu";
+import { MatButtonModule } from "@angular/material/button";
+import { FormsModule } from "@angular/forms";
+import { MatTooltipModule } from "@angular/material/tooltip";
+import { MatTabsModule } from "@angular/material/tabs";
+import { NotificationEvent } from "../model/notification-event";
+import { NotificationTimePipe } from "../notification-time.pipe";
+import { closeOnlySubmenu } from "../close-only-submenu";
+
+@Component({
+ selector: "app-notification-item",
+ standalone: true,
+ imports: [
+ MatBadgeModule,
+ FontAwesomeModule,
+ MatMenu,
+ MatButtonModule,
+ MatMenuTrigger,
+ MatMenuItem,
+ FormsModule,
+ MatTooltipModule,
+ MatTabsModule,
+ CommonModule,
+ NotificationTimePipe,
+ ],
+ templateUrl: "./notification-item.component.html",
+ styleUrl: "./notification-item.component.scss",
+})
+export class NotificationItemComponent {
+ @Input() notification: NotificationEvent;
+
+ @Output() readStatusChange = new EventEmitter();
+ @Output() deleteClick = new EventEmitter();
+ @Output() notificationClick = new EventEmitter();
+ protected readonly closeOnlySubmenu = closeOnlySubmenu;
+
+ updateReadStatus(newStatus: boolean) {
+ this.readStatusChange.emit(newStatus);
+ }
+
+ handleDeleteNotification() {
+ this.deleteClick.emit();
+ }
+
+ onNotificationClick(notification: NotificationEvent) {
+ this.notificationClick.emit(notification);
+ }
+}
diff --git a/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.html b/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.html
new file mode 100644
index 0000000000..1e8eea084a
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.html
@@ -0,0 +1,40 @@
+
diff --git a/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.spec.ts b/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.spec.ts
new file mode 100644
index 0000000000..6b30214a42
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.spec.ts
@@ -0,0 +1,52 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NotificationConditionComponent } from "./notification-condition.component";
+import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
+import {
+ EntityRegistry,
+ entityRegistry,
+} from "app/core/entity/database-entity.decorator";
+
+describe("NotificationConditionComponent", () => {
+ let component: NotificationConditionComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ NotificationConditionComponent,
+ FontAwesomeTestingModule,
+ NoopAnimationsModule,
+ ReactiveFormsModule,
+ ],
+ providers: [{ provide: EntityRegistry, useValue: entityRegistry }],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NotificationConditionComponent);
+ component = fixture.componentInstance;
+
+ component.form = new FormGroup({
+ entityTypeField: new FormControl(""),
+ operator: new FormControl(""),
+ condition: new FormControl(""),
+ });
+
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should update the form value when form controls are changed", () => {
+ component.form.get("operator")?.setValue("$gte");
+ expect(component.form.get("operator")?.value).toBe("$gte");
+ });
+
+ it("should emit removeNotificationCondition event when removeNotificationCondition is triggered", () => {
+ spyOn(component.removeNotificationCondition, "emit");
+ component.removeNotificationCondition.emit();
+ expect(component.removeNotificationCondition.emit).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.ts b/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.ts
new file mode 100644
index 0000000000..4ffec79853
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-condition/notification-condition.component.ts
@@ -0,0 +1,82 @@
+import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
+import { MatFormFieldModule } from "@angular/material/form-field";
+import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
+import { FormGroup, ReactiveFormsModule } from "@angular/forms";
+import { MatInputModule } from "@angular/material/input";
+import { MatButtonModule } from "@angular/material/button";
+import { MatTooltipModule } from "@angular/material/tooltip";
+import { CdkAccordionModule } from "@angular/cdk/accordion";
+import { EntityFieldSelectComponent } from "app/core/entity/entity-field-select/entity-field-select.component";
+import { BasicAutocompleteComponent } from "app/core/common-components/basic-autocomplete/basic-autocomplete.component";
+import { NotificationRule } from "../../model/notification-config";
+
+/**
+ * Configure a single notification rule condition.
+ */
+@Component({
+ selector: "app-notification-condition",
+ standalone: true,
+ imports: [
+ MatInputModule,
+ FontAwesomeModule,
+ MatFormFieldModule,
+ MatButtonModule,
+ MatTooltipModule,
+ ReactiveFormsModule,
+ CdkAccordionModule,
+ EntityFieldSelectComponent,
+ BasicAutocompleteComponent,
+ ],
+ templateUrl: "./notification-condition.component.html",
+ styleUrl: "../../notification-settings/notification-settings.component.scss",
+})
+export class NotificationConditionComponent implements OnInit {
+ @Input() notificationRule: NotificationRule;
+
+ @Input() form: FormGroup;
+
+ @Output() removeNotificationCondition = new EventEmitter();
+
+ conditionalOptions: SimpleDropdownValue[] = [];
+
+ optionsToLabel = (v: SimpleDropdownValue) => this.conditionMappings[v.value];
+ optionsToValue = (v: SimpleDropdownValue) =>
+ Object.keys(this.conditionMappings).find(
+ (key) => this.conditionMappings[key] === v.label,
+ );
+
+ private conditionMappings: Record = {
+ $eq: "Equal To",
+ $gt: "Greater Than",
+ $gte: "Greater Than or Equal To",
+ $lt: "Less Than",
+ $lte: "Less Than or Equal To",
+ $ne: "Not Equal To",
+ $in: "In List",
+ $nin: "Not In List",
+ $and: "AND",
+ $not: "NOT",
+ $nor: "Neither",
+ $or: "OR",
+ $exists: "Exists",
+ $type: "Has Type",
+ $where: "Where To",
+ };
+
+ ngOnInit(): void {
+ this.conditionalOptions = Object.keys(this.conditionMappings).map(
+ (key) => ({ label: this.conditionMappings[key], value: key }),
+ );
+ }
+}
+
+interface SimpleDropdownValue {
+ label: string;
+ value: string;
+}
+
+export interface NotificationRuleCondition {
+ entityTypeField: string;
+ operator: string;
+ condition: string;
+}
diff --git a/src/app/features/notification/notification-rule/notification-rule.component.html b/src/app/features/notification/notification-rule/notification-rule.component.html
new file mode 100644
index 0000000000..699970b1ff
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-rule.component.html
@@ -0,0 +1,165 @@
+
diff --git a/src/app/features/notification/notification-rule/notification-rule.component.scss b/src/app/features/notification/notification-rule/notification-rule.component.scss
new file mode 100644
index 0000000000..59d134e59a
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-rule.component.scss
@@ -0,0 +1,23 @@
+@use "../../../../styles/variables/breakpoints";
+@use "variables/sizes";
+
+.full-width {
+ width: 100%;
+}
+
+.full-width-field {
+ @extend .full-width;
+ display: inline-block;
+
+ ::ng-deep mat-form-field {
+ @extend .full-width;
+ }
+}
+
+
+.new-rule-condition-button {
+ background-color: transparent;
+ padding: sizes.$regular sizes.$large;
+ cursor: pointer;
+ border-radius: sizes.$large;
+}
diff --git a/src/app/features/notification/notification-rule/notification-rule.component.spec.ts b/src/app/features/notification/notification-rule/notification-rule.component.spec.ts
new file mode 100644
index 0000000000..d2fbbae34a
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-rule.component.spec.ts
@@ -0,0 +1,98 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NotificationRuleComponent } from "./notification-rule.component";
+import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ReactiveFormsModule } from "@angular/forms";
+import { NotificationRule } from "../model/notification-config";
+import {
+ entityRegistry,
+ EntityRegistry,
+} from "app/core/entity/database-entity.decorator";
+import { HttpClient } from "@angular/common/http";
+import { KeycloakAuthService } from "app/core/session/auth/keycloak/keycloak-auth.service";
+import { NotificationService } from "../notification.service";
+
+describe("NotificationRuleComponent", () => {
+ let component: NotificationRuleComponent;
+ let fixture: ComponentFixture;
+ let mockValue: NotificationRule;
+ let mockHttp: jasmine.SpyObj;
+ let mockAuthService: jasmine.SpyObj;
+ let mockNotificationService: jasmine.SpyObj;
+
+ beforeEach(async () => {
+ mockNotificationService = jasmine.createSpyObj([
+ "hasNotificationPermissionGranted",
+ ]);
+
+ await TestBed.configureTestingModule({
+ imports: [
+ NotificationRuleComponent,
+ FontAwesomeTestingModule,
+ NoopAnimationsModule,
+ ReactiveFormsModule,
+ ],
+ providers: [
+ { provide: EntityRegistry, useValue: entityRegistry },
+ { provide: HttpClient, useValue: mockHttp },
+ { provide: KeycloakAuthService, useValue: mockAuthService },
+ { provide: NotificationService, useValue: mockNotificationService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NotificationRuleComponent);
+ component = fixture.componentInstance;
+
+ mockValue = {
+ label: "label1",
+ entityType: "entityType1",
+ changeType: ["created"],
+ enabled: true,
+ conditions: {},
+ notificationType: "entity_change",
+ };
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should parse component.value into formControls on ngOnChanges", () => {
+ component.value = mockValue;
+ component.ngOnChanges({ value: { currentValue: mockValue } } as any);
+
+ expect(component.form.getRawValue()).toEqual({
+ label: "label1",
+ entityType: "entityType1",
+ changeType: ["created"],
+ enabled: true,
+ conditions: [],
+ notificationType: "entity_change",
+ });
+ });
+
+ it("should emit valueChange with the correct format when a formControl is updated", () => {
+ spyOn(component.valueChange, "emit");
+ component.initForm();
+
+ component.form.setValue({
+ label: "label2",
+ entityType: "EventNote",
+ changeType: ["created", "updated"],
+ notificationType: "entity_change",
+ conditions: [],
+ enabled: true,
+ });
+
+ expect(component.valueChange.emit).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ label: "label2",
+ entityType: "EventNote",
+ changeType: ["created", "updated"],
+ notificationType: "entity_change",
+ conditions: {},
+ enabled: true,
+ } as NotificationRule),
+ );
+ });
+});
diff --git a/src/app/features/notification/notification-rule/notification-rule.component.ts b/src/app/features/notification/notification-rule/notification-rule.component.ts
new file mode 100644
index 0000000000..4fb81551d8
--- /dev/null
+++ b/src/app/features/notification/notification-rule/notification-rule.component.ts
@@ -0,0 +1,285 @@
+import {
+ Component,
+ EventEmitter,
+ inject,
+ Input,
+ OnChanges,
+ Output,
+ SimpleChanges,
+} from "@angular/core";
+import { MatFormFieldModule } from "@angular/material/form-field";
+import { HelpButtonComponent } from "app/core/common-components/help-button/help-button.component";
+import { EntityTypeSelectComponent } from "app/core/entity/entity-type-select/entity-type-select.component";
+import { MatSlideToggle } from "@angular/material/slide-toggle";
+import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
+import {
+ AbstractControl,
+ FormArray,
+ FormControl,
+ FormGroup,
+ ReactiveFormsModule,
+} from "@angular/forms";
+import { MatInputModule } from "@angular/material/input";
+import { MatButtonModule } from "@angular/material/button";
+import { MatTooltipModule } from "@angular/material/tooltip";
+import { NotificationRule } from "../model/notification-config";
+import {
+ NotificationConditionComponent,
+ NotificationRuleCondition,
+} from "./notification-condition/notification-condition.component";
+import { DataFilter } from "../../../core/filter/filters/filters";
+import { NotificationService } from "../notification.service";
+import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
+import { MatDialog } from "@angular/material/dialog";
+import { Logging } from "../../../core/logging/logging.service";
+import { MatOption } from "@angular/material/core";
+import { MatSelect } from "@angular/material/select";
+import { JsonEditorDialogComponent } from "app/core/admin/json-editor/json-editor-dialog/json-editor-dialog.component";
+import {
+ MatExpansionPanel,
+ MatExpansionPanelHeader,
+} from "@angular/material/expansion";
+
+/**
+ * Configure a single notification rule.
+ */
+@Component({
+ selector: "app-notification-rule",
+ standalone: true,
+ imports: [
+ MatSlideToggle,
+ MatInputModule,
+ FontAwesomeModule,
+ MatFormFieldModule,
+ MatButtonModule,
+ MatTooltipModule,
+ EntityTypeSelectComponent,
+ HelpButtonComponent,
+ ReactiveFormsModule,
+ NotificationConditionComponent,
+ MatProgressSpinnerModule,
+ MatOption,
+ MatSelect,
+ MatExpansionPanel,
+ MatExpansionPanelHeader,
+ ],
+ templateUrl: "./notification-rule.component.html",
+ styleUrl: "./notification-rule.component.scss",
+})
+export class NotificationRuleComponent implements OnChanges {
+ @Input() value: NotificationRule;
+ @Output() valueChange = new EventEmitter();
+
+ @Output() removeNotificationRule = new EventEmitter();
+
+ form: FormGroup;
+ entityTypeControl: AbstractControl;
+
+ readonly dialog = inject(MatDialog);
+ readonly notificationService = inject(NotificationService);
+
+ pushNotificationsEnabled =
+ this.notificationService.hasNotificationPermissionGranted();
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.value) {
+ this.initForm();
+ }
+ }
+
+ initForm() {
+ this.form = new FormGroup({
+ label: new FormControl(this.value?.label ?? ""),
+ entityType: new FormControl({
+ value: this.value?.entityType ?? "",
+ disabled: Object.keys(this.value?.conditions ?? {}).length > 0,
+ }),
+ changeType: new FormControl(
+ this.value?.changeType ?? ["created", "updated"],
+ ),
+ enabled: new FormControl(this.value?.enabled || false),
+ conditions: new FormArray([]),
+ notificationType: new FormControl(
+ this.value?.notificationType ?? "entity_change",
+ ),
+ });
+ this.entityTypeControl = this.form.get("entityType");
+
+ // Parse conditions from object to array and setup the form
+ const parsedConditions = this.parseConditionsObjectToArray(
+ this.value?.conditions,
+ );
+ this.setupConditionsArray(parsedConditions);
+
+ this.updateEntityTypeControlState();
+ this.form.valueChanges.subscribe((value) => this.updateValue(value));
+ }
+
+ /**
+ * Disable the entityType field if there are notification conditions.
+ */
+ private updateEntityTypeControlState() {
+ const conditionsControl = this.form.get("conditions");
+
+ if (!conditionsControl || !(conditionsControl instanceof FormArray)) {
+ return;
+ }
+ conditionsControl.valueChanges.subscribe(() => {
+ const conditionsLength = (conditionsControl as FormArray).length;
+ if (conditionsLength > 0) {
+ this.entityTypeControl.disable();
+ } else {
+ this.entityTypeControl.enable();
+ }
+ });
+ }
+
+ private updateValue(value: any) {
+ const entityTypeControl = this.form.get("entityType");
+ if (entityTypeControl?.disabled) {
+ value.entityType = entityTypeControl.value;
+ }
+ value.conditions = this.parseConditionsArrayToObject(value.conditions);
+
+ if (JSON.stringify(value) === JSON.stringify(this.value)) {
+ // skip if no actual change
+ return;
+ }
+
+ this.value = value;
+ this.valueChange.emit(value);
+ }
+
+ /**
+ * Sends a test notification.
+ */
+ testNotification() {
+ this.notificationService.testNotification().catch((reason) => {
+ Logging.error("Could not send test notification: " + reason.message);
+ });
+ }
+
+ addNewNotificationCondition() {
+ const newCondition = new FormGroup({
+ label: new FormControl(""),
+ entityTypeField: new FormControl(""),
+ operator: new FormControl(""),
+ condition: new FormControl(""),
+ });
+ (this.form.get("conditions") as FormArray).push(newCondition);
+ }
+
+ removeCondition(notificationConditionIndex: number) {
+ (this.form.get("conditions") as FormArray).removeAt(
+ notificationConditionIndex,
+ );
+ this.updateValue(this.form.value);
+ }
+
+ /**
+ * Open the conditions JSON editor popup.
+ */
+ openConditionsInJsonEditorPopup() {
+ const notificationConditions = this.form.get("conditions")?.value;
+ const dialogRef = this.dialog.open(JsonEditorDialogComponent, {
+ data: {
+ value: this.parseConditionsArrayToObject(notificationConditions) ?? {},
+ closeButton: true,
+ },
+ });
+
+ dialogRef.afterClosed().subscribe((result) => {
+ this.handleConditionsJsonEditorPopupClose(result);
+ });
+ }
+
+ /**
+ * Handle the result of the conditions JSON editor popup.
+ * @param result
+ * @private
+ */
+ private handleConditionsJsonEditorPopupClose(result: string[]) {
+ if (!result) {
+ return;
+ }
+
+ const parsedConditions = this.parseConditionsObjectToArray(result);
+ this.setupConditionsArray(parsedConditions);
+ }
+
+ /**
+ * Parse from config format to a format that can be used in the form
+ * e.g. from `{ fieldName: { '$eq': 'value' } }`
+ * to `[ { entityTypeField: 'fieldName', operator: '$eq', condition: 'value' } ]`
+ *
+ * @param conditions
+ * @private
+ */
+ private parseConditionsObjectToArray(
+ conditions: DataFilter | undefined,
+ ): NotificationRuleCondition[] {
+ if (!conditions) {
+ return [];
+ }
+
+ return Object.entries(conditions)?.map(([entityField, condition]) => {
+ const operator = Object.keys(condition)[0];
+ return {
+ entityTypeField: entityField,
+ operator,
+ condition: condition[operator],
+ };
+ });
+ }
+
+ /**
+ * Transform form format back to the needed config entity format
+ * e.g. from `[ { entityTypeField: 'fieldName', operator: '$eq', condition: 'value' } ]`
+ * to { fieldName: { '$eq': 'value' } }`
+ *
+ * @param conditions
+ * @private
+ */
+ private parseConditionsArrayToObject(
+ conditions: NotificationRuleCondition[],
+ ): DataFilter {
+ if (!conditions) {
+ return {};
+ }
+
+ return conditions.reduce((acc, condition) => {
+ if (
+ !condition.entityTypeField ||
+ !condition.operator ||
+ condition.operator === "" ||
+ !condition.condition ||
+ condition.condition === ""
+ ) {
+ // continue without adding incomplete condition
+ return acc;
+ }
+
+ acc[condition.entityTypeField] = {
+ [condition.operator]: condition.condition,
+ };
+ return acc;
+ }, {});
+ }
+
+ /**
+ * Setup the conditions array in the form.
+ * @param conditions
+ */
+ private setupConditionsArray(conditions: NotificationRuleCondition[] = []) {
+ const conditionsFormArray = this.form.get("conditions") as FormArray;
+ conditionsFormArray.clear();
+ conditions.forEach((condition) => {
+ const conditionGroup = new FormGroup({
+ entityTypeField: new FormControl(condition.entityTypeField),
+ operator: new FormControl(condition.operator),
+ condition: new FormControl(condition.condition),
+ });
+ conditionsFormArray.push(conditionGroup);
+ });
+ }
+}
diff --git a/src/app/features/notification/notification-settings/notification-settings.component.html b/src/app/features/notification/notification-settings/notification-settings.component.html
new file mode 100644
index 0000000000..ffbb9ddbe3
--- /dev/null
+++ b/src/app/features/notification/notification-settings/notification-settings.component.html
@@ -0,0 +1,142 @@
+
+
Notifications Settings
+
+ Notifications alert you to important system events, such as new
+ registrations via public forms or tasks assigned to you. They appear in the
+ toolbar and can be sent via email or push notifications.
+
+
+
+
+
+ What notifications you receive
+
+
+ Define rules for events that you want to get notifications for. You can
+ add as many different rules as you want and disable some temporarily to
+ pause these notifications.
+
+
+
+ @for (
+ notificationRule of notificationConfig?.notificationRules;
+ track notificationRule;
+ let index = $index
+ ) {
+
+ }
+
+
+
+
+
+ Add new notification rule
+
+
+
+
+
+
+
+
+ Where you receive notifications
+ @if (isPushNotificationEnabled) {
+
+ Enabled
+
+ } @else {
+
+ Disabled
+
+ }
+
+
+ Notifications are always visible through the bell icon in the toolbar at
+ the top of the application. You can enable other ways to get
+ notifications sent to you below.
+
+
+
+
+
+
+
+
+
+
+
Notification Center (in app)
+
+
+
+
+
+
+
Push notifications
+
+
+
+
+
+
+
+
+
+ Email
+ Coming Soon
+
+
+
+
+
diff --git a/src/app/features/notification/notification-settings/notification-settings.component.scss b/src/app/features/notification/notification-settings/notification-settings.component.scss
new file mode 100644
index 0000000000..60250eafc7
--- /dev/null
+++ b/src/app/features/notification/notification-settings/notification-settings.component.scss
@@ -0,0 +1,61 @@
+@use "variables/colors";
+@use "variables/sizes";
+@use "@angular/material/core/style/elevation" as mat-elevation;
+
+
+.panel-wrapper {
+ @include mat-elevation.elevation(2);
+ border-radius: 10px;
+ padding: 16px;
+}
+
+.receive-notifications-container {
+ @extend .panel-wrapper;
+ background-color: colors.$background;
+ margin-top: sizes.$regular;
+}
+
+
+.no-margin {
+ margin: 0;
+}
+
+.add-new-rule-button {
+ width: 50%;
+ border-radius: 20px;
+ padding: 22px;
+ background-color: aliceblue;
+}
+
+.coming-soon-label {
+ color: colors.$inactive;
+ margin-left: 10px;
+ font-style: italic;
+}
+
+
+.text-badge {
+ font-size: 12px;
+ font-weight: bold;
+ margin-left: 10px;
+ border-radius: 25px;
+ padding-inline: 15px;
+ border: 1px solid;
+}
+
+.disabled-text {
+ @extend .text-badge;
+ border-color: #fa222a;
+ background-color: #eba7a9;
+}
+
+.enabled-text {
+ @extend .text-badge;
+ border-color: green;
+ background-color: #beddba;
+}
+
+
+.indented-item {
+ margin-left: sizes.$large;
+}
diff --git a/src/app/features/notification/notification-settings/notification-settings.component.spec.ts b/src/app/features/notification/notification-settings/notification-settings.component.spec.ts
new file mode 100644
index 0000000000..8940ef7573
--- /dev/null
+++ b/src/app/features/notification/notification-settings/notification-settings.component.spec.ts
@@ -0,0 +1,58 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NotificationSettingsComponent } from "./notification-settings.component";
+import {
+ EntityRegistry,
+ entityRegistry,
+} from "app/core/entity/database-entity.decorator";
+import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
+import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
+import { MockEntityMapperService } from "../../../core/entity/entity-mapper/mock-entity-mapper-service";
+import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
+import {
+ SessionInfo,
+ SessionSubject,
+} from "../../../core/session/auth/session-info";
+import { BehaviorSubject } from "rxjs";
+import { TEST_USER } from "../../../core/user/demo-user-generator.service";
+import { HttpClient } from "@angular/common/http";
+import { KeycloakAuthService } from "app/core/session/auth/keycloak/keycloak-auth.service";
+import { NotificationService } from "../notification.service";
+
+describe("NotificationSettingComponent", () => {
+ let component: NotificationSettingsComponent;
+ let fixture: ComponentFixture;
+ let entityMapper: MockEntityMapperService;
+ let mockHttp: jasmine.SpyObj;
+ const testUser: SessionInfo = { name: TEST_USER, id: TEST_USER, roles: [] };
+ let mockAuthService: jasmine.SpyObj;
+ let mockNotificationService: jasmine.SpyObj;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ NotificationSettingsComponent,
+ FontAwesomeTestingModule,
+ BrowserAnimationsModule,
+ ],
+ providers: [
+ { provide: EntityRegistry, useValue: entityRegistry },
+ { provide: EntityMapperService, useValue: entityMapper },
+ {
+ provide: SessionSubject,
+ useValue: new BehaviorSubject(testUser),
+ },
+ { provide: HttpClient, useValue: mockHttp },
+ { provide: KeycloakAuthService, useValue: mockAuthService },
+ { provide: NotificationService, useValue: mockNotificationService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NotificationSettingsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/notification/notification-settings/notification-settings.component.ts b/src/app/features/notification/notification-settings/notification-settings.component.ts
new file mode 100644
index 0000000000..6ba81fab0d
--- /dev/null
+++ b/src/app/features/notification/notification-settings/notification-settings.component.ts
@@ -0,0 +1,189 @@
+import { Component, OnInit } from "@angular/core";
+import {
+ MatSlideToggle,
+ MatSlideToggleChange,
+} from "@angular/material/slide-toggle";
+import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
+import { Logging } from "app/core/logging/logging.service";
+import { MatButtonModule } from "@angular/material/button";
+import { MatFormFieldModule } from "@angular/material/form-field";
+import { HelpButtonComponent } from "app/core/common-components/help-button/help-button.component";
+import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service";
+import { EntityMapperService } from "app/core/entity/entity-mapper/entity-mapper.service";
+import {
+ NotificationConfig,
+ NotificationRule,
+} from "app/features/notification/model/notification-config";
+import { SessionSubject } from "app/core/session/auth/session-info";
+import { NotificationRuleComponent } from "../notification-rule/notification-rule.component";
+import { MatTooltip } from "@angular/material/tooltip";
+import { CdkAccordionModule } from "@angular/cdk/accordion";
+import { NotificationService } from "../notification.service";
+import { MatAccordion } from "@angular/material/expansion";
+import { AlertService } from "../../../core/alerts/alert.service";
+
+/**
+ * UI for current user to configure individual notification settings.
+ */
+@Component({
+ selector: "app-notification-settings",
+ standalone: true,
+ imports: [
+ MatSlideToggle,
+ FontAwesomeModule,
+ MatFormFieldModule,
+ MatButtonModule,
+ HelpButtonComponent,
+ NotificationRuleComponent,
+ MatTooltip,
+ CdkAccordionModule,
+ MatAccordion,
+ ],
+ templateUrl: "./notification-settings.component.html",
+ styleUrl: "./notification-settings.component.scss",
+})
+export class NotificationSettingsComponent implements OnInit {
+ notificationConfig: NotificationConfig = null;
+ isPushNotificationEnabled = false;
+
+ constructor(
+ private entityMapper: EntityMapperService,
+ private sessionInfo: SessionSubject,
+ private confirmationDialog: ConfirmationDialogService,
+ private notificationService: NotificationService,
+ private alertService: AlertService,
+ ) {}
+
+ /**
+ * Get the logged-in user id
+ */
+ private get userId() {
+ return this.sessionInfo.value?.id;
+ }
+
+ async ngOnInit() {
+ this.notificationConfig = await this.loadNotificationConfig();
+
+ if (this.notificationService.hasNotificationPermissionGranted()) {
+ this.isPushNotificationEnabled =
+ this.notificationConfig.channels?.push || false;
+ }
+ }
+
+ private async loadNotificationConfig() {
+ let notificationConfig: NotificationConfig;
+ try {
+ notificationConfig = await this.entityMapper.load(
+ NotificationConfig,
+ this.userId,
+ );
+ } catch (err) {
+ if (err.status === 404) {
+ notificationConfig = this.generateDefaultNotificationConfig();
+ await this.saveNotificationConfig(notificationConfig);
+ this.alertService.addInfo(
+ $localize`Initial notification settings created and saved.`,
+ );
+ } else {
+ Logging.warn(err);
+ }
+ }
+
+ return notificationConfig;
+ }
+
+ private generateDefaultNotificationConfig(): NotificationConfig {
+ const config = new NotificationConfig(this.userId);
+
+ config.notificationRules = [
+ {
+ label: $localize`:Default notification rule label:a new Child being registered`,
+ notificationType: "entity_change",
+ entityType: "Child",
+ changeType: ["created"],
+ conditions: {},
+ enabled: true,
+ },
+ {
+ label: $localize`:Default notification rule label:Tasks assigned to me`,
+ notificationType: "entity_change",
+ entityType: "Todo",
+ changeType: ["created", "updated"],
+ conditions: { assignedTo: { $elemMatch: this.userId } },
+ enabled: true,
+ },
+ {
+ label: $localize`:Default notification rule label:Notes involving me`,
+ notificationType: "entity_change",
+ entityType: "Note",
+ changeType: ["created", "updated"],
+ conditions: { authors: { $elemMatch: this.userId } },
+ enabled: false,
+ },
+ ];
+
+ return config;
+ }
+
+ async togglePushNotifications(event: MatSlideToggleChange) {
+ if (event.checked) {
+ this.notificationService.registerDevice();
+ } else {
+ this.notificationService.unregisterDevice();
+ }
+ this.isPushNotificationEnabled = event?.checked;
+
+ this.notificationConfig.channels = {
+ ...this.notificationConfig.channels,
+ push: this.isPushNotificationEnabled,
+ };
+
+ await this.saveNotificationConfig(this.notificationConfig);
+ }
+
+ private async saveNotificationConfig(config: NotificationConfig) {
+ try {
+ await this.entityMapper.save(config);
+ } catch (err) {
+ Logging.error(err.message);
+ }
+ }
+
+ async addNewNotificationRule() {
+ const newRule: NotificationRule = {
+ notificationType: "entity_change",
+ entityType: undefined,
+ channels: this.notificationConfig.channels, // by default, use the global channels
+ conditions: {},
+ enabled: true,
+ };
+
+ if (!this.notificationConfig.notificationRules) {
+ this.notificationConfig.notificationRules = [];
+ }
+ this.notificationConfig.notificationRules.push(newRule);
+
+ // saving this only once the fields are actually edited by the user
+ }
+
+ async updateNotificationRule(
+ notificationRule: NotificationRule,
+ updatedRule: NotificationRule,
+ ) {
+ Object.assign(notificationRule, updatedRule);
+ await this.saveNotificationConfig(this.notificationConfig);
+ }
+
+ async confirmRemoveNotificationRule(index: number) {
+ const confirmed = await this.confirmationDialog.getConfirmation(
+ $localize`Delete notification rule`,
+ $localize`Are you sure you want to remove this notification rule?`,
+ );
+ if (confirmed) {
+ this.notificationConfig.notificationRules.splice(index, 1);
+ await this.saveNotificationConfig(this.notificationConfig);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/app/features/notification/notification-time.pipe.ts b/src/app/features/notification/notification-time.pipe.ts
new file mode 100644
index 0000000000..3417c6a413
--- /dev/null
+++ b/src/app/features/notification/notification-time.pipe.ts
@@ -0,0 +1,50 @@
+/**
+ * Converts a timestamp into a human-readable time format,
+ * such as "Just Now", "5m", "2h", "Yesterday", "3d", or "Jan 2024".
+ */
+import { Pipe, PipeTransform } from "@angular/core";
+
+@Pipe({
+ name: "notificationTime",
+ standalone: true,
+})
+export class NotificationTimePipe implements PipeTransform {
+ transform(value: any): string {
+ if (!value) return "";
+
+ const currentTime = new Date();
+ const notificationTime = new Date(value);
+ if (
+ !(notificationTime instanceof Date) ||
+ isNaN(notificationTime.getTime())
+ ) {
+ return "";
+ }
+ const timeDifference = currentTime.getTime() - notificationTime.getTime();
+
+ const seconds = Math.floor(timeDifference / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (seconds < 60) {
+ return $localize`Just Now`;
+ } else if (minutes < 60) {
+ return $localize`${minutes}m`;
+ } else if (hours < 24) {
+ return $localize`${hours}h ago`;
+ } else if (days === 1) {
+ return $localize`Yesterday`;
+ } else if (days < 7) {
+ return $localize`${days}d ago`;
+ } else if (days >= 7 && days < 30) {
+ return $localize`${days}d ago`;
+ } else {
+ const monthYear = notificationTime.toLocaleString("en-US", {
+ month: "short",
+ year: "numeric",
+ });
+ return monthYear;
+ }
+ }
+}
diff --git a/src/app/features/notification/notification.component.html b/src/app/features/notification/notification.component.html
new file mode 100644
index 0000000000..16f9df0ca7
--- /dev/null
+++ b/src/app/features/notification/notification.component.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+ 0 ? unreadNotifications?.length : null
+ "
+ matBadgeColor="accent"
+ >
+
+
+
+
+
+
+
+
+
+
+ @if (!hasNotificationConfig) {
+
+ Activate Notifications
+
+ }
+
+
+ @for (
+ notification of selectedTab === 0
+ ? allNotifications
+ : unreadNotifications;
+ track notification
+ ) {
+
+ } @empty {
+
+
+ You have no notifications
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/features/notification/notification.component.scss b/src/app/features/notification/notification.component.scss
new file mode 100644
index 0000000000..1216f4ee15
--- /dev/null
+++ b/src/app/features/notification/notification.component.scss
@@ -0,0 +1,44 @@
+@use "variables/colors";
+
+.notification-list-header {
+ position: sticky;
+ top: 0px;
+ background-color: colors.$background;
+}
+
+.notification-title {
+ font-weight: 700;
+ margin: 0px;
+}
+
+.no-notification-text {
+ color: colors.$inactive;
+}
+
+.notification-panel-header {
+ padding: 8px 10px;
+}
+
+.notification-list-body {
+ overflow-y: auto;
+}
+
+.notification-panel {
+ width: 280px;
+ max-height: 90vh;
+}
+
+.no-notification-message {
+ text-align: center;
+ padding: 24px;
+}
+
+.no-notification-icon {
+ font-size: 56px;
+ margin-bottom: 20px;
+ color: colors.$inactive;
+}
+
+.menu-option {
+ margin-left: 12px;
+}
diff --git a/src/app/features/notification/notification.component.spec.ts b/src/app/features/notification/notification.component.spec.ts
new file mode 100644
index 0000000000..dd8c2055c7
--- /dev/null
+++ b/src/app/features/notification/notification.component.spec.ts
@@ -0,0 +1,42 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NotificationComponent } from "./notification.component";
+import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service";
+import { mockEntityMapper } from "../../core/entity/entity-mapper/mock-entity-mapper-service";
+import { SessionSubject } from "../../core/session/auth/session-info";
+import { of } from "rxjs";
+import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ActivatedRoute } from "@angular/router";
+import {
+ entityRegistry,
+ EntityRegistry,
+} from "app/core/entity/database-entity.decorator";
+
+describe("NotificationComponent", () => {
+ let component: NotificationComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ NotificationComponent,
+ FontAwesomeTestingModule,
+ NoopAnimationsModule,
+ ],
+ providers: [
+ { provide: EntityMapperService, useValue: mockEntityMapper() },
+ { provide: SessionSubject, useValue: of(null) },
+ { provide: ActivatedRoute, useValue: {} },
+ { provide: EntityRegistry, useValue: entityRegistry },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NotificationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/notification/notification.component.ts b/src/app/features/notification/notification.component.ts
new file mode 100644
index 0000000000..790c4f1fd8
--- /dev/null
+++ b/src/app/features/notification/notification.component.ts
@@ -0,0 +1,230 @@
+import { Component, OnInit } from "@angular/core";
+import { Subject, Subscription } from "rxjs";
+import { MatBadgeModule } from "@angular/material/badge";
+import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
+import { MatMenu, MatMenuModule, MatMenuTrigger } from "@angular/material/menu";
+import { MatButtonModule } from "@angular/material/button";
+import { FormsModule } from "@angular/forms";
+import { MatTooltipModule } from "@angular/material/tooltip";
+import { NotificationEvent } from "./model/notification-event";
+import { EntityMapperService } from "app/core/entity/entity-mapper/entity-mapper.service";
+import { MatTabsModule } from "@angular/material/tabs";
+import { NotificationItemComponent } from "./notification-item/notification-item.component";
+import { SessionSubject } from "app/core/session/auth/session-info";
+import { closeOnlySubmenu } from "./close-only-submenu";
+import { Router, RouterLink } from "@angular/router";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { applyUpdate } from "../../core/entity/model/entity-update";
+import { EntityRegistry } from "app/core/entity/database-entity.decorator";
+import { NotificationConfig } from "./model/notification-config";
+
+@UntilDestroy()
+@Component({
+ selector: "app-notification",
+ standalone: true,
+ imports: [
+ MatBadgeModule,
+ FontAwesomeModule,
+ MatMenu,
+ MatButtonModule,
+ MatMenuTrigger,
+ MatMenuModule,
+ FormsModule,
+ MatTooltipModule,
+ MatTabsModule,
+ NotificationItemComponent,
+ RouterLink,
+ ],
+ templateUrl: "./notification.component.html",
+ styleUrl: "./notification.component.scss",
+})
+export class NotificationComponent implements OnInit {
+ public allNotifications: NotificationEvent[] = [];
+ public unreadNotifications: NotificationEvent[] = [];
+ private notificationsSubject = new Subject();
+ public selectedTab = 0;
+ protected readonly closeOnlySubmenu = closeOnlySubmenu;
+
+ /** whether an initial notification config exists for the user */
+ hasNotificationConfig = false;
+
+ constructor(
+ private entityMapper: EntityMapperService,
+ private sessionInfo: SessionSubject,
+ private router: Router,
+ private entityRegistry: EntityRegistry,
+ ) {}
+
+ ngOnInit() {
+ this.notificationsSubject.subscribe((notifications) => {
+ this.filterUserNotifications(notifications);
+ });
+
+ this.loadAndProcessNotifications();
+ this.listenToEntityUpdates();
+
+ this.checkNotificationConfigStatus();
+ }
+
+ private async checkNotificationConfigStatus() {
+ this.hasNotificationConfig = await this.entityMapper
+ .load(NotificationConfig, this.userId)
+ .then((doc) => !!doc)
+ .catch(() => false);
+
+ this.entityMapper
+ .receiveUpdates(NotificationConfig)
+ .pipe(untilDestroyed(this))
+ .subscribe((next) => (this.hasNotificationConfig = !!next.entity));
+ }
+
+ /**
+ * Get the logged-in user id
+ */
+ private get userId(): string | undefined {
+ return this.sessionInfo.value?.id;
+ }
+
+ /**
+ * Loads all notifications and processes them to update the list and unread count.
+ */
+ private async loadAndProcessNotifications() {
+ const notifications =
+ await this.entityMapper.loadType(NotificationEvent);
+
+ this.notificationsSubject.next(notifications);
+ }
+
+ private updateSubscription: Subscription;
+
+ private listenToEntityUpdates() {
+ if (!this.updateSubscription) {
+ this.updateSubscription = this.entityMapper
+ .receiveUpdates(NotificationEvent)
+ .pipe(untilDestroyed(this))
+ .subscribe((next) => {
+ this.notificationsSubject.next(
+ applyUpdate(this.allNotifications, next),
+ );
+ });
+ }
+ }
+
+ /**
+ * Filters notifications based on the sender and read status.
+ */
+ private filterUserNotifications(notifications: NotificationEvent[]) {
+ this.allNotifications = notifications
+ .filter((notification) => notification.notificationFor === this.userId)
+ .sort(
+ (notificationA, notificationB) =>
+ notificationB.created.at.getTime() -
+ notificationA.created.at.getTime(),
+ );
+ this.unreadNotifications = notifications.filter(
+ (notification) =>
+ notification.notificationFor === this.userId &&
+ !notification.readStatus,
+ );
+ }
+
+ /**
+ * Marks all notifications as read.
+ */
+ async markAllRead(): Promise {
+ const unreadNotifications = this.allNotifications.filter(
+ (notification) => !notification.readStatus,
+ );
+ await this.updateReadStatus(unreadNotifications, true);
+ }
+
+ /**
+ * Updates the read status for multiple notifications.
+ */
+ async updateReadStatus(
+ notifications: NotificationEvent[],
+ newStatus: boolean,
+ ) {
+ for (const notification of notifications) {
+ notification.readStatus = newStatus;
+ await this.entityMapper.save(notification);
+ }
+ this.filterUserNotifications(this.allNotifications);
+ }
+
+ /**
+ * Deletes a user notification.
+ */
+ async deleteNotification(notification: NotificationEvent) {
+ await this.entityMapper.remove(notification);
+ }
+
+ private generateNotificationActionURL(
+ notification: NotificationEvent,
+ ): string {
+ if (!notification.context) return notification.actionURL;
+
+ let actionURL = "";
+
+ switch (notification.notificationType) {
+ case "entity_change":
+ actionURL = this.generateEntityUrl(notification);
+ break;
+ default:
+ actionURL = notification.actionURL;
+ }
+
+ return actionURL;
+ }
+
+ private generateEntityUrl(notification: NotificationEvent): string {
+ let url = "";
+
+ const entityCtr = this.entityRegistry.get(notification.context.entityType);
+ if (entityCtr) {
+ url = `/${entityCtr?.route}`;
+ if (notification.context.entityId) {
+ url += `/${notification.context.entityId}`;
+ }
+ }
+
+ return url;
+ }
+
+ /**
+ * Updates the read status of a selected notification.
+ * Handles notification events by redirecting the user to the corresponding action URL.
+ * @param {NotificationEvent} notification - The notification event containing the action URL.
+ */
+ async notificationClicked(notification: NotificationEvent) {
+ await this.updateReadStatus([notification], true);
+ const actionURL = this.generateNotificationActionURL(notification);
+ if (!actionURL) return;
+ await this.router.navigate([actionURL]);
+ }
+
+ // TODO: remove test code before final merge
+ private testEventTypeToggle = false;
+
+ async createTestEvent() {
+ this.testEventTypeToggle = !this.testEventTypeToggle;
+
+ const event = new NotificationEvent();
+ event.title = "Test Notification";
+ event.body = "This is a test notification.";
+ event.notificationFor = this.userId;
+ event.notificationType = "entity_change";
+ event.context = {
+ entityType: "School",
+ entityId: "1",
+ };
+ if (this.testEventTypeToggle) {
+ event.actionURL = "/school";
+ event.context = undefined;
+ event.notificationType = "other" as any;
+ event.title = event.title + " (with explicit action)";
+ }
+
+ await this.entityMapper.save(event);
+ }
+}
diff --git a/src/app/features/notification/notification.service.spec.ts b/src/app/features/notification/notification.service.spec.ts
new file mode 100644
index 0000000000..41974265f5
--- /dev/null
+++ b/src/app/features/notification/notification.service.spec.ts
@@ -0,0 +1,41 @@
+import { TestBed } from "@angular/core/testing";
+import {
+ HttpTestingController,
+ provideHttpClientTesting,
+} from "@angular/common/http/testing";
+import { NotificationService } from "./notification.service";
+import { provideHttpClient } from "@angular/common/http";
+import { KeycloakAuthService } from "app/core/session/auth/keycloak/keycloak-auth.service";
+import { MockedTestingModule } from "app/utils/mocked-testing.module";
+
+class MockKeycloakAuthService {
+ addAuthHeader(headers: Record) {
+ headers["Authorization"] = "Bearer mock-token";
+ }
+}
+
+describe("NotificationService", () => {
+ let service: NotificationService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [MockedTestingModule.withState()],
+ providers: [
+ provideHttpClientTesting(),
+ provideHttpClient(),
+ { provide: KeycloakAuthService, useClass: MockKeycloakAuthService },
+ ],
+ });
+ service = TestBed.inject(NotificationService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it("should be created", () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/features/notification/notification.service.ts b/src/app/features/notification/notification.service.ts
new file mode 100644
index 0000000000..594ca895f8
--- /dev/null
+++ b/src/app/features/notification/notification.service.ts
@@ -0,0 +1,212 @@
+import { inject, Injectable } 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 } from "rxjs/operators";
+
+/**
+ * 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 firebaseMessaging = inject(AngularFireMessaging);
+ private httpClient = inject(HttpClient);
+ private authService = inject(KeycloakAuthService);
+ private alertService = inject(AlertService);
+
+ private tokenSubscription: Subscription | undefined = undefined;
+
+ private readonly NOTIFICATION_API_URL = "/api/v1/notification";
+
+ init() {
+ if (environment.enableNotificationModule) {
+ this.listenForMessages();
+ }
+ // this.messaging = firebase.messaging();
+ }
+
+ /**
+ * 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.`,
+ );
+ });
+ },
+ 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);
+ }
+ },
+ });
+ }
+
+ /**
+ * 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 {
+ 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 {
+ const headers = {};
+ this.authService.addAuthHeader(headers);
+
+ return firstValueFrom(
+ this.httpClient.delete(
+ this.NOTIFICATION_API_URL + "/device/" + notificationToken,
+ {
+ headers,
+ },
+ ),
+ );
+ }
+
+ testNotification(): Promise {
+ 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.
+ */
+ listenForMessages(): void {
+ this.firebaseMessaging.messages.subscribe({
+ next: (payload) => {
+ new Notification(payload.notification.title, {
+ body: payload.notification.body,
+ icon: payload.notification.image,
+ });
+ },
+ error: (err) => {
+ Logging.error("Error while listening for messages.", err);
+ },
+ });
+ }
+
+ /**
+ * user given the notification permission to browser or not
+ * @returns boolean
+ */
+ hasNotificationPermissionGranted(): boolean {
+ switch (Notification.permission) {
+ case "granted":
+ return true;
+ case "denied":
+ return false;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/src/assets/firebase-config.json b/src/assets/firebase-config.json
new file mode 100644
index 0000000000..3db7d8fe65
--- /dev/null
+++ b/src/assets/firebase-config.json
@@ -0,0 +1,8 @@
+{
+ "projectId": "aam-digital-b8a7b",
+ "appId": "1:189059495005:web:151bb9f04d6bebb637c9b4",
+ "storageBucket": "aam-digital-b8a7b.firebasestorage.app",
+ "apiKey": "AIzaSyAVxpEeaCL8b4KQPwMqvWRW7lpcgDYZHdw",
+ "authDomain": "aam-digital-b8a7b.firebaseapp.com",
+ "messagingSenderId": "189059495005"
+}
diff --git a/src/assets/keycloak.json b/src/assets/keycloak.json
index 2d52d21a5b..73e5d551d6 100644
--- a/src/assets/keycloak.json
+++ b/src/assets/keycloak.json
@@ -1,6 +1,6 @@
{
"realm": "dummy-realm",
- "auth-server-url": "http://localhost:8080/",
+ "auth-server-url": "https://aam.localhost/auth",
"ssl-required": "external",
"resource": "app",
"public-client": true,
diff --git a/src/bootstrap-environment.ts b/src/bootstrap-environment.ts
index 68c174ae06..6633627ffa 100644
--- a/src/bootstrap-environment.ts
+++ b/src/bootstrap-environment.ts
@@ -7,14 +7,22 @@ import { Logging } from "./app/core/logging/logging.service";
**/
export async function initEnvironmentConfig() {
const CONFIG_FILE = "assets/config.json";
+ const FIREBASE_CONFIG_FILE = "assets/firebase-config.json";
let config: Object;
+ let notificationsConfig: Object;
try {
const configResponse = await fetch(CONFIG_FILE);
config = await configResponse.json();
if (typeof config !== "object") {
throw new Error("config.json must be an object");
}
+
+ const firebaseConfigResponse = await fetch(FIREBASE_CONFIG_FILE);
+ notificationsConfig = await firebaseConfigResponse.json();
+ if (typeof notificationsConfig !== "object") {
+ throw new Error("firebase-config.json must be an object");
+ }
} catch (err) {
if (
!environment.production ||
@@ -37,5 +45,5 @@ export async function initEnvironmentConfig() {
window.location.reload();
}
- Object.assign(environment, config);
+ Object.assign(environment, config, { notificationsConfig });
}
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index 8d48022916..c9ef6f22ad 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -15,4 +15,6 @@ export const environment = {
email: undefined,
DB_PROXY_PREFIX: "/db",
DB_NAME: "app",
+
+ enableNotificationModule: true,
};
diff --git a/src/environments/environment.spec.ts b/src/environments/environment.spec.ts
index 62597dbec7..3088a944c3 100644
--- a/src/environments/environment.spec.ts
+++ b/src/environments/environment.spec.ts
@@ -14,4 +14,6 @@ export const environment = {
email: undefined,
DB_PROXY_PREFIX: "/db",
DB_NAME: "app",
+
+ enableNotificationModule: false,
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 08b3f31750..ea7d2fe9d9 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -33,8 +33,8 @@ export const environment = {
remoteLoggingDsn: undefined, // only set for production mode in environment.prod.ts
demo_mode: true,
- session_type: SessionType.mock,
- account_url: "https://accounts.aam-digital.net",
+ session_type: SessionType.synced,
+ account_url: "https://aam.localhost/accounts-backend",
email: undefined,
/** Path for the reverse proxy that forwards to the database - configured in `proxy.conf.json` and `default.conf` */
@@ -42,4 +42,6 @@ export const environment = {
/** Name of the database that is used */
DB_NAME: "app",
+
+ enableNotificationModule: true,
};
diff --git a/src/firebase-messaging-sw.js b/src/firebase-messaging-sw.js
new file mode 100644
index 0000000000..caa4a83889
--- /dev/null
+++ b/src/firebase-messaging-sw.js
@@ -0,0 +1,50 @@
+// see source: https://github.com/firebase/snippets-web/blob/56d70627e2dc275f01cd0e55699794bf40faca80/messaging/service-worker.js#L10-L33
+
+importScripts("https://www.gstatic.com/firebasejs/11.2.0/firebase-app-compat.js");
+importScripts("https://www.gstatic.com/firebasejs/11.2.0/firebase-messaging-compat.js");
+
+function loadConfig() {
+ return fetch("/assets/firebase-config.json")
+ .then(function(response) {
+ return parseResponse(response);
+ })
+ .catch(function(error) {
+ console.error("Could not fetch firebase-config in service worker. Background Notifications not available.", error);
+ });
+}
+
+function parseResponse(response) {
+ return response.json()
+ .then(function(firebaseConfig) {
+ return firebaseConfig;
+ })
+ .catch(function(error) {
+ console.error("Could not parse firebase-config in service worker. Background Notifications not available.", error);
+ });
+}
+
+loadConfig()
+ .then(function(firebaseConfig) {
+ // Initialize the Firebase app in the service worker by passing in
+ // your app's Firebase config object.
+ // https://firebase.google.com/docs/web/setup#config-object
+ firebase.initializeApp(firebaseConfig);
+
+ // Retrieve an instance of Firebase Messaging so that it can handle background
+ // messages.
+ const messaging = firebase.messaging();
+
+ messaging.onBackgroundMessage(function(payload) {
+ const { title, body, image } = payload.notification;
+ const notificationOptions = {
+ title: title,
+ body: body,
+ icon: image,
+ data: {
+ url: payload.data?.url
+ }
+ };
+ self.registration.showNotification(title, notificationOptions);
+ });
+
+});
diff --git a/src/styles/globals/_flex-classes.scss b/src/styles/globals/_flex-classes.scss
index bc2e59147e..6d48e9b9b2 100644
--- a/src/styles/globals/_flex-classes.scss
+++ b/src/styles/globals/_flex-classes.scss
@@ -49,7 +49,7 @@
* between the items is maxed. This is useful, for example, when two items
* need to be placed at opposite ends (for example left and right or top and bottom)
*
- + The main axis is dependent on the flex direction
++ The main axis is dependent on the flex direction
* - flex-direction = row : main axis is x-axis
* - flex-direction = column : main axis is y-axis
*
@@ -65,6 +65,27 @@
justify-content: space-between;
}
+/**
+ * Justifies (places the items on their main axis) items so that they are
+ * centered along the main axis. This is useful, for example, when you want
+ * to place all items equidistantly from the center of the container.
+ *
+ * + The main axis is dependent on the flex direction
+ * - flex-direction = row : main axis is x-axis
+ * - flex-direction = column : main axis is y-axis
+ *
+ * Example assuming flex-direction = row:
+ *
+ * Without justify-content-center:
+ * | ----- | ----- | |
+ *
+ * With justify-content-center:
+ * | ----- ----- |
+ */
+.justify-content-center {
+ justify-content: center;
+}
+
/**
* aligns the items (i.e. places the items on the non main axis)
*/
diff --git a/src/styles/globals/_icon-classes.scss b/src/styles/globals/_icon-classes.scss
index fd9d7bf379..f01f243f4e 100644
--- a/src/styles/globals/_icon-classes.scss
+++ b/src/styles/globals/_icon-classes.scss
@@ -2,5 +2,5 @@
.standard-icon-with-text {
margin-right: sizes.$small;
- vertical-align: middle;
+ vertical-align: baseline;
}