diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js
index 0755f9764e..6c11da3c32 100755
--- a/frontend/cypress.config.js
+++ b/frontend/cypress.config.js
@@ -15,6 +15,7 @@ module.exports = defineConfig({
"cypress/e2e/nonConform.cy.js",
"cypress/e2e/modifyOrder.cy.js",
"cypress/e2e/batchOrderEntry.cy.js",
+ "cypress/e2e/dashboard.cy.js",
];
return config;
},
diff --git a/frontend/cypress/e2e/dashboard.cy.js b/frontend/cypress/e2e/dashboard.cy.js
new file mode 100644
index 0000000000..38b5a4a781
--- /dev/null
+++ b/frontend/cypress/e2e/dashboard.cy.js
@@ -0,0 +1,84 @@
+import LoginPage from "../pages/LoginPage";
+
+let homePage = null;
+let loginPage = null;
+let dashboard = null;
+
+before("login", () => {
+ loginPage = new LoginPage();
+ loginPage.visit();
+});
+
+describe("Pathology Dashboard", function () {
+ it("User Visits Pathology Dashboard", function () {
+ homePage = loginPage.goToHomePage();
+ dashboard = homePage.goToPathologyDashboard();
+
+ dashboard.checkForHeader("Pathology");
+ });
+
+ it("User adds a new Pathology order", function () {
+ homePage.goToOrderPage();
+ dashboard.addOrder("Histopathology");
+ });
+ it("Check For Order", () => {
+ homePage.goToPathologyDashboard();
+ dashboard.checkForHeader("Pathology");
+
+ cy.fixture("DashBoard").then((order) => {
+ dashboard.validatePreStatus(order.labNo);
+ });
+ });
+
+ it("Change The Status of Order and save it", () => {
+ dashboard.changeStatus("Completed");
+ dashboard.enterDetails();
+ dashboard.saveOrder();
+ });
+
+ it("Validate the Status of Order", () => {
+ cy.fixture("DashBoard").then((order) => {
+ dashboard.validateOrderStatus(order.labNo, 4);
+ });
+ });
+});
+
+describe("ImmunoChemistry Dashboard", function () {
+ it("User Visits ImmunoChemistry Dashboard", function () {
+ homePage = loginPage.goToHomePage();
+ dashboard = homePage.goToImmunoChemistryDashboard();
+ dashboard.checkForHeader("Immunohistochemistry");
+
+ // cy.fixture("DashBoard").then((order) => {
+ // dashboard.validatePreStatus(order.labNo);
+
+ // });
+ });
+
+ it("User adds a new ImmunioChemistry order", function () {
+ homePage.goToOrderPage();
+ dashboard.addOrder("Immunohistochemistry");
+ });
+
+ it("Check For Order", () => {
+ homePage.goToImmunoChemistryDashboard();
+
+ dashboard.checkForHeader("Immunohistochemistry");
+
+ cy.fixture("DashBoard").then((order) => {
+ dashboard.validatePreStatus(order.labNo);
+ });
+ });
+
+ it("Change The Status of Order and save it", () => {
+ dashboard.changeStatus("Completed");
+ dashboard.selectPathologist("ELIS,Open");
+ dashboard.saveOrder();
+ });
+
+ it("Validate the Status of Order", () => {
+ cy.fixture("DashBoard").then((order) => {
+ dashboard.validateOrderStatus(order.labNo, 3);
+ });
+ });
+});
diff --git a/frontend/cypress/pages/DashBoard.js b/frontend/cypress/pages/DashBoard.js
new file mode 100644
index 0000000000..4ebee5f489
--- /dev/null
+++ b/frontend/cypress/pages/DashBoard.js
@@ -0,0 +1,82 @@
+class DashBoardPage {
+ addOrder(Program) {
+ cy.fixture("Order").then((order) => {
+ cy.get(
+ ":nth-child(2) > .cds--radio-button__label > .cds--radio-button__appearance",
+ ).click();
+ cy.get("#local_search").click();
+ cy.get(
+ "tbody > :nth-child(1) > :nth-child(1) > .cds--radio-button-wrapper > .cds--radio-button__label > .cds--radio-button__appearance",
+ ).click();
+ cy.get(".forwardButton").click();
+ cy.get("#additionalQuestionsSelect").select(Program);
+ cy.get(".forwardButton").click();
+ cy.get("#sampleId_0").select("Serum");
+ cy.get(
+ ".testPanels > .cds--col > :nth-child(5) > .cds--checkbox-label",
+ ).click();
+ cy.get(".forwardButton").click();
+ cy.get(
+ ":nth-child(2) > :nth-child(1) > :nth-child(2) > .cds--link",
+ ).click();
+ cy.wait(1000);
+
+ cy.get("#labNo")
+ .invoke("val")
+ .then((labNoValue) => {
+ if (labNoValue) {
+ const data = { labNo: labNoValue };
+ cy.writeFile("cypress/fixtures/DashBoard.json", data);
+ } else {
+ cy.log("labNoValue is empty or undefined");
+ }
+ });
+
+ cy.get("#siteName").type(order.siteName);
+ cy.get("#requesterFirstName").type(order.requester.firstName);
+ cy.get("#requesterLastName").type(order.requester.firstName);
+ cy.get(".forwardButton").should("be.visible").click();
+ });
+ }
+
+ checkForHeader(title) {
+ cy.get("section > h3").should("have.text", title);
+ }
+
+ enterDetails() {
+ cy.get(
+ ":nth-child(14) > .gridBoundary > :nth-child(1) > .cds--form-item > .cds--text-area__wrapper > .cds--text-area",
+ ).type("Test");
+ cy.get(
+ ":nth-child(2) > .cds--form-item > .cds--text-area__wrapper > .cds--text-area",
+ ).type("Test");
+ }
+
+ validateOrderStatus(orderNumber, childIndex) {
+ cy.get(":nth-child(2) > .cds--link").click();
+ cy.get(":nth-child(1) > .tile-value").should("have.text", "0");
+ cy.get(`:nth-child(${childIndex}) > .tile-value`).should("have.text", "1");
+ cy.get("#statusFilter").select("Completed");
+ cy.get("tbody > tr > :nth-child(4)").should("have.text", "John");
+ }
+
+ validatePreStatus(order) {
+ cy.get(":nth-child(1) > .tile-value").should("have.text", "1");
+ cy.get("tbody > tr > :nth-child(4)").should("have.text", "John");
+ cy.get("tbody > tr > :nth-child(6)").click();
+ }
+
+ saveOrder() {
+ cy.get("#pathology_save2").click();
+ }
+
+ changeStatus(status) {
+ cy.get("#status").select(status);
+ }
+
+ selectPathologist(pathologist) {
+ cy.get("#assignedPathologist").select(pathologist);
+ }
+}
+
+export default DashBoardPage;
diff --git a/frontend/cypress/pages/HomePage.js b/frontend/cypress/pages/HomePage.js
index c22181af3f..b076716ecc 100755
--- a/frontend/cypress/pages/HomePage.js
+++ b/frontend/cypress/pages/HomePage.js
@@ -5,6 +5,7 @@ import ModifyOrderPage from "./ModifyOrderPage";
import WorkPlan from "./WorkPlan";
import NonConform from "./NonConformPage";
import BatchOrderEntry from "./BatchOrderEntryPage";
+import DashBoardPage from "./DashBoard";
class HomePage {
constructor() {}
@@ -97,6 +98,20 @@ class HomePage {
cy.get("#menu_non_conforming_corrective_actions_nav").click();
return new NonConform();
}
+
+ goToPathologyDashboard() {
+ this.openNavigationMenu();
+ cy.get("#menu_pathology_dropdown").click();
+ cy.get("#menu_pathologydashboard_nav").click();
+ return new DashBoardPage();
+ }
+
+ goToImmunoChemistryDashboard() {
+ this.openNavigationMenu();
+ cy.get("#menu_immunochem_dropdown").click();
+ cy.get("#menu_immunochemdashboard_nav").click();
+ return new DashBoardPage();
+ }
}
export default HomePage;
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
index 302a19a512..d39c65ff48 100644
--- a/frontend/public/manifest.json
+++ b/frontend/public/manifest.json
@@ -1,6 +1,6 @@
{
- "short_name": "React App",
- "name": "Create React App Sample",
+ "short_name": "OpenELIS",
+ "name": "OpenELIS Global",
"icons": [
{
"src": "images/favicon-16x16.png",
diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js
new file mode 100644
index 0000000000..4e5f5b3990
--- /dev/null
+++ b/frontend/public/service-worker.js
@@ -0,0 +1,85 @@
+// Define a cache name for versioning your cache
+const CACHE_NAME = "my-cache-v1";
+
+// Cache assets during the install phase
+self.addEventListener("install", (event) => {
+ console.log("[Service Worker] Install");
+ event.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then((cache) => {
+ return cache.addAll(["/", "/index.html", "/styles.css"]);
+ })
+ .then(() => self.skipWaiting()), // Skip waiting to activate new service worker immediately
+ );
+});
+
+// Clean up old caches during the activate phase
+self.addEventListener("activate", (event) => {
+ console.log("[Service Worker] Activate");
+ event.waitUntil(
+ caches
+ .keys()
+ .then((cacheNames) => {
+ return Promise.all(
+ cacheNames.map((cacheName) => {
+ if (cacheName !== CACHE_NAME) {
+ console.log("[Service Worker] Deleting old cache:", cacheName);
+ return caches.delete(cacheName);
+ }
+ }),
+ );
+ })
+ .then(() => self.clients.claim()), // Take control of all clients as soon as active
+ );
+});
+
+// Listen for push events and display notifications
+self.addEventListener("push", (event) => {
+ console.log("[Service Worker] Push Received", event);
+ if (event.data) {
+ const data = event.data.json();
+ const notificationOptions = {
+ body: data.body || "Message Received from OpenELIS",
+ tag: data.external_id || "default-tag",
+ icon: "images/openelis_logo.png",
+ };
+
+ event.waitUntil(
+ self.registration.showNotification(
+ "OpenELIS Message Received",
+ notificationOptions,
+ ),
+ );
+ }
+});
+
+// Notification click event listener
+// self.addEventListener("notificationclick", (event) => {
+// console.log('Notification clicked');
+// event.notification.close(); // Close the notification popout
+
+// event.waitUntil(
+// clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
+// // Check if any client (tab or window) is already open
+// for (let i = 0; i < clientList.length; i++) {
+// const client = clientList[i];
+// if (client.url === 'https://www.youtube.com/' && 'focus' in client) {
+// return client.focus();
+// }
+// }
+
+// // If no client is open, open a new window
+// if (clients.openWindow) {
+// return clients.openWindow('https://www.youtube.com/');
+// }
+// })
+// );
+// });
+
+// Handle messages from clients
+self.addEventListener("message", (event) => {
+ if (event.data && event.data.type === "SKIP_WAITING") {
+ self.skipWaiting();
+ }
+});
diff --git a/frontend/src/components/Style.css b/frontend/src/components/Style.css
index d1f629f5b6..d9e8fefcb3 100644
--- a/frontend/src/components/Style.css
+++ b/frontend/src/components/Style.css
@@ -508,7 +508,7 @@ button {
}
#mainHeader .cds--side-nav__link {
- pointer-events: none
+ pointer-events: none;
}
@media screen and (max-width: 792px) {
diff --git a/frontend/src/components/notifications/NoNotificationSVG.jsx b/frontend/src/components/notifications/NoNotificationSVG.jsx
new file mode 100644
index 0000000000..978484be5f
--- /dev/null
+++ b/frontend/src/components/notifications/NoNotificationSVG.jsx
@@ -0,0 +1,294 @@
+import { FormattedMessage } from "react-intl";
+
+export default function NoNotificationSVG() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/notifications/SlideOverNotifications.jsx b/frontend/src/components/notifications/SlideOverNotifications.jsx
index b2ed3a4289..a6aab35b47 100644
--- a/frontend/src/components/notifications/SlideOverNotifications.jsx
+++ b/frontend/src/components/notifications/SlideOverNotifications.jsx
@@ -1,10 +1,198 @@
-import { Renew, NotificationFilled, Email, Filter } from "@carbon/icons-react";
-import { formatTimestamp } from "../utils/Utils";
+import {
+ Renew,
+ NotificationFilled,
+ Email,
+ Filter,
+ NotificationOff,
+} from "@carbon/icons-react";
+import {
+ formatTimestamp,
+ getFromOpenElisServerV2,
+ postToOpenElisServer,
+ putToOpenElisServer,
+ urlBase64ToUint8Array,
+ deleteToOpenElisServer,
+} from "../utils/Utils";
import Spinner from "../common/Sprinner";
-import { FormattedMessage, useIntl } from "react-intl";
+import { useIntl } from "react-intl";
+import { useContext, useEffect, useState } from "react";
+import { NotificationContext } from "../layout/Layout";
+import { AlertDialog } from "../common/CustomNotification";
+import NoNotificationSVG from "./NoNotificationSVG";
export default function SlideOverNotifications(props) {
const intl = useIntl();
+ const { notificationVisible, addNotification, setNotificationVisible } =
+ useContext(NotificationContext);
+ const [iconLoading, setIconLoading] = useState({
+ icon: null,
+ loading: false,
+ });
+
+ const [subscriptionState, setSubscriptionState] = useState(null);
+
+ useEffect(() => {
+ // Whenever subscriptionState changes, re-check the subscription status
+
+ intialSubscriptionState(); // Fetch the current subscription state again
+ }, [subscriptionState]);
+
+ const intialSubscriptionState = async () => {
+ try {
+ const res = await getFromOpenElisServerV2("/rest/notification/pnconfig");
+ const reg = await navigator.serviceWorker.ready;
+ const subscription = await reg.pushManager.getSubscription();
+ if (!subscription && !res?.pf_endpoint) {
+ setSubscriptionState("NotSubscribed");
+ console.log("NotSubscribed");
+ } else if (subscription?.endpoint === res?.pfEndpoint) {
+ setSubscriptionState("SubscribedOnThisDevice");
+ console.log("SubscribedOnThisDevice");
+ } else {
+ console.log("subscription?.endpoint", subscription?.endpoint);
+
+ setSubscriptionState("SubscribedOnAnotherDevice");
+ console.log("SubscribedOnAnotherDevice");
+ }
+ } catch (error) {
+ console.error("Error checking subscription status:", error);
+ setSubscriptionState("NotSubscribed");
+ }
+ };
+
+ async function unsubscribe() {
+ try {
+ putToOpenElisServer("/rest/notification/unsubscribe", null, (res) => {
+ addNotification({
+ kind: "success",
+ message: intl.formatMessage({
+ id: "notification.slideover.button.unsubscribe.success",
+ }),
+ title: intl.formatMessage({ id: "notification.title" }),
+ });
+ setNotificationVisible(true);
+ setSubscriptionState("NotSubscribed");
+ });
+ } catch (e) {
+ addNotification({
+ kind: "warning",
+ message: intl.formatMessage({
+ id: "notification.slideover.button.unsubscribe.fail",
+ }),
+ title: intl.formatMessage({ id: "notification.title" }),
+ });
+ setNotificationVisible(true);
+ }
+ }
+
+ async function subscribe() {
+ try {
+ // Set the loading state
+ setIconLoading({ icon: "NOTIFICATION", loading: true });
+
+ // Check if service workers are supported
+ if (!("serviceWorker" in navigator)) {
+ throw new Error("Service workers are not supported in this browser.");
+ }
+
+ // Check if push messaging is supported
+ if (!("PushManager" in window)) {
+ throw new Error("Push messaging is not supported in this browser.");
+ }
+
+ // Register the service worker if not already registered
+ const registration = await navigator.serviceWorker
+ .register("/service-worker.js")
+ .catch((error) => {
+ throw new Error(
+ "Service worker registration failed: " + error.message,
+ );
+ });
+
+ // Ensure the service worker is ready
+ const sw = await navigator.serviceWorker.ready;
+
+ // Attempt to retrieve the public key from the server
+ let pbKeyData = await getFromOpenElisServerV2(
+ "/rest/notification/public_key",
+ ).catch((error) => {
+ throw new Error(
+ "Failed to retrieve public key from server: " + error.message,
+ );
+ });
+
+ // Convert the public key to a Uint8Array
+ const applicationServerKey = urlBase64ToUint8Array(pbKeyData.publicKey);
+
+ // Attempt to subscribe to push notifications
+ const push = await sw.pushManager
+ .subscribe({
+ userVisibleOnly: true,
+ applicationServerKey,
+ })
+ .catch((error) => {
+ throw new Error("Push subscription failed: " + error.message);
+ });
+
+ // Encode the subscription keys
+ const p256dh = btoa(
+ String.fromCharCode.apply(null, new Uint8Array(push.getKey("p256dh"))),
+ );
+ const auth = btoa(
+ String.fromCharCode.apply(null, new Uint8Array(push.getKey("auth"))),
+ );
+
+ // Construct the data object
+ const data = {
+ pfEndpoint: push.endpoint,
+ pfP256dh: p256dh,
+ pfAuth: auth,
+ };
+
+ // Send the subscription data to the server
+ postToOpenElisServer(
+ "/rest/notification/subscribe",
+ JSON.stringify(data),
+ (res) => {
+ console.log("res", res);
+ },
+ );
+
+ // Set the loading state to false
+ setIconLoading({ icon: null, loading: false });
+ addNotification({
+ kind: "success",
+ message: intl.formatMessage({
+ id: "notification.slideover.button.subscribe.success",
+ }),
+ title: intl.formatMessage({ id: "notification.title" }),
+ });
+ setNotificationVisible(true);
+ setSubscriptionState("SubscribedOnThisDevice");
+ } catch (error) {
+ // Handle any errors that occurred during the process
+ console.error(
+ "An error occurred during the subscription process:",
+ error,
+ );
+
+ // let a = NotificationKinds.
+
+ addNotification({
+ kind: "warning",
+ message: intl.formatMessage({
+ id: "notification.slideover.button.subscribe.fail",
+ }),
+ title: intl.formatMessage({ id: "notification.title" }),
+ });
+ setNotificationVisible(true);
+ setSubscriptionState("NotSubscribed");
+
+ setIconLoading({ icon: null, loading: false });
+
+ // Optionally set an error state here or provide user feedback
+ }
+ }
const {
loading,
@@ -57,7 +245,10 @@ export default function SlideOverNotifications(props) {
margin: "0 auto",
}}
>
+ {notificationVisible === true ? : ""}
+
+
{[
{
- icon: ,
+ icon:
+ iconLoading.loading == true && iconLoading.icon == "RELOAD" ? (
+
+ ) : (
+
+ ),
label: intl.formatMessage({
id: "notification.slideover.button.reload",
}),
- onClick: () => {
- getNotifications();
+ onClick: async () => {
+ setIconLoading({ icon: "RELOAD", loading: true });
+ await getNotifications();
+ setIconLoading({ icon: null, loading: false });
},
},
{
- icon: ,
- label: intl.formatMessage({
- id: "notification.slideover.button.subscribe",
- }),
- onClick: () => {},
+ icon:
+ iconLoading.loading == true &&
+ iconLoading.icon == "NOTIFICATION" ? (
+
+ ) : subscriptionState == "SubscribedOnThisDevice" ? (
+
+ ) : (
+
+ ),
+ label:
+ subscriptionState &&
+ subscriptionState == "SubscribedOnThisDevice"
+ ? intl.formatMessage({
+ id: "notification.slideover.button.unsubscribe",
+ })
+ : intl.formatMessage({
+ id: "notification.slideover.button.subscribe",
+ }),
+ onClick: async () => {
+ if (subscriptionState == "SubscribedOnThisDevice") {
+ unsubscribe();
+ } else {
+ subscribe();
+ }
+ },
},
{
- icon: ,
+ icon:
+ iconLoading.loading == true && iconLoading.icon == "EMAIL" ? (
+
+ ) : (
+
+ ),
label: intl.formatMessage({
id: "notification.slideover.button.markallasread",
}),
- onClick: () => {
- markAllNotificationsAsRead();
+ onClick: async () => {
+ setIconLoading({ icon: "EMAIL", loading: true });
+ await markAllNotificationsAsRead();
+ setIconLoading({ icon: null, loading: false });
},
},
{
@@ -163,294 +388,7 @@ export default function SlideOverNotifications(props) {
))
) : (
-
-
-
-
-
-
-
-
-
-
+
)}
diff --git a/frontend/src/components/notifications/Spinner.jsx b/frontend/src/components/notifications/Spinner.jsx
new file mode 100644
index 0000000000..7f90d5f13c
--- /dev/null
+++ b/frontend/src/components/notifications/Spinner.jsx
@@ -0,0 +1,19 @@
+import React from "react";
+
+const Spinner = () => (
+
+);
+
+export default Spinner;
diff --git a/frontend/src/components/utils/Utils.js b/frontend/src/components/utils/Utils.js
index 45f67c9401..e69b63cee2 100644
--- a/frontend/src/components/utils/Utils.js
+++ b/frontend/src/components/utils/Utils.js
@@ -216,6 +216,20 @@ export const hasRole = (userSessionDetails, role) => {
// this is complicated to enable it to format "smartly" as a person types
// possible rework could allow it to only format completed numbers
+
+export const getFromOpenElisServerV2 = (url) => {
+ return new Promise((resolve, reject) => {
+ // Simulating the original callback-based function
+ getFromOpenElisServer(url, (res) => {
+ if (res) {
+ resolve(res);
+ } else {
+ reject(new Error("Failed to fetch data"));
+ }
+ });
+ });
+};
+
export const convertAlphaNumLabNumForDisplay = (labNumber) => {
if (!labNumber) {
return labNumber;
@@ -328,3 +342,17 @@ export function formatTimestamp(timestamp) {
// Combine and return the formatted string
return `${formattedHours}:${formattedMinutes} ${ampm}; ${formattedDay}/${formattedMonth}/${year}`;
}
+
+// Helper function to convert a URL-safe base64 string to a Uint8Array
+export function urlBase64ToUint8Array(base64String) {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 76f2bcd213..026fe15cfc 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -118,7 +118,6 @@ code {
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
height: 100%;
- /* Take full height of the container */
}
.slide-over-header {
@@ -147,7 +146,6 @@ code {
margin: 1px;
overflow-y: auto;
flex-grow: 1;
- /* Allow the body to take remaining height */
}
@keyframes pulse {
diff --git a/frontend/src/index.js b/frontend/src/index.js
index 211e34a603..f1727dff56 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -3,6 +3,9 @@ import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
+import * as ServiceWorker from "./serviceWorkerRegistration";
+
+ServiceWorker.registerServiceWorker();
ReactDOM.render(
diff --git a/frontend/src/languages/en.json b/frontend/src/languages/en.json
index a36765f8fc..d595c89fee 100644
--- a/frontend/src/languages/en.json
+++ b/frontend/src/languages/en.json
@@ -1234,5 +1234,10 @@
"label.test.batch.cancel.start": "You have selected to cancel the test",
"label.test.batch.cancel.finish": "This is non-reversible.",
"label.test.batch.replace.start": "The following",
- "label.test.batch.no.change.finish": "tests will not be changed."
+ "label.test.batch.no.change.finish": "tests will not be changed.",
+ "notification.slideover.button.unsubscribe": "Unsubscribe",
+ "notification.slideover.button.unsubscribe.success": "Unsubscribed successfully",
+ "notification.slideover.button.unsubscribe.fail": "Unsubscribe failed",
+ "notification.slideover.button.subscribe.success": "Subscribed successfully",
+ "notification.slideover.button.subscribe.fail": "Subscribe failed"
}
diff --git a/frontend/src/languages/fr.json b/frontend/src/languages/fr.json
index f15957610c..6ae9c22038 100644
--- a/frontend/src/languages/fr.json
+++ b/frontend/src/languages/fr.json
@@ -1138,5 +1138,10 @@
"label.test.batch.cancel.start": "Vous avez choisi d'annuler le test",
"label.test.batch.cancel.finish": "Cette action est irréversible.",
"label.test.batch.replace.start": "Les tests suivants",
- "label.test.batch.no.change.finish": "ne seront pas modifiés."
+ "label.test.batch.no.change.finish": "ne seront pas modifiés.",
+ "notification.slideover.button.unsubscribe": "Se désabonner",
+ "notification.slideover.button.unsubscribe.success": "Désabonnement réussi",
+ "notification.slideover.button.unsubscribe.fail": "Échec de l'annulation de l'abonnement",
+ "notification.slideover.button.subscribe.success": "Abonnement réussi",
+ "notification.slideover.button.subscribe.fail": "Échec de l'abonnement"
}
diff --git a/frontend/src/serviceWorkerRegistration.js b/frontend/src/serviceWorkerRegistration.js
new file mode 100644
index 0000000000..f754ef631d
--- /dev/null
+++ b/frontend/src/serviceWorkerRegistration.js
@@ -0,0 +1,31 @@
+// Function to register the service worker
+export function registerServiceWorker() {
+ if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ const swUrl = `./service-worker.js`;
+
+ navigator.serviceWorker
+ .register(swUrl)
+ .then((registration) => {
+ console.log(
+ "Service Worker registered with scope:",
+ registration.scope,
+ );
+ })
+ .catch((error) => {
+ console.error("Service Worker registration failed:", error);
+ });
+ });
+ }
+}
+
+// Function to unregister the service worker
+export function unregisterServiceWorker() {
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.ready.then((registration) => {
+ registration.unregister().then((boolean) => {
+ console.log("Service Worker unregistered:", boolean);
+ });
+ });
+ }
+}
diff --git a/pom.xml b/pom.xml
index eb8390c58f..61880cc82b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -193,6 +193,17 @@
hibernate-search-backend-lucene
${hibernate-search.version}
+
+
+ nl.martijndwars
+ web-push
+ 5.1.1
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ 1.78
+
javax.annotation
diff --git a/src/main/java/org/openelisglobal/common/provider/query/LabOrderSearchProvider.java b/src/main/java/org/openelisglobal/common/provider/query/LabOrderSearchProvider.java
index 31097a167a..305a2612fa 100644
--- a/src/main/java/org/openelisglobal/common/provider/query/LabOrderSearchProvider.java
+++ b/src/main/java/org/openelisglobal/common/provider/query/LabOrderSearchProvider.java
@@ -231,12 +231,12 @@ public void processRequest(HttpServletRequest request, HttpServletResponse respo
}
}
- if (!GenericValidator.isBlankOrNull(serviceRequest.getRequester().getReferenceElement().getIdPart())
- && serviceRequest.getRequester().getReference().contains(ResourceType.Practitioner.toString())) {
+ if (!GenericValidator.isBlankOrNull(task.getOwner().getReferenceElement().getIdPart())
+ && task.getOwner().getReference().contains(ResourceType.Practitioner.toString())) {
try {
requesterPerson = localFhirClient.read() //
.resource(Practitioner.class) //
- .withId(serviceRequest.getRequester().getReferenceElement().getIdPart()) //
+ .withId(task.getOwner().getReferenceElement().getIdPart()) //
.execute();
LogEvent.logDebug(this.getClass().getSimpleName(), "processRequest",
"found matching requester " + requesterPerson.getIdElement().getIdPart());
@@ -245,6 +245,24 @@ public void processRequest(HttpServletRequest request, HttpServletResponse respo
}
}
+ if (requesterPerson == null) {
+ if (!GenericValidator.isBlankOrNull(serviceRequest.getRequester().getReferenceElement().getIdPart())
+ && serviceRequest.getRequester().getReference()
+ .contains(ResourceType.Practitioner.toString())) {
+ try {
+ requesterPerson = localFhirClient.read() //
+ .resource(Practitioner.class) //
+ .withId(serviceRequest.getRequester().getReferenceElement().getIdPart()) //
+ .execute();
+ LogEvent.logDebug(this.getClass().getSimpleName(), "processRequest",
+ "found matching requester " + requesterPerson.getIdElement().getIdPart());
+ } catch (ResourceNotFoundException e) {
+ LogEvent.logWarn(this.getClass().getSimpleName(), "processRequest", "no matching requester");
+ }
+ }
+
+ }
+
if (specimen != null && !GenericValidator
.isBlankOrNull(specimen.getCollection().getCollector().getReferenceElement().getIdPart())) {
try {
@@ -389,6 +407,14 @@ private void addRequester(StringBuilder xml) {
}
requesterValuesMap.put(PROVIDER_LAST_NAME, requesterPerson.getNameFirstRep().getFamily());
requesterValuesMap.put(PROVIDER_FIRST_NAME, requesterPerson.getNameFirstRep().getGivenAsSingleString());
+ } else {
+ Provider provider = providerService
+ .getProviderByFhirId(UUID.fromString(task.getOwner().getReferenceElement().getIdPart()));
+ if (provider != null) {
+ requesterValuesMap.put(PROVIDER_ID, provider.getId());
+ requesterValuesMap.put(PROVIDER_PERSON_ID, provider.getPerson().getId());
+ }
+
}
xml.append("");
XMLUtil.appendKeyValue(PROVIDER_ID, requesterValuesMap.get(PROVIDER_ID), xml);
diff --git a/src/main/java/org/openelisglobal/dataexchange/fhir/FhirConfig.java b/src/main/java/org/openelisglobal/dataexchange/fhir/FhirConfig.java
index d556ab1ac6..ab71aef34b 100644
--- a/src/main/java/org/openelisglobal/dataexchange/fhir/FhirConfig.java
+++ b/src/main/java/org/openelisglobal/dataexchange/fhir/FhirConfig.java
@@ -4,8 +4,19 @@
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.client.apache.ApacheRestfulClientFactory;
+import ca.uhn.fhir.rest.client.api.IClientInterceptor;
+import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.IRestfulClientFactory;
+import ca.uhn.fhir.rest.client.interceptor.BasicAuthInterceptor;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.validator.GenericValidator;
import org.apache.http.impl.client.CloseableHttpClient;
+import org.hl7.fhir.instance.model.api.IBaseBundle;
+import org.hl7.fhir.r4.model.Bundle;
+import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent;
+import org.hl7.fhir.r4.model.Practitioner;
+import org.hl7.fhir.r4.model.ResourceType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
@@ -29,6 +40,9 @@ public class FhirConfig {
@Value("${org.openelisglobal.fhirstore.password:}")
private String password;
+ @Value("${org.openelisglobal.remote.source.identifier:}#{T(java.util.Collections).emptyList()}")
+ private List remoteStoreIdentifier;
+
@Autowired
CloseableHttpClient httpClient;
@@ -65,4 +79,40 @@ public String getPassword() {
public String[] getRemoteStorePaths() {
return remoteStorePaths;
}
+
+ public List getRemoteStoreIdentifier() {
+ if (remoteStoreIdentifier.get(0).equals(ResourceType.Practitioner + "/*")) {
+ remoteStoreIdentifier = new ArrayList<>();
+ for (String remoteStorePath : getRemoteStorePaths()) {
+ IGenericClient fhirClient = fhirContext().newRestfulGenericClient(remoteStorePath);
+ if (!GenericValidator.isBlankOrNull(getUsername())
+ && !getLocalFhirStorePath().equals(remoteStorePath)) {
+ IClientInterceptor authInterceptor = new BasicAuthInterceptor(getUsername(), getPassword());
+ fhirClient.registerInterceptor(authInterceptor);
+ }
+ List allBundles = new ArrayList<>();
+ Bundle practitionerBundle = fhirClient.search().forResource(Practitioner.class)
+ .returnBundle(Bundle.class).execute();
+ allBundles.add(practitionerBundle);
+
+ while (practitionerBundle.getLink(IBaseBundle.LINK_NEXT) != null) {
+ practitionerBundle = fhirClient.loadPage().next(practitionerBundle).execute();
+ allBundles.add(practitionerBundle);
+ }
+
+ for (Bundle bundle : allBundles) {
+ for (BundleEntryComponent bundleComponent : bundle.getEntry()) {
+ if (bundleComponent.hasResource()
+ && ResourceType.Practitioner.equals(bundleComponent.getResource().getResourceType())) {
+
+ remoteStoreIdentifier.add(ResourceType.Practitioner + "/"
+ + bundleComponent.getResource().getIdElement().getIdPart());
+ }
+ }
+ }
+ }
+ }
+ return remoteStoreIdentifier;
+ }
+
}
diff --git a/src/main/java/org/openelisglobal/dataexchange/fhir/service/FhirApiWorkFlowServiceImpl.java b/src/main/java/org/openelisglobal/dataexchange/fhir/service/FhirApiWorkFlowServiceImpl.java
index 37ef903427..d2a2147585 100644
--- a/src/main/java/org/openelisglobal/dataexchange/fhir/service/FhirApiWorkFlowServiceImpl.java
+++ b/src/main/java/org/openelisglobal/dataexchange/fhir/service/FhirApiWorkFlowServiceImpl.java
@@ -127,7 +127,7 @@ public void processWorkflow(ResourceType resourceType) {
}
private void beginTaskCheckIfAcceptedPath(String remoteStorePath) throws FhirLocalPersistingException {
- if (remoteStoreIdentifier.isEmpty()) {
+ if (fhirConfig.getRemoteStoreIdentifier().isEmpty()) {
return;
}
@@ -208,7 +208,7 @@ private void beginTaskCheckIfAcceptedPath(String remoteStorePath) throws FhirLoc
}
private void beginTaskImportResultsPath(String remoteStorePath) {
- if (remoteStoreIdentifier.isEmpty()) {
+ if (fhirConfig.getRemoteStoreIdentifier().isEmpty()) {
return;
}
@@ -369,7 +369,7 @@ private void addResultImportObject(BundleEntryComponent bundleEntry,
}
private void beginTaskImportOrderPath(String remoteStorePath) {
- if (remoteStoreIdentifier.isEmpty()) {
+ if (fhirConfig.getRemoteStoreIdentifier().isEmpty()) {
return;
}
@@ -383,7 +383,7 @@ private void beginTaskImportOrderPath(String remoteStorePath) {
// .include(Task.INCLUDE_PATIENT)//
// .include(Task.INCLUDE_BASED_ON)//
.where(Task.STATUS.exactly().code(TaskStatus.REQUESTED.toCode())) //
- .where(Task.OWNER.hasAnyOfIds(remoteStoreIdentifier));
+ .where(Task.OWNER.hasAnyOfIds(fhirConfig.getRemoteStoreIdentifier()));
Bundle importBundle = searchQuery.execute();
importBundles.add(importBundle);
if (importBundle.hasEntry()) {
diff --git a/src/main/java/org/openelisglobal/notifications/dao/NotificationSubscriptionDAO.java b/src/main/java/org/openelisglobal/notifications/dao/NotificationSubscriptionDAO.java
new file mode 100644
index 0000000000..2727a10cc1
--- /dev/null
+++ b/src/main/java/org/openelisglobal/notifications/dao/NotificationSubscriptionDAO.java
@@ -0,0 +1,17 @@
+package org.openelisglobal.notifications.dao;
+
+import org.openelisglobal.notifications.entity.NotificationSubscriptions;
+
+public interface NotificationSubscriptionDAO {
+
+ void save(NotificationSubscriptions notificationSubscription);
+
+ NotificationSubscriptions getNotificationSubscriptionByUserId(Long id);
+
+ void updateNotificationSubscription(NotificationSubscriptions notificationSubscription);
+
+ void saveOrUpdate(NotificationSubscriptions notificationSubscription);
+
+ void delete(NotificationSubscriptions ns);
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/openelisglobal/notifications/dao/NotificationSubscriptionDAOImpl.java b/src/main/java/org/openelisglobal/notifications/dao/NotificationSubscriptionDAOImpl.java
new file mode 100644
index 0000000000..9e630a8d6b
--- /dev/null
+++ b/src/main/java/org/openelisglobal/notifications/dao/NotificationSubscriptionDAOImpl.java
@@ -0,0 +1,73 @@
+package org.openelisglobal.notifications.dao;
+
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import org.openelisglobal.notifications.entity.NotificationSubscriptions;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+@Repository
+public class NotificationSubscriptionDAOImpl implements NotificationSubscriptionDAO {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Override
+ @Transactional
+ public void save(NotificationSubscriptions notificationSubscription) {
+ entityManager.persist(notificationSubscription);
+ }
+
+ @Override
+ public NotificationSubscriptions getNotificationSubscriptionByUserId(Long id) {
+
+ try {
+ return entityManager.createQuery("SELECT ns FROM NotificationSubscriptions ns WHERE ns.user.id = :id",
+ NotificationSubscriptions.class).setParameter("id", id).getSingleResult();
+ } catch (Exception e) {
+ return null;
+ }
+
+ }
+
+ @Override
+ @Transactional
+ public void updateNotificationSubscription(NotificationSubscriptions notificationSubscription) {
+ entityManager.merge(notificationSubscription);
+ }
+
+ @Override
+ @Transactional
+ public void delete(NotificationSubscriptions ns) {
+ // Reattach the entity to the current session
+ NotificationSubscriptions attachedEntity = entityManager.find(NotificationSubscriptions.class, ns.getId());
+
+ if (attachedEntity != null) {
+ // Now remove the attached entity
+ entityManager.remove(attachedEntity);
+ } else {
+ throw new IllegalArgumentException("Subscription not found or already deleted");
+ }
+ }
+
+ // Update saveOrUpdate method
+
+ @Transactional
+ public void saveOrUpdate(NotificationSubscriptions notificationSubscription) {
+
+ NotificationSubscriptions existingSubscription = getNotificationSubscriptionByUserId(
+ Long.valueOf(notificationSubscription.getUser().getId()));
+
+ if (existingSubscription == null) {
+ // Create a new subscription
+ entityManager.persist(notificationSubscription); // Use persist() for new entities
+ } else {
+ // Update the existing subscription
+ existingSubscription.setPfEndpoint(notificationSubscription.getPfEndpoint());
+ existingSubscription.setPfP256dh(notificationSubscription.getPfP256dh());
+ existingSubscription.setPfAuth(notificationSubscription.getPfAuth());
+ entityManager.merge(existingSubscription); // Use merge() for existing entities
+ }
+ }
+
+}
diff --git a/src/main/java/org/openelisglobal/notifications/entity/NotificationSubscriptions.java b/src/main/java/org/openelisglobal/notifications/entity/NotificationSubscriptions.java
new file mode 100644
index 0000000000..13ecd873b8
--- /dev/null
+++ b/src/main/java/org/openelisglobal/notifications/entity/NotificationSubscriptions.java
@@ -0,0 +1,98 @@
+package org.openelisglobal.notifications.entity;
+
+import javax.persistence.*;
+import org.openelisglobal.systemuser.valueholder.SystemUser;
+
+@Entity
+@Table(name = "notification_subscriptions")
+public class NotificationSubscriptions {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "pf_endpoint", nullable = false)
+ private String pfEndpoint;
+
+ @Column(name = "pf_p256dh", nullable = false)
+ private String pfP256dh;
+
+ @Column(name = "pf_auth", nullable = false)
+ private String pfAuth;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private SystemUser user;
+
+ // Transient fields
+ @Transient
+ private String title;
+
+ @Transient
+ private String message;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public SystemUser getUser() {
+ return user;
+ }
+
+ public void setUser(SystemUser user) {
+ this.user = user;
+ }
+
+ public String getPfEndpoint() {
+ return pfEndpoint;
+ }
+
+ public void setPfEndpoint(String pfEndpoint) {
+ this.pfEndpoint = pfEndpoint;
+ }
+
+ public String getPfP256dh() {
+ return pfP256dh;
+ }
+
+ public void setPfP256dh(String pfP256dh) {
+ this.pfP256dh = pfP256dh;
+ }
+
+ public String getPfAuth() {
+ return pfAuth;
+ }
+
+ public void setPfAuth(String pfAuth) {
+ this.pfAuth = pfAuth;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return "NotificationSubscription{" + "userId=" + (user != null ? user.getId() : "null") + ", pfEndpoint='"
+ + pfEndpoint + '\'' + ", pfP256dh='" + pfP256dh + '\'' + ", pfAuth='" + pfAuth + '\'' + ", user="
+ + (user != null ? user.toString() : "null") + ", title='" + title + '\'' + ", message='" + message
+ + '\'' + '}';
+ }
+}
diff --git a/src/main/java/org/openelisglobal/notifications/rest/NotificationRestController.java b/src/main/java/org/openelisglobal/notifications/rest/NotificationRestController.java
index 7a6e9010fd..a45f3f2a32 100644
--- a/src/main/java/org/openelisglobal/notifications/rest/NotificationRestController.java
+++ b/src/main/java/org/openelisglobal/notifications/rest/NotificationRestController.java
@@ -1,14 +1,25 @@
package org.openelisglobal.notifications.rest;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.security.Security;
import java.time.OffsetDateTime;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import javax.servlet.http.HttpServletRequest;
+import nl.martijndwars.webpush.PushService;
+import org.apache.http.HttpResponse;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.openelisglobal.login.valueholder.UserSessionData;
import org.openelisglobal.notifications.dao.NotificationDAO;
+import org.openelisglobal.notifications.dao.NotificationSubscriptionDAO;
import org.openelisglobal.notifications.entity.Notification;
+import org.openelisglobal.notifications.entity.NotificationSubscriptions;
import org.openelisglobal.systemuser.service.SystemUserService;
import org.openelisglobal.systemuser.valueholder.SystemUser;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -24,12 +35,18 @@ public class NotificationRestController {
private final NotificationDAO notificationDAO;
private final SystemUserService systemUserService;
+ private final NotificationSubscriptionDAO notificationSubscriptionDAO;
private static final String USER_SESSION_DATA = "userSessionData";
@Autowired
- public NotificationRestController(NotificationDAO notificationDAO, SystemUserService systemUserService) {
+ private ConfigurableEnvironment env;
+
+ @Autowired
+ public NotificationRestController(NotificationDAO notificationDAO, SystemUserService systemUserService,
+ NotificationSubscriptionDAO notificationSubscriptionDAO) {
this.notificationDAO = notificationDAO;
this.systemUserService = systemUserService;
+ this.notificationSubscriptionDAO = notificationSubscriptionDAO;
}
@GetMapping("/notifications/all")
@@ -49,7 +66,64 @@ public ResponseEntity> saveNotification(@PathVariable String userId, @RequestB
notification.setCreatedDate(OffsetDateTime.now());
notification.setReadAt(null);
notificationDAO.save(notification);
- return ResponseEntity.ok().body("Notification saved successfully");
+
+ // Ensure BouncyCastleProvider is added for cryptographic operations
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+
+ NotificationSubscriptions ns = notificationSubscriptionDAO
+ .getNotificationSubscriptionByUserId(Long.valueOf(userId));
+
+ if (ns == null) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Subscription not found");
+ }
+
+ try {
+ // Configure PushService with VAPID keys
+ PushService pushService = new PushService(env.getProperty("vapid.public.key"),
+ env.getProperty("vapid.private.key"), "mailto:your-email@example.com");
+
+ String title = "OpenELIS Global Notification";
+ String body = notification.getMessage();
+ String url = "http://localhost";
+
+ // Create a notification message
+ Map payload = new HashMap<>();
+ payload.put("title", title);
+ payload.put("body", body);
+ payload.put("url", url);
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ String payloadJson = objectMapper.writeValueAsString(payload);
+
+ // Use fully qualified name for web push Notification
+ nl.martijndwars.webpush.Notification webPushNotification = new nl.martijndwars.webpush.Notification(
+ ns.getPfEndpoint(), ns.getPfP256dh(), ns.getPfAuth(), payloadJson);
+
+ HttpResponse response = pushService.send(webPushNotification);
+
+ return ResponseEntity.ok()
+ .body("Push notification sent successfully. Response: " + response.getStatusLine());
+ } catch (Exception e) {
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body("Failed to send push notification: " + e.getMessage());
+ }
+
+ }
+
+ @GetMapping("/notification/pnconfig")
+ public ResponseEntity> getSubscriptionDetails(HttpServletRequest request) {
+ String sysUserId = getSysUserId(request);
+ NotificationSubscriptions ns = notificationSubscriptionDAO
+ .getNotificationSubscriptionByUserId(Long.valueOf(sysUserId));
+
+ if (ns == null) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Subscription not found");
+ }
+
+ return ResponseEntity.ok().body(ns);
+
}
@PutMapping("/notification/markasread/{id}")
@@ -72,6 +146,53 @@ public List getSystemUsers() {
return notificationDAO.getSystemUsers();
}
+ @GetMapping("/notification/public_key")
+ public ResponseEntity