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> getPublicKey() { + + String publicKey = env.getProperty("vapid.public.key"); + + Map response = new HashMap<>(); + response.put("publicKey", publicKey); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/notification/subscribe") + public ResponseEntity subscribe(@RequestBody NotificationSubscriptions notificationSubscription, + HttpServletRequest request) { + + String sysUserId = getSysUserId(request); + + // Fetch the user object + SystemUser user = systemUserService.getUserById(sysUserId); + + // Ensure user object is not null + if (user == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); + } + + // Set the user entity directly + notificationSubscription.setUser(user); + + notificationSubscriptionDAO.saveOrUpdate(notificationSubscription); + + return ResponseEntity.ok().body("Subscribed successfully"); + } + + @PutMapping("/notification/unsubscribe") + public ResponseEntity unsubscribe(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"); + } + + notificationSubscriptionDAO.delete(ns); + + return ResponseEntity.ok().body("Unsubscribed successfully"); + } + protected String getSysUserId(HttpServletRequest request) { UserSessionData usd = (UserSessionData) request.getSession().getAttribute(USER_SESSION_DATA); if (usd == null) { diff --git a/src/main/java/org/openelisglobal/referral/fhir/service/FhirReferralServiceImpl.java b/src/main/java/org/openelisglobal/referral/fhir/service/FhirReferralServiceImpl.java index 09aa9cc477..33f24d5c78 100644 --- a/src/main/java/org/openelisglobal/referral/fhir/service/FhirReferralServiceImpl.java +++ b/src/main/java/org/openelisglobal/referral/fhir/service/FhirReferralServiceImpl.java @@ -77,7 +77,6 @@ import org.openelisglobal.testresult.valueholder.TestResult; import org.openelisglobal.typeoftestresult.service.TypeOfTestResultServiceImpl; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -121,9 +120,6 @@ public class FhirReferralServiceImpl implements FhirReferralService { @Autowired private FhirConfig fhirConfig; - @Value("${org.openelisglobal.remote.source.identifier:}#{T(java.util.Collections).emptyList()}") - private List remoteStoreIdentifier; - private final String RESULT_SUBJECT = "Result Note"; private String RESULT_TABLE_ID; private String RESULT_REPORT_ID; @@ -246,9 +242,9 @@ public Task createReferralTask(Organization referralOrganization, Patient patien if (requester.isPresent()) { task.setRequester(fhirTransformService.createReferenceFor(requester.get())); } - if (!remoteStoreIdentifier.isEmpty()) { + if (!fhirConfig.getRemoteStoreIdentifier().isEmpty()) { task.setRestriction(new TaskRestrictionComponent() - .setRecipient(Arrays.asList(new Reference(remoteStoreIdentifier.get(0))))); + .setRecipient(Arrays.asList(new Reference(fhirConfig.getRemoteStoreIdentifier().get(0))))); } task.setAuthoredOn(new Date()); task.setStatus(TaskStatus.REQUESTED); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ba6fc4fb5b..02b7c19f20 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -45,3 +45,9 @@ server.ssl.key-password = testtest server.ssl.trust-store=file:/ssl/lf.truststore server.ssl.trust-store-password=testtest +# Push Notification config + +vapid.public.key=BJDIyXHWK_o9fYNwD3fUie2Ed04-yx5fxz9-GUT1c0QhfdDiGMvVbJwvB_On3XapXqIRR471uh7Snw3bfPt9niw +vapid.private.key=FVONpka44MuWq6U8l3X4HY1hAfWM1v1IQB698gsS0KQ + + diff --git a/src/main/resources/liquibase/2.8.x.x/base.xml b/src/main/resources/liquibase/2.8.x.x/base.xml index a1c63ed783..2a96ccf4be 100644 --- a/src/main/resources/liquibase/2.8.x.x/base.xml +++ b/src/main/resources/liquibase/2.8.x.x/base.xml @@ -20,6 +20,7 @@ + diff --git a/src/main/resources/liquibase/2.8.x.x/notification_subscriptions.xml b/src/main/resources/liquibase/2.8.x.x/notification_subscriptions.xml new file mode 100644 index 0000000000..c75c6ff2b1 --- /dev/null +++ b/src/main/resources/liquibase/2.8.x.x/notification_subscriptions.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + +