diff --git a/.active.txt.swp b/.active.txt.swp new file mode 100644 index 000000000..9ed1af507 Binary files /dev/null and b/.active.txt.swp differ diff --git a/.github/ISSUE_TEMPLATE/report-bug.md b/.github/ISSUE_TEMPLATE/report-bug.md index 3ee790e33..0893dd2b1 100644 --- a/.github/ISSUE_TEMPLATE/report-bug.md +++ b/.github/ISSUE_TEMPLATE/report-bug.md @@ -1,7 +1,7 @@ --- name: Report a Bug -about: Template for reporting a Snomio bug -description: "Template for reporting a Snomio bug" +about: Template for reporting a Lingo bug +description: "Template for reporting a Lingo bug" title: '' labels: "bug" assignees: '' @@ -9,10 +9,12 @@ assignees: '' --- ### ENVIRONMENT DETAILS * **Environment (Dev/UAT/Prod):** +* **Browser:** * **Date observed:** * **Observed by:** ### OBSERVED BEHAVIOUR - ### EXPECTED BEHAVIOUR + +### STEPS TO REPRODUCE diff --git a/.github/ISSUE_TEMPLATE/user-story.md b/.github/ISSUE_TEMPLATE/user-story.md index 016ec13d2..ce2f74671 100644 --- a/.github/ISSUE_TEMPLATE/user-story.md +++ b/.github/ISSUE_TEMPLATE/user-story.md @@ -1,6 +1,6 @@ --- name: User story -about: User story template for Snomio +about: User story template for Lingo title: '' labels: '' assignees: '' diff --git a/.gitignore b/.gitignore index 750a82001..f52f227bb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ .DS_Store api/src/main/resources/static ui/reports -setenv.sh \ No newline at end of file +setenv.sh +sergio-extension/dependency-reduced-pom.xml +/eclrefset/target/ diff --git a/.trivyignore b/.trivyignore index 1f3565fcf..60f4a1f72 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,14 +1,3 @@ -# This is 7 hours old and eclipse base image is not updated yet -CVE-2023-4911 -CVE-2023-34062 -CVE-2023-6378 -CVE-2023-34054 -CVE-2023-34055 -CVE-2023-34053 -# No fixed version yet 10/01/24 -CVE-2023-51074 -CVE-2024-22243 -# No fixed version yet - 22/02/24 -CVE-2024-22234 -# Requires Spring boot starter upgrade -CVE-2024-22262 +# Reactor-netty, in spring-boot-starter-webflux, there is currently not a fix for it, also does not affect us as it is +# A vulnerability that only affects windows machines. +CVE-2024-47535 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f8d8f18eb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,157 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The following sections are considered for each release: **Added, Changed, Fixed, Security, Deprecated, Removed** + +## [Unreleased] +- No updates yet. + + +## [1.2.10.6] - 2025-07-02 + +### Fixed + +- Fixed handling of primitive concept selection as component of a multicomponent, multipack product in one of the packs + +## [1.2.10.5] - 2025-06-24 + +### Fixed + +- Detection of datatypes or object properties based on the SnowstormRelationship.getConcrete() Boolean result was unreliable due to null values. This led to missing clauses in the ECL and overly broad detection of existing concepts. + +## [1.2.10.4] - 2025-05-29 + +### Added + +- Ability to drag/drop attachments onto tickets +- Can now filter for refsets with inactive concepts +- Can now create tasks on the snodine page, on any project +- Create additional synonyms in the 7 box create screen + +### Changed + +- Sort refset members by title +- Reference sets are now handled on the front end, all data is queried when a reference set is loaded + +### Fixed + +- Pagination of reference set members now extends beyond 10,000 +- Issues with the artgid reference set +- Issues with the semantic tags when the duplicate fsn/pt warning would appear on the 7 box create screen + +## [1.2.10.3] - 2025-04-24 + +### Added + +- Ability to manually lock Snomio processes during a release week (#1350) +- Display a concept model diagram for a concept using the inferred view (#870) +- Create additional synonyms for new product concepts within the 7-box Preview screen (#159) +- Ticket management: Attach multiple files to a ticket in a single manual operation (#1263) +- Separation of semantic tags from concept descriptions in the 7-box Preview screen (#1354) +- Validation of concept semantic tags within the Preview screen (#1353) +- Sergio: Add/update additional URLs targeting product information (#1262) + +### Changed + +- In the dashboard screen, add the saved filter's name as a title for each cell (#867) +- 7-box Preview screen does not remove newline characters from manually-edited descriptions (#1328) +- Task Management: Sort tasks by reviewers (#1303) +- Sergio: Remove BlackTriangle label from ticket when the product is no longer BTS (#1370) +- Sergio: Additional stabilisation improvements (#1300) +- Snodine: Overnight processing should skip broken reference sets without aborting the entire run (#1398) + +### Fixed + +- Unable to edit the description of a multi-component product (#1323) +- Sergio: Tickets are being updated with incomplete comments (#1277) +- Sergio: Incorrect title for product URL in tickets (#1409) + +## [1.2.10.2] - 2025-02-27 + + +## [1.2.10.1] - 2025-02-27 + +## [1.2.9] - 2024-12-06 +### Added +- "Help & Support" button for reporting bugs or requesting features + * You need to specify your name and email address, then an internal ticket (which is neither Jira nor Snomio) will be generated. + * The system will capture some of the browser logs, the URL you're on, etc. and you can also add a screenshot. + * The development team is notified when a ticket is created, and bug fixes and features will be added to the Snomio backlog as required for tracking. + * This system will also automatically report backend errors encountered by Snomio, for example if the server gets a random error while communicating with Snowstorm. + * If you are unsure of whether an on-screen behaviour is an issue, or you have an initial suggestion for a complex feature, please continue to use the Teams channels for discussing interactively first. +- Add and remove ARTG IDs from existing concepts + * From within a task, when the atomic data entry form is displayed one of the authoring options is “Edit Product”. + * After searching for and selecting a product, all of the CTPPs will have a pencil icon for editing the concept. + * In addition to adding and removing ARTG IDs, the FSN and PT of the CTPP can be modified as well. + * A history of these editing changes is not currently being recorded against the ticket. This will be added after a design discussion around recording other product/concept changes has occurred (e.g. when modifying a product by using the atomic data entry form). + * Until the history of changes is being recorded against a ticket, users should record these updates as a comment on the ticket – so reviewers can check the changes using the Authoring Platform. +- Addition of environment-specific Lingo logo + * To match the Authoring Platform: Production = blue, UAT = green, Dev = red + * The logo colour helps ensure users (and testers) are working within the correct environment. +- Modify and Delete saved ticket backlog filters + * Available within the System Settings after clicking the user’s name. + * The new modification feature currently only modifies the name of the filter. You can still modify the way the filter works by applying it to the backlog, and then saving over the top of the existing filter – you just could never change its name before. +- Preview for attachments in the Edit Ticket screen + * The following document types will offer a preview: png/jpg/jpeg, pdf + * These are among the most commonly-used attachment file types. Extending the preview to other file types will require significant development effort. + * Attachments can still be downloaded from the tickets. +### Changed +- Users can highlight/copy text from list rows + * All lists (except the list of tickets on a task) can have their row text highlighted and copied to the clipboard (e.g. ticket numbers in the Sergio notifications). + * Previously, dragging the cursor across the row would cause a drag-and-drop event to start. +- Within the 7-box model screens, when viewing the list of reference sets for a concept they are now sorted alphabetically +- Internal changes to support open-sourcing of Snomio + * These changes do not affect the functionality of the applications, just makes them more generic for external developers to view and use. + * These changes affected a wide range of code across the platform, which has then undergone regression testing to ensure it will be suitable for deployment. +### Fixed +- In the product Advanced Search feature, entering a 5-digit number would cause an error message to be displayed + * This was related to the Advanced Search’s inherent ability to search for an ARTG ID. +- Clicking the title of a ticket in the Edit Task screen sends the user to the Backlog list and then draws the Edit Ticket panel (instead of leaving the Edit Task screen open in the background) +- When associating a task with a ticket in the Edit Task panel, task titles are artificially truncated +- Ticket management: when a ticket is assigned to a user, the comments saved against the ticket are shown to have been created by the new user + * This was just a display issue; the data underneath was not being overwritten. +- Unable to associate specific tickets with tasks + * The original list of tickets was manually fixed in the Production environment. + * Further changes have now occurred to reduce the chance of this occurring in the future, and to ensure that clearing the associated task from a ticket would occur without performing misleading validation. +- Manage Products from Deleted Task When Reassigning Ticket to New Task + * After creating a product against a task, and then deleting that task and associating the ticket with another task, the saved product is still present in the Products list for the ticket. + * Users used to be able to click the product to view its 7-box model diagram, even though the concepts no longer exist. Now the only option is to upload the product’s details into the atomic data entry form. + * Products which have been saved against the ticket but are not available within the current task will be coloured green (because they are completed) however they will also have a warning logo displayed in the row. +- 7-box Preview screen: Concept heading box should be red when multiple concepts exist for a box (was displaying as green, which was misleading) +- 7-box Preview screen: Unknown validation error occurs after selecting a concept option (when multiple potential concepts have been found) and reloading screen + * Atomic data saved against a ticket can immediately be loaded into the atomic data entry form and then previewed; the 7-box Preview screen will show the MPUU (or whichever notable concept that was affected) in red and either an existing concept selected, or a new concept created, from within the screen. +- 7-Box Preview screen: Product details saved against ticket are skewing new product creation calculation + * This was observed when multiple potential MPUUs were found during product creation. After selecting one and saving the product, then loading the product into the atomic data entry form and clicking Preview, the system would automatically select an MPUU instead of offering the original set of options to the user. + +## [1.2.8] + +## [1.2.7] + +## [1.2.5] + +## [1.2.4] + +## [1.2.3] + +## [1.2.2] + +## [1.2.1] + +## [1.2.0] + +## [1.1.3] + +### Fixed + +- a bug causing decimal concrete domain values that were whole numbers to not have a zero decimal + place rendered in the concrete domain value making the classifier think they were integers + +## [1.1.2] + +## [1.1.0] + +Initial testing version diff --git a/LICENSE b/LICENSE index 261eeb9e9..0bea90964 100644 --- a/LICENSE +++ b/LICENSE @@ -175,27 +175,933 @@ END OF TERMS AND CONDITIONS - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +THIRD PARTY COMPONENTS + +The following third party components are distributed with the Software. You +agree to comply with the licence terms for these components as part of +accessing the Software. Other third party software may also be identified in +separate files distributed with the Software. + + (Apache License, Version 2.0) Apache Commons Collections (org.apache.commons:commons-collections4:4.4 - https://commons.apache.org/proper/commons-collections/) + (Apache License, Version 2.0) Apache Commons CSV (org.apache.commons:commons-csv:1.11.0 - https://commons.apache.org/proper/commons-csv/) + (Apache License, Version 2.0) Apache Commons Validator (commons-validator:commons-validator:1.9.0 - http://commons.apache.org/proper/commons-validator/) + (Apache License, Version 2.0) Apache Maven Resources Plugin (org.apache.maven.plugins:maven-resources-plugin:3.3.1 - https://maven.apache.org/plugins/maven-resources-plugin/) + (Eclipse Public License 2.0) AspectJ Weaver (org.aspectj:aspectjweaver:1.9.22.1 - https://www.eclipse.org/aspectj/) + (Apache License, Version 2.0) Awaitility (org.awaitility:awaitility:4.2.1 - http://awaitility.org) + (Apache License, Version 2.0) com.drewnoakes:metadata-extractor (com.drewnoakes:metadata-extractor:2.19.0 - https://drewnoakes.com/code/exif/) + (Apache License, Version 2.0) Ehcache (org.ehcache:ehcache:3.10.8 - http://ehcache.org) + (Apache License, Version 2.0) flyway-core (org.flywaydb:flyway-core:10.10.0 - https://flywaydb.org/flyway-core) + (Apache License, Version 2.0) flyway-database-postgresql (org.flywaydb:flyway-database-postgresql:10.10.0 - https://flywaydb.org/flyway-database-postgresql) + (Apache License, Version 2.0) Gson (com.google.code.gson:gson:2.10.1 - https://github.com/google/gson/gson) + (Apache License, Version 2.0) Gson (com.google.code.gson:gson:2.11.0 - https://github.com/google/gson) + (Eclipse Public License 1.0) (MPL 2.0) H2 Database Engine (com.h2database:h2:2.3.230 - https://h2database.com) + (GNU Library General Public License v2.1 or later) hibernate-jcache - relocation (org.hibernate:hibernate-jcache:6.5.2.Final - https://hibernate.org/orm) + (Apache License, Version 2.0) imgscalr - A Java Image Scaling Library (org.imgscalr:imgscalr-lib:4.2 - http://www.thebuzzmedia.com/software/imgscalr-java-image-scaling-library/) + (Apache License, Version 2.0) Jackson datatype: JSR310 (com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.2 - https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310) + (Apache License, Version 2.0) jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.17.2 - https://github.com/FasterXML/jackson) + (Eclipse Public License 2.0) JaCoCo :: Maven Plugin (org.jacoco:jacoco-maven-plugin:0.8.12 - https://www.jacoco.org/jacoco/trunk/doc/maven.html) + (Apache License, Version 2.0) Java JSON Schema Generator (com.github.victools:jsonschema-generator:4.36.0 - https://github.com/victools/jsonschema-generator) + (Apache License, Version 2.0) Java JSON Schema Generator Module – jackson (com.github.victools:jsonschema-module-jackson:4.36.0 - https://github.com/victools/jsonschema-generator) + (Apache License, Version 2.0) Java JSON Schema Generator Module – jakarta.validation (com.github.victools:jsonschema-module-jakarta-validation:4.36.0 - https://github.com/victools/jsonschema-generator/jsonschema-module-jakarta-validation) + (Eclipse Public License 2.0) (GNU Lesser General Public License Version 2.1, February 1999) JGraphT - Core (org.jgrapht:jgrapht-core:1.5.2 - http://www.jgrapht.org/jgrapht-core) + (Public Domain) JSON in Java (org.json:json:20240303 - https://github.com/douglascrockford/JSON-java) + (Eclipse Public License 2.0) JUnit Jupiter API (org.junit.jupiter:junit-jupiter-api:5.10.3 - https://junit.org/junit5/) + (Eclipse Public License 2.0) JUnit Jupiter Params (org.junit.jupiter:junit-jupiter-params:5.10.3 - https://junit.org/junit5/) + (Apache License, Version 2.0) MapStruct Core (org.mapstruct:mapstruct:1.5.5.Final - https://mapstruct.org/mapstruct/) + (Apache License, Version 2.0) MapStruct Processor (org.mapstruct:mapstruct-processor:1.5.5.Final - https://mapstruct.org/mapstruct-processor/) + (Apache License, Version 2.0) micrometer-registry-prometheus (io.micrometer:micrometer-registry-prometheus:1.13.2 - https://github.com/micrometer-metrics/micrometer) + (Apache License, Version 2.0) mockwebserver (com.squareup.okhttp3:mockwebserver:5.0.0-alpha.12 - https://square.github.io/okhttp/) + (Apache License, Version 2.0) okhttp (com.squareup.okhttp3:okhttp:5.0.0-alpha.12 - https://square.github.io/okhttp/) + (Apache License, Version 2.0) OpenTelemetry Instrumentation for Java (io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:2.6.0 - https://github.com/open-telemetry/opentelemetry-java-instrumentation) + (Apache License Version 2.0, January 2004) (GNU LGPL Version 3.0) OWLAPI :: Interfaces (net.sourceforge.owlapi:owlapi-api:4.5.19 - http://owlcs.github.io/owlapi/owlapi-api/) + (Apache License Version 2.0, January 2004) (GNU LGPL Version 3.0) OWLAPI Binding and Config (net.sourceforge.owlapi:owlapi-apibinding:4.5.19 - http://owlcs.github.io/owlapi/owlapi-apibinding/) + (Apache License Version 2.0, January 2004) (GNU LGPL Version 3.0) OWLAPI Default Implementation (net.sourceforge.owlapi:owlapi-impl:4.5.19 - http://owlcs.github.io/owlapi/owlapi-impl/) + (BSD-2-Clause) PostgreSQL JDBC Driver (org.postgresql:postgresql:42.7.3 - https://jdbc.postgresql.org) + (MIT License) Project Lombok (org.projectlombok:lombok:1.18.30 - https://projectlombok.org) + (MIT License) Project Lombok (org.projectlombok:lombok:1.18.34 - https://projectlombok.org) + (Apache License, Version 2.0) Querydsl - APT support (com.querydsl:querydsl-apt:5.1.0 - http://www.querydsl.com) + (Apache License, Version 2.0) Querydsl - Core module (com.querydsl:querydsl-core:5.1.0 - http://www.querydsl.com) + (Apache License, Version 2.0) Querydsl - JPA support (com.querydsl:querydsl-jpa:5.1.0 - http://www.querydsl.com) + (Apache License, Version 2.0) Querydsl - SQL Spring support (com.querydsl:querydsl-sql-spring:5.1.0 - https://querydsl.github.io/querydsl-sql-spring/) + (Apache License, Version 2.0) Querydsl - SQL support (com.querydsl:querydsl-sql:5.1.0 - https://querydsl.github.io/querydsl-sql/) + (Apache License, Version 2.0) REST Assured (io.rest-assured:rest-assured:5.3.2 - http://code.google.com/p/rest-assured) + (Apache License, Version 2.0) REST Assured (io.rest-assured:rest-assured:5.5.0 - https://rest-assured.io/) + (Apache License, Version 2.0) sergio-extension (au.gov.digitalhealth:sergio-extension:1.2.4-SNAPSHOT - https://spring.io/projects/spring-boot/snomio/sergio-extension) + (Apache License, Version 2.0) SNOMED CT Expression Constraint Language Parser (org.snomed.languages:snomed-ecl-parser:3.0.0 - no url defined) + (Apache License, Version 2.0) snomed-owl-toolkit (org.snomed.otf:snomed-owl-toolkit:5.4.0 - no url defined) + (Apache License, Version 2.0) snomio api (au.csiro:snowstorm-java-client:8.1.1 - no url defined) + (Apache License, Version 2.0) snomio-auth (au.gov.digitalhealth:snomio-auth:1.2.4-SNAPSHOT - https://spring.io/projects/spring-boot/snomio/snomio-auth) + (Apache License, Version 2.0) snomio-common (au.gov.digitalhealth:snomio-common:1.2.4-SNAPSHOT - https://spring.io/projects/spring-boot/snomio/snomio-common) + (Apache License, Version 2.0) Spring Context (org.springframework:spring-context:6.1.11 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Data Core (org.springframework.data:spring-data-commons:3.3.2 - https://spring.io/projects/spring-data) + (Apache License, Version 2.0) Spring Data Envers (org.springframework.data:spring-data-envers:3.3.2 - https://spring.io/projects/spring-data-jpa/spring-data-envers) + (Apache License, Version 2.0) Spring Web (org.springframework:spring-web:6.1.6 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) spring-boot (org.springframework.boot:spring-boot:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-configuration-processor (org.springframework.boot:spring-boot-configuration-processor:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter (org.springframework.boot:spring-boot-starter:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-actuator (org.springframework.boot:spring-boot-starter-actuator:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-amqp (org.springframework.boot:spring-boot-starter-amqp:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-aop (org.springframework.boot:spring-boot-starter-aop:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-data-jpa (org.springframework.boot:spring-boot-starter-data-jpa:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-hateoas (org.springframework.boot:spring-boot-starter-hateoas:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security (org.springframework.boot:spring-boot-starter-security:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-test (org.springframework.boot:spring-boot-starter-test:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-validation (org.springframework.boot:spring-boot-starter-validation:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-web (org.springframework.boot:spring-boot-starter-web:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-webflux (org.springframework.boot:spring-boot-starter-webflux:3.3.2 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) springdoc-openapi-starter-webmvc-ui (org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0 - https://springdoc.org/springdoc-openapi-starter-webmvc-ui/) + (MIT License) Testcontainers :: JDBC :: PostgreSQL (org.testcontainers:postgresql:1.19.8 - https://java.testcontainers.org) + (MIT License) Testcontainers :: JUnit Jupiter Extension (org.testcontainers:junit-jupiter:1.19.8 - https://java.testcontainers.org) + (MIT License) Testcontainers :: RabbitMQ (org.testcontainers:rabbitmq:1.19.8 - https://java.testcontainers.org) + (MIT License) Testcontainers Core (org.testcontainers:testcontainers:1.19.8 - https://java.testcontainers.org) + (BSD License) TwelveMonkeys :: ImageIO :: WebP plugin (com.twelvemonkeys.imageio:imageio-webp:3.11.0 - https://github.com/haraldk/TwelveMonkeys/tree/master/imageio/imageio-webp) + (Apache License, Version 2.0) WireMock (com.github.tomakehurst:wiremock-standalone:3.0.0 - https://wiremock.org) + +# NPM Dependencies and Licenses + + (MIT) @adobe/css-tools@4.4.0 + (Apache-2.0) @ampproject/remapping@2.3.0 + (MIT) @ant-design/colors@7.1.0 + (MIT) @ant-design/icons-svg@4.4.2 + (MIT) @ant-design/icons@5.4.0 + (MIT) @asamuzakjp/dom-selector@2.0.2 + (MIT) @babel/code-frame@7.24.7 + (MIT) @babel/compat-data@7.25.2 + (MIT) @babel/core@7.25.2 + (MIT) @babel/generator@7.25.0 + (MIT) @babel/helper-compilation-targets@7.25.2 + (MIT) @babel/helper-module-imports@7.24.7 + (MIT) @babel/helper-module-transforms@7.25.2 + (MIT) @babel/helper-plugin-utils@7.24.8 + (MIT) @babel/helper-simple-access@7.24.7 + (MIT) @babel/helper-string-parser@7.24.8 + (MIT) @babel/helper-validator-identifier@7.24.7 + (MIT) @babel/helper-validator-option@7.24.8 + (MIT) @babel/helpers@7.25.0 + (MIT) @babel/highlight@7.24.7 + (MIT) @babel/parser@7.25.0 + (MIT) @babel/plugin-transform-react-jsx-self@7.24.7 + (MIT) @babel/plugin-transform-react-jsx-source@7.24.7 + (MIT) @babel/runtime@7.25.0 + (MIT) @babel/template@7.25.0 + (MIT) @babel/traverse@7.25.2 + (MIT) @babel/types@7.25.2 + (MIT) @colors/colors@1.5.0 + (MIT) @ctrl/tinycolor@3.6.1 + (Apache-2.0) @cypress/request@3.0.1 + (MIT) @cypress/xvfb@1.2.4 + (MIT) @dnd-kit/accessibility@3.1.0 + (MIT) @dnd-kit/core@6.1.0 + (MIT) @dnd-kit/sortable@8.0.0 + (MIT) @dnd-kit/utilities@3.2.2 + (MIT) @emotion/babel-plugin@11.12.0 + (MIT) @emotion/cache@11.13.1 + (MIT) @emotion/hash@0.9.2 + (MIT) @emotion/is-prop-valid@0.8.8 + (MIT) @emotion/is-prop-valid@1.3.0 + (MIT) @emotion/memoize@0.7.4 + (MIT) @emotion/memoize@0.9.0 + (MIT) @emotion/react@11.13.0 + (MIT) @emotion/serialize@1.3.0 + (MIT) @emotion/sheet@1.4.0 + (MIT) @emotion/styled@11.13.0 + (MIT) @emotion/unitless@0.9.0 + (MIT) @emotion/use-insertion-effect-with-fallbacks@1.1.0 + (MIT) @emotion/utils@1.4.0 + (MIT) @emotion/weak-memoize@0.4.0 + (MIT) @esbuild/darwin-arm64@0.19.12 + (MIT) @eslint-community/eslint-utils@4.4.0 + (MIT) @eslint-community/regexpp@4.11.0 + (MIT) @eslint/eslintrc@2.1.4 + (MIT) @eslint/js@8.57.0 + (MIT) @floating-ui/core@1.6.5 + (MIT) @floating-ui/dom@1.6.8 + (MIT) @floating-ui/react-dom@2.1.1 + (MIT) @floating-ui/utils@0.2.5 + (MIT) @formatjs/ecma402-abstract@2.0.0 + (MIT) @formatjs/fast-memoize@2.2.0 + (MIT) @formatjs/icu-messageformat-parser@2.7.8 + (MIT) @formatjs/icu-skeleton-parser@1.8.2 + (MIT) @formatjs/intl-displaynames@6.6.8 + (MIT) @formatjs/intl-listformat@7.5.7 + (MIT) @formatjs/intl-localematcher@0.5.4 + (MIT) @formatjs/intl@2.10.4 + (MIT) @hookform/resolvers@3.9.0 + (Apache-2.0) @humanwhocodes/config-array@0.11.14 + (Apache-2.0) @humanwhocodes/module-importer@1.0.1 + (BSD-3-Clause) @humanwhocodes/object-schema@2.0.3 + (MIT) @jest/expect-utils@29.7.0 + (MIT) @jest/schemas@29.6.3 + (MIT) @jest/types@29.6.3 + (MIT) @jridgewell/gen-mapping@0.3.5 + (MIT) @jridgewell/resolve-uri@3.1.2 + (MIT) @jridgewell/set-array@1.2.1 + (MIT) @jridgewell/sourcemap-codec@1.5.0 + (MIT) @jridgewell/trace-mapping@0.3.25 + (MIT) @mui/base@5.0.0-beta.10 + (MIT) @mui/base@5.0.0-beta.40 + (MIT) @mui/base@5.0.0-beta.9 + (MIT) @mui/core-downloads-tracker@5.16.5 + (MIT) @mui/icons-material@5.14.3 + (MIT) @mui/lab@5.0.0-alpha.139 + (MIT) @mui/material@5.14.3 + (MIT) @mui/private-theming@5.16.5 + (MIT) @mui/styled-engine@5.16.4 + (MIT) @mui/system@5.12.1 + (MIT) @mui/system@5.16.5 + (MIT) @mui/types@7.2.15 + (MIT) @mui/utils@5.16.5 + (MIT) @mui/x-data-grid@6.11.0 + (MIT) @mui/x-date-pickers@6.16.1 + (MIT) @nodelib/fs.scandir@2.1.5 + (MIT) @nodelib/fs.stat@2.0.5 + (MIT) @nodelib/fs.walk@1.2.8 + (Apache-2.0) @opentelemetry/api-logs@0.50.0 + (Apache-2.0) @opentelemetry/api@1.8.0 + (Apache-2.0) @opentelemetry/auto-instrumentations-web@0.38.0 + (Apache-2.0) @opentelemetry/context-zone-peer-dep@1.25.1 + (Apache-2.0) @opentelemetry/context-zone@1.25.1 + (Apache-2.0) @opentelemetry/core@1.23.0 + (Apache-2.0) @opentelemetry/core@1.25.1 + (Apache-2.0) @opentelemetry/exporter-zipkin@1.25.1 + (Apache-2.0) @opentelemetry/instrumentation-document-load@0.37.0 + (Apache-2.0) @opentelemetry/instrumentation-fetch@0.50.0 + (Apache-2.0) @opentelemetry/instrumentation-user-interaction@0.37.0 + (Apache-2.0) @opentelemetry/instrumentation-xml-http-request@0.50.0 + (Apache-2.0) @opentelemetry/instrumentation@0.50.0 + (Apache-2.0) @opentelemetry/propagator-b3@1.25.1 + (Apache-2.0) @opentelemetry/resources@1.23.0 + (Apache-2.0) @opentelemetry/resources@1.25.1 + (Apache-2.0) @opentelemetry/sdk-trace-base@1.23.0 + (Apache-2.0) @opentelemetry/sdk-trace-base@1.25.1 + (Apache-2.0) @opentelemetry/sdk-trace-web@1.23.0 + (Apache-2.0) @opentelemetry/sdk-trace-web@1.25.1 + (Apache-2.0) @opentelemetry/semantic-conventions@1.23.0 + (Apache-2.0) @opentelemetry/semantic-conventions@1.25.1 + (MIT) @popperjs/core@2.11.8 + (MIT) @reduxjs/toolkit@2.2.7 + (MIT) @remirror/core-constants@2.0.2 + (MIT) @remix-run/router@1.18.0 + (MIT) @rollup/rollup-darwin-arm64@4.19.1 + (MIT) @sinclair/typebox@0.27.8 + (MIT) @socket.io/component-emitter@3.1.2 + (MIT) @tabler/icons-react@2.47.0 + (MIT) @tabler/icons@2.47.0 + (MIT) @tanstack/query-core@4.36.1 + (MIT) @tanstack/query-core@5.51.15 + (MIT) @tanstack/react-query@4.36.1 + (MIT) @tanstack/react-query@5.51.15 + (MIT) @testing-library/dom@9.3.4 + (MIT) @testing-library/jest-dom@5.17.0 + (MIT) @testing-library/react@14.3.1 + (MIT) @tiptap/core@2.5.7 + (MIT) @tiptap/extension-blockquote@2.5.7 + (MIT) @tiptap/extension-bold@2.5.7 + (MIT) @tiptap/extension-bubble-menu@2.5.7 + (MIT) @tiptap/extension-bullet-list@2.5.7 + (MIT) @tiptap/extension-code-block@2.5.7 + (MIT) @tiptap/extension-code@2.5.7 + (MIT) @tiptap/extension-color@2.5.7 + (MIT) @tiptap/extension-document@2.5.7 + (MIT) @tiptap/extension-dropcursor@2.5.7 + (MIT) @tiptap/extension-floating-menu@2.5.7 + (MIT) @tiptap/extension-font-family@2.5.7 + (MIT) @tiptap/extension-gapcursor@2.5.7 + (MIT) @tiptap/extension-hard-break@2.5.7 + (MIT) @tiptap/extension-heading@2.5.7 + (MIT) @tiptap/extension-highlight@2.5.7 + (MIT) @tiptap/extension-history@2.5.7 + (MIT) @tiptap/extension-horizontal-rule@2.5.7 + (MIT) @tiptap/extension-image@2.5.7 + (MIT) @tiptap/extension-italic@2.5.7 + (MIT) @tiptap/extension-link@2.5.7 + (MIT) @tiptap/extension-list-item@2.5.7 + (MIT) @tiptap/extension-ordered-list@2.5.7 + (MIT) @tiptap/extension-paragraph@2.5.7 + (MIT) @tiptap/extension-placeholder@2.5.7 + (MIT) @tiptap/extension-strike@2.5.7 + (MIT) @tiptap/extension-subscript@2.5.7 + (MIT) @tiptap/extension-superscript@2.5.7 + (MIT) @tiptap/extension-table-cell@2.5.7 + (MIT) @tiptap/extension-table-header@2.5.7 + (MIT) @tiptap/extension-table-row@2.5.7 + (MIT) @tiptap/extension-table@2.5.7 + (MIT) @tiptap/extension-task-item@2.5.7 + (MIT) @tiptap/extension-task-list@2.5.7 + (MIT) @tiptap/extension-text-align@2.5.7 + (MIT) @tiptap/extension-text-style@2.5.7 + (MIT) @tiptap/extension-text@2.5.7 + (MIT) @tiptap/extension-underline@2.5.7 + (MIT) @tiptap/pm@2.5.7 + (MIT) @tiptap/react@2.5.7 + (MIT) @tiptap/starter-kit@2.5.7 + (MIT) @types/antlr4@4.11.6 + (MIT) @types/aria-query@5.0.4 + (MIT) @types/babel__core@7.20.5 + (MIT) @types/babel__generator@7.6.8 + (MIT) @types/babel__template@7.4.4 + (MIT) @types/babel__traverse@7.20.6 + (MIT) @types/estree@1.0.5 + (MIT) @types/fhir@0.0.35 + (MIT) @types/fhir@0.0.41 + (MIT) @types/file-saver@2.0.7 + (MIT) @types/hoist-non-react-statics@3.3.5 + (MIT) @types/istanbul-lib-coverage@2.0.6 + (MIT) @types/istanbul-lib-report@3.0.3 + (MIT) @types/istanbul-reports@3.0.4 + (MIT) @types/jest@29.5.12 + (MIT) @types/json-schema@7.0.15 + (MIT) @types/lodash-es@4.17.12 + (MIT) @types/lodash@4.17.7 + (MIT) @types/node@22.0.0 + (MIT) @types/parse-json@4.0.2 + (MIT) @types/prop-types@15.7.12 + (MIT) @types/react-copy-to-clipboard@5.0.7 + (MIT) @types/react-dom@18.3.0 + (MIT) @types/react-gravatar@2.6.14 + (MIT) @types/react-transition-group@4.4.10 + (MIT) @types/react@18.3.3 + (MIT) @types/semver@7.5.8 + (MIT) @types/shimmer@1.2.0 + (MIT) @types/sinonjs__fake-timers@8.1.1 + (MIT) @types/sizzle@2.3.8 + (MIT) @types/sockjs-client@1.5.4 + (MIT) @types/stack-utils@2.0.3 + (MIT) @types/stompjs@2.3.9 + (MIT) @types/testing-library__jest-dom@5.14.9 + (MIT) @types/use-sync-external-store@0.0.3 + (MIT) @types/use-sync-external-store@0.0.6 + (MIT) @types/uuid@8.3.4 + (MIT) @types/yargs-parser@21.0.3 + (MIT) @types/yargs@17.0.32 + (MIT) @types/yauzl@2.10.3 + (MIT) @typescript-eslint/eslint-plugin@5.62.0 + (BSD-2-Clause) @typescript-eslint/parser@5.62.0 + (MIT) @typescript-eslint/scope-manager@5.62.0 + (MIT) @typescript-eslint/type-utils@5.62.0 + (MIT) @typescript-eslint/types@5.62.0 + (BSD-2-Clause) @typescript-eslint/typescript-estree@5.62.0 + (MIT) @typescript-eslint/utils@5.62.0 + (MIT) @typescript-eslint/visitor-keys@5.62.0 + (ISC) @ungap/structured-clone@1.2.0 + (MIT) @vitejs/plugin-basic-ssl@1.1.0 + (MIT) @vitejs/plugin-react@4.3.1 + (MIT) @vitest/expect@1.6.0 + (MIT) @vitest/runner@1.6.0 + (MIT) @vitest/snapshot@1.6.0 + (MIT) @vitest/spy@1.6.0 + (MIT) @vitest/utils@1.6.0 + (ISC) abbrev@1.1.1 + (MIT) acorn-import-assertions@1.9.0 + (MIT) acorn-jsx@5.3.2 + (MIT) acorn-walk@8.3.3 + (MIT) acorn@8.12.1 + (MIT) add-px-to-style@1.0.0 + (MIT) agent-base@7.1.1 + (MIT) aggregate-error@3.1.0 + (MIT) ajv@6.12.6 + (MIT) ansi-colors@4.1.3 + (MIT) ansi-escapes@4.3.2 + (MIT) ansi-regex@5.0.1 + (MIT) ansi-styles@3.2.1 + (MIT) ansi-styles@4.3.0 + (MIT) ansi-styles@5.2.0 + (BSD-3-Clause) antlr4@4.10.1 + (MIT) arch@2.2.0 + (Python-2.0) argparse@2.0.1 + (Apache-2.0) aria-query@5.1.3 + (Apache-2.0) aria-query@5.3.0 + (MIT) array-buffer-byte-length@1.0.1 + (MIT) array-find-index@1.0.2 + (MIT) array-union@2.1.0 + (MIT) asap@2.0.6 + (MIT) asn1@0.2.6 + (MIT) assert-plus@1.0.0 + (MIT) assertion-error@1.1.0 + (MIT) astral-regex@2.0.0 + (MIT) async@3.2.5 + (MIT) asynckit@0.4.0 + (ISC) at-least-node@1.0.0 + (MIT) attr-accept@2.2.2 + (MIT) available-typed-arrays@1.0.7 + (Apache-2.0) aws-sign2@0.7.0 + (MIT) aws4@1.13.0 + (MPL-2.0) axe-core@4.10.0 + (MIT) axios@1.7.2 + (MIT) babel-plugin-macros@3.1.0 + (MIT) balanced-match@1.0.2 + (MIT) base64-js@1.5.1 + (BSD-3-Clause) bcrypt-pbkdf@1.0.2 + (MIT) bidi-js@1.0.3 + (MIT) big.js@6.2.1 + (Apache-2.0) blob-util@2.0.2 + (MIT) bluebird@3.7.2 + (MIT) brace-expansion@1.1.11 + (MIT) braces@3.0.3 + (MIT) browserslist@4.23.2 + (MIT) buffer-crc32@0.2.13 + (MIT) buffer@5.7.1 + (MIT) bufferutil@4.0.8 + (MIT) cac@6.7.14 + (MIT) cachedir@2.4.0 + (MIT) call-bind@1.0.7 + (MIT) callsites@3.1.0 + (CC-BY-4.0) caniuse-lite@1.0.30001643 + (Apache-2.0) caseless@0.12.0 + (MIT) chai@4.5.0 + (MIT) chalk@2.4.2 + (MIT) chalk@3.0.0 + (MIT) chalk@4.1.2 + (BSD-3-Clause) charenc@0.0.2 + (MIT) check-error@1.0.3 + (MIT) check-more-types@2.24.0 + (MIT) ci-info@3.9.0 + (MIT) cjs-module-lexer@1.3.1 + (MIT) classnames@2.5.1 + (MIT) clean-stack@2.2.0 + (MIT) cli-cursor@3.1.0 + (MIT) cli-table3@0.6.5 + (MIT) cli-truncate@2.1.0 + (MIT) clsx@1.2.1 + (MIT) clsx@2.1.1 + (MIT) color-convert@1.9.3 + (MIT) color-convert@2.0.1 + (MIT) color-name@1.1.3 + (MIT) color-name@1.1.4 + (MIT) colorette@2.0.20 + (MIT) combined-stream@1.0.8 + (MIT) commander@6.2.1 + (MIT) common-tags@1.8.2 + (MIT) concat-map@0.0.1 + (MIT) confbox@0.1.7 + (MIT) convert-source-map@1.9.0 + (MIT) convert-source-map@2.0.0 + (MIT) copy-to-clipboard@3.3.3 + (MIT) core-util-is@1.0.2 + (MIT) cosmiconfig@7.1.0 + (MIT) crelt@1.0.6 + (MIT) cross-spawn@7.0.3 + (BSD-3-Clause) crypt@0.0.2 + (MIT) css-tree@2.3.1 + (MIT) css.escape@1.5.1 + (MIT) cssstyle@4.0.1 + (MIT) csstype@3.1.3 + (MIT) cypress-axe@1.5.0 + (MIT) cypress-promise@1.1.0 + (MIT) cypress@13.13.1 + (ISC) d@1.0.2 + (MIT) dashdash@1.14.1 + (MIT) data-urls@5.0.0 + (MIT) dayjs@1.11.12 + (MIT) debug@2.6.9 + (MIT) debug@3.2.7 + (MIT) debug@4.3.6 + (MIT) debuglog@1.0.1 + (MIT) decimal.js@10.4.3 + (MIT) deep-eql@4.1.4 + (MIT) deep-equal@2.2.3 + (MIT) deep-is@0.1.4 + (MIT) define-data-property@1.1.4 + (MIT) define-properties@1.2.1 + (MIT) delayed-stream@1.0.0 + (MIT) dequal@2.0.3 + (ISC) dezalgo@1.0.4 + (MIT) diff-sequences@29.6.3 + (MIT) dir-glob@3.0.1 + (Apache-2.0) doctrine@3.0.0 + (MIT) dom-accessibility-api@0.5.16 + (MIT) dom-css@2.1.0 + (MIT) dom-helpers@5.2.1 + (BSD-2-Clause) dotenv@16.4.5 + (MIT) ecc-jsbn@0.1.2 + (Apache-2.0) ecl-builder@0.2.1 + (ISC) electron-to-chromium@1.5.3 + (MIT) emoji-regex@8.0.0 + (MIT) encodeurl@1.0.2 + (MIT) end-of-stream@1.4.4 + (MIT) engine.io-client@6.5.4 + (MIT) engine.io-parser@5.2.3 + (MIT) enquirer@2.4.1 + (BSD-2-Clause) entities@4.5.0 + (MIT) error-ex@1.3.2 + (MIT) es-define-property@1.0.0 + (MIT) es-errors@1.3.0 + (MIT) es-get-iterator@1.1.3 + (ISC) es5-ext@0.10.64 + (MIT) es6-iterator@2.0.3 + (ISC) es6-symbol@3.1.4 + (MIT) esbuild@0.19.12 + (MIT) escalade@3.1.2 + (MIT) escape-string-regexp@1.0.5 + (MIT) escape-string-regexp@2.0.0 + (MIT) escape-string-regexp@4.0.0 + (MIT) eslint-plugin-react-hooks@4.6.2 + (MIT) eslint-plugin-react-refresh@0.4.9 + (BSD-2-Clause) eslint-scope@5.1.1 + (BSD-2-Clause) eslint-scope@7.2.2 + (Apache-2.0) eslint-visitor-keys@3.4.3 + (MIT) eslint@8.57.0 + (ISC) esniff@2.0.1 + (BSD-2-Clause) espree@9.6.1 + (BSD-3-Clause) esquery@1.6.0 + (BSD-2-Clause) esrecurse@4.3.0 + (BSD-2-Clause) estraverse@4.3.0 + (BSD-2-Clause) estraverse@5.3.0 + (MIT) estree-walker@3.0.3 + (BSD-2-Clause) esutils@2.0.3 + (MIT) event-emitter@0.3.5 + (MIT) eventemitter2@6.4.7 + (MIT) eventsource@2.0.2 + (MIT) execa@4.1.0 + (MIT) execa@8.0.1 + (MIT) executable@4.1.1 + (MIT) expect@29.7.0 + (ISC) ext@1.7.0 + (MIT) extend@3.0.2 + (BSD-2-Clause) extract-zip@2.0.1 + (MIT) extsprintf@1.3.0 + (MIT) fast-deep-equal@3.1.3 + (MIT) fast-glob@3.3.2 + (MIT) fast-json-stable-stringify@2.1.0 + (MIT) fast-levenshtein@2.0.6 + (ISC) fastq@1.17.1 + (Apache-2.0) faye-websocket@0.11.4 + (MIT) fd-slicer@1.1.0 + (MIT) figures@3.2.0 + (MIT) file-entry-cache@6.0.1 + (MIT) file-saver@2.0.5 + (MIT) file-selector@0.6.0 + (MIT) fill-range@7.1.1 + (MIT) find-root@1.1.0 + (MIT) find-up@5.0.0 + (MIT) flat-cache@3.2.0 + (ISC) flatted@3.3.1 + (MIT) follow-redirects@1.15.6 + (MIT) for-each@0.3.3 + (Apache-2.0) forever-agent@0.6.1 + (MIT) form-data@2.3.3 + (MIT) form-data@4.0.0 + (MIT) framer-motion@10.18.0 + (MIT) fs-extra@9.1.0 + (ISC) fs.realpath@1.0.0 + (MIT) fsevents@2.3.3 + (MIT) function-bind@1.1.2 + (MIT) functions-have-names@1.2.3 + (MIT) gensync@1.0.0-beta.2 + (MIT) get-func-name@2.0.2 + (MIT) get-intrinsic@1.2.4 + (MIT) get-stream@5.2.0 + (MIT) get-stream@8.0.1 + (MIT) getos@3.2.1 + (MIT) getpass@0.1.7 + (ISC) glob-parent@5.1.2 + (ISC) glob-parent@6.0.2 + (ISC) glob@7.2.3 + (MIT) global-dirs@3.0.1 + (MIT) globals@11.12.0 + (MIT) globals@13.24.0 + (MIT) globby@11.1.0 + (MIT) goober@2.1.14 + (MIT) gopd@1.0.1 + (ISC) graceful-fs@4.2.11 + (MIT) graphemer@1.4.0 + (MIT) has-bigints@1.0.2 + (MIT) has-flag@3.0.0 + (MIT) has-flag@4.0.0 + (MIT) has-property-descriptors@1.0.2 + (MIT) has-proto@1.0.3 + (MIT) has-symbols@1.0.3 + (MIT) has-tostringtag@1.0.2 + (MIT) hasown@2.0.2 + (BSD-3-Clause) hoist-non-react-statics@3.3.2 + (ISC) hosted-git-info@2.8.9 + (MIT) html-encoding-sniffer@4.0.0 + (MIT) http-parser-js@0.5.8 + (MIT) http-proxy-agent@7.0.2 + (MIT) http-signature@1.3.6 + (MIT) https-proxy-agent@7.0.5 + (Apache-2.0) human-signals@1.1.1 + (Apache-2.0) human-signals@5.0.0 + (MIT) iconv-lite@0.6.3 + (BSD-3-Clause) ieee754@1.2.1 + (MIT) ignore@5.3.1 + (MIT) immer@10.1.1 + (MIT) import-fresh@3.3.0 + (Apache-2.0) import-in-the-middle@1.7.1 + (MIT) imurmurhash@0.1.4 + (MIT) indent-string@4.0.0 + (ISC) inflight@1.0.6 + (ISC) inherits@2.0.4 + (ISC) ini@2.0.0 + (MIT) internal-slot@1.0.7 + (BSD-3-Clause) intl-messageformat@10.5.14 + (MIT) is-arguments@1.1.1 + (MIT) is-array-buffer@3.0.4 + (MIT) is-arrayish@0.2.1 + (MIT) is-bigint@1.0.4 + (MIT) is-boolean-object@1.1.2 + (MIT) is-buffer@1.1.6 + (MIT) is-callable@1.2.7 + (MIT) is-ci@3.0.1 + (MIT) is-core-module@2.15.0 + (MIT) is-date-object@1.0.5 + (MIT) is-extglob@2.1.1 + (MIT) is-fullwidth-code-point@3.0.0 + (MIT) is-glob@4.0.3 + (MIT) is-installed-globally@0.4.0 + (MIT) is-map@2.0.3 + (MIT) is-number-object@1.0.7 + (MIT) is-number@7.0.0 + (MIT) is-path-inside@3.0.3 + (MIT) is-potential-custom-element-name@1.0.1 + (MIT) is-regex@1.1.4 + (MIT) is-retina@1.0.3 + (MIT) is-set@2.0.3 + (MIT) is-shared-array-buffer@1.0.3 + (MIT) is-stream@2.0.1 + (MIT) is-stream@3.0.0 + (MIT) is-string@1.0.7 + (MIT) is-symbol@1.0.4 + (MIT) is-typedarray@1.0.0 + (MIT) is-unicode-supported@0.1.0 + (MIT) is-weakmap@2.0.2 + (MIT) is-weakset@2.0.3 + (MIT) isarray@2.0.5 + (ISC) isexe@2.0.0 + (MIT) isstream@0.1.2 + (MIT) jest-diff@29.7.0 + (MIT) jest-get-type@29.6.3 + (MIT) jest-matcher-utils@29.7.0 + (MIT) jest-message-util@29.7.0 + (MIT) jest-util@29.7.0 + (MIT) js-tokens@4.0.0 + (MIT) js-tokens@9.0.0 + (MIT) js-yaml@4.1.0 + (MIT) jsbn@0.1.1 + (MIT) jsdom@23.2.0 + (MIT) jsesc@2.5.2 + (MIT) json-buffer@3.0.1 + (MIT) json-parse-even-better-errors@2.3.1 + (MIT) json-schema-traverse@0.4.1 + ((AFL-2.1 OR BSD-3-Clause)) json-schema@0.4.0 + (MIT) json-stable-stringify-without-jsonify@1.0.1 + (ISC) json-stringify-safe@5.0.1 + (MIT) json5@2.2.3 + (MIT) jsonfile@6.1.0 + (MIT) jsprim@2.0.2 + (MIT) keyv@4.5.4 + (MIT) konva@9.3.14 + (MIT) lazy-ass@1.6.0 + (MIT) levn@0.4.1 + (BSD-3-Clause) license-checker@25.0.1 + (MIT) lines-and-columns@1.2.4 + (MIT) linkify-it@5.0.0 + (MIT) linkifyjs@4.1.3 + (MIT) listr2@3.14.0 + (MIT) local-pkg@0.5.0 + (MIT) locate-path@6.0.0 + (MIT) lodash-es@4.17.21 + (MIT) lodash.merge@4.6.2 + (MIT) lodash.once@4.1.1 + (MIT) lodash@4.17.21 + (MIT) log-symbols@4.1.0 + (MIT) log-update@4.0.0 + (MIT) loose-envify@1.4.0 + (MIT) loupe@2.3.7 + (ISC) lru-cache@5.1.1 + (MIT) lz-string@1.5.0 + (MIT) magic-string@0.30.11 + (MIT) markdown-it@14.1.0 + (BSD-3-Clause) md5@2.3.0 + (CC0-1.0) mdn-data@2.0.30 + (MIT) mdurl@2.0.0 + (MIT) merge-stream@2.0.0 + (MIT) merge2@1.4.1 + (MIT) micromatch@4.0.7 + (MIT) mime-db@1.52.0 + (MIT) mime-types@2.1.35 + (MIT) mimic-fn@2.1.0 + (MIT) mimic-fn@4.0.0 + (MIT) min-indent@1.0.1 + (ISC) minimatch@3.1.2 + (MIT) minimist@1.2.8 + (MIT) mkdirp@0.5.6 + (MIT) mlly@1.7.1 + (MIT) module-details-from-path@1.0.3 + (MIT) ms@2.0.0 + (MIT) ms@2.1.2 + (MIT) mui-tiptap@1.9.5 + (MIT) nanoid@3.3.7 + (MIT) nanoid@5.0.7 + (MIT) natural-compare-lite@1.4.0 + (MIT) natural-compare@1.4.0 + (ISC) next-tick@1.1.0 + (MIT) node-gyp-build@4.8.1 + (MIT) node-releases@2.0.18 + (ISC) nopt@4.0.3 + (BSD-2-Clause) normalize-package-data@2.5.0 + (MIT) notistack@3.0.1 + (ISC) npm-normalize-package-bin@1.0.1 + (MIT) npm-run-path@4.0.1 + (MIT) npm-run-path@5.3.0 + (MIT) object-assign@4.1.1 + (MIT) object-inspect@1.13.2 + (MIT) object-is@1.1.6 + (MIT) object-keys@1.1.1 + (MIT) object.assign@4.1.5 + (ISC) once@1.4.0 + (MIT) onetime@5.1.2 + (MIT) onetime@6.0.0 + (MIT) optionator@0.9.4 + (MIT) orderedmap@2.1.1 + (MIT) os-homedir@1.0.2 + (MIT) os-tmpdir@1.0.2 + (ISC) osenv@0.1.5 + (MIT) ospath@1.2.2 + (MIT) p-limit@3.1.0 + (MIT) p-limit@5.0.0 + (MIT) p-locate@5.0.0 + (MIT) p-map@4.0.0 + (MIT) parent-module@1.0.1 + (MIT) parse-json@5.2.0 + (MIT) parse5@7.1.2 + (MIT) path-exists@4.0.0 + (MIT) path-is-absolute@1.0.1 + (MIT) path-key@3.1.1 + (MIT) path-key@4.0.0 + (MIT) path-parse@1.0.7 + (MIT) path-type@4.0.0 + (MIT) pathe@1.1.2 + (MIT) pathval@1.1.1 + (MIT) pend@1.2.0 + (MIT) performance-now@2.1.0 + (ISC) picocolors@1.0.1 + (MIT) picomatch@2.3.1 + (MIT) pify@2.3.0 + (MIT) pkg-types@1.1.3 + (MIT) possible-typed-array-names@1.0.0 + (MIT) postcss@8.4.40 + (MIT) prefix-style@2.0.1 + (MIT) prelude-ls@1.2.1 + (MIT) prettier@3.2.4 + (MIT) pretty-bytes@5.6.0 + (MIT) pretty-format@27.5.1 + (MIT) pretty-format@29.7.0 + (MIT) primeflex@3.3.1 + (MIT) primereact@10.8.0 + (MIT) process@0.11.10 + (MIT) prop-types@15.8.1 + (MIT) property-expr@2.0.6 + (MIT) prosemirror-changeset@2.2.1 + (MIT) prosemirror-collab@1.3.1 + (MIT) prosemirror-commands@1.6.0 + (MIT) prosemirror-dropcursor@1.8.1 + (MIT) prosemirror-gapcursor@1.3.2 + (MIT) prosemirror-history@1.4.1 + (MIT) prosemirror-inputrules@1.4.0 + (MIT) prosemirror-keymap@1.2.2 + (MIT) prosemirror-markdown@1.13.0 + (MIT) prosemirror-menu@1.2.4 + (MIT) prosemirror-model@1.22.2 + (MIT) prosemirror-schema-basic@1.2.3 + (MIT) prosemirror-schema-list@1.4.1 + (MIT) prosemirror-state@1.4.3 + (MIT) prosemirror-tables@1.4.0 + (MIT) prosemirror-trailing-node@2.0.9 + (MIT) prosemirror-transform@1.9.0 + (MIT) prosemirror-view@1.33.9 + (MIT) proxy-from-env@1.0.0 + (MIT) proxy-from-env@1.1.0 + (MIT) psl@1.9.0 + (MIT) pump@3.0.0 + (MIT) punycode.js@2.3.1 + (MIT) punycode@2.3.1 + (BSD-3-Clause) qs@6.10.4 + (MIT) query-string@4.3.4 + (MIT) querystringify@2.2.0 + (MIT) queue-microtask@1.2.3 + (MIT) raf@3.4.1 + (MIT) rc-util@5.43.0 + (MIT) react-colorful@5.6.1 + (MIT) react-copy-to-clipboard@5.1.0 + (MIT) react-custom-scrollbars-2@4.5.0 + (MIT) react-device-detect@2.2.3 + (MIT) react-dom@18.3.1 + (MIT) react-dropzone@14.2.3 + (MIT) react-gravatar@2.6.3 + (MIT) react-hook-form@7.49.2 + (BSD-3-Clause) react-intl@6.6.8 + (MIT) react-is@16.13.1 + (MIT) react-is@17.0.2 + (MIT) react-is@18.3.1 + (MIT) react-redux@8.1.3 + (MIT) react-refresh@0.14.2 + (MIT) react-router-dom@6.25.1 + (MIT) react-router@6.25.1 + (BSD-3-Clause) react-transition-group@4.4.5 + (MIT) react@18.3.1 + (ISC) read-installed@4.0.3 + (ISC) read-package-json@2.1.2 + (ISC) readdir-scoped-modules@1.1.0 + (MIT) redent@3.0.0 + (MIT) redux-thunk@3.1.0 + (MIT) redux@5.0.1 + (MIT) regenerator-runtime@0.14.1 + (MIT) regexp.prototype.flags@1.5.2 + (MIT) request-progress@3.0.0 + (MIT) require-from-string@2.0.2 + (MIT) require-in-the-middle@7.4.0 + (MIT) requires-port@1.0.0 + (MIT) reselect@4.1.8 + (MIT) reselect@5.1.1 + (MIT) resolve-from@4.0.0 + (MIT) resolve@1.22.8 + (MIT) restore-cursor@3.1.0 + (MIT) reusify@1.0.4 + (MIT) rfdc@1.4.1 + (ISC) rimraf@3.0.2 + (MIT) rollup@4.19.1 + (MIT) rope-sequence@1.3.4 + (MIT) rrweb-cssom@0.6.0 + (MIT) run-parallel@1.2.0 + (Apache-2.0) rxjs@7.8.1 + (MIT) safe-buffer@5.2.1 + (MIT) safer-buffer@2.1.2 + (ISC) saxes@6.0.0 + (MIT) scheduler@0.23.2 + (ISC) semver@5.7.2 + (ISC) semver@6.3.1 + (ISC) semver@7.6.3 + (MIT) set-function-length@1.2.2 + (MIT) set-function-name@2.0.2 + (MIT) shebang-command@2.0.0 + (MIT) shebang-regex@3.0.0 + (BSD-2-Clause) shimmer@1.2.1 + (MIT) side-channel@1.0.6 + (ISC) siginfo@2.0.0 + (ISC) signal-exit@3.0.7 + (ISC) signal-exit@4.1.0 + (MIT) simplebar-core@1.2.6 + (MIT) simplebar-react@3.2.6 + (MIT) slash@3.0.0 + (MIT) slice-ansi@3.0.0 + (MIT) slice-ansi@4.0.0 + (ISC) slide@1.1.6 + (MIT) socket.io-client@4.7.5 + (MIT) socket.io-parser@4.2.4 + (MIT) sockjs-client@1.6.1 + (BSD-3-Clause) source-map-js@1.2.0 + (BSD-3-Clause) source-map@0.5.7 + (MIT) spdx-compare@1.0.0 + (Apache-2.0) spdx-correct@3.2.0 + (CC-BY-3.0) spdx-exceptions@2.5.0 + (MIT) spdx-expression-parse@3.0.1 + (CC0-1.0) spdx-license-ids@3.0.18 + ((MIT AND CC-BY-3.0)) spdx-ranges@2.1.1 + (MIT) spdx-satisfies@4.0.1 + (MIT) sshpk@1.18.0 + (MIT) stack-utils@2.0.6 + (MIT) stackback@0.0.2 + (MIT) std-env@3.7.0 + (Apache-2.0) stompjs@2.3.3 + (MIT) stop-iteration-iterator@1.0.0 + (MIT) strict-uri-encode@1.1.0 + (MIT) string-width@4.2.3 + (MIT) strip-ansi@6.0.1 + (MIT) strip-final-newline@2.0.0 + (MIT) strip-final-newline@3.0.0 + (MIT) strip-indent@3.0.0 + (MIT) strip-json-comments@3.1.1 + (MIT) strip-literal@2.1.0 + (MIT) stylis@4.2.0 + (MIT) supports-color@5.5.0 + (MIT) supports-color@7.2.0 + (MIT) supports-color@8.1.1 + (MIT) supports-preserve-symlinks-flag@1.0.0 + (MIT) symbol-tree@3.2.4 + (MIT) text-table@0.2.0 + (MIT) throttleit@1.0.1 + (MIT) through@2.3.8 + (MIT) tiny-case@1.0.3 + (MIT) tinybench@2.8.0 + (MIT) tinypool@0.8.4 + (MIT) tinyspy@2.2.1 + (MIT) tippy.js@6.3.7 + (MIT) tmp@0.2.3 + (MIT) to-camel-case@1.0.0 + (MIT) to-fast-properties@2.0.0 + (MIT) to-no-case@1.0.2 + (MIT) to-regex-range@5.0.1 + (MIT) to-space-case@1.0.0 + (MIT) toggle-selection@1.0.6 + (MIT) toposort@2.0.2 + (BSD-3-Clause) tough-cookie@4.1.4 + (MIT) tr46@5.0.0 + (MIT) treeify@1.1.0 + (0BSD) tslib@1.14.1 + (0BSD) tslib@2.6.3 + (MIT) tss-react@4.9.11 + (MIT) tsutils@3.21.0 + (Apache-2.0) tunnel-agent@0.6.0 + (Unlicense) tweetnacl@0.14.5 + (MIT) type-check@0.4.0 + (MIT) type-detect@4.1.0 + ((MIT OR CC0-1.0)) type-fest@0.20.2 + ((MIT OR CC0-1.0)) type-fest@0.21.3 + ((MIT OR CC0-1.0)) type-fest@2.19.0 + ((MIT OR CC0-1.0)) type-fest@3.13.1 + (ISC) type@2.7.3 + (MIT) typedarray-to-buffer@3.1.5 + (Apache-2.0) typescript@5.3.3 + (MIT) ua-parser-js@1.0.38 + (MIT) uc.micro@2.1.0 + (MIT) ufo@1.5.4 + (MIT) undici-types@6.11.1 + (MIT) universalify@0.2.0 + (MIT) universalify@2.0.1 + (MIT) untildify@4.0.0 + (MIT) update-browserslist-db@1.1.0 + (BSD-2-Clause) uri-js@4.4.1 + (MIT) url-parse@1.5.10 + (MIT) use-sync-external-store@1.2.0 + (MIT) use-sync-external-store@1.2.2 + (MIT) utf-8-validate@5.0.10 + (MIT) util-extend@1.0.3 + (MIT) uuid@8.3.2 + (Apache-2.0) validate-npm-package-license@3.0.4 + (MIT) verror@1.10.0 + (MIT) vite-node@1.6.0 + (MIT) vite@5.1.5 + (MIT) vitest@1.6.0 + (MIT) w3c-keyname@2.2.8 + (MIT) w3c-xmlserializer@5.0.0 + (BSD-2-Clause) webidl-conversions@7.0.0 + (Apache-2.0) websocket-driver@0.7.4 + (Apache-2.0) websocket-extensions@0.1.4 + (Apache-2.0) websocket@1.0.35 + (MIT) whatwg-encoding@3.1.1 + (MIT) whatwg-mimetype@4.0.0 + (MIT) whatwg-url@14.0.0 + (MIT) which-boxed-primitive@1.0.2 + (MIT) which-collection@1.0.2 + (MIT) which-typed-array@1.1.15 + (ISC) which@2.0.2 + (MIT) why-is-node-running@2.3.0 + (MIT) word-wrap@1.2.5 + (MIT) wrap-ansi@6.2.0 + (MIT) wrap-ansi@7.0.0 + (ISC) wrappy@1.0.2 + (MIT) ws@8.17.1 + (MIT) ws@8.18.0 + (Apache-2.0) xml-name-validator@5.0.0 + (MIT) xmlchars@2.2.0 + (MIT) xmlhttprequest-ssl@2.0.0 + (MIT) yaeti@0.0.6 + (ISC) yallist@3.1.1 + (ISC) yaml@1.10.2 + (MIT) yauzl@2.10.0 + (MIT) yocto-queue@0.1.0 + (MIT) yocto-queue@1.1.1 + (MIT) yup@1.4.0 + (MIT) zone.js@0.14.8 + (MIT) zustand@4.5.4 diff --git a/README.md b/README.md index 2b8b0fd04..3787d6d4c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ -# Snomio +# Lingo + [![App Status](https://ncts-cd.australiaeast.cloudapp.azure.com/api/badge?name=snomio-dev&revision=true&showAppName=true)](https://ncts-cd.australiaeast.cloudapp.azure.com/applications/snomio-dev) [![App Status](https://ncts-cd.australiaeast.cloudapp.azure.com/api/badge?name=snomio-uat&revision=true&showAppName=true)](https://ncts-cd.australiaeast.cloudapp.azure.com/applications/snomio-uat) -An integration with Snomed International's Authoring Platform that extends functionality to improve authoring of medicinal terminology. +An integration with Snomed International's Authoring Platform that extends functionality to improve +authoring of medicinal terminology. + +# ⚠️ WARNING + +Over the next couple of months this repo will be undergoing UI changes using RJSF. -To run this project +If you intend on contributing, this is likely to affect you. Please get in touch and we can explain where we are up to and how to contribute. -cookies for the .ihtsdotools domain are only shared one the same domain so you will need to -add snomio.ihtsdotools.org & snomio-api.ihtsdotools.org to your /etc/hosts file +## Getting Started -The ECL Refset Tool UI requires an npm package published to a registry in the aehrc Azure DevOps organization. -To install you will need to setup credentials in your user `.npmrc` file to -[connect to the aehrc-npm feed](https://dev.azure.com/aehrc/ontoserver/_artifacts/feed/aehrc-npm/connect). +To run this project you will need to follow some changes that are listed +in [docs/CONFIGURATION.md](/docs/CONFIGURATION.md). If you do not follow the set up in +CONFIGURATION.md, the following steps will not be able to run the application. + +After you have followed these steps, to run the application: ``` cd ui @@ -20,17 +27,36 @@ cd api mvn spring-boot:run ``` -To build you will need to pass ims-username and ims-password as VM arguments eg +From there you navigate to `WHATEVER_YOU_SET.ihtsdotools.org` (as specified in CONFIGURATION.md above). + +To build you will need to pass ims-username and ims-password as VM arguments, for example: ``` mvn clean package -Dims-username=myusername -Dims-password=mypassword ``` +## Dependencies & Technologies + +For a list of the major dependencies and technologies please +read [here](/docs/design/technologies.md). + +For a detailed list of dependencies please +view [here](https://github.com/aehrc/snomio/network/dependencies). + ## License -This project uses the Apache License 2.0. +Snomio is copyright © 2024 Australian Digital Health Agency, and is licensed under the Apache +License, Version 2.0 (the "License"); +you may not use Snomio or the content of this repository except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 -To read more on it see [LICENSE](./LICENSE) +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. ## Contributing @@ -40,143 +66,17 @@ See [contributing.md](./contributing.md) for ways to get started. ## Code of conduct -Please adhere to this project's [code_of_conduct.md](./code_of_con:q!duct.md). - - -```mermaid -C4Context - title System Context diagram for Snomio - - Enterprise_Boundary(ext, "Other") { - System(hpp, "HPP") - System(artg, "ARTG") - } - - Enterprise_Boundary(csiro, "CSIRO") { - Person(ta,"Terminology Analyst") - Person(amt_ta,"AMT Terminology Analyst") - System(sergio, "Sergio") - - SystemDb_Ext(snomiodb,"Ticket Database") - - System(snomio, "Snomio") - - BiRel(amt_ta, snomio, "") - Rel(snomio, snomiodb, "") - Rel(sergio, snomio, "") - } - - Enterprise_Boundary(si, "SNOMED International") { - System(ims, "Identity Management Service") - System(cis, "Component Identifier Service") - SystemDb(cisdb,"Identifier database") - System(as, "Authoring Service") - System(authoring, "Authoring Platform") - System(snowstorm, "Snowstorm") - SystemDb_Ext(elastic,"Elasticsearch") - Rel(snowstorm, elastic, "") - Rel(snowstorm, cis, "") - Rel(authoring, snowstorm, "") - Rel(authoring, as, "") - Rel(as, snowstorm, "") - Rel(cis, cisdb, "") - } - -BiRel(ta, authoring, "") -BiRel(amt_ta, authoring, "") -Rel(snomio, ims, "") -Rel(snomio, as, "") -Rel(snomio, cis, "") -Rel(sergio, ims, "") -Rel(snomio, snowstorm, "") -Rel(sergio, hpp, "") -Rel(sergio, artg, "") - -UpdateLayoutConfig($c4ShapeInRow="6", $c4BoundaryInRow="1") -``` -## Deployment environment for reference -```mermaid -C4Context - title High level Snomio Deployment Environment - - Person(developer, "Developer", "NCTS Developer or DevOps person") - - System_Boundary(github, "GitHub repositories") { - Container(snomioRepo, "Snomio Repository", "Source Code", "https://github.com/aehrc/snomio", $link="https://github.com/aehrc/snomio") - Container(sergioRepo, "Sergio Repository", "Source Code", "https://github.com/aehrc/sergio") - Container(nctsArgoRepo, "NCTS ArgoCD repository", "GitOps code", "https://github.com/aehrc/ncts-argo") - Container_Boundary(nctsHelmRepo, "NCTS Helm source repository") { - Container(nctsHelmRepoContainer, "Git repository", "Helm Charts", "https://github.com/aehrc/ncts-helm") - Container(nctsHelmGitHubActions, "GitHub Actions", "Build/Deploy") - } - } - - System_Boundary(aci, "Azure Cloud Infrastructure") { - Container(azuredevops, "Azure DevOps CI/CD Pipelines") - Container(acr, "NCTS Azure Container Registry", "Docker images and Helm charts", "nctsacr.azurecr.io") - - Container_Boundary(nctsaks, "Azure Kubernetes Cluster") { - Container_Boundary(snomiodevns, "Snomio DEV Namespace") { - Container(snomiodev, "Snomio DEV", "https://dev-snomio.ihtsdotools.org/") - Container(sergiodev, "Sergio DEV", "sergio-dev-service in k8s") - } - Container_Boundary(snomiouatns, "Snomio UAT Namespace") { - Container(snomiouat, "Snomio UAT", "https://uat-snomio.ihtsdotools.org/") - Container(sergiouat, "Sergio UAT", "sergio-uat-service in k8s") - } - Container_Boundary(argocdns, "ArgoCD Namespace") { - Container(argoCD, "ArgoCD GitOps Tool") - } - } - Container_Boundary(nctsprodaks, "Production Azure Kubernetes Cluster") { - Container_Boundary(snomioprodns, "Snomio Prod Namespace") { - Container(snomioprod, "Snomio Prod", "https://snomio.ihtsdotools.org/") - Container(sergioprod, "Sergio Prod", "sergio-service in k8s") - } - } - } - - Rel(developer, snomioRepo, "Pushes changes") - Rel(developer, sergioRepo, "Pushes changes") - Rel(developer, nctsArgoRepo, "Pushes changes") - Rel(developer, nctsHelmRepoContainer, "Pushes changes") - - Rel(snomioRepo, azuredevops, "CI Build") - Rel(sergioRepo, azuredevops, "CI Build") - Rel(nctsHelmGitHubActions, acr, "Builds and Pushes Helm Charts") - - Rel(azuredevops, acr, "Builds and Pushes Images") - Rel(azuredevops, nctsArgoRepo, "Updates Image References") - - Rel(acr, argoCD, "Pulls Released Helm Charts and Docker Images") - Rel(nctsArgoRepo, argoCD, "Pulls Changes") - Rel(nctsHelmRepoContainer, argoCD, "Pulls Helm Charts") - - Rel(argoCD, snomiodev, "Deploys Application") - Rel(argoCD, sergiodev, "Deploys Application") - Rel(argoCD, snomiouat, "Deploys Application") - Rel(argoCD, sergiouat, "Deploys Application") - Rel(argoCD, snomioprod, "Deploys Application") - Rel(argoCD, sergioprod, "Deploys Application") - - UpdateElementStyle(snomioRepo, $bgColor="green", $borderColor="green") - UpdateElementStyle(sergiodev, $bgColor="grey", $borderColor="gray") - UpdateElementStyle(sergiouat, $bgColor="grey", $borderColor="gray") - UpdateElementStyle(sergioprod, $bgColor="grey", $borderColor="gray") - UpdateElementStyle(sergioRepo, $bgColor="grey", $borderColor="gray") - - UpdateRelStyle(developer, snomioRepo, "green", "red", "10", "-20") - UpdateRelStyle(developer, nctsHelmRepoContainer, "green", "red", "25", "120") - UpdateRelStyle(developer, nctsArgoRepo, "green", "red", "-80", "-40") - UpdateRelStyle(nctsHelmRepoContainer, argoCD, "green", "red", "-140", "-40") - UpdateRelStyle(acr, argoCD, "green", "red", "-140", "-340") - UpdateRelStyle(nctsArgoRepo, argoCD, "green", "red", "-60", "40") - UpdateRelStyle(snomioRepo, azuredevops, "green", "red", "-370", "20") - UpdateRelStyle(sergioRepo, azuredevops, "", "", "-200", "20") - UpdateRelStyle(nctsHelmGitHubActions, acr, "green", "red", "-170", "40") - UpdateRelStyle(azuredevops, nctsArgoRepo, "green", "red", "-60", "20") - UpdateRelStyle(argoCD, snomiodev, "green", "red", "-60", "20") - UpdateRelStyle(argoCD, snomiouat, "green", "red", "-80", "20") - UpdateRelStyle(argoCD, snomioprod, "green", "red", "-110", "20") - UpdateRelStyle(azuredevops, acr, "green", "red", "10", "0") -``` +Please adhere to this project's [code_of_conduct.md](./code_of_conduct.md). + +## Design + +For more information on the design of Lingo see the [design documentation](./docs/DESIGN.md). + +## Deployment and Configuration + +For more information on how to deploy and configure Lingo see +the [deployment](./docs/DEPLOYMENT.md) and [configuration](./docs/CONFIGURATION.md) documentation. + +## User guide + +A basic [user guide](./docs/USERGUIDE.md) is available to orientate new users. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..48587300c --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,23 @@ +# How to release an Snomio version + +This page contains the steps needed to produce and publish a Snomio release. + +## 1. Perform the release + +1. Clean check out the `main` branch +2. Run `mvn gitflow:release-start` to start the release process, this will ask for a release version + and create a new branch for the release +3. Update the CHANGELOG.md, replacing the `[Unreleased]` header with the new version number and date +4. ~~Update the eclrefset project's snomio.auth.version property for the snapshot version~~ +5. Commit the changes +7. Run `mvn gitflow:release-finish -DskipTestProject=true` to finish the release process, this will + 1. merge the release branch back into `main` + 2. tag the release in git +8. ~~Update the eclrefset project's snomio.auth.version to the new POM version~~ +9. ~~Commit the changes to the eclrefet project~~ + +*TODO - update the eclrefset project to be a module of the parent POM to avoid the need for steps 4, +8, and 9* + +The above will trigger a build on the CI server for the tag which `gitflow:release-finish` will +push. This will build, test and deploy a new image to the container registry. \ No newline at end of file diff --git a/active.txt b/active.txt new file mode 100644 index 000000000..b5bae0396 --- /dev/null +++ b/active.txt @@ -0,0 +1,35 @@ +docs/Deployment +bugfix/package-rename +feature/environment-specific-logos +fix-e2e-tests +dependabot/npm_and_yarn/ui/main/tabler/icons-react-3.19.0 +dependabot/maven/eclrefset/org.springframework-spring-web-6.1.12 +dependabot/npm_and_yarn/utils/jira-ticket-export/rollup-3.29.5 +dependabot/npm_and_yarn/ui/main/jsdom-25.0.1 +dependabot/npm_and_yarn/utils/jira-ticket-export/multi-d66d039ac5 +dependabot/npm_and_yarn/utils/jira-ticket-export/multi-cf87d80143 +dependabot/npm_and_yarn/ui/vite-5.1.8 +new_import +dependabot/npm_and_yarn/utils/jira-ticket-export/multi-9423f4c335 +bugfix/ticket-comments +dependabot/npm_and_yarn/utils/jira-ticket-export/vite-4.5.5 +dependabot/maven/main/org.mapstruct-mapstruct-processor-1.6.2 +dependabot/maven/main/org.mapstruct-mapstruct-1.6.2 +fix-trivy-webmvc +dependabot/npm_and_yarn/utils/jira-ticket-export/multi-ceff1a497b +documentation +dependabot/npm_and_yarn/utils/jira-ticket-export/micromatch-4.0.8 +dependabot/npm_and_yarn/ui/main/testing-library/jest-dom-6.5.0 +dependabot/npm_and_yarn/utils/jira-ticket-export/axios-1.7.4 +dependabot/maven/main/com.h2database-h2-2.3.232 +dependabot/npm_and_yarn/ui/main/mui/icons-material-5.16.7 +dependabot/maven/main/org.awaitility-awaitility-4.2.2 +ing-comp-pack-ordering +dependabot/npm_and_yarn/utils/jira-ticket-export/braces-3.0.3 +feature/rabbit +dependabot/npm_and_yarn/ui/main/react-redux-9.1.2 +ecl-refset-process-unexpected-processing-volume +snodine +fix-duplicate-TP-issue +origin/HEAD -> origin/main +main diff --git a/api/.gitignore b/api/.gitignore index 558560049..5e5934050 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -5,3 +5,4 @@ out .idea src/main/resources/static/* application-custom.properties +src/main/resources/application-local.properties \ No newline at end of file diff --git a/api/pom.xml b/api/pom.xml index b8d3e95a4..0418c6b4a 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -1,11 +1,45 @@ + api - + + build-helper-maven-plugin + + + + + target/generated-sources/java + + + + add-source + + generate-sources + + + org.codehaus.mojo + 3.6.0 + fmt-maven-plugin @@ -27,7 +61,6 @@ flyway-maven-plugin org.flywaydb - ${flyway-version} jacoco-maven-plugin @@ -82,7 +115,7 @@ jsonschema-maven-plugin - com/csiro/**/models/**/*,au/csiro/**/model/* + au/gov/digitalhealth/**/models/**/*,au/csiro/**/model/* WITH_ALL_DEPENDENCIES false @@ -113,13 +146,23 @@ ${project.version} - com.csiro.snomio.SnomioApplication + au.gov.digitalhealth.lingo.LingoApplication 8080 - eclipse-temurin:17 + ${java.docker.image} + + + arm64 + linux + + + amd64 + linux + + ${docker.registry.host}/${docker.repository} @@ -132,7 +175,7 @@ - dockerBuild + build package @@ -148,15 +191,35 @@ ${argLine} -Dspring.profiles.active=test + + license-maven-plugin + +
${project.parent.basedir}/src/license/HEADER.txt
+ + **/*.java + **/pom.xml + +
+ + + + format + + validate + + + com.mycila + 4.0.rc2 +
+ imgscalr-lib org.imgscalr 4.2 - - + metadata-extractor com.drewnoakes @@ -167,7 +230,7 @@ imageio-webp com.twelvemonkeys.imageio runtime - 3.10.1 + 3.11.0 @@ -194,15 +257,20 @@ org.aspectj - io.micrometer micrometer-registry-prometheus + io.micrometer runtime - io.opentelemetry.instrumentation opentelemetry-instrumentation-annotations - 2.3.0 + io.opentelemetry.instrumentation + 2.6.0 + + io.sentry + sentry-spring-boot-starter-jakarta + ${sentry.version} + spring-boot-starter-actuator org.springframework.boot @@ -210,13 +278,18 @@ spring-boot-starter-hateoas org.springframework.boot - 3.2.5 + 3.3.2 spring-boot-configuration-processor org.springframework.boot true + + springdoc-openapi-starter-webmvc-ui + org.springdoc + 2.6.0 + gson com.google.code.gson @@ -287,16 +360,21 @@ ${rest-assured.version} - com.squareup.okhttp3 okhttp - 5.0.0-alpha.12 + com.squareup.okhttp3 test + 5.0.0-alpha.12 - com.squareup.okhttp3 mockwebserver - 5.0.0-alpha.12 + com.squareup.okhttp3 test + 5.0.0-alpha.12 + + + wiremock-standalone + com.github.tomakehurst + 3.0.0 postgresql @@ -306,7 +384,10 @@ flyway-core org.flywaydb - ${flyway-version} + + + flyway-database-postgresql + org.flywaydb jackson-datatype-jsr310 @@ -330,7 +411,7 @@ commons-validator commons-validator - 1.8.0 + 1.9.0 spring-context @@ -339,7 +420,7 @@ commons-csv org.apache.commons - 1.11.0 + 1.12.0 commons-collections4 @@ -361,6 +442,11 @@ org.testcontainers test + + rabbitmq + org.testcontainers + test + jgrapht-core org.jgrapht @@ -387,34 +473,53 @@ ${owlapi.version} - snomio-common - com.csiro - ${snomio.common.version} + lingo-common + au.gov.digitalhealth + + + lingo-auth + au.gov.digitalhealth - snomio-auth - com.csiro - ${snomio.auth.version} + sergio-extension + au.gov.digitalhealth + + + awaitility + org.awaitility + 4.2.2 + + + mapstruct + org.mapstruct + 1.6.3 + + + mapstruct-processor + org.mapstruct + provided + 1.5.5.Final + + + hibernate-jcache + org.hibernate + ${hibernate.version} + + + ehcache + org.ehcache + 3.10.8 - - - - testcontainers-bom - org.testcontainers - import - pom - 1.19.3 - - - - Api for snomio + Api for Lingo 4.0.0 - snomio api + + lingo api + - snomio - com.csiro - 1.0.0-SNAPSHOT + lingo + au.gov.digitalhealth + 1.2.10.7-SNAPSHOT @@ -449,26 +554,65 @@ ${project.build.directory}/classes/static + + + + + + aarch64 + + + + + + jib-maven-plugin + + + eclipse-temurin:17 + + + arm64 + linux + + + + + + + + dockerBuild + + package + + + com.google.cloud.tools + ${jib.version} + + + + local + + 1.1.3 nctsacr.azurecr.io snomio - 9.22.3 - 2.22.1 - 2.2.224 - 0.8.11 + 2.23 + 2.3.232 + 0.8.12 + eclipse-temurin:17 17 3.1.0 1.5.2 - 3.4.0 - 4.34.0 - 3.4.1 + 3.4.3 + 4.36.0 + 3.5.0 4.5.19 - 42.7.2 + 42.7.3 0.23.0 5.1.0 - 5.0.0 - 8.1.1-SNAPSHOT + 5.4.0 + 7.18.0
diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/ExtensionChecker.java b/api/src/main/java/au/gov/digitalhealth/lingo/ExtensionChecker.java new file mode 100644 index 000000000..1421ab827 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/ExtensionChecker.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo; + +import au.gov.digitalhealth.lingo.extension.LingoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.stereotype.Component; + +@Component +@Configuration +@PropertySource(value = "classpath:application-extension.properties", ignoreResourceNotFound = true) +@SuppressWarnings("java:S6813") +public class ExtensionChecker implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(ExtensionChecker.class); + + @Value("${snomio.extensions.sergio.enabled}") + boolean sergioEnabled; + + // SonarLint no good here don't remove this + @Autowired private ApplicationContext applicationContext; + + @Override + public void run(ApplicationArguments args) throws Exception { + if (!sergioEnabled) { + logger.warn("Sergio extension is disabled."); + return; + } + + String[] extensionBeanNames = applicationContext.getBeanNamesForType(LingoExtension.class); + if (extensionBeanNames.length > 0) { + logger.info("The following extensions have been found and will be initialised:"); + for (String beanName : extensionBeanNames) { + LingoExtension extension = (LingoExtension) applicationContext.getBean(beanName); + logger.info(" - {}", beanName); + extension.initialise(); + } + } else { + logger.warn("No extensions found."); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/LingoApplication.java b/api/src/main/java/au/gov/digitalhealth/lingo/LingoApplication.java new file mode 100644 index 000000000..070c6a4e8 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/LingoApplication.java @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo; + +import au.gov.digitalhealth.lingo.configuration.Configuration; +import lombok.extern.java.Log; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@EnableTransactionManagement +@EnableAspectJAutoProxy +@Log +public class LingoApplication extends Configuration { + + public static void main(String[] args) { + SpringApplication.run(LingoApplication.class, args); + } + + @Bean + public CommandLineRunner commandLineRunner(ApplicationContext ctx) { + return args -> { + log.finer("Beans"); + + String[] beansNames = ctx.getBeanDefinitionNames(); + for (String beanName : beansNames) { + log.finer(beanName); + } + }; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/aspect/LogExecutionTime.java b/api/src/main/java/au/gov/digitalhealth/lingo/aspect/LogExecutionTime.java new file mode 100644 index 000000000..d84a1bc42 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/aspect/LogExecutionTime.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.aspect; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LogExecutionTime {} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/aspect/LogExecutionTimeAspect.java b/api/src/main/java/au/gov/digitalhealth/lingo/aspect/LogExecutionTimeAspect.java new file mode 100644 index 000000000..f764383dc --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/aspect/LogExecutionTimeAspect.java @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.aspect; + +import au.gov.digitalhealth.lingo.auth.helper.AuthHelper; +import java.util.Arrays; +import java.util.logging.Level; +import lombok.extern.java.Log; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +@Log +@Aspect +@Component +public class LogExecutionTimeAspect { + + final AuthHelper authHelper; + + public LogExecutionTimeAspect(AuthHelper authHelper) { + this.authHelper = authHelper; + } + + @Pointcut("@annotation(au.gov.digitalhealth.lingo.aspect.LogExecutionTime)") + public void callAt() {} + + @Around("callAt()") + public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + long start = System.currentTimeMillis(); + + Object proceed = joinPoint.proceed(); + + long executionTime = System.currentTimeMillis() - start; + + if (log.isLoggable(Level.FINE)) { + log.fine( + joinPoint.getSignature() + + " for user " + + authHelper.getImsUser().getLogin() + + " executed in " + + executionTime + + "ms"); + log.fine("Parameters: " + Arrays.toString(joinPoint.getArgs())); + } + return proceed; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUser.java b/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUser.java new file mode 100644 index 000000000..817bb36d2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUser.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.auth; + +import java.util.HashMap; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class JiraUser { + + private String name; + + private String key; + + private String emailAddress; + + private String displayName; + + private boolean active; + + private HashMap avatarUrls; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUserItems.java b/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUserItems.java new file mode 100644 index 000000000..b2db26b2c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUserItems.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class JiraUserItems { + private Integer size; + + @JsonProperty("max-results") + private Integer maxResults; + + @JsonProperty("start-index") + private Integer startIndex; + + @JsonProperty("end-index") + private Integer endIndex; + + private List items; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUserResponse.java b/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUserResponse.java new file mode 100644 index 000000000..c3f2c07fd --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/auth/JiraUserResponse.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class JiraUserResponse { + private String name; + private JiraUserItems users; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/ApiWebConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/ApiWebConfiguration.java new file mode 100644 index 000000000..d27f74823 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/ApiWebConfiguration.java @@ -0,0 +1,152 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import au.gov.digitalhealth.lingo.auth.helper.AuthHelper; +import au.gov.digitalhealth.lingo.log.SnowstormLogger; +import au.gov.digitalhealth.lingo.util.AuthSnowstormLogger; +import io.netty.handler.logging.LogLevel; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.logging.AdvancedByteBufFormat; + +@Configuration +@Log +public class ApiWebConfiguration { + + private final AuthHelper authHelper; + + public ApiWebConfiguration(AuthHelper authHelper) { + this.authHelper = authHelper; + } + + @Bean + public WebClient snowStormApiClient( + @Value("${ihtsdo.snowstorm.api.url}") String authoringServiceUrl, + WebClient.Builder webClientBuilder) { + HttpClient httpClient = + HttpClient.create() + .baseUrl(authoringServiceUrl) + .wiretap( + "reactor.netty.http.client.HttpClient", + LogLevel.DEBUG, + AdvancedByteBufFormat.TEXTUAL); + return webClientBuilder + .codecs( + clientCodecConfigurer -> + clientCodecConfigurer.defaultCodecs().maxInMemorySize(1024 * 1024 * 100)) + .baseUrl(authoringServiceUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(authHelper.addImsAuthCookie) // Cookies are injected through filter + .build(); + } + + @Bean + public WebClient authoringPlatformApiClient( + @Value("${ihtsdo.ap.api.url}") String authoringServiceUrl, + WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(authoringServiceUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(authHelper.addImsAuthCookie) // Cookies are injected through filter + .build(); + } + + @Bean + public WebClient nameGeneratorApiClient( + @Value("${name.generator.api.url}") String namegenApiUrl, + WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(namegenApiUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Bean + public WebClient defaultAuthoringPlatformApiClient( + @Value("${ihtsdo.ap.api.url}") String authoringServiceUrl, + WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(authoringServiceUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(authHelper.addDefaultAuthCookie) // Cookies are injected through filter + .build(); + } + + @Bean + public WebClient sergioApiClient( + @Value("${sergio.base.url}") String sergioUrl, WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(sergioUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(authHelper.addDefaultAuthCookie) + .filter(logRequest()) + .build(); + } + + private ExchangeFilterFunction logRequest() { + return ExchangeFilterFunction.ofRequestProcessor( + clientRequest -> { + log.info("Request: " + clientRequest.method() + " " + clientRequest.url()); + clientRequest + .headers() + .forEach((name, values) -> values.forEach(value -> log.info(name + "=" + value))); + // Log cookies + clientRequest + .cookies() + .forEach( + (name, values) -> + values.forEach(value -> log.info("Cookie: " + name + "=" + value))); + + return Mono.just(clientRequest); + }); + } + + @Bean + public WebClient otCollectorZipkinClient( + @Value("${snomio.telemetry.zipkinendpoint}") String zipkinEndpointUrl, + WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(zipkinEndpointUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Bean + public WebClient otCollectorOTLPClient( + @Value("${snomio.telemetry.otelendpoint}") String otelEndpointUrl, + WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(otelEndpointUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Bean + public SnowstormLogger snowstormLogger(AuthSnowstormLogger authLogger) { + return authLogger; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/CachingConfig.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/CachingConfig.java new file mode 100644 index 000000000..17db14b82 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/CachingConfig.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import au.gov.digitalhealth.lingo.service.JiraUserManagerService; +import au.gov.digitalhealth.lingo.service.SnowstormClient; +import au.gov.digitalhealth.lingo.util.CacheConstants; +import java.util.concurrent.TimeUnit; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +@Configuration +@EnableCaching +@EnableScheduling +@Log +public class CachingConfig { + + SnowstormClient snowstormClient; + + JiraUserManagerService jiraUserManagerService; + + @Value("${caching.spring.jiraUser.enabled}") + private boolean jiraUserCacheEnabled; + + @Value("${ihtsdo.ap.codeSystem}") + String codeSystem; + + CachingConfig(SnowstormClient snowstormClient, JiraUserManagerService jiraUserManagerService) { + this.snowstormClient = snowstormClient; + this.jiraUserManagerService = jiraUserManagerService; + } + + @CacheEvict(value = CacheConstants.USERS_CACHE, allEntries = true) + @Scheduled(fixedRateString = "${caching.spring.usersTTL}") + public void emptyUsersCache() { + log.finer("emptying user cache"); + } + + @CacheEvict(value = CacheConstants.JIRA_USERS_CACHE, allEntries = true) + @Scheduled(fixedRateString = "${caching.spring.usersTTL}") + public void emptyJiraUsersCache() { + if (jiraUserCacheEnabled) { + log.finer("refreshing jira user cache"); + try { + jiraUserManagerService.getAllJiraUsers(); + } catch (Exception e) { + log.warning("Error refreshing jira user cache: " + e.getMessage()); + } + } + } + + @CacheEvict(value = CacheConstants.SNOWSTORM_STATUS_CACHE, allEntries = true) + @Scheduled(fixedRateString = "60000") + public void refreshSnowstormStatusCache() { + log.finer("Refresh snowstorm status cache"); + try { + snowstormClient.getStatus(codeSystem); + } catch (Exception e) { + log.warning("Error refreshing snowstorm status cache: " + e.getMessage()); + } + } + + @CacheEvict(value = CacheConstants.AP_STATUS_CACHE, allEntries = true) + @Scheduled(fixedRateString = "60000") + public void refreshApStatusCache() { + log.finer("Refreshing ap status cache"); + } + + @CacheEvict(value = CacheConstants.ALL_TASKS_CACHE, allEntries = true) + @Scheduled(fixedRateString = "60000") + public void refreshAllTasksCache() { + log.finer("Refresh all Tasks cache"); + } + + @CacheEvict(value = CacheConstants.COMPOSITE_UNIT_CACHE) + @Scheduled(fixedRateString = "60", timeUnit = TimeUnit.MINUTES) + public void refreshCompositeUnitCache() { + log.finer("Refresh composite unit cache"); + } + + @CacheEvict(value = CacheConstants.UNIT_NUMERATOR_DENOMINATOR_CACHE) + @Scheduled(fixedRateString = "60", timeUnit = TimeUnit.MINUTES) + public void refreshUniNumeratorDenominatorCache() { + log.finer("Refresh unit numerator denominator cache"); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/Configuration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/Configuration.java new file mode 100644 index 000000000..7db7d854f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/Configuration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication( + scanBasePackages = {"au.gov.digitalhealth.lingo", "au.gov.digitalhealth.tickets"}) +@EnableConfigurationProperties +@ConfigurationPropertiesScan( + basePackages = {"au.gov.digitalhealth.lingo", "au.gov.digitalhealth.tickets"}) +@EntityScan("au.gov.digitalhealth") +@ComponentScan(basePackages = {"au.gov.digitalhealth.lingo", "au.gov.digitalhealth.tickets"}) +@EnableJpaRepositories( + basePackages = {"au.gov.digitalhealth.lingo", "au.gov.digitalhealth.tickets"}) +public abstract class Configuration {} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/FhirConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/FhirConfiguration.java new file mode 100644 index 000000000..1f53e6c17 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/FhirConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "fhir") +@Getter +@Setter +@Validated +public class FhirConfiguration { + + @Value("${fhir.server.url}") + String fhirServerBaseUrl; + + @Value("${fhir.extension}") + String fhirServerExtension; + + @Value("${fhir.preferred-for-language.code}") + String fhirPreferredForLanguage; + + @Value("${fhir.request-count}") + String fhirRequestCount; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/FieldBindingConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/FieldBindingConfiguration.java new file mode 100644 index 000000000..666e6fd4b --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/FieldBindingConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import au.gov.digitalhealth.lingo.util.CacheConstants; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "snomio.field-bindings") +@Getter +@Setter +@Validated +public class FieldBindingConfiguration { + private static final String DEFAULT_BRANCH_KEY = "MAIN_SNOMEDCT-AU_AUAMT"; + Map> mappers = new HashMap<>(); + + @Cacheable(cacheNames = CacheConstants.VALIDATION_EXCLUDED_SUBSTANCES) + public Set getExcludedSubstances() { + // will use default branch for now + Map resultMap = + mappers.getOrDefault(DEFAULT_BRANCH_KEY, mappers.entrySet().iterator().next().getValue()); + String excludedItems = resultMap.getOrDefault("product.validation.exclude.substances", ""); + return Arrays.stream(excludedItems.split(",")) + .map(String::trim) + .filter(item -> !item.isEmpty()) + .collect(Collectors.toSet()); + } + + @Cacheable(cacheNames = CacheConstants.BRAND_SEMANTIC_TAG) + public String getBrandSemanticTag() { + // will use default branch for now + Map resultMap = + mappers.getOrDefault(DEFAULT_BRANCH_KEY, mappers.entrySet().iterator().next().getValue()); + return resultMap.getOrDefault("product.productName.semanticTag", ""); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/IhtsdoConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/IhtsdoConfiguration.java new file mode 100644 index 000000000..27ebd5f90 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/IhtsdoConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "ihtsdo") +@Getter +@Setter +@Validated +public class IhtsdoConfiguration { + + @Value("${ihtsdo.ims.api.url}") + String imsApiUrl; + + @Value("${ihtsdo.ims.api.cookie.name}") + String imsApiCookieName; + + @Value("${ihtsdo.ims.api.cookie.value}") + String imsApiCookieValue; + + @Value("${ihtsdo.ap.api.url}") + String apApiUrl; + + @Value("${ihtsdo.ap.projectkey}") + String apProjectKey; + + @Value("${ihtsdo.ap.defaultBranch}") + String apDefaultBranch; + + @Value("${ihtsdo.ap.snodine.defaultBranch}") + String apSnodineDefaultBranch; + + @Value("${ihtsdo.ap.languageHeader}") + String apLanguageHeader; + + @Value("${ihtsdo.base.api.url}") + String apApiBaseUrl; + + @Value("${snomio.snodine.snowstorm.proxy}") + String snodineSnowstormProxy; + + @Value("${snomio.snodine.extensionModules}") + List snodineExtensionModules; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/ModellingConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/ModellingConfiguration.java new file mode 100644 index 000000000..a52ff573f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/ModellingConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import java.util.Set; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "snomio.modelling") +@Getter +@Setter +@Validated +public class ModellingConfiguration { + Set ungroupedRelationshipTypes = + Set.of( + 116680003L, + 784276002L, + 774159003L, + 766953001L, + 738774007L, + 736473005L, + 766939001L, + 733930001L, + 272741003L, + 736475003L, + 774081006L, + 736518005L, + 411116001L, + 766952006L, + 726542003L, + 766954007L, + 733932009L, + 774158006L, + 736474004L, + 736472000L, + 30394011000036104L, + 30465011000036106L, + 30523011000036108L, + 700000061000036106L, + 700000071000036103L, + 700000091000036104L, + 700000101000036108L, + 733933004L, + 763032000L, + 733928003L, + 733931002L, + 736476002L); +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/NamespaceConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/NamespaceConfiguration.java new file mode 100644 index 000000000..2477c366d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/NamespaceConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "snomio.namespace") +@Getter +@Validated +public class NamespaceConfiguration { + Map map = new HashMap<>(); + + public static String getConceptPartitionId(int namespace) { + String partitionId; + if (namespace == 0) { + partitionId = "00"; + } else { + partitionId = "10"; + } + return partitionId; + } + + public Integer getNamespace(String key) { + return map.get(key); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/RequestLoggingFilterConfig.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/RequestLoggingFilterConfig.java new file mode 100644 index 000000000..f7a69ca6f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/RequestLoggingFilterConfig.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.CommonsRequestLoggingFilter; + +@Configuration +public class RequestLoggingFilterConfig { + + @Bean + public CommonsRequestLoggingFilter logFilter() { + CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter(); + filter.setIncludeQueryString(true); + filter.setIncludePayload(true); + filter.setMaxPayloadLength(100000000); + filter.setIncludeHeaders(false); + filter.setAfterMessagePrefix("REQUEST DATA: "); + return filter; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SecureConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SecureConfiguration.java new file mode 100644 index 000000000..25b511b71 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SecureConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class SecureConfiguration { + + String sentryDsn; + String sentryEnvironment; + String sentryTracesSampleRate; + Boolean sentryEnabled; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SentryConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SentryConfiguration.java new file mode 100644 index 000000000..2bfb7b6d2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SentryConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "sentry") +@Getter +@Setter +@Validated +public class SentryConfiguration { + + @Value("${sentry.dsn}") + private String sentryDsn; + + @Value("${sentry.environment}") + private String sentryEnvironment; + + @Value("${sentry.traces-sample-rate}") + private String sentryTracesSampleRate; + + @Value("${sentry.enabled}") + private Boolean sentryEnabled; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SpaConfig.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SpaConfig.java new file mode 100644 index 000000000..800de4f4c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/SpaConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import java.io.IOException; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +@Configuration +public class SpaConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + + // By setting this, you instruct Spring to prioritize this handler above the + // default one (which is order 0), obviously don't do this. But it's good to + // understand. + // -- registry.setOrder(-1); + + // Handler for Swagger UI + registry + .addResourceHandler("/swagger-ui/**") + .addResourceLocations("classpath:/META-INF/resources/webjars/springdoc-openapi-ui/") + .resourceChain(true); + + registry + // Capture everything (REST controllers get priority over this, see above) + .addResourceHandler("/**") + // Add locations where files might be found + .addResourceLocations("classpath:/static/**") + // Needed to allow use of `addResolver` below + .resourceChain(true) + // This thing is what does all the resolving. This impl. is responsible for + // resolving ALL files. Meaning nothing gets resolves automatically by pointing + // out "static" above. + .addResolver( + new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, Resource location) + throws IOException { + Resource requestedResource = location.createRelative(resourcePath); + + // If we actually hit a file, serve that. This is stuff like .js and .css files. + if (requestedResource.exists() && requestedResource.isReadable()) { + return requestedResource; + } + + // Anything else returns the index. + return new ClassPathResource("/static/index.html"); + } + }); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/configuration/UserInterfaceConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/UserInterfaceConfiguration.java new file mode 100644 index 000000000..81ba70d58 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/configuration/UserInterfaceConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.configuration; + +import java.util.List; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class UserInterfaceConfiguration { + + String appName = "lingo"; + + String imsUrl; + + String apUrl; + + String apProjectKey; + + String apDefaultBranch; + + String apSnodineDefaultBranch; + + String apLanguageHeader; + + String apApiBaseUrl; + + String fhirServerBaseUrl; + + String fhirServerExtension; + + String fhirPreferredForLanguage; + + String fhirRequestCount; + + String snodineSnowstormProxy; + + List snodineExtensionModules; + + String appEnvironment; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/AuthController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/AuthController.java new file mode 100644 index 000000000..521e2968f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/AuthController.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.auth.helper.AuthHelper; +import au.gov.digitalhealth.lingo.auth.model.ImsUser; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping( + value = "/api/auth", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class AuthController { + + private final AuthHelper authHelper; + + public AuthController(AuthHelper authHelper) { + this.authHelper = authHelper; + } + + @GetMapping(value = "") + public ImsUser auth(HttpServletRequest request) { + return authHelper.getImsUser(); + } + + @PostMapping(value = "/logout") + public void logout(HttpServletRequest request, HttpServletResponse response) { + authHelper.cancelImsCookie(request, response); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/ConfigController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/ConfigController.java new file mode 100644 index 000000000..d35b8bc5d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/ConfigController.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.configuration.FhirConfiguration; +import au.gov.digitalhealth.lingo.configuration.IhtsdoConfiguration; +import au.gov.digitalhealth.lingo.configuration.UserInterfaceConfiguration; +import au.gov.digitalhealth.lingo.configuration.UserInterfaceConfiguration.UserInterfaceConfigurationBuilder; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping( + value = "/config", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class ConfigController { + + private final IhtsdoConfiguration ihtsdoConfiguration; + + private final FhirConfiguration fhirConfiguration; + + @Value("${snomio.environment}") + private String appEnvironment; + + public ConfigController( + IhtsdoConfiguration ihtsdoConfiguration, FhirConfiguration fhirConfiguration) { + this.ihtsdoConfiguration = ihtsdoConfiguration; + this.fhirConfiguration = fhirConfiguration; + } + + @GetMapping(value = "") + public UserInterfaceConfiguration config(HttpServletRequest request) { + UserInterfaceConfigurationBuilder builder = + UserInterfaceConfiguration.builder() + .imsUrl(ihtsdoConfiguration.getImsApiUrl()) + .apUrl(ihtsdoConfiguration.getApApiUrl()) + .apProjectKey(ihtsdoConfiguration.getApProjectKey()) + .apDefaultBranch(ihtsdoConfiguration.getApDefaultBranch()) + .apSnodineDefaultBranch(ihtsdoConfiguration.getApSnodineDefaultBranch()) + .apLanguageHeader(ihtsdoConfiguration.getApLanguageHeader()) + .apApiBaseUrl(ihtsdoConfiguration.getApApiBaseUrl()) + .fhirServerBaseUrl(fhirConfiguration.getFhirServerBaseUrl()) + .fhirServerExtension(fhirConfiguration.getFhirServerExtension()) + .fhirPreferredForLanguage(fhirConfiguration.getFhirPreferredForLanguage()) + .fhirRequestCount(fhirConfiguration.getFhirRequestCount()) + .snodineSnowstormProxy(ihtsdoConfiguration.getSnodineSnowstormProxy()) + .snodineExtensionModules(ihtsdoConfiguration.getSnodineExtensionModules()) + .appEnvironment(appEnvironment); + + return builder.build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/DeviceController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/DeviceController.java new file mode 100644 index 000000000..ab8b755a5 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/DeviceController.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.aspect.LogExecutionTime; +import au.gov.digitalhealth.lingo.product.ProductCreationDetails; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.details.DeviceProductDetails; +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.service.DeviceProductCalculationService; +import au.gov.digitalhealth.lingo.service.DeviceService; +import au.gov.digitalhealth.lingo.service.ProductCreationService; +import au.gov.digitalhealth.lingo.service.TaskManagerService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping( + value = "/api", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class DeviceController { + + private final DeviceService deviceService; + private final DeviceProductCalculationService deviceProductCalculationService; + private final TaskManagerService taskManagerService; + private final ProductCreationService productCreationService; + + DeviceController( + DeviceService deviceService, + DeviceProductCalculationService deviceProductCalculationService, + TaskManagerService taskManagerService, + ProductCreationService productCreationService) { + this.deviceService = deviceService; + this.deviceProductCalculationService = deviceProductCalculationService; + this.taskManagerService = taskManagerService; + this.productCreationService = productCreationService; + } + + @LogExecutionTime + @GetMapping("/{branch}/devices/{productId}") + public PackageDetails getDevicePackageAtomioData( + @PathVariable String branch, @PathVariable Long productId) { + return deviceService.getPackageAtomicData(branch, productId.toString()); + } + + @LogExecutionTime + @GetMapping("/{branch}/devices/product/{productId}") + public DeviceProductDetails getDeviceProductAtomioData( + @PathVariable String branch, @PathVariable Long productId) { + return deviceService.getProductAtomicData(branch, productId.toString()); + } + + @LogExecutionTime + @PostMapping("/{branch}/devices/product") + public ResponseEntity createDeviceProductFromAtomioData( + @PathVariable String branch, + @RequestBody @Valid ProductCreationDetails<@Valid DeviceProductDetails> creationDetails) + throws InterruptedException { + taskManagerService.validateTaskState(branch); + return new ResponseEntity<>( + productCreationService.createProductFromAtomicData(branch, creationDetails), + HttpStatus.CREATED); + } + + @LogExecutionTime + @PostMapping("/{branch}/devices/product/$calculate") + public ProductSummary calculateDeviceProductFromAtomioData( + @PathVariable String branch, + @RequestBody @Valid PackageDetails<@Valid DeviceProductDetails> productDetails) { + taskManagerService.validateTaskState(branch); + return deviceProductCalculationService.calculateProductFromAtomicData(branch, productDetails); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/JiraUserController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/JiraUserController.java new file mode 100644 index 000000000..0c67cda1f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/JiraUserController.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.auth.JiraUser; +import au.gov.digitalhealth.lingo.service.JiraUserManagerService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +public class JiraUserController { + + private final JiraUserManagerService jiraUserManagerService; + + public JiraUserController(JiraUserManagerService jiraUserManagerService) { + this.jiraUserManagerService = jiraUserManagerService; + } + + @GetMapping("") + public List getAllUsers(HttpServletRequest request) { + return jiraUserManagerService.getAllJiraUsers(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/MedicationController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/MedicationController.java new file mode 100644 index 000000000..7073aa959 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/MedicationController.java @@ -0,0 +1,184 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.aspect.LogExecutionTime; +import au.gov.digitalhealth.lingo.configuration.FieldBindingConfiguration; +import au.gov.digitalhealth.lingo.exception.MultipleFieldBindingsProblem; +import au.gov.digitalhealth.lingo.exception.NoFieldBindingsProblem; +import au.gov.digitalhealth.lingo.product.ProductBrands; +import au.gov.digitalhealth.lingo.product.ProductCreationDetails; +import au.gov.digitalhealth.lingo.product.ProductPackSizes; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.bulk.BrandPackSizeCreationDetails; +import au.gov.digitalhealth.lingo.product.bulk.BulkProductAction; +import au.gov.digitalhealth.lingo.product.details.MedicationProductDetails; +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.service.BrandPackSizeService; +import au.gov.digitalhealth.lingo.service.MedicationProductCalculationService; +import au.gov.digitalhealth.lingo.service.MedicationService; +import au.gov.digitalhealth.lingo.service.ProductCreationService; +import au.gov.digitalhealth.lingo.service.TaskManagerService; +import jakarta.validation.Valid; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping( + value = "/api", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class MedicationController { + + private final MedicationService medicationService; + private final MedicationProductCalculationService medicationProductCalculationService; + private final TaskManagerService taskManagerService; + private final FieldBindingConfiguration fieldBindingConfiguration; + private final ProductCreationService productCreationService; + private final BrandPackSizeService brandPackSizeService; + + MedicationController( + MedicationService medicationService, + MedicationProductCalculationService medicationProductCalculationService, + TaskManagerService taskManagerService, + FieldBindingConfiguration fieldBindingConfiguration, + ProductCreationService productCreationService, + BrandPackSizeService brandPackSizeService) { + this.medicationService = medicationService; + this.medicationProductCalculationService = medicationProductCalculationService; + this.fieldBindingConfiguration = fieldBindingConfiguration; + this.taskManagerService = taskManagerService; + this.productCreationService = productCreationService; + this.brandPackSizeService = brandPackSizeService; + } + + @LogExecutionTime + @GetMapping("/{branch}/medications/{productId}") + public PackageDetails getMedicationPackageAtomicData( + @PathVariable String branch, @PathVariable Long productId) { + return medicationService.getPackageAtomicData(branch, productId.toString()); + } + + /** + * Finds the other pack sizes for a given product. + * + * @param branch The branch to search in. + * @param productId The product ID to search for. + * @return The other pack sizes for the given product. + */ + @LogExecutionTime + @GetMapping("/{branch}/medications/{productId}/pack-sizes") + public ProductPackSizes getMedicationProductPackSizes( + @PathVariable String branch, @PathVariable Long productId) { + return medicationService.getProductPackSizes(branch, productId); + } + + /** + * Finds the brands for a given product. + * + * @param branch The branch to search in. + * @param productId The product ID to search for. + * @return The brands for the given product. + */ + @LogExecutionTime + @GetMapping("/{branch}/medications/{productId}/brands") + public ProductBrands getMedicationProductBrands( + @PathVariable String branch, @PathVariable Long productId) { + return medicationService.getProductBrands(branch, productId); + } + + @LogExecutionTime + @GetMapping("/{branch}/medications/product/{productId}") + public MedicationProductDetails getMedicationProductAtomioData( + @PathVariable String branch, @PathVariable Long productId) { + return medicationService.getProductAtomicData(branch, productId.toString()); + } + + @LogExecutionTime + @GetMapping("/{branch}/medications/field-bindings") + public Map getMedicationAtomioDataFieldBindings(@PathVariable String branch) { + String branchKey = branch.replace("|", "_"); + + Set keys = + fieldBindingConfiguration.getMappers().keySet().stream() + .filter(branchKey::startsWith) + .collect(Collectors.toSet()); + + if (keys.isEmpty()) { + throw new NoFieldBindingsProblem(branchKey, fieldBindingConfiguration.getMappers().keySet()); + } else if (keys.size() > 1) { + throw new MultipleFieldBindingsProblem(branchKey, keys); + } + + return fieldBindingConfiguration.getMappers().get(keys.iterator().next()); + } + + @LogExecutionTime + @PostMapping("/{branch}/medications/product") + public ResponseEntity createMedicationProductFromAtomioData( + @PathVariable String branch, + @RequestBody @Valid + ProductCreationDetails<@Valid MedicationProductDetails> productCreationDetails) + throws InterruptedException { + taskManagerService.validateTaskState(branch); + return new ResponseEntity<>( + productCreationService.createProductFromAtomicData(branch, productCreationDetails), + HttpStatus.CREATED); + } + + @LogExecutionTime + @PostMapping("/{branch}/medications/product/new-brand-pack-sizes") + public ResponseEntity createProductFromBrandPackSizeCreationDetails( + @PathVariable String branch, + @RequestBody @Valid BulkProductAction creationDetails) + throws InterruptedException { + taskManagerService.validateTaskState(branch); + return new ResponseEntity<>( + productCreationService.createProductFromBrandPackSizeCreationDetails( + branch, creationDetails), + HttpStatus.CREATED); + } + + @LogExecutionTime + @PostMapping("/{branch}/medications/product/$calculate") + public ProductSummary calculateMedicationProductFromAtomicData( + @PathVariable String branch, + @RequestBody @Valid PackageDetails<@Valid MedicationProductDetails> productDetails) + throws ExecutionException, InterruptedException { + taskManagerService.validateTaskState(branch); + return medicationProductCalculationService.calculateProductFromAtomicData( + branch, productDetails); + } + + @LogExecutionTime + @PostMapping("/{branch}/medications/product/$calculateNewBrandPackSizes") + public ProductSummary calculateNewBrandPackSizeMedicationProducts( + @PathVariable String branch, + @RequestBody @Valid BrandPackSizeCreationDetails brandPackSizeCreationDetails) { + taskManagerService.validateTaskState(branch); + return brandPackSizeService.calculateNewBrandPackSizes(branch, brandPackSizeCreationDetails); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/ProductsController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/ProductsController.java new file mode 100644 index 000000000..267985eb9 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/ProductsController.java @@ -0,0 +1,137 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.csiro.snowstorm_client.model.SnowstormTermLangPojo; +import au.gov.digitalhealth.lingo.aspect.LogExecutionTime; +import au.gov.digitalhealth.lingo.product.Edge; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import au.gov.digitalhealth.lingo.product.update.ProductUpdateRequest; +import au.gov.digitalhealth.lingo.service.ProductSummaryService; +import au.gov.digitalhealth.lingo.service.ProductUpdateService; +import au.gov.digitalhealth.lingo.service.TaskManagerService; +import au.gov.digitalhealth.tickets.models.BulkProductAction; +import jakarta.validation.Valid; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import lombok.extern.java.Log; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping( + value = "/api", + produces = {MediaType.APPLICATION_JSON_VALUE}) +@Log +public class ProductsController { + + final ProductSummaryService productService; + final TaskManagerService taskManagerService; + final ProductUpdateService productUpdateService; + + public ProductsController( + ProductSummaryService productService, + TaskManagerService taskManagerService, + ProductUpdateService productUpdateService) { + this.productService = productService; + this.taskManagerService = taskManagerService; + this.productUpdateService = productUpdateService; + } + + @LogExecutionTime + @GetMapping("/{branch}/product-model/{productId}") + public ProductSummary getProductModel(@PathVariable String branch, @PathVariable Long productId) { + return productService.getProductSummary(branch, productId.toString()); + } + + @LogExecutionTime + @PutMapping("/{branch}/product-model/{productId}/update") + public ResponseEntity updateProductDescriptions( + @PathVariable String branch, + @PathVariable Long productId, + @RequestBody @Valid ProductUpdateRequest productUpdateRequest) + throws InterruptedException { + taskManagerService.validateTaskState(branch); + BulkProductAction response = + productUpdateService.updateProduct(branch, String.valueOf(productId), productUpdateRequest); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @LogExecutionTime + @GetMapping("/{branch}/product-model/{productId}/externalIdentifiers") + public ResponseEntity> getExternalIdentifiers( + @PathVariable String branch, @PathVariable Long productId) throws InterruptedException { + Set externalIdentifiers = + productUpdateService.getExternalIdentifiers(branch, String.valueOf(productId)); + return new ResponseEntity<>(externalIdentifiers, HttpStatus.OK); + } + + @LogExecutionTime + @GetMapping("/{branch}/product-model-graph/{productId}") + public String getProductModelGraph(@PathVariable String branch, @PathVariable Long productId) { + + ProductSummary summary = productService.getProductSummary(branch, productId.toString()); + + Map> nodesByType = new HashMap<>(); + + summary + .getNodes() + .forEach( + node -> nodesByType.computeIfAbsent(node.getLabel(), k -> new HashSet<>()).add(node)); + + StringBuilder graph = new StringBuilder(); + graph.append("digraph G {\n rankdir=\"BT\"\n"); + for (Entry> entry : nodesByType.entrySet()) { + graph.append(" subgraph cluster_").append(entry.getKey()).append(" {\n"); + graph.append(" label = \"").append(entry.getKey()).append("\";\n"); + for (Node node : entry.getValue()) { + SnowstormTermLangPojo pt = node.getConcept().getPt(); + if (pt != null) { + graph + .append(" ") + .append(node.getConcept().getConceptId()) + .append(" [label=\"") + .append(pt.getTerm()) + .append("\"];\n"); + } + } + graph.append(" }\n"); + } + for (Edge edge : summary.getEdges()) { + graph + .append(" ") + .append(edge.getSource()) + .append(" -> ") + .append(edge.getTarget()) + .append(" [label=\"") + .append(edge.getLabel()) + .append("\" ") + .append( + edge.getLabel().equals(ProductSummaryService.IS_A_LABEL) + ? "arrowhead=empty" + : "style=dashed arrowhead=open") + .append("];\n"); + } + return graph.append("}").toString(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/QualifierController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/QualifierController.java new file mode 100644 index 000000000..03641c2b2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/QualifierController.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.gov.digitalhealth.lingo.aspect.LogExecutionTime; +import au.gov.digitalhealth.lingo.product.BrandCreationRequest; +import au.gov.digitalhealth.lingo.service.ProductCreationService; +import au.gov.digitalhealth.lingo.service.TaskManagerService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping( + value = "/api", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class QualifierController { + + private final TaskManagerService taskManagerService; + private final ProductCreationService productCreationService; + + QualifierController( + TaskManagerService taskManagerService, ProductCreationService productCreationService) { + this.taskManagerService = taskManagerService; + this.productCreationService = productCreationService; + } + + @LogExecutionTime + @PostMapping("/{branch}/qualifier/product-name") + public ResponseEntity createBrand( + @PathVariable String branch, @RequestBody @Valid BrandCreationRequest brandCreationRequest) + throws InterruptedException { + taskManagerService.validateTaskState(branch); + return new ResponseEntity<>( + productCreationService.createBrand(branch, brandCreationRequest), HttpStatus.CREATED); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/SecureConfigController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/SecureConfigController.java new file mode 100644 index 000000000..1c67cc94f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/SecureConfigController.java @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.configuration.SecureConfiguration; +import au.gov.digitalhealth.lingo.configuration.SentryConfiguration; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping( + value = "/api/config", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class SecureConfigController { + + private final SentryConfiguration sentryConfiguration; + + public SecureConfigController(SentryConfiguration sentryConfiguration) { + this.sentryConfiguration = sentryConfiguration; + } + + @GetMapping(value = "") + public SecureConfiguration getConfig() { + SecureConfiguration.SecureConfigurationBuilder builder = + SecureConfiguration.builder() + .sentryDsn(sentryConfiguration.getSentryDsn()) + .sentryEnabled(sentryConfiguration.getSentryEnabled()) + .sentryEnvironment(sentryConfiguration.getSentryEnvironment()) + .sentryTracesSampleRate(sentryConfiguration.getSentryTracesSampleRate()); + return builder.build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/StatusController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/StatusController.java new file mode 100644 index 000000000..ae0e554a4 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/StatusController.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.service.ServiceStatus; +import au.gov.digitalhealth.lingo.service.ServiceStatus.SnowstormStatus; +import au.gov.digitalhealth.lingo.service.ServiceStatus.Status; +import au.gov.digitalhealth.lingo.service.SnowstormClient; +import au.gov.digitalhealth.lingo.service.TaskManagerClient; +import au.gov.digitalhealth.lingo.service.identifier.IdentifierSource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping( + value = "/api/status", + produces = {MediaType.APPLICATION_JSON_VALUE}) +public class StatusController { + + TaskManagerClient taskManagerClient; + SnowstormClient snowstormClient; + IdentifierSource identifierSource; + + @Value("${ihtsdo.ap.codeSystem}") + String codeSystem; + + StatusController( + TaskManagerClient taskManagerClient, + SnowstormClient snowstormClient, + IdentifierSource identifierSource) { + this.taskManagerClient = taskManagerClient; + this.snowstormClient = snowstormClient; + this.identifierSource = identifierSource; + } + + @GetMapping(value = "") + public ServiceStatus status(HttpServletRequest request, HttpServletResponse response) { + Status apStatus = taskManagerClient.getStatus(); + SnowstormStatus snowstormStatus = snowstormClient.getStatus(codeSystem); + Status cisStatus = identifierSource.getStatus(); + + return ServiceStatus.builder() + .authoringPlatform(apStatus) + .snowstorm(snowstormStatus) + .cis(cisStatus) + .build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TasksController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TasksController.java new file mode 100644 index 000000000..397673c87 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TasksController.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.lingo.service.TaskManagerClient; +import au.gov.digitalhealth.lingo.util.Task; +import com.google.gson.JsonArray; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/tasks") +public class TasksController { + + private final TaskManagerClient taskManagerClient; + + public TasksController(TaskManagerClient taskManagerClient) { + this.taskManagerClient = taskManagerClient; + } + + @GetMapping("") + public List tasks(HttpServletRequest request) { + return taskManagerClient.getAllTasks(); + } + + @GetMapping("/myTasks") + public JsonArray myTasks(HttpServletRequest request) { + return taskManagerClient.getUserTasks(); + } + + @PostMapping(value = "", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createTask(@RequestBody Task task) { + Task createdTask = taskManagerClient.createTask(task); + return new ResponseEntity<>(createdTask, HttpStatus.OK); + } +} diff --git a/api/src/main/java/com/csiro/snomio/controllers/TelemetryController.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TelemetryController.java similarity index 89% rename from api/src/main/java/com/csiro/snomio/controllers/TelemetryController.java rename to api/src/main/java/au/gov/digitalhealth/lingo/controllers/TelemetryController.java index 9260c1257..219d21e1f 100644 --- a/api/src/main/java/com/csiro/snomio/controllers/TelemetryController.java +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TelemetryController.java @@ -1,7 +1,22 @@ -package com.csiro.snomio.controllers; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; -import com.csiro.snomio.auth.model.ImsUser; -import com.csiro.snomio.exception.TelemetryProblem; +import au.gov.digitalhealth.lingo.auth.model.ImsUser; +import au.gov.digitalhealth.lingo.exception.TelemetryProblem; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -23,6 +38,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; @@ -41,11 +57,10 @@ public class TelemetryController { public static final String SERVICE_NAME = "serviceName"; public static final String SERVICE_DOT_NAME = "service.name"; public static final String RESOURCE_SPANS = "resourceSpans"; + private final ObjectMapper objectMapper = new ObjectMapper(); WebClient zipkinClient; WebClient otlpClient; - private final ObjectMapper objectMapper = new ObjectMapper(); - @Value("${snomio.telemetry.otelendpoint}") private String otelExporterEndpoint; @@ -79,7 +94,7 @@ public Mono forwardTelemetry( user = Base64.getEncoder().encodeToString(imsUser.getLogin().getBytes()); } else { user = principal.toString(); - log.info("Principal is: " + principal.toString()); + log.info("Principal is: " + principal); } try { @@ -123,6 +138,12 @@ private Mono sendTelemetryData(String finalData, WebClient webClient, Http return Mono.empty(); } return Mono.error(e); // Propagate other errors + }) + .onErrorResume( + WebClientRequestException.class, + e -> { + log.severe("Collector error when forwarding telemetry data. " + e.getMessage()); + return Mono.empty(); }); } diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TicketFiltersDto.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TicketFiltersDto.java new file mode 100644 index 000000000..f6f399682 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/TicketFiltersDto.java @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import au.gov.digitalhealth.tickets.helper.SearchConditionBody; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Accessors(chain = true) +public class TicketFiltersDto implements Serializable { + + private Long id; + private Integer version; + private Instant created; + private String createdBy; + private Instant modified; + private String modifiedBy; + @NotNull private String name; + @NotNull private SearchConditionBody filter; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/UiSearchConfigurationDto.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/UiSearchConfigurationDto.java new file mode 100644 index 000000000..fea1f3c31 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/UiSearchConfigurationDto.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers; + +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +/** DTO for {@link UiSearchConfiguration} */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Accessors(chain = true) +public class UiSearchConfigurationDto implements Serializable { + + private Long id; + private Integer version; + private Instant created; + private String createdBy; + private Instant modified; + private String modifiedBy; + @NotNull private String username; + private TicketFiltersDto filter; + private int grouping; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/controllers/swagger/OpenAPIConfig.java b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/swagger/OpenAPIConfig.java new file mode 100644 index 000000000..cad6052bc --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/controllers/swagger/OpenAPIConfig.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.controllers.swagger; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenAPIConfig { + + @Value("${snomio.base.url:/}") + public String baseUrl; + + @Bean + public OpenAPI customOpenAPI() { + + List servers = new ArrayList<>(); + servers.add(new Server().url(baseUrl).description("Default server url")); + + return new OpenAPI() + .info( + new Info() + .title("Lingo") + .version("1.0") + .description( + "An application that allows authoring of medicinal products through the IHTSDO Authoring Platform.")) + .servers(servers); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/CISClientProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/CISClientProblem.java new file mode 100644 index 000000000..0c5cfa6f0 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/CISClientProblem.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class CISClientProblem extends LingoProblem { + public CISClientProblem(String message) { + super( + "cis-integration", + "CIS client integration problem", + HttpStatus.INTERNAL_SERVER_ERROR, + message); + } + + public CISClientProblem(String message, Throwable e) { + super( + "cis-integration", + "CIS client integration problem", + HttpStatus.INTERNAL_SERVER_ERROR, + message, + e); + } + + public static CISClientProblem cisClientProblemForOperation(String operation, Throwable e) { + return new CISClientProblem("Failed to " + operation + " identifiers.", e); + } + + public static CISClientProblem cisClientProblemForOperation(String operation) { + return new CISClientProblem("Failed to " + operation + " identifiers."); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/CsvCreationProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/CsvCreationProblem.java new file mode 100644 index 000000000..c51ac160d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/CsvCreationProblem.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class CsvCreationProblem extends LingoProblem { + + public CsvCreationProblem(String message) { + super( + "file-creation-problem", "Error Creating File", HttpStatus.INTERNAL_SERVER_ERROR, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/DateFormatProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/DateFormatProblem.java new file mode 100644 index 000000000..7cd5a339b --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/DateFormatProblem.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class DateFormatProblem extends LingoProblem { + + public DateFormatProblem(String message) { + super("date-format-problem", "Date Format Problem", HttpStatus.BAD_REQUEST, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/EmptyProductCreationProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/EmptyProductCreationProblem.java new file mode 100644 index 000000000..ba7def03d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/EmptyProductCreationProblem.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class EmptyProductCreationProblem extends LingoProblem { + + public EmptyProductCreationProblem() { + super( + "empty-product-creation-problem", + "Empty product creation problem", + HttpStatus.UNPROCESSABLE_ENTITY, + "Product creation request did not contain any concepts to create"); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/ErrorMessages.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ErrorMessages.java new file mode 100644 index 000000000..afa490f1c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ErrorMessages.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +public class ErrorMessages { + + public static final String TICKET_ID_NOT_FOUND = "Ticket with ID %s not found"; + + public static final String COMMENT_ID_NOT_FOUND = "Comment with ID %s not found"; + + public static final String COMMENT_NOT_FOUND_FOR_TICKET = + "Comment with ID %s not found for Ticket with Id %s"; + + public static final String TICKET_NUMBER_NOT_FOUND = "Ticket with Number %s not found"; + public static final String LABEL_ID_NOT_FOUND = "Label with ID %s not found"; + + public static final String LABEL_NAME_NOT_FOUND = "Label with Name %s not found"; + + public static final String EXTERNAL_REQUESTOR_NAME_NOT_FOUND = + "External Requestor with Name %s not found"; + + public static final String EXTERNAL_REQUESTOR_ID_NOT_FOUND = + "External Requestor with ID %s not found"; + public static final String TASK_ASSOCIATION_ID_NOT_FOUND = "TaskAssociation with ID %s not found"; + public static final String TASK_ASSOCIATION_ALREADY_EXISTS = + "TaskAssociation already exists for ticket with id %s"; + public static final String PRIORITY_BUCKET_ID_NOT_FOUND = "Priority Bucket with ID %s not found"; + public static final String ADDITIONAL_FIELD_VALUE_ID_NOT_FOUND = + "Additional field with ID %s not found"; + public static final String ITERATION_NOT_FOUND = "Iteration with ID %s not found"; + + public static final String STATE_NOT_FOUND = "State with ID %s not found"; + + public static final String STATE_LABEL_NOT_FOUND = "State with Label %s not found"; + + public static final String TICKET_ASSOCIATION_EXISTS = + "Association between tickets %s and %s already exists"; + + public static final String ID_NOT_FOUND = "ID %s not found"; + + private ErrorMessages() {} +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/GlobalExceptionHandler.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..63fdb127b --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/GlobalExceptionHandler.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import au.gov.digitalhealth.lingo.auth.exception.AuthenticationProblem; +import com.drew.lang.annotations.Nullable; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.http.*; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + @ExceptionHandler(AuthenticationProblem.class) + ProblemDetail handleAuthenticationProblem(AuthenticationProblem e) { + return e.getBody(); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + ProblemDetail detail = + ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + Stream.concat( + ex.getBindingResult().getFieldErrors().stream() + .map( + fe -> + fe.getObjectName() + + "." + + fe.getField() + + " value " + + fe.getRejectedValue() + + " rejected: " + + fe.getDefaultMessage()), + ex.getBindingResult().getGlobalErrors().stream() + .map(ge -> ge.getObjectName() + " rejected: " + ge.getDefaultMessage())) + .collect(Collectors.joining(". "))); + return new ResponseEntity<>(detail, HttpStatus.BAD_REQUEST); + } + + @Override + @Nullable + protected ResponseEntity handleHandlerMethodValidationException( + HandlerMethodValidationException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + ProblemDetail problemDetail = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failure"); + problemDetail.setType(URI.create("about:blank")); + + List> errorList = new ArrayList<>(); + ex.getAllErrors() + .forEach( + (error) -> { + Map errorMap = new HashMap<>(); + if (error instanceof FieldError) { + FieldError fieldError = (FieldError) error; + errorMap.put("field", fieldError.getField()); + errorMap.put("message", fieldError.getDefaultMessage()); + } else { + errorMap.put("message", error.getDefaultMessage()); + } + errorList.add(errorMap); + }); + + problemDetail.setProperty("errors", errorList); + + return new ResponseEntity<>(problemDetail, HttpStatus.BAD_REQUEST); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/InvalidSearchProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/InvalidSearchProblem.java new file mode 100644 index 000000000..283bb2db8 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/InvalidSearchProblem.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class InvalidSearchProblem extends LingoProblem { + + public InvalidSearchProblem(String message) { + super( + "invalid-search-parameters", "Invalid Search Parameters", HttpStatus.BAD_REQUEST, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/MoreThanOneSubjectProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/MoreThanOneSubjectProblem.java new file mode 100644 index 000000000..604dcd64f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/MoreThanOneSubjectProblem.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class MoreThanOneSubjectProblem extends LingoProblem { + public MoreThanOneSubjectProblem(String s) { + super( + "more-than-one-subject-problem", + "More than one subject problem", + HttpStatus.BAD_REQUEST, + s); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/MultipleFieldBindingsProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/MultipleFieldBindingsProblem.java new file mode 100644 index 000000000..dc672e79c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/MultipleFieldBindingsProblem.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import java.util.Set; +import org.springframework.http.HttpStatus; + +public class MultipleFieldBindingsProblem extends LingoProblem { + public MultipleFieldBindingsProblem(String branch, Set keys) { + super( + "multiple-field-bindings", + "Multiple field bindings configured", + HttpStatus.BAD_REQUEST, + "Multiple field bindings are configured that match branch name " + + branch + + " matching configurations were " + + String.join(", ", keys)); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/NamespaceNotConfiguredProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/NamespaceNotConfiguredProblem.java new file mode 100644 index 000000000..f11c83a68 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/NamespaceNotConfiguredProblem.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class NamespaceNotConfiguredProblem extends LingoProblem { + public NamespaceNotConfiguredProblem(String key) { + super( + "namespace-not-configured", + "Namespace not configured: " + key, + HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/NoFieldBindingsProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/NoFieldBindingsProblem.java new file mode 100644 index 000000000..8806b57aa --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/NoFieldBindingsProblem.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import java.util.Set; +import org.springframework.http.HttpStatus; + +public class NoFieldBindingsProblem extends LingoProblem { + public NoFieldBindingsProblem(String branch, Set keys) { + super( + "no-field-bindings", + "No field bindings configured", + HttpStatus.BAD_REQUEST, + "No field bindings are configured that match branch name " + + branch + + " configured options are " + + String.join(", ", keys)); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/OntologyCreationProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/OntologyCreationProblem.java new file mode 100644 index 000000000..acc1367ac --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/OntologyCreationProblem.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class OntologyCreationProblem extends LingoProblem { + public OntologyCreationProblem(String conceptId, Exception e) { + super( + "ontology-creation-error", + "Problem creating ontology", + HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to create OWL Ontology to calculate axioms for " + + conceptId + + " - " + + e.getMessage()); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/OwnershipProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/OwnershipProblem.java new file mode 100644 index 000000000..1dafc06d4 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/OwnershipProblem.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class OwnershipProblem extends LingoProblem { + + public OwnershipProblem(String message) { + super("ownership-problem", "Forbidden", HttpStatus.FORBIDDEN, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/ProductModelProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ProductModelProblem.java new file mode 100644 index 000000000..b05f002d0 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ProductModelProblem.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class ProductModelProblem extends LingoProblem { + + public ProductModelProblem(String type, Long productId, SingleConceptExpectedProblem e) { + super( + "product-model-problem", + "Product model problem", + HttpStatus.INTERNAL_SERVER_ERROR, + "Product " + + productId + + " is expected to have 1 " + + type + + " but has " + + e.getSize() + + ". " + + e.getBody().getDetail(), + e); + } + + public ProductModelProblem(String message) { + super( + "product-model-problem", + "Product model problem", + HttpStatus.INTERNAL_SERVER_ERROR, + message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/ResourceAlreadyExists.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ResourceAlreadyExists.java new file mode 100644 index 000000000..21d8ff5c5 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ResourceAlreadyExists.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class ResourceAlreadyExists extends LingoProblem { + + public ResourceAlreadyExists(String message) { + super("resource-already-exists", "Resource Already Exists", HttpStatus.CONFLICT, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/ResourceInUseProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ResourceInUseProblem.java new file mode 100644 index 000000000..7507db49b --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/ResourceInUseProblem.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class ResourceInUseProblem extends LingoProblem { + + public ResourceInUseProblem(String message) { + super("resource-in-use", "Resource in use", HttpStatus.CONFLICT, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/TaskActionsLockedProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TaskActionsLockedProblem.java new file mode 100644 index 000000000..57c500d51 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TaskActionsLockedProblem.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class TaskActionsLockedProblem extends LingoProblem { + + public TaskActionsLockedProblem(String message) { + super("task-actions-locked-problem", "Forbidden", HttpStatus.FORBIDDEN, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/TelemetryProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TelemetryProblem.java new file mode 100644 index 000000000..d5732a921 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TelemetryProblem.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class TelemetryProblem extends LingoProblem { + public TelemetryProblem(String message) { + super( + "telemetry-problem", + "Failed to parse and enrich telemetry data", + HttpStatus.SERVICE_UNAVAILABLE, + message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/TicketImportProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TicketImportProblem.java new file mode 100644 index 000000000..73d7ebf1c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TicketImportProblem.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class TicketImportProblem extends LingoProblem { + public TicketImportProblem(String message) { + super("ticket-import-problem", "Ticket Import Failure", HttpStatus.NOT_FOUND, message); + } + + public TicketImportProblem() { + super("ticket-import-problem", "Ticket Import Failure", HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/TicketStateClosedProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TicketStateClosedProblem.java new file mode 100644 index 000000000..bc3027247 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/TicketStateClosedProblem.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class TicketStateClosedProblem extends LingoProblem { + + public TicketStateClosedProblem(String message) { + super("ticket-state-closed-problem", "Forbidden", HttpStatus.FORBIDDEN, message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/UnexpectedSnowstormResponseProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/UnexpectedSnowstormResponseProblem.java new file mode 100644 index 000000000..bf74b057b --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/UnexpectedSnowstormResponseProblem.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import org.springframework.http.HttpStatus; + +public class UnexpectedSnowstormResponseProblem extends LingoProblem { + + public UnexpectedSnowstormResponseProblem(String message) { + super( + "unexpected-snowstorm-response", + "Unexpected Snowstorm response", + HttpStatus.INTERNAL_SERVER_ERROR, + message); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/exception/UnknownAttributeTypeProblem.java b/api/src/main/java/au/gov/digitalhealth/lingo/exception/UnknownAttributeTypeProblem.java new file mode 100644 index 000000000..f43c98c33 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/exception/UnknownAttributeTypeProblem.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.exception; + +import au.gov.digitalhealth.tickets.models.AdditionalFieldType.Type; +import org.springframework.http.HttpStatus; + +public class UnknownAttributeTypeProblem extends LingoProblem { + public UnknownAttributeTypeProblem(Type type) { + super( + "unknown-attribute-type", + "Unknown attribute type", + HttpStatus.INTERNAL_SERVER_ERROR, + "Attribute type " + type.name() + " is unknown"); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/BrandCreationRequest.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/BrandCreationRequest.java new file mode 100644 index 000000000..ae65cd286 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/BrandCreationRequest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class BrandCreationRequest implements Serializable { + @NotNull @Valid String brandName; + + @NotNull Long ticketId; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/BrandWithIdentifiers.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/BrandWithIdentifiers.java new file mode 100644 index 000000000..f94bf48fd --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/BrandWithIdentifiers.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Set; +import lombok.Data; + +@Data +public class BrandWithIdentifiers implements Serializable { + @NotNull private SnowstormConceptMini brand; + @NotNull @Valid private Set<@Valid ExternalIdentifier> externalIdentifiers; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/Edge.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/Edge.java new file mode 100644 index 000000000..e30d0ebea --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/Edge.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Edge in a {@link ProductSummary} which represents a relationship between two concepts within a + * product's model, with a label indicating the type of the relationship. + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Edge { + @NotNull @NotEmpty String source; + @NotNull @NotEmpty String target; + @NotNull @NotEmpty String label; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/FsnAndPt.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/FsnAndPt.java new file mode 100644 index 000000000..367eb02b9 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/FsnAndPt.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@SuppressWarnings("java:S116") +public class FsnAndPt { + + String FSN; + String PT; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/NameGeneratorSpec.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/NameGeneratorSpec.java new file mode 100644 index 000000000..ae000fc3e --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/NameGeneratorSpec.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class NameGeneratorSpec { + + String tag; + String owl; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/PackSizeWithIdentifiers.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/PackSizeWithIdentifiers.java new file mode 100644 index 000000000..f32c23069 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/PackSizeWithIdentifiers.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Set; +import lombok.Data; + +@Data +public class PackSizeWithIdentifiers implements Serializable { + @NotNull private BigDecimal packSize; + @NotNull @Valid private Set<@Valid ExternalIdentifier> externalIdentifiers; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductBrands.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductBrands.java new file mode 100644 index 000000000..35defd216 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductBrands.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.gov.digitalhealth.lingo.util.PartionIdentifier; +import au.gov.digitalhealth.lingo.validation.ValidSctId; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.Data; + +@Valid +@Data +public class ProductBrands implements Serializable { + + @NotNull + @ValidSctId(partitionIdentifier = PartionIdentifier.CONCEPT) + private String productId; + + @Valid @NotNull @NotEmpty private Set<@Valid BrandWithIdentifiers> brands; + + @JsonIgnore + public Map getIdFsnMap() { + return brands.stream() + .map(BrandWithIdentifiers::getBrand) + .distinct() + .filter(s -> s.getFsn() != null) + .collect(Collectors.toMap(SnowstormConceptMini::getConceptId, s -> s.getFsn().getTerm())); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductCreationDetails.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductCreationDetails.java new file mode 100644 index 000000000..992bc76ff --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductCreationDetails.java @@ -0,0 +1,56 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.product.details.ProductDetails; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data class for product creation request + * + * @param product details type either #MedicationProductDetails or #DeviceProductDetails + */ +@AllArgsConstructor +@NoArgsConstructor +@Data +public class ProductCreationDetails { + /** Summary of the product concepts that exist and to create */ + @NotNull @Valid ProductSummary productSummary; + + /** Atomic data used to calculate the product summary */ + @NotNull @Valid PackageDetails packageDetails; + + /** Ticket to record this against */ + @NotNull Long ticketId; + + /** + * The name of a previous partial save of the product details loaded and completed to create this + * product - will be overwritten with the creation product details. + */ + String partialSaveName; + + /** + * A name to override the name on the saved product, used in situations where two products on an + * authored ticket MAY have the same name, but the intention is not to override the already saved + * product, as the name + ticketId is meant to be unique + */ + String nameOverride; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductPackSizes.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductPackSizes.java new file mode 100644 index 000000000..54d0ad325 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductPackSizes.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import lombok.Data; + +@Valid +@Data +public class ProductPackSizes implements Serializable { + private String productId; + + @NotNull private SnowstormConceptMini unitOfMeasure; + + @NotEmpty @NotNull private Set packSizes; + + @JsonIgnore + public Map getIdFsnMap() { + return Map.of( + Objects.requireNonNull(unitOfMeasure.getConceptId()), + Objects.requireNonNull(Objects.requireNonNull(unitOfMeasure.getFsn()).getTerm())); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductSummary.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductSummary.java new file mode 100644 index 000000000..82d769b39 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/ProductSummary.java @@ -0,0 +1,231 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product; + +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.exception.MoreThanOneSubjectProblem; +import au.gov.digitalhealth.lingo.exception.SingleConceptExpectedProblem; +import au.gov.digitalhealth.lingo.service.ProductSummaryService; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * "N-box" model DTO listing a set of nodes and edges between them where the nodes and edges have + * labels indicating their type. + */ +@Getter +public class ProductSummary implements Serializable { + + @NotNull final Set subjects = new HashSet<>(); + + @NotNull @NotEmpty final Set<@Valid Node> nodes = new HashSet<>(); + + @NotNull @NotEmpty final Set<@Valid Edge> edges = new HashSet<>(); + + @JsonProperty(value = "containsNewConcepts", access = JsonProperty.Access.READ_ONLY) + public boolean isContainsNewConcepts() { + return nodes.stream().anyMatch(Node::isNewConcept); + } + + public void addNode(Node node) { + synchronized (nodes) { + for (Node n : nodes) { + if (n.getConceptId().equals(node.getConceptId()) && !n.getLabel().equals(node.getLabel())) { + throw new SingleConceptExpectedProblem( + "Node with id " + + node.getConceptId() + + " and label " + + node.getLabel() + + " already exists in product model with label " + + n.getLabel(), + 1); + } + } + + nodes.add(node); + } + } + + public void addNode(SnowstormConceptMini conceptSummary, String label) { + synchronized (nodes) { + Node node = new Node(conceptSummary, label); + addNode(node); + } + } + + public void addEdge(String source, String target, String type) { + synchronized (edges) { + edges.add(new Edge(source, target, type)); + } + } + + public void addSummary(ProductSummary productSummary) { + synchronized (edges) { + productSummary.getNodes().forEach(this::addNode); + edges.addAll(productSummary.getEdges()); + } + } + + public Node getSingleConceptWithLabel(String label) { + synchronized (nodes) { + Set filteredNodes = + getNodes().stream().filter(n -> n.getLabel().equals(label)).collect(Collectors.toSet()); + if (filteredNodes.size() != 1) { + throw new SingleConceptExpectedProblem( + "Expected 1 " + + label + + " but found " + + filteredNodes.stream().map(Node::getIdAndFsnTerm).collect(Collectors.joining()), + filteredNodes.size()); + } else { + return filteredNodes.iterator().next(); + } + } + } + + public Set getConceptIdsWithLabel(String label) { + synchronized (nodes) { + return getNodes().stream() + .filter(n -> n.getLabel().equals(label)) + .map(Node::getConceptId) + .collect(Collectors.toSet()); + } + } + + public Set getTargetsOfTypeWithLabel(String source, String nodeLabel, String edgeLabel) { + synchronized (edges) { + Set potentialTargets = getConceptIdsWithLabel(nodeLabel); + return getEdges().stream() + .filter( + e -> + e.getSource().equals(source) + && e.getLabel().equals(edgeLabel) + && potentialTargets.contains(e.getTarget())) + .map(Edge::getTarget) + .collect(Collectors.toSet()); + } + } + + public String getSingleTargetOfTypeWithLabel(String source, String nodeLabel, String edgeLabel) { + Set target = getTargetsOfTypeWithLabel(source, nodeLabel, edgeLabel); + if (target.size() != 1) { + throw new SingleConceptExpectedProblem( + "Expected 1 target of type " + + nodeLabel + + " from edge type " + + edgeLabel + + " from " + + source + + " but found " + + String.join(", ", target), + target.size()); + } else { + return target.iterator().next(); + } + } + + public Set calculateSubject(boolean singleSubject) { + synchronized (nodes) { + Set subjectNodes = + getNodes().stream() + .filter( + n -> + n.getLabel().equals(ProductSummaryService.CTPP_LABEL) + && getEdges().stream() + .noneMatch(e -> e.getTarget().equals(n.getConceptId()))) + .collect(Collectors.toSet()); + + if (singleSubject && subjectNodes.size() != 1) { + throw new MoreThanOneSubjectProblem( + "Product model must have exactly one CTPP node (root) with no incoming edges. Found " + + subjectNodes.size() + + " which were " + + subjectNodes.stream().map(Node::getConceptId).collect(Collectors.joining(", "))); + } + + return subjectNodes; + } + } + + public Node getNode(String id) { + synchronized (nodes) { + return nodes.stream().filter(n -> n.getConceptId().equals(id)).findFirst().orElse(null); + } + } + + public void updateNodeChangeStatus(List taskChangedIds, List projectChangedIds) { + synchronized (nodes) { + nodes.forEach( + n -> { + n.setNewInTask( + taskChangedIds.contains(n.getConceptId()) + && !projectChangedIds.contains(n.getConceptId())); + n.setNewInProject(projectChangedIds.contains(n.getConceptId())); + }); + } + } + + public void addSubject(Node node) { + synchronized (subjects) { + subjects.add(node); + } + } + + @JsonIgnore + public Node getSingleSubject() { + synchronized (subjects) { + if (subjects.size() != 1) { + throw new LingoProblem( + "product-summary", + "No subject set", + HttpStatus.INTERNAL_SERVER_ERROR, + "Expected 1 subject but found " + + subjects.stream().map(Node::getConceptId).collect(Collectors.joining(", "))); + } + return subjects.iterator().next(); + } + } + + @JsonIgnore + public void setSingleSubject(Node ctppNode) { + synchronized (subjects) { + if (subjects.size() == 1 && subjects.contains(ctppNode)) { + return; + } else if (!subjects.isEmpty()) { + throw new LingoProblem( + "product-summary", + "Subject already set", + HttpStatus.INTERNAL_SERVER_ERROR, + "Subject already set to " + + subjects.stream().map(Node::getConceptId).collect(Collectors.joining(", ")) + + " cannot set to " + + ctppNode.getConceptId()); + } + subjects.add(ctppNode); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BrandPackSizeCreationDetails.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BrandPackSizeCreationDetails.java new file mode 100644 index 000000000..61bd4897e --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BrandPackSizeCreationDetails.java @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.bulk; + +import au.gov.digitalhealth.lingo.product.ProductBrands; +import au.gov.digitalhealth.lingo.product.ProductPackSizes; +import au.gov.digitalhealth.lingo.util.PartionIdentifier; +import au.gov.digitalhealth.lingo.validation.ValidSctId; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Valid +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BrandPackSizeCreationDetails implements BulkProductActionDetails, Serializable { + @NotNull + @ValidSctId(partitionIdentifier = PartionIdentifier.CONCEPT) + private String productId; + + @Valid private ProductBrands brands; + + @Valid private ProductPackSizes packSizes; + + @JsonIgnore + public Map getIdFsnMap() { + Map returnMap = brands == null ? new HashMap<>() : brands.getIdFsnMap(); + returnMap.putAll(packSizes == null ? Map.of() : packSizes.getIdFsnMap()); + return returnMap; + } + + @Override + public String calculateSaveName() { + return "Brand/pack size " + new Date(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BulkProductAction.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BulkProductAction.java new file mode 100644 index 000000000..e4d4a0631 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BulkProductAction.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.bulk; + +import au.gov.digitalhealth.lingo.product.ProductSummary; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Valid +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BulkProductAction implements Serializable { + /** Summary of the product concepts that exist and to create */ + @NotNull @Valid ProductSummary productSummary; + + /** Data used to calculate the product summary */ + @NotNull @Valid T details; + + /** Ticket to record this against */ + @NotNull Long ticketId; + + /** + * The name of a previous partial save of the details loaded and completed to create this product + * - will be overwritten with the creation product details. + */ + String partialSaveName; + + public String calculateSaveName() { + return details.calculateSaveName(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BulkProductActionDetails.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BulkProductActionDetails.java new file mode 100644 index 000000000..1144578e2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/BulkProductActionDetails.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.bulk; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.io.Serializable; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = BrandPackSizeCreationDetails.class, name = "brand-pack-size"), + @JsonSubTypes.Type(value = ProductUpdateCreationDetails.class, name = "product-update") +}) +public interface BulkProductActionDetails extends Serializable { + + String calculateSaveName(); +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/ProductUpdateCreationDetails.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/ProductUpdateCreationDetails.java new file mode 100644 index 000000000..fc8fcddac --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/bulk/ProductUpdateCreationDetails.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.bulk; + +import au.gov.digitalhealth.lingo.product.update.ProductUpdateState; +import au.gov.digitalhealth.lingo.util.PartionIdentifier; +import au.gov.digitalhealth.lingo.validation.ValidSctId; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductUpdateCreationDetails implements BulkProductActionDetails, Serializable { + @NotNull + @ValidSctId(partitionIdentifier = PartionIdentifier.CONCEPT) + private String productId; + + private ProductUpdateState historicState; + + private ProductUpdateState updatedState; + + @Override + public String calculateSaveName() { + return "Product Update " + new Date(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductDescriptionUpdateRequest.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductDescriptionUpdateRequest.java new file mode 100644 index 000000000..940674ba3 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductDescriptionUpdateRequest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.update; + +import au.csiro.snowstorm_client.model.SnowstormDescription; +import jakarta.validation.Valid; +import java.io.Serializable; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Valid +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductDescriptionUpdateRequest implements Serializable { + + Set descriptions; + + public boolean areDescriptionsModified(Set existingDescriptions) { + + Set currentDescriptionsCopy = new HashSet<>(descriptions); + Set existingDescriptionsCopy = new HashSet<>(existingDescriptions); + + for (SnowstormDescription currentDesc : currentDescriptionsCopy) { + boolean foundMatchingDescription = false; + for (SnowstormDescription existingDesc : existingDescriptionsCopy) { + if (Objects.equals(currentDesc.getDescriptionId(), existingDesc.getDescriptionId())) { + foundMatchingDescription = true; + + if (!currentDesc.equals(existingDesc)) { + return true; + } + + existingDescriptionsCopy.remove(existingDesc); + break; + } + } + if (!foundMatchingDescription) { + return true; + } + } + + if (!existingDescriptionsCopy.isEmpty()) { + return true; + } + + return false; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductExternalIdentifierUpdateRequest.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductExternalIdentifierUpdateRequest.java new file mode 100644 index 000000000..89fd3ab64 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductExternalIdentifierUpdateRequest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.update; + +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import jakarta.validation.Valid; +import java.io.Serializable; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Valid +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductExternalIdentifierUpdateRequest implements Serializable { + @Valid Set<@Valid ExternalIdentifier> externalIdentifiers; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductUpdateRequest.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductUpdateRequest.java new file mode 100644 index 000000000..476f5d459 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductUpdateRequest.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.update; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ProductUpdateRequest { + + @NotNull Long ticketId; + + private String conceptId; + + private ProductDescriptionUpdateRequest descriptionUpdate; + + private ProductExternalIdentifierUpdateRequest externalRequesterUpdate; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductUpdateState.java b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductUpdateState.java new file mode 100644 index 000000000..b0bb7882d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/product/update/ProductUpdateState.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.product.update; + +import au.csiro.snowstorm_client.model.SnowstormConcept; +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import java.io.Serializable; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductUpdateState implements Serializable { + + SnowstormConcept concept; + + Set externalIdentifiers; +} diff --git a/api/src/main/java/com/csiro/snomio/security/BranchPathUriRewriteFilter.java b/api/src/main/java/au/gov/digitalhealth/lingo/security/BranchPathUriRewriteFilter.java similarity index 81% rename from api/src/main/java/com/csiro/snomio/security/BranchPathUriRewriteFilter.java rename to api/src/main/java/au/gov/digitalhealth/lingo/security/BranchPathUriRewriteFilter.java index b3e065b45..d1bbb932e 100644 --- a/api/src/main/java/com/csiro/snomio/security/BranchPathUriRewriteFilter.java +++ b/api/src/main/java/au/gov/digitalhealth/lingo/security/BranchPathUriRewriteFilter.java @@ -1,4 +1,19 @@ -package com.csiro.snomio.security; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.security; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/security/BranchPathUriUtil.java b/api/src/main/java/au/gov/digitalhealth/lingo/security/BranchPathUriUtil.java new file mode 100644 index 000000000..cea6cb563 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/security/BranchPathUriUtil.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.security; + +public class BranchPathUriUtil { + + public static final String SLASH = "/"; + public static final String ENCODED_PIPE = "%7C"; + public static final String ENCODED_SLASH = "%2F"; + + private BranchPathUriUtil() { + /* Prevent instantiation */ + } + + public static String encodePath(String path) { + return path.replace(ENCODED_SLASH, ENCODED_PIPE).replace(SLASH, ENCODED_PIPE); + } + + public static String decodePath(String branch) { + return branch.replace("|", SLASH).replace(ENCODED_PIPE, SLASH); + } +} diff --git a/api/src/main/java/com/csiro/snomio/security/SecurityConfiguration.java b/api/src/main/java/au/gov/digitalhealth/lingo/security/SecurityConfiguration.java similarity index 77% rename from api/src/main/java/com/csiro/snomio/security/SecurityConfiguration.java rename to api/src/main/java/au/gov/digitalhealth/lingo/security/SecurityConfiguration.java index 68f0bccbf..ea40fb797 100644 --- a/api/src/main/java/com/csiro/snomio/security/SecurityConfiguration.java +++ b/api/src/main/java/au/gov/digitalhealth/lingo/security/SecurityConfiguration.java @@ -1,8 +1,23 @@ -package com.csiro.snomio.security; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.security; import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; -import com.csiro.snomio.auth.security.CookieAuthenticationFilter; +import au.gov.digitalhealth.lingo.auth.security.CookieAuthenticationFilter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; @@ -23,6 +38,10 @@ public class SecurityConfiguration { @Value("${security.enable-csrf}") private boolean csrfEnabled; + @Value("${security.allowed-roles}") + private String[] allowedRoles; + + @SuppressWarnings("java:S4502") @Bean public SecurityFilterChain filterChain( HttpSecurity http, CookieAuthenticationFilter cookieAuthenticationFilter) throws Exception { @@ -45,7 +64,7 @@ public SecurityFilterChain filterChain( .requestMatchers(antMatcher("/api/h2-console/**")) .permitAll() .requestMatchers(antMatcher("/api/**")) - .hasRole("ms-australia") + .hasAnyRole(allowedRoles) .anyRequest() .anonymous()); @@ -59,6 +78,7 @@ public FilterRegistrationBean getUrlRewriteFilter() new BranchPathUriRewriteFilter( "/api/(.*)/medications/.*", "/api/(.*)/devices/.*", + "/api/(.*)/qualifier/.*", "/api/(.*)/product-model/.*", "/api/(.*)/product-model-graph/.*")); } diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/AtomicCache.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/AtomicCache.java new file mode 100644 index 000000000..df9aa8b4d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/AtomicCache.java @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.gov.digitalhealth.lingo.util.LingoConstants; +import jakarta.validation.constraints.NotNull; +import java.util.*; + +public class AtomicCache { + + private final Map idToFsnMap; + + private int nextId = -2; + + public AtomicCache( + Map idFsnMap, @SuppressWarnings("unchecked") T[]... enumerations) { + this.idToFsnMap = idFsnMap; + + Arrays.stream(enumerations) + .flatMap(Arrays::stream) + .filter(LingoConstants::hasLabel) + .filter(con -> !this.containsFsnFor(con.getValue())) + .forEach(con -> this.addFsn(con.getValue(), con.getLabel())); + } + + public String substituteIdsInAxiom(String axiom, @NotNull Integer conceptId) { + synchronized (idToFsnMap) { + for (String id : getFsnIds()) { + axiom = substituteIdInAxiom(axiom, id, getFsn(id)); + } + axiom = substituteIdInAxiom(axiom, conceptId.toString(), ""); + + return axiom; + } + } + + private String substituteIdInAxiom(String axiom, String id, String replacement) { + return axiom + .replaceAll( + "(|: *'?" + id + "'?)", ":'" + replacement + "'") + .replace("''", ""); + } + + private boolean containsFsnFor(String id) { + synchronized (idToFsnMap) { + return idToFsnMap.containsKey(id); + } + } + + public void addFsn(String id, String fsn) { + synchronized (idToFsnMap) { + idToFsnMap.put(id, fsn); + } + } + + public Set getFsnIds() { + synchronized (idToFsnMap) { + return this.idToFsnMap.keySet(); + } + } + + public String getFsn(String id) { + synchronized (idToFsnMap) { + return this.idToFsnMap.get(id); + } + } + + public synchronized int getNextId() { + return nextId--; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/AtomicDataService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/AtomicDataService.java new file mode 100644 index 000000000..1c49188a2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/AtomicDataService.java @@ -0,0 +1,521 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.ARTGID_REFSET; +import static au.gov.digitalhealth.lingo.util.AmtConstants.ARTGID_SCHEME; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_DEVICE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CTPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_CONTAINER_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_OTHER_IDENTIFYING_INFORMATION; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINS_CD; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_UNIT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_VALUE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRODUCT_NAME; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.IS_A; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT_PACKAGE; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getActiveRelationshipsInRoleGroup; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getActiveRelationshipsOfType; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getRelationshipsFromAxioms; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleActiveBigDecimal; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleActiveConcreteValue; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleActiveTarget; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleAxiom; + +import au.csiro.snowstorm_client.model.SnowstormAxiom; +import au.csiro.snowstorm_client.model.SnowstormConcept; +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.csiro.snowstorm_client.model.SnowstormItemsPageReferenceSetMember; +import au.csiro.snowstorm_client.model.SnowstormReferenceSetMember; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.aspect.LogExecutionTime; +import au.gov.digitalhealth.lingo.exception.AtomicDataExtractionProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.lingo.product.BrandWithIdentifiers; +import au.gov.digitalhealth.lingo.product.PackSizeWithIdentifiers; +import au.gov.digitalhealth.lingo.product.ProductBrands; +import au.gov.digitalhealth.lingo.product.ProductPackSizes; +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.product.details.PackageQuantity; +import au.gov.digitalhealth.lingo.product.details.ProductDetails; +import au.gov.digitalhealth.lingo.product.details.ProductQuantity; +import au.gov.digitalhealth.lingo.util.EclBuilder; +import au.gov.digitalhealth.lingo.util.SnomedConstants; +import au.gov.digitalhealth.lingo.util.ValidationUtil; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public abstract class AtomicDataService { + + public static final String MAP_TARGET = "mapTarget"; + + private static Collection getSimilarConcepts( + SnowstormAxiom axiom, + SnomedConstants typeToSuppress, + SnowstormClient snowStormApiClient, + String branch, + Map substitutionMap, + Integer limit) { + Set eclRels = + axiom.getRelationships().stream() + .filter(r -> !r.getTypeId().equals(typeToSuppress.getValue())) + .filter(r -> !r.getTypeId().equals(IS_A.getValue())) + .collect(Collectors.toSet()); + + eclRels.add( + new SnowstormRelationship() + .groupId(0) + .typeId(IS_A.getValue()) + .active(true) + .destinationId(MEDICINAL_PRODUCT_PACKAGE.getValue())); + + String ecl = EclBuilder.build(eclRels, Set.of(), false, true); + + for (Map.Entry entry : substitutionMap.entrySet()) { + ecl = ecl.replace(entry.getKey(), "(" + entry.getValue() + ")"); + } + + return snowStormApiClient.getConceptIdsFromEcl(branch, ecl, 0, limit != null ? limit : 100); + } + + protected abstract SnowstormClient getSnowStormApiClient(); + + protected abstract String getPackageAtomicDataEcl(); + + protected abstract String getProductAtomicDataEcl(); + + protected abstract T populateSpecificProductDetails( + SnowstormConcept product, + String productId, + Map browserMap, + Map typeMap); + + protected abstract String getType(); + + protected abstract String getContainedUnitRelationshipType(); + + protected abstract String getSubpackRelationshipType(); + + public PackageDetails getPackageAtomicData(String branch, String productId) { + Maps maps = getMaps(branch, productId, getPackageAtomicDataEcl()); + + return populatePackageDetails(productId, maps.browserMap(), maps.typeMap(), maps.artgMap()); + } + + public T getProductAtomicData(String branch, String productId) { + Maps maps = getMaps(branch, productId, getProductAtomicDataEcl()); + + return populateProductDetails( + maps.browserMap.get(productId), productId, maps.browserMap(), maps.typeMap()); + } + + @LogExecutionTime + private Maps getMaps(String branch, String productId, String ecl) { + SnowstormClient snowStormApiClient = getSnowStormApiClient(); + Collection concepts = getConceptsToMap(branch, productId, ecl, snowStormApiClient); + + Mono> browserMap = + snowStormApiClient + .getBrowserConcepts(branch, concepts) + .collectMap(SnowstormConcept::getConceptId); + + Flux refsetMembers = + snowStormApiClient + .getRefsetMembers(branch, concepts, null, 0, 100) + .map(r -> r.getItems()) + .flatMapIterable(c -> c); + + Mono> typeMap = + refsetMembers + .filter( + m -> + m.getRefsetId().equals(CTPP_REFSET_ID.getValue()) + || m.getRefsetId().equals(TPUU_REFSET_ID.getValue()) + || m.getRefsetId().equals(MPUU_REFSET_ID.getValue()) + || m.getRefsetId().equals(MP_REFSET_ID.getValue())) + .collect( + Collectors.toMap( + SnowstormReferenceSetMember::getReferencedComponentId, + SnowstormReferenceSetMember::getRefsetId)); + + @SuppressWarnings("null") + Mono>> artgMap = + refsetMembers + .filter(m -> m.getRefsetId().equals(ARTGID_REFSET.getValue())) + .collectMultimap( + SnowstormReferenceSetMember::getReferencedComponentId, + m -> + m.getAdditionalFields() != null + ? m.getAdditionalFields().getOrDefault(MAP_TARGET, null) + : null); + + Maps maps = + Mono.zip(browserMap, typeMap, artgMap) + .map(t -> new Maps(t.getT1(), t.getT2(), t.getT3())) + .block(); + + if (maps == null || !maps.typeMap.keySet().equals(maps.browserMap.keySet())) { + throw new AtomicDataExtractionProblem( + "Mismatch between browser and refset members", productId); + } + return maps; + } + + /** + * Finds the other pack sizes for a given product. + * + * @param branch The branch to search in. + * @param productId The product ID to search for. + * @return The other pack sizes for the given product. + */ + @LogExecutionTime + public ProductPackSizes getProductPackSizes(String branch, Long productId) { + SnowstormClient snowStormApiClient = getSnowStormApiClient(); + SnowstormConcept concept = + Mono.from(snowStormApiClient.getBrowserConcepts(branch, Set.of(productId.toString()))) + .block(); + + assert concept != null; + ValidationUtil.assertSingleComponentSinglePackProduct(concept); + + SnowstormAxiom axiom = getSingleAxiom(concept); + + Collection packVariantIds = + getSimilarConcepts(axiom, HAS_PACK_SIZE_VALUE, snowStormApiClient, branch, Map.of(), 100); + + Mono> packVariants = + snowStormApiClient.getBrowserConcepts(branch, packVariantIds).collectList(); + + List block = packVariants.block(); + assert block != null; + + Mono> packVariantRefsetMembers = + snowStormApiClient + .getRefsetMembers( + branch, + packVariantIds, + null, + 0, + packVariantIds.size() * 100) // TODO Need to comeback + .map(r -> r.getItems()); + + List packVariantResult = packVariants.block(); + + Set packSizeWithIdentifiers = new HashSet<>(); + + List packVariantRefsetMemebersResult = + packVariantRefsetMembers.block(); + if (packVariantRefsetMemebersResult == null) { + packVariantRefsetMemebersResult = List.of(); + } + + for (SnowstormConcept packVariant : packVariantResult) { + PackSizeWithIdentifiers packSizeWithIdentifier = new PackSizeWithIdentifiers(); + BigDecimal pack = + packVariant.getClassAxioms().iterator().next().getRelationships().stream() + .filter(r -> r.getTypeId().equals(HAS_PACK_SIZE_VALUE.getValue())) + .map( + r -> + new BigDecimal( + Objects.requireNonNull( + Objects.requireNonNull(r.getConcreteValue()).getValue()))) + .findFirst() + .get(); + + packSizeWithIdentifier.setPackSize(pack); + + Set externalIdentifiers = new HashSet<>(); + for (SnowstormReferenceSetMember refsetMember : packVariantRefsetMemebersResult) { + if (refsetMember.getReferencedComponentId().equals(packVariant.getConceptId()) + && refsetMember.getRefsetId().equals(ARTGID_REFSET.getValue())) { + externalIdentifiers.add( + new ExternalIdentifier( + ARTGID_SCHEME.getValue(), refsetMember.getAdditionalFields().get(MAP_TARGET))); + } + } + packSizeWithIdentifier.setExternalIdentifiers(externalIdentifiers); + packSizeWithIdentifiers.add(packSizeWithIdentifier); + } + + ProductPackSizes productPackSizes = new ProductPackSizes(); + + productPackSizes.setProductId(productId.toString()); + productPackSizes.setPackSizes(packSizeWithIdentifiers); + productPackSizes.setUnitOfMeasure( + getSingleActiveTarget(axiom.getRelationships(), HAS_PACK_SIZE_UNIT.getValue())); + + return productPackSizes; + } + + /** + * Finds the brands for a given product. + * + * @param branch The branch to search in. + * @param productId The product ID to search for. + * @return The brands for the given product. + */ + public ProductBrands getProductBrands(String branch, Long productId) { + SnowstormClient snowStormApiClient = getSnowStormApiClient(); + SnowstormConcept concept = + Mono.from(snowStormApiClient.getBrowserConcepts(branch, Set.of(productId.toString()))) + .block(); + + assert concept != null; + ValidationUtil.assertSingleComponentSinglePackProduct(concept); + + boolean medication = + getRelationshipsFromAxioms(concept).stream() + .anyMatch(r -> r.getTypeId().equals(CONTAINS_CD.getValue())); + + SnowstormConcept containedConcept = + Mono.from( + snowStormApiClient.getBrowserConcepts( + branch, + Set.of( + Objects.requireNonNull( + getSingleActiveTarget( + getSingleAxiom(concept).getRelationships(), + medication + ? CONTAINS_CD.getValue() + : CONTAINS_DEVICE.getValue()) + .getConceptId())))) + .block(); + + assert containedConcept != null; + String containedProductEcl = + EclBuilder.build( + getRelationshipsFromAxioms(containedConcept).stream() + .filter(r -> !r.getTypeId().equals(HAS_PRODUCT_NAME.getValue())) + .collect(Collectors.toSet()), + Set.of(), + false, + true); + + SnowstormAxiom axiom = getSingleAxiom(concept); + + Collection packVariantIds = + getSimilarConcepts( + axiom, + HAS_PRODUCT_NAME, + snowStormApiClient, + branch, + Map.of(Objects.requireNonNull(containedConcept.getConceptId()), containedProductEcl), + 1000); + + Mono> packVariants = + snowStormApiClient.getBrowserConcepts(branch, packVariantIds).collectList(); + + Mono> packVariantRefsetMembers = + snowStormApiClient + .getRefsetMembers(branch, packVariantIds, null, 0, packVariantIds.size() * 100) + .map(SnowstormItemsPageReferenceSetMember::getItems); + + List packVariantResult = packVariants.block(); + if (packVariantResult == null || packVariantResult.isEmpty()) { + throw new AtomicDataExtractionProblem("No pack variants found for ", productId.toString()); + } + + Set brandsWithIdentifiers = new HashSet<>(); + + List packVariantRefsetMemebersResult = + packVariantRefsetMembers.block(); + if (packVariantRefsetMemebersResult == null) { + packVariantRefsetMemebersResult = List.of(); + } + + for (SnowstormConcept packVariant : packVariantResult) { + + SnowstormConceptMini brand = + getSingleActiveTarget( + getSingleAxiom(packVariant).getRelationships(), HAS_PRODUCT_NAME.getValue()); + + // Find the BrandWithIdentifiers if present, or create a new one + BrandWithIdentifiers brandWithIdentifiers = + brandsWithIdentifiers.stream() + .filter(bi -> bi.getBrand().getConceptId().equals(brand.getConceptId())) + .findAny() + .orElseGet(BrandWithIdentifiers::new); + boolean newBrand = brandWithIdentifiers.getBrand() == null; + if (newBrand) { + brandWithIdentifiers.setBrand(brand); + } + + Set externalIdentifiers = + brandWithIdentifiers.getExternalIdentifiers() != null + ? brandWithIdentifiers.getExternalIdentifiers() + : new HashSet<>(); + for (SnowstormReferenceSetMember refsetMember : packVariantRefsetMemebersResult) { + if (refsetMember.getReferencedComponentId().equals(packVariant.getConceptId()) + && refsetMember.getRefsetId().equals(ARTGID_REFSET.getValue())) { + externalIdentifiers.add( + new ExternalIdentifier( + ARTGID_SCHEME.getValue(), refsetMember.getAdditionalFields().get(MAP_TARGET))); + } + } + brandWithIdentifiers.setExternalIdentifiers(externalIdentifiers); + if (newBrand) { // add only for not existing + brandsWithIdentifiers.add(brandWithIdentifiers); + } + } + + ProductBrands productBrands = new ProductBrands(); + productBrands.setProductId(productId.toString()); + productBrands.setBrands(brandsWithIdentifiers); + + return productBrands; + } + + @LogExecutionTime + private Collection getConceptsToMap( + String branch, String productId, String ecl, SnowstormClient snowStormApiClient) { + Collection concepts = + snowStormApiClient.getConceptsIdsFromEcl(branch, ecl, Long.parseLong(productId), 0, 100); + + if (concepts.isEmpty()) { + throw new ResourceNotFoundProblem( + "No matching concepts for " + productId + " of type " + getType()); + } + return concepts; + } + + @SuppressWarnings("null") + private PackageDetails populatePackageDetails( + String productId, + Map browserMap, + Map typeMap, + Map> artgMap) { + + PackageDetails details = new PackageDetails<>(); + + SnowstormConcept basePackage = browserMap.get(productId); + Set basePackageRelationships = getRelationshipsFromAxioms(basePackage); + // container type + details.setContainerType( + getSingleActiveTarget(basePackageRelationships, HAS_CONTAINER_TYPE.getValue())); + // product name + details.setProductName( + getSingleActiveTarget(basePackageRelationships, HAS_PRODUCT_NAME.getValue())); + // ARTG ID + if (artgMap.containsKey(productId)) { + artgMap + .get(productId) + .forEach( + artg -> + details + .getExternalIdentifiers() + .add(new ExternalIdentifier("https://www.tga.gov.au/artg", artg))); + } + + Set subpacksRelationships = + getActiveRelationshipsOfType(basePackageRelationships, getSubpackRelationshipType()); + Set productRelationships = + getActiveRelationshipsOfType(basePackageRelationships, getContainedUnitRelationshipType()); + + if (!subpacksRelationships.isEmpty()) { + if (!productRelationships.isEmpty()) { + throw new AtomicDataExtractionProblem( + "Multipack should not have direct products", productId); + } + for (SnowstormRelationship subpacksRelationship : subpacksRelationships) { + Set roleGroup = + getActiveRelationshipsInRoleGroup(subpacksRelationship, basePackageRelationships); + PackageQuantity packageQuantity = new PackageQuantity<>(); + details.getContainedPackages().add(packageQuantity); + // sub pack quantity unit + packageQuantity.setUnit(getSingleActiveTarget(roleGroup, HAS_PACK_SIZE_UNIT.getValue())); + // sub pack quantity value + packageQuantity.setValue( + new BigDecimal( + getSingleActiveConcreteValue(roleGroup, HAS_PACK_SIZE_VALUE.getValue()))); + // sub pack product details + assert subpacksRelationship.getTarget() != null; + packageQuantity.setPackageDetails( + populatePackageDetails( + subpacksRelationship.getTarget().getConceptId(), browserMap, typeMap, artgMap)); + } + } else { + if (productRelationships.isEmpty()) { + throw new AtomicDataExtractionProblem( + "Package has no sub packs, expected product relationships", productId); + } + + for (SnowstormRelationship subProductRelationship : productRelationships) { + Set subRoleGroup = + getActiveRelationshipsInRoleGroup(subProductRelationship, basePackageRelationships); + ProductQuantity productQuantity = new ProductQuantity<>(); + details.getContainedProducts().add(productQuantity); + // contained product quantity value + productQuantity.setValue( + getSingleActiveBigDecimal(subRoleGroup, HAS_PACK_SIZE_VALUE.getValue())); + // contained product quantity unit + productQuantity.setUnit(getSingleActiveTarget(subRoleGroup, HAS_PACK_SIZE_UNIT.getValue())); + + assert subProductRelationship.getTarget() != null; + SnowstormConcept product = + browserMap.get(subProductRelationship.getTarget().getConceptId()); + + if (product == null) { + throw new AtomicDataExtractionProblem( + "Expected concept to be in downloaded set for the product but was not found: " + + subProductRelationship.getTarget().getIdAndFsnTerm(), + productId); + } + + // contained product details + productQuantity.setProductDetails( + populateProductDetails(product, productId, browserMap, typeMap)); + } + } + return details; + } + + private T populateProductDetails( + SnowstormConcept product, + String productId, + Map browserMap, + Map typeMap) { + + T productDetails = populateSpecificProductDetails(product, productId, browserMap, typeMap); + + // product name + Set productRelationships = getRelationshipsFromAxioms(product); + productDetails.setProductName( + getSingleActiveTarget(productRelationships, HAS_PRODUCT_NAME.getValue())); + + productDetails.setOtherIdentifyingInformation( + getSingleActiveConcreteValue( + productRelationships, HAS_OTHER_IDENTIFYING_INFORMATION.getValue())); + + return productDetails; + } + + private record Maps( + Map browserMap, + Map typeMap, + Map> artgMap) {} +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/BrandPackSizeService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/BrandPackSizeService.java new file mode 100644 index 000000000..a4063b640 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/BrandPackSizeService.java @@ -0,0 +1,764 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.CONTAINS_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.CTPP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.HAS_PRODUCT_NAME_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.IS_A_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.MPP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.MPUU_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.TPP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.TPUU_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.TP_LABEL; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_DEVICE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CTPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_DEVICE_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_CLINICAL_DRUG_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_PRODUCT_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINERIZED_BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINERIZED_BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINS_CD; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_UNIT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_VALUE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRODUCT_NAME; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.IS_A; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT_PACKAGE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PRODUCT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.STATED_RELATIONSHUIP_CHARACTRISTIC_TYPE; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.cloneNewRelationships; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleActiveBigDecimal; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleActiveTarget; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleAxiom; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleOptionalActiveTarget; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSnowstormRelationship; +import static au.gov.digitalhealth.lingo.util.ValidationUtil.assertSingleComponentSinglePackProduct; + +import au.csiro.snowstorm_client.model.SnowstormConcept; +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.product.BrandWithIdentifiers; +import au.gov.digitalhealth.lingo.product.Edge; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.PackSizeWithIdentifiers; +import au.gov.digitalhealth.lingo.product.ProductBrands; +import au.gov.digitalhealth.lingo.product.ProductPackSizes; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.bulk.BrandPackSizeCreationDetails; +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import au.gov.digitalhealth.lingo.util.AmtConstants; +import au.gov.digitalhealth.lingo.util.BigDecimalFormatter; +import au.gov.digitalhealth.lingo.util.RelationshipSorter; +import au.gov.digitalhealth.lingo.util.SnomedConstants; +import au.gov.digitalhealth.lingo.util.SnowstormDtoUtil; +import java.math.BigDecimal; +import java.util.*; +import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@Log +public class BrandPackSizeService { + + private final ProductSummaryService productSummaryService; + private final SnowstormClient snowstormClient; + private final NameGenerationService nameGenerationService; + private final NodeGeneratorService nodeGeneratorService; + + @Value("${snomio.decimal-scale}") + int decimalScale; + + @Autowired + public BrandPackSizeService( + SnowstormClient snowstormClient, + NameGenerationService nameGenerationService, + NodeGeneratorService nodeGeneratorService, + ProductSummaryService productSummaryService) { + this.snowstormClient = snowstormClient; + this.nameGenerationService = nameGenerationService; + this.nodeGeneratorService = nodeGeneratorService; + this.productSummaryService = productSummaryService; + } + + private static void validateUnitOfMeasure( + BrandPackSizeCreationDetails brandPackSizeCreationDetails, SnowstormConcept ctppConcept) { + String ctppUnitOfMeasure = + getSingleActiveTarget( + getSingleAxiom(ctppConcept).getRelationships(), HAS_PACK_SIZE_UNIT.getValue()) + .getConceptId(); + + assert ctppUnitOfMeasure != null; + if (!ctppUnitOfMeasure.equals( + brandPackSizeCreationDetails.getPackSizes().getUnitOfMeasure().getConceptId())) { + throw new ProductAtomicDataValidationProblem( + "The selected product must have the same pack size unit of measure. The CTPP has a unit of measure of " + + ctppUnitOfMeasure + + " and the selected product has a unit of measure of " + + brandPackSizeCreationDetails.getPackSizes().getUnitOfMeasure().getConceptId()); + } + } + + private static SnowstormConceptMini validateSingleBrand( + SnowstormConcept ctppConcept, SnowstormConcept tpuuConcept) { + SnowstormConceptMini ctppBrand = + getSingleActiveTarget( + getSingleAxiom(ctppConcept).getRelationships(), HAS_PRODUCT_NAME.getValue()); + + SnowstormConceptMini tpuuBrand = + getSingleActiveTarget( + getSingleAxiom(tpuuConcept).getRelationships(), HAS_PRODUCT_NAME.getValue()); + + if (!Objects.equals(ctppBrand.getConceptId(), tpuuBrand.getConceptId())) { + throw new ProductAtomicDataValidationProblem( + "The brand of the CTPP and TPUU must be the same. Brands were " + + ctppBrand.getConceptId() + + " and " + + tpuuBrand.getConceptId()); + } + return ctppBrand; + } + + private static Set calculateNewBrandedPackRelationships( + BigDecimal packSize, + int decimalScale, + SnowstormConcept tppConcept, + SnowstormConceptMini brand, + Node newTpuuNode, + AtomicCache atomicCache) { + Set newRelationships = + cloneNewRelationships(tppConcept.getClassAxioms().iterator().next().getRelationships()); + + for (SnowstormRelationship relationship : newRelationships) { + relationship.setConcrete(relationship.getConcreteValue() != null); + relationship.setCharacteristicTypeId(STATED_RELATIONSHUIP_CHARACTRISTIC_TYPE.getValue()); + if (relationship.getTypeId().equals(HAS_PACK_SIZE_VALUE.getValue())) { + String packSizeString = BigDecimalFormatter.formatBigDecimal(packSize, decimalScale); + Objects.requireNonNull(relationship.getConcreteValue()).setValue(packSizeString); + Objects.requireNonNull(relationship.getConcreteValue()) + .setValueWithPrefix("#" + packSizeString); + relationship.setConcrete(true); + } + if (relationship.getTypeId().equals(HAS_PRODUCT_NAME.getValue())) { + relationship.setDestinationId(brand.getConceptId()); + relationship.setTarget(brand); + } + if (newTpuuNode != null && relationship.getTypeId().equals(CONTAINS_CD.getValue())) { + relationship.setDestinationId(newTpuuNode.getConceptId()); + relationship.setTarget(SnowstormDtoUtil.toSnowstormConceptMini(newTpuuNode)); + } + } + + newRelationships = + newRelationships.stream() + .filter(r -> !r.getTypeId().equals(IS_A.getValue())) + .collect(Collectors.toSet()); + + newRelationships.add( + SnowstormDtoUtil.getSnowstormRelationship(IS_A, MEDICINAL_PRODUCT_PACKAGE, 0)); + + newRelationships.forEach( + r -> { + if (r.getConcreteValue() == null && r.getTarget() != null) { + atomicCache.addFsn(r.getDestinationId(), r.getTarget().getFsn().getTerm()); + } + }); + + return newRelationships; + } + + private static void addParent(Node child, Node parent) { + if (child.isNewConcept()) { + child + .getNewConceptDetails() + .getAxioms() + .forEach(a -> a.getRelationships().add(getSnowstormRelationship(IS_A, parent, 0))); + } + } + + private static void addEdgesAndNodes( + Node newTpuuNode, + ProductSummary productSummary, + SnowstormConceptMini brand, + Node mpuu, + Node newMppNode, + CompletableFuture newTppNode, + Node tpuu, + Node mpp, + CompletableFuture newCtppNode) { + + Node tp = productSummary.getNode(brand.getConceptId()); + if (tp == null) { + tp = new Node(brand, TP_LABEL); + productSummary.addNode(tp); + } + + if (newTpuuNode != null) { + productSummary.addNode(newTpuuNode); + productSummary.addEdge(newTpuuNode.getConceptId(), tp.getConceptId(), HAS_PRODUCT_NAME_LABEL); + productSummary.addEdge(newTpuuNode.getConceptId(), mpuu.getConceptId(), IS_A_LABEL); + addParent(newTpuuNode, mpuu); + } + + if (newMppNode != null) { + productSummary.addNode(newMppNode); + productSummary.addEdge(newMppNode.getConceptId(), mpuu.getConceptId(), CONTAINS_LABEL); + } + + productSummary.addNode(newTppNode.join()); + productSummary.addEdge( + newTppNode.join().getConceptId(), + newTpuuNode == null ? tpuu.getConceptId() : newTpuuNode.getConceptId(), + CONTAINS_LABEL); + productSummary.addEdge( + newTppNode.join().getConceptId(), + newMppNode == null ? mpp.getConceptId() : newMppNode.getConceptId(), + IS_A_LABEL); + addParent(newTppNode.join(), newMppNode == null ? mpp : newMppNode); + productSummary.addEdge( + newTppNode.join().getConceptId(), tp.getConceptId(), HAS_PRODUCT_NAME_LABEL); + + productSummary.addNode(newCtppNode.join()); + productSummary.addEdge( + newCtppNode.join().getConceptId(), + newTpuuNode == null ? tpuu.getConceptId() : newTpuuNode.getConceptId(), + CONTAINS_LABEL); + productSummary.addEdge( + newCtppNode.join().getConceptId(), newTppNode.join().getConceptId(), IS_A_LABEL); + addParent(newCtppNode.join(), newTppNode.join()); + productSummary.addEdge( + newCtppNode.join().getConceptId(), tp.getConceptId(), HAS_PRODUCT_NAME_LABEL); + } + + /** + * Calculates the new brand pack sizes required to create a product based on the brand pack size + * details. + * + * @param branch branch to lookup concepts in + * @param brandPackSizeCreationDetails details of the brand pack sizes to create + * @return ProductSummary representing the existing and new concepts required to create this + * product + */ + public ProductSummary calculateNewBrandPackSizes( + String branch, BrandPackSizeCreationDetails brandPackSizeCreationDetails) { + + ProductBrands brands = brandPackSizeCreationDetails.getBrands(); + ProductPackSizes packSizes = brandPackSizeCreationDetails.getPackSizes(); + + if ((packSizes == null || packSizes.getPackSizes().isEmpty()) + && (brands == null || brands.getBrands().isEmpty())) { + throw new ProductAtomicDataValidationProblem("No pack sizes or brands provided"); + } + + ProductSummary productSummary = + productSummaryService.getProductSummary( + branch, brandPackSizeCreationDetails.getProductId()); + + AtomicCache atomicCache = + new AtomicCache( + brandPackSizeCreationDetails.getIdFsnMap(), + AmtConstants.values(), + SnomedConstants.values()); + + Node ctpp = + productSummary.getNode(productSummary.getSingleConceptWithLabel(CTPP_LABEL).getConceptId()); + Node tpp = + productSummary.getNode(productSummary.getSingleConceptWithLabel(TPP_LABEL).getConceptId()); + Node mpp = + productSummary.getNode(productSummary.getSingleConceptWithLabel(MPP_LABEL).getConceptId()); + Node tpuu = + productSummary.getNode(productSummary.getSingleConceptWithLabel(TPUU_LABEL).getConceptId()); + Node mpuu = + productSummary.getNode(productSummary.getSingleConceptWithLabel(MPUU_LABEL).getConceptId()); + + Map concepts = + snowstormClient + .getBrowserConcepts( + branch, + Set.of( + ctpp.getConceptId(), + tpp.getConceptId(), + mpp.getConceptId(), + tpuu.getConceptId())) + .collectMap(SnowstormConcept::getConceptId, c -> c) + .block(); + + assert concepts != null; + + SnowstormConcept ctppConcept = concepts.get(ctpp.getConceptId()); + SnowstormConcept tppConcept = concepts.get(tpp.getConceptId()); + SnowstormConcept mppConcept = concepts.get(mpp.getConceptId()); + SnowstormConcept tpuuConcept = concepts.get(tpuu.getConceptId()); + + assertSingleComponentSinglePackProduct(ctppConcept); + + SnowstormConceptMini ctppBrand = validateSingleBrand(ctppConcept, tpuuConcept); + + if (packSizes != null) { + validateUnitOfMeasure(brandPackSizeCreationDetails, ctppConcept); + } + + BigDecimal ctppPackSizeValue = + getSingleActiveBigDecimal( + getSingleAxiom(ctppConcept).getRelationships(), HAS_PACK_SIZE_VALUE.getValue()); + + PackSizeWithIdentifiers cttpPackSize = new PackSizeWithIdentifiers(); + cttpPackSize.setPackSize(ctppPackSizeValue); + cttpPackSize.setExternalIdentifiers(Collections.emptySet()); + + boolean isDevice = + getSingleOptionalActiveTarget( + getSingleAxiom(ctppConcept).getRelationships(), HAS_DEVICE_TYPE.getValue()) + != null; + + if ((packSizes == null + || (packSizes.getPackSizes().size() == 1 + && packSizes + .getPackSizes() + .iterator() + .next() + .getPackSize() + .equals(cttpPackSize.getPackSize()))) + && (brands == null + || (brands.getBrands().size() == 1 + && brands + .getBrands() + .iterator() + .next() + .getBrand() + .getConceptId() + .equals(ctppBrand.getConceptId())))) { + + productSummary.getNodes().stream() + .filter(Node::isNewConcept) + .forEach( + n -> + n.getNewConceptDetails() + .getAxioms() + .forEach(RelationshipSorter::sortRelationships)); + + // no new concepts required + return productSummary; + } + + // get the CTPP, TPP, MPP and TPUU concepts and generate new concepts one per brand/pack + // combination + List>> tpuuFutures = new ArrayList<>(); + if (brands != null) { + for (BrandWithIdentifiers brandPackSizeEntry : brands.getBrands()) { + SnowstormConceptMini brand = brandPackSizeEntry.getBrand(); + // if the brand is different, create a new TPUU node + if (!brand.getConceptId().equals(ctppBrand.getConceptId())) { + log.fine("Creating new TPUU node"); + tpuuFutures.add( + Pair.of( + brand.getConceptId(), + createNewTpuuNode(branch, tpuuConcept, brand, atomicCache, isDevice) + .thenApply( + n -> { + atomicCache.addFsn(n.getConceptId(), n.getFullySpecifiedName()); + return n; + }))); + } else { + log.fine("Reusing existing TPUU node"); + atomicCache.addFsn(tpuu.getConceptId(), tpuu.getFullySpecifiedName()); + } + } + } + + List>> mppFutures = new ArrayList<>(); + if (packSizes != null) { + for (PackSizeWithIdentifiers packSize : packSizes.getPackSizes()) { + if (!packSize.getPackSize().equals(cttpPackSize.getPackSize())) { + log.fine("Creating new MPP node"); + mppFutures.add( + Pair.of( + packSize.getPackSize(), + createNewMppNode( + branch, packSize.getPackSize(), mppConcept, atomicCache, isDevice) + .thenApply( + m -> { + atomicCache.addFsn(m.getConceptId(), m.getFullySpecifiedName()); + return m; + }))); + } else { + atomicCache.addFsn(mpp.getConceptId(), mpp.getFullySpecifiedName()); + log.fine("Reusing existing MPP node"); + } + } + } + + Map tpuuMap = + new ConcurrentHashMap<>( + tpuuFutures.stream() + .collect( + Collectors.toMap( + Pair::getLeft, + n -> n.getRight().join(), + (existing, replacement) -> { + if (existing.getConceptId().equals(replacement.getConceptId())) { + return existing; + } else { + throw new IllegalStateException( + "Duplicate key with different ConceptId"); + } + }))); + if (tpuuMap.isEmpty()) { + tpuuMap.put(tpuu.getConceptId(), tpuu); + } + + Map mppMap = + new ConcurrentHashMap<>( + mppFutures.stream() + .collect( + Collectors.toMap( + Pair::getLeft, + n -> n.getRight().join(), + (existing, replacement) -> { + if (existing.getConceptId().equals(replacement.getConceptId())) { + return existing; + } else { + throw new IllegalStateException( + "Duplicate key with different ConceptId"); + } + }))); + if (mppMap.isEmpty()) { + mppMap.put(cttpPackSize.getPackSize(), mpp); + } + + Map> consolidatedBrands = + brands == null + ? Map.of(ctppBrand, new HashSet<>()) + : brands.getBrands().stream() + .collect( + Collectors.toMap( + BrandWithIdentifiers::getBrand, + BrandWithIdentifiers::getExternalIdentifiers, + (existing, replacement) -> { + existing.addAll(replacement); + return existing; + })); + + List> productSummaryFutures = new ArrayList<>(); + + Set packSizesToProcess = + packSizes == null ? Set.of(cttpPackSize) : packSizes.getPackSizes(); + + for (Entry> brandPackSizeEntry : + consolidatedBrands.entrySet()) { + SnowstormConceptMini brand = brandPackSizeEntry.getKey(); + Set brandExternalIdentifiers = brandPackSizeEntry.getValue(); + for (PackSizeWithIdentifiers packSize : packSizesToProcess) { + if (!brand.getConceptId().equals(ctppBrand.getConceptId()) + || !packSize.getPackSize().equals(cttpPackSize.getPackSize())) { + if (log.isLoggable(Level.FINE)) { + log.fine( + "Creating new brand pack size for brand " + + brand.getConceptId() + + " and pack size " + + packSize); + } + Node newTpuuNode = tpuuMap.get(brand.getConceptId()); + Node newMppNode = mppMap.get(packSize.getPackSize()); + Set unionOfBrandAndPackExternalIdentifiers = + new HashSet<>(packSize.getExternalIdentifiers()); + unionOfBrandAndPackExternalIdentifiers.addAll(brandExternalIdentifiers); + + log.fine("Creating new TPP node"); + CompletableFuture newTppNode = + createNewTppNode( + branch, + packSize.getPackSize(), + tppConcept, + brand, + newTpuuNode, + atomicCache, + isDevice); + + log.fine("Creating new CTPP node"); + CompletableFuture newCtppNode = + createNewCtppNode( + branch, + packSize.getPackSize(), + ctppConcept, + brand, + newTpuuNode, + atomicCache, + unionOfBrandAndPackExternalIdentifiers, + isDevice); + + productSummaryFutures.add( + CompletableFuture.allOf(newTppNode, newCtppNode) + .thenApply( + v -> { + if (log.isLoggable(Level.FINE)) { + log.fine( + "Created product summary for brand " + + brand.getConceptId() + + " and pack size " + + packSize + + " new TPUU node " + + (newTpuuNode != null && newTpuuNode.isNewConcept()) + + " new CTPP node " + + newCtppNode.join().isNewConcept() + + " new TPP node " + + newTppNode.join().isNewConcept() + + " new MPP node " + + (newMppNode != null && newMppNode.isNewConcept())); + + log.fine("Adding edges and nodes"); + } + addEdgesAndNodes( + newTpuuNode, + productSummary, + brand, + mpuu, + newMppNode, + newTppNode, + tpuu, + mpp, + newCtppNode); + + log.fine( + "adding subject " + + newCtppNode.join().getConceptId() + + " to product summary"); + productSummary.addSubject(newCtppNode.join()); + + return productSummary; + })); + } else { + log.fine("Skipping existing brand pack size"); + } + } + } + + CompletableFuture.allOf( + productSummaryFutures.toArray(new CompletableFuture[productSummaryFutures.size()])) + .join(); + + Set transitiveContainsEdges = + ProductSummaryService.getTransitiveEdges(productSummary, new HashSet<>()); + productSummary.getEdges().addAll(transitiveContainsEdges); + + productSummary.getNodes().stream() + .filter(Node::isNewConcept) + .forEach( + n -> + n.getNewConceptDetails() + .getAxioms() + .forEach(RelationshipSorter::sortRelationships)); + // return the product summary + return productSummary; + } + + private CompletableFuture createNewCtppNode( + String branch, + BigDecimal packSize, + SnowstormConcept ctppConcept, + SnowstormConceptMini brand, + Node newTpuuNode, + AtomicCache atomicCache, + Set externalIdentifiers, + boolean isDevice) { + Set newCtppRelationships = + calculateNewBrandedPackRelationships( + packSize, decimalScale, ctppConcept, brand, newTpuuNode, atomicCache); + + String semanticTag = + isDevice + ? CONTAINERIZED_BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG.getValue() + : CONTAINERIZED_BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG.getValue(); + + return nodeGeneratorService + .generateNodeAsync( + branch, + atomicCache, + newCtppRelationships, + Set.of(CTPP_REFSET_ID.getValue()), + CTPP_LABEL, + SnowstormDtoUtil.getExternalIdentifierReferenceSetEntries(externalIdentifiers), + semanticTag, + List.of(), + false, + false, + true) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt(atomicCache, semanticTag, n); + return n; + }); + } + + private CompletableFuture createNewTppNode( + String branch, + BigDecimal packSize, + SnowstormConcept tppConcept, + SnowstormConceptMini brand, + Node newTpuuNode, + AtomicCache atomicCache, + boolean isDevice) { + Set newTppRelationships = + calculateNewBrandedPackRelationships( + packSize, decimalScale, tppConcept, brand, newTpuuNode, atomicCache); + + String semanticTag = + isDevice + ? BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG.getValue() + : BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG.getValue(); + + return nodeGeneratorService + .generateNodeAsync( + branch, + atomicCache, + newTppRelationships, + Set.of(TPP_REFSET_ID.getValue()), + TPP_LABEL, + Set.of(), + semanticTag, + List.of(), + false, + false, + true) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt(atomicCache, semanticTag, n); + return n; + }); + } + + private CompletableFuture createNewMppNode( + String branch, + BigDecimal packSize, + SnowstormConcept mppConcept, + AtomicCache atomicCache, + boolean isDevice) { + + Set relationships = + cloneNewRelationships(mppConcept.getClassAxioms().iterator().next().getRelationships()); + + relationships.forEach( + r -> { + r.setCharacteristicTypeId(STATED_RELATIONSHUIP_CHARACTRISTIC_TYPE.getValue()); + r.setConcrete(r.getConcreteValue() != null); + if (r.getTypeId().equals(HAS_PACK_SIZE_VALUE.getValue())) { + String packSizeString = BigDecimalFormatter.formatBigDecimal(packSize, decimalScale); + r.getConcreteValue().setValue(packSizeString); + r.getConcreteValue().setValueWithPrefix("#" + packSizeString); + } + if (r.getTypeId().equals(CONTAINS_DEVICE.getValue()) + || r.getTypeId().equals(CONTAINS_CD.getValue())) { + atomicCache.addFsn(r.getDestinationId(), r.getTarget().getFsn().getTerm()); + } + }); + + relationships.forEach( + r -> { + if (r.getConcreteValue() == null && r.getTarget() != null) { + atomicCache.addFsn(r.getDestinationId(), r.getTarget().getFsn().getTerm()); + } + }); + + String semanticTag = + isDevice + ? PRODUCT_PACKAGE_SEMANTIC_TAG.getValue() + : CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG.getValue(); + + return nodeGeneratorService + .generateNodeAsync( + branch, + atomicCache, + relationships, + Set.of(MPP_REFSET_ID.getValue()), + MPP_LABEL, + Set.of(), + semanticTag, + List.of(), + false, + false, + true) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt(atomicCache, semanticTag, n); + return n; + }); + } + + private CompletableFuture createNewTpuuNode( + String branch, + SnowstormConcept tpuuConcept, + SnowstormConceptMini brand, + AtomicCache atomicCache, + boolean isDevice) { + Set relationships = + cloneNewRelationships(tpuuConcept.getClassAxioms().iterator().next().getRelationships()); + + relationships.forEach( + r -> { + r.setConcrete(r.getConcreteValue() != null); + r.setCharacteristicTypeId(STATED_RELATIONSHUIP_CHARACTRISTIC_TYPE.getValue()); + if (r.getTypeId().equals(HAS_PRODUCT_NAME.getValue())) { + r.setDestinationId(brand.getConceptId()); + r.setTarget(brand); + } + }); + + relationships.forEach( + r -> { + if (r.getConcreteValue() == null && r.getTarget() != null) { + atomicCache.addFsn(r.getDestinationId(), r.getTarget().getFsn().getTerm()); + } + }); + + String semanticTag = + isDevice + ? BRANDED_PRODUCT_SEMANTIC_TAG.getValue() + : BRANDED_CLINICAL_DRUG_SEMANTIC_TAG.getValue(); + + return nodeGeneratorService + .generateNodeAsync( + branch, + atomicCache, + relationships, + Set.of(TPUU_REFSET_ID.getValue()), + TPUU_LABEL, + Set.of(), + semanticTag, + List.of(), + false, + false, + true) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt(atomicCache, semanticTag, n); + return n; + }); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/DeviceProductCalculationService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/DeviceProductCalculationService.java new file mode 100644 index 000000000..da55f1c5e --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/DeviceProductCalculationService.java @@ -0,0 +1,540 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_DEVICE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.COUNT_OF_DEVICE_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CTPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_CONTAINER_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_OTHER_IDENTIFYING_INFORMATION; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.NO_OII_VALUE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.SCT_AU_MODULE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_PHYSICAL_OBJECT_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINERIZED_BRANDED_PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_UNIT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_VALUE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRODUCT_NAME; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.IS_A; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PACKAGE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PHYSICAL_OBJECT_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PRIMITIVE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.UNIT_OF_PRESENTATION; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSnowstormDatatypeComponent; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSnowstormRelationship; + +import au.csiro.snowstorm_client.model.SnowstormAxiom; +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.csiro.snowstorm_client.model.SnowstormConcreteValue.DataTypeEnum; +import au.csiro.snowstorm_client.model.SnowstormReferenceSetMemberViewComponent; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.product.Edge; +import au.gov.digitalhealth.lingo.product.NewConceptDetails; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.details.DeviceProductDetails; +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.product.details.ProductQuantity; +import au.gov.digitalhealth.lingo.util.AmtConstants; +import au.gov.digitalhealth.lingo.util.BigDecimalFormatter; +import au.gov.digitalhealth.lingo.util.SnomedConstants; +import au.gov.digitalhealth.lingo.util.SnowstormDtoUtil; +import au.gov.digitalhealth.lingo.util.ValidationUtil; +import jakarta.validation.Valid; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +@Service +public class DeviceProductCalculationService { + + SnowstormClient snowstormClient; + NodeGeneratorService nodeGeneratorService; + + @Value("${snomio.decimal-scale}") + int decimalScale; + + public DeviceProductCalculationService( + SnowstormClient snowstormClient, NodeGeneratorService nodeGeneratorService) { + this.snowstormClient = snowstormClient; + this.nodeGeneratorService = nodeGeneratorService; + } + + private static Set getTpuuRelationships( + Node mpuu, DeviceProductDetails productDetails) { + Set relationships = new HashSet<>(); + relationships.add(getSnowstormRelationship(IS_A, mpuu, 0)); + relationships.add( + getSnowstormRelationship(HAS_PRODUCT_NAME, productDetails.getProductName(), 0)); + relationships.add( + getSnowstormDatatypeComponent( + HAS_OTHER_IDENTIFYING_INFORMATION, + !StringUtils.hasLength(productDetails.getOtherIdentifyingInformation()) + ? NO_OII_VALUE.getValue() + : productDetails.getOtherIdentifyingInformation(), + DataTypeEnum.STRING, + 0)); + return relationships; + } + + private static Set getMpuuRelationships( + Node mp, Set otherParentConcepts) { + Set relationships = new HashSet<>(); + relationships.add(getSnowstormRelationship(IS_A, mp, 0)); + if (otherParentConcepts != null) { + otherParentConcepts.forEach( + otherParentConcept -> + relationships.add(getSnowstormRelationship(IS_A, otherParentConcept, 0))); + } + return relationships; + } + + private static String generatePackTerm( + Entry, ProductSummary> entry, String label) { + ProductSummary productSummary = entry.getValue(); + ProductQuantity productQuantity = entry.getKey(); + return productSummary + .getNode(productSummary.getSingleConceptWithLabel(label).getConceptId()) + .getPreferredTerm() + + ", " + + productQuantity.getValue() + + (productQuantity.getUnit().getConceptId().equals(UNIT_OF_PRESENTATION.getValue()) + ? "" + : " " + productQuantity.getUnit().getPt().getTerm()); + } + + public ProductSummary calculateProductFromAtomicData( + String branch, @Valid PackageDetails<@Valid DeviceProductDetails> packageDetails) { + + Mono> taskChangedConceptIds = snowstormClient.getConceptIdsChangedOnTask(branch); + + Mono> projectChangedConceptIds = + snowstormClient.getConceptIdsChangedOnProject(branch); + + AtomicCache cache = + new AtomicCache( + packageDetails.getIdFsnMap(), AmtConstants.values(), SnomedConstants.values()); + + validateProductDetails(packageDetails); + + ProductSummary productSummary = new ProductSummary(); + + Map, ProductSummary> innerProductSummaries = + new HashMap<>(); + + for (ProductQuantity productQuantity : + packageDetails.getContainedProducts()) { + ProductSummary innerProductSummary = + createSummaryForContainedProduct(branch, packageDetails, productQuantity, cache); + + innerProductSummaries.put(productQuantity, innerProductSummary); + productSummary.addSummary(innerProductSummary); + } + + CompletableFuture mpp = + getPackageNode( + branch, + packageDetails, + cache, + innerProductSummaries, + productSummary, + ProductSummaryService.MPP_LABEL); + + CompletableFuture tpp = + getPackageNode( + branch, + packageDetails, + cache, + innerProductSummaries, + productSummary, + ProductSummaryService.TPP_LABEL); + + CompletableFuture ctpp = + getPackageNode( + branch, + packageDetails, + cache, + innerProductSummaries, + productSummary, + ProductSummaryService.CTPP_LABEL); + + CompletableFuture.allOf(mpp, tpp, ctpp).join(); + + Node mppNode = mpp.join(); + Node tppNode = tpp.join(); + Node ctppNode = ctpp.join(); + + if (mppNode.isNewConcept()) { + String mppPreferredTerm = calculateMppPreferredTerm(innerProductSummaries); + mppNode.getNewConceptDetails().setPreferredTerm(mppPreferredTerm); + mppNode + .getNewConceptDetails() + .setFullySpecifiedName( + mppPreferredTerm + " (" + PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG.getValue() + ")"); + } + + if (tppNode.isNewConcept()) { + String tppPreferredTerm = calculateTppPreferredTerm(innerProductSummaries); + tppNode.getNewConceptDetails().setPreferredTerm(tppPreferredTerm); + tppNode + .getNewConceptDetails() + .setFullySpecifiedName( + tppPreferredTerm + + " (" + + BRANDED_PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG.getValue() + + ")"); + tppNode + .getNewConceptDetails() + .getAxioms() + .forEach( + axiom -> axiom.getRelationships().add(getSnowstormRelationship(IS_A, mppNode, 0))); + } + + productSummary.addEdge( + tppNode.getConceptId(), mppNode.getConceptId(), ProductSummaryService.IS_A_LABEL); + productSummary.addNode(packageDetails.getProductName(), ProductSummaryService.TP_LABEL); + productSummary.addEdge( + tppNode.getConceptId(), + packageDetails.getProductName().getConceptId(), + ProductSummaryService.HAS_PRODUCT_NAME_LABEL); + + if (ctppNode.isNewConcept()) { + String ctppPreferredTerm = + calculateCtppPreferredTerm(innerProductSummaries, packageDetails.getContainerType()); + ctppNode.getNewConceptDetails().setPreferredTerm(ctppPreferredTerm); + ctppNode + .getNewConceptDetails() + .setFullySpecifiedName( + ctppPreferredTerm + + " (" + + CONTAINERIZED_BRANDED_PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG.getValue() + + ")"); + + ctppNode + .getNewConceptDetails() + .getAxioms() + .forEach( + axiom -> axiom.getRelationships().add(getSnowstormRelationship(IS_A, tppNode, 0))); + } + + productSummary.addEdge( + ctppNode.getConceptId(), tppNode.getConceptId(), ProductSummaryService.IS_A_LABEL); + productSummary.addEdge( + ctppNode.getConceptId(), + packageDetails.getProductName().getConceptId(), + ProductSummaryService.HAS_PRODUCT_NAME_LABEL); + + productSummary.setSingleSubject(ctppNode); + + Set transitiveContainsEdges = + ProductSummaryService.getTransitiveEdges(productSummary, new HashSet<>()); + productSummary.getEdges().addAll(transitiveContainsEdges); + + productSummary.updateNodeChangeStatus( + taskChangedConceptIds.block(), projectChangedConceptIds.block()); + + return productSummary; + } + + private String calculateMppPreferredTerm( + Map, ProductSummary> innerProductSummaries) { + return innerProductSummaries.entrySet().stream() + .map(entry -> generatePackTerm(entry, ProductSummaryService.MPUU_LABEL)) + .collect(Collectors.joining(", ")); + } + + private String calculateTppPreferredTerm( + Map, ProductSummary> innerProductSummaries) { + return innerProductSummaries.entrySet().stream() + .map(entry -> generatePackTerm(entry, ProductSummaryService.TPUU_LABEL)) + .collect(Collectors.joining(", ")); + } + + private String calculateCtppPreferredTerm( + Map, ProductSummary> innerProductSummaries, + SnowstormConceptMini containerType) { + return innerProductSummaries.entrySet().stream() + .map(entry -> generatePackTerm(entry, ProductSummaryService.TPUU_LABEL)) + .collect(Collectors.joining(", ")) + .concat(", " + containerType.getPt().getTerm()); + } + + private CompletableFuture getPackageNode( + String branch, + PackageDetails packageDetails, + AtomicCache cache, + Map, ProductSummary> innerProductSummaries, + ProductSummary productSummary, + String label) { + String containedLabel; + AmtConstants refset; + SnomedConstants semanticTag; + Set referenceSetMembers; + + switch (label) { + case ProductSummaryService.MPP_LABEL -> { + containedLabel = ProductSummaryService.MPUU_LABEL; + refset = MPP_REFSET_ID; + semanticTag = PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG; + referenceSetMembers = Set.of(); + } + case ProductSummaryService.TPP_LABEL -> { + containedLabel = ProductSummaryService.TPUU_LABEL; + refset = TPP_REFSET_ID; + semanticTag = BRANDED_PHYSICAL_OBJECT_SEMANTIC_TAG; + referenceSetMembers = Set.of(); + } + case ProductSummaryService.CTPP_LABEL -> { + containedLabel = ProductSummaryService.TPUU_LABEL; + refset = CTPP_REFSET_ID; + semanticTag = CONTAINERIZED_BRANDED_PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG; + referenceSetMembers = + SnowstormDtoUtil.getExternalIdentifierReferenceSetEntries(packageDetails); + } + default -> throw new IllegalArgumentException("Invalid label: " + label); + } + + return nodeGeneratorService + .generateNodeAsync( + branch, + cache, + getPackageRelationships( + packageDetails, innerProductSummaries, containedLabel, semanticTag), + Set.of(refset.getValue()), + label, + referenceSetMembers, + semanticTag.getValue(), + packageDetails.getSelectedConceptIdentifiers(), + true, + label.equals(ProductSummaryService.MPP_LABEL), + true) + .thenApply( + n -> { + productSummary.addNode(n); + innerProductSummaries.forEach( + (productQuantity, innerProductSummary) -> + productSummary.addEdge( + n.getConceptId(), + innerProductSummary + .getSingleConceptWithLabel(containedLabel) + .getConceptId(), + ProductSummaryService.CONTAINS_LABEL)); + return n; + }); + } + + private Set getPackageRelationships( + PackageDetails packageDetails, + Map, ProductSummary> innerProductSummaries, + String containedTypeLabel, + SnomedConstants semanticTag) { + Set relationships = new HashSet<>(); + relationships.add(getSnowstormRelationship(IS_A, PACKAGE, 0)); + int group = 1; + for (Entry, ProductSummary> innerProductSummaryEntry : + innerProductSummaries.entrySet()) { + relationships.add( + getSnowstormRelationship( + CONTAINS_DEVICE, + innerProductSummaryEntry.getValue().getSingleConceptWithLabel(containedTypeLabel), + group)); + relationships.add( + getSnowstormRelationship( + HAS_PACK_SIZE_UNIT, innerProductSummaryEntry.getKey().getUnit(), group)); + relationships.add( + getSnowstormDatatypeComponent( + HAS_PACK_SIZE_VALUE, + BigDecimalFormatter.formatBigDecimal( + innerProductSummaryEntry.getKey().getValue(), decimalScale), + DataTypeEnum.DECIMAL, + group)); + group++; + } + relationships.add( + getSnowstormDatatypeComponent( + COUNT_OF_DEVICE_TYPE, + Integer.toString(innerProductSummaries.size()), + DataTypeEnum.INTEGER, + 0)); + + if (!containedTypeLabel.equals(ProductSummaryService.MPUU_LABEL)) { + relationships.add( + getSnowstormRelationship(HAS_PRODUCT_NAME, packageDetails.getProductName(), group++)); + } + + if (semanticTag.equals(CONTAINERIZED_BRANDED_PHYSICAL_OBJECT_PACKAGE_SEMANTIC_TAG)) { + relationships.add( + getSnowstormRelationship(HAS_CONTAINER_TYPE, packageDetails.getContainerType(), group)); + } + return relationships; + } + + private ProductSummary createSummaryForContainedProduct( + String branch, + PackageDetails packageDetails, + ProductQuantity productQuantity, + AtomicCache cache) { + ProductSummary innerProductSummary = new ProductSummary(); + Node mp = + Node.builder() + .concept(productQuantity.getProductDetails().getDeviceType()) + .label(ProductSummaryService.MP_LABEL) + .build(); + innerProductSummary.addNode(mp); + + Node mpuu; + if (productQuantity.getProductDetails().getSpecificDeviceType() != null) { + mpuu = + Node.builder() + .concept(productQuantity.getProductDetails().getSpecificDeviceType()) + .label(ProductSummaryService.MPUU_LABEL) + .build(); + } else { + mpuu = + Node.builder() + .newConceptDetails(getNewMpuuDetails(productQuantity, cache.getNextId(), mp)) + .label(ProductSummaryService.MPUU_LABEL) + .build(); + } + innerProductSummary.addNode(mpuu); + innerProductSummary.addEdge( + mpuu.getConceptId(), mp.getConceptId(), ProductSummaryService.IS_A_LABEL); + + Node tpuu = + nodeGeneratorService.generateNode( + branch, + cache, + getTpuuRelationships(mpuu, productQuantity.getProductDetails()), + Set.of(TPUU_REFSET_ID.getValue()), + ProductSummaryService.TPUU_LABEL, + Set.of(), + BRANDED_PHYSICAL_OBJECT_SEMANTIC_TAG.getValue(), + packageDetails.getSelectedConceptIdentifiers(), + false, + false, + true); + if (tpuu.isNewConcept()) { + tpuu.getNewConceptDetails() + .setPreferredTerm(calculateTpuuName(productQuantity.getProductDetails())); + tpuu.getNewConceptDetails() + .setFullySpecifiedName( + tpuu.getNewConceptDetails().getPreferredTerm() + + " (" + + BRANDED_PHYSICAL_OBJECT_SEMANTIC_TAG.getValue() + + ")"); + } + + innerProductSummary.addNode(tpuu); + innerProductSummary.addEdge( + tpuu.getConceptId(), mpuu.getConceptId(), ProductSummaryService.IS_A_LABEL); + + innerProductSummary.addNode(packageDetails.getProductName(), ProductSummaryService.TP_LABEL); + innerProductSummary.addEdge( + tpuu.getConceptId(), + packageDetails.getProductName().getConceptId(), + ProductSummaryService.HAS_PRODUCT_NAME_LABEL); + + innerProductSummary.setSingleSubject(tpuu); + return innerProductSummary; + } + + private String calculateTpuuName(DeviceProductDetails productDetails) { + String genericDeviceName = + productDetails.getNewSpecificDeviceName() == null + ? productDetails.getSpecificDeviceType().getPt().getTerm() + : productDetails.getNewSpecificDeviceName(); + String deviceType = productDetails.getDeviceType().getPt().getTerm(); + String productName = productDetails.getProductName().getPt().getTerm(); + + return productName + " " + genericDeviceName.replace(deviceType, ""); + } + + private NewConceptDetails getNewMpuuDetails( + ProductQuantity<@Valid DeviceProductDetails> productQuantity, int id, Node mp) { + NewConceptDetails mpuuDetails = new NewConceptDetails(id); + mpuuDetails.setSemanticTag(PHYSICAL_OBJECT_SEMANTIC_TAG.getValue()); + String newSpecificDeviceName = productQuantity.getProductDetails().getNewSpecificDeviceName(); + mpuuDetails.setPreferredTerm(newSpecificDeviceName); + mpuuDetails.setFullySpecifiedName( + newSpecificDeviceName + " (" + PHYSICAL_OBJECT_SEMANTIC_TAG.getValue() + ")"); + SnowstormAxiom axiom = new SnowstormAxiom(); + axiom.active(true); + axiom.setDefinitionStatusId(PRIMITIVE.getValue()); + axiom.setDefinitionStatus("PRIMITIVE"); + Set relationships = + getMpuuRelationships(mp, productQuantity.getProductDetails().getOtherParentConcepts()); + axiom.setRelationships(relationships); + axiom.setModuleId(SCT_AU_MODULE.getValue()); + axiom.setReleased(false); + mpuuDetails.getAxioms().add(axiom); + return mpuuDetails; + } + + private void validateProductDetails(PackageDetails productDetails) { + // device packages should not contain other packages + if (!productDetails.getContainedPackages().isEmpty()) { + throw new ProductAtomicDataValidationProblem("Device packages cannot contain other packages"); + } + + // if specific device type is not null, other parent concepts must be null or empty + if (productDetails.getContainedProducts().stream() + .anyMatch( + productQuantity -> + productQuantity.getProductDetails().getSpecificDeviceType() != null + && !(productQuantity.getProductDetails().getOtherParentConcepts() == null + || productQuantity + .getProductDetails() + .getOtherParentConcepts() + .isEmpty()))) { + throw new ProductAtomicDataValidationProblem( + "Specific device type and other parent concepts cannot both be populated"); + } + + // device packages must contain at least one device + if (productDetails.getContainedProducts().isEmpty()) { + throw new ProductAtomicDataValidationProblem( + "Device packages must contain at least one device"); + } + + for (ProductQuantity productQuantity : + productDetails.getContainedProducts()) { + // validate quantity is one if unit is each + ValidationUtil.validateQuantityValueIsOneIfUnitIsEach(productQuantity); + validateDeviceType(productQuantity.getProductDetails()); + } + } + + private void validateDeviceType(DeviceProductDetails deviceProductDetails) { + // validate device type is not null + if (deviceProductDetails.getDeviceType() == null) { + throw new ProductAtomicDataValidationProblem("Device type is required"); + } + } +} diff --git a/api/src/main/java/com/csiro/snomio/service/DeviceService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/DeviceService.java similarity index 75% rename from api/src/main/java/com/csiro/snomio/service/DeviceService.java rename to api/src/main/java/au/gov/digitalhealth/lingo/service/DeviceService.java index 4f1a7ecdc..ddf74dc99 100644 --- a/api/src/main/java/com/csiro/snomio/service/DeviceService.java +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/DeviceService.java @@ -1,28 +1,40 @@ -package com.csiro.snomio.service; - -import static com.csiro.snomio.util.AmtConstants.CONTAINS_DEVICE; -import static com.csiro.snomio.util.AmtConstants.CONTAINS_PACKAGED_DEVICE; -import static com.csiro.snomio.util.AmtConstants.MPUU_REFSET_ID; -import static com.csiro.snomio.util.AmtConstants.MP_REFSET_ID; -import static com.csiro.snomio.util.SnomedConstants.IS_A; -import static com.csiro.snomio.util.SnowstormDtoUtil.filterActiveStatedRelationshipByType; -import static com.csiro.snomio.util.SnowstormDtoUtil.getRelationshipsFromAxioms; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_DEVICE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_PACKAGED_DEVICE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.IS_A; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.filterActiveStatedRelationshipByType; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getRelationshipsFromAxioms; import au.csiro.snowstorm_client.model.SnowstormConcept; import au.csiro.snowstorm_client.model.SnowstormConceptMini; import au.csiro.snowstorm_client.model.SnowstormRelationship; -import com.csiro.snomio.exception.AtomicDataExtractionProblem; -import com.csiro.snomio.product.details.DeviceProductDetails; +import au.gov.digitalhealth.lingo.exception.AtomicDataExtractionProblem; +import au.gov.digitalhealth.lingo.product.details.DeviceProductDetails; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import lombok.extern.java.Log; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** Service for product-centric operations */ @Service -@Log public class DeviceService extends AtomicDataService { private static final String PACKAGE_CONCEPTS_FOR_ATOMIC_EXTRACTION_DEVICE_ECL = "( or (.999000111000168106) " @@ -34,7 +46,6 @@ public class DeviceService extends AtomicDataService { "( or (>> and (^929360071000036103 or ^929360061000036106))) and < 260787004"; private final SnowstormClient snowStormApiClient; - @Autowired DeviceService(SnowstormClient snowStormApiClient) { this.snowStormApiClient = snowStormApiClient; } diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/JiraUserManagerService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/JiraUserManagerService.java new file mode 100644 index 000000000..f1418a9ee --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/JiraUserManagerService.java @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.gov.digitalhealth.lingo.auth.JiraUser; +import au.gov.digitalhealth.lingo.auth.JiraUserResponse; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.util.CacheConstants; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@EnableScheduling +public class JiraUserManagerService { + @Value("${snomio.jira.users:}") + private Set users; + + private final WebClient defaultAuthoringPlatformApiClient; + + @Autowired + public JiraUserManagerService( + @Qualifier("defaultAuthoringPlatformApiClient") WebClient defaultAuthoringPlatformApiClient) { + this.defaultAuthoringPlatformApiClient = defaultAuthoringPlatformApiClient; + } + + @Cacheable(cacheNames = CacheConstants.JIRA_USERS_CACHE) + public List getAllJiraUsers() throws AccessDeniedException { + List jiraUserList = new ArrayList<>(); + int offset = 0; + int size = 0; + while (offset <= size) { + JiraUserResponse response = invokeApi(offset); + if (response == null) { + throw new LingoProblem( + "bad-jira-user-response", "Error getting Jira users", HttpStatus.BAD_GATEWAY); + } + jiraUserList.addAll( + response.getUsers().getItems().stream() + .filter(jiraUser -> jiraUser.isActive() && users.contains(jiraUser.getName())) + .toList()); + + offset += 50; + size = response.getUsers().getSize(); + } + return jiraUserList.stream().distinct().toList(); // remove any duplicates + } + + private JiraUserResponse invokeApi(int offset) { + return defaultAuthoringPlatformApiClient + .get() + .uri("/users?offset=" + offset) + .retrieve() + .bodyToMono(JiraUserResponse.class) // TODO May be change to actual objects? + .block(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/MedicationProductCalculationService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/MedicationProductCalculationService.java new file mode 100644 index 000000000..272df3684 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/MedicationProductCalculationService.java @@ -0,0 +1,1115 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONCENTRATION_STRENGTH_UNIT; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONCENTRATION_STRENGTH_VALUE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_PACKAGED_CD; +import static au.gov.digitalhealth.lingo.util.AmtConstants.COUNT_OF_CD_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.COUNT_OF_CONTAINED_COMPONENT_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.AmtConstants.COUNT_OF_CONTAINED_PACKAGE_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CTPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_CONTAINER_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_DEVICE_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_OTHER_IDENTIFYING_INFORMATION; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_TOTAL_QUANTITY_UNIT; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_TOTAL_QUANTITY_VALUE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.NO_OII_VALUE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPP_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.AmtConstants.TPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_CLINICAL_DRUG_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.BRANDED_PRODUCT_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CLINICAL_DRUG_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINERIZED_BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINERIZED_BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINS_CD; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.COUNT_OF_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_BOSS; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_MANUFACTURED_DOSE_FORM; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_UNIT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_VALUE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRECISE_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRODUCT_NAME; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.IS_A; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT_PACKAGE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PRODUCT_PACKAGE_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PRODUCT_SEMANTIC_TAG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.UNIT_MG; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.UNIT_ML; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.UNIT_OF_PRESENTATION; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.addQuantityIfNotNull; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.addRelationshipIfNotNull; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getIdAndFsnTerm; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSnowstormDatatypeComponent; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSnowstormRelationship; + +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.csiro.snowstorm_client.model.SnowstormConcreteValue.DataTypeEnum; +import au.csiro.snowstorm_client.model.SnowstormReferenceSetMemberViewComponent; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.configuration.FieldBindingConfiguration; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.product.Edge; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.details.Ingredient; +import au.gov.digitalhealth.lingo.product.details.MedicationProductDetails; +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.product.details.PackageQuantity; +import au.gov.digitalhealth.lingo.product.details.ProductQuantity; +import au.gov.digitalhealth.lingo.product.details.Quantity; +import au.gov.digitalhealth.lingo.util.AmtConstants; +import au.gov.digitalhealth.lingo.util.BigDecimalFormatter; +import au.gov.digitalhealth.lingo.util.OwlAxiomService; +import au.gov.digitalhealth.lingo.util.RelationshipSorter; +import au.gov.digitalhealth.lingo.util.SnomedConstants; +import au.gov.digitalhealth.lingo.util.SnowstormDtoUtil; +import au.gov.digitalhealth.lingo.util.ValidationUtil; +import au.gov.digitalhealth.tickets.service.TicketServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +@Service +@Log +public class MedicationProductCalculationService { + + SnowstormClient snowstormClient; + NameGenerationService nameGenerationService; + TicketServiceImpl ticketService; + + OwlAxiomService owlAxiomService; + ObjectMapper objectMapper; + + NodeGeneratorService nodeGeneratorService; + FieldBindingConfiguration fieldBindingConfiguration; + + @Value("${snomio.decimal-scale}") + int decimalScale; + + @Autowired + public MedicationProductCalculationService( + SnowstormClient snowstormClient, + NameGenerationService nameGenerationService, + TicketServiceImpl ticketService, + OwlAxiomService owlAxiomService, + ObjectMapper objectMapper, + NodeGeneratorService nodeGeneratorService, + FieldBindingConfiguration fieldBindingConfiguration) { + this.snowstormClient = snowstormClient; + this.nameGenerationService = nameGenerationService; + this.ticketService = ticketService; + this.owlAxiomService = owlAxiomService; + this.objectMapper = objectMapper; + this.nodeGeneratorService = nodeGeneratorService; + this.fieldBindingConfiguration = fieldBindingConfiguration; + } + + public static BigDecimal calculateConcentrationStrength( + BigDecimal totalQty, BigDecimal productSize) { + BigDecimal result = + totalQty + .divide(productSize, new MathContext(10, RoundingMode.HALF_UP)) + .stripTrailingZeros(); + + // Check if the decimal part is greater than 0.999 + if ((result.remainder(BigDecimal.ONE).compareTo(new BigDecimal("0.999")) >= 0 + && isWithinRoundingPercentage(result, 0, "0.01")) + || result.remainder(BigDecimal.ONE).compareTo(BigDecimal.ZERO) == 0) { + + // Round to a whole number + result = result.setScale(0, RoundingMode.HALF_UP).stripTrailingZeros(); + } else { + BigDecimal rounded = result.setScale(6, RoundingMode.HALF_UP).stripTrailingZeros(); + + if (isWithinRoundingPercentage(result, 6, "0.01")) { + result = rounded; + } else { + throw new ProductAtomicDataValidationProblem( + "Result of " + + totalQty + + "/" + + productSize + + " = " + + result + + " which cannot be rounded to 6 decimal places within 1%."); + } + } + + return result; + } + + private static boolean isWithinRoundingPercentage( + BigDecimal result, int newScale, String percentage) { + BigDecimal rounded = result.setScale(newScale, RoundingMode.HALF_UP); + BigDecimal changePercentage = + rounded.subtract(result).abs().divide(result, 10, RoundingMode.HALF_UP); + + return changePercentage.compareTo(new BigDecimal(percentage)) <= 0; + } + + private static void addParent(Node mpuuNode, Node mpNode) { + if (mpuuNode.isNewConcept()) { + mpuuNode + .getNewConceptDetails() + .getAxioms() + .forEach(a -> a.getRelationships().add(getSnowstormRelationship(IS_A, mpNode, 0))); + } + } + + private static String getSemanticTag( + boolean branded, + boolean containerized, + PackageDetails packageDetails) { + boolean device = + packageDetails.getContainedPackages().stream() + .anyMatch( + p -> + p.getPackageDetails().getContainedProducts().stream() + .anyMatch( + product -> product.getProductDetails().getDeviceType() != null)) + || packageDetails.getContainedProducts().stream() + .anyMatch(product -> product.getProductDetails().getDeviceType() != null); + + if (branded && containerized && device) { + return CONTAINERIZED_BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG.getValue(); + } else if (branded && containerized) { + return CONTAINERIZED_BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG.getValue(); + } else if (branded && device) { + return BRANDED_PRODUCT_PACKAGE_SEMANTIC_TAG.getValue(); + } else if (branded) { + return BRANDED_CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG.getValue(); + } else if (device) { + return PRODUCT_PACKAGE_SEMANTIC_TAG.getValue(); + } else { + return CLINICAL_DRUG_PACKAGE_SEMANTIC_TAG.getValue(); + } + } + + private static String getSemanticTag(boolean branded, MedicationProductDetails productDetails) { + boolean device = productDetails.getDeviceType() != null; + if (branded && device) { + return BRANDED_PRODUCT_SEMANTIC_TAG.getValue(); + } else if (branded) { + return BRANDED_CLINICAL_DRUG_SEMANTIC_TAG.getValue(); + } else if (device) { + return PRODUCT_SEMANTIC_TAG.getValue(); + } else { + return CLINICAL_DRUG_SEMANTIC_TAG.getValue(); + } + } + + /** + * Calculates the existing and new products required to create a product based on the product + * details. + * + * @param branch branch to lookup concepts in + * @param packageDetails details of the product to create + * @return ProductSummary representing the existing and new concepts required to create this + * product + */ + public ProductSummary calculateProductFromAtomicData( + String branch, PackageDetails packageDetails) + throws ExecutionException, InterruptedException { + + // todo - this is a work around because the UI doesn't know which package to put the selected + // identifiers in, so it puts them at the top level. They need to be cascaded down to the lower + // level packages. It is possible this isn't enough if there are different packages with + // intersecting conceptOptions, but this will do for the moment. + packageDetails.cascadeSelectedIdentifiers(); + + return calculateCreatePackage( + branch, + packageDetails, + new AtomicCache( + packageDetails.getIdFsnMap(), AmtConstants.values(), SnomedConstants.values())); + } + + private ProductSummary calculateCreatePackage( + String branch, + PackageDetails packageDetails, + AtomicCache atomicCache) + throws ExecutionException, InterruptedException { + ProductSummary productSummary = new ProductSummary(); + + Mono> taskChangedConceptIds = snowstormClient.getConceptIdsChangedOnTask(branch); + + Mono> projectChangedConceptIds = + snowstormClient.getConceptIdsChangedOnProject(branch); + + validatePackageDetails(packageDetails); + + Map, ProductSummary> innerPackageSummaries = + new HashMap<>(); + for (PackageQuantity packageQuantity : + packageDetails.getContainedPackages()) { + validatePackageQuantity(packageQuantity); + ProductSummary innerPackageSummary = + calculateCreatePackage(branch, packageQuantity.getPackageDetails(), atomicCache); + innerPackageSummaries.put(packageQuantity, innerPackageSummary); + } + + Map, ProductSummary> innnerProductSummaries = + new HashMap<>(); + for (ProductQuantity productQuantity : + packageDetails.getContainedProducts()) { + validateProductQuantity(branch, productQuantity); + ProductSummary innerProductSummary = + createProduct( + branch, + productQuantity.getProductDetails(), + atomicCache, + packageDetails.getSelectedConceptIdentifiers()); + innnerProductSummaries.put(productQuantity, innerProductSummary); + } + + CompletableFuture mpp = + getOrCreatePackagedClinicalDrug( + branch, + packageDetails, + innerPackageSummaries, + innnerProductSummaries, + false, + false, + atomicCache) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt( + atomicCache, getSemanticTag(false, false, packageDetails), n); + productSummary.addNode(n); + return n; + }); + + CompletableFuture tpp = + getOrCreatePackagedClinicalDrug( + branch, + packageDetails, + innerPackageSummaries, + innnerProductSummaries, + true, + false, + atomicCache) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt( + atomicCache, getSemanticTag(true, false, packageDetails), n); + productSummary.addNode(n); + return n; + }); + + CompletableFuture ctpp = + getOrCreatePackagedClinicalDrug( + branch, + packageDetails, + innerPackageSummaries, + innnerProductSummaries, + true, + true, + atomicCache) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt( + atomicCache, getSemanticTag(true, true, packageDetails), n); + productSummary.addNode(n); + return n; + }); + + CompletableFuture.allOf(mpp, tpp, ctpp).get(); + + Node mppNode = mpp.get(); + Node tppNode = tpp.get(); + Node ctppNode = ctpp.get(); + + addParent(tppNode, mppNode); + addParent(ctppNode, tppNode); + productSummary.addEdge( + tppNode.getConceptId(), mppNode.getConceptId(), ProductSummaryService.IS_A_LABEL); + productSummary.addEdge( + ctppNode.getConceptId(), tppNode.getConceptId(), ProductSummaryService.IS_A_LABEL); + productSummary.setSingleSubject(ctppNode); + + productSummary.addNode(packageDetails.getProductName(), ProductSummaryService.TP_LABEL); + productSummary.addEdge( + tppNode.getConceptId(), + packageDetails.getProductName().getConceptId(), + ProductSummaryService.HAS_PRODUCT_NAME_LABEL); + productSummary.addEdge( + ctppNode.getConceptId(), + packageDetails.getProductName().getConceptId(), + ProductSummaryService.HAS_PRODUCT_NAME_LABEL); + + for (ProductSummary summary : innerPackageSummaries.values()) { + productSummary.addSummary(summary); + productSummary.addEdge( + ctppNode.getConceptId(), + summary.getSingleSubject().getConceptId(), + ProductSummaryService.CONTAINS_LABEL); + productSummary.addEdge( + tppNode.getConceptId(), + summary.getSingleConceptWithLabel(ProductSummaryService.TPP_LABEL).getConceptId(), + ProductSummaryService.CONTAINS_LABEL); + productSummary.addEdge( + mppNode.getConceptId(), + summary.getSingleConceptWithLabel(ProductSummaryService.MPP_LABEL).getConceptId(), + ProductSummaryService.CONTAINS_LABEL); + } + + for (ProductSummary summary : innnerProductSummaries.values()) { + productSummary.addSummary(summary); + productSummary.addEdge( + ctppNode.getConceptId(), + summary.getSingleSubject().getConceptId(), + ProductSummaryService.CONTAINS_LABEL); + productSummary.addEdge( + tppNode.getConceptId(), + summary.getSingleSubject().getConceptId(), + ProductSummaryService.CONTAINS_LABEL); + productSummary.addEdge( + mppNode.getConceptId(), + summary.getSingleConceptWithLabel(ProductSummaryService.MPUU_LABEL).getConceptId(), + ProductSummaryService.CONTAINS_LABEL); + } + + Set transitiveContainsEdges = + ProductSummaryService.getTransitiveEdges(productSummary, new HashSet<>()); + productSummary.getEdges().addAll(transitiveContainsEdges); + + productSummary.getNodes().stream() + .filter(Node::isNewConcept) + .forEach( + n -> + n.getNewConceptDetails() + .getAxioms() + .forEach(RelationshipSorter::sortRelationships)); + + productSummary.updateNodeChangeStatus( + taskChangedConceptIds.block(), projectChangedConceptIds.block()); + + return productSummary; + } + + private CompletableFuture getOrCreatePackagedClinicalDrug( + String branch, + PackageDetails packageDetails, + Map, ProductSummary> innerPackageSummaries, + Map, ProductSummary> innnerProductSummaries, + boolean branded, + boolean container, + AtomicCache atomicCache) { + + String semanticTag; + String label; + Set refsets; + final Set referenceSetMembers = + branded && container + ? SnowstormDtoUtil.getExternalIdentifierReferenceSetEntries(packageDetails) + : Set.of(); + if (branded) { + if (container) { + label = ProductSummaryService.CTPP_LABEL; + refsets = Set.of(CTPP_REFSET_ID.getValue()); + } else { + label = ProductSummaryService.TPP_LABEL; + refsets = Set.of(TPP_REFSET_ID.getValue()); + } + } else { + label = ProductSummaryService.MPP_LABEL; + refsets = Set.of(MPP_REFSET_ID.getValue()); + } + + semanticTag = getSemanticTag(branded, container, packageDetails); + + Set relationships = + createPackagedClinicalDrugRelationships( + packageDetails, innerPackageSummaries, innnerProductSummaries, branded, container); + + return nodeGeneratorService.generateNodeAsync( + branch, + atomicCache, + relationships, + refsets, + label, + referenceSetMembers, + semanticTag, + packageDetails.getSelectedConceptIdentifiers(), + true, + label.equals(ProductSummaryService.MPP_LABEL), + true); + } + + private Set createPackagedClinicalDrugRelationships( + PackageDetails packageDetails, + Map, ProductSummary> innerPackageSummaries, + Map, ProductSummary> innnerProductSummaries, + boolean branded, + boolean container) { + + Set relationships = new HashSet<>(); + relationships.add(getSnowstormRelationship(IS_A, MEDICINAL_PRODUCT_PACKAGE, 0)); + + if (branded && container) { + addRelationshipIfNotNull( + relationships, packageDetails.getContainerType(), HAS_CONTAINER_TYPE, 0); + } + + if (branded) { + addRelationshipIfNotNull(relationships, packageDetails.getProductName(), HAS_PRODUCT_NAME, 0); + } + + int group = 1; + for (Entry, ProductSummary> entry : + innnerProductSummaries.entrySet()) { + Node contained; + ProductSummary productSummary = entry.getValue(); + if (branded) { + contained = productSummary.getSingleSubject(); + } else { + contained = productSummary.getSingleConceptWithLabel(ProductSummaryService.MPUU_LABEL); + } + relationships.add(getSnowstormRelationship(CONTAINS_CD, contained, group)); + + ProductQuantity quantity = entry.getKey(); + relationships.add(getSnowstormRelationship(HAS_PACK_SIZE_UNIT, quantity.getUnit(), group)); + relationships.add( + getSnowstormDatatypeComponent( + HAS_PACK_SIZE_VALUE, + BigDecimalFormatter.formatBigDecimal(quantity.getValue(), decimalScale), + DataTypeEnum.DECIMAL, + group)); + + relationships.add( + getSnowstormDatatypeComponent( + COUNT_OF_CONTAINED_COMPONENT_INGREDIENT, + // get the unique set of active ingredients + Integer.toString( + quantity.getProductDetails().getActiveIngredients().stream() + .map(i -> i.getActiveIngredient().getConceptId()) + .collect(Collectors.toSet()) + .size()), + DataTypeEnum.INTEGER, + group)); + + group++; + } + + if (!innnerProductSummaries.isEmpty()) { + relationships.add( + getSnowstormDatatypeComponent( + COUNT_OF_CD_TYPE, + // get the unique set of CD types + Integer.toString( + innnerProductSummaries.values().stream() + .map(v -> v.getSingleSubject().getConceptId()) + .collect(Collectors.toSet()) + .size()), + DataTypeEnum.INTEGER, + 0)); + } + + for (Entry, ProductSummary> entry : + innerPackageSummaries.entrySet()) { + Node contained; + ProductSummary productSummary = entry.getValue(); + if (branded && container) { + contained = productSummary.getSingleSubject(); + } else if (branded) { + contained = productSummary.getSingleConceptWithLabel(ProductSummaryService.TPP_LABEL); + } else { + contained = productSummary.getSingleConceptWithLabel(ProductSummaryService.MPP_LABEL); + } + relationships.add(getSnowstormRelationship(CONTAINS_PACKAGED_CD, contained, group)); + + PackageQuantity quantity = entry.getKey(); + relationships.add(getSnowstormRelationship(HAS_PACK_SIZE_UNIT, quantity.getUnit(), group)); + relationships.add( + getSnowstormDatatypeComponent( + HAS_PACK_SIZE_VALUE, + BigDecimalFormatter.formatBigDecimal(quantity.getValue(), decimalScale), + DataTypeEnum.DECIMAL, + group)); + group++; + } + + if (!innerPackageSummaries.isEmpty()) { + relationships.add( + getSnowstormDatatypeComponent( + COUNT_OF_CONTAINED_PACKAGE_TYPE, + // get the unique set of package types + Integer.toString( + innerPackageSummaries.values().stream() + .map(v -> v.getSingleSubject().getConceptId()) + .collect(Collectors.toSet()) + .size()), + DataTypeEnum.INTEGER, + 0)); + } + + return relationships; + } + + private ProductSummary createProduct( + String branch, + MedicationProductDetails productDetails, + AtomicCache atomicCache, + List selectedConceptIdentifiers) + throws ExecutionException, InterruptedException { + + validateProductDetails(productDetails); + + ProductSummary productSummary = new ProductSummary(); + + CompletableFuture mp = + findOrCreateMp(branch, productDetails, atomicCache, selectedConceptIdentifiers) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt( + atomicCache, MEDICINAL_PRODUCT_SEMANTIC_TAG.getValue(), n); + productSummary.addNode(n); + return n; + }); + CompletableFuture mpuu = + findOrCreateUnit( + branch, productDetails, null, false, atomicCache, selectedConceptIdentifiers) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt( + atomicCache, getSemanticTag(false, productDetails), n); + productSummary.addNode(n); + return n; + }); + + CompletableFuture.allOf(mp, mpuu).get(); + + Node mpNode = mp.get(); + Node mpuuNode = mpuu.get(); + + CompletableFuture tpuu = + findOrCreateUnit( + branch, productDetails, mpuuNode, true, atomicCache, selectedConceptIdentifiers) + .thenApply( + n -> { + nameGenerationService.addGeneratedFsnAndPt( + atomicCache, getSemanticTag(true, productDetails), n); + productSummary.addNode(n); + return n; + }); + Node tpuuNode = tpuu.get(); + + addParent(mpuuNode, mpNode); + + productSummary.addEdge( + mpuuNode.getConceptId(), mpNode.getConceptId(), ProductSummaryService.IS_A_LABEL); + productSummary.addEdge( + tpuuNode.getConceptId(), mpuuNode.getConceptId(), ProductSummaryService.IS_A_LABEL); + + productSummary.addNode(productDetails.getProductName(), ProductSummaryService.TP_LABEL); + productSummary.addEdge( + tpuuNode.getConceptId(), + productDetails.getProductName().getConceptId(), + ProductSummaryService.HAS_PRODUCT_NAME_LABEL); + + productSummary.setSingleSubject(tpuuNode); + + return productSummary; + } + + private CompletableFuture findOrCreateUnit( + String branch, + MedicationProductDetails productDetails, + Node parent, + boolean branded, + AtomicCache atomicCache, + List selectedConceptIdentifiers) { + String label = branded ? ProductSummaryService.TPUU_LABEL : ProductSummaryService.MPUU_LABEL; + Set referencedIds = + Set.of(branded ? TPUU_REFSET_ID.getValue() : MPUU_REFSET_ID.getValue()); + String semanticTag = getSemanticTag(branded, productDetails); + + Set relationships = + createClinicalDrugRelationships(productDetails, parent, branded); + + return nodeGeneratorService.generateNodeAsync( + branch, + atomicCache, + relationships, + referencedIds, + label, + null, + semanticTag, + selectedConceptIdentifiers, + !branded, + false, + true); + } + + private CompletableFuture findOrCreateMp( + String branch, + MedicationProductDetails details, + AtomicCache atomicCache, + List selectedConceptIdentifiers) { + Set relationships = createMpRelationships(details); + String semanticTag = MEDICINAL_PRODUCT_SEMANTIC_TAG.getValue(); + + return nodeGeneratorService.generateNodeAsync( + branch, + atomicCache, + relationships, + Set.of(MP_REFSET_ID.getValue()), + ProductSummaryService.MP_LABEL, + null, + semanticTag, + selectedConceptIdentifiers, + false, + false, + false); + } + + private Set createClinicalDrugRelationships( + MedicationProductDetails productDetails, Node parent, boolean branded) { + Set relationships = new HashSet<>(); + relationships.add(getSnowstormRelationship(IS_A, MEDICINAL_PRODUCT, 0)); + if (parent != null) { + relationships.add(getSnowstormRelationship(IS_A, parent, 0)); + } + + if (branded) { + relationships.add( + getSnowstormRelationship(HAS_PRODUCT_NAME, productDetails.getProductName(), 0)); + + relationships.add( + getSnowstormDatatypeComponent( + HAS_OTHER_IDENTIFYING_INFORMATION, + !StringUtils.hasLength(productDetails.getOtherIdentifyingInformation()) + ? NO_OII_VALUE.getValue() + : productDetails.getOtherIdentifyingInformation(), + DataTypeEnum.STRING, + 0)); + } + + addRelationshipIfNotNull( + relationships, productDetails.getContainerType(), HAS_CONTAINER_TYPE, 0); + addRelationshipIfNotNull(relationships, productDetails.getDeviceType(), HAS_DEVICE_TYPE, 0); + + SnowstormConceptMini doseForm = + productDetails.getGenericForm() == null ? null : productDetails.getGenericForm(); + + if (branded) { + doseForm = + productDetails.getSpecificForm() == null ? doseForm : productDetails.getSpecificForm(); + } + + if (doseForm != null) { + relationships.add(getSnowstormRelationship(HAS_MANUFACTURED_DOSE_FORM, doseForm, 0)); + } + + addQuantityIfNotNull( + productDetails.getQuantity(), + decimalScale, + relationships, + HAS_PACK_SIZE_VALUE, + HAS_PACK_SIZE_UNIT, + DataTypeEnum.DECIMAL, + 0); + + int group = 1; + for (Ingredient ingredient : productDetails.getActiveIngredients()) { + addRelationshipIfNotNull( + relationships, ingredient.getActiveIngredient(), HAS_ACTIVE_INGREDIENT, group); + addRelationshipIfNotNull( + relationships, ingredient.getPreciseIngredient(), HAS_PRECISE_ACTIVE_INGREDIENT, group); + addRelationshipIfNotNull( + relationships, ingredient.getBasisOfStrengthSubstance(), HAS_BOSS, group); + addQuantityIfNotNull( + ingredient.getTotalQuantity(), + decimalScale, + relationships, + HAS_TOTAL_QUANTITY_VALUE, + HAS_TOTAL_QUANTITY_UNIT, + DataTypeEnum.DECIMAL, + group); + addQuantityIfNotNull( + ingredient.getConcentrationStrength(), + decimalScale, + relationships, + CONCENTRATION_STRENGTH_VALUE, + CONCENTRATION_STRENGTH_UNIT, + DataTypeEnum.DECIMAL, + group); + group++; + } + + // MPUUs/CDs use "some" semantics, TPUUs/BCDs use "only" semantics + if (branded + && productDetails.getActiveIngredients() != null + && !productDetails.getActiveIngredients().isEmpty()) { + relationships.add( + getSnowstormDatatypeComponent( + COUNT_OF_ACTIVE_INGREDIENT, + // get the unique set of active ingredients + Integer.toString( + productDetails.getActiveIngredients().stream() + .map(i -> i.getActiveIngredient().getConceptId()) + .collect(Collectors.toSet()) + .size()), + DataTypeEnum.INTEGER, + 0)); + } + + return relationships; + } + + private Set createMpRelationships( + MedicationProductDetails productDetails) { + Set relationships = new HashSet<>(); + relationships.add(getSnowstormRelationship(IS_A, MEDICINAL_PRODUCT, 0)); + int group = 1; + for (Ingredient ingredient : productDetails.getActiveIngredients()) { + relationships.add( + getSnowstormRelationship(HAS_ACTIVE_INGREDIENT, ingredient.getActiveIngredient(), group)); + group++; + } + return relationships; + } + + private void validateProductQuantity( + String branch, ProductQuantity productQuantity) { + // Leave the MRCM validation to the MRCM - the UI should already enforce this and the validation + // in the MS will catch it. Validating here will just slow things down. + ValidationUtil.validateQuantityValueIsOneIfUnitIsEach(productQuantity); + + // if the contained product has a container/device type or a quantity then the unit must be + // each and the quantity must be an integer + MedicationProductDetails productDetails = productQuantity.getProductDetails(); + Quantity productDetailsQuantity = productDetails.getQuantity(); + if ((productDetails.getContainerType() != null + || productDetails.getDeviceType() != null + || productDetailsQuantity != null) + && (!productQuantity.getUnit().getConceptId().equals(UNIT_OF_PRESENTATION.getValue()) + || !ValidationUtil.isIntegerValue(productQuantity.getValue()))) { + throw new ProductAtomicDataValidationProblem( + "Product quantity must be a positive whole number and unit each if a container type or device type are specified"); + } + + // -- for each ingredient + // --- total quantity unit if present must not be composite + // --- concentration strength if present must be composite unit + for (Ingredient ingredient : productDetails.getActiveIngredients()) { + boolean hasProductQuantity = productDetailsQuantity != null; + boolean hasProductQuantityWithUnit = + hasProductQuantity && productDetailsQuantity.getUnit() != null; + boolean hasTotalQuantity = ingredient.getTotalQuantity() != null; + boolean hasConcentrationStrength = ingredient.getConcentrationStrength() != null; + if (hasTotalQuantity + && snowstormClient.isCompositeUnit(branch, ingredient.getTotalQuantity().getUnit())) { + throw new ProductAtomicDataValidationProblem( + "Total quantity unit must not be composite. Ingredient was " + + getIdAndFsnTerm(ingredient.getActiveIngredient()) + + " with unit " + + getIdAndFsnTerm(ingredient.getTotalQuantity().getUnit())); + } + + if (hasConcentrationStrength + && !snowstormClient.isCompositeUnit( + branch, ingredient.getConcentrationStrength().getUnit())) { + throw new ProductAtomicDataValidationProblem( + "Concentration strength unit must be composite. Ingredient was " + + getIdAndFsnTerm(ingredient.getActiveIngredient()) + + " with unit " + + getIdAndFsnTerm(ingredient.getConcentrationStrength().getUnit())); + } + + // Total quantity and concentration strength must be present if the product quantity exists, + // except under the following special conditions for legacy products: + // - either the product size unit is not in mg or ml, + // - or the concentration unit denominator does not match the product size unit, + // - or the ingredient is inert/excluded and therefore doesn't have a strength + if (hasProductQuantityWithUnit && !(hasTotalQuantity && hasConcentrationStrength)) { + boolean isUnitML = + UNIT_ML.getValue().equals(productDetailsQuantity.getUnit().getConceptId()); + boolean isUnitMG = + UNIT_MG.getValue().equals(productDetailsQuantity.getUnit().getConceptId()); + boolean isStrengthUnitMismatch = + hasConcentrationStrength + && ingredient.getConcentrationStrength().getUnit() != null + && !isStrengthDenominatorMatchesQuantityUnit( + ingredient, productDetailsQuantity, branch); + + if (!isUnitML && !isUnitMG) { + // Log a warning if the product quantity unit is not mg or mL + log.warning( + "Handling anomalous products, Product quantity unit is not mg or mL: " + + getIdAndFsnTerm(productDetailsQuantity.getUnit())); + } else if (isStrengthUnitMismatch) { + // Log a warning if the product quantity unit does not match the strength unit denominator + log.warning( + "Handling anomalous products, Product quantity unit does not match the strength unit denominator for ingredient: " + + getIdAndFsnTerm(ingredient.getActiveIngredient())); + } else if (!fieldBindingConfiguration + .getExcludedSubstances() + .contains(ingredient.getActiveIngredient().getConceptId())) { + // Invalid scenario user needs to provide the missing fields + String missingFieldsMessage; + if (!hasTotalQuantity && !hasConcentrationStrength) { + missingFieldsMessage = "total quantity and concentration strength are not specified"; + } else if (!hasTotalQuantity) { + missingFieldsMessage = "total quantity is not specified"; + } else { + missingFieldsMessage = "concentration strength is not specified"; + } + + throw new ProductAtomicDataValidationProblem( + String.format( + "Total quantity and concentration strength must be present if the product quantity exists for ingredient %s but %s", + getIdAndFsnTerm(ingredient.getActiveIngredient()), missingFieldsMessage)); + } + + } else if ((!hasProductQuantity || !hasProductQuantityWithUnit) + && hasTotalQuantity + && hasConcentrationStrength) { + throw new ProductAtomicDataValidationProblem( + "Total ingredient quantity and concentration strength specified for ingredient " + + getIdAndFsnTerm(ingredient.getActiveIngredient()) + + " but product quantity not specified. " + + "0, 1, or all 3 of these properties must be populated, populating 2 is not valid."); + } + + // if pack size and concentration strength are populated + if (hasProductQuantityWithUnit && hasConcentrationStrength) { + // validate that the units line up + Pair numeratorAndDenominator = + snowstormClient.getNumeratorAndDenominatorUnit( + branch, ingredient.getConcentrationStrength().getUnit().getConceptId()); + + // validate the product quantity unit matches the denominator of the concentration strength + if (!productDetailsQuantity + .getUnit() + .getConceptId() + .equals(numeratorAndDenominator.getSecond().getConceptId())) { + log.warning( + "Product quantity unit " + + getIdAndFsnTerm(productDetailsQuantity.getUnit()) + + " does not match ingredient " + + getIdAndFsnTerm(ingredient.getActiveIngredient()) + + " concetration strength denominator " + + getIdAndFsnTerm(numeratorAndDenominator.getSecond()) + + " as expected"); + } + + // if the total quantity is also populated + if (hasTotalQuantity) { + // validate that the total quantity unit matches the numerator of the concentration + // strength + if (!ingredient + .getTotalQuantity() + .getUnit() + .getConceptId() + .equals(numeratorAndDenominator.getFirst().getConceptId())) { + log.warning( + "Ingredient " + + getIdAndFsnTerm(ingredient.getActiveIngredient()) + + " total quantity unit " + + getIdAndFsnTerm(ingredient.getTotalQuantity().getUnit()) + + " does not match the concetration strength numerator " + + getIdAndFsnTerm(numeratorAndDenominator.getFirst()) + + " as expected"); + } + + // validate that the values calculate out correctly + BigDecimal totalQuantity = ingredient.getTotalQuantity().getValue(); + BigDecimal concentration = ingredient.getConcentrationStrength().getValue(); + BigDecimal quantity = productDetailsQuantity.getValue(); + + BigDecimal calculatedConcentrationStrength = + calculateConcentrationStrength(totalQuantity, quantity); + + if (!concentration.stripTrailingZeros().equals(calculatedConcentrationStrength)) { + throw new ProductAtomicDataValidationProblem( + "Concentration strength " + + concentration + + " for ingredient " + + getIdAndFsnTerm(ingredient.getActiveIngredient()) + + " does not match calculated value " + + calculatedConcentrationStrength + + " from the provided total quantity and product quantity"); + } + } + } + } + } + + private boolean isStrengthDenominatorMatchesQuantityUnit( + Ingredient ingredient, Quantity productDetailsQuantity, String branch) { + Pair numeratorAndDenominator = + snowstormClient.getNumeratorAndDenominatorUnit( + branch, ingredient.getConcentrationStrength().getUnit().getConceptId()); + + // validate the product quantity unit matches the denominator of the concentration strength + return productDetailsQuantity + .getUnit() + .getConceptId() + .equals(numeratorAndDenominator.getSecond().getConceptId()); + } + + private void validatePackageQuantity(PackageQuantity packageQuantity) { + // Leave the MRCM validation to the MRCM - the UI should already enforce this and the validation + // in the MS will catch it. Validating here will just slow things down. + + // -- package quantity unit must be each and the quantitiy must be an integer + ValidationUtil.validateQuantityValueIsOneIfUnitIsEach(packageQuantity); + + // validate that the package is only nested one deep + if (packageQuantity.getPackageDetails().getContainedPackages() != null + && !packageQuantity.getPackageDetails().getContainedPackages().isEmpty()) { + throw new ProductAtomicDataValidationProblem( + "A contained package must not contain further packages - nesting is only one level deep"); + } + } + + private void validateProductDetails(MedicationProductDetails productDetails) { + // one of form, container or device must be populated + if (productDetails.getGenericForm() == null + && productDetails.getContainerType() == null + && productDetails.getDeviceType() == null) { + throw new ProductAtomicDataValidationProblem( + "One of form, container type or device type must be populated"); + } + + // specific dose form can only be populated if generic dose form is populated + if (productDetails.getSpecificForm() != null && productDetails.getGenericForm() == null) { + throw new ProductAtomicDataValidationProblem( + "Specific form can only be populated if generic form is populated"); + } + + // If Container is populated, Form must be populated + if (productDetails.getContainerType() != null && productDetails.getGenericForm() == null) { + throw new ProductAtomicDataValidationProblem( + "If container type is populated, form must be populated"); + } + + // If Form is populated, Device must not be populated + if (productDetails.getGenericForm() != null && productDetails.getDeviceType() != null) { + throw new ProductAtomicDataValidationProblem( + "If form is populated, device type must not be populated"); + } + + // If Device is populated, Form and Container must not be populated + if (productDetails.getDeviceType() != null + && (productDetails.getGenericForm() != null || productDetails.getContainerType() != null)) { + throw new ProductAtomicDataValidationProblem( + "If device type is populated, form and container type must not be populated"); + } + + // product name must be populated + if (productDetails.getProductName() == null) { + throw new ProductAtomicDataValidationProblem("Product name must be populated"); + } + + productDetails.getActiveIngredients().forEach(this::validateIngredient); + } + + private void validateIngredient(Ingredient ingredient) { + // BoSS is only populated if the active ingredient is populated + if (ingredient.getActiveIngredient() == null + && ingredient.getBasisOfStrengthSubstance() != null) { + throw new ProductAtomicDataValidationProblem( + "Basis of strength substance can only be populated if active ingredient is populated"); + } + + // precise ingredient is only populated if active ingredient is populated + if (ingredient.getActiveIngredient() == null && ingredient.getPreciseIngredient() != null) { + throw new ProductAtomicDataValidationProblem( + "Precise ingredient can only be populated if active ingredient is populated"); + } + + // if BoSS is populated then total quantity or concentration strength must be populated + if (ingredient.getBasisOfStrengthSubstance() != null + && ingredient.getTotalQuantity() == null + && ingredient.getConcentrationStrength() == null) { + throw new ProductAtomicDataValidationProblem( + "Basis of strength substance is populated but neither total quantity or concentration strength are populated"); + } + + // active ingredient is mandatory + if (ingredient.getActiveIngredient() == null) { + throw new ProductAtomicDataValidationProblem("Active ingredient must be populated"); + } + } + + private void validatePackageDetails(PackageDetails packageDetails) { + // Leave the MRCM validation to the MRCM - the UI should already enforce this and the validation + // in the MS will catch it. Validating here will just slow things down. + + // validate the package details + // - product name is a product name - MRCM? + // - container type is a container type - MRCM? + + // product name must be populated + if (packageDetails.getProductName() == null) { + throw new ProductAtomicDataValidationProblem("Product name must be populated"); + } + + // container type is mandatory + if (packageDetails.getContainerType() == null) { + throw new ProductAtomicDataValidationProblem("Container type must be populated"); + } + + // if the package contains other packages it must use a unit of each for the contained packages + if (packageDetails.getContainedPackages() != null + && !packageDetails.getContainedPackages().isEmpty() + && !packageDetails.getContainedPackages().stream() + .allMatch(p -> p.getUnit().getConceptId().equals(UNIT_OF_PRESENTATION.getValue()))) { + throw new ProductAtomicDataValidationProblem( + "If the package contains other packages it must use a unit of 'each' for the contained packages"); + } + + // if the package contains other packages it must have a container type of "Pack" + if (packageDetails.getContainedPackages() != null + && !packageDetails.getContainedPackages().isEmpty() + && !packageDetails + .getContainerType() + .getConceptId() + .equals(SnomedConstants.PACK.getValue())) { + throw new ProductAtomicDataValidationProblem( + "If the package contains other packages it must have a container type of 'Pack'"); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/MedicationService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/MedicationService.java new file mode 100644 index 000000000..b1d7c91e8 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/MedicationService.java @@ -0,0 +1,240 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONCENTRATION_STRENGTH_UNIT; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONCENTRATION_STRENGTH_VALUE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_PACKAGED_CD; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_CONTAINER_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_DEVICE_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_TOTAL_QUANTITY_UNIT; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_TOTAL_QUANTITY_VALUE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.MPUU_REFSET_ID; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINS_CD; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_BOSS; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_MANUFACTURED_DOSE_FORM; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_UNIT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PACK_SIZE_VALUE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRECISE_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.IS_A; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.filterActiveStatedRelationshipByType; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getActiveRelationshipsInRoleGroup; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getActiveRelationshipsOfType; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getRelationshipsFromAxioms; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleActiveTarget; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleOptionalActiveBigDecimal; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleOptionalActiveTarget; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.relationshipOfTypeExists; + +import au.csiro.snowstorm_client.model.SnowstormConcept; +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.exception.AtomicDataExtractionProblem; +import au.gov.digitalhealth.lingo.product.details.Ingredient; +import au.gov.digitalhealth.lingo.product.details.MedicationProductDetails; +import au.gov.digitalhealth.lingo.product.details.Quantity; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** Service for product-centric operations */ +@Service +@Log +public class MedicationService extends AtomicDataService { + private static final String PACKAGE_CONCEPTS_FOR_ATOMIC_EXTRACTION_ECL = + "( or (.999000011000168107) " + + "or (.774160008) " + + "or (.999000011000168107.774160008) " + + "or ((>>((.774160008) or (.999000011000168107.774160008))) and (^929360071000036103))) " + + "and <373873005"; + private static final String PRODUCT_CONCEPTS_FOR_ATOMIC_EXTRACTION_ECL = + "( or (>> and ^929360071000036103)) and <373873005"; + private final SnowstormClient snowStormApiClient; + + @Autowired + MedicationService(SnowstormClient snowStormApiClient) { + this.snowStormApiClient = snowStormApiClient; + } + + private static Ingredient getIngredient( + SnowstormRelationship ingredientRelationship, + Set productRelationships) { + Set ingredientRoleGroup = + getActiveRelationshipsInRoleGroup(ingredientRelationship, productRelationships); + Ingredient ingredient = new Ingredient(); + ingredient.setActiveIngredient(ingredientRelationship.getTarget()); + ingredient.setPreciseIngredient( + getSingleOptionalActiveTarget( + ingredientRoleGroup, HAS_PRECISE_ACTIVE_INGREDIENT.getValue())); + ingredient.setBasisOfStrengthSubstance( + getSingleOptionalActiveTarget(ingredientRoleGroup, HAS_BOSS.getValue())); + if (relationshipOfTypeExists(ingredientRoleGroup, HAS_TOTAL_QUANTITY_VALUE.getValue())) { + ingredient.setTotalQuantity( + new Quantity( + getSingleOptionalActiveBigDecimal( + ingredientRoleGroup, HAS_TOTAL_QUANTITY_VALUE.getValue()), + getSingleActiveTarget(ingredientRoleGroup, HAS_TOTAL_QUANTITY_UNIT.getValue()))); + } + if (relationshipOfTypeExists(ingredientRoleGroup, CONCENTRATION_STRENGTH_VALUE.getValue())) { + ingredient.setConcentrationStrength( + new Quantity( + getSingleOptionalActiveBigDecimal( + ingredientRoleGroup, CONCENTRATION_STRENGTH_VALUE.getValue()), + getSingleActiveTarget(ingredientRoleGroup, CONCENTRATION_STRENGTH_UNIT.getValue()))); + } + return ingredient; + } + + private static void populateDoseForm( + String productId, + Map browserMap, + Map typeMap, + Set productRelationships, + MedicationProductDetails productDetails) { + Set mpuu = + filterActiveStatedRelationshipByType(productRelationships, IS_A.getValue()).stream() + .filter( + r -> + r.getTarget() != null + && typeMap.get(r.getTarget().getConceptId()) != null + && typeMap + .get(r.getTarget().getConceptId()) + .equals(MPUU_REFSET_ID.getValue())) + .map(r -> browserMap.get(r.getTarget().getConceptId())) + .collect(Collectors.toSet()); + + if (mpuu.size() != 1) { + throw new AtomicDataExtractionProblem("Expected 1 MPUU but found " + mpuu.size(), productId); + } + + SnowstormConceptMini genericDoseForm = + getSingleActiveTarget( + getRelationshipsFromAxioms(mpuu.stream().findFirst().orElseThrow()), + HAS_MANUFACTURED_DOSE_FORM.getValue()); + + productDetails.setGenericForm(genericDoseForm); + SnowstormConceptMini specificDoseForm = + getSingleActiveTarget(productRelationships, HAS_MANUFACTURED_DOSE_FORM.getValue()); + if (specificDoseForm.getConceptId() != null + && !specificDoseForm.getConceptId().equals(genericDoseForm.getConceptId())) { + productDetails.setSpecificForm(specificDoseForm); + } + if (relationshipOfTypeExists(productRelationships, HAS_DEVICE_TYPE.getValue())) { + throw new AtomicDataExtractionProblem( + "Expected manufactured dose form or device type, product has both", productId); + } + } + + private static void populatePackSize( + Set productRelationships, MedicationProductDetails productDetails) { + if (relationshipOfTypeExists(productRelationships, HAS_PACK_SIZE_UNIT.getValue())) { + productDetails.setQuantity( + new Quantity( + getSingleOptionalActiveBigDecimal( + productRelationships, HAS_PACK_SIZE_VALUE.getValue()), + getSingleActiveTarget(productRelationships, HAS_PACK_SIZE_UNIT.getValue()))); + } + } + + @Override + protected SnowstormClient getSnowStormApiClient() { + return snowStormApiClient; + } + + @Override + protected String getPackageAtomicDataEcl() { + return PACKAGE_CONCEPTS_FOR_ATOMIC_EXTRACTION_ECL; + } + + @Override + protected String getProductAtomicDataEcl() { + return PRODUCT_CONCEPTS_FOR_ATOMIC_EXTRACTION_ECL; + } + + @Override + protected String getContainedUnitRelationshipType() { + return CONTAINS_CD.getValue(); + } + + @Override + protected String getSubpackRelationshipType() { + return CONTAINS_PACKAGED_CD.getValue(); + } + + @Override + protected MedicationProductDetails populateSpecificProductDetails( + SnowstormConcept product, + String productId, + Map browserMap, + Map typeMap) { + + MedicationProductDetails productDetails = new MedicationProductDetails(); + + // product name + Set productRelationships = getRelationshipsFromAxioms(product); + + // manufactured dose form - need to detect generic and specific forms if present + boolean hasDoseForm = + relationshipOfTypeExists(productRelationships, HAS_MANUFACTURED_DOSE_FORM.getValue()); + if (hasDoseForm) { + populateDoseForm(productId, browserMap, typeMap, productRelationships, productDetails); + } + + boolean hasContainerType = + relationshipOfTypeExists(productRelationships, HAS_CONTAINER_TYPE.getValue()); + if (hasContainerType) { + productDetails.setContainerType( + getSingleActiveTarget(productRelationships, HAS_CONTAINER_TYPE.getValue())); + } + + boolean hasDevice = relationshipOfTypeExists(productRelationships, HAS_DEVICE_TYPE.getValue()); + if (hasDevice) { + productDetails.setDeviceType( + getSingleActiveTarget(productRelationships, HAS_DEVICE_TYPE.getValue())); + } + + if (!hasDoseForm && !hasDevice) { + throw new AtomicDataExtractionProblem( + "Expected manufactured dose form or device type, product has neither", productId); + } else if (hasDoseForm && hasDevice) { + throw new AtomicDataExtractionProblem( + "Expected manufactured dose form or device type, product has both", productId); + } else if (hasDevice && hasContainerType) { + throw new AtomicDataExtractionProblem( + "Expected container type or device type, product has both", productId); + } + + populatePackSize(productRelationships, productDetails); + + Set ingredientRelationships = + getActiveRelationshipsOfType(productRelationships, HAS_ACTIVE_INGREDIENT.getValue()); + for (SnowstormRelationship ingredientRelationship : ingredientRelationships) { + productDetails + .getActiveIngredients() + .add(getIngredient(ingredientRelationship, productRelationships)); + } + return productDetails; + } + + @Override + protected String getType() { + return "medication"; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/NameGenerationClient.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/NameGenerationClient.java new file mode 100644 index 000000000..a4eecabfc --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/NameGenerationClient.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.gov.digitalhealth.lingo.product.FsnAndPt; +import au.gov.digitalhealth.lingo.product.NameGeneratorSpec; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@Log +public class NameGenerationClient { + + public static final String GENERATED_NAME_UNAVAILABLE = "Generated name unavailable"; + WebClient client; + + @Autowired + public NameGenerationClient(@Qualifier("nameGeneratorApiClient") WebClient namegenApiClient) { + this.client = namegenApiClient; + } + + public FsnAndPt generateNames(NameGeneratorSpec spec) { + return this.client + .post() + // .uri("/amt_name_gen") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(spec) + .retrieve() + .bodyToMono(FsnAndPt.class) + .doOnError(e -> log.severe("Name generator failed to execute with " + e.getMessage())) + .onErrorReturn( + FsnAndPt.builder() + .FSN(GENERATED_NAME_UNAVAILABLE) + .PT(GENERATED_NAME_UNAVAILABLE) + .build()) + .block(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/NameGenerationService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/NameGenerationService.java new file mode 100644 index 000000000..947a170a5 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/NameGenerationService.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.csiro.snowstorm_client.model.SnowstormConceptView; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.product.FsnAndPt; +import au.gov.digitalhealth.lingo.product.NameGeneratorSpec; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.util.OwlAxiomService; +import au.gov.digitalhealth.lingo.util.SnowstormDtoUtil; +import java.time.Duration; +import java.time.Instant; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Level; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@Log +public class NameGenerationService { + + private final boolean failOnBadInput; + NameGenerationClient client; + + OwlAxiomService owlAxiomService; + + @Autowired + public NameGenerationService( + NameGenerationClient client, + OwlAxiomService owlAxiomService, + @Value("${snomio.nameGenerator.failOnBadInput:false}") boolean failOnBadInput) { + this.client = client; + this.owlAxiomService = owlAxiomService; + this.failOnBadInput = failOnBadInput; + } + + public void addGeneratedFsnAndPt(AtomicCache atomicCache, String semanticTag, Node node) { + Instant start = Instant.now(); + Optional nameGeneratorSpec = + generateNameGeneratorSpec(atomicCache, semanticTag, node); + if (nameGeneratorSpec.isEmpty()) return; + FsnAndPt fsnAndPt = createFsnAndPreferredTerm(nameGeneratorSpec.get()); + node.getNewConceptDetails().setFullySpecifiedName(fsnAndPt.getFSN()); + node.getNewConceptDetails().setPreferredTerm(fsnAndPt.getPT()); + atomicCache.addFsn(node.getConceptId(), fsnAndPt.getFSN()); + if (log.isLoggable(java.util.logging.Level.FINE)) { + log.fine( + "Generated FSN and PT for " + + node.getConceptId() + + " FSN: " + + fsnAndPt.getFSN() + + " PT: " + + fsnAndPt.getPT() + + " in " + + (Duration.between(start, Instant.now()).toMillis()) + + " ms"); + } + } + + public Optional generateNameGeneratorSpec( + AtomicCache atomicCache, String semanticTag, Node node) { + if (node.isNewConcept()) { + SnowstormConceptView scon = SnowstormDtoUtil.toSnowstormConceptView(node); + Set axioms = owlAxiomService.translate(scon); + String axiomN; + try { + if (axioms == null || axioms.size() != 1) { + throw new NoSuchElementException(); + } + axiomN = axioms.stream().findFirst().orElseThrow(); + } catch (NoSuchElementException e) { + throw new ProductAtomicDataValidationProblem( + "Could not calculate one (and only one) axiom for concept " + scon.getConceptId()); + } + axiomN = atomicCache.substituteIdsInAxiom(axiomN, node.getNewConceptDetails().getConceptId()); + + return Optional.of(new NameGeneratorSpec(semanticTag, axiomN)); + } + + return Optional.empty(); + } + + public FsnAndPt createFsnAndPreferredTerm(NameGeneratorSpec spec) { + + if (spec.getOwl().matches(".*\\d{7,18}.*")) { + String msg = + "Axiom to generate names for contains SCTID/s - results may be unreliable. Axiom was - " + + spec.getOwl(); + log.severe(msg); + if (failOnBadInput) { + throw new IllegalArgumentException(msg); + } + } + + FsnAndPt result = client.generateNames(spec); + + if (log.isLoggable(Level.FINE)) { + log.fine("NameGeneratorSpec: " + spec); + log.fine("Result: " + result); + } + + return result; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/NodeGeneratorService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/NodeGeneratorService.java new file mode 100644 index 000000000..0d908b507 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/NodeGeneratorService.java @@ -0,0 +1,311 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.CTPP_LABEL; +import static au.gov.digitalhealth.lingo.util.AmtConstants.*; +import static au.gov.digitalhealth.lingo.util.ExternalIdentifierUtils.getExternalIdentifierReferenceSet; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.DEFINED; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.PRIMITIVE; + +import au.csiro.snowstorm_client.model.SnowstormAxiom; +import au.csiro.snowstorm_client.model.SnowstormConcept; +import au.csiro.snowstorm_client.model.SnowstormConceptMini; +import au.csiro.snowstorm_client.model.SnowstormReferenceSetMemberViewComponent; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.exception.SingleConceptExpectedProblem; +import au.gov.digitalhealth.lingo.product.NewConceptDetails; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.util.EclBuilder; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; + +@Service +@Log +@EnableAsync +public class NodeGeneratorService { + SnowstormClient snowstormClient; + + @Value("${snomio.node.concept.search.limit:50}") + private int limit; + + @Autowired + public NodeGeneratorService(SnowstormClient snowstormClient) { + this.snowstormClient = snowstormClient; + } + + @Async + public CompletableFuture lookUpNode(String branch, String productId, String label) { + Node node = new Node(); + node.setLabel(label); + SnowstormConceptMini concept = snowstormClient.getConcept(branch, productId); + node.setConcept(concept); + return CompletableFuture.completedFuture(node); + } + + @Async + public CompletableFuture lookUpNode( + String branch, Long productId, String ecl, String label) { + Node node = new Node(); + node.setLabel(label); + SnowstormConceptMini concept = snowstormClient.getConceptFromEcl(branch, ecl, productId); + node.setConcept(concept); + return CompletableFuture.completedFuture(node); + } + + @Async + public CompletableFuture> lookUpNodes( + String branch, String productId, String ecl, String label) { + return CompletableFuture.completedFuture( + snowstormClient.getConceptsFromEcl(branch, ecl, Long.parseLong(productId), 0, 100).stream() + .map( + concept -> { + Node node = new Node(); + node.setLabel(label); + node.setConcept(concept); + return node; + }) + .toList()); + } + + @Async + public CompletableFuture generateNodeAsync( + String branch, + AtomicCache atomicCache, + Set relationships, + Set refsets, + String label, + Set referenceSetMembers, + String semanticTag, + List selectedConceptIdentifiers, + boolean suppressIsa, + boolean suppressNegativeStatements, + boolean enforceRefsets) { + return CompletableFuture.completedFuture( + generateNode( + branch, + atomicCache, + relationships, + refsets, + label, + referenceSetMembers, + semanticTag, + selectedConceptIdentifiers, + suppressIsa, + suppressNegativeStatements, + enforceRefsets)); + } + + public Node generateNode( + String branch, + AtomicCache atomicCache, + Set relationships, + Set refsets, + String label, + Set referenceSetMembers, + String semanticTag, + List selectedConceptIdentifiers, + boolean suppressIsa, + boolean suppressNegativeStatements, + boolean enforceRefsets) { + + boolean selectedConcept = false; // indicates if a selected concept has been detected + Node node = new Node(); + node.setLabel(label); + + // if the relationships are empty or a relationship to a new concept (-ve id) + // then don't bother looking + if (!relationships.isEmpty() + && relationships.stream() + .noneMatch( + r -> + r.getConcreteValue() == null + && r.getDestinationId() != null + && Long.parseLong(r.getDestinationId()) < 0)) { + String ecl = + EclBuilder.build(relationships, refsets, suppressIsa, suppressNegativeStatements); + + if (log.isLoggable(Level.FINE)) { + log.fine("ECL for " + label + " " + ecl); + } + + Collection matchingConcepts = + snowstormClient.getConceptsFromEcl(branch, ecl, limit); + + matchingConcepts = filterByOii(branch, relationships, matchingConcepts); + + if (matchingConcepts.isEmpty() && !enforceRefsets) { + log.info( + "No concept found for " + + label + + " ECL " + + ecl + + " trying again without refset constraint"); + ecl = EclBuilder.build(relationships, Set.of(), suppressIsa, suppressNegativeStatements); + } + + if (log.isLoggable(Level.FINE)) { + log.fine("ECL for " + label + " " + ecl); + } + + matchingConcepts = snowstormClient.getConceptsFromEcl(branch, ecl, limit); + + matchingConcepts = filterByOii(branch, relationships, matchingConcepts); + + if (matchingConcepts.isEmpty()) { + log.warning("No concept found for " + label + " ECL " + ecl); + } else if (matchingConcepts.size() == 1 + && matchingConcepts.iterator().next().getDefinitionStatus().equals("FULLY_DEFINED")) { + node.setConcept(matchingConcepts.iterator().next()); + if (node.getLabel().equals(CTPP_LABEL) + && referenceSetMembers != null) { // populate external identifiers in response + + node.getExternalIdentifiers() + .addAll(getExternalIdentifierReferenceSet(referenceSetMembers)); + } + atomicCache.addFsn(node.getConceptId(), node.getFullySpecifiedName()); + } else { + node.setConceptOptions(matchingConcepts); + Set selectedConcepts = + matchingConcepts.stream() + .filter(c -> selectedConceptIdentifiers.contains(c.getConceptId())) + .collect(Collectors.toSet()); + + if (!selectedConcepts.isEmpty()) { + if (selectedConcepts.size() > 1) { + throw new SingleConceptExpectedProblem( + selectedConcepts, + " Multiple matches for selected concept identifiers " + + String.join(", ", selectedConceptIdentifiers)); + } + node.setConcept(selectedConcepts.iterator().next()); + selectedConcept = true; + } + } + } + + // if there is no single matching concept found, or the user has selected a single concept + // provide the modelling for a new concept so they can select a new concept as an option. + if (node.getConcept() == null || selectedConcept) { + node.setLabel(label); + NewConceptDetails newConceptDetails = new NewConceptDetails(atomicCache.getNextId()); + SnowstormAxiom axiom = new SnowstormAxiom(); + axiom.active(true); + axiom.setDefinitionStatusId( + node.getConceptOptions().isEmpty() ? DEFINED.getValue() : PRIMITIVE.getValue()); + axiom.setDefinitionStatus(node.getConceptOptions().isEmpty() ? "FULLY_DEFINED" : "PRIMITIVE"); + axiom.setRelationships(relationships); + axiom.setModuleId(SCT_AU_MODULE.getValue()); + axiom.setReleased(false); + newConceptDetails.setSemanticTag(semanticTag); + newConceptDetails.getAxioms().add(axiom); + newConceptDetails.setReferenceSetMembers(referenceSetMembers); + node.setNewConceptDetails(newConceptDetails); + log.fine("New concept for " + label + " " + newConceptDetails.getConceptId()); + } else { + log.fine("Concept found for " + label + " " + node.getConceptId()); + } + + return node; + } + + /** + * Post filters a set of concept to remove those that don't match the OII required by the set of + * candidate relationships - this is because Snowstorm does not support String type concrete + * domains in ECL so this is a work around. + * + * @param branch branch to check the concepts against + * @param relationships original candidate relationships to check the concepts against + * @param matchingConcepts matching concepts to filter that matched the ECL + * @return filtered down set of matching concepts removing any concepts that don't match the OII + */ + private Collection filterByOii( + String branch, + Set relationships, + Collection matchingConcepts) { + if (relationships.stream() + .anyMatch(r -> r.getTypeId().equals(HAS_OTHER_IDENTIFYING_INFORMATION.getValue()))) { + List oii = + relationships.stream() + .filter(r -> r.getTypeId().equals(HAS_OTHER_IDENTIFYING_INFORMATION.getValue())) + .map(r -> r.getConcreteValue().getValue()) + .toList(); + + Set matchingConceptIds = + matchingConcepts.stream() + .map(SnowstormConceptMini::getConceptId) + .collect(Collectors.toSet()); + + Set idsWithMatchingOii = + snowstormClient + .getBrowserConcepts(branch, matchingConceptIds) + .collectList() + .block() + .stream() + .filter( + c -> + c.getClassAxioms().stream() + .anyMatch( + a -> + a.getRelationships().stream() + .anyMatch( + r -> + r.getTypeId() + .equals( + HAS_OTHER_IDENTIFYING_INFORMATION + .getValue()) + && oii.contains( + r.getConcreteValue().getValue())))) + .map(SnowstormConcept::getConceptId) + .collect(Collectors.toSet()); + + matchingConcepts = + matchingConcepts.stream() + .filter(c -> idsWithMatchingOii.contains(c.getConceptId())) + .toList(); + } + return matchingConcepts; + } + + @Async + public CompletableFuture addTransitiveEdges( + String branch, Node node, String nodeIdOrClause, ProductSummary productSummary) { + snowstormClient + .getConceptsFromEcl( + branch, + "(<" + node.getConceptId() + ") AND (" + nodeIdOrClause + ")", + 0, + productSummary.getNodes().size()) + .stream() + .map(SnowstormConceptMini::getConceptId) + .forEach( + id -> + productSummary.addEdge( + id, node.getConcept().getConceptId(), ProductSummaryService.IS_A_LABEL)); + return CompletableFuture.completedFuture(productSummary); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductCreationService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductCreationService.java new file mode 100644 index 000000000..33fd50e4d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductCreationService.java @@ -0,0 +1,748 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.CTPP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.MPP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.MPUU_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.MP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.TPP_LABEL; +import static au.gov.digitalhealth.lingo.service.ProductSummaryService.TPUU_LABEL; +import static au.gov.digitalhealth.lingo.util.AmtConstants.*; +import static au.gov.digitalhealth.lingo.util.ExternalIdentifierUtils.getExternalIdentifierReferenceSet; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.*; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.*; + +import au.csiro.snowstorm_client.model.*; +import au.gov.digitalhealth.lingo.configuration.FieldBindingConfiguration; +import au.gov.digitalhealth.lingo.configuration.NamespaceConfiguration; +import au.gov.digitalhealth.lingo.exception.EmptyProductCreationProblem; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.exception.NamespaceNotConfiguredProblem; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.lingo.product.BrandCreationRequest; +import au.gov.digitalhealth.lingo.product.Edge; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.ProductCreationDetails; +import au.gov.digitalhealth.lingo.product.ProductSummary; +import au.gov.digitalhealth.lingo.product.bulk.BrandPackSizeCreationDetails; +import au.gov.digitalhealth.lingo.product.bulk.BulkProductAction; +import au.gov.digitalhealth.lingo.product.details.ProductDetails; +import au.gov.digitalhealth.lingo.service.identifier.IdentifierSource; +import au.gov.digitalhealth.lingo.util.OwlAxiomService; +import au.gov.digitalhealth.lingo.util.SnomedConstants; +import au.gov.digitalhealth.lingo.util.SnowstormDtoUtil; +import au.gov.digitalhealth.tickets.TicketDto; +import au.gov.digitalhealth.tickets.TicketDtoExtended; +import au.gov.digitalhealth.tickets.controllers.BulkProductActionDto; +import au.gov.digitalhealth.tickets.controllers.ProductDto; +import au.gov.digitalhealth.tickets.service.ModifiedGeneratedNameService; +import au.gov.digitalhealth.tickets.service.TicketServiceImpl; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import java.util.*; +import java.util.logging.Level; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.apache.commons.collections4.BidiMap; +import org.apache.commons.collections4.bidimap.DualHashBidiMap; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Log +@Service +public class ProductCreationService { + + SnowstormClient snowstormClient; + NameGenerationService nameGenerationService; + TicketServiceImpl ticketService; + OwlAxiomService owlAxiomService; + ObjectMapper objectMapper; + IdentifierSource identifierSource; + NamespaceConfiguration namespaceConfiguration; + + ModifiedGeneratedNameService modifiedGeneratedNameService; + FieldBindingConfiguration fieldBindingConfiguration; + + public ProductCreationService( + SnowstormClient snowstormClient, + NameGenerationService nameGenerationService, + TicketServiceImpl ticketService, + OwlAxiomService owlAxiomService, + ObjectMapper objectMapper, + IdentifierSource identifierSource, + NamespaceConfiguration namespaceConfiguration, + ModifiedGeneratedNameService modifiedGeneratedNameService, + FieldBindingConfiguration fieldBindingConfiguration) { + this.snowstormClient = snowstormClient; + this.nameGenerationService = nameGenerationService; + this.ticketService = ticketService; + this.owlAxiomService = owlAxiomService; + this.objectMapper = objectMapper; + this.identifierSource = identifierSource; + this.namespaceConfiguration = namespaceConfiguration; + this.modifiedGeneratedNameService = modifiedGeneratedNameService; + this.fieldBindingConfiguration = fieldBindingConfiguration; + } + + private static void updateAxiomIdentifierReferences( + Map idMap, SnowstormConceptView concept) { + // if the concept references a concept that has just been created, update the destination + // from the placeholder negative number to the new SCTID + concept + .getClassAxioms() + .forEach( + a -> + a.getRelationships() + .forEach( + r -> { + if (idMap.containsKey(r.getDestinationId())) { + r.setDestinationId(idMap.get(r.getDestinationId())); + } + })); + } + + private static void assertNoRemainingReferencesToPlaceholderDestinationIds( + List nodeCreateOrder, Map idMap, List concepts) { + if (concepts.stream() + .map(SnowstormConceptView::getConceptId) + .anyMatch(id -> id != null && Long.parseLong(id) < 0)) { + throw new LingoProblem( + "product-creation", + "All negative identifiers should have been replaced", + HttpStatus.INTERNAL_SERVER_ERROR); + } else if (concepts.stream() + .flatMap(c -> c.getClassAxioms().stream().flatMap(a -> a.getRelationships().stream())) + .anyMatch( + r -> + r.getConcreteValue() == null + && Long.parseLong(Objects.requireNonNull(r.getDestinationId())) < 0)) { + + List offendingConcepts = + concepts.stream() + .filter( + c -> + c.getClassAxioms().stream() + .anyMatch( + a -> + a.getRelationships().stream() + .anyMatch( + r -> + r.getConcreteValue() == null + && Long.parseLong( + Objects.requireNonNull( + r.getDestinationId())) + < 0))) + .toList(); + + log.severe( + "Identifier references should have been replaced with allocated identifiers. Node create order " + + nodeCreateOrder.stream().map(Node::getConceptId).collect(Collectors.joining(", ")) + + ". Offending concepts: " + + offendingConcepts + + " Id map: " + + idMap); + + throw new LingoProblem( + "product-creation", + "Identifier references should have been replaced with allocated identifiers", + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + public ProductSummary createProductFromBrandPackSizeCreationDetails( + String branch, @Valid BulkProductAction creationDetails) + throws InterruptedException { + + // validate the ticket exists + TicketDtoExtended ticket = ticketService.findTicket(creationDetails.getTicketId()); + + ProductSummary productSummary = creationDetails.getProductSummary(); + if (productSummary.getNodes().stream().noneMatch(Node::isNewConcept)) { + throw new EmptyProductCreationProblem(); + } + ProductSummary productSummaryClone = null; + try { + productSummaryClone = + objectMapper.readValue( + objectMapper.writeValueAsString(productSummary), ProductSummary.class); + } catch (JsonProcessingException jsonProcessingException) { + log.severe("Could not clone product summary - potentially missed ModifiedGeneratedNames"); + } + + Set newSubjects = + productSummary.calculateSubject(false).stream() + .filter(Node::isNewConcept) + .collect(Collectors.toSet()); + + BidiMap idMap = create(branch, productSummary, false); + + if (productSummaryClone != null) { + modifiedGeneratedNameService.createAndSaveModifiedGeneratedNames( + creationDetails.getDetails().getIdFsnMap(), productSummaryClone, branch, idMap); + } + + BulkProductActionDto dto = + BulkProductActionDto.builder() + .conceptIds(newSubjects.stream().map(Node::getConceptId).collect(Collectors.toSet())) + .details(creationDetails.getDetails()) + .build(); + + dto.setName(ticketService.getNewBulkProductActionName(ticket.getId(), dto)); + creationDetails.setPartialSaveName(dto.getName()); + + updateTicket(creationDetails, ticket, dto); + return productSummary; + } + + /** + * Creates the product concepts in the ProductSummary that are new concepts and returns an updated + * ProductSummary with the new concepts. + * + * @param branch branch to write the changes to + * @param productCreationDetails ProductCreationDetails containing the concepts to create + * @return ProductSummary with the new concepts + */ + public ProductSummary createProductFromAtomicData( + String branch, @Valid ProductCreationDetails productCreationDetails) + throws InterruptedException { + + // validate the ticket exists + TicketDtoExtended ticket = ticketService.findTicket(productCreationDetails.getTicketId()); + + ProductSummary productSummary = productCreationDetails.getProductSummary(); + ProductSummary productSummaryClone = null; + try { + productSummaryClone = + objectMapper.readValue( + objectMapper.writeValueAsString(productSummary), ProductSummary.class); + } catch (JsonProcessingException jsonProcessingException) { + log.severe("Could not clone product summary - potentially missed ModifiedGeneratedNames"); + } + + if (productSummary.getNodes().stream().noneMatch(Node::isNewConcept)) { + throw new EmptyProductCreationProblem(); + } + + BidiMap idMap = create(branch, productSummary, true); + + if (productSummaryClone != null) { + modifiedGeneratedNameService.createAndSaveModifiedGeneratedNames( + productCreationDetails.getPackageDetails().getIdFsnMap(), + productSummaryClone, + branch, + idMap); + } + + ProductDto productDto = + ProductDto.builder() + .conceptId(productSummary.getSingleSubject().getConceptId()) + .packageDetails(productCreationDetails.getPackageDetails()) + .name( + productCreationDetails.getNameOverride() != null + ? productCreationDetails.getNameOverride() + : productSummary.getSingleSubject().getFullySpecifiedName()) + .build(); + updateTicket(productCreationDetails, ticket, productDto); + return productSummary; + } + + public SnowstormConceptMini createBrand( + String branch, @Valid BrandCreationRequest brandCreationRequest) throws InterruptedException { + + // Validate that the ticket exists + ticketService.findTicket(brandCreationRequest.getTicketId()); + + // Generate the fully specified name (FSN) for the brand + String semanticTag = fieldBindingConfiguration.getBrandSemanticTag(); + + String generatePT = generatePT(brandCreationRequest.getBrandName().trim(), semanticTag); + String generatedFsn = String.format("%s %s", generatePT, semanticTag); + + SnowstormConceptView createdConcept = + createPrimitiveConcept( + branch, + generatedFsn, + generatePT, + Set.of(getSnowstormRelationship(IS_A, PRODUCT_NAME, 0))); + + // put in the 929360021000036102 reference set + addToRefset(branch, createdConcept.getConceptId(), TP_REFSET_ID.getValue()); + return toSnowstormConceptMini(createdConcept); + } + + /** + * Generates a modified version of the preferred name by potentially removing the specified + * semantic tag from its end. + * + *

This method checks if the given name ends with the provided semantic tag. If it does, the + * method removes the last occurrence of the semantic tag from the name before returning the + * modified name. If the name does not end with the semantic tag, it remains unchanged. + * + * @param name The name to be processed, which may contain the semantic tag. + * @param semanticTag The semantic tag to check for at the end of the name. + * @return The modified name with the semantic tag removed if it was present at the end; + * otherwise, returns the original name unchanged. + */ + private String generatePT(String name, String semanticTag) { + if (name.endsWith(semanticTag)) { + int lastIndex = name.lastIndexOf(semanticTag); + name = name.substring(0, lastIndex) + name.substring(lastIndex + semanticTag.length()); + } + return name.trim(); + } + + private SnowstormConceptView createPrimitiveConcept( + String branch, String fsn, String pt, Set relationships) { + snowstormClient.checkForDuplicateFsn(fsn, branch); + // Create and configure the new Snowstorm concept + SnowstormConceptView newConcept = new SnowstormConceptView(); + newConcept.setActive(true); + newConcept.setDefinitionStatusId(PRIMITIVE.getValue()); + + // Add descriptions to the concept (synonym and fully specified name) + SnowstormDtoUtil.addDescription(newConcept, pt, SnomedConstants.SYNONYM.getValue()); + SnowstormDtoUtil.addDescription(newConcept, fsn, SnomedConstants.FSN.getValue()); + + // Add the axiom to the concept + SnowstormAxiom axiom = createPrimitiveAxiom(relationships); + newConcept.setClassAxioms(Set.of(axiom)); + + // Create the concept in Snowstorm and return the result + return snowstormClient.createConcept(branch, newConcept, false); + } + + private SnowstormAxiom createPrimitiveAxiom(Set relationships) { + SnowstormAxiom axiom = new SnowstormAxiom(); + axiom.active(true); + axiom.setModuleId(SCT_AU_MODULE.getValue()); // Noticed coming as null + axiom.setDefinitionStatusId(PRIMITIVE.getValue()); + axiom.setDefinitionStatus("PRIMITIVE"); + axiom.setRelationships(relationships); + axiom.setReleased(false); + return axiom; + } + + private void addToRefset(String branch, String conceptId, String refsetId) + throws InterruptedException { + List refsetMembers = new ArrayList<>(); + refsetMembers.add( + new SnowstormReferenceSetMemberViewComponent() + .active(true) + .refsetId(refsetId) + .referencedComponentId(conceptId) + .moduleId(SCT_AU_MODULE.getValue())); + + snowstormClient.createRefsetMembers(branch, refsetMembers); + } + + private BidiMap create( + String branch, ProductSummary productSummary, boolean singleSubject) + throws InterruptedException { + + Mono> taskChangedConceptIds = snowstormClient.getConceptIdsChangedOnTask(branch); + + Mono> projectChangedConceptIds = + snowstormClient.getConceptIdsChangedOnProject(branch); + + // tidy up selections + // remove any concept options - should all be empty in the response from this method + // if a concept is selected, removed new concept section + productSummary + .getNodes() + .forEach( + node -> { + if (node.getConceptOptions() != null) { + node.getConceptOptions().clear(); + } + if (node.getConcept() != null) { + node.setNewConceptDetails(null); + } + }); + + Set subjects = productSummary.calculateSubject(singleSubject); + + // This is really for the device scenario, where the user has selected an existing concept as + // the device type that isn't already an MP. + productSummary.getNodes().stream() + .filter( + n -> + !n.isNewConcept() + && n.getLabel().equals(ProductSummaryService.MP_LABEL) + && snowstormClient + .getConceptIdsFromEcl( + branch, "^" + MP_REFSET_ID + " AND " + n.getConceptId(), 0, 1) + .isEmpty()) + .forEach( + n -> + snowstormClient.createRefsetMembership( + branch, MP_REFSET_ID.getValue(), n.getConceptId(), true)); + + List nodeCreateOrder = + productSummary.getNodes().stream() + .filter(Node::isNewConcept) + .sorted(Node.getNodeComparator(productSummary.getNodes())) + .toList(); + + // Force Snowstorm to work from the ids rather than the SnowstormConceptMini objects + // which were added for diagramming + nodeCreateOrder.forEach( + n -> + n.getNewConceptDetails() + .getAxioms() + .forEach( + a -> + a.getRelationships() + .forEach( + r -> { + r.setTarget(null); + r.setType(null); + }))); + + if (log.isLoggable(Level.FINE)) { + log.fine( + "Creating concepts in order " + + nodeCreateOrder.stream() + .map(n -> n.getConceptId() + "_" + n.getLabel()) + .collect(Collectors.joining(", "))); + nodeCreateOrder.forEach( + n -> + n.getNewConceptDetails().getAxioms().stream() + .flatMap(a -> a.getRelationships().stream()) + .filter( + r -> + r.getConcreteValue() == null + && r.getDestinationId() != null + && Long.parseLong(r.getDestinationId()) < 0) + .forEach( + r -> + log.fine( + "Relationship " + n.getConceptId() + " -> " + r.getDestinationId()))); + } + + BidiMap idMap = new DualHashBidiMap<>(); + + createConcepts(branch, nodeCreateOrder, idMap); + + for (Edge edge : productSummary.getEdges()) { + if (idMap.containsKey(edge.getSource())) { + edge.setSource(idMap.get(edge.getSource())); + } + if (idMap.containsKey(edge.getTarget())) { + edge.setTarget(idMap.get(edge.getTarget())); + } + } + + productSummary.getSubjects().clear(); + productSummary.getSubjects().addAll(subjects); + + productSummary.updateNodeChangeStatus( + taskChangedConceptIds.block(), projectChangedConceptIds.block()); + + return idMap; + } + + private void createConcepts(String branch, List nodeCreateOrder, Map idMap) + throws InterruptedException { + Deque preallocatedIdentifiers = new ArrayDeque<>(); + + if (identifierSource.isReservationAvailable()) { + int namespace = getNamespace(branch); + + log.fine("Reserving identifiers for new concepts, namespace is " + namespace); + try { + preallocatedIdentifiers.addAll( + identifierSource + .reserveIds( + namespace, + NamespaceConfiguration.getConceptPartitionId(namespace), + nodeCreateOrder.stream() + .filter(n -> n.getNewConceptDetails().getSpecifiedConceptId() == null) + .toList() + .size()) + .stream() + .map(String::valueOf) + .toList()); + } catch (LingoProblem e) { + log.log( + Level.SEVERE, + "Failed to reserve identifiers, falling back to sequential concept creation", + e); + preallocatedIdentifiers.clear(); + } + } + + if (log.isLoggable(Level.FINE)) { + log.fine("Preallocated identifiers " + String.join(",", preallocatedIdentifiers)); + } + + log.fine("Validating specified identifiers"); + // check if any concepts already exist if ids are specified + validateSpecifiedIdentifiers(branch, nodeCreateOrder); + + log.fine("Preparing concepts"); + boolean bulkCreate = !preallocatedIdentifiers.isEmpty(); + // set up the concepts to create + List concepts = new ArrayList<>(); + for (Node node : nodeCreateOrder) { + SnowstormConceptView concept = SnowstormDtoUtil.toSnowstormConceptView(node); + + updateAxiomIdentifierReferences(idMap, concept); + + String conceptId = node.getNewConceptDetails().getConceptId().toString(); + if (Long.parseLong(conceptId) < 0) { + if (!bulkCreate) { + log.warning("Creating concept sequentially - this will be slow"); + concept.setConceptId(node.getNewConceptDetails().getSpecifiedConceptId()); + concept = snowstormClient.createConcept(branch, concept, false); + } else if (node.getNewConceptDetails().getSpecifiedConceptId() != null) { + concept.setConceptId(node.getNewConceptDetails().getSpecifiedConceptId()); + } else { + concept.setConceptId(preallocatedIdentifiers.pop()); + log.fine("Allocated identifier " + concept.getConceptId() + " for " + conceptId); + } + idMap.put(conceptId, concept.getConceptId()); + } else { + throw new ProductAtomicDataValidationProblem( + "Concept id must be negative for new concepts, found " + conceptId); + } + + concepts.add(concept); + } + + assertNoRemainingReferencesToPlaceholderDestinationIds(nodeCreateOrder, idMap, concepts); + + log.fine("Concepts prepared, creating concepts"); + + List createdConcepts; + + if (bulkCreate) { + log.info("Creating " + concepts.size() + " concepts with preallocated identifiers"); + createdConcepts = snowstormClient.createConcepts(branch, concepts); + } else { + createdConcepts = concepts.stream().map(SnowstormDtoUtil::toSnowstormConceptMini).toList(); + } + + log.fine("Concepts created"); + + Map conceptMap = + createdConcepts.stream() + .collect(Collectors.toMap(SnowstormConceptMini::getConceptId, c -> c)); + + nodeCreateOrder.forEach( + node -> { + String allocatedIdentifier = + idMap.get(node.getNewConceptDetails().getConceptId().toString()); + node.setConcept(conceptMap.get(allocatedIdentifier)); + }); + + createRefsetMemberships(branch, nodeCreateOrder); + + nodeCreateOrder.forEach( + n -> { + if (n.getLabel().equals(CTPP_LABEL) + && n.getNewConceptDetails() != null) { // populate external identifiers in response + n.getExternalIdentifiers() + .addAll( + getExternalIdentifierReferenceSet( + n.getNewConceptDetails().getReferenceSetMembers(), n.getConceptId())); + } + n.setNewConceptDetails(null); + }); + + log.fine("Concepts created and refset members created"); + } + + public List createRefsetMemberships(String branch, List nodeCreateOrder) + throws InterruptedException { + log.fine("Creating refset members"); + List referenceSetMemberViewComponents = + nodeCreateOrder.stream() + .map( + n -> { + List refsetMembers = new ArrayList<>(); + refsetMembers.add( + new SnowstormReferenceSetMemberViewComponent() + .active(true) + .refsetId(getRefsetId(n.getLabel())) + .referencedComponentId(n.getConcept().getConceptId()) + .moduleId(SCT_AU_MODULE.getValue())); + + if (n.getNewConceptDetails().getReferenceSetMembers() != null) { + for (SnowstormReferenceSetMemberViewComponent member : + n.getNewConceptDetails().getReferenceSetMembers()) { + member.setReferencedComponentId(n.getConcept().getConceptId()); + refsetMembers.add(member); + } + } + return refsetMembers; + }) + .flatMap(Collection::stream) + .toList(); + + return snowstormClient.createRefsetMembers(branch, referenceSetMemberViewComponents); + } + + private int getNamespace(String branch) { + do { + Integer namespace = namespaceConfiguration.getNamespace(branch.replace("|", "_")); + if (namespace != null) { + return namespace; + } + branch = branch.substring(0, branch.lastIndexOf("|")); + } while (branch.contains("|")); + + Integer namespace = namespaceConfiguration.getNamespace(branch); + + if (namespace == null) { + throw new NamespaceNotConfiguredProblem(branch); + } + + return namespace; + } + + private void validateSpecifiedIdentifiers(String branch, List nodeCreateOrder) { + Set idsToCreate = + nodeCreateOrder.stream() + .map(n -> n.getNewConceptDetails().getSpecifiedConceptId()) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (!idsToCreate.isEmpty()) { + Collection existingConcepts = + snowstormClient.conceptIdsThatExist(branch, idsToCreate); + + if (!existingConcepts.isEmpty()) { + throw new ProductAtomicDataValidationProblem( + "Concepts with ids " + + String.join(", ", existingConcepts) + + " already exist, cannot create new concepts with the specified ids"); + } + } + } + + @SuppressWarnings("java:S1192") + private void updateTicket( + BulkProductAction creationDetails, TicketDto ticket, BulkProductActionDto dto) { + try { + ticketService.putBulkProductActionOnTicket(ticket.getId(), dto); + } catch (Exception e) { + String dtoString = null; + try { + dtoString = objectMapper.writeValueAsString(dto); + } catch (Exception ex) { + log.log(Level.SEVERE, "Failed to serialise dto", ex); + } + + log.log( + Level.SEVERE, + "Saving the bulk details failed after the product/s were created. " + + "Bulk details were not saved on the ticket, details were " + + dtoString, + e); + } + + if (creationDetails.getPartialSaveName() != null + && !creationDetails.getPartialSaveName().isEmpty()) { + try { + ticketService.deleteProduct(ticket.getId(), creationDetails.getPartialSaveName()); + } catch (ResourceNotFoundProblem p) { + log.warning( + "Partial save name " + + creationDetails.getPartialSaveName() + + " on ticket " + + ticket.getId() + + " could not be found to be deleted on product creation. " + + "Ignored to allow new product details to be saved to the ticket."); + } catch (Exception e) { + log.log( + Level.SEVERE, + "Delete of partial save name " + + creationDetails.getPartialSaveName() + + " on ticket " + + ticket.getId() + + " failed for new product creation. " + + "Ignored to allow new product details to be saved to the ticket.", + e); + } + } + } + + @SuppressWarnings("java:S1192") + private void updateTicket( + ProductCreationDetails productCreationDetails, + TicketDto ticket, + ProductDto productDto) { + try { + ticketService.putProductOnTicket(ticket.getId(), productDto); + } catch (Exception e) { + String dtoString = null; + try { + dtoString = objectMapper.writeValueAsString(productDto); + } catch (Exception ex) { + log.log(Level.SEVERE, "Failed to serialise productDto", ex); + } + + log.log( + Level.SEVERE, + "Saving the product details failed after the product was created. " + + "Product details were not saved on the ticket, details were " + + dtoString, + e); + } + + if (productCreationDetails.getPartialSaveName() != null + && !productCreationDetails.getPartialSaveName().isEmpty()) { + try { + ticketService.deleteProduct( + ticket.getId(), Long.parseLong(productCreationDetails.getPartialSaveName())); + } catch (ResourceNotFoundProblem p) { + log.warning( + "Partial save name " + + productCreationDetails.getPartialSaveName() + + " on ticket " + + ticket.getId() + + " could not be found to be deleted on product creation. " + + "Ignored to allow new product details to be saved to the ticket."); + } catch (Exception e) { + log.log( + Level.SEVERE, + "Delete of partial save name " + + productCreationDetails.getPartialSaveName() + + " on ticket " + + ticket.getId() + + " failed for new product creation. " + + "Ignored to allow new product details to be saved to the ticket.", + e); + } + } + } + + private String getRefsetId(String label) { + return switch (label) { + case MPP_LABEL -> MPP_REFSET_ID.getValue(); + case TPP_LABEL -> TPP_REFSET_ID.getValue(); + case CTPP_LABEL -> CTPP_REFSET_ID.getValue(); + case MP_LABEL -> MP_REFSET_ID.getValue(); + case MPUU_LABEL -> MPUU_REFSET_ID.getValue(); + case TPUU_LABEL -> TPUU_REFSET_ID.getValue(); + default -> throw new IllegalArgumentException("Unknown refset for label " + label); + }; + } +} diff --git a/api/src/main/java/com/csiro/snomio/service/ProductSummaryService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductSummaryService.java similarity index 85% rename from api/src/main/java/com/csiro/snomio/service/ProductSummaryService.java rename to api/src/main/java/au/gov/digitalhealth/lingo/service/ProductSummaryService.java index cb6c3f286..9d12083fa 100644 --- a/api/src/main/java/com/csiro/snomio/service/ProductSummaryService.java +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductSummaryService.java @@ -1,8 +1,27 @@ -package com.csiro.snomio.service; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; -import com.csiro.snomio.product.Edge; -import com.csiro.snomio.product.Node; -import com.csiro.snomio.product.ProductSummary; +import static au.gov.digitalhealth.lingo.util.AmtConstants.ARTGID_REFSET; +import static au.gov.digitalhealth.lingo.util.ExternalIdentifierUtils.getExternalIdentifierReferences; + +import au.csiro.snowstorm_client.model.SnowstormReferenceSetMember; +import au.gov.digitalhealth.lingo.product.Edge; +import au.gov.digitalhealth.lingo.product.Node; +import au.gov.digitalhealth.lingo.product.ProductSummary; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -11,8 +30,8 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import lombok.extern.java.Log; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** Service for product-centric operations */ @@ -47,7 +66,6 @@ public class ProductSummaryService { private final SnowstormClient snowStormApiClient; private final NodeGeneratorService nodeGeneratorService; - @Autowired ProductSummaryService( SnowstormClient snowStormApiClient, NodeGeneratorService nodeGeneratorService) { this.snowStormApiClient = snowStormApiClient; @@ -130,6 +148,33 @@ public ProductSummary getProductSummary(String branch, String productId) { productSummary.updateNodeChangeStatus( taskChangedConceptIds.block(), projectChangedConceptIds.block()); + // Extract CTPP concept IDs from product summary nodes and store them in a Set + Set ctppIds = + productSummary.getNodes().stream() + .filter(n -> n.getLabel().equals(CTPP_LABEL)) // Filter nodes by CTPP label + .map(Node::getConceptId) // Get concept ID from each node + .collect(Collectors.toSet()); // Collect concept IDs into a Set + + // Fetch ARTG reference set members based on the extracted CTPP concept IDs + Flux artgRefsetMembers = + snowStormApiClient + .getRefsetMembers(branch, ctppIds, ARTGID_REFSET.getValue(), 0, 100) + .map(r -> r.getItems()) + .flatMapIterable(c -> c); + + // Iterate over the product summary nodes and update reference set members for CTPP nodes + productSummary + .getNodes() + .forEach( + node -> { + if (node.getLabel().equals(CTPP_LABEL)) { // Check if the node has the CTPP label + // Filter ARTG refset members for the current node and collect into a Set + node.getExternalIdentifiers() + .addAll( + getExternalIdentifierReferences(artgRefsetMembers, node.getConceptId())); + } + }); + log.info("Done product model for " + productId + " on branch " + branch); return productSummary; } @@ -150,9 +195,10 @@ CompletableFuture addConceptsAndRelationshipsForProduct( .thenApply( c -> { productSummary.addNode(c); - if (productSummary.getSubject() == null) { + if (productSummary.getSubjects() == null + || productSummary.getSubjects().isEmpty()) { // set this for the first, outermost CTPP - productSummary.setSubject(c); + productSummary.setSingleSubject(c); } return c; }); diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductUpdateService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductUpdateService.java new file mode 100644 index 000000000..2a1d4ab08 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/ProductUpdateService.java @@ -0,0 +1,504 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import static au.gov.digitalhealth.lingo.service.AtomicDataService.MAP_TARGET; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.*; + +import au.csiro.snowstorm_client.model.*; +import au.gov.digitalhealth.lingo.configuration.FieldBindingConfiguration; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.product.bulk.ProductUpdateCreationDetails; +import au.gov.digitalhealth.lingo.product.details.ExternalIdentifier; +import au.gov.digitalhealth.lingo.product.update.ProductDescriptionUpdateRequest; +import au.gov.digitalhealth.lingo.product.update.ProductExternalIdentifierUpdateRequest; +import au.gov.digitalhealth.lingo.product.update.ProductUpdateRequest; +import au.gov.digitalhealth.lingo.product.update.ProductUpdateState; +import au.gov.digitalhealth.lingo.util.AmtConstants; +import au.gov.digitalhealth.lingo.util.SnowstormDtoUtil; +import au.gov.digitalhealth.tickets.models.BulkProductAction; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.repository.BulkProductActionRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import au.gov.digitalhealth.tickets.service.TicketServiceImpl; +import jakarta.validation.Valid; +import java.time.Instant; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +@Log +@Service +public class ProductUpdateService { + + SnowstormClient snowstormClient; + + TicketServiceImpl ticketService; + + TicketRepository ticketRepository; + FieldBindingConfiguration fieldBindingConfiguration; + + BulkProductActionRepository bulkProductActionRepository; + + public ProductUpdateService( + SnowstormClient snowstormClient, + TicketServiceImpl ticketService, + FieldBindingConfiguration fieldBindingConfiguration, + TicketRepository ticketRepository, + BulkProductActionRepository bulkProductActionRepository) { + this.snowstormClient = snowstormClient; + + this.ticketService = ticketService; + this.fieldBindingConfiguration = fieldBindingConfiguration; + this.ticketRepository = ticketRepository; + this.bulkProductActionRepository = bulkProductActionRepository; + } + + public BulkProductAction updateProduct( + String branch, String productId, @Valid ProductUpdateRequest productUpdateRequest) + throws InterruptedException { + + String conceptId = productUpdateRequest.getConceptId(); + log.info(String.format("Product update for %s commencing", conceptId)); + ProductDescriptionUpdateRequest productDescriptionUpdateRequest = + productUpdateRequest.getDescriptionUpdate(); + ProductExternalIdentifierUpdateRequest productExternalIdentifierUpdateRequest = + productUpdateRequest.getExternalRequesterUpdate(); + Ticket ticket = ticketRepository.findById(productUpdateRequest.getTicketId()).orElseThrow(); + String fsn = + getFsnFromDescriptions(productDescriptionUpdateRequest.getDescriptions()).getTerm(); + BulkProductAction productUpdate = BulkProductAction.builder().ticket(ticket).name(fsn).build(); + ProductUpdateState historicState = ProductUpdateState.builder().build(); + ProductUpdateState updateState = ProductUpdateState.builder().build(); + ProductUpdateCreationDetails productUpdateCreationDetails = + ProductUpdateCreationDetails.builder() + .productId(productId) + .historicState(historicState) + .updatedState(updateState) + .build(); + + updateProductDescriptions( + branch, conceptId, productDescriptionUpdateRequest, productUpdateCreationDetails); + + if (productExternalIdentifierUpdateRequest != null) { + log.info(String.format("Product update for %s contains ARTGIDS.", conceptId)); + updateProductExternalIdentifiers( + branch, conceptId, productExternalIdentifierUpdateRequest, productUpdateCreationDetails); + } + + productUpdate.setDetails(productUpdateCreationDetails); + + Optional existingBulkProductAction = + bulkProductActionRepository.findByNameAndTicketId( + productUpdate.getName(), productUpdateRequest.getTicketId()); + if (existingBulkProductAction.isPresent()) { + productUpdate.setName(productUpdate.getName() + Instant.now().toString()); + } + log.info( + String.format( + "Product description update for %s saving on ticket %s", conceptId, ticket.getId())); + return bulkProductActionRepository.save(productUpdate); + } + + public ProductUpdateCreationDetails updateProductDescriptions( + String branch, + String conceptId, + @Valid ProductDescriptionUpdateRequest productDescriptionUpdateRequest, + ProductUpdateCreationDetails productUpdateCreationDetails) { + + SnowstormConcept existingConceptView = fetchBrowserConcept(branch, conceptId); + existingConceptView.definitionStatusId( + mapFromDefinitionStatusToId( + Objects.requireNonNull(existingConceptView.getDefinitionStatus()))); + + productUpdateCreationDetails.getHistoricState().setConcept(existingConceptView); + + Map retireReplaceDescriptions = + getDescriptionsNeedingRetireReplace(existingConceptView, productDescriptionUpdateRequest); + + if (!retireReplaceDescriptions.isEmpty()) { + log.info( + String.format("Product description update for %s requires retire/replace", conceptId)); + // Create a map for quick lookup of keys by descriptionId + Map keyDescriptionsById = + retireReplaceDescriptions.keySet().stream() + .filter(desc -> desc.getDescriptionId() != null) + .collect( + Collectors.toMap(SnowstormDescription::getDescriptionId, Function.identity())); + + // Create a new set to hold the updated descriptions + Set updatedDescriptions = new HashSet<>(); + + // Process each description in the request + for (SnowstormDescription description : productDescriptionUpdateRequest.getDescriptions()) { + String descriptionId = description.getDescriptionId(); + + // If this matches a key in retireReplaceDescriptions, replace it + if (descriptionId != null && keyDescriptionsById.containsKey(descriptionId)) { + // Add the original description from the key set + updatedDescriptions.add(keyDescriptionsById.get(descriptionId)); + + // Find the corresponding value + SnowstormDescription valueDescription = null; + for (Map.Entry entry : + retireReplaceDescriptions.entrySet()) { + if (descriptionId.equals(entry.getKey().getDescriptionId())) { + valueDescription = entry.getValue(); + break; + } + } + + // Create a new description based on the value with type SYNONYM + if (valueDescription != null) { + SnowstormDescription synonymDesc = new SnowstormDescription(); + // Copy all properties from valueDescription + BeanUtils.copyProperties(valueDescription, synonymDesc); + // Set type to SYNONYM + synonymDesc.setType("SYNONYM"); + synonymDesc.setDescriptionId(null); + synonymDesc.setReleased(false); + synonymDesc.setTypeId("900000000000013009"); + // Add the new SYNONYM description + updatedDescriptions.add(synonymDesc); + } + } else { + // Keep descriptions that don't need replacement + updatedDescriptions.add(description); + } + } + + // Update the request with the modified descriptions + productDescriptionUpdateRequest.setDescriptions(updatedDescriptions); + } + + SnowstormConcept conceptsNeedUpdate = + prepareConceptUpdate(existingConceptView, productDescriptionUpdateRequest, branch); + + productUpdateCreationDetails.getUpdatedState().setConcept(conceptsNeedUpdate); + + boolean areDescriptionsModified = + productDescriptionUpdateRequest.areDescriptionsModified( + existingConceptView.getDescriptions()); + + if (!areDescriptionsModified) { + return productUpdateCreationDetails; + } + + if (conceptsNeedUpdate != null) { + try { + log.info( + String.format( + "Product description update for %s initial description update commencing", + conceptId)); + SnowstormConcept response = + snowstormClient.updateConcept( + branch, conceptsNeedUpdate.getConceptId(), conceptsNeedUpdate, false); + log.info( + String.format( + "Product description update for %s initial description update completed", + conceptId)); + productUpdateCreationDetails.getUpdatedState().setConcept(response); + + if (!retireReplaceDescriptions.isEmpty()) { + + Set descriptionsWithRetireReplaceCompleted = new HashSet<>(); + response + .getDescriptions() + .forEach( + desc -> { + Optional descriptionForRetirement = + retireReplaceDescriptions.keySet().stream() + .filter( + key -> + key.getDescriptionId() != null + && key.getDescriptionId().equals(desc.getDescriptionId())) + .findFirst(); + + Optional isAReplacementDescription = + retireReplaceDescriptions.values().stream() + .filter( + key -> + key.getTerm() != null && key.getTerm().equals(desc.getTerm())) + .findFirst(); + + if (!descriptionForRetirement.isEmpty()) { + // Get the matching key and its corresponding value from + // retireReplaceDescriptions + SnowstormDescription keyDescription = descriptionForRetirement.get(); + SnowstormDescription valueDescription = + retireReplaceDescriptions.get(keyDescription); + + // Find the description in response that has the same term as valueDescription + Optional matchingValueDesc = + response.getDescriptions().stream() + .filter( + responseDesc -> + valueDescription.getTerm() != null + && valueDescription + .getTerm() + .equals(responseDesc.getTerm())) + .findFirst(); + + if (matchingValueDesc.isPresent()) { + // add the replaced by + Map> replacedBy = new HashMap<>(); + Set replacementIds = new HashSet<>(); + SnowstormDescription matchingDesc = matchingValueDesc.get(); + replacementIds.add( + matchingDesc.getDescriptionId()); // Add the replacement ID + + replacedBy.put("REPLACED_BY", replacementIds); + + // Set the association targets on the description + SnowstormDescription unwrappedDescriptionForRetirement = + SnowstormDtoUtil.cloneSnowstormDescription( + descriptionForRetirement.get()); + unwrappedDescriptionForRetirement.setInactivationIndicator("OUTDATED"); + unwrappedDescriptionForRetirement.setAssociationTargets(replacedBy); + unwrappedDescriptionForRetirement.setActive(false); + unwrappedDescriptionForRetirement.setAcceptabilityMap(new HashMap<>()); + + // Add to the completed set + descriptionsWithRetireReplaceCompleted.add( + unwrappedDescriptionForRetirement); + } else { + // If no matching term found, add the original description + descriptionsWithRetireReplaceCompleted.add(desc); + } + } else if (isAReplacementDescription.isPresent()) { + SnowstormDescription replacement = isAReplacementDescription.get(); + replacement.setDescriptionId(desc.getDescriptionId()); + replacement.setReleased(false); + descriptionsWithRetireReplaceCompleted.add(replacement); + } else { + descriptionsWithRetireReplaceCompleted.add(desc); + } + }); + + response.setDescriptions(descriptionsWithRetireReplaceCompleted); + response.setDefinitionStatusId( + mapFromDefinitionStatusToId(Objects.requireNonNull(response.getDefinitionStatus()))); + log.info( + String.format( + "Product description update for %s retire/replace description update commencing", + conceptId)); + SnowstormConcept response2 = + snowstormClient.updateConcept( + branch, conceptsNeedUpdate.getConceptId(), response, false); + log.info( + String.format( + "Product description update for %s retire/replace description update completed", + conceptId)); + productUpdateCreationDetails.getUpdatedState().setConcept(response2); + } + } catch (WebClientResponseException ex) { + + String errorBody = ex.getResponseBodyAsString(); + + throw new ProductAtomicDataValidationProblem(String.format("%s", errorBody)); + } + } + + return productUpdateCreationDetails; + } + + private SnowstormConcept prepareConceptUpdate( + SnowstormConcept existingConcept, + ProductDescriptionUpdateRequest productDescriptionUpdateRequest, + String branch) { + if (productDescriptionUpdateRequest == null) return null; + + SnowstormConcept conceptNeedToUpdate = SnowstormDtoUtil.cloneConcept(existingConcept); + + String fsn = + SnowstormDtoUtil.getFsnFromDescriptions(productDescriptionUpdateRequest.getDescriptions()) + .getTerm(); + String existingFsn = conceptNeedToUpdate.getFsn().getTerm(); + if (fsn != null && existingFsn != null && !existingFsn.equals(fsn.trim())) { + String newFsn = fsn.trim(); + + snowstormClient.checkForDuplicateFsn(newFsn, branch); + } + + conceptNeedToUpdate.setDescriptions(productDescriptionUpdateRequest.getDescriptions()); + + return conceptNeedToUpdate; + } + + private Map getDescriptionsNeedingRetireReplace( + SnowstormConcept existingConcept, + ProductDescriptionUpdateRequest productDescriptionUpdateRequest) { + if (productDescriptionUpdateRequest == null || existingConcept == null) { + return Collections.emptyMap(); + } + + Map existingDescriptionsById = + existingConcept.getDescriptions().stream() + .collect(Collectors.toMap(SnowstormDescription::getDescriptionId, Function.identity())); + + Map descriptionsNeedingUpdate = new HashMap<>(); + + for (SnowstormDescription newDescription : productDescriptionUpdateRequest.getDescriptions()) { + String descriptionId = newDescription.getDescriptionId(); + if (descriptionId != null && existingDescriptionsById.containsKey(descriptionId)) { + SnowstormDescription oldDescription = existingDescriptionsById.get(descriptionId); + + // Check if both are released and term has changed + if (oldDescription != null + && newDescription != null + && Boolean.TRUE.equals(oldDescription.getReleased()) + && Boolean.TRUE.equals(newDescription.getReleased()) + && !Objects.equals(oldDescription.getTerm(), newDescription.getTerm())) { + + descriptionsNeedingUpdate.put(oldDescription, newDescription); + } + } + } + + return descriptionsNeedingUpdate; + } + + public SnowstormConcept fetchBrowserConcept(String branch, String conceptId) { + return snowstormClient.getBrowserConcept(branch, conceptId).block(); + } + + public Set updateProductExternalIdentifiers( + String branch, + String conceptId, + @Valid ProductExternalIdentifierUpdateRequest productExternalIdentifierUpdateRequest, + ProductUpdateCreationDetails productUpdateCreationDetails) + throws InterruptedException { + Set externalIdentifiers = + productExternalIdentifierUpdateRequest.getExternalIdentifiers(); + + // Prepare collections for changes + Set artgToBeRemoved = new HashSet<>(); + Set artgToBeAdded = new HashSet<>(); + + // Fetch existing ARTG reference set members + List existingMembers = + snowstormClient.getArtgMembers(branch, Set.of(conceptId)); + + Set existingIdentifiers = getExternalIdentifiers(branch, conceptId); + + productUpdateCreationDetails.getHistoricState().setExternalIdentifiers(existingIdentifiers); + if (existingMembers == null || existingMembers.isEmpty()) { + // Add all externalIdentifiers as new members if no existing members + externalIdentifiers.forEach( + identifier -> + artgToBeAdded.add( + createSnowstormReferenceSetMemberViewComponent(identifier, conceptId))); + } else { + // Process external identifiers + processExternalIdentifiers( + existingMembers, externalIdentifiers, conceptId, artgToBeAdded, artgToBeRemoved); + } + + // Apply changes + + if (!artgToBeAdded.isEmpty()) { + log.info( + String.format( + "Product update for %s needs adding of %s ARTGIDS.", + conceptId, List.copyOf(artgToBeAdded))); + snowstormClient.createRefsetMembers(branch, List.copyOf(artgToBeAdded)); + } + + // Remove outdated ARTG members + if (!artgToBeRemoved.isEmpty()) { + Set memberIdsToRemove = + artgToBeRemoved.stream() + .map(SnowstormReferenceSetMember::getMemberId) + .collect(Collectors.toSet()); + log.info( + String.format( + "Product update for %s needs removing of %s ARTGIDS.", + conceptId, List.copyOf(artgToBeRemoved))); + snowstormClient.removeRefsetMembers(branch, artgToBeRemoved); + } + productUpdateCreationDetails.getUpdatedState().setExternalIdentifiers(externalIdentifiers); + return externalIdentifiers; + } + + public Set getExternalIdentifiers(String branch, String productId) { + List existingMembers = + snowstormClient.getArtgMembers(branch, Set.of(productId)); + + Set existingIdentifiers = + existingMembers.stream() + .map( + referenceSetMember -> { + return ExternalIdentifier.builder() + .identifierScheme(AmtConstants.ARTGID_SCHEME.toString()) + .identifierValue(referenceSetMember.getAdditionalFields().get("mapTarget")) + .build(); + }) + .collect(Collectors.toSet()); + + return existingIdentifiers; + } + + private void processExternalIdentifiers( + List existingMembers, + Set externalIdentifiers, + String productId, + Set artgToBeAdded, + Set artgToBeRemoved) { + + Set existingMapTargets = + existingMembers.stream() + .map(member -> member.getAdditionalFields().get(MAP_TARGET)) + .collect(Collectors.toSet()); + + // Identify ARTG to be added + externalIdentifiers.stream() + .filter(identifier -> shouldAddArtg(existingMapTargets, identifier)) + .forEach( + identifier -> + artgToBeAdded.add( + createSnowstormReferenceSetMemberViewComponent(identifier, productId))); + + // Identify ARTG to be removed + existingMembers.stream() + .filter(member -> shouldRemoveArtg(member, externalIdentifiers)) + .forEach(artgToBeRemoved::add); + } + + private boolean shouldAddArtg(Set existingMapTargets, ExternalIdentifier newArtg) { + return !existingMapTargets.contains(newArtg.getIdentifierValue()); + } + + private boolean shouldRemoveArtg( + SnowstormReferenceSetMember existingMember, Set externalIdentifiers) { + String existingTarget = existingMember.getAdditionalFields().get(MAP_TARGET); + return externalIdentifiers.stream() + .noneMatch(identifier -> identifier.getIdentifierValue().equals(existingTarget)); + } + + private String mapFromDefinitionStatusToId(String definitionStatus) { + if (definitionStatus.equals("PRIMITIVE")) { + return "900000000000074008"; + } + if (definitionStatus.equals("FULLY_DEFINED")) { + return "900000000000073002"; + } + return null; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/SergioService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/SergioService.java new file mode 100644 index 000000000..3c6ced20d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/SergioService.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.gov.digitalhealth.lingo.auth.exception.AuthenticationProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.TicketDto; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Service +public class SergioService { + + private final WebClient sergioApiClient; + + public SergioService(@Qualifier("sergioApiClient") WebClient sergioApiClient) { + this.sergioApiClient = sergioApiClient; + } + + public TicketDto getTicketByArtgEntryId(Long artgEntryId) throws AccessDeniedException { + return sergioApiClient + .get() + .uri(String.format("/api/artgid/%s", artgEntryId)) + .retrieve() + .onStatus( + // Handle specific status codes here + status -> status.equals(HttpStatus.FORBIDDEN) || status.equals(HttpStatus.BAD_REQUEST), + response -> handleError(response, artgEntryId)) + .bodyToMono(TicketDto.class) + .block(); + } + + private Mono handleError(ClientResponse response, Long artgEntryId) { + HttpStatus status = (HttpStatus) response.statusCode(); + if (status.equals(HttpStatus.FORBIDDEN)) { + throw new AuthenticationProblem("Incorrect cookie supplied"); + } else if (status.equals(HttpStatus.BAD_REQUEST)) { + throw new ResourceNotFoundProblem( + String.format("Cannot find artgEntry for id: %s", artgEntryId)); + } else { + // Handle other status codes if needed + return Mono.error(new RuntimeException("Unexpected error occurred")); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/TaskManagerClient.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/TaskManagerClient.java new file mode 100644 index 000000000..043d4a54c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/TaskManagerClient.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.gov.digitalhealth.lingo.configuration.CachingConfig; +import au.gov.digitalhealth.lingo.service.ServiceStatus.Status; +import au.gov.digitalhealth.lingo.util.CacheConstants; +import au.gov.digitalhealth.lingo.util.ClientHelper; +import au.gov.digitalhealth.lingo.util.Task; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import java.util.Arrays; +import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +public class TaskManagerClient { + + private final WebClient authoringPlatformApiClient; + + private final WebClient defaultAuthoringPlatformApiClient; + + private final CachingConfig cachingConfig; + + @Value("${ihtsdo.ap.projectKey}") + String apProject; + + public TaskManagerClient( + @Qualifier("authoringPlatformApiClient") WebClient authoringPlatformApiClient, + @Qualifier("defaultAuthoringPlatformApiClient") WebClient defaultAuthoringPlatformApiClient, + CachingConfig cachingConfig) { + this.authoringPlatformApiClient = authoringPlatformApiClient; + this.defaultAuthoringPlatformApiClient = defaultAuthoringPlatformApiClient; + this.cachingConfig = cachingConfig; + } + + public JsonArray getUserTasks() throws AccessDeniedException { + String json = + authoringPlatformApiClient + .get() + .uri("/projects/my-tasks?excludePromoted=false") + .retrieve() + .bodyToMono(String.class) // TODO May be change to actual objects? + .block(); + return new Gson().fromJson(json, JsonArray.class); // //TODO Serialization Bean? + } + + @SuppressWarnings("java:S1192") + @Cacheable(cacheNames = CacheConstants.ALL_TASKS_CACHE) + public List getAllTasks() throws AccessDeniedException { + + Task[] tasks = + authoringPlatformApiClient + .get() + .uri("/projects/" + apProject + "/tasks?lightweight=false") + .retrieve() + .bodyToMono(Task[].class) + .block(); + return Arrays.asList(tasks); + } + + public List getAllTasksOverProject() throws AccessDeniedException { + + Task[] tasks = + defaultAuthoringPlatformApiClient + .get() + .uri("/projects/tasks/search?criteria=" + apProject) + .retrieve() + .bodyToMono(Task[].class) + .block(); + return Arrays.asList(tasks); + } + + public Task getTaskByKey(String branch, String key) throws AccessDeniedException { + return authoringPlatformApiClient + .get() + .uri("/projects/" + branch + "/tasks/" + key) + .retrieve() + .bodyToMono(Task.class) // TODO May be change to actual objects? + .block(); + } + + public List getTaskByKeyOverProjects(String key) throws AccessDeniedException { + // should actually only ever be 1 + Task[] tasks = + defaultAuthoringPlatformApiClient + .get() + .uri("/projects/tasks/search?criteria=" + key + "&lightweight=false") + .retrieve() + .bodyToMono(Task[].class) + .block(); + return Arrays.asList(tasks); + } + + public Task createTask(Task task) throws AccessDeniedException { + Task createdTask = + authoringPlatformApiClient + .post() + .uri("/projects/" + task.getProjectKey() + "/tasks") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(task) + .retrieve() + .bodyToMono(Task.class) + .block(); + if (createdTask != null) { + cachingConfig.refreshAllTasksCache(); // evict cache + } + return createdTask; + } + + @Cacheable(cacheNames = CacheConstants.AP_STATUS_CACHE) + public Status getStatus() throws AccessDeniedException { + return ClientHelper.getStatus(authoringPlatformApiClient, "package_version"); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/TaskManagerService.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/TaskManagerService.java new file mode 100644 index 000000000..1c47ea6ea --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/TaskManagerService.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service; + +import au.gov.digitalhealth.lingo.auth.model.ImsUser; +import au.gov.digitalhealth.lingo.exception.OwnershipProblem; +import au.gov.digitalhealth.lingo.exception.TaskActionsLockedProblem; +import au.gov.digitalhealth.lingo.util.Task; +import au.gov.digitalhealth.lingo.util.Task.Status; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class TaskManagerService { + + private final TaskManagerClient taskManagerClient; + + @Value("${spring.profiles.active}") + private String activeProfile; + + public TaskManagerService(@Autowired TaskManagerClient taskManagerClient) { + this.taskManagerClient = taskManagerClient; + } + + public void validateTaskState(String branch) { + if (activeProfile.equals("test")) { + return; + } + String[] parts = branch.split("\\|"); + + String branchPath = parts[parts.length - 2]; + String key = parts[parts.length - 1]; + Task task = taskManagerClient.getTaskByKey(branchPath, key); + ImsUser imsUser = + (ImsUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!task.getAssignee().getUsername().equals(imsUser.getLogin())) { + throw new OwnershipProblem("The task is not owned by the user."); + } + if (task.getStatus() != null && task.getStatus().equals(Status.PROMOTED)) { + throw new TaskActionsLockedProblem("Task has been promoted. No further changes allowed."); + } + if (task.getLatestClassificationJson() != null + && task.getLatestClassificationJson().getStatus() != null + && task.getLatestClassificationJson().getStatus().equalsIgnoreCase("RUNNING")) { + throw new TaskActionsLockedProblem("Classification is running"); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/CachingIdentifierSource.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/CachingIdentifierSource.java new file mode 100644 index 000000000..f50aee2ea --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/CachingIdentifierSource.java @@ -0,0 +1,168 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier; + +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.service.ServiceStatus.Status; +import au.gov.digitalhealth.lingo.service.identifier.cis.CISClient; +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.java.Log; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@Log +public class CachingIdentifierSource implements IdentifierSource { + private final Map, IdentifierCache> reservedIds = new HashMap<>(); + + @Value("${cis.api.url}") + String cisApiUrl; + + @Value("${cis.username}") + String username; + + @Value("${cis.password}") + String password; + + @Value("${cis.softwareName}") + String softwareName; + + @Value("${cis.timeout:20}") + int timeoutSeconds; + + @Value("${cis.cache.size:50}") + int cacheSize; + + @Value("${cis.cache.refill.threshold:0.2}") + float refillThreshold; + + @Value("${cis.cache.precreate}") + List precreate = new ArrayList<>(); + + @Value("${cis.backoff.levels:30,60,180,300,600,1800}") + List backoffLevels = new ArrayList<>(); + + private IdentifierSource identifierSource = null; + + @PostConstruct + public void init() throws InterruptedException { + log.info("Initialising CachingIdentifierSource"); + if (cisApiUrl != null && !cisApiUrl.isBlank() && !cisApiUrl.equals("local")) { + identifierSource = + new CISClient(cisApiUrl, username, password, softwareName, timeoutSeconds, backoffLevels); + + precreate.forEach( + cacheKey -> { + String[] parts = cacheKey.split(":"); + int namespace = Integer.parseInt(parts[0]); + String partitionId = parts[1]; + reservedIds.put( + Pair.of(namespace, partitionId), + new IdentifierCache( + namespace, partitionId, cacheSize, refillThreshold, identifierSource)); + }); + + if (identifierSource.isReservationAvailable()) { + topUp(); + + log.info( + "CachingIdentifierSource initialised with " + + reservedIds.keySet().stream() + .map(k -> k.getLeft() + ":" + k.getRight()) + .collect(Collectors.joining(", ")) + + " caches preloaded with " + + cacheSize + + " identifiers."); + } else { + log.warning( + "CIS client not available, configured but identifiers are not reserved " + + "and identifier allocation not available"); + } + + } else { + log.warning("CIS client not available, identifier allocation not available"); + } + } + + @Scheduled(fixedDelayString = "${cis.cache.topup.interval:10000}") + public void topUp() throws InterruptedException { + if (identifierSource != null && identifierSource.isReservationAvailable()) { + log.fine("Topping up identifier caches"); + for (IdentifierCache cache : reservedIds.values()) { + try { + cache.topUp(); + } catch (LingoProblem e) { + log.severe( + "Error topping up cache " + + cache.getNamespaceId() + + " " + + cache.getPartitionId() + + ": " + + e.getMessage()); + } + } + } + } + + @Override + public Status getStatus() { + return identifierSource == null + ? Status.builder().running(false).version("CIS not configured").build() + : identifierSource.getStatus(); + } + + @Override + public boolean isReservationAvailable() { + return identifierSource != null && identifierSource.isReservationAvailable(); + } + + @Override + public List reserveIds(int namespace, String partitionId, int quantity) + throws LingoProblem, InterruptedException { + if (identifierSource == null) { + throw new LingoProblem( + "identifier-service", + "Identifier allocation not available", + HttpStatus.INTERNAL_SERVER_ERROR, + "CIS client not available."); + } + + Pair key = Pair.of(namespace, partitionId); + + IdentifierCache cache = + reservedIds.computeIfAbsent( + key, + k -> + new IdentifierCache( + namespace, partitionId, quantity, refillThreshold, identifierSource)); + + List identifiers = new ArrayList<>(quantity); + + for (int i = 0; i < quantity; i++) { + identifiers.add(cache.getIdentifier()); + } + + return identifiers; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/IdentifierCache.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/IdentifierCache.java new file mode 100644 index 000000000..37765b66d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/IdentifierCache.java @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier; + +import java.util.Deque; +import java.util.NoSuchElementException; +import java.util.concurrent.ConcurrentLinkedDeque; +import lombok.Getter; +import lombok.extern.java.Log; + +@Getter +@Log +public class IdentifierCache { + @Getter private final int namespaceId; + @Getter private final String partitionId; + @Getter private final int maxCapacity; + @Getter private final float refilThreshold; + + private final IdentifierSource source; + + private final Deque identifiers = new ConcurrentLinkedDeque<>(); + + IdentifierCache( + int namespaceId, + String partitionId, + int maxCapacity, + float refilThreshold, + IdentifierSource source) { + this.namespaceId = namespaceId; + this.partitionId = partitionId; + this.maxCapacity = maxCapacity; + this.refilThreshold = refilThreshold; + this.source = source; + } + + public void topUp() throws InterruptedException { + log.fine("Top up identifiers in cache " + namespaceId + " " + partitionId); + if (identifiers.size() < (maxCapacity / refilThreshold)) { + int quantity = maxCapacity - identifiers.size(); + log.fine( + "Reserving " + + quantity + + "more identifiers for cache " + + namespaceId + + " " + + partitionId); + identifiers.addAll(source.reserveIds(namespaceId, partitionId, quantity)); + } + } + + public Long getIdentifier() throws InterruptedException { + Long identifier = null; + try { + identifier = identifiers.pop(); + } catch (NoSuchElementException e) { + topUp(); + identifier = identifiers.pop(); + } + return identifier; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/IdentifierSource.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/IdentifierSource.java new file mode 100644 index 000000000..5e00c9a8e --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/IdentifierSource.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier; + +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.service.ServiceStatus.Status; +import java.util.List; + +public interface IdentifierSource { + + Status getStatus(); + + default boolean isReservationAvailable() { + return false; + } + + List reserveIds(int namespace, String partitionId, int quantity) + throws LingoProblem, InterruptedException; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISBulkJobStatusResponse.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISBulkJobStatusResponse.java new file mode 100644 index 000000000..fbfb9fa72 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISBulkJobStatusResponse.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier.cis; + +import lombok.Data; + +@Data +public class CISBulkJobStatusResponse { + private String status; + private String log; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISBulkRequestResponse.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISBulkRequestResponse.java new file mode 100644 index 000000000..4b94375a2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISBulkRequestResponse.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier.cis; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CISBulkRequestResponse { + private String id; +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISClient.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISClient.java new file mode 100644 index 000000000..3a5fac614 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISClient.java @@ -0,0 +1,388 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier.cis; + +import au.gov.digitalhealth.lingo.aspect.LogExecutionTime; +import au.gov.digitalhealth.lingo.exception.CISClientProblem; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.service.ServiceStatus.Status; +import au.gov.digitalhealth.lingo.service.identifier.IdentifierSource; +import io.netty.channel.ChannelOption; +import io.netty.handler.logging.LogLevel; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import lombok.extern.java.Log; +import org.codehaus.plexus.util.StringUtils; +import org.springframework.core.NestedRuntimeException; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.logging.AdvancedByteBufFormat; + +/** + * Client for the CIS API. Based on + * https://github.com/IHTSDO/component-identifier-service-legacy/blob/cf65d7023a34a477cef9d422dede7ad04101ac2f/java-client/src/main/java/org/snomed/cis/client/CISClient.java + */ +@Log +public class CISClient implements IdentifierSource { + + public static final String TOKEN_VAR_NAME = "token"; + public static final int STATUS_SUCCESS = 2; + private static final int MAX_BULK_REQUEST = 1000; + private static final DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final String username; + private final String password; + private final String softwareName; + private final WebClient client; + private final int timeoutSeconds; + List backOffLevels; + private String token = ""; + private int backOffLevel; + private long lastFailedReservationAttempt; + + private Status status; + + public CISClient( + String cisApiUrl, + String username, + String password, + String softwareName, + int timeoutSeconds, + List backOffLevels) { + + if (timeoutSeconds < 1 || timeoutSeconds > 100) { + throw new IllegalArgumentException("Timeout must be between 1 and 100 seconds."); + } + + if (StringUtils.isBlank(cisApiUrl)) { + throw new IllegalArgumentException("CIS API URL must be provided."); + } + + if (StringUtils.isBlank(username)) { + throw new IllegalArgumentException("Username must be provided."); + } + + if (StringUtils.isBlank(password)) { + throw new IllegalArgumentException("Password must be provided."); + } + + if (StringUtils.isBlank(softwareName)) { + throw new IllegalArgumentException("Software name must be provided."); + } + + this.username = username; + this.password = password; + this.softwareName = softwareName; + this.timeoutSeconds = timeoutSeconds; + this.backOffLevels = backOffLevels; + + HttpClient httpClient = + HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, timeoutSeconds * 1000) // Connect timeout + .responseTimeout(Duration.ofSeconds(timeoutSeconds)) + .wiretap(log.getName(), LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL); + + client = + WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl(cisApiUrl) + .build(); + + try { + login(); + authenticate(); // Fail fast + } catch (LingoProblem | WebClientException e) { + registerFailedReservationAttempt(e); + log.log( + Level.SEVERE, + "Failed to authenticate with CIS on initialisation. " + + "This may be due to a server misconfiguration or a temporary outage of the CIS", + e); + } + } + + @Override + public Status getStatus() { + if (status == null) { + status = Status.builder().version("N/A").build(); + } + status.setRunning(isReservationAvailable()); + + return status; + } + + @Override + public boolean isReservationAvailable() { + return !inFailureBackoff(); + } + + protected void authenticate() { + Map request = new HashMap<>(); + request.put(TOKEN_VAR_NAME, token); + try { + client + .post() + .uri("/authenticate") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(Void.class) + .block(); + } catch (WebClientResponseException e) { + if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + log.warning("Failed to authenticate with CIS(401). Will retry the login "); + login(); + } else { + log.severe("Failed to authenticate with CIS(other exceptions). " + e.getMessage()); + throw e; + } + } + } + + protected void login() { + log.info("Logging in."); + Map request = new HashMap<>(); + request.put("username", username); + request.put("password", password); + Map response = + client + .post() + .uri("/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(Map.class) + .doOnError( + e -> { + throw new CISClientProblem("Failed to login to CIS", e); + }) + .block(); + assert response != null; + token = response.get(TOKEN_VAR_NAME); + } + + @LogExecutionTime + @Override + public List reserveIds(int namespace, String partitionId, int quantity) + throws LingoProblem, InterruptedException { + + if (inFailureBackoff()) { + LocalDateTime backoffEndDateTime = + LocalDateTime.ofInstant( + Instant.ofEpochMilli(calculateBackoffEnd()), ZoneId.systemDefault()); + throw new CISClientProblem( + "Failed to reserve identifiers, CIS unusable and backoff in effect (" + + this.backOffLevels.get(this.backOffLevel) + + " seconds) until " + + backoffEndDateTime.format(formatter)); + } + + try { + authenticate(); + List reservedIdentifiers = new ArrayList<>(); + int requestQuantity = MAX_BULK_REQUEST; + while (reservedIdentifiers.size() < quantity) { + if (requestQuantity > quantity) { + requestQuantity = quantity - reservedIdentifiers.size(); + } + CISGenerateRequest request = + new CISGenerateRequest(namespace, partitionId, requestQuantity, softwareName); + reservedIdentifiers.addAll(callCis("reserve", request, false)); + } + clearFailedReservationAttempts(); + return reservedIdentifiers; + } catch (LingoProblem | WebClientException e) { + registerFailedReservationAttempt(e); + throw new CISClientProblem("Failed to reserve identifiers", e); + } + } + + private boolean inFailureBackoff() { + synchronized (this) { + return this.backOffLevel > 0 && System.currentTimeMillis() < calculateBackoffEnd(); + } + } + + private long calculateBackoffEnd() { + return this.lastFailedReservationAttempt + this.backOffLevels.get(this.backOffLevel) * 1000L; + } + + private void clearFailedReservationAttempts() { + synchronized (this) { + this.lastFailedReservationAttempt = 0; + this.backOffLevel = 0; + } + } + + private void registerFailedReservationAttempt(NestedRuntimeException e) { + synchronized (this) { + log.warning("Failed to reserve identifiers: " + e.getMessage()); + this.lastFailedReservationAttempt = System.currentTimeMillis(); + if (this.backOffLevel < this.backOffLevels.size() - 1) { + log.info( + "Increasing backoff level to " + + this.backOffLevels.get(this.backOffLevel) + + " seconds"); + this.backOffLevel++; + } + } + } + + private List callCis( + String operation, CISGenerateRequest request, boolean includeSchemeName) + throws LingoProblem, InterruptedException { + String bulkJobId = executeBulkRequest(operation, request, includeSchemeName); + + waitForJobToComplete(bulkJobId); + + return getSctIdsFromBulkJob(bulkJobId); + } + + private List getSctIdsFromBulkJob(String bulkJobId) { + ResponseEntity> recordsResponse = + client + .get() + .uri( + uriBuilder -> + uriBuilder + .path("/bulk/jobs/{jobId}/records") + .queryParam(TOKEN_VAR_NAME, token) + .build(bulkJobId)) + .retrieve() + .toEntity(new ParameterizedTypeReference>() {}) + .doOnError( + e -> { + throw new CISClientProblem("Failed to fetch records for job " + bulkJobId, e); + }) + .block(); + + // this null checking looks clumsy but it was hard to get Sonar to stop complaining about it + if (recordsResponse != null) { + List body = recordsResponse.getBody(); + if (body != null) { + return body.stream().map(CISRecord::getSctidAsLong).toList(); + } + } + throw new CISClientProblem("Failed to fetch records for job " + bulkJobId); + } + + private void waitForJobToComplete(String bulkJobId) throws InterruptedException { + long timeout = System.currentTimeMillis() + timeoutSeconds * 1000L; + CISBulkJobStatusResponse statusResponse; + do { + if (timeout < System.currentTimeMillis()) { + throw new CISClientProblem("Bulk job " + bulkJobId + " timed out."); + } + statusResponse = + client + .get() + .uri( + uriBuilder -> + uriBuilder + .path("/bulk/jobs/{jobId}") + .queryParam(TOKEN_VAR_NAME, token) + .build(bulkJobId)) + .retrieve() + .bodyToMono(CISBulkJobStatusResponse.class) + .doOnError( + e -> { + throw new CISClientProblem("Failed to fetch status for job " + bulkJobId, e); + }) + .block(); + + if (statusResponse == null || statusResponse.getStatus() == null) { + throw new CISClientProblem("Failed to fetch status for job " + bulkJobId); + } + + Thread.sleep(500); + + } while (Integer.parseInt(statusResponse.getStatus()) < STATUS_SUCCESS); + + if (Integer.parseInt(statusResponse.getStatus()) != STATUS_SUCCESS) { + throw new CISClientProblem( + "Bulk identifier reservation job " + + bulkJobId + + " failed with status " + + statusResponse.getStatus() + + " due to " + + statusResponse.getLog()); + } + } + + private String executeBulkRequest( + String operation, CISGenerateRequest request, boolean includeSchemeName) { + CISBulkRequestResponse responseBody; + if (includeSchemeName) { + responseBody = + client + .post() + .uri( + uriBuilder -> + uriBuilder + .path("/sct/bulk/{operation}") + .queryParam(TOKEN_VAR_NAME, token) + .queryParam("schemeName", "SNOMEDID") + .build(operation)) + .bodyValue(request) + .retrieve() + .bodyToMono(CISBulkRequestResponse.class) + .doOnError( + e -> { + throw CISClientProblem.cisClientProblemForOperation(operation, e); + }) + .block(); + } else { + responseBody = + client + .post() + .uri( + uriBuilder -> + uriBuilder + .path("/sct/bulk/{operation}") + .queryParam(TOKEN_VAR_NAME, token) + .build(operation)) + .bodyValue(request) + .retrieve() + .bodyToMono(CISBulkRequestResponse.class) + .doOnError( + e -> { + throw CISClientProblem.cisClientProblemForOperation(operation, e); + }) + .block(); + } + + if (responseBody == null || responseBody.getId() == null) { + throw CISClientProblem.cisClientProblemForOperation(operation); + } + + return responseBody.getId(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISGenerateRequest.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISGenerateRequest.java new file mode 100644 index 000000000..bff1dbcad --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISGenerateRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier.cis; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public final class CISGenerateRequest { + + private final String softwareName; + private final int namespace; + private final String partitionId; + private final int quantity; + + @JsonCreator + public CISGenerateRequest( + @JsonProperty("namespace") int namespace, + @JsonProperty("partitionId") String partitionId, + @JsonProperty("quantity") int quantity, + @JsonProperty("software") String software) { + + this.namespace = namespace; + this.partitionId = partitionId; + this.quantity = quantity; + this.softwareName = software; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISRecord.java b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISRecord.java new file mode 100644 index 000000000..466ebafcd --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/service/identifier/cis/CISRecord.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.service.identifier.cis; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.io.Serializable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@Setter +@NoArgsConstructor +public class CISRecord implements Serializable { + + private static final long serialVersionUID = -2727499661155145447L; + + private String sctid; + private String status; + + public CISRecord(Long sctid) { + this.sctid = sctid.toString(); + } + + public Long getSctidAsLong() { + return Long.parseLong(sctid); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/util/AuthSnowstormLogger.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/AuthSnowstormLogger.java new file mode 100644 index 000000000..b663fb92c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/AuthSnowstormLogger.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; + +import au.gov.digitalhealth.lingo.auth.helper.AuthHelper; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.log.SnowstormLogger; +import java.util.logging.Logger; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class AuthSnowstormLogger implements SnowstormLogger { + private final Logger logger = Logger.getLogger(AuthSnowstormLogger.class.getName()); + private final AuthHelper authHelper; + + public AuthSnowstormLogger(AuthHelper authHelper) { + if (authHelper == null) { + throw new LingoProblem("auth", "AuthHelper is null", HttpStatus.INTERNAL_SERVER_ERROR); + } + this.authHelper = authHelper; + } + + @Override + public void logFine(String message, Object... params) { + String userLogin = authHelper.getImsUser().getLogin(); + logger.fine(() -> String.format("User %s %s", userLogin, String.format(message, params))); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/util/EclBuilder.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/EclBuilder.java new file mode 100644 index 000000000..ecf5936a9 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/EclBuilder.java @@ -0,0 +1,285 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_CONTAINER_TYPE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.HAS_OTHER_IDENTIFYING_INFORMATION; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.COUNT_OF_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.COUNT_OF_BASE_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_MANUFACTURED_DOSE_FORM; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRECISE_ACTIVE_INGREDIENT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.HAS_PRODUCT_NAME; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.MEDICINAL_PRODUCT_PACKAGE; +import static java.util.stream.Collectors.mapping; + +import au.csiro.snowstorm_client.model.SnowstormConcreteValue.DataTypeEnum; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.exception.UnexpectedSnowstormResponseProblem; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; +import lombok.extern.java.Log; + +@Log +public class EclBuilder { + + private EclBuilder() {} + + @SuppressWarnings("java:S1192") + public static String build( + Set relationships, + Set referencedIds, + boolean suppressIsa, + boolean suppressNegativeStatements) { + // first do the isa relationships + // and the refsets + // then group 0 relationships, including grouped relationships + // then grouped relatiopnships + String isaEcl = suppressIsa ? "" : buildIsaRelationships(relationships); + String refsetEcl = buildRefsets(referencedIds); + + StringBuilder ecl = new StringBuilder(); + ecl.append("("); + if (isaEcl.isEmpty() && refsetEcl.isEmpty()) { + ecl.append("*"); + } else if (isaEcl.isEmpty()) { + ecl.append(refsetEcl); + } else if (refsetEcl.isEmpty()) { + ecl.append(isaEcl); + } else { + ecl.append(isaEcl); + ecl.append(" AND "); + ecl.append(refsetEcl); + } + ecl.append(")"); + + String ungrouped = buildUngroupedRelationships(relationships, suppressNegativeStatements); + String grouped = buildGroupedRelationships(relationships); + + if (!ungrouped.isEmpty() && !grouped.isEmpty()) { + ecl.append(":"); + ecl.append(ungrouped); + ecl.append(","); + ecl.append(grouped); + } else if (!ungrouped.isEmpty()) { + ecl.append(":"); + ecl.append(ungrouped); + } else if (!grouped.isEmpty()) { + ecl.append(":"); + ecl.append(grouped); + } + + log.info("ECL: " + ecl); + return ecl.toString(); + } + + private static String buildGroupedRelationships(Set relationships) { + Map> groupMap = + relationships.stream() + .filter(r -> r.getGroupId() != 0) + .filter( + r -> + r.getConcreteValue() != null + || (r.getDestinationId() != null && r.getDestinationId().matches("\\d+"))) + .collect( + Collectors.groupingBy( + SnowstormRelationship::getGroupId, + TreeMap::new, + mapping(r -> r, Collectors.toSet()))); + + return groupMap.keySet().stream() + .map(k -> "{" + getRelationshipFilters(groupMap.get(k)) + "}") + .collect(Collectors.joining(",")); + } + + @SuppressWarnings("java:S1192") + private static String buildUngroupedRelationships( + Set relationships, boolean suppressNegativeStatements) { + StringBuilder response = new StringBuilder(); + + response.append(getRelationshipFilters(relationships)); + + if (!suppressNegativeStatements) { + if (relationships.stream() + .anyMatch( + r -> + r.getTypeId().equals(SnomedConstants.IS_A.getValue()) + && r.getDestinationId() != null + && r.getDestinationId().equals(MEDICINAL_PRODUCT.getValue()))) { + response.append( + generateNegativeFilters(relationships, HAS_MANUFACTURED_DOSE_FORM.getValue())); + response.append( + generateNegativeFilters(relationships, COUNT_OF_ACTIVE_INGREDIENT.getValue())); + response.append( + generateNegativeFilters(relationships, COUNT_OF_BASE_ACTIVE_INGREDIENT.getValue())); + response.append(generateNegativeFilters(relationships, HAS_ACTIVE_INGREDIENT.getValue())); + response.append( + generateNegativeFilters(relationships, HAS_PRECISE_ACTIVE_INGREDIENT.getValue())); + } + + if (relationships.stream() + .anyMatch( + r -> + r.getTypeId().equals(SnomedConstants.IS_A.getValue()) + && r.getDestinationId() != null + && r.getDestinationId().equals(MEDICINAL_PRODUCT_PACKAGE.getValue())) + && relationships.stream() + .noneMatch(r -> r.getTypeId().equals(HAS_CONTAINER_TYPE.getValue()))) { + response.append(", [0..0] " + HAS_CONTAINER_TYPE + " = *"); + } + + if (relationships.stream() + .noneMatch(r -> r.getTypeId().equals(HAS_PRODUCT_NAME.getValue()))) { + response.append(", [0..0] " + HAS_PRODUCT_NAME + " = *"); + } + } + + return response.toString(); + } + + private static String getRelationshipFilters(Set relationships) { + Set filteredRelationships = relationships; + + if (relationships.stream() + .anyMatch(r -> r.getTypeId().equals(HAS_PRECISE_ACTIVE_INGREDIENT.getValue()))) { + filteredRelationships = + relationships.stream() + .filter(r -> !r.getTypeId().equals(HAS_ACTIVE_INGREDIENT.getValue())) + .collect(Collectors.toSet()); + } + + // TODO this is a Snowstorm defect - this is needed but has to be filtered out for now + filteredRelationships = + filteredRelationships.stream() + .filter(r -> !r.getTypeId().equals(HAS_OTHER_IDENTIFYING_INFORMATION.getValue())) + .collect(Collectors.toSet()); + + return filteredRelationships.stream() + .filter(r -> !r.getTypeId().equals(SnomedConstants.IS_A.getValue())) + .filter( + r -> + r.getConcreteValue() != null + || (r.getDestinationId() != null && Long.parseLong(r.getDestinationId()) > 0)) + .map(EclBuilder::toRelationshipEclFilter) + .distinct() + .collect(Collectors.joining(", ")); + } + + private static String toRelationshipEclFilter(SnowstormRelationship r) { + StringBuilder response = new StringBuilder(); + response.append(r.getTypeId()); + response.append(" = "); + if (r.getConcreteValue() != null) { + if (Objects.equals( + Objects.requireNonNull(r.getConcreteValue()).getDataType(), DataTypeEnum.STRING)) { + response.append("\""); + } else { + response.append("#"); + } + response.append(r.getConcreteValue().getValue()); + if (Objects.equals(r.getConcreteValue().getDataType(), DataTypeEnum.STRING)) { + response.append("\""); + } + } else { + response.append(r.getDestinationId()); + } + return response.toString(); + } + + @SuppressWarnings("java:S1192") + private static String generateNegativeFilters( + Set relationships, String typeId) { + String response; + if (relationships.stream().noneMatch(r -> r.getTypeId().equals(typeId))) { + response = ", [0..0] " + typeId + " = *"; + } else { + String value; + + Set relationshipSet = + relationships.stream() + .filter(r -> r.getTypeId().equals(typeId)) + .collect(Collectors.toSet()); + + if (relationshipSet.stream().allMatch(r -> r.getConcreteValue() != null)) { + DataTypeEnum datatype = relationshipSet.iterator().next().getConcreteValue().getDataType(); + + if (!relationshipSet.stream() + .allMatch( + r -> + r.getConcreteValue() != null + && r.getConcreteValue().getDataType() != null + && r.getConcreteValue().getDataType().equals(datatype))) { + throw new UnexpectedSnowstormResponseProblem( + "Expected all concrete domains to share the same datatype for " + + typeId + + " for source concept " + + relationshipSet.iterator().next().getSourceId() + + " set was " + + relationshipSet.stream() + .map(SnowstormRelationship::getConcreteValue) + .map(Objects::toString) + .distinct() + .collect(Collectors.joining(", "))); + } + + value = + relationshipSet.stream() + .map( + r -> + datatype.equals(DataTypeEnum.STRING) + ? "\"" + Objects.requireNonNull(r.getConcreteValue()).getValue() + "\"" + : "#" + Objects.requireNonNull(r.getConcreteValue()).getValue()) + .collect(Collectors.joining(" OR ")); + } else { + value = + relationshipSet.stream() + .map(SnowstormRelationship::getDestinationId) + .collect(Collectors.joining(" OR ")); + } + + if (value.contains(" OR ")) { + value = "(" + value + ")"; + } + response = ", [0..0] " + typeId + " != " + value; + } + return response; + } + + @SuppressWarnings("java:S1192") + private static String buildRefsets(Set referencedIds) { + return referencedIds.stream().map(id -> "^" + id).collect(Collectors.joining(" AND ")); + } + + @SuppressWarnings("java:S1192") + private static String buildIsaRelationships(Set relationships) { + String isARelationships = + relationships.stream() + .filter(r -> r.getTypeId().equals(SnomedConstants.IS_A.getValue())) + .filter(r -> r.getConcreteValue() == null) + .filter(r -> r.getDestinationId() != null && Long.parseLong(r.getDestinationId()) > 0) + .map(r -> "<" + r.getDestinationId()) + .collect(Collectors.joining(" AND ")); + + if (isARelationships.isEmpty()) { + isARelationships = "*"; + } + return isARelationships; + } +} diff --git a/api/src/main/java/com/csiro/snomio/util/OwlAxiomService.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/OwlAxiomService.java similarity index 84% rename from api/src/main/java/com/csiro/snomio/util/OwlAxiomService.java rename to api/src/main/java/au/gov/digitalhealth/lingo/util/OwlAxiomService.java index 1a80115c4..d401031d6 100644 --- a/api/src/main/java/com/csiro/snomio/util/OwlAxiomService.java +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/OwlAxiomService.java @@ -1,4 +1,19 @@ -package com.csiro.snomio.util; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; import static java.lang.Long.parseLong; @@ -6,8 +21,9 @@ import au.csiro.snowstorm_client.model.SnowstormConceptView; import au.csiro.snowstorm_client.model.SnowstormConcreteValue; import au.csiro.snowstorm_client.model.SnowstormRelationship; -import com.csiro.snomio.configuration.ModellingConfiguration; -import com.csiro.snomio.exception.OntologyCreationProblem; +import au.gov.digitalhealth.lingo.configuration.ModellingConfiguration; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.exception.OntologyCreationProblem; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -17,7 +33,6 @@ import lombok.extern.java.Log; import org.semanticweb.owlapi.functional.renderer.FunctionalSyntaxObjectRenderer; import org.semanticweb.owlapi.model.OWLAxiom; -import org.semanticweb.owlapi.model.OWLLogicalAxiom; import org.semanticweb.owlapi.model.OWLOntology; import org.semanticweb.owlapi.model.OWLOntologyCreationException; import org.snomed.otf.owltoolkit.constants.Concepts; @@ -27,6 +42,7 @@ import org.snomed.otf.owltoolkit.ontology.render.SnomedPrefixManager; import org.snomed.otf.owltoolkit.taxonomy.SnomedTaxonomy; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Log @@ -46,7 +62,8 @@ public Set translate(SnowstormConceptView concept) { try { log.info("CONCEPT: " + mapper.writeValueAsString(concept)); } catch (JsonProcessingException e) { - throw new RuntimeException(e); + throw new LingoProblem( + "axiom-service", "Cannot process concept", HttpStatus.INTERNAL_SERVER_ERROR, e); } SnomedTaxonomy taxonomy = createSnomedTaxonomy(concept); @@ -56,7 +73,8 @@ public Set translate(SnowstormConceptView concept) { try { log.info("TAXONOMY: " + mapper.writeValueAsString(taxonomy)); } catch (JsonProcessingException e) { - throw new RuntimeException(e); + throw new LingoProblem( + "axiom-service", "Cannot process taxonomy", HttpStatus.INTERNAL_SERVER_ERROR, e); } SnomedPrefixManager prefixManager = ontologyService.getSnomedPrefixManager(); @@ -76,7 +94,7 @@ public Set translate(SnowstormConceptView concept) { Set axiomAsOwlF = new HashSet<>(); for (OWLAxiom axiom : ontology.getAxioms()) { - axiomAsOwlF.add(axiomRelConversionService.axiomToString((OWLLogicalAxiom) axiom)); + axiomAsOwlF.add(axiomRelConversionService.axiomToString(axiom)); } return axiomAsOwlF; @@ -114,7 +132,7 @@ private SnomedTaxonomy createSnomedTaxonomy(SnowstormConceptView concept) { for (SnowstormRelationship relationship : axiom.getRelationships()) { if ((relationship.getActive() == null || relationship.getActive()) && !relationship - .getCharacteristicType() + .getCharacteristicTypeId() .equals(Concepts.ADDITIONAL_RELATIONSHIP_LONG.toString())) { // ----below is from SNINT code with minor adaptations---- @@ -122,7 +140,7 @@ private SnomedTaxonomy createSnomedTaxonomy(SnowstormConceptView concept) { relationship.getModifierId() != null && relationship.getModifierId().equals(Concepts.UNIVERSAL_RESTRICTION_MODIFIER); int unionGroup = 0; - if (!relationship.getConcrete()) { + if (relationship.getConcreteValue() == null) { taxonomy.addOrModifyRelationship( relationship.getInferred() == null || !relationship.getInferred(), conceptId, @@ -137,7 +155,7 @@ private SnomedTaxonomy createSnomedTaxonomy(SnowstormConceptView concept) { relationship.getGroupId(), unionGroup, universal, - toNumericId(relationship.getCharacteristicType()))); + toNumericId(relationship.getCharacteristicTypeId()))); } else { SnowstormConcreteValue snCV = Objects.requireNonNull(relationship.getConcreteValue()); taxonomy.addOrModifyRelationship( @@ -157,7 +175,7 @@ private SnomedTaxonomy createSnomedTaxonomy(SnowstormConceptView concept) { relationship.getGroupId(), unionGroup, universal, - toNumericId(relationship.getCharacteristicType()))); + toNumericId(relationship.getCharacteristicTypeId()))); } } } diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/util/RelationshipSorter.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/RelationshipSorter.java new file mode 100644 index 000000000..7cf7ed272 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/RelationshipSorter.java @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; + +import static java.util.stream.Collectors.counting; +import static java.util.stream.Collectors.groupingBy; + +import au.csiro.snowstorm_client.model.SnowstormAxiom; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; + +public class RelationshipSorter { + private RelationshipSorter() {} + + public static Comparator getRelationshipComparator( + Map groupSizeMap) { + return Comparator.comparing(SnowstormRelationship::getActive) + .reversed() + .thenComparingInt(RelationshipSorter::isaComparator) + .thenComparingInt(r -> extractGroupWeight(r, groupSizeMap)) + .thenComparing(SnowstormRelationship::getTypeId, Comparator.nullsLast(String::compareTo)) + .thenComparing( + r -> r.getTarget() == null ? null : r.getTarget().getFsn().getTerm(), + Comparator.nullsLast(String::compareTo)) + .thenComparing( + SnowstormRelationship::getDestinationId, Comparator.nullsLast(String::compareTo)) + .thenComparing(SnowstormRelationship::hashCode); + } + + private static int isaComparator(SnowstormRelationship r) { + if (r.getTypeId().equals("116680003")) { + return 0; + } + return 1; + } + + private static int extractGroupWeight(SnowstormRelationship r, Map groupSizeMap) { + Number groupKey = r.getGroupId() != null ? r.getGroupId() : r.getRelationshipGroup(); + + if (groupKey == null) { + return 0; // Default to 0 if both groupId and relationshipGroup are null + } + + if (groupKey.equals(0)) { + return 0; + } else if (groupSizeMap.getOrDefault(groupKey, 0L) == 1) { + return 1; + } else { + return groupKey.intValue() * 10; + } + } + + public static void sortRelationships(SnowstormAxiom a) { + + Map map = + a.getRelationships().stream() + .collect( + groupingBy( + r -> { + Integer groupId = r.getGroupId(); + Integer relationshipGroup = r.getRelationshipGroup(); + return Objects.requireNonNullElseGet( + groupId, () -> relationshipGroup != null ? relationshipGroup : 0); + }, + counting())); + + SortedSet sortedRelationships = + new TreeSet<>(getRelationshipComparator(map)); + + sortedRelationships.addAll(a.getRelationships()); + a.setRelationships(sortedRelationships); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/util/SemanticTagUtil.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/SemanticTagUtil.java new file mode 100644 index 000000000..ac80e3d50 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/SemanticTagUtil.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SemanticTagUtil { + public static String extractSemanticTag(String input) { + // Regular expression to match substrings in the format "(xyz)" + Pattern pattern = Pattern.compile("\\(.*?\\)"); + Matcher matcher = pattern.matcher(input); + + String lastMatch = null; + + // Iterate through all matches and store the last one + while (matcher.find()) { + lastMatch = matcher.group(); + } + + return lastMatch; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/util/Task.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/Task.java new file mode 100644 index 000000000..3838cf245 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/Task.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class Task { + + private String key; + private String projectKey; + private String summary; + private Status status; + private String branchState; + private long branchHeadTimestamp; + private long branchBaseTimestamp; + private long latestCodeSystemVersionTimestamp; + private String description; + private AuthoringPlatformUser assignee; + private List reviewers; + private String created; + private String updated; + private LatestClassificationJson latestClassificationJson; + private String latestValidationStatus; + private String feedbackMessagesStatus; + private String branchPath; + private List labels; + + @Data + public static class LatestClassificationJson { + private String completionDate; + private String creationDate; + private boolean equivalentConceptsFound; + private String id; + private boolean inferredRelationshipChangesFound; + private String lastCommitDate; + private String path; + private String reasonerId; + private String status; + private String userId; + + // Constructors, getters, and setters + + // Other necessary methods + } + + @Data + public static class AuthoringPlatformUser { + private String email; + private String displayName; + private String username; + private String avatarUrl; + } + + public enum Status { + @JsonProperty("New") + NEW("New"), + @JsonProperty("In Progress") + IN_PROGRESS("In Progress"), + @JsonProperty("In Review") + IN_REVIEW("In Review"), + @JsonProperty("Review Completed") + REVIEW_COMPLETED("Review Completed"), + @JsonProperty("Promoted") + PROMOTED("Promoted"), + @JsonProperty("Completed") + COMPLETED("Completed"), + @JsonProperty("Deleted") + DELETED("Deleted"), + + @JsonProperty("Auto Classifying") + AUTO_CLASSIFYING("Auto Classifying"), + @JsonProperty("Unknown") + UNKNOWN("Unknown"); + + private final String value; + + Status(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/lingo/util/ValidationUtil.java b/api/src/main/java/au/gov/digitalhealth/lingo/util/ValidationUtil.java new file mode 100644 index 000000000..f8de8a1d5 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/lingo/util/ValidationUtil.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.lingo.util; + +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_DEVICE; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_PACKAGED_CD; +import static au.gov.digitalhealth.lingo.util.AmtConstants.CONTAINS_PACKAGED_DEVICE; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.CONTAINS_CD; +import static au.gov.digitalhealth.lingo.util.SnomedConstants.UNIT_OF_PRESENTATION; +import static au.gov.digitalhealth.lingo.util.SnowstormDtoUtil.getSingleAxiom; + +import au.csiro.snowstorm_client.model.SnowstormAxiom; +import au.csiro.snowstorm_client.model.SnowstormConcept; +import au.csiro.snowstorm_client.model.SnowstormRelationship; +import au.gov.digitalhealth.lingo.exception.ProductAtomicDataValidationProblem; +import au.gov.digitalhealth.lingo.product.details.Quantity; +import java.math.BigDecimal; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public class ValidationUtil { + + private ValidationUtil() { + throw new IllegalStateException("Utility class"); + } + + public static boolean isIntegerValue(BigDecimal bd) { + return bd.stripTrailingZeros().scale() <= 0; + } + + public static void validateQuantityValueIsOneIfUnitIsEach(Quantity quantity) { + if (Objects.requireNonNull(quantity.getUnit().getConceptId()) + .equals(UNIT_OF_PRESENTATION.getValue()) + && !isIntegerValue(quantity.getValue())) { + throw new ProductAtomicDataValidationProblem( + "Quantity must be an integer if the unit is 'each', unit was " + + SnowstormDtoUtil.getIdAndFsnTerm(quantity.getUnit())); + } + } + + public static void assertSingleComponentSinglePackProduct(SnowstormConcept concept) { + SnowstormAxiom axiom = getSingleAxiom(concept); + if (axiom.getRelationships().stream() + .anyMatch( + r -> + r.getTypeId().equals(CONTAINS_PACKAGED_CD.getValue()) + || r.getTypeId().equals(CONTAINS_PACKAGED_DEVICE.getValue()))) { + throw new ProductAtomicDataValidationProblem( + "Cannot get brands for multi pack product " + concept.getConceptId()); + } + + Set containsProductRelationships = + axiom.getRelationships().stream() + .filter(r -> r.getActive() != null && r.getActive()) + .filter( + r -> + r.getTypeId().equals(CONTAINS_CD.getValue()) + || r.getTypeId().equals(CONTAINS_DEVICE.getValue())) + .collect(Collectors.toSet()); + + if (containsProductRelationships.size() != 1) { + throw new ProductAtomicDataValidationProblem( + "Cannot get brands for multi component product " + concept.getConceptId()); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/AdditionalFieldController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/AdditionalFieldController.java new file mode 100644 index 000000000..7de2a5fda --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/AdditionalFieldController.java @@ -0,0 +1,105 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.tickets.AdditionalFieldTypeDto; +import au.gov.digitalhealth.tickets.AdditionalFieldValueDto; +import au.gov.digitalhealth.tickets.AdditionalFieldValuesForListTypeDto; +import au.gov.digitalhealth.tickets.service.AdditionalFieldService; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AdditionalFieldController { + private final AdditionalFieldService additionalFieldService; + + public AdditionalFieldController(AdditionalFieldService additionalFieldService) { + this.additionalFieldService = additionalFieldService; + } + + @GetMapping("/api/tickets/additionalFieldTypes") + public ResponseEntity> getAllAdditionalFieldTypes() { + + return new ResponseEntity<>(additionalFieldService.getAllAdditionalFieldTypes(), HttpStatus.OK); + } + + @PostMapping( + value = "/api/tickets/{ticketId}/additionalFieldValue/{additionalFieldTypeId}/{valueOf}") + public ResponseEntity createTicketAdditionalField( + @PathVariable Long ticketId, + @PathVariable Long additionalFieldTypeId, + @PathVariable String valueOf) { + + return new ResponseEntity<>( + additionalFieldService.createTicketAdditionalField( + ticketId, additionalFieldTypeId, valueOf), + HttpStatus.OK); + } + + @PostMapping( + value = "/api/tickets/{ticketId}/additionalFieldValue/{additionalFieldTypeId}", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createTicketAdditionalFieldByBody( + @PathVariable Long ticketId, + @PathVariable Long additionalFieldTypeId, + @RequestBody AdditionalFieldValueDto afv) { + + return new ResponseEntity<>( + additionalFieldService.createTicketAdditionalField(ticketId, additionalFieldTypeId, afv), + HttpStatus.OK); + } + + @GetMapping(value = "/api/tickets/additionalFieldType/{aftId}/additionalFieldValues") + public ResponseEntity>> getAllValuesByAdditionalFieldType( + @PathVariable Long aftId) { + return new ResponseEntity<>( + additionalFieldService.getAllValuesByAdditionalFieldType(aftId), HttpStatus.OK); + } + + @DeleteMapping(value = "/api/tickets/{ticketId}/additionalFieldValue/{additionalFieldTypeId}") + public ResponseEntity deleteTicketAdditionalField( + @PathVariable Long ticketId, @PathVariable Long additionalFieldTypeId) { + additionalFieldService.deleteTicketAdditionalField(ticketId, additionalFieldTypeId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/api/additionalFieldValuesForListType") + public ResponseEntity> + getAdditionalFieldValuesForListType() { + + return new ResponseEntity<>( + additionalFieldService.getAdditionalFieldValuesForListType(), HttpStatus.OK); + } + + @PostMapping( + value = "/api/tickets/additionalFieldType", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createAdditionalFieldType( + @RequestBody AdditionalFieldTypeDto aft) { + return new ResponseEntity<>( + additionalFieldService.createAdditionalFieldType(aft), HttpStatus.OK); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/AttachmentController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/AttachmentController.java new file mode 100644 index 000000000..01aae7cd6 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/AttachmentController.java @@ -0,0 +1,259 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ErrorMessages; +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.AttachmentUploadResponse; +import au.gov.digitalhealth.tickets.helper.AttachmentUtils; +import au.gov.digitalhealth.tickets.models.Attachment; +import au.gov.digitalhealth.tickets.models.AttachmentType; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.repository.AttachmentRepository; +import au.gov.digitalhealth.tickets.repository.AttachmentTypeRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import au.gov.digitalhealth.tickets.service.AttachmentService; +import jakarta.transaction.Transactional; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class AttachmentController { + + private static final String UPLOAD_API = "/api/attachments/upload/"; + protected final Log logger = LogFactory.getLog(getClass()); + final AttachmentRepository attachmentRepository; + final AttachmentTypeRepository attachmentTypeRepository; + final TicketRepository ticketRepository; + + final AttachmentService attachmentService; + + @Value("${snomio.attachments.directory}") + private String attachmentsDirectory; + + public AttachmentController( + AttachmentRepository attachmentRepository, + TicketRepository ticketRepository, + AttachmentTypeRepository attachmentTypeRepository, + AttachmentService attachmentService) { + this.attachmentRepository = attachmentRepository; + this.ticketRepository = ticketRepository; + this.attachmentTypeRepository = attachmentTypeRepository; + this.attachmentService = attachmentService; + } + + @GetMapping("/api/attachments/{id}") + public ResponseEntity getAttachment(@PathVariable Long id) { + Optional attachmentOptional = attachmentRepository.findById(id); + if (attachmentOptional.isPresent()) { + return ResponseEntity.ok(attachmentOptional.get()); + } else { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/api/attachments/download/{id}") + public ResponseEntity downloadAttachment(@PathVariable Long id) { + Optional attachmentOptional = attachmentRepository.findById(id); + if (attachmentOptional.isPresent()) { + Attachment attachment = attachmentOptional.get(); + return getFile(attachment, false); + } else { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/api/attachments/thumbnail/{id}") + public ResponseEntity getThumbnail(@PathVariable Long id) { + Optional attachmentOptional = attachmentRepository.findById(id); + if (attachmentOptional.isPresent()) { + Attachment attachment = attachmentOptional.get(); + return getFile(attachment, true); + } else { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping(value = UPLOAD_API + "{ticketId}", produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional + public ResponseEntity uploadAttachment( + @PathVariable Long ticketId, @RequestParam("file") MultipartFile file) { + if (file.isEmpty()) { + throw new LingoProblem( + UPLOAD_API + ticketId, + "File is empty!", + HttpStatus.BAD_REQUEST, + "The file you are trying to upload is empty. [" + file.getOriginalFilename() + "]"); + } + String attachmentsDir = attachmentsDirectory + (attachmentsDirectory.endsWith("/") ? "" : "/"); + Ticket theTicket = + ticketRepository + .findById(ticketId) + .orElseThrow( + () -> + new ResourceNotFoundProblem( + String.format(ErrorMessages.TICKET_ID_NOT_FOUND, ticketId))); + try { + // Save attachment file to target location. Filename will be SHA256 hash + String attachmentSHA = AttachmentUtils.calculateSHA256(file); + String attachmentLocation = + AttachmentUtils.getAttachmentAbsolutePath(attachmentsDir, attachmentSHA); + String attachmentFileName = file.getOriginalFilename(); + File attachmentFile = new File(attachmentLocation); + if (!attachmentFile.exists()) { + attachmentFile.getParentFile().mkdirs(); + Files.copy(file.getInputStream(), Path.of(attachmentLocation)); + } + + // Handle the Content Type of the new attachment + String contentType = file.getContentType(); + AttachmentType attachmentType = getExistingAttachmentType(contentType, ticketId); + + Attachment newAttachment = + Attachment.builder() + .description(attachmentFileName) + .filename(attachmentFileName) + .location(AttachmentUtils.getAttachmentRelativePath(attachmentSHA)) + .length(file.getSize()) + .sha256(attachmentSHA) + .ticket(theTicket) + .attachmentType(attachmentType) + .build(); + + generateThumbnail(attachmentsDir, attachmentFile, newAttachment); + + // Save the attachment in the DB + attachmentRepository.save(newAttachment); + return ResponseEntity.ok( + AttachmentUploadResponse.builder() + .message(AttachmentUploadResponse.MESSAGE_SUCCESS) + .attachmentId(newAttachment.getId()) + .ticketId(ticketId) + .sha256(attachmentSHA) + .build()); + } catch (IOException | NoSuchAlgorithmException e) { + throw new LingoProblem( + UPLOAD_API + ticketId, + "Could not upload file: " + file.getOriginalFilename(), + HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage()); + } + } + + // Generate thumbnail for the attachment if it's an image + private void generateThumbnail( + String attachmentsDir, File attachmentFile, Attachment newAttachment) throws IOException { + if (newAttachment.getAttachmentType().getMimeType().startsWith("image") + && (AttachmentUtils.saveThumbnail( + attachmentFile, + AttachmentUtils.getThumbnailAbsolutePath(attachmentsDir, newAttachment.getSha256())))) { + newAttachment.setThumbnailLocation( + AttachmentUtils.getThumbnailRelativePath(newAttachment.getSha256())); + } + } + + private AttachmentType getExistingAttachmentType(String contentType, Long ticketId) { + if (contentType == null || contentType.isEmpty()) { + throw new LingoProblem( + UPLOAD_API + ticketId, "Missing Content type", HttpStatus.INTERNAL_SERVER_ERROR); + } + AttachmentType attachmentType = null; + Optional existingAttachmentType = + attachmentTypeRepository.findByMimeType(contentType); + if (existingAttachmentType.isPresent()) { + attachmentType = existingAttachmentType.get(); + } else { + attachmentType = AttachmentType.of(contentType); + attachmentTypeRepository.save(attachmentType); + } + return attachmentType; + } + + ResponseEntity getFile(Attachment attachment, boolean isThumbnail) { + try { + File theFile = null; + if (isThumbnail) { + theFile = + new File( + attachmentsDirectory + + (attachmentsDirectory.endsWith("/") ? "" : "/") + + attachment.getThumbnailLocation()); + } else { + theFile = + new File( + attachmentsDirectory + + (attachmentsDirectory.endsWith("/") ? "" : "/") + + attachment.getLocation()); + } + ByteArrayResource data = + new ByteArrayResource(Files.readAllBytes(Paths.get(theFile.getAbsolutePath()))); + HttpHeaders headers = new HttpHeaders(); + if (!isThumbnail) { + headers.add( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + attachment.getFilename() + '"'); + } + MediaType mediaType = MediaType.parseMediaType(attachment.getAttachmentType().getMimeType()); + return ResponseEntity.ok() + .headers(headers) + .contentLength(data.contentLength()) + .contentType(mediaType) + .body(data); + } catch (IOException e) { + throw new ResourceNotFoundProblem( + isThumbnail + ? "Could not find thumbnail " + : "Could not find file " + " for attachment id " + attachment.getId()); + } + } + + @Transactional + @DeleteMapping("/api/attachments/{id}") + public ResponseEntity deleteAttachment(@PathVariable Long id) { + Optional attachmentOptional = attachmentRepository.findById(id); + if (!attachmentOptional.isPresent()) { + throw new ResourceNotFoundProblem( + "Attachment " + id + " does not exist and cannot be deleted"); + } + Attachment attachment = attachmentOptional.get(); + attachmentRepository.deleteById(id); + attachmentRepository.flush(); + // Remove the attachment files if we can + attachmentService.deleteAttachmentFiles(attachment); + return ResponseEntity.noContent().build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/BulkProductActionController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/BulkProductActionController.java new file mode 100644 index 000000000..68d525c1c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/BulkProductActionController.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.tickets.service.TicketServiceImpl; +import java.util.Set; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class BulkProductActionController { + + final TicketServiceImpl ticketService; + + public BulkProductActionController(TicketServiceImpl ticketService) { + this.ticketService = ticketService; + } + + @PutMapping( + value = "/api/tickets/{ticketId}/bulk-product-actions", + consumes = MediaType.APPLICATION_JSON_VALUE) + public void put(@PathVariable Long ticketId, @RequestBody BulkProductActionDto dto) { + ticketService.putBulkProductActionOnTicket(ticketId, dto); + } + + @GetMapping(value = "/api/tickets/{ticketId}/bulk-product-actions") + public Set getAllForTicket(@PathVariable Long ticketId) { + return ticketService.getBulkProductActionForTicket(ticketId); + } + + @GetMapping(value = "/api/tickets/{ticketId}/bulk-product-actions/{name}") + public BulkProductActionDto get(@PathVariable Long ticketId, @PathVariable String name) { + return ticketService.getBulkProductActionByName(ticketId, name); + } + + @DeleteMapping(value = "/api/tickets/{ticketId}/bulk-product-actions/{name}") + public ResponseEntity delete(@PathVariable Long ticketId, @PathVariable String name) { + ticketService.deleteBulkProductAction(ticketId, name); + return ResponseEntity.noContent().build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/BulkProductActionDto.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/BulkProductActionDto.java new file mode 100644 index 000000000..d419d41f7 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/BulkProductActionDto.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.product.ProductBrands; +import au.gov.digitalhealth.lingo.product.ProductPackSizes; +import au.gov.digitalhealth.lingo.product.bulk.BrandPackSizeCreationDetails; +import au.gov.digitalhealth.lingo.product.bulk.BulkProductActionDetails; +import au.gov.digitalhealth.tickets.BaseAuditableDto; +import au.gov.digitalhealth.tickets.models.BulkProductAction; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +/** DTO for {@link BulkProductAction} */ +@Data +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Accessors(chain = true) +public class BulkProductActionDto extends BaseAuditableDto implements Serializable { + private Long ticketId; + @NotNull @NotEmpty private String name; + private Set conceptIds; + @NotNull private BulkProductActionDetails details; + + @JsonIgnore + public ProductBrands getBrands() { + if (details instanceof BrandPackSizeCreationDetails brandPackSizeCreationDetails) { + return brandPackSizeCreationDetails.getBrands(); + } else { + return null; // Handle other types or return an empty set as per your requirement + } + } + + @JsonIgnore + public ProductPackSizes getPackSizes() { + if (details instanceof BrandPackSizeCreationDetails brandPackSizeCreationDetails) { + return brandPackSizeCreationDetails.getPackSizes(); + } else { + return null; // Handle other types or return an empty set as per your requirement + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/CommentController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/CommentController.java new file mode 100644 index 000000000..3985c56b6 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/CommentController.java @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ErrorMessages; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.CommentDto; +import au.gov.digitalhealth.tickets.models.Comment; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.models.mappers.CommentMapper; +import au.gov.digitalhealth.tickets.repository.CommentRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CommentController { + + TicketRepository ticketRepository; + CommentRepository commentRepository; + CommentMapper commentMapper; + + @Autowired + public CommentController( + TicketRepository ticketRepository, + CommentRepository commentRepository, + CommentMapper commentMapper) { + this.ticketRepository = ticketRepository; + this.commentRepository = commentRepository; + this.commentMapper = commentMapper; + } + + @GetMapping( + value = "/api/tickets/{ticketId}/comments", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getComments(@PathVariable Long ticketId) { + + return new ResponseEntity<>( + commentMapper.toDtoList(commentRepository.findByTicket_Id(ticketId)), HttpStatus.OK); + } + + @PostMapping( + value = "/api/tickets/{ticketId}/comments", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createComment( + @PathVariable Long ticketId, @RequestBody CommentDto commentDto) { + + final Optional optional = ticketRepository.findById(ticketId); + Comment comment = new Comment(); + comment.setText(commentDto.getText()); + if (optional.isPresent()) { + comment.setTicket(optional.get()); + final Comment newComment = commentRepository.save(comment); + return new ResponseEntity<>(commentMapper.toDto(newComment), HttpStatus.OK); + } else { + throw new ResourceNotFoundProblem(String.format(ErrorMessages.TICKET_ID_NOT_FOUND, ticketId)); + } + } + + @PatchMapping( + value = "/api/tickets/{ticketId}/comments/{commentId}", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateComment( + @PathVariable Long ticketId, + @PathVariable Long commentId, + @RequestBody CommentDto commentDto) { + + Optional optionalComment = commentRepository.findById(commentId); + + if (optionalComment.isEmpty()) { + throw new ResourceNotFoundProblem( + String.format(ErrorMessages.COMMENT_ID_NOT_FOUND, commentId)); + } + + Comment comment = optionalComment.get(); + + if (!comment.getTicket().getId().equals(ticketId)) { + throw new ResourceNotFoundProblem( + String.format(ErrorMessages.COMMENT_NOT_FOUND_FOR_TICKET, commentId, ticketId)); + } + + comment.setText(commentDto.getText()); + Comment updatedComment = commentRepository.save(comment); + + return new ResponseEntity<>(commentMapper.toDto(updatedComment), HttpStatus.OK); + } + + @DeleteMapping(value = "/api/tickets/{ticketId}/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long ticketId, @PathVariable Long commentId) { + + if (!commentRepository.existsByTicket_IdAndId(ticketId, commentId)) { + throw new ResourceNotFoundProblem( + String.format( + "Comment with id %s is not associated to ticket with id %s", commentId, ticketId)); + } + + commentRepository.deleteById(commentId); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExportController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExportController.java new file mode 100644 index 000000000..51756f5b2 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExportController.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.tickets.service.ExportService; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ExportController { + + final ExportService exportService; + + public ExportController(ExportService exportService) { + this.exportService = exportService; + } + + @GetMapping("/api/tickets/export/{iterationId}") + public ResponseEntity adhaCsvExport(@PathVariable Long iterationId) { + + return exportService.adhaCsvExport(iterationId); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExternalProcessController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExternalProcessController.java new file mode 100644 index 000000000..2553f91bc --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExternalProcessController.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.tickets.models.ExternalProcess; +import au.gov.digitalhealth.tickets.service.ExternalProcessService; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/tickets/external-processes") // Added a base path for clarity +public class ExternalProcessController { + + private final ExternalProcessService externalProcessService; + + @Autowired + public ExternalProcessController(ExternalProcessService externalProcessService) { + this.externalProcessService = externalProcessService; + } + + @PostMapping + public ResponseEntity createExternalProcess( + @RequestBody ExternalProcess externalProcess) { + ExternalProcess createdProcess = externalProcessService.createExternalProcess(externalProcess); + return new ResponseEntity<>(createdProcess, HttpStatus.CREATED); + } + + @GetMapping + public ResponseEntity> getAllExternalProcesses() { + List processes = externalProcessService.getAllExternalProcesses(); + return new ResponseEntity<>(processes, HttpStatus.OK); + } + + @GetMapping("/{id}") + public ResponseEntity getExternalProcessById(@PathVariable Long id) { + ExternalProcess process = externalProcessService.getExternalProcessById(id); + return new ResponseEntity<>(process, HttpStatus.OK); + } + + @PutMapping("/{id}") + public ResponseEntity updateExternalProcess( + @PathVariable Long id, @RequestBody ExternalProcess externalProcess) { + ExternalProcess updatedProcess = + externalProcessService.updateExternalProcess(id, externalProcess); + if (updatedProcess != null) { + return new ResponseEntity<>(updatedProcess, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteExternalProcess(@PathVariable Long id) { + boolean deleted = externalProcessService.deleteExternalProcess(id); + if (deleted) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @PatchMapping("/{id}/enable") + public ResponseEntity enableExternalProcess(@PathVariable Long id) { + ExternalProcess enabledProcess = externalProcessService.changeProcessState(id, true); + if (enabledProcess != null) { + return new ResponseEntity<>(enabledProcess, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @PatchMapping("/{id}/disable") + public ResponseEntity disableExternalProcess(@PathVariable Long id) { + ExternalProcess disabledProcess = externalProcessService.changeProcessState(id, false); + if (disabledProcess != null) { + return new ResponseEntity<>(disabledProcess, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @GetMapping("/name/{processName}") // Using a path variable + public ResponseEntity getExternalProcessByName( + @PathVariable("processName") String processName) { + ExternalProcess process = externalProcessService.getExternalProcessByName(processName); + return new ResponseEntity<>(process, HttpStatus.OK); + } +} diff --git a/api/src/main/java/com/csiro/tickets/controllers/ExternalRequestorController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExternalRequestorController.java similarity index 81% rename from api/src/main/java/com/csiro/tickets/controllers/ExternalRequestorController.java rename to api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExternalRequestorController.java index cb596235d..2a56b30a1 100644 --- a/api/src/main/java/com/csiro/tickets/controllers/ExternalRequestorController.java +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ExternalRequestorController.java @@ -1,16 +1,31 @@ -package com.csiro.tickets.controllers; - -import com.csiro.snomio.exception.ErrorMessages; -import com.csiro.snomio.exception.ResourceAlreadyExists; -import com.csiro.snomio.exception.ResourceInUseProblem; -import com.csiro.snomio.exception.ResourceNotFoundProblem; -import com.csiro.tickets.models.ExternalRequestor; -import com.csiro.tickets.models.Ticket; -import com.csiro.tickets.repository.ExternalRequestorRepository; -import com.csiro.tickets.repository.TicketRepository; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ErrorMessages; +import au.gov.digitalhealth.lingo.exception.ResourceAlreadyExists; +import au.gov.digitalhealth.lingo.exception.ResourceInUseProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.models.ExternalRequestor; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.repository.ExternalRequestorRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import jakarta.transaction.Transactional; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -22,7 +37,6 @@ public class ExternalRequestorController { private final ExternalRequestorRepository externalRequestorRepository; private final TicketRepository ticketRepository; - @Autowired public ExternalRequestorController( ExternalRequestorRepository externalRequestorRepository, TicketRepository ticketRepository) { this.externalRequestorRepository = externalRequestorRepository; @@ -81,7 +95,7 @@ public ResponseEntity updateExternalRequestor( } @DeleteMapping(value = "/api/tickets/externalRequestors/{externalRequestorId}") - public ResponseEntity deleteExternalRequestor(@PathVariable Long externalRequestorId) { + public ResponseEntity deleteExternalRequestor(@PathVariable Long externalRequestorId) { ExternalRequestor foundExternalRequestor = externalRequestorRepository .findById(externalRequestorId) @@ -102,6 +116,7 @@ public ResponseEntity deleteExternalRequestor(@PathVariable Long externalRequest } @PostMapping(value = "/api/tickets/{ticketId}/externalRequestors/{externalRequestorId}") + @Transactional public ResponseEntity createExternalRequestor( @PathVariable Long externalRequestorId, @PathVariable Long ticketId) { ExternalRequestor externalRequestor = @@ -130,7 +145,8 @@ public ResponseEntity createExternalRequestor( } @DeleteMapping("/api/tickets/{ticketId}/externalRequestors/{externalRequestorId}") - public ResponseEntity deleteLabel( + @Transactional + public ResponseEntity deleteExternalRequestor( @PathVariable Long ticketId, @PathVariable Long externalRequestorId) { Optional externalRequestorOptional = externalRequestorRepository.findById(externalRequestorId); diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ImportResponse.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ImportResponse.java new file mode 100644 index 000000000..545a8e06b --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/ImportResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import lombok.Builder; +import lombok.Value; +import org.springframework.http.HttpStatus; + +@Value +@Builder +public class ImportResponse { + + private String message; + + private HttpStatus status; +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/IterationController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/IterationController.java new file mode 100644 index 000000000..4862b15c9 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/IterationController.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ResourceAlreadyExists; +import au.gov.digitalhealth.lingo.exception.ResourceInUseProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.models.Iteration; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.repository.IterationRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +public class IterationController { + + private final IterationRepository iterationRepository; + private final TicketRepository ticketRepository; + + public IterationController( + IterationRepository iterationRepository, TicketRepository ticketRepository) { + this.iterationRepository = iterationRepository; + this.ticketRepository = ticketRepository; + } + + @GetMapping("/api/tickets/iterations") + public ResponseEntity> getAllIterations() { + List iterations = iterationRepository.findAll(); + + return new ResponseEntity<>(iterations, HttpStatus.OK); + } + + @PostMapping(value = "/api/tickets/iterations", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createIteration(@RequestBody Iteration iteration) { + + String iterationName = iteration.getName(); + Optional iterationOptional = iterationRepository.findByName(iterationName); + + if (iterationOptional.isPresent()) { + throw new ResourceAlreadyExists( + String.format("Iteration with name %s already exists", iterationName)); + } + Iteration createdIteration = iterationRepository.save(iteration); + + return new ResponseEntity<>(createdIteration, HttpStatus.OK); + } + + @PutMapping( + value = "/api/tickets/iterations/{iterationId}", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity updateIteration( + @PathVariable Long iterationId, @RequestBody Iteration iteration) { + Iteration foundIteration = + iterationRepository + .findById(iterationId) + .orElseThrow( + () -> + new ResourceNotFoundProblem( + String.format("Iteration with id %s not found", iterationId))); + + foundIteration.setStartDate(iteration.getStartDate()); + foundIteration.setEndDate(iteration.getEndDate()); + foundIteration.setName(iteration.getName()); + foundIteration.setCompleted(iteration.isCompleted()); + foundIteration.setActive(iteration.isActive()); + + Iteration updatedIteration = iterationRepository.save(foundIteration); + + return new ResponseEntity<>(updatedIteration, HttpStatus.OK); + } + + @DeleteMapping(value = "/api/tickets/iterations/{iterationId}") + public ResponseEntity deleteIteration(@PathVariable Long iterationId) { + Iteration existingIteration = + iterationRepository + .findById(iterationId) + .orElseThrow( + () -> + new ResourceNotFoundProblem( + String.format("Iteration with ID %s not found", iterationId))); + List tickets = ticketRepository.findAllByIteration(existingIteration); + if (!tickets.isEmpty()) { + throw new ResourceInUseProblem( + String.format( + "Iteration with ID %s is mapped to tickets and can't be deleted", iterationId)); + } + iterationRepository.deleteById(iterationId); + return ResponseEntity.noContent().build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/JobResultController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/JobResultController.java new file mode 100644 index 000000000..839c4809a --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/JobResultController.java @@ -0,0 +1,98 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.JobResultDto; +import au.gov.digitalhealth.tickets.models.JobResult; +import au.gov.digitalhealth.tickets.models.mappers.JobResultMapper; +import au.gov.digitalhealth.tickets.repository.JobResultRepository; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/tickets/jobResults") +public class JobResultController { + + private final JobResultMapper jobResultMapper; + + private final JobResultRepository jobResultRepository; + + public JobResultController( + JobResultMapper jobResultMapper, JobResultRepository jobResultRepository) { + this.jobResultMapper = jobResultMapper; + this.jobResultRepository = jobResultRepository; + } + + @PostMapping + public ResponseEntity createJobResult(@RequestBody JobResultDto jobResultDto) { + + // If jobId is not provided, generate a unique one + if (jobResultDto.getJobId() == null || jobResultDto.getJobId().isBlank()) { + jobResultDto.setJobId(UUID.randomUUID().toString()); + } + + // Ensure the jobId is unique in the database + if (jobResultRepository.existsByJobId(jobResultDto.getJobId())) { + return new ResponseEntity<>(HttpStatus.CONFLICT); + } + + JobResult jobResult = jobResultMapper.toEntity(jobResultDto); + + jobResultRepository.save(jobResult); + + return new ResponseEntity<>(HttpStatus.CREATED); + } + + // There is a requirement that we only send JobResults from within the last week. + @GetMapping + public ResponseEntity> getJobResults() { + + Instant oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS); + + List jobResults = jobResultRepository.findByCreatedAfter(oneWeekAgo); + + List jobResultDtos = jobResults.stream().map(jobResultMapper::toDto).toList(); + + return new ResponseEntity<>(jobResultDtos, HttpStatus.OK); + } + + @PostMapping("/{jobResultId}") + public ResponseEntity acklowedgeJobResult(@PathVariable Long jobResultId) { + + JobResult jr = + jobResultRepository + .findById(jobResultId) + .orElseThrow( + () -> + new ResourceNotFoundProblem( + String.format("Job Result With Id %s not found", jobResultId))); + + jr.setAcknowledged(true); + + return new ResponseEntity<>(jobResultRepository.save(jr), HttpStatus.OK); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/controllers/JsonFieldController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/JsonFieldController.java new file mode 100644 index 000000000..3ae9fcfbc --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/JsonFieldController.java @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ResourceAlreadyExists; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.JsonFieldDto; +import au.gov.digitalhealth.tickets.models.JsonField; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.models.mappers.JsonFieldMapper; +import au.gov.digitalhealth.tickets.repository.JsonFieldRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/tickets/json-fields") +public class JsonFieldController { + + private static final String JSON_FIELD_WITH_ID_NOT_FOUND = "JsonField with id %s not found"; + private final JsonFieldRepository jsonFieldRepository; + private final TicketRepository ticketRepository; + private final JsonFieldMapper jsonFieldMapper; + + public JsonFieldController( + JsonFieldRepository jsonFieldRepository, + TicketRepository ticketRepository, + JsonFieldMapper jsonFieldMapper) { + this.jsonFieldRepository = jsonFieldRepository; + this.ticketRepository = ticketRepository; + this.jsonFieldMapper = jsonFieldMapper; + } + + @GetMapping("/{ticketId}") + public ResponseEntity> getAllJsonFields(@PathVariable Long ticketId) { + List jsonFields = + jsonFieldRepository.findAll().stream().map(jsonFieldMapper::toDto).toList(); + return new ResponseEntity<>(jsonFields, HttpStatus.OK); + } + + @Transactional + @PostMapping("/{ticketId}") + public ResponseEntity createJsonField( + @PathVariable Long ticketId, @RequestBody JsonField jsonField) { + Optional ticketOptional = ticketRepository.findById(ticketId); + if (ticketOptional.isEmpty()) { + throw new ResourceNotFoundProblem(String.format("Ticket with id %s not found", ticketId)); + } + + Ticket ticket = ticketOptional.get(); + String jsonFieldName = jsonField.getName(); + Optional jsonFieldOptional = + jsonFieldRepository.findByNameAndTicket(jsonFieldName, ticket); + if (jsonFieldOptional.isPresent()) { + throw new ResourceAlreadyExists( + String.format("JsonField with name %s already exists for the ticket", jsonFieldName)); + } + + jsonField.setTicket(ticket); + JsonField jsonFieldToAdd = jsonFieldRepository.save(jsonField); + return new ResponseEntity<>(jsonFieldMapper.toDto(jsonFieldToAdd), HttpStatus.CREATED); + } + + @Transactional + @PutMapping("/{jsonFieldId}") + public ResponseEntity updateJsonField( + @PathVariable Long jsonFieldId, @RequestBody JsonFieldDto jsonFieldDto) { + JsonField foundJsonField = + jsonFieldRepository + .findById(jsonFieldId) + .orElseThrow( + () -> + new ResourceNotFoundProblem( + String.format(JSON_FIELD_WITH_ID_NOT_FOUND, jsonFieldId))); + foundJsonField.setValue(jsonFieldDto.getValue()); + jsonFieldRepository.save(foundJsonField); + return new ResponseEntity<>(jsonFieldMapper.toDto(foundJsonField), HttpStatus.OK); + } + + @Transactional + @DeleteMapping("/{jsonFieldId}") + public ResponseEntity deleteJsonField(@PathVariable Long jsonFieldId) { + if (!jsonFieldRepository.existsById(jsonFieldId)) { + throw new ResourceNotFoundProblem(String.format(JSON_FIELD_WITH_ID_NOT_FOUND, jsonFieldId)); + } + jsonFieldRepository.deleteById(jsonFieldId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @GetMapping("/{jsonFieldId}") + public ResponseEntity getJsonField(@PathVariable Long jsonFieldId) { + JsonField jsonField = + jsonFieldRepository + .findById(jsonFieldId) + .orElseThrow( + () -> + new ResourceNotFoundProblem( + String.format(JSON_FIELD_WITH_ID_NOT_FOUND, jsonFieldId))); + return new ResponseEntity<>(jsonFieldMapper.toDto(jsonField), HttpStatus.OK); + } +} diff --git a/api/src/main/java/com/csiro/tickets/controllers/LabelController.java b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/LabelController.java similarity index 77% rename from api/src/main/java/com/csiro/tickets/controllers/LabelController.java rename to api/src/main/java/au/gov/digitalhealth/tickets/controllers/LabelController.java index 1ba90c612..8df13451d 100644 --- a/api/src/main/java/com/csiro/tickets/controllers/LabelController.java +++ b/api/src/main/java/au/gov/digitalhealth/tickets/controllers/LabelController.java @@ -1,17 +1,31 @@ -package com.csiro.tickets.controllers; - -import com.csiro.snomio.exception.ErrorMessages; -import com.csiro.snomio.exception.ResourceAlreadyExists; -import com.csiro.snomio.exception.ResourceInUseProblem; -import com.csiro.snomio.exception.ResourceNotFoundProblem; -import com.csiro.tickets.models.Label; -import com.csiro.tickets.models.Ticket; -import com.csiro.tickets.repository.LabelRepository; -import com.csiro.tickets.repository.TicketRepository; -import com.csiro.tickets.service.TicketService; +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.controllers; + +import au.gov.digitalhealth.lingo.exception.ErrorMessages; +import au.gov.digitalhealth.lingo.exception.ResourceAlreadyExists; +import au.gov.digitalhealth.lingo.exception.ResourceInUseProblem; +import au.gov.digitalhealth.lingo.exception.ResourceNotFoundProblem; +import au.gov.digitalhealth.tickets.models.Label; +import au.gov.digitalhealth.tickets.models.Ticket; +import au.gov.digitalhealth.tickets.repository.LabelRepository; +import au.gov.digitalhealth.tickets.repository.TicketRepository; +import au.gov.digitalhealth.tickets.service.TicketServiceImpl; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -22,15 +36,14 @@ public class LabelController { final TicketRepository ticketRepository; - final TicketService ticketService; + final TicketServiceImpl ticketService; final LabelRepository labelRepository; - @Autowired public LabelController( TicketRepository ticketRepository, LabelRepository labelRepository, - TicketService ticketService) { + TicketServiceImpl ticketService) { this.ticketRepository = ticketRepository; this.labelRepository = labelRepository; this.ticketService = ticketService; @@ -71,7 +84,7 @@ public ResponseEntity

Pbs Description:" + this.getDescription() + "

"; + } + + // TODO: name and description, as these aren't necassarily what is in the title & description of + // the ticket + public static PbsRequest fromTicket(Ticket ticket) { + String artgid = AdditionalFieldUtils.findValueByAdditionalFieldName("ARTGID", ticket); + Long artgidLong = artgid != null ? Long.valueOf(artgid) : null; + return PbsRequest.builder().id(ticket.getId()).artgid(artgidLong).build(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/PbsRequestResponse.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/PbsRequestResponse.java new file mode 100644 index 000000000..688808e9d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/PbsRequestResponse.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import au.gov.digitalhealth.tickets.models.Ticket; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PbsRequestResponse { + // correspends to a ticket Id + private Long id; + + private Instant submissionDate; + + // intentionally returned as null, as this is handled by the amtapi, depending on what the + // concepts status is in snowstorm + private RequestStatus status; + + private PbsRequest productSubmission; + + @Builder.Default private List relatedConcepts = new ArrayList<>(); + + public PbsRequestResponse(Ticket ticket) { + this(PbsRequest.fromTicket(ticket), ticket); + } + + public PbsRequestResponse(PbsRequest pbsRequest, Ticket ticket) { + this.id = ticket.getId(); + this.submissionDate = ticket.getCreated(); + this.relatedConcepts = + ticket.getProducts().stream() + .map( + product -> + RequestConcept.builder() + .id(product.getConceptId()) + .name(product.getName()) + .build()) + .toList(); + this.productSubmission = + PbsRequest.builder() + .id(ticket.getId()) + .artgid(pbsRequest.getArtgid()) + .name(ticket.getTitle()) + .build(); + } + + private enum RequestStatus { + PENDING("Pending"), + AUTHORED_FOR_RELEASE("Authored for release"), + RELEASED("Released"), + REJECTED("Rejected"); + + private final String status; + + RequestStatus(String status) { + this.status = status; + } + + public String getStatus() { + return status; + } + } + + @Builder + private static class RequestConcept { + + Long id; + + String name; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/SafeUtils.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SafeUtils.java new file mode 100644 index 000000000..db906ffee --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SafeUtils.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import au.gov.digitalhealth.lingo.exception.LingoProblem; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import org.apache.commons.logging.Log; +import org.springframework.http.HttpStatus; + +public class SafeUtils { + + private SafeUtils() { + throw new IllegalStateException("Utility class"); + } + + public static void checkFile( + File originalFile, String allowedImportDirectory, Class exceptionClass) { + T exception; + try { + exception = exceptionClass.getDeclaredConstructor().newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { + throw new LingoProblem( + "check-file-error", + "Error instantiating exception: " + e.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + if (originalFile == null) { + exception.setDetail("File location cannot be null!"); + throw exception; + } + try { + String canonicalOriginalFilePath = originalFile.getCanonicalPath(); + if (canonicalOriginalFilePath.contains("..")) { + exception.setDetail("Invalid file path provided. Don't use .. in your file path!"); + throw exception; + } + } catch (IOException e) { + exception.setDetail("Issue while checking file paths: " + e.getMessage()); + throw exception; + } + if (!originalFile.toPath().normalize().startsWith(allowedImportDirectory)) { + exception.setDetail("Entry is outside of the allowed import directory"); + throw exception; + } + if (!originalFile.exists()) { + exception.setDetail("File doesn't exist: " + originalFile.getAbsolutePath()); + throw exception; + } + } + + public static void loginfo(Log logger, String message) { + if (message != null) { + message = message.replaceAll("[\n\r]", "_"); + logger.info(message); + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchCondition.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchCondition.java new file mode 100644 index 000000000..b60907c59 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchCondition.java @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SearchCondition implements Serializable { + private String key; + private String operation; + private String value; + private List valueIn; + private String condition; + + public static class SearchConditionBuilder { + private List valueIn; + + public SearchConditionBuilder valueIn(String value) { + this.valueIn = parseValuesInBrackets(value); + return this; + } + + private List parseValuesInBrackets(String input) { + // Regular expression to match names inside square brackets + Pattern pattern = Pattern.compile("\\[([^\\]]*)\\]"); + Matcher matcher = pattern.matcher(input); + + if (matcher.matches()) { + // Group 1 contains the names + String namesString = matcher.group(1); + + // Split the names by comma + String[] nameArray = namesString.split(","); + + // Convert array to a list + List namesList = new ArrayList<>(); + for (String name : nameArray) { + namesList.add(name.trim()); // Trim to remove leading/trailing whitespaces + } + + return namesList; + } else { + return null; // Invalid format + } + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionBody.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionBody.java new file mode 100644 index 000000000..804a7171c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionBody.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import java.io.Serializable; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SearchConditionBody implements Serializable { + + private OrderCondition orderCondition; + private List searchConditions; +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionFactory.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionFactory.java new file mode 100644 index 000000000..a64b2f522 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionFactory.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import java.util.ArrayList; +import java.util.List; + +public class SearchConditionFactory { + + private SearchConditionFactory() {} + + public static List parseSearchConditions(String searchParam) { + List conditions = new ArrayList<>(); + + String[] conditionsArray = searchParam.split("(?=&)|(?<=&)"); + + String lastCondition = "&"; + + for (String condition : conditionsArray) { + if (condition.equals("&") || condition.equals(",")) { + lastCondition = condition; + continue; + } + String[] parts = condition.split("="); + + if (parts.length == 2) { + String key = parts[0]; + String value = parts[1]; + String operator = lastCondition.equals("&") ? "and" : "or"; + + conditions.add( + SearchCondition.builder() + .key(key) + .operation("=") + .value(value) + .valueIn(value) + .condition(operator) + .build()); + } + } + + return conditions; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionUtils.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionUtils.java new file mode 100644 index 000000000..2ab54c3fd --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/SearchConditionUtils.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +public class SearchConditionUtils { + + public static final String NOT_EQUALS = "!="; + + public static final String EQUAL_TO = "="; + public static final String GREATER_THAN = ">="; + public static final String LESS_THAN = "<="; + + private SearchConditionUtils() {} +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/StringUtils.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/StringUtils.java new file mode 100644 index 000000000..c978999ab --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/StringUtils.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +public class StringUtils { + + private StringUtils() { + throw new IllegalStateException("Utility class"); + } + + public static String removePageAndAfter(String input) { + int pageIndex = input.indexOf("&page"); + if (pageIndex != -1) { + return input.substring(0, pageIndex); + } else { + return input; + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/TaskTicketUtils.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/TaskTicketUtils.java new file mode 100644 index 000000000..9e0368d1d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/TaskTicketUtils.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import au.gov.digitalhealth.lingo.util.Task; +import au.gov.digitalhealth.tickets.models.Ticket; +import java.util.List; + +public class TaskTicketUtils { + + private TaskTicketUtils() {} + + public static Task findAssociatedTask(Ticket ticket, List tasks) { + for (Task task : tasks) { + if (ticket.getTaskAssociation().getTaskId().equals(task.getKey())) { + return task; + } + } + return null; + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/TicketPredicateBuilder.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/TicketPredicateBuilder.java new file mode 100644 index 000000000..76188d206 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/TicketPredicateBuilder.java @@ -0,0 +1,446 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import au.gov.digitalhealth.lingo.exception.InvalidSearchProblem; +import au.gov.digitalhealth.tickets.models.QTicket; +import au.gov.digitalhealth.tickets.models.QTicketAssociation; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.DateTimePath; +import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.JPAExpressions; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public class TicketPredicateBuilder { + public static final String TICKET_NUMBER_PATH = "ticketnumber"; + + public static final String TITLE_PATH = "title"; + + public static final String ASSIGNEE_PATH = "assignee"; + + public static final String CREATED_PATH = "created"; + + public static final String DESCRIPTION_PATH = "description"; + + public static final String COMMENTS_PATH = "comments.text"; + + public static final String PRIORITY_PATH = "prioritybucket.name"; + + public static final String LABELS_PATH = "labels.name"; + public static final String EXTERNAL_REQUESTORS_PATH = "externalrequestors.name"; + + public static final String STATE_PATH = "state.label"; + + public static final String SCHEDULE_PATH = "schedule.name"; + + public static final String SCHEDULE_ORDER_PATH = "schedule.grouping"; + + public static final String ITERATION_PATH = "iteration.name"; + + public static final String AF_PATH = "additionalfieldvalues.valueOf"; + + public static final String TASK_PATH = "taskassociation"; + + public static final String TASK_ID_PATH = "taskassociation.taskid"; + + public static final String TICKET_ASSOCIATION = "ticketassociation"; + + private TicketPredicateBuilder() {} // SonarLint + + public static BooleanBuilder buildPredicate(String search) { + + List searchConditions = SearchConditionFactory.parseSearchConditions(search); + + return buildPredicateFromSearchConditions(searchConditions); + } + + public static BooleanBuilder buildPredicateFromSearchConditions( + List searchConditions) { + BooleanBuilder predicate = new BooleanBuilder(); + + if (searchConditions == null) return predicate; + searchConditions.forEach( + searchCondition -> { + BooleanExpression combinedConditions = null; + BooleanExpression booleanExpression = null; + StringPath path = null; + String field = searchCondition.getKey().toLowerCase(); + String condition = searchCondition.getCondition(); + String operation = searchCondition.getOperation(); + String value = searchCondition.getValue(); + List valueIn = searchCondition.getValueIn(); + List valueInWithNull = searchCondition.getValueIn(); + BooleanExpression nullExpression = null; + if (valueInContainsNull(valueIn) + || (searchCondition.getOperation().equals(SearchConditionUtils.NOT_EQUALS) + && !valueInContainsNull(valueIn))) { + nullExpression = createNullExpressions(field); + valueIn = removeNullValueIn(valueIn); + } + if (TICKET_NUMBER_PATH.equals(field)) { + path = QTicket.ticket.ticketNumber; + } + if (TITLE_PATH.equals(field)) { + path = QTicket.ticket.title; + } + if (ASSIGNEE_PATH.equals(field)) { + path = QTicket.ticket.assignee; + } + if (CREATED_PATH.equals(field)) { + // special case + DateTimePath datePath = QTicket.ticket.created; + String[] dates = InstantUtils.splitDates(value); + Instant startOfRange = InstantUtils.convert(dates[0]); + if (startOfRange == null) { + throw new InvalidSearchProblem("Incorrectly formatted date"); + } + Instant endOfRange = null; + if (dates.length == 2 && dates[1] != null) { + endOfRange = InstantUtils.convert(dates[1]); + } else { + endOfRange = startOfRange.plus(Duration.ofDays(1).minusMillis(1)); + } + BooleanExpression between = datePath.between(startOfRange, endOfRange); + switch (operation) { + case SearchConditionUtils.NOT_EQUALS -> predicate.and(between.not()); + case SearchConditionUtils.LESS_THAN -> predicate.and(datePath.before(startOfRange)); + case SearchConditionUtils.GREATER_THAN -> predicate.and(datePath.after(endOfRange)); + default -> predicate.and(between); + } + } + if (DESCRIPTION_PATH.equals(field)) { + path = QTicket.ticket.description; + } + if (COMMENTS_PATH.equals(field)) { + path = QTicket.ticket.comments.any().text; + } + if (ITERATION_PATH.equals(field)) { + path = QTicket.ticket.iteration.name; + } + if (PRIORITY_PATH.equals(field)) { + path = QTicket.ticket.priorityBucket.name; + } + if (STATE_PATH.equals(field)) { + path = QTicket.ticket.state.label; + } + if (SCHEDULE_PATH.equals(field)) { + path = QTicket.ticket.schedule.name; + } + if (LABELS_PATH.equals(field)) { + path = QTicket.ticket.labels.any().name; + + if (condition.equalsIgnoreCase("and")) { + for (String labelName : valueIn) { + if (combinedConditions == null) { + combinedConditions = QTicket.ticket.labels.any().name.eq(labelName); + } else { + combinedConditions = + combinedConditions.and(QTicket.ticket.labels.any().name.eq(labelName)); + } + } + } + if (condition.equalsIgnoreCase("or")) { + for (String labelName : valueIn) { + if (combinedConditions == null) { + combinedConditions = QTicket.ticket.labels.any().name.eq(labelName); + } else { + combinedConditions = + combinedConditions.or(QTicket.ticket.labels.any().name.eq(labelName)); + } + } + } + addNeNullExpression(combinedConditions, nullExpression, condition); + if (operation.equals(SearchConditionUtils.NOT_EQUALS) && combinedConditions != null) { + combinedConditions = combinedConditions.not(); + } + } + if (EXTERNAL_REQUESTORS_PATH.equals(field)) { + path = QTicket.ticket.externalRequestors.any().name; + + if (condition.equalsIgnoreCase("and")) { + for (String labelName : valueIn) { + if (combinedConditions == null) { + combinedConditions = QTicket.ticket.externalRequestors.any().name.eq(labelName); + } else { + combinedConditions = + combinedConditions.and( + QTicket.ticket.externalRequestors.any().name.eq(labelName)); + } + } + } + + if (condition.equalsIgnoreCase("or")) { + for (String labelName : valueIn) { + if (combinedConditions == null) { + combinedConditions = QTicket.ticket.externalRequestors.any().name.eq(labelName); + } else { + combinedConditions = + combinedConditions.or( + QTicket.ticket.externalRequestors.any().name.eq(labelName)); + } + } + } + addNeNullExpression(combinedConditions, nullExpression, condition); + if (operation.equals(SearchConditionUtils.NOT_EQUALS) && combinedConditions != null) { + combinedConditions = combinedConditions.not(); + } + } + + if (AF_PATH.equals(field)) { + path = QTicket.ticket.additionalFieldValues.any().valueOf; + } + if (TASK_PATH.equals(field)) { + booleanExpression = QTicket.ticket.taskAssociation.isNull(); + } + if (TASK_ID_PATH.equals(field)) { + path = QTicket.ticket.taskAssociation.taskId; + } + if (TICKET_ASSOCIATION.equals(field)) { + // don't want to find ourself, nor do we want to find any ticket we are already linked + // to, so one where we are the source association, + // or any ticket where we are the targetAssociation + QTicket ticket = QTicket.ticket; + QTicketAssociation association = QTicketAssociation.ticketAssociation; + + Long ticketId = Long.valueOf(value); + + BooleanExpression notSelfTicket = ticket.id.ne(ticketId); + + // Find tickets that are not associated with the passed ticketId in either direction + BooleanExpression notAssociated = + ticket + .id + .notIn( + JPAExpressions.select(association.associationSource.id) + .from(association) + .where(association.associationTarget.id.eq(ticketId))) + .and( + ticket.id.notIn( + JPAExpressions.select(association.associationTarget.id) + .from(association) + .where(association.associationSource.id.eq(ticketId)))); + + booleanExpression = notAssociated.and(notSelfTicket); + } + + if (combinedConditions == null) { + createPredicate( + field, + predicate, + booleanExpression, + nullExpression, + path, + value, + valueIn, + valueInWithNull, + searchCondition, + operation, + searchConditions); + } else { + predicate.and(combinedConditions); + } + }); + + return predicate; + } + + private static void createPredicate( + String field, + BooleanBuilder predicate, + BooleanExpression booleanExpression, + BooleanExpression nullExpression, + StringPath path, + String value, + List valueIn, + List valueInWithNull, + SearchCondition searchCondition, + String operation, + List searchConditions) { + + if (booleanExpression != null) { + + predicate.and(booleanExpression); + } + if (path == null) return; + + BooleanExpression generatedExpression; + if (operation.equals("!=")) { + generatedExpression = + createNePath(path, nullExpression, value, valueIn, valueInWithNull, searchCondition); + } else { + generatedExpression = createPath(path, nullExpression, value, valueIn); + } + + if (!predicate.hasValue()) { + predicate.or(generatedExpression); + } else if ((COMMENTS_PATH.equals(field) + || TICKET_NUMBER_PATH.equals(field) + || TITLE_PATH.equals(field)) + && isQuickSearchField(searchConditions)) { + predicate.or(generatedExpression); + } else { + predicate.and(generatedExpression); + } + } + + private static boolean isQuickSearchField(List searchConditions) { + Set requiredKeys = + new HashSet<>( + Arrays.asList( + COMMENTS_PATH.toLowerCase(), + TICKET_NUMBER_PATH.toLowerCase(), + TITLE_PATH.toLowerCase())); + int matchCount = 0; + + for (SearchCondition searchCondition : searchConditions) { + String key = searchCondition.getKey().toLowerCase(); + if (requiredKeys.contains(key)) { + matchCount++; + if (matchCount >= 2) { + return true; + } + } + } + + return false; + } + + private static BooleanExpression createNePath( + StringPath path, + BooleanExpression nullExpression, + String value, + List valueIn, + List valueInWithNull, + SearchCondition searchCondition) { + String andOrOr; + if (value == null && valueIn != null) { + + BooleanExpression expression = path.notIn(valueIn); + BooleanExpression nullExpression2; + if (valueInWithNull.contains("null")) { + andOrOr = "and"; + nullExpression2 = nullExpression.not(); + } else { + andOrOr = "or"; + nullExpression2 = nullExpression; + } + + return addNeNullExpression(!valueIn.isEmpty() ? expression : null, nullExpression2, andOrOr); + } + andOrOr = searchCondition.getCondition(); + if (value != null && (value.equals("null") || value.isEmpty())) { + return addNeNullExpression(path.isNull(), nullExpression, andOrOr); + } + + return addNeNullExpression(path.containsIgnoreCase(value), nullExpression, andOrOr); + } + + private static BooleanExpression createPath( + StringPath path, BooleanExpression nullExpression, String value, List valueIn) { + + if (value == null && valueIn != null) { + return addNullExpression(!valueIn.isEmpty() ? path.in(valueIn) : null, nullExpression); + } + + if (value != null && (value.equals("null") || value.isEmpty())) { + return addNullExpression(path.isNull(), nullExpression); + } + + if (value != null && value.contains("!")) { + // first part !, second part val + String[] parts = value.split("!"); + return addNullExpression(path.containsIgnoreCase(parts[1]).not(), nullExpression); + } + return addNullExpression(path.containsIgnoreCase(value), nullExpression); + } + + private static BooleanExpression addNullExpression( + BooleanExpression booleanExpression, BooleanExpression nullExpression) { + if (booleanExpression == null) { + return nullExpression; + } + if (nullExpression != null) { + return booleanExpression.or(nullExpression); + } + return booleanExpression; + } + + private static BooleanExpression addNeNullExpression( + BooleanExpression booleanExpression, BooleanExpression nullExpression, String andOrOr) { + if (booleanExpression == null) { + return nullExpression; + } + if (nullExpression != null) { + return andOrOr.equals("and") + ? booleanExpression.and(nullExpression) + : booleanExpression.or(nullExpression); + } + return booleanExpression; + } + + private static BooleanExpression createNullExpressions(String field) { + BooleanExpression nullPath = null; + + if (ASSIGNEE_PATH.equals(field)) { + nullPath = QTicket.ticket.assignee.isNull(); + } + if (ITERATION_PATH.equals(field)) { + nullPath = QTicket.ticket.iteration.isNull(); + } + if (PRIORITY_PATH.equals(field)) { + nullPath = QTicket.ticket.priorityBucket.isNull(); + } + if (STATE_PATH.equals(field)) { + nullPath = QTicket.ticket.state.isNull(); + } + if (SCHEDULE_PATH.equals(field)) { + nullPath = QTicket.ticket.schedule.isNull(); + } + if (TASK_PATH.equals(field)) { + nullPath = QTicket.ticket.taskAssociation.isNull(); + } + if (TASK_ID_PATH.equals(field)) { + nullPath = QTicket.ticket.taskAssociation.isNull(); + } + if (LABELS_PATH.equals(field)) { + nullPath = QTicket.ticket.labels.isEmpty(); + } + if (EXTERNAL_REQUESTORS_PATH.equals(field)) { + nullPath = QTicket.ticket.externalRequestors.isEmpty(); + } + + return nullPath; + } + + private static boolean valueInContainsNull(List valueIn) { + if (valueIn == null) return false; + return valueIn.contains("null"); + } + + private static List removeNullValueIn(List valueIn) { + return valueIn == null + ? Collections.emptyList() + : valueIn.stream().filter(Objects::nonNull).filter(v -> !v.equals("null")).toList(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/helper/TicketUtils.java b/api/src/main/java/au/gov/digitalhealth/tickets/helper/TicketUtils.java new file mode 100644 index 000000000..af205dda1 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/helper/TicketUtils.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.helper; + +import au.gov.digitalhealth.tickets.models.Label; +import au.gov.digitalhealth.tickets.models.Ticket; + +public class TicketUtils { + + public static final String DUPLICATE_LABEL_NAME = "Duplicate"; + + private TicketUtils() {} + + public static boolean isTicketDuplicate(Ticket ticket) { + for (Label label : ticket.getLabels()) { + if (DUPLICATE_LABEL_NAME.equals(label.getName())) { + return true; + } + } + return false; + } +} diff --git a/api/src/main/java/com/csiro/tickets/lombok.config b/api/src/main/java/au/gov/digitalhealth/tickets/lombok.config similarity index 100% rename from api/src/main/java/com/csiro/tickets/lombok.config rename to api/src/main/java/au/gov/digitalhealth/tickets/lombok.config diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/AdditionalFieldType.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/AdditionalFieldType.java new file mode 100644 index 000000000..be0387757 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/AdditionalFieldType.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Pattern; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "additional_field_type") +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class AdditionalFieldType extends BaseAuditableEntity { + + public static final String ADDITIONAL_FIELD_TYPE_NAME_PATTERN = "^[a-zA-Z]{1,29}$"; + + @Column(unique = true) + @Pattern( + regexp = ADDITIONAL_FIELD_TYPE_NAME_PATTERN, + message = + "Name must be less than 30 characters and contain only upper and lower case letters with no spaces or special characters") + private String name; + + @Column private String description; + + @Column(columnDefinition = "BOOLEAN DEFAULT true") + private boolean display; + + @Enumerated(EnumType.STRING) + @Column + private Type type; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + AdditionalFieldType that = (AdditionalFieldType) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } + + public enum Type { + DATE, + NUMBER, + STRING, + LIST + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/AdditionalFieldValue.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/AdditionalFieldValue.java new file mode 100644 index 000000000..9bcfa0c0d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/AdditionalFieldValue.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.ToString.Exclude; +import lombok.experimental.SuperBuilder; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "additional_field_value") +@Audited +@EntityListeners(AuditingEntityListener.class) +public class AdditionalFieldValue extends BaseAuditableEntity { + + @ManyToMany( + mappedBy = "additionalFieldValues", + fetch = FetchType.LAZY, + cascade = CascadeType.PERSIST) + @JsonIgnore + @Exclude + private List tickets; + + @ManyToOne( + cascade = {CascadeType.PERSIST}, + fetch = FetchType.EAGER) + @NotNull + private AdditionalFieldType additionalFieldType; + + @Column private String valueOf; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + AdditionalFieldValue that = (AdditionalFieldValue) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Attachment.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Attachment.java new file mode 100644 index 000000000..99c9954ee --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Attachment.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@Table(name = "attachment") +@Audited +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class Attachment extends BaseAuditableEntity { + + @Column private String description; + + @Column private String filename; + + @Column private String location; + + @Column private String thumbnailLocation; + + @Column private Long length; + + @Column private String sha256; + + @Column private Instant jiraCreated; + + @ManyToOne( + cascade = {CascadeType.PERSIST}, + fetch = FetchType.EAGER) + private AttachmentType attachmentType; + + @ManyToOne + @JsonBackReference(value = "ticket-attachment") + private Ticket ticket; + + @PrePersist + public void prePersist() { + if (jiraCreated != null) { + setCreated(jiraCreated); + } + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Attachment that = (Attachment) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/AttachmentType.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/AttachmentType.java new file mode 100644 index 000000000..00f600472 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/AttachmentType.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import au.gov.digitalhealth.tickets.helper.MimeTypeUtils; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.NaturalId; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@Audited +@Builder +@AllArgsConstructor +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table(name = "attachment_type") +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class AttachmentType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column private String name; + + @Column(unique = true) + @NaturalId + private String mimeType; + + public static AttachmentType of(AttachmentType attachmentType) { + return AttachmentType.builder() + .name(attachmentType.getName()) + .mimeType(attachmentType.getMimeType()) + .build(); + } + + public static AttachmentType of(AttachmentType attachmentType, boolean fixname) { + return AttachmentType.builder() + .name( + fixname + ? MimeTypeUtils.toHumanReadable(attachmentType.getMimeType()) + : attachmentType.getName()) + .mimeType(attachmentType.getMimeType()) + .build(); + } + + public static AttachmentType of(String attachmentType) { + return AttachmentType.builder() + .name(MimeTypeUtils.toHumanReadable(attachmentType)) + .mimeType(attachmentType) + .build(); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + AttachmentType that = (AttachmentType) o; + return getMimeType() != null && Objects.equals(getMimeType(), that.getMimeType()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/BaseAuditableEntity.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/BaseAuditableEntity.java new file mode 100644 index 000000000..59b3fd2d7 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/BaseAuditableEntity.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Version; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; + +@Getter +@Setter +@ToString +@MappedSuperclass +@SuperBuilder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public abstract class BaseAuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Version private Integer version; + + @Column(name = "created", nullable = false, updatable = false) + @CreatedDate + private Instant created; + + @Column(name = "created_by", updatable = false) + @CreatedBy + private String createdBy; + + @Column(name = "modified") + @LastModifiedDate + private Instant modified; + + @Column(name = "modified_by") + @LastModifiedBy + private String modifiedBy; +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/BulkProductAction.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/BulkProductAction.java new file mode 100644 index 000000000..b27fb89ad --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/BulkProductAction.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import au.gov.digitalhealth.lingo.product.bulk.BulkProductActionDetails; +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode.Exclude; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.hibernate.type.SqlTypes; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@SuperBuilder +@Getter +@Setter +@Audited +@AllArgsConstructor +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "bulk_product_action", + uniqueConstraints = + @UniqueConstraint( + name = "bulk_product_name_ticket_unique", + columnNames = {"ticket_id", "name"})) +public class BulkProductAction extends BaseAuditableEntity { + + @ManyToOne + @JoinColumn(name = "ticket_id", nullable = false) + @JsonBackReference(value = "ticket-bulk-product-action") + @Exclude + private Ticket ticket; + + @NotNull + @NotEmpty + @Column(nullable = false, length = 2048) + private String name; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "bulk_concept_ids", joinColumns = @JoinColumn(name = "id")) + @Column(name = "concept_id") + @Builder.Default + private Set conceptIds = new HashSet<>(); + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + private BulkProductActionDetails details; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + BulkProductAction that = (BulkProductAction) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Comment.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Comment.java new file mode 100644 index 000000000..71b894b37 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Comment.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@ToString +@Table(name = "comment") +@Entity +@Audited +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class Comment extends BaseAuditableEntity { + + @ManyToOne(cascade = CascadeType.DETACH) + @JoinColumn(name = "ticket_id") + @JsonBackReference(value = "ticket-comment") + private Ticket ticket; + + @Column(length = 1000000) + private String text; + + @Column private Instant jiraCreated; + + public static Comment of(Comment comment) { + return Comment.builder().text(comment.getText()).jiraCreated(comment.getJiraCreated()).build(); + } + + @PrePersist + public void prePersist() { + if (jiraCreated != null) { + setCreated(jiraCreated); + } + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Comment comment = (Comment) o; + return getId() != null && Objects.equals(getId(), comment.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/ExternalProcess.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/ExternalProcess.java new file mode 100644 index 000000000..df530f19d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/ExternalProcess.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.envers.Audited; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "external_process") +@Entity +@Audited +@EntityListeners(AuditingEntityListener.class) +public class ExternalProcess extends BaseAuditableEntity { + + private String processName; + + private boolean enabled; +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/ExternalRequestor.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/ExternalRequestor.java new file mode 100644 index 000000000..baea3eb8c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/ExternalRequestor.java @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@SuperBuilder(toBuilder = true) +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@ToString +@Table(name = "external_requestor") +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class ExternalRequestor extends BaseAuditableEntity { + + @Column(unique = true) + private String name; + + private String description; + + // Can be success, error, warning, info, secondary, primary or some hex value + private String displayColor; + + public static ExternalRequestor of(ExternalRequestor label) { + return ExternalRequestor.builder() + .name(label.getName()) + .description(label.getDescription()) + .displayColor(label.getDisplayColor()) + .build(); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + ExternalRequestor that = (ExternalRequestor) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Iteration.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Iteration.java new file mode 100644 index 000000000..7d86f7e2c --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Iteration.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "iteration") +@Entity +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class Iteration extends BaseAuditableEntity { + + @Column(unique = true) + private String name; + + @Column private Instant startDate; + + @Column private Instant endDate; + + @Column private boolean active; + + @Column private boolean completed; + + @OneToMany( + mappedBy = "iteration", + fetch = FetchType.EAGER, + cascade = {CascadeType.ALL, CascadeType.REMOVE}, + orphanRemoval = false) + @Transient + @JsonIgnore + private List tickets; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Iteration iteration = (Iteration) o; + return getId() != null && Objects.equals(getId(), iteration.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/JobResult.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/JobResult.java new file mode 100644 index 000000000..e1a7c3333 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/JobResult.java @@ -0,0 +1,109 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.envers.Audited; +import org.hibernate.type.SqlTypes; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "job_results") +@Entity +@Audited +@EntityListeners(AuditingEntityListener.class) +public class JobResult extends BaseAuditableEntity { + + private String jobName; + + private String jobId; + + private Instant finishedTime; + + private boolean acknowledged; + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private List results; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Result { + + private String name; + + private int count; + + private ResultNotification notification; + + private List results; + + private List items; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResultItem { + + private String id; + + private String title; + + private String link; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResultNotification { + + private ResultNotificationType type; + + private String description; + + public enum ResultNotificationType { + ERROR, + WARNING, + SUCCESS + } + } + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/JsonField.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/JsonField.java new file mode 100644 index 000000000..8f95e3d15 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/JsonField.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.ToString.Exclude; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.hibernate.type.SqlTypes; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table( + name = "json_field", + uniqueConstraints = @UniqueConstraint(columnNames = {"name", "ticket_id"})) +@Entity +@Audited +@EntityListeners(AuditingEntityListener.class) +public class JsonField extends BaseAuditableEntity { + + @Column(name = "name") + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id") + @JsonIgnoreProperties("jsonFields") + @Exclude // Avoiding recursion + private Ticket ticket; + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode value; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + JsonField jsonField = (JsonField) o; + return getId() != null && Objects.equals(getId(), jsonField.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Label.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Label.java new file mode 100644 index 000000000..cd6d60ea8 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Label.java @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@SuperBuilder(toBuilder = true) +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@ToString +@Table(name = "label") +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class Label extends BaseAuditableEntity { + + @Column(unique = true) + private String name; + + private String description; + + // Can be success, error, warning, info, secondary, primary or some hex value + private String displayColor; + + public static Label of(Label label) { + return Label.builder() + .name(label.getName()) + .description(label.getDescription()) + .displayColor(label.getDisplayColor()) + .build(); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Label label = (Label) o; + return getId() != null && Objects.equals(getId(), label.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/ModifiedGeneratedName.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/ModifiedGeneratedName.java new file mode 100644 index 000000000..ebc84b201 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/ModifiedGeneratedName.java @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import au.gov.digitalhealth.lingo.product.NameGeneratorSpec; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "modified_generated_name") +@Entity +@EntityListeners(AuditingEntityListener.class) +public class ModifiedGeneratedName { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column private String identifier; + + @Column(nullable = false, updatable = false) + @CreatedDate + private Instant created; + + @Column(updatable = false) + @CreatedBy + private String createdBy; + + @Column private String taskId; + + @Column private String generatedFullySpecifiedName; + + @Column private String modifiedFullySpecifiedName; + + @Column private String generatedPreferredTerm; + + @Column private String modifiedPreferredTerm; + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + private NameGeneratorSpec nameGeneratorSpec; +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/PriorityBucket.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/PriorityBucket.java new file mode 100644 index 000000000..c018b77d3 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/PriorityBucket.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.NaturalId; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "priority_bucket") +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class PriorityBucket extends BaseAuditableEntity { + + @Column(unique = true) + @NaturalId + private String name; + + private Integer orderIndex; + + private String description; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + PriorityBucket that = (PriorityBucket) o; + return getName() != null && Objects.equals(getName(), that.getName()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Product.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Product.java new file mode 100644 index 000000000..855605c03 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Product.java @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import au.gov.digitalhealth.lingo.product.details.PackageDetails; +import au.gov.digitalhealth.lingo.product.details.ProductDetails; +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode.Exclude; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.hibernate.type.SqlTypes; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@SuperBuilder +@Getter +@Setter +@ToString +@Audited +@AllArgsConstructor +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "product", + uniqueConstraints = + @UniqueConstraint( + name = "product_name_ticket_unique", + columnNames = {"ticket_id", "name"})) +public class Product extends BaseAuditableEntity { + + @ManyToOne + @JoinColumn(name = "ticket_id", nullable = false) + @JsonBackReference(value = "ticket-product") + @Exclude + private Ticket ticket; + + @NotNull + @NotEmpty + @Column(nullable = false, length = 2048) + private String name; + + private Long conceptId; + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + private PackageDetails packageDetails; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Product product = (Product) o; + return getId() != null && Objects.equals(getId(), product.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Schedule.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Schedule.java new file mode 100644 index 000000000..f4d313c14 --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Schedule.java @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.NaturalId; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "schedule") +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class Schedule extends BaseAuditableEntity { + + @Column(name = "name", unique = true) + @NaturalId + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "grouping") + private Integer grouping; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Schedule schedule = (Schedule) o; + return getName() != null && Objects.equals(getName(), schedule.getName()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/State.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/State.java new file mode 100644 index 000000000..96413854f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/State.java @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.NaturalId; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "state") +@Audited +@EntityListeners(AuditingEntityListener.class) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class State extends BaseAuditableEntity { + + @Column(name = "label", unique = true) + @NaturalId + private String label; + + @Column(name = "description") + private String description; + + @Column(name = "grouping") + private Integer grouping; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + State state = (State) o; + return getLabel() != null && Objects.equals(getLabel(), state.getLabel()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/TaskAssociation.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/TaskAssociation.java new file mode 100644 index 000000000..057b4945f --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/TaskAssociation.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.envers.Audited; +import org.hibernate.envers.RelationTargetAuditMode; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@Setter +@ToString +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED) +@EntityListeners(AuditingEntityListener.class) +@Table(name = "task_association", uniqueConstraints = @UniqueConstraint(columnNames = "ticket_id")) +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +public class TaskAssociation extends BaseAuditableEntity { + + @OneToOne + @JsonBackReference(value = "ticket-task") + private Ticket ticket; + + @Column private String taskId; + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + TaskAssociation that = (TaskAssociation) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + @SuppressWarnings("java:S6201") // Suppressed because code is direct from JPABuddy advice + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() + : getClass().hashCode(); + } +} diff --git a/api/src/main/java/au/gov/digitalhealth/tickets/models/Ticket.java b/api/src/main/java/au/gov/digitalhealth/tickets/models/Ticket.java new file mode 100644 index 000000000..acd472c1d --- /dev/null +++ b/api/src/main/java/au/gov/digitalhealth/tickets/models/Ticket.java @@ -0,0 +1,237 @@ +/* + * Copyright 2024 Australian Digital Health Agency ABN 84 425 496 912. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package au.gov.digitalhealth.tickets.models; + +import au.gov.digitalhealth.tickets.models.listeners.TicketEntityListener; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.OrderBy; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Builder.Default; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.ToString.Exclude; +import lombok.experimental.SuperBuilder; +import org.hibernate.envers.Audited; +import org.hibernate.proxy.HibernateProxy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@NamedEntityGraph( + name = "Ticket.backlogSearch", + attributeNodes = { + @NamedAttributeNode("labels"), + @NamedAttributeNode("externalRequestors"), + }) +@Entity +@SuperBuilder +@Getter +@Setter +@ToString(callSuper = true) +@Audited +@AllArgsConstructor +@NoArgsConstructor +@EntityListeners({AuditingEntityListener.class, TicketEntityListener.class}) +@Table(name = "ticket") +public class Ticket extends BaseAuditableEntity { + + @Column private Instant jiraCreated; + + @Column private String title; + + @Column private String ticketNumber; + + @Column(length = 1000000) + private String description; + + @ManyToOne(cascade = {CascadeType.PERSIST}) + private TicketType ticketType; + + @ManyToOne(cascade = CascadeType.PERSIST) + private Iteration iteration; + + @ManyToMany( + cascade = {CascadeType.PERSIST}, + fetch = FetchType.EAGER) + @JoinTable( + name = "ticket_labels", + joinColumns = @JoinColumn(name = "ticket_id"), + inverseJoinColumns = @JoinColumn(name = "label_id")) + @JsonProperty("labels") + @Builder.Default + private Set