diff --git a/README.adoc b/README.adoc index 056d3706dd0cf96aed81fa2aac5bbc6acee38cb3..d109fc6bfb0878efbb038c69b784e4bebb8cb12e 100644 --- a/README.adoc +++ b/README.adoc @@ -28,6 +28,7 @@ We appreciate your help in improving the project! - link:docs/sharing-code.adoc[Sharing Code] - link:reverse-proxy/README.md[Reverse Proxy] - link:docs/content-security-policy-header.adoc[Content Security Policy (CSP) Header] +- link:docs/flaky-tests.adoc[Flaky Tests] == Licensing diff --git a/admin-portal/src/lib/components/view/actors/ActorTable.tsx b/admin-portal/src/lib/components/view/actors/ActorTable.tsx index 046efe82e032b30f196673fbfc7e3a5e38f8b8a9..14d62287e782a0b9ec08d57e68b33bf8dc3384c7 100644 --- a/admin-portal/src/lib/components/view/actors/ActorTable.tsx +++ b/admin-portal/src/lib/components/view/actors/ActorTable.tsx @@ -12,7 +12,6 @@ import { ApiAdminStagedEntityAdminPartialActor, ApiAdminStagedEntityType, ApiGetOrgUnitsResponse, - ApiStagingStatus, } from "@eshg/admin-portal-api/serviceDirectory"; import { createColumnHelper, filterFns } from "@tanstack/react-table"; import { useMemo } from "react"; @@ -337,7 +336,7 @@ function useGetSubRows() { _matchingClientRules: [], _matchingServerRules: [], stagedEntityType: ApiAdminStagedEntityType.Del, - stagingStatus: ApiStagingStatus.WorkInProgress, + stagingStatus: sa.stagingStatus, _type: "actor", _parent: originalRow, }; diff --git a/admin-portal/src/lib/components/view/org-units/OrgUnitTable.tsx b/admin-portal/src/lib/components/view/org-units/OrgUnitTable.tsx index 080a4bf27953a25c4fb917604452ee6af1c01162..1f53c756df09d9c237491485d7cf2ca1b657cb54 100644 --- a/admin-portal/src/lib/components/view/org-units/OrgUnitTable.tsx +++ b/admin-portal/src/lib/components/view/org-units/OrgUnitTable.tsx @@ -12,7 +12,6 @@ import { ApiFederalState, ApiGetOrgUnitsResponse, ApiOrgUnitType, - ApiStagingStatus, } from "@eshg/admin-portal-api/serviceDirectory"; import { createColumnHelper, filterFns } from "@tanstack/react-table"; import { useMemo } from "react"; @@ -206,7 +205,7 @@ function getSubRows(orgUnits: ApiGetOrgUnitsResponse | undefined) { author: sou.author, _override: DeleteRow, stagedEntityType: ApiAdminStagedEntityType.Del, - stagingStatus: ApiStagingStatus.WorkInProgress, + stagingStatus: sou.stagingStatus, _type: "orgUnit", _parent: originalRow, }, diff --git a/admin-portal/src/lib/components/view/rules/RuleTable.tsx b/admin-portal/src/lib/components/view/rules/RuleTable.tsx index 3399c16500ca123ae1b7d56a7bdfb921ff88d2b8..708afcc17a15c4ada1f6488addd2878d46defe5c 100644 --- a/admin-portal/src/lib/components/view/rules/RuleTable.tsx +++ b/admin-portal/src/lib/components/view/rules/RuleTable.tsx @@ -9,7 +9,6 @@ import { ApiAdminStagedEntityAdminPartialRule, ApiAdminStagedEntityType, ApiGetRulesResponse, - ApiStagingStatus, } from "@eshg/admin-portal-api/serviceDirectory"; import { createColumnHelper, filterFns } from "@tanstack/react-table"; import { useMemo } from "react"; @@ -216,7 +215,7 @@ function useGetSubRows() { _matchingClientActors: [], _matchingServerActors: [], stagedEntityType: ApiAdminStagedEntityType.Del, - stagingStatus: ApiStagingStatus.WorkInProgress, + stagingStatus: sr.stagingStatus, _type: "rule", _parent: originalRow, }; diff --git a/backend/auditlog-api/src/main/java/de/eshg/auditlog/GetEncryptedSymmetricKeyResponse.java b/backend/auditlog-api/src/main/java/de/eshg/auditlog/GetEncryptedSymmetricKeyResponse.java index 7a54ad387e626179c59d0396feb89c5f5d905cb4..87434eed6d950e44f13b7e90edb57b44824bfbb8 100644 --- a/backend/auditlog-api/src/main/java/de/eshg/auditlog/GetEncryptedSymmetricKeyResponse.java +++ b/backend/auditlog-api/src/main/java/de/eshg/auditlog/GetEncryptedSymmetricKeyResponse.java @@ -5,7 +5,10 @@ package de.eshg.auditlog; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import java.util.List; public record GetEncryptedSymmetricKeyResponse( - @NotNull byte[] encapsulatedKey, @NotNull byte[] encryptedSymmetricKey) {} + @NotNull @NotEmpty List<Byte> encapsulatedKey, + @NotNull @NotEmpty List<Byte> encryptedSymmetricKey) {} diff --git a/backend/auditlog/openApi.yaml b/backend/auditlog/openApi.yaml index fcc47e4011e8123cc0cf1c958b3f4871e4e52a25..c2f94ee0a8c95d059fbd28105feab2699dfcd60b 100644 --- a/backend/auditlog/openApi.yaml +++ b/backend/auditlog/openApi.yaml @@ -539,11 +539,15 @@ components: type: object properties: encapsulatedKey: - type: string - format: byte + type: array + items: + type: string + format: byte encryptedSymmetricKey: - type: string - format: byte + type: array + items: + type: string + format: byte required: - encapsulatedKey - encryptedSymmetricKey diff --git a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogController.java b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogController.java index 1339f44bc802ccae80eef80383235c423bf950dd..b41055de36e083e2630ce3cbd75f7a21c57ae93a 100644 --- a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogController.java +++ b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogController.java @@ -55,6 +55,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.Comparator; @@ -68,6 +69,7 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ContentDisposition; @@ -337,12 +339,17 @@ public class AuditLogController implements AuditLogApi, AuditLogArchivingApi { byte[] encapsulatedKey = Files.readAllBytes(encapsulatedKeyPath); log.info("Using encapsulated key {}", encapsulatedKeyPath); - return new GetEncryptedSymmetricKeyResponse(encapsulatedKey, encryptedSymmetricKey); + return new GetEncryptedSymmetricKeyResponse( + toByteList(encapsulatedKey), toByteList(encryptedSymmetricKey)); } catch (IOException e) { throw new UncheckedIOException("Unable to read user specific key", e); } } + private List<Byte> toByteList(byte[] byteArray) { + return Arrays.asList(ArrayUtils.toObject(byteArray)); + } + @Override public GetAvailableAuditLogsResponse getAvailableLogs( GetAvailableAuditLogsFilterOptions filterOptions, diff --git a/backend/auth/src/main/java/de/eshg/security/auth/login/LoginMethod.java b/backend/auth/src/main/java/de/eshg/security/auth/login/LoginMethod.java index 831b82d30456b4dedba46d6acd4f2530043caa5e..9383062bb0b087eca1f325e6d2c41afabb872a58 100644 --- a/backend/auth/src/main/java/de/eshg/security/auth/login/LoginMethod.java +++ b/backend/auth/src/main/java/de/eshg/security/auth/login/LoginMethod.java @@ -13,6 +13,8 @@ import org.springframework.util.AntPathMatcher; import org.springframework.web.util.UriComponentsBuilder; public abstract class LoginMethod { + public static final String LOGIN_LOCALE_PARAM_NAME = "ui_locales"; + protected final AntPathMatcher antPathMatcher = new AntPathMatcher(); protected final AuthProperties authProperties; @@ -28,6 +30,14 @@ public abstract class LoginMethod { OAuth2AuthorizationRequest auth2AuthorizationRequest, String redirectUrl) { return OAuth2AuthorizationRequest.from(auth2AuthorizationRequest) .additionalParameters(params -> this.applyParameters(params, redirectUrl)) + .additionalParameters( + params -> { + String urlPath = UriComponentsBuilder.fromUriString(redirectUrl).build().getPath(); + LocaleAndPath localeAndPath = extractLanguagePathPrefix(urlPath); + if (localeAndPath.language() != null) { + params.put(LOGIN_LOCALE_PARAM_NAME, localeAndPath.language()); + } + }) .build(); } @@ -37,21 +47,26 @@ public abstract class LoginMethod { return false; } String urlPath = UriComponentsBuilder.fromUriString(url).build().getPath(); - String normalizedUrlPath = replaceLanguagePathPrefix(urlPath); + LocaleAndPath localeAndPath = extractLanguagePathPrefix(urlPath); + String normalizedUrlPath = localeAndPath.path(); return normalizedUrlPath != null && patterns.stream().anyMatch(pattern -> antPathMatcher.match(pattern, normalizedUrlPath)); } - private String replaceLanguagePathPrefix(String url) { + private LocaleAndPath extractLanguagePathPrefix(String url) { List<String> languagePathPrefixes = authProperties.getLanguagePathPrefixes(); if (languagePathPrefixes == null) { - return url; + return new LocaleAndPath(null, url); } for (String languagePathPrefix : languagePathPrefixes) { if (url.startsWith(languagePathPrefix + "/")) { - return url.substring(languagePathPrefix.length()); + String locale = url.substring(1, languagePathPrefix.length()); + String subPath = url.substring(languagePathPrefix.length()); + return new LocaleAndPath(locale, subPath); } } - return url; + return new LocaleAndPath(null, url); } + + record LocaleAndPath(String language, String path) {} } diff --git a/backend/auth/src/main/resources/application.properties b/backend/auth/src/main/resources/application.properties index defc4ab54b07cf9f576ec3545cf61726cee25b25..e9fdaf577081a00c8ce1b206e19361e90369ac9e 100644 --- a/backend/auth/src/main/resources/application.properties +++ b/backend/auth/src/main/resources/application.properties @@ -1,5 +1,3 @@ -spring.profiles.active=employee-portal - server.port=8092 spring.security.oauth2.client.registration.keycloak.client-id=system-eshg-auth-service diff --git a/backend/base-api/src/main/java/de/eshg/base/centralfile/PersonApi.java b/backend/base-api/src/main/java/de/eshg/base/centralfile/PersonApi.java index 51e15c665fae2592529cc142704b3d18298005a5..1350fa1c0205cb202babfdc277f399814f98171a 100644 --- a/backend/base-api/src/main/java/de/eshg/base/centralfile/PersonApi.java +++ b/backend/base-api/src/main/java/de/eshg/base/centralfile/PersonApi.java @@ -120,6 +120,17 @@ public interface PersonApi { @Valid AddPersonFileStatesRequest request); + @PostExchange(FILE_STATES_URL + "/bulk-search") + @ApiResponse(responseCode = "200") + @Operation( + summary = + """ + Search multiple reference persons by the given key attributes + and return all file state ids associated with these reference persons. + """) + GetPersonFileStateIdsByKeyAttributesResponse getPersonFileStateIdsByReferencePersonKeyAttributes( + @Valid @RequestBody GetPersonFileStateIdsByKeyAttributesRequest request); + @PostExchange(FILE_STATES_URL + "/bulk-get") @ApiResponse(responseCode = "200") @Operation(summary = "Get multiple persons") diff --git a/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/GetPersonFileStateIdsByKeyAttributesRequest.java b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/GetPersonFileStateIdsByKeyAttributesRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..5c879f6aa4d3f7ff08e5d8392576a72b19d3a1d8 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/GetPersonFileStateIdsByKeyAttributesRequest.java @@ -0,0 +1,13 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.centralfile.api.person; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import java.util.Set; + +public record GetPersonFileStateIdsByKeyAttributesRequest( + @Valid @NotEmpty Set<PersonKeyAttributes> searchAttributes) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/GetPersonFileStateIdsByKeyAttributesResponse.java b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/GetPersonFileStateIdsByKeyAttributesResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..9271a700793ba4337317c5db84b651c5d0c8aa25 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/GetPersonFileStateIdsByKeyAttributesResponse.java @@ -0,0 +1,15 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.centralfile.api.person; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record GetPersonFileStateIdsByKeyAttributesResponse( + @Valid @NotNull Map<PersonKeyAttributes, List<UUID>> fileStateIdsByPersons) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/PersonKeyAttributes.java b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/PersonKeyAttributes.java new file mode 100644 index 0000000000000000000000000000000000000000..debb4ae35b5ee80d517bc26074a165856dc0ef2c --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/PersonKeyAttributes.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.centralfile.api.person; + +import com.fasterxml.jackson.annotation.JsonCreator; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.util.Assert; + +public record PersonKeyAttributes( + @NotBlank String firstName, @NotBlank String lastName, @NotNull LocalDate dateOfBirth) { + + public static final Pattern SERIALIZATION_PATTERN = + Pattern.compile( + "PersonKeyAttributes\\[firstName=(.+?), lastName=(.+?), dateOfBirth=(.+?)\\]"); + + /* + Required for deserializing PersonKeyAttributes as Map keys + See de.eshg.base.centralfile.api.person.GetPersonFileStateIdsByKeyAttributesResponse + */ + @JsonCreator + private static PersonKeyAttributes fromJsonString(String s) { + Matcher matcher = SERIALIZATION_PATTERN.matcher(s); + Assert.isTrue(matcher.matches(), "Did not match expected pattern"); + Assert.isTrue(matcher.groupCount() == 3, "Wrong number of groups"); + return new PersonKeyAttributes( + matcher.group(1), matcher.group(2), LocalDate.parse(matcher.group(3))); + } +} diff --git a/backend/base-api/src/main/java/de/eshg/base/gdpr/GdprProcedureApi.java b/backend/base-api/src/main/java/de/eshg/base/gdpr/GdprProcedureApi.java index 9d35cd41079317e2b71092a0fd5800084b41f79e..370462f9351c7e8ab2ee87f6e023d9e9840772cf 100644 --- a/backend/base-api/src/main/java/de/eshg/base/gdpr/GdprProcedureApi.java +++ b/backend/base-api/src/main/java/de/eshg/base/gdpr/GdprProcedureApi.java @@ -7,8 +7,10 @@ package de.eshg.base.gdpr; import de.eshg.api.commons.InlineParameterObject; import de.eshg.base.gdpr.api.AddGdprProcedureRequest; +import de.eshg.base.gdpr.api.GdprProcedureChangeStatusRequest; import de.eshg.base.gdpr.api.GetGdprProcedureDetailsPageResponse; import de.eshg.base.gdpr.api.GetGdprProcedureResponse; +import de.eshg.base.gdpr.api.SetMatterOfConcernRequest; import de.eshg.rest.service.security.config.BaseUrls; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.HttpExchange; import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.annotation.PutExchange; @HttpExchange(url = GdprProcedureApi.BASE_URL) public interface GdprProcedureApi { @@ -59,4 +62,18 @@ public interface GdprProcedureApi { GetGdprProcedureResponse addCentralFileIdToGdprProcedure( @Parameter(description = "The Id of the GDPR procedure.") @PathVariable("id") UUID id, @RequestBody @Valid AddCentralFileIdToGdprProcedureRequest request); + + @PutExchange("/{id}/matter-of-concern") + @ApiResponse(responseCode = "200") + @Operation( + summary = + "Changes the matter of concern of this GDPR procedure, this is only relevant for right to correction and right to objection.") + void setMatterOfConcern( + @PathVariable("id") UUID id, @RequestBody @Valid SetMatterOfConcernRequest request); + + @PostExchange("/{id}/change-status") + @ApiResponse(responseCode = "200") + @Operation(summary = "Changes the current status of the GDPR procedure.") + void changeStatus( + @PathVariable("id") UUID id, @RequestBody @Valid GdprProcedureChangeStatusRequest request); } diff --git a/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GdprProcedureChangeStatusRequest.java b/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GdprProcedureChangeStatusRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..c8b6bdaafc8fddb6c2ae4464760307eb5ea221d4 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GdprProcedureChangeStatusRequest.java @@ -0,0 +1,11 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.gdpr.api; + +import jakarta.validation.constraints.NotNull; + +public record GdprProcedureChangeStatusRequest( + @NotNull GdprProcedureStatusDto newStatus, @NotNull long version) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GetGdprProcedureResponse.java b/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GetGdprProcedureResponse.java index f59383438883995577dffa01b3d1d021060842de..6437833429539a79e04edd2a699ce81edc0fa233 100644 --- a/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GetGdprProcedureResponse.java +++ b/backend/base-api/src/main/java/de/eshg/base/gdpr/api/GetGdprProcedureResponse.java @@ -34,4 +34,9 @@ public record GetGdprProcedureResponse( description = "The date and time of when the GDPR procedure was created.", example = "2024-02-01T00:00:00.123456Z") @NotNull - Instant createdAt) {} + Instant createdAt, + @Schema( + description = + "The matter of concern for this GDPR procedure, only relevant for right to correction and right to objection.", + example = "Person requested to stop all related procedures.") + String matterOfConcern) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/gdpr/api/SetMatterOfConcernRequest.java b/backend/base-api/src/main/java/de/eshg/base/gdpr/api/SetMatterOfConcernRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..bc26c898ff1ddcb2b84bc2d994ed8a7e6472d605 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/gdpr/api/SetMatterOfConcernRequest.java @@ -0,0 +1,14 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.gdpr.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record SetMatterOfConcernRequest( + @Schema(description = "The matter of concern for the GDPR procedure.") @NotBlank String concern, + @NotNull long version) {} diff --git a/backend/base/openApi.yaml b/backend/base/openApi.yaml index 3412a8b50131b9091faa3417ac405cc0eb42f8a1..9935c0f4427f9825d7c4a044facf6f964e17f366 100644 --- a/backend/base/openApi.yaml +++ b/backend/base/openApi.yaml @@ -1326,6 +1326,28 @@ paths: summary: Add central file id to GDPR procedure. tags: - GdprProcedure + /gdpr-procedures/{id}/change-status: + post: + operationId: changeStatus + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/GdprProcedureChangeStatusRequest" + required: true + responses: + "200": + description: OK + summary: Changes the current status of the GDPR procedure. + tags: + - GdprProcedure /gdpr-procedures/{id}/details-page: get: operationId: getGdprProcedureDetailsPage @@ -1348,6 +1370,29 @@ paths: frontend to display the GDPR Procedure. tags: - GdprProcedure + /gdpr-procedures/{id}/matter-of-concern: + put: + operationId: setMatterOfConcern + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SetMatterOfConcernRequest" + required: true + responses: + "200": + description: OK + summary: "Changes the matter of concern of this GDPR procedure, this is only\ + \ relevant for right to correction and right to objection." + tags: + - GdprProcedure /inventoryItems: get: operationId: getInventoryItems @@ -1926,6 +1971,27 @@ paths: summary: Get multiple persons tags: - Person + /persons/centralfilestates/bulk-search: + post: + operationId: getPersonFileStateIdsByReferencePersonKeyAttributes + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/GetPersonFileStateIdsByKeyAttributesRequest" + required: true + responses: + "200": + content: + '*/*': + schema: + $ref: "#/components/schemas/GetPersonFileStateIdsByKeyAttributesResponse" + description: OK + summary: | + Search multiple reference persons by the given key attributes + and return all file state ids associated with these reference persons. + tags: + - Person /persons/centralfilestates/external-source: post: operationId: addPersonFromExternalSource @@ -5241,6 +5307,17 @@ components: - dateOfBirth - firstName - lastName + GdprProcedureChangeStatusRequest: + type: object + properties: + newStatus: + $ref: "#/components/schemas/GdprProcedureStatus" + version: + type: integer + format: int64 + required: + - newStatus + - version GdprProcedureSortKey: type: string enum: @@ -5800,6 +5877,11 @@ components: oneOf: - $ref: "#/components/schemas/GdprFacility" - $ref: "#/components/schemas/GdprPerson" + matterOfConcern: + type: string + description: "The matter of concern for this GDPR procedure, only relevant\ + \ for right to correction and right to objection." + example: Person requested to stop all related procedures. status: $ref: "#/components/schemas/GdprProcedureStatus" type: @@ -5892,6 +5974,28 @@ components: - contactAddressDiff - personDetailsDiff - referenceVersion + GetPersonFileStateIdsByKeyAttributesRequest: + type: object + properties: + searchAttributes: + type: array + items: + $ref: "#/components/schemas/PersonKeyAttributes" + uniqueItems: true + required: + - searchAttributes + GetPersonFileStateIdsByKeyAttributesResponse: + type: object + properties: + fileStateIdsByPersons: + type: object + additionalProperties: + type: array + items: + type: string + format: uuid + required: + - fileStateIdsByPersons GetPersonFileStateResponse: type: object properties: @@ -7204,6 +7308,20 @@ components: - phoneNumbers - referenceVersion - salutation + PersonKeyAttributes: + type: object + properties: + dateOfBirth: + type: string + format: date + firstName: + type: string + lastName: + type: string + required: + - dateOfBirth + - firstName + - lastName Population: type: object properties: @@ -7678,6 +7796,18 @@ components: - subject - text - to + SetMatterOfConcernRequest: + type: object + properties: + concern: + type: string + description: The matter of concern for the GDPR procedure. + version: + type: integer + format: int64 + required: + - concern + - version ShowAs: type: string description: |2 diff --git a/backend/base/src/main/java/de/eshg/base/centralfile/PersonController.java b/backend/base/src/main/java/de/eshg/base/centralfile/PersonController.java index 54a5b8e1bfd13e6ed4cb889ce6d244040b9116fd..a10d5bf7c7faa3c697a15b490e8b46ee841fc293 100644 --- a/backend/base/src/main/java/de/eshg/base/centralfile/PersonController.java +++ b/backend/base/src/main/java/de/eshg/base/centralfile/PersonController.java @@ -133,6 +133,27 @@ public class PersonController implements PersonApi { return new GetFileStateIdsResponse(searchResultsFromDb); } + @Override + @Transactional(readOnly = true) + public GetPersonFileStateIdsByKeyAttributesResponse + getPersonFileStateIdsByReferencePersonKeyAttributes( + GetPersonFileStateIdsByKeyAttributesRequest request) { + List<Person> referencePersons = + personService.findReferencePersonsByKeyAttributes(request.searchAttributes()); + + Map<PersonKeyAttributes, List<UUID>> result = new LinkedHashMap<>(); + for (Person referencePerson : referencePersons) { + PersonKeyAttributes key = + new PersonKeyAttributes( + referencePerson.getFirstName(), + referencePerson.getLastName(), + referencePerson.getBirthDetails().dateOfBirth()); + result.put( + key, personRepository.findAllByReferencePersonIdOrderById(referencePerson.getId())); + } + return new GetPersonFileStateIdsByKeyAttributesResponse(result); + } + @Override @Transactional(readOnly = true) public GetPersonFileStatesResponse getPersonFileStates(GetPersonFileStatesRequest request) { diff --git a/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonKeyAttributes.java b/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonKeyAttributes.java deleted file mode 100644 index b2aa1bb6cb54e39bf5755285c58bcbd2686b7599..0000000000000000000000000000000000000000 --- a/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonKeyAttributes.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2024 cronn GmbH - * SPDX-License-Identifier: Apache-2.0 - */ - -package de.eshg.base.centralfile.persistence; - -import java.time.LocalDate; - -public record PersonKeyAttributes(String firstName, String lastName, LocalDate dateOfBirth) {} diff --git a/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonService.java b/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonService.java index adf77d0714774beb16ff31237c8619e011de363c..27413901b06e0465a5d7bffdffafcc2246ca8fe3 100644 --- a/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonService.java +++ b/backend/base/src/main/java/de/eshg/base/centralfile/persistence/PersonService.java @@ -10,6 +10,7 @@ import static de.eshg.base.util.SearchSpecificationUtil.getSimilarityThreshold; import de.cronn.commons.lang.StreamUtil; import de.eshg.base.centralfile.CentralFileAuditLogger; +import de.eshg.base.centralfile.api.person.PersonKeyAttributes; import de.eshg.base.centralfile.persistence.entity.BirthDetails_; import de.eshg.base.centralfile.persistence.entity.DataOrigin; import de.eshg.base.centralfile.persistence.entity.Person; @@ -108,7 +109,7 @@ public class PersonService { private List<UUID> addPersonFileStatesWhenLocked(List<Person> fileStates) { Set<PersonKeyAttributes> personKeyAttributes = collectPersonKeyAttributes(fileStates); - List<Person> potentialMatches = findAllByKeyAttributes(personKeyAttributes); + List<Person> potentialMatches = findReferencePersonsByKeyAttributes(personKeyAttributes); Map<PersonKeyAttributes, Person> lowestIdPersons = createLowestIdMap(potentialMatches); @@ -314,7 +315,7 @@ public class PersonService { return personRepository.findAllByExternalIdInAndReferencePersonIsNotNullOrderById(fileStateIds); } - public List<Person> findAllByKeyAttributes(Set<PersonKeyAttributes> keyAttributes) { + public List<Person> findReferencePersonsByKeyAttributes(Set<PersonKeyAttributes> keyAttributes) { Specification<Person> personSpecification = (root, query, criteriaBuilder) -> { root.fetch(Person_.emailAddresses, JoinType.LEFT); @@ -323,14 +324,14 @@ public class PersonService { List<Predicate> conjunctions = new ArrayList<>(); - for (PersonKeyAttributes pair : keyAttributes) { + for (PersonKeyAttributes key : keyAttributes) { conjunctions.add( criteriaBuilder.and( - criteriaBuilder.equal(root.get(Person_.firstName), pair.firstName()), - criteriaBuilder.equal(root.get(Person_.lastName), pair.lastName()), + criteriaBuilder.equal(root.get(Person_.firstName), key.firstName()), + criteriaBuilder.equal(root.get(Person_.lastName), key.lastName()), criteriaBuilder.equal( root.get(Person_.birthDetails).get(BirthDetails_.dateOfBirth), - pair.dateOfBirth()), + key.dateOfBirth()), criteriaBuilder.isNull(root.get(Person_.referencePerson)), criteriaBuilder.notEqual(root.get(Person_.dataOrigin), DataOrigin.EXTERNAL))); } diff --git a/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureController.java b/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureController.java index 356ff070e9f8196939f395ec75e7b321e1d0cdb2..eca9504c7cf643fa9892e70ce96fa724a325ff7c 100644 --- a/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureController.java +++ b/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureController.java @@ -20,7 +20,9 @@ import de.eshg.base.gdpr.api.*; import de.eshg.base.gdpr.persistence.*; import de.eshg.base.util.MappingUtil; import de.eshg.base.util.PaginationUtil; +import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.NotFoundException; +import de.eshg.validation.ValidationUtil; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import java.util.UUID; @@ -58,7 +60,8 @@ public class GdprProcedureController implements GdprProcedureApi { mapToApi(gdprProcedure.getStatus()), mapToApi(gdprProcedure.getType()), mapToApi(gdprProcedure.getIdentificationData()), - gdprProcedure.getCreatedAt()); + gdprProcedure.getCreatedAt(), + gdprProcedure.getMatterOfConcern()); } private static GdprIdentificationDataDto mapToApi(IdentificationData identificationData) { @@ -235,6 +238,43 @@ public class GdprProcedureController implements GdprProcedureApi { service.addCentralFileIdToGdprProcedure(request.centralFileId(), id, request.version())); } + @Override + @Transactional + public void setMatterOfConcern(UUID id, SetMatterOfConcernRequest request) { + GdprProcedure procedure = service.getGdprProcedureForUpdate(id); + ValidationUtil.validateVersion(request.version(), procedure); + procedure.setMatterOfConcern(request.concern()); + } + + @Override + @Transactional + public void changeStatus(UUID id, GdprProcedureChangeStatusRequest request) { + GdprProcedure procedure = service.getGdprProcedureForUpdate(id); + + if (procedure.getType() != GdprProcedureType.RIGHT_TO_OBJECT) { + throw new BadRequestException( + "Changing the status of GDPR procedures with type '" + + procedure.getType() + + "' is not supported yet."); + } + + ValidationUtil.validateVersion(request.version(), procedure); + + if (procedure.getStatus() == GdprProcedureStatus.DRAFT) { + if (request.newStatus() != GdprProcedureStatusDto.IN_PROGRESS) { + throw badStatusTransition(request.newStatus(), procedure.getStatus()); + } + + if (procedure.getMatterOfConcern() == null) { + throw new BadRequestException("Cannot start procedure without valid matter of concern."); + } + + procedure.setStatus(GdprProcedureStatus.IN_PROGRESS); + } else { + throw badStatusTransition(request.newStatus(), procedure.getStatus()); + } + } + public GetGdprProceduresResponse mapGdprProceduresToApi(Page<GdprProcedure> procedures) { return new GetGdprProceduresResponse( procedures.stream().map(GdprProcedureController::mapGdprProcedureToApi).toList(), @@ -257,4 +297,14 @@ public class GdprProcedureController implements GdprProcedureApi { case GdprProcedureSortKey.CREATED_AT -> GdprProcedure_.CREATED_AT; }; } + + private static BadRequestException badStatusTransition( + GdprProcedureStatusDto wantedStatus, GdprProcedureStatus currentStatus) { + return new BadRequestException( + "Status cannot be changed to '" + + wantedStatus + + "' while current status is '" + + currentStatus + + "'."); + } } diff --git a/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureService.java b/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureService.java index b97f0644fa9bb294f09f83a2fdfcc0610fd8e68f..5b63621db65c1758a3b2b9e5b9265f2947e90a0f 100644 --- a/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureService.java +++ b/backend/base/src/main/java/de/eshg/base/gdpr/GdprProcedureService.java @@ -77,7 +77,7 @@ public class GdprProcedureService { return repository.findByExternalId(gdprProcedureId).orElseThrow(notFound(gdprProcedureId)); } - private GdprProcedure getGdprProcedureForUpdate(UUID gdprProcedureId) { + public GdprProcedure getGdprProcedureForUpdate(UUID gdprProcedureId) { return repository .findByExternalIdForUpdate(gdprProcedureId) .orElseThrow(notFound(gdprProcedureId)); diff --git a/backend/base/src/main/java/de/eshg/base/gdpr/persistence/GdprProcedure.java b/backend/base/src/main/java/de/eshg/base/gdpr/persistence/GdprProcedure.java index d5c79f85fc2969d7f36ffb7bebd5a065456ca4af..76e8f0577217ba377ea0be38e8a10485bcc670de 100644 --- a/backend/base/src/main/java/de/eshg/base/gdpr/persistence/GdprProcedure.java +++ b/backend/base/src/main/java/de/eshg/base/gdpr/persistence/GdprProcedure.java @@ -53,6 +53,9 @@ public class GdprProcedure extends BaseEntityWithExternalId { @DataSensitivity(SensitivityLevel.SENSITIVE) private IdentificationData identificationData; + @DataSensitivity(SensitivityLevel.SENSITIVE) + private String matterOfConcern; + public UUID getCentralFileId() { return centralFileId; } @@ -116,4 +119,12 @@ public class GdprProcedure extends BaseEntityWithExternalId { public void setIdentificationData(IdentificationData identificationData) { this.identificationData = identificationData; } + + public String getMatterOfConcern() { + return matterOfConcern; + } + + public void setMatterOfConcern(String matterOfConcern) { + this.matterOfConcern = matterOfConcern; + } } diff --git a/backend/base/src/main/java/de/eshg/base/keycloak/ModuleClient.java b/backend/base/src/main/java/de/eshg/base/keycloak/ModuleClient.java index 943921e5c65e84d8732e9c0793b620fd3343581f..04b2321f879c357a7d0bc9e5addf3ce54ae8bf8e 100644 --- a/backend/base/src/main/java/de/eshg/base/keycloak/ModuleClient.java +++ b/backend/base/src/main/java/de/eshg/base/keycloak/ModuleClient.java @@ -28,7 +28,7 @@ public enum ModuleClient { SCHOOL_ENTRY("school-entry", List.of(BASE_MAIL_SEND)), STATISTICS("statistics", List.of(STATISTICS_STATISTICS_WRITE)), TRAVEL_MEDICINE("travel-medicine", List.of(BASE_MAIL_SEND, BASE_ACCESS_CODE_USER_ADMIN)), - STI_PROTECTION("sti-protection"); + STI_PROTECTION("sti-protection", List.of(BASE_MAIL_SEND)); private final String clientIdWithoutPrefix; private final List<EmployeePermissionRole> roles; diff --git a/backend/base/src/main/resources/migrations/0015_gdpr_matter_of_concern.xml b/backend/base/src/main/resources/migrations/0015_gdpr_matter_of_concern.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb9b333f17712a6334165717a3b1be89deb44362 --- /dev/null +++ b/backend/base/src/main/resources/migrations/0015_gdpr_matter_of_concern.xml @@ -0,0 +1,13 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2024 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1727369112763-1"> + <addColumn tableName="gdpr_procedure"> + <column name="matter_of_concern" type="text"/> + </addColumn> + </changeSet> +</databaseChangeLog> diff --git a/backend/base/src/main/resources/migrations/changelog.xml b/backend/base/src/main/resources/migrations/changelog.xml index 511fef83c76894702ff0a930ddbeb35263f106e5..5fb2e561ff518c94fefa0f9c8a1a39116a2ce2b9 100644 --- a/backend/base/src/main/resources/migrations/changelog.xml +++ b/backend/base/src/main/resources/migrations/changelog.xml @@ -22,5 +22,6 @@ <include file="migrations/0012_rename_single_string_entity_columns.xml"/> <include file="migrations/0013_person_sequence.xml"/> <include file="migrations/0014_contact_index_full_name.xml" /> + <include file="migrations/0015_gdpr_matter_of_concern.xml" /> </databaseChangeLog> diff --git a/backend/buildSrc/src/main/groovy/eshg.service.gradle b/backend/buildSrc/src/main/groovy/eshg.service.gradle index 2ffde22d6b5f58d1217eff06233cb8336326f09f..25f6a742212db2a523554b0e6bed72baa72294fc 100644 --- a/backend/buildSrc/src/main/groovy/eshg.service.gradle +++ b/backend/buildSrc/src/main/groovy/eshg.service.gradle @@ -12,7 +12,7 @@ plugins { } bootRun { - systemProperty 'spring.profiles.active', 'test-helper' + systemProperty 'spring.profiles.active', getActiveSpringProfiles() } def dockerBuildDir = layout.buildDirectory.dir('docker') @@ -165,12 +165,21 @@ rootProject.tasks.named("composeUp").configure { dependsOn tasks.named("composeUp") } +String getActiveSpringProfiles() { + String defaultSpringProfiles = "local, test-helper" + if (project.hasProperty("preview-features") || project.hasProperty("previewFeatures")) { + return "${defaultSpringProfiles}, preview-features" + } else { + return defaultSpringProfiles + } +} + dockerCompose { projectName = rootProject.name useComposeFiles = ["${rootDir}/docker-compose.yaml"] if (project.hasProperty("preview-features") || project.hasProperty("previewFeatures")) { - environment.put "ACTIVE_SPRING_PROFILES", "test-helper, preview-features" + environment.put "ACTIVE_SPRING_PROFILES", getActiveSpringProfiles() } } diff --git a/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/DefaultEshgSecurityConfig.java b/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/DefaultEshgSecurityConfig.java index 60ac6ad4b116a529b6072303e5e5636392018bc5..730a66829944298e2070de0c465a8efc26f4e800 100644 --- a/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/DefaultEshgSecurityConfig.java +++ b/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/DefaultEshgSecurityConfig.java @@ -45,6 +45,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter.CrossOriginOpenerPolicy; import org.springframework.security.web.header.writers.CrossOriginResourcePolicyHeaderWriter.CrossOriginResourcePolicy; import org.springframework.security.web.util.matcher.AnyRequestMatcher; @@ -111,6 +112,7 @@ public class DefaultEshgSecurityConfig { .csrf(AbstractHttpConfigurer::disable) .sessionManagement(AbstractHttpConfigurer::disable) .headers(DefaultEshgSecurityConfig::securityHeaders) + .addFilterAfter(new UserSessionIdLoggingFilter(), AuthorizationFilter.class) .build(); } diff --git a/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/UserSessionIdLoggingFilter.java b/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/UserSessionIdLoggingFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..df5cb35f88b03166e8379d4d35b09b5b4866c473 --- /dev/null +++ b/backend/business-module-commons/src/main/java/de/eshg/rest/service/security/UserSessionIdLoggingFilter.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.rest.service.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.Closeable; +import java.io.IOException; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.web.filter.OncePerRequestFilter; + +public class UserSessionIdLoggingFilter extends OncePerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(UserSessionIdLoggingFilter.class); + + private static final int SESSION_ID_TRUNCATION_LENGTH = "aaaaaaaa-bbbb".length(); + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String userSessionId = + CurrentUserHelper.getCurrentUserSessionIdGracefully() + .map(UserSessionIdLoggingFilter::truncateUuid) + .orElse(null); + try (Closeable closeable = putCloseableIfNotNull("userSessionId", userSessionId)) { + log.trace("{}", closeable); + filterChain.doFilter(request, response); + } + } + + // For security reasons we truncate the UUID from "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" to + // "aaaaaaaa-bbbb" + private static String truncateUuid(String sessionId) { + return StringUtils.left(sessionId, SESSION_ID_TRUNCATION_LENGTH) + "…"; + } + + private static Closeable putCloseableIfNotNull(String key, String value) { + if (value == null) { + return () -> {}; + } + return MDC.putCloseable(key, value); + } +} diff --git a/backend/business-module-persistence-commons/src/testFixtures/java/de/eshg/UserSessionIdPrefixNormalizer.java b/backend/business-module-persistence-commons/src/testFixtures/java/de/eshg/UserSessionIdPrefixNormalizer.java new file mode 100644 index 0000000000000000000000000000000000000000..68e556b6b5cf1d29f3026dd0cac4867747c862f0 --- /dev/null +++ b/backend/business-module-persistence-commons/src/testFixtures/java/de/eshg/UserSessionIdPrefixNormalizer.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg; + +import de.cronn.assertions.validationfile.normalization.IdNormalizer; +import de.cronn.assertions.validationfile.normalization.IncrementingIdProvider; +import de.cronn.assertions.validationfile.normalization.ValidationNormalizer; + +class UserSessionIdPrefixNormalizer implements ValidationNormalizer { + + private final ValidationNormalizer delegate = + new IdNormalizer( + new IncrementingIdProvider(), + "USER_SESSION_", + "(?<=userSessionId=)([0-9a-f]{8}-[0-9a-f]{4}…)"); + + @Override + public String normalize(String source) { + return delegate.normalize(source); + } +} diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index 8cf8939f7db28da05e4e4e4ce75d40f44f9994d5..97ffabbbaa42088111a29aafc6d308049e54e019 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -17,6 +17,8 @@ services: auth-employee-portal: extends: service: auth-base + environment: + - spring.profiles.active=local, employee-portal ports: - 8092:8080 @@ -24,7 +26,7 @@ services: extends: service: auth-base environment: - - spring.profiles.active=citizen-portal + - spring.profiles.active=local, citizen-portal - spring.data.redis.database=1 # use a different SESSION cookie such that browsers don’t confuse the session # when opening http://localhost:4000 and http://localhost:4001 in parallel @@ -49,7 +51,7 @@ services: image: ga-lotse/base environment: - DOCKER_HOSTNAME=${DOCKER_HOSTNAME:-localhost} - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://base-db/eshgbase - eshg.keycloak.internal.url=http://keycloak:8080 - eshg.keycloak.admin-client.client-secret=admin @@ -89,6 +91,7 @@ services: keycloak-reverse-proxy: image: nginx:1.27.1 + restart: unless-stopped ports: - 4003:4003 read_only: true @@ -141,7 +144,7 @@ services: env_file: - 'lib-procedures/src/test/resources/archiving-test.env' environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://inspection-db/inspection - de.eshg.base.service-url=http://base:8080 - de.eshg.centralrepository.service-url=http://central-repository:8080 @@ -171,7 +174,7 @@ services: env_file: - 'lib-procedures/src/test/resources/archiving-test.env' environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://school-entry-db/schoolentry - de.eshg.base.service-url=http://base:8080 - eshg.keycloak.internal.url=http://keycloak:8080 @@ -199,7 +202,7 @@ services: service-directory: image: ga-lotse/service-directory environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://service-directory-db/servicedirectory restart: unless-stopped depends_on: @@ -224,7 +227,8 @@ services: env_file: - 'lib-procedures/src/test/resources/archiving-test.env' environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - DOCKER_HOSTNAME=${DOCKER_HOSTNAME:-localhost} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://travel-medicine-db/travelmedicine - de.eshg.base.service-url=http://base:8080 - eshg.keycloak.internal.url=http://keycloak:8080 @@ -239,6 +243,8 @@ services: - de.eshg.travel-medicine.department-info.phoneNumber=+49 123 12345678 - de.eshg.travel-medicine.department-info.homepage=www.travel-medicine.de - de.eshg.travel-medicine.department-info.email=travel@medicine.de + - de.eshg.travel-medicine.notification.fromAddress=info.reisemedizin@stadt-frankfurt.de + - de.eshg.travel-medicine.notification.greeting=Ihr Impfberatungsteam der Stadt Frankfurt restart: unless-stopped depends_on: travel-medicine-db: @@ -264,7 +270,7 @@ services: - eshg.keycloak.internal.url=http://keycloak:8080 - eshg.servicedirectory.baseUrl=http://service-directory:8080 - de.eshg.servicedirectory.mock-cert-subject-cn=dummy - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} restart: unless-stopped depends_on: keycloak: @@ -279,7 +285,7 @@ services: env_file: - 'lib-procedures/src/test/resources/archiving-test.env' environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://measles-protection-db/measles_protection - de.eshg.base.service-url=http://base:8080 - eshg.keycloak.internal.url=http://keycloak:8080 @@ -305,7 +311,7 @@ services: statistics: image: ga-lotse/statistics environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://statistics-db/statistics - eshg.keycloak.internal.url=http://keycloak:8080 - de.eshg.base.service-url=http://base:8080 @@ -333,7 +339,7 @@ services: central-repository: image: ga-lotse/central-repository environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://central-repository-db/centralrepository - eshg.servicedirectory.baseUrl=http://service-directory:8080 - de.eshg.servicedirectory.mock-cert-subject-cn=dummy @@ -361,7 +367,7 @@ services: image: ga-lotse/chat-management environment: - synapse.url=http://synapse:8008 - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://chat-management-db/chat_management - de.eshg.base.service-url=http://base:8080 - eshg.keycloak.internal.url=http://keycloak:8080 @@ -429,7 +435,7 @@ services: - spring.datasource.url=jdbc:postgresql://auditlog-db/auditlog - de.eshg.base.service-url=http://base:8080 - de.eshg.auditlog.log-storage-dir=/tmp/auditlog-storage - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - eshg.keycloak.internal.url=http://keycloak:8080 auditlog-db: @@ -448,7 +454,7 @@ services: env_file: - 'lib-procedures/src/test/resources/archiving-test.env' environment: - - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-test-helper} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} - spring.datasource.url=jdbc:postgresql://sti-protection-db/sti_protection - de.eshg.base.service-url=http://base:8080 - eshg.keycloak.internal.url=http://keycloak:8080 @@ -476,13 +482,12 @@ services: ports: - 8096:8080 environment: - DOCKER_HOSTNAME: ${DOCKER_HOSTNAME:-localhost} - spring.datasource.url: jdbc:postgresql://opendata-db/opendata - de.eshg.base.service-url: http://base:8080 - de.eshg.auditlog.log-storage-dir: /tmp/opendata-storage - spring.profiles.active: test-helper - eshg.testclock.enabled: true - eshg.keycloak.internal.url: http://keycloak:8080 + - DOCKER_HOSTNAME=${DOCKER_HOSTNAME:-localhost} + - spring.profiles.active=${ACTIVE_SPRING_PROFILES:-local,test-helper} + - spring.datasource.url=jdbc:postgresql://opendata-db/opendata + - de.eshg.base.service-url=http://base:8080 + - de.eshg.auditlog.log-storage-dir=/tmp/opendata-storage + - eshg.keycloak.internal.url=http://keycloak:8080 opendata-db: extends: diff --git a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionPopulator.java b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionPopulator.java index f442caf2098bae7add54b1ff1a21ddd6b769dd86..ffb137ed7c07839116e9a8e9aed106c2961583de 100644 --- a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionPopulator.java +++ b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionPopulator.java @@ -12,6 +12,7 @@ import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import de.eshg.testhelper.population.BasePopulator; import de.eshg.testhelper.population.ListWithTotalNumber; import de.eshg.testhelper.population.PopulateWithAccessTokenHelper; +import de.eshg.testhelper.population.RequestContextFaker; import java.time.Clock; import net.datafaker.Faker; import org.springframework.core.env.Environment; @@ -61,7 +62,11 @@ public class InspectionPopulator extends BasePopulator<InspectionDto> { Faker faker, BasePopulator<InspectionDto>.UniqueValueProvider uniqueValueProvider) { InspectionDto response = facilityTestDataProvider.createTestFacilityAndStartInsp(index); - inspectionTestDataProvider.prepareTestInspection(response.externalId(), faker, index); + RequestContextFaker.withFakedRequestContextIfNecessary( + () -> { + inspectionTestDataProvider.prepareTestInspection(response.externalId(), faker, index); + return null; + }); return response; } diff --git a/backend/keycloak/resources/themes/custom-keycloak/login/resources/css/custom-login.css b/backend/keycloak/resources/themes/custom-keycloak/login/resources/css/custom-login.css index ebe3a228f84c7a416227c69838d49ae338c7b62c..95e046c3c3e9cf415e600ea49e58eed9d0f3a5f9 100644 --- a/backend/keycloak/resources/themes/custom-keycloak/login/resources/css/custom-login.css +++ b/backend/keycloak/resources/themes/custom-keycloak/login/resources/css/custom-login.css @@ -176,6 +176,10 @@ a { } } +#kc-current-locale-link { + display: none; +} + #kc-page-title { text-align: left; align-self: self-start; diff --git a/backend/lib-aggregation/build.gradle b/backend/lib-aggregation/build.gradle index 8d0f608c431799c5a2f886cc9792d9e5ec19d952..0332c2145f1d47b87051188aeb68c9665bbe996e 100644 --- a/backend/lib-aggregation/build.gradle +++ b/backend/lib-aggregation/build.gradle @@ -5,6 +5,7 @@ plugins { dependencies { implementation project(':business-module-commons') + implementation project(':logging-commons') api project(':lib-statistics-api') api project(':lib-procedures-api') diff --git a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/AggregationHelper.java b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/AggregationHelper.java index d448581ebc85aa60a745b34ff52d8c3e30f6f8bd..1b178d604b462f39a4c07d8d339df560f3e1549d 100644 --- a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/AggregationHelper.java +++ b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/AggregationHelper.java @@ -44,8 +44,8 @@ public abstract class AggregationHelper { <R, C extends ClientWithLocationAndTimeout> List<ClientResponse<R>> requestFromClients( List<C> clients, Function<C, R> getFromClient) { try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) { - DelegatingSecurityContextExecutor delegatingSecurityContextExecutor = - new DelegatingSecurityContextExecutor(executorService); + Executor executor = + new CorrelationIdAwareExecutor(new DelegatingSecurityContextExecutor(executorService)); Map<String, Future<R>> futures = clients.stream() @@ -56,7 +56,7 @@ public abstract class AggregationHelper { getAsyncOrTimout( () -> getFromClient.apply(client), client.getClientTimeout(), - delegatingSecurityContextExecutor))); + executor))); try { return futures.entrySet().stream() diff --git a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleAggregationHelper.java b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleAggregationHelper.java index f5ead3e4dd169920dd39ea672540de51bf6fed8b..bb31981175e0deea3ebc4b647dec53d982ebc202 100644 --- a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleAggregationHelper.java +++ b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleAggregationHelper.java @@ -75,8 +75,8 @@ public class BusinessModuleAggregationHelper extends AggregationHelper { } } - public <T2> List<ClientResponse<T2>> requestFromBusinessModulesClients( - Set<String> businessModules, Function<BusinessModuleClient, T2> getFromBusinessModule) { + public <T> List<ClientResponse<T>> requestFromBusinessModulesClients( + Set<String> businessModules, Function<BusinessModuleClient, T> getFromBusinessModule) { List<BusinessModuleClient> businessModuleClients = businessModuleClientRegistry.getBusinessModuleClients().stream() .filter(client -> bySetContainingValue(businessModules, client::getLocation)) @@ -84,9 +84,9 @@ public class BusinessModuleAggregationHelper extends AggregationHelper { return requestFromClients(businessModuleClients, getFromBusinessModule); } - public <T2> List<ClientResponse<T2>> requestFromBusinessModules( + public <T> List<ClientResponse<T>> requestFromBusinessModules( Set<BusinessModule> businessModules, - Function<BusinessModuleClient, T2> getFromBusinessModule) { + Function<BusinessModuleClient, T> getFromBusinessModule) { Set<String> businessModuleNames = businessModules == null ? null diff --git a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleClient.java b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleClient.java index 610d3a559c8a886f0f359f2b2ad5cce7d639f013..57c9485795284928b48e69df38f5a73f7c5ddefa 100644 --- a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleClient.java +++ b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/BusinessModuleClient.java @@ -30,6 +30,7 @@ import de.eshg.lib.statistics.api.GetDataSourcesResponse; import de.eshg.lib.statistics.api.GetSpecificDataRequest; import de.eshg.lib.statistics.api.GetSpecificDataResponse; import de.eshg.rest.client.AccessTokenForwardingInterceptor; +import de.eshg.rest.client.CorrelationIdForwardingInterceptor; import de.eshg.rest.client.SimpleModelAttributeArgumentResolver; import java.time.Duration; import java.time.Instant; @@ -93,6 +94,7 @@ public class BusinessModuleClient extends ClientWithLocationAndTimeout restClientBuilder .baseUrl(url) .requestInterceptor(new AccessTokenForwardingInterceptor()) + .requestInterceptor(new CorrelationIdForwardingInterceptor()) .build(); RestClientAdapter restClientAdapter = RestClientAdapter.create(restClient); diff --git a/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/CorrelationIdAwareExecutor.java b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/CorrelationIdAwareExecutor.java new file mode 100644 index 0000000000000000000000000000000000000000..36381de296b5e30b4d2316a99807cc4d147374f2 --- /dev/null +++ b/backend/lib-aggregation/src/main/java/de/eshg/lib/aggregation/CorrelationIdAwareExecutor.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.aggregation; + +import de.eshg.logging.LoggingConstants; +import java.util.concurrent.Executor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +public class CorrelationIdAwareExecutor implements Executor { + + private static final Logger log = LoggerFactory.getLogger(CorrelationIdAwareExecutor.class); + + private final Executor executor; + + public CorrelationIdAwareExecutor(Executor executor) { + this.executor = executor; + } + + @Override + public final void execute(Runnable task) { + this.executor.execute(wrap(task)); + } + + private static Runnable wrap(Runnable task) { + String correlationId = MDC.get(LoggingConstants.CORRELATION_ID_MDC_KEY); + if (correlationId == null) { + return task; + } + return () -> { + try (MDC.MDCCloseable mdcCloseable = + MDC.putCloseable(LoggingConstants.CORRELATION_ID_MDC_KEY, correlationId)) { + log.trace("{}", mdcCloseable); + task.run(); + } + }; + } +} diff --git a/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogAutoConfiguration.java b/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogAutoConfiguration.java index 1b9a40f2027a5b5e0ce736096c9814f26fbdac95..deff229c1d465d38646f5695cd2d30154e09c824 100644 --- a/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogAutoConfiguration.java +++ b/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogAutoConfiguration.java @@ -13,6 +13,7 @@ import de.eshg.lib.auditlog.AuditLogger; import de.eshg.lib.auditlog.DefaultUuidProvider; import de.eshg.lib.auditlog.config.AuditLogConfig; import de.eshg.rest.client.AccessTokenForwardingInterceptor; +import de.eshg.rest.client.CorrelationIdForwardingInterceptor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -45,6 +46,7 @@ public class AuditLogAutoConfiguration { restClientBuilder .baseUrl(auditLogConfig.getServiceUrl()) .requestInterceptor(new AccessTokenForwardingInterceptor()) + .requestInterceptor(new CorrelationIdForwardingInterceptor()) .build(); RestClientAdapter restClientAdapter = RestClientAdapter.create(restTemplate); diff --git a/backend/lib-base-client/src/main/java/de/eshg/base/client/BaseClientAutoConfiguration.java b/backend/lib-base-client/src/main/java/de/eshg/base/client/BaseClientAutoConfiguration.java index 7505359c06b1764018fa7f4af26796ccda605b46..ee3ba9b145a389355584e0d544ae405c606466ce 100644 --- a/backend/lib-base-client/src/main/java/de/eshg/base/client/BaseClientAutoConfiguration.java +++ b/backend/lib-base-client/src/main/java/de/eshg/base/client/BaseClientAutoConfiguration.java @@ -20,6 +20,7 @@ import de.eshg.base.statistics.BaseStatisticsApi; import de.eshg.base.testhelper.BaseTestHelperApi; import de.eshg.base.user.UserApi; import de.eshg.rest.client.AccessTokenForwardingInterceptor; +import de.eshg.rest.client.CorrelationIdForwardingInterceptor; import de.eshg.rest.client.SimpleModelAttributeArgumentResolver; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import de.eshg.testhelper.TestHelperAutoConfiguration; @@ -127,6 +128,7 @@ class BaseClientAutoConfiguration { restClientBuilder .baseUrl(baseClientProperties.getServiceUrl()) .requestInterceptor(new AccessTokenForwardingInterceptor()) + .requestInterceptor(new CorrelationIdForwardingInterceptor()) .build(); RestClientAdapter restClientAdapter = RestClientAdapter.create(restClient); diff --git a/backend/lib-central-repository-client/src/main/java/de/eshg/centralrepository/client/CentralRepositoryClientAutoConfiguration.java b/backend/lib-central-repository-client/src/main/java/de/eshg/centralrepository/client/CentralRepositoryClientAutoConfiguration.java index 11efa1b2a8f05d6453e59295cdf53addc7034a5a..df4f6c46b8ddd46813114b2ca4251a47b2a15e5c 100644 --- a/backend/lib-central-repository-client/src/main/java/de/eshg/centralrepository/client/CentralRepositoryClientAutoConfiguration.java +++ b/backend/lib-central-repository-client/src/main/java/de/eshg/centralrepository/client/CentralRepositoryClientAutoConfiguration.java @@ -6,12 +6,12 @@ package de.eshg.centralrepository.client; import de.eshg.rest.client.AccessTokenForwardingInterceptor; +import de.eshg.rest.client.CorrelationIdForwardingInterceptor; import io.micrometer.common.util.StringUtils; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.PropertySource; -import org.springframework.core.env.Environment; import org.springframework.web.client.RestClient; @AutoConfiguration @@ -21,13 +21,12 @@ public class CentralRepositoryClientAutoConfiguration { @Bean CentralRepositoryRestClient centralRepositoryRestClient( - RestClient.Builder restClientBuilder, - CentralRepositoryClientProperties properties, - Environment env) { + RestClient.Builder restClientBuilder, CentralRepositoryClientProperties properties) { restClientBuilder .baseUrl(properties.getServiceUrl()) - .requestInterceptor(new AccessTokenForwardingInterceptor()); + .requestInterceptor(new AccessTokenForwardingInterceptor()) + .requestInterceptor(new CorrelationIdForwardingInterceptor()); if (StringUtils.isNotEmpty(properties.getMockCertSubjectCn())) { restClientBuilder.requestInterceptor( diff --git a/backend/lib-document-generator/src/main/java/de/eshg/lib/document/generator/department/DepartmentClient.java b/backend/lib-document-generator/src/main/java/de/eshg/lib/document/generator/department/DepartmentClient.java index 28eca7352f7acfa7adc1e756b7415da25ef9fd01..e9ea736a9cf6da2265a2c858e20d79b392a6a6cf 100644 --- a/backend/lib-document-generator/src/main/java/de/eshg/lib/document/generator/department/DepartmentClient.java +++ b/backend/lib-document-generator/src/main/java/de/eshg/lib/document/generator/department/DepartmentClient.java @@ -14,21 +14,35 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.util.Assert; +import org.springframework.web.context.annotation.RequestScope; @Component +@RequestScope public class DepartmentClient { private final DepartmentApi departmentApi; + private GetDepartmentInfoResponse cachedDepartmentInfo; + private DepartmentLogo cachedDepartmentLogo; public DepartmentClient(DepartmentApi departmentApi) { this.departmentApi = departmentApi; } public GetDepartmentInfoResponse getDepartmentInfo() { - return departmentApi.getDepartmentInfo(); + if (cachedDepartmentInfo == null) { + cachedDepartmentInfo = departmentApi.getDepartmentInfo(); + } + return cachedDepartmentInfo; } public DepartmentLogo getDepartmentLogo() { + if (cachedDepartmentLogo == null) { + cachedDepartmentLogo = fetchDepartmentLogo(); + } + return cachedDepartmentLogo; + } + + public DepartmentLogo fetchDepartmentLogo() { ResponseEntity<Resource> departmentLogoResponse = departmentApi.getDepartmentLogo(); Assert.isTrue( departmentLogoResponse.getStatusCode().equals(HttpStatus.OK), diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/repository/ProcedureRepository.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/repository/ProcedureRepository.java index 06e0c5ef8f4ef76d9869d9d3412acb530a368fae..e826913d5c35fa2121af40f8acfa448480482b52 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/repository/ProcedureRepository.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/repository/ProcedureRepository.java @@ -171,9 +171,31 @@ public interface ProcedureRepository<ProcedureT extends Procedure<ProcedureT, ?, List<ProcedureT> findByRelatedPersonsCentralFileStateIdInOrderByCreatedAtDescIdAsc( Collection<UUID> centralFileStateIds); - List<ProcedureT> - findByRelatedPersonsCentralFileStateIdInAndRelatedPersonsPersonTypeOrderByCreatedAtDescIdAsc( - Collection<UUID> centralFileStateIds, PersonType personType); + @Query( + """ + SELECT procedure from #{#entityName} procedure +JOIN procedure.relatedPersons relatedPerson +WHERE relatedPerson.centralFileStateId IN :centralFileStateIds +AND relatedPerson.personType = :personType +ORDER BY procedure.createdAt DESC, procedure.id ASC +""") + List<ProcedureT> findByRelatedPersonsCentralFileStateIds( + @Param("centralFileStateIds") Collection<UUID> centralFileStateIds, + @Param("personType") PersonType personType); + + @Query( + """ + SELECT procedure from #{#entityName} procedure + JOIN procedure.relatedPersons relatedPerson + WHERE relatedPerson.centralFileStateId IN :centralFileStateIds + AND relatedPerson.personType = :personType + AND procedure.procedureStatus = :procedureStatus + ORDER BY procedure.createdAt DESC, procedure.id ASC + """) + List<ProcedureT> findByRelatedPersonsCentralFileStateIds( + @Param("centralFileStateIds") Collection<UUID> centralFileStateIds, + @Param("personType") PersonType personType, + @Param("procedureStatus") ProcedureStatus procedureStatus); List<ProcedureT> findAllByArchivingRelevance(ArchivingRelevance archivingRelevance); diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureSearchService.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureSearchService.java index 764e585e55bfe7a394cb2a5b95a309fe581cd237..52d4a2c3a897f13165974235710781731e8fdcf6 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureSearchService.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureSearchService.java @@ -12,8 +12,10 @@ import de.eshg.base.centralfile.PersonApi; import de.eshg.base.centralfile.api.facility.AddFacilityFileStateResponse; import de.eshg.base.centralfile.api.facility.GetFacilityFileStatesRequest; import de.eshg.base.centralfile.api.person.AddPersonFileStateResponse; +import de.eshg.base.centralfile.api.person.GetPersonFileStateIdsByKeyAttributesRequest; import de.eshg.base.centralfile.api.person.GetPersonFileStatesRequest; import de.eshg.base.centralfile.api.person.GetReferencePersonResponse; +import de.eshg.base.centralfile.api.person.PersonKeyAttributes; import de.eshg.lib.procedure.domain.model.PersonType; import de.eshg.lib.procedure.domain.model.Procedure; import de.eshg.lib.procedure.domain.model.ProcedureStatus; @@ -26,10 +28,13 @@ import java.time.LocalDate; import java.util.Collection; import java.util.Comparator; import java.util.EnumSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Map.Entry; import java.util.Optional; +import java.util.Set; import java.util.StringJoiner; import java.util.UUID; import java.util.function.Function; @@ -104,6 +109,27 @@ public class ProcedureSearchService<ProcedureT extends Procedure<ProcedureT, ?, return foundProcedures; } + public Map<PersonKeyAttributes, List<ProcedureT>> searchOpenProceduresByPersons( + Set<PersonKeyAttributes> searchAttributes, PersonType personType) { + if (searchAttributes.isEmpty()) { + return Map.of(); + } + Map<PersonKeyAttributes, List<UUID>> fileStateIdsByPersonAttributes = + personApi + .getPersonFileStateIdsByReferencePersonKeyAttributes( + new GetPersonFileStateIdsByKeyAttributesRequest(searchAttributes)) + .fileStateIdsByPersons(); + + Map<PersonKeyAttributes, List<ProcedureT>> result = new LinkedHashMap<>(); + for (Entry<PersonKeyAttributes, List<UUID>> entry : fileStateIdsByPersonAttributes.entrySet()) { + List<ProcedureT> procedures = + procedureRepository.findByRelatedPersonsCentralFileStateIds( + entry.getValue(), personType, ProcedureStatus.OPEN); + result.put(entry.getKey(), procedures); + } + return result; + } + public List<ProcedureT> searchProceduresByPerson( String firstName, String lastName, LocalDate dateOfBirth, PersonType personType) { List<UUID> fileStateIds = @@ -116,9 +142,7 @@ public class ProcedureSearchService<ProcedureT extends Procedure<ProcedureT, ?, .fileStateIds()) .flatMap(Collection::stream) .toList(); - return procedureRepository - .findByRelatedPersonsCentralFileStateIdInAndRelatedPersonsPersonTypeOrderByCreatedAtDescIdAsc( - fileStateIds, personType); + return procedureRepository.findByRelatedPersonsCentralFileStateIds(fileStateIds, personType); } private Function<ProcedureT, SearchableProcedure<ProcedureT>> formatAsSearchable( diff --git a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/BasePublicSecurityConfig.java b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/BasePublicSecurityConfig.java index 61399343dfa0b06e938a15b7a1bc04b455a12dd2..35aaaebd5e999833937d2a8766dcd16aa752b402 100644 --- a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/BasePublicSecurityConfig.java +++ b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/BasePublicSecurityConfig.java @@ -71,6 +71,8 @@ public final class BasePublicSecurityConfig extends AbstractPublicSecurityConfig .hasRole(EmployeePermissionRole.BASE_GDPR_PROCEDURE_READ); requestMatchers(POST, BaseUrls.Base.GDPR_PROCEDURE_API + "/**") .hasRole(EmployeePermissionRole.BASE_GDPR_PROCEDURE_WRITE); + requestMatchers(PUT, BaseUrls.Base.GDPR_PROCEDURE_API + "/**") + .hasRole(EmployeePermissionRole.BASE_GDPR_PROCEDURE_WRITE); } private void contacts() { diff --git a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StatisticsPublicSecurityConfig.java b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StatisticsPublicSecurityConfig.java index 8d83fac7d0df5009e57c757c263979395e90e2db..97900114732042bf1292e60ba92a3015a62c3937 100644 --- a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StatisticsPublicSecurityConfig.java +++ b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StatisticsPublicSecurityConfig.java @@ -63,6 +63,8 @@ public final class StatisticsPublicSecurityConfig extends AbstractPublicSecurity .hasAnyRole( EmployeePermissionRole.STATISTICS_STATISTICS_READ, EmployeePermissionRole.STATISTICS_STATISTICS_WRITE); + requestMatchers(DELETE, BaseUrls.Statistics.REPORT_URL + "/**") + .hasRole(EmployeePermissionRole.STATISTICS_STATISTICS_WRITE); requestMatchers(BaseUrls.Statistics.DATA_SOURCE_CONTROLLER + "/**") .hasAnyRole( diff --git a/backend/logging-commons/README_LICENSE.adoc b/backend/logging-commons/README_LICENSE.adoc new file mode 100644 index 0000000000000000000000000000000000000000..87f2419aaf60835f287ea4b3d058bd1a2cd01097 --- /dev/null +++ b/backend/logging-commons/README_LICENSE.adoc @@ -0,0 +1,5 @@ +== Licensing + +All files within this directory, including those in all subdirectories, are licensed under the Apache License 2.0. + +For the complete license text, please refer to the `LICENSE-APACHE-2.0.txt` file located in the project root. diff --git a/backend/logging-commons/build.gradle b/backend/logging-commons/build.gradle new file mode 100644 index 0000000000000000000000000000000000000000..16abd18c2e7b1339a8106fcc35843907f8a4869f --- /dev/null +++ b/backend/logging-commons/build.gradle @@ -0,0 +1,3 @@ +plugins { + id "eshg.java-lib" +} diff --git a/backend/logging-commons/buildscript-gradle.lockfile b/backend/logging-commons/buildscript-gradle.lockfile new file mode 100644 index 0000000000000000000000000000000000000000..0d156738b209adc7660610a8d35b7e87ebdb8211 --- /dev/null +++ b/backend/logging-commons/buildscript-gradle.lockfile @@ -0,0 +1,4 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +empty=classpath diff --git a/backend/logging-commons/gradle.lockfile b/backend/logging-commons/gradle.lockfile new file mode 100644 index 0000000000000000000000000000000000000000..0dccf585508f79ea672a181febfba0ac3e39fbf0 --- /dev/null +++ b/backend/logging-commons/gradle.lockfile @@ -0,0 +1,61 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +ch.qos.logback:logback-classic:1.5.8=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.8=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.13.4=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.13.4=testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.3=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:2.1.1=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.14.19=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.14.19=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.5.1=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.5.1=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.23.1=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.23.1=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.25.3=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.2.2=testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:2.2=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.11=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.11=jacocoAnt +org.jacoco:org.jacoco.core:0.8.11=jacocoAnt +org.jacoco:org.jacoco.report:0.8.11=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.10.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.10.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.10.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.10.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.10.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.10.3=testRuntimeClasspath +org.junit:junit-bom:5.10.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.11.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.11.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-commons:9.6=jacocoAnt +org.ow2.asm:asm-tree:9.6=jacocoAnt +org.ow2.asm:asm:9.6=jacocoAnt,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.16=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:3.3.4=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:6.1.13=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:6.1.13=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:6.1.13=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:6.1.13=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:6.1.13=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jcl:6.1.13=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:6.1.13=testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.9.1=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.2=testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor,compileClasspath,developmentOnly,productionRuntimeClasspath,runtimeClasspath,testAndDevelopmentOnly,testAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath diff --git a/backend/logging-commons/src/main/java/de/eshg/logging/LoggingConstants.java b/backend/logging-commons/src/main/java/de/eshg/logging/LoggingConstants.java new file mode 100644 index 0000000000000000000000000000000000000000..8f17829d77d2ce2de7ecb62b25d64fc97c315231 --- /dev/null +++ b/backend/logging-commons/src/main/java/de/eshg/logging/LoggingConstants.java @@ -0,0 +1,14 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.logging; + +public final class LoggingConstants { + + public static final String CORRELATION_ID_HEADER = "X-Correlation-ID"; + public static final String CORRELATION_ID_MDC_KEY = "correlationId"; + + private LoggingConstants() {} +} diff --git a/backend/relay-server/src/main/java/de/eshg/relayserver/ws/WebsocketEndpoint.java b/backend/relay-server/src/main/java/de/eshg/relayserver/ws/WebsocketEndpoint.java index 365039d9bca18556834e3036bd75fb00723eca6d..17248ee51580f9c9954243ad9db3735ddc475ab5 100644 --- a/backend/relay-server/src/main/java/de/eshg/relayserver/ws/WebsocketEndpoint.java +++ b/backend/relay-server/src/main/java/de/eshg/relayserver/ws/WebsocketEndpoint.java @@ -85,7 +85,7 @@ public class WebsocketEndpoint { @OnMessage public void onPong(PongMessage pongMessage) { String pongPayload = StandardCharsets.UTF_8.decode(pongMessage.getApplicationData()).toString(); - logger.info("Received pong with applicationData {} for sni {}", pongPayload, this.sni); + logger.debug("Received pong with applicationData {} for sni {}", pongPayload, this.sni); if (!pongPayload.equals(outstandingPingPayload.get())) { logger.warn( "Payload mismatch: expected {}. Ignoring pong for sni {}", @@ -206,7 +206,7 @@ public class WebsocketEndpoint { return; } String id = UUID.randomUUID().toString(); - logger.info("Sending ping for sni {} with payload {}", this.sni, id); + logger.debug("Sending ping for sni {} with payload {}", this.sni, id); try { session.getBasicRemote().sendPing(ByteBuffer.wrap(id.getBytes(StandardCharsets.UTF_8))); outstandingPingPayload.set(id); diff --git a/backend/rest-client-commons/build.gradle b/backend/rest-client-commons/build.gradle index 897b1bf2ef87cf0da673d7fcfa824a0c9eff24f2..03548c0e79adfec8fed6ea9145d0505bf2f3a4eb 100644 --- a/backend/rest-client-commons/build.gradle +++ b/backend/rest-client-commons/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation 'org.apache.httpcomponents.client5:httpclient5' implementation "org.zalando:logbook-httpclient5:latest.release" implementation project(":api-commons") + implementation project(":logging-commons") runtimeOnly "org.zalando:logbook-spring-boot-starter:latest.release" diff --git a/backend/rest-client-commons/src/main/java/de/eshg/rest/client/CorrelationIdForwardingInterceptor.java b/backend/rest-client-commons/src/main/java/de/eshg/rest/client/CorrelationIdForwardingInterceptor.java new file mode 100644 index 0000000000000000000000000000000000000000..d9066787256e248347c8b82eab918b8943f4443f --- /dev/null +++ b/backend/rest-client-commons/src/main/java/de/eshg/rest/client/CorrelationIdForwardingInterceptor.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.rest.client; + +import de.eshg.logging.LoggingConstants; +import java.io.IOException; +import org.slf4j.MDC; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +public class CorrelationIdForwardingInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept( + HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + String correlationId = MDC.get(LoggingConstants.CORRELATION_ID_MDC_KEY); + if (correlationId != null) { + request.getHeaders().set(LoggingConstants.CORRELATION_ID_HEADER, correlationId); + } + return execution.execute(request, body); + } +} diff --git a/backend/rest-service-commons/build.gradle b/backend/rest-service-commons/build.gradle index 7f0aed937ba0ec96b2b5507d1b8827d38b7681da..e7505d862a5b51a9912b71e606d6da474fe03ac7 100644 --- a/backend/rest-service-commons/build.gradle +++ b/backend/rest-service-commons/build.gradle @@ -9,6 +9,7 @@ dependencies { implementation 'org.springframework:spring-web' implementation 'org.apache.tomcat.embed:tomcat-embed-core' + implementation project(":logging-commons") implementation project(':test-helper-commons-spring') compileOnly 'org.jetbrains:annotations:latest.release' diff --git a/backend/rest-service-commons/src/main/java/de/eshg/rest/service/commons/filter/RequestLoggingFilter.java b/backend/rest-service-commons/src/main/java/de/eshg/rest/service/commons/filter/RequestLoggingFilter.java index 0daa231772800d60f78b5780688a609595aef4f1..7813120dd72d0ab0d811248d530516d4771c4b5c 100644 --- a/backend/rest-service-commons/src/main/java/de/eshg/rest/service/commons/filter/RequestLoggingFilter.java +++ b/backend/rest-service-commons/src/main/java/de/eshg/rest/service/commons/filter/RequestLoggingFilter.java @@ -5,16 +5,19 @@ package de.eshg.rest.service.commons.filter; +import de.eshg.logging.LoggingConstants; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.Closeable; import java.io.IOException; import java.util.Objects; import java.util.stream.Collectors; import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; @@ -55,20 +58,32 @@ public class RequestLoggingFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); return; } - log.info( - "Starting to process {} {}{}", - request.getMethod(), - request.getRequestURI(), - maskedQueryString(request.getQueryString())); - try { - filterChain.doFilter(request, response); - } finally { - log.info( - "Processed {} {} with result {}", - request.getMethod(), - request.getRequestURI(), - HttpStatus.valueOf(response.getStatus())); + try (Closeable closeable = putCorrelationIdToMdc(request)) { + log.trace("{}", closeable); + try { + log.info( + "Starting to process {} {}{}", + request.getMethod(), + request.getRequestURI(), + maskedQueryString(request.getQueryString())); + + filterChain.doFilter(request, response); + } finally { + log.info( + "Processed {} {} with result {}", + request.getMethod(), + request.getRequestURI(), + HttpStatus.valueOf(response.getStatus())); + } + } + } + + private static Closeable putCorrelationIdToMdc(HttpServletRequest request) { + String correlationId = request.getHeader(LoggingConstants.CORRELATION_ID_HEADER); + if (correlationId == null) { + return () -> {}; } + return MDC.putCloseable(LoggingConstants.CORRELATION_ID_MDC_KEY, correlationId); } @VisibleForTesting diff --git a/backend/school-entry/openApi.yaml b/backend/school-entry/openApi.yaml index 8d45e9164f13036959a8368c169347fb5aeae597..627b49a9582cd82f2ae34701b1c40d64e0802720 100644 --- a/backend/school-entry/openApi.yaml +++ b/backend/school-entry/openApi.yaml @@ -2686,6 +2686,14 @@ paths: description: OK tags: - TestHelper + /test-helper/direct-procedure-type-assignment-on-import: + post: + operationId: enableDirectProcedureTypeAssignmentOnImport + responses: + "200": + description: OK + tags: + - TestHelper /test-helper/enabled-new-features/{featureToDisable}: delete: operationId: disableNewFeature @@ -3413,8 +3421,6 @@ components: items: type: integer format: int32 - required: - - siblingsBirthYears CitizenAnamnesis: type: object properties: @@ -5203,9 +5209,12 @@ components: GetSchoolEntryConfigResponse: type: object properties: + isDirectProcedureTypeAssignmentOnImport: + type: boolean locationSelectionMode: $ref: "#/components/schemas/LocationSelectionMode" required: + - isDirectProcedureTypeAssignmentOnImport - locationSelectionMode GetSchoolEntryFeatureTogglesResponse: type: object diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryConfigController.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryConfigController.java index b8b42a6a052653ba1e1e0ba18203123c25eed040..335a88dfa28a61b7f99795aba1e3195393312e61 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryConfigController.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryConfigController.java @@ -8,6 +8,7 @@ package de.eshg.schoolentry; import de.eshg.lib.appointmentblock.spring.AppointmentBlockProperties; import de.eshg.rest.service.security.config.BaseUrls; import de.eshg.schoolentry.api.GetSchoolEntryConfigResponse; +import de.eshg.schoolentry.config.SchoolEntryProperties; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,13 +22,19 @@ public class SchoolEntryConfigController { public static final String BASE_URL = BaseUrls.SchoolEntry.CONFIG_CONTROLLER; private final AppointmentBlockProperties appointmentBlockProperties; + private final SchoolEntryProperties schoolEntryProperties; - public SchoolEntryConfigController(AppointmentBlockProperties appointmentBlockProperties) { + public SchoolEntryConfigController( + AppointmentBlockProperties appointmentBlockProperties, + SchoolEntryProperties schoolEntryProperties) { this.appointmentBlockProperties = appointmentBlockProperties; + this.schoolEntryProperties = schoolEntryProperties; } @GetMapping public GetSchoolEntryConfigResponse getConfig() { - return new GetSchoolEntryConfigResponse(appointmentBlockProperties.getLocationSelectionMode()); + return new GetSchoolEntryConfigResponse( + appointmentBlockProperties.getLocationSelectionMode(), + schoolEntryProperties.isDirectProcedureTypeAssignmentOnImport()); } } diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryController.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryController.java index 3b871fed6f629cd4f796b13b9c4efe7c73b4312f..537d78756290375820204cf102bd0c2010f7a40d 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryController.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryController.java @@ -19,6 +19,7 @@ import de.eshg.lib.appointmentblock.api.AppointmentDto; import de.eshg.lib.appointmentblock.api.GetFreeAppointmentsResponse; import de.eshg.lib.appointmentblock.spring.AppointmentBlockProperties; import de.eshg.lib.procedure.domain.model.Pdf; +import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.security.config.BaseUrls; import de.eshg.schoolentry.api.*; import de.eshg.schoolentry.api.anamnesis.AnamnesisDto; @@ -26,6 +27,7 @@ import de.eshg.schoolentry.business.model.ImportResult; import de.eshg.schoolentry.business.model.ProcedureDetailsData; import de.eshg.schoolentry.config.SchoolEntryFeature; import de.eshg.schoolentry.config.SchoolEntryFeatureToggle; +import de.eshg.schoolentry.config.SchoolEntryProperties; import de.eshg.schoolentry.domain.model.*; import de.eshg.schoolentry.importer.ImportService; import de.eshg.schoolentry.importer.ImportType; @@ -88,6 +90,7 @@ public class SchoolEntryController { private final Clock clock; private final SchoolEntryFeatureToggle featureToggle; private final AppointmentBlockProperties appointmentBlockProperties; + private final SchoolEntryProperties schoolEntryProperties; public SchoolEntryController( SchoolEntryService schoolEntryService, @@ -99,7 +102,8 @@ public class SchoolEntryController { @Value("classpath:templates/import/CitizenListTemplate.xlsx") Resource citizenListTemplate, @Value("classpath:templates/import/SchoolListTemplate.xlsx") Resource schoolListTemplate, SchoolEntryFeatureToggle featureToggle, - AppointmentBlockProperties appointmentBlockProperties) { + AppointmentBlockProperties appointmentBlockProperties, + SchoolEntryProperties schoolEntryProperties) { this.schoolEntryService = schoolEntryService; this.importService = importService; this.medicalReportGenerator = medicalReportGenerator; @@ -110,6 +114,7 @@ public class SchoolEntryController { this.clock = clock; this.featureToggle = featureToggle; this.appointmentBlockProperties = appointmentBlockProperties; + this.schoolEntryProperties = schoolEntryProperties; } @PostMapping @@ -438,6 +443,10 @@ public class SchoolEntryController { @RequestParam(value = "schoolYear") @Min(1900) int schoolYear, @RequestPart("file") MultipartFile file) throws IOException { + if (schoolEntryProperties.isDirectProcedureTypeAssignmentOnImport()) { + throw new BadRequestException( + "Citizen list import is not allowed when direct procedure type assignment is enabled."); + } return importData(file, ImportType.CITIZEN_LIST, null, null, Year.of(schoolYear)); } @@ -604,6 +613,7 @@ public class SchoolEntryController { @Valid @RequestBody CreateMedicalReportRequest request) { featureToggle.assertNewFeatureIsEnabled(SchoolEntryFeature.MEDICAL_REPORT); SchoolEntryProcedure procedure = schoolEntryService.findProcedureByExternalId(procedureId); + Validator.validateProcedureStatusNotClosed(procedure); ProcedureDetailsData procedureDetailsData = schoolEntryService.augmentWithDetails(procedure); Pdf pdf = medicalReportGenerator.generateMedicalReport(procedureDetailsData.child(), request); @@ -625,6 +635,7 @@ public class SchoolEntryController { featureToggle.assertNewFeatureIsEnabled(SchoolEntryFeature.SCHOOL_INFO_LETTER); SchoolEntryProcedure procedure = schoolEntryService.findProcedureByExternalId(procedureId); + Validator.validateProcedureStatusNotClosed(procedure); ProcedureDetailsData procedureDetailsData = schoolEntryService.augmentWithDetails(procedure); Pdf pdf = diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryService.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryService.java index aea9d74c09ef26857ddbc8b88c9b88a99aab7f87..2c3f7d445bc170f3c62de39fb1a41821093be78e 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryService.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryService.java @@ -14,6 +14,7 @@ import static java.util.Comparator.nullsLast; import de.cronn.commons.lang.StreamUtil; import de.eshg.base.SortDirection; +import de.eshg.base.centralfile.api.person.PersonKeyAttributes; import de.eshg.base.citizenuser.CitizenAccessCodeUserApi; import de.eshg.base.citizenuser.api.AddCitizenAccessCodeUserRequest; import de.eshg.base.citizenuser.api.CitizenAccessCodeUserDto; @@ -53,19 +54,18 @@ import de.eshg.schoolentry.pdf.invitation.InvitationGenerator; import de.eshg.schoolentry.percentiles.PercentileCalculationService; import de.eshg.schoolentry.util.ExceptionUtil; import de.eshg.schoolentry.util.ProcedureSortKey; +import de.eshg.schoolentry.util.ProcedureTypeAssignmentHelper; import de.eshg.schoolentry.util.ProgressEntryUtil; import de.eshg.schoolentry.util.SchoolEntrySystemProgressEntryType; import de.eshg.validation.ValidationUtil; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; -import java.time.MonthDay; import java.time.Year; import java.util.*; import java.util.function.Supplier; import java.util.stream.Stream; import org.apache.commons.collections4.CollectionUtils; -import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Sort; @@ -105,6 +105,7 @@ public class SchoolEntryService { private final SchoolEntryFeatureToggle schoolEntryFeatureToggle; private final ProceduresHelper proceduresHelper; private final ProcedureDeletionService<SchoolEntryProcedure> procedureDeletionService; + private final ProcedureTypeAssignmentHelper procedureTypeAssignmentHelper; public SchoolEntryService( SchoolEntryProcedureRepository schoolEntryProcedureRepository, @@ -133,7 +134,8 @@ public class SchoolEntryService { ProcedureSearchService<SchoolEntryProcedure> procedureSearchService, SchoolEntryFeatureToggle schoolEntryFeatureToggle, ProceduresHelper proceduresHelper, - ProcedureDeletionService<SchoolEntryProcedure> procedureDeletionService) { + ProcedureDeletionService<SchoolEntryProcedure> procedureDeletionService, + ProcedureTypeAssignmentHelper procedureTypeAssignmentHelper) { this.schoolEntryProcedureRepository = schoolEntryProcedureRepository; this.personRepository = personRepository; this.hearingTestResultRepository = hearingTestResultRepository; @@ -161,11 +163,14 @@ public class SchoolEntryService { this.schoolEntryFeatureToggle = schoolEntryFeatureToggle; this.proceduresHelper = proceduresHelper; this.procedureDeletionService = procedureDeletionService; + this.procedureTypeAssignmentHelper = procedureTypeAssignmentHelper; } public SchoolEntryProcedure createProcedure(CreateProcedureRequest request) { return createProcedures( - List.of(new ImportProcedureData(request.child(), request.type())), + List.of( + new ImportProcedureData( + request.child(), ProcedureMapper.mapToDomain(request.type()))), null, null, null, @@ -192,7 +197,7 @@ public class SchoolEntryService { ImportProcedureData procedure = procedures.get(i); ProcedureIds procedureIds = createdIds.get(i); - ProcedureTypeDto procedureType = procedure.procedureType(); + ProcedureType procedureType = procedure.procedureType(); SchoolEntryProcedure schoolEntryProcedure = saveSchoolEntryProcedure( @@ -230,14 +235,14 @@ public class SchoolEntryService { private SchoolEntryProcedure saveSchoolEntryProcedure( UUID childIdFromCentralFile, List<UUID> custodianIdsFromCentralFile, - ProcedureTypeDto type, + ProcedureType type, UUID schoolId, UUID locationId, Year schoolYear, boolean isEntryLevel) { SchoolEntryProcedure schoolEntryProcedure = new SchoolEntryProcedure(); schoolEntryProcedure.updateProcedureStatus(ProcedureStatus.OPEN, clock, auditLogger); - schoolEntryProcedure.setProcedureType(ProcedureMapper.mapToDomain(type)); + schoolEntryProcedure.setProcedureType(type); schoolEntryProcedure.setSchoolId(schoolId); schoolEntryProcedure.setLocationId(locationId); schoolEntryProcedure.setEntryLevel(isEntryLevel); @@ -1064,26 +1069,11 @@ public class SchoolEntryService { } } - public List<ProcedureWithChildData> searchOpenProceduresByChildWithExactMatching( - ImportChildData childData) { - List<SchoolEntryProcedure> procedures = - procedureSearchService - .searchProceduresByPerson( - childData.firstName(), - childData.lastName(), - childData.dateOfBirth(), - PersonType.PATIENT) - .stream() - .filter(procedure -> ProcedureStatus.OPEN.equals(procedure.getProcedureStatus())) - .toList(); - return personClient - .augmentWithChildData(procedures) - .filter( - procedure -> - Objects.equals(procedure.child().firstName(), childData.firstName()) - && Objects.equals(procedure.child().lastName(), childData.lastName()) - && Objects.equals(procedure.child().dateOfBirth(), childData.dateOfBirth())) - .toList(); + public Map<PersonKeyAttributes, List<ProcedureWithChildData>> searchForMergeCandidates( + Set<PersonKeyAttributes> searchAttributes) { + Map<PersonKeyAttributes, List<SchoolEntryProcedure>> proceduresByPersons = + procedureSearchService.searchOpenProceduresByPersons(searchAttributes, PersonType.PATIENT); + return personClient.augmentWithChildData(proceduresByPersons); } void updateHearingTestResult( @@ -1456,37 +1446,11 @@ public class SchoolEntryService { } private void updateProcedureTypeWithSuggestion(SchoolEntryProcedure procedure) { - if (procedure.isEntryLevel()) { - procedure.setProcedureType(ProcedureType.ENTRY_LEVEL); - } else { - LocalDate dateOfBirth = personClient.fetchChildData(procedure).dateOfBirth(); - if (isRegularSchoolEntry(dateOfBirth, procedure.getSchoolYear())) { - procedure.setProcedureType(ProcedureType.REGULAR_EXAMINATION); - } else { - procedure.setProcedureType(ProcedureType.CAN_CHILD); - } - } - } - - @VisibleForTesting - boolean isRegularSchoolEntry(LocalDate dateOfBirth, Year schoolYear) { - MonthDay maxDateOfBirthForRegularSchoolEntry = - schoolEntryProperties.getMaxDateOfBirthForRegularSchoolEntry(); - if (schoolEntryFeatureToggle.isNewFeatureEnabled(SchoolEntryFeature.SCHOOL_YEAR)) { - LocalDate maxDateOfBirthForRegularSchoolEntryWithYear = - schoolYear.minusYears(6).atMonthDay(maxDateOfBirthForRegularSchoolEntry); - if (schoolEntryProperties.isMaxDateOfBirthForRegularSchoolEntryIsInclusive()) { - return !dateOfBirth.isAfter(maxDateOfBirthForRegularSchoolEntryWithYear); - } else { - return dateOfBirth.isBefore(maxDateOfBirthForRegularSchoolEntryWithYear); - } - } else { - if (schoolEntryProperties.isMaxDateOfBirthForRegularSchoolEntryIsInclusive()) { - return MonthDay.from(dateOfBirth).compareTo(maxDateOfBirthForRegularSchoolEntry) <= 0; - } else { - return MonthDay.from(dateOfBirth).compareTo(maxDateOfBirthForRegularSchoolEntry) < 0; - } - } + procedure.setProcedureType( + procedureTypeAssignmentHelper.suggestProcedureType( + procedure.isEntryLevel(), + personClient.fetchChildData(procedure).dateOfBirth(), + procedure.getSchoolYear())); } void updateWaitingRoomDetails( diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/api/GetSchoolEntryConfigResponse.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/api/GetSchoolEntryConfigResponse.java index bc81e1426ad914cf50ebbc038bdba951e733ffdd..a62f296077cc9302b60ef186e5f65c959b4898ef 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/api/GetSchoolEntryConfigResponse.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/api/GetSchoolEntryConfigResponse.java @@ -8,4 +8,6 @@ package de.eshg.schoolentry.api; import de.eshg.lib.appointmentblock.LocationSelectionMode; import jakarta.validation.constraints.NotNull; -public record GetSchoolEntryConfigResponse(@NotNull LocationSelectionMode locationSelectionMode) {} +public record GetSchoolEntryConfigResponse( + @NotNull LocationSelectionMode locationSelectionMode, + @NotNull boolean isDirectProcedureTypeAssignmentOnImport) {} diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/api/anamnesis/CitizenAdditionalChildInfoDto.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/api/anamnesis/CitizenAdditionalChildInfoDto.java index 27ba79eee085e3e5cc7c6f21b94a9bb0ef737de1..6c10e56a9ac8383479efaa7699dc54c80f93044e 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/api/anamnesis/CitizenAdditionalChildInfoDto.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/api/anamnesis/CitizenAdditionalChildInfoDto.java @@ -6,13 +6,12 @@ package de.eshg.schoolentry.api.anamnesis; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; import java.util.List; @Schema(name = "CitizenAdditionalChildInfo") public record CitizenAdditionalChildInfoDto( - String responsiblePhysician, @NotNull List<Integer> siblingsBirthYears) { + String responsiblePhysician, List<Integer> siblingsBirthYears) { public CitizenAdditionalChildInfoDto() { - this(null, List.of()); + this(null, null); } } diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/business/model/ImportProcedureData.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/business/model/ImportProcedureData.java index 04000885613314d874fd4b6d50f35b2e9972f0ab..3c016e6188551a172572984f5e22212045f1d176 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/business/model/ImportProcedureData.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/business/model/ImportProcedureData.java @@ -5,24 +5,24 @@ package de.eshg.schoolentry.business.model; +import de.eshg.lib.procedure.domain.model.ProcedureType; import de.eshg.schoolentry.api.CreatePersonDto; -import de.eshg.schoolentry.api.ProcedureTypeDto; import java.util.List; public record ImportProcedureData( CreatePersonDto child, List<ImportCustodianData> custodians, - ProcedureTypeDto procedureType, + ProcedureType procedureType, boolean isEntryLevel, boolean isEarlyExamination) { - public ImportProcedureData(CreatePersonDto child, ProcedureTypeDto procedureType) { + public ImportProcedureData(CreatePersonDto child, ProcedureType procedureType) { this(child, procedureType, false, false); } public ImportProcedureData( CreatePersonDto child, - ProcedureTypeDto procedureType, + ProcedureType procedureType, boolean isEntryLevel, boolean isEarlyExamination) { this(child, List.of(), procedureType, isEntryLevel, isEarlyExamination); diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/client/PersonClient.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/client/PersonClient.java index 082d6553d9d2a1c1fdc3a0c48e205485aeeb7400..75fbb4fbd86b680f9eb40df59f81f2293d3119fd 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/client/PersonClient.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/client/PersonClient.java @@ -27,6 +27,7 @@ import de.eshg.schoolentry.domain.model.Person; import de.eshg.schoolentry.domain.model.SchoolEntryProcedure; import de.eshg.schoolentry.mapper.PersonMapper; import java.util.*; +import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -215,6 +216,34 @@ public class PersonClient { return new ProcedureWithPersonDetailsData(procedure, childDetailsData, custodianData); } + public Map<PersonKeyAttributes, List<ProcedureWithChildData>> augmentWithChildData( + Map<PersonKeyAttributes, List<SchoolEntryProcedure>> proceduresByPerson) { + if (proceduresByPerson.isEmpty()) { + return Map.of(); + } + + List<UUID> personIdsToFetch = + proceduresByPerson.values().stream() + .flatMap(Collection::stream) + .map(SchoolEntryProcedure::getChildIdFromCentralFile) + .toList(); + + Map<UUID, AddPersonFileStateResponse> personsById = + fetchPersonsBulk(personIdsToFetch, null, null, null, null).stream() + .collect(StreamUtil.toLinkedHashMap(AddPersonFileStateResponse::id)); + + Map<PersonKeyAttributes, List<ProcedureWithChildData>> result = new LinkedHashMap<>(); + for (Entry<PersonKeyAttributes, List<SchoolEntryProcedure>> entry : + proceduresByPerson.entrySet()) { + List<ProcedureWithChildData> augmentedProcedures = + entry.getValue().stream() + .map(procedure -> extractChildData(procedure, personsById)) + .toList(); + result.put(entry.getKey(), augmentedProcedures); + } + return result; + } + public Stream<ProcedureWithChildData> augmentWithChildData( List<SchoolEntryProcedure> procedures) { if (procedures.isEmpty()) { diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/config/SchoolEntryProperties.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/config/SchoolEntryProperties.java index dfc7e859115e4ab5d8cce6ed5459e8788b1ac939..a876f9589426a3b03e7618493ac392c0ce8ba8a7 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/config/SchoolEntryProperties.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/config/SchoolEntryProperties.java @@ -22,6 +22,7 @@ public final class SchoolEntryProperties implements ResettableProperties { private @NotNull MonthDay maxDateOfBirthForRegularSchoolEntry; private boolean maxDateOfBirthForRegularSchoolEntryIsInclusive; private @NotNull Integer maxNumberOfImportRows = 10_000; + private boolean directProcedureTypeAssignmentOnImport; public Period getBulkCreateAppointmentsMinLeadTime() { return bulkCreateAppointmentsMinLeadTime; @@ -65,6 +66,15 @@ public final class SchoolEntryProperties implements ResettableProperties { this.bulkCreateAppointmentsMinLeadTime = bulkCreateAppointmentsMinLeadTime; } + public boolean isDirectProcedureTypeAssignmentOnImport() { + return directProcedureTypeAssignmentOnImport; + } + + public void setDirectProcedureTypeAssignmentOnImport( + boolean directProcedureTypeAssignmentOnImport) { + this.directProcedureTypeAssignmentOnImport = directProcedureTypeAssignmentOnImport; + } + public record Citizens( @NotNull Period freeAppointmentsMinLeadTime, @NotNull Period freeAppointmentsMaxLeadTime) {} } diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/CitizenListRowProcessor.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/CitizenListRowProcessor.java index ed5335d8de59a559da3ddf0dc96821400a78b8a6..06786f0d11dd38fbe9eb099b94474405b904d630 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/CitizenListRowProcessor.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/CitizenListRowProcessor.java @@ -7,7 +7,7 @@ package de.eshg.schoolentry.importer; import de.eshg.base.CountryCodeDto; import de.eshg.base.GenderDto; -import de.eshg.schoolentry.api.ProcedureTypeDto; +import de.eshg.lib.procedure.domain.model.ProcedureType; import de.eshg.schoolentry.business.model.AddressData; import de.eshg.schoolentry.business.model.ImportChildData; import de.eshg.schoolentry.business.model.ImportCustodianData; @@ -56,7 +56,7 @@ public class CitizenListRowProcessor extends RowProcessor<CitizenListRowValues> return new ImportProcedureData( PersonMapper.mapImportChildDataToCreatePersonDto(values.getChild()), values.getCustodians(), - ProcedureTypeDto.DRAFT_CITIZEN_OFFICE_IMPORT, + ProcedureType.DRAFT_CITIZEN_OFFICE_IMPORT, false, false); } diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/ImportService.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/ImportService.java index 01947dd5dbe8a1c548560caed4681cec34a44798..3c3af4faba5406b74b0e57f64540c612f2447849 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/ImportService.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/ImportService.java @@ -13,17 +13,21 @@ import de.eshg.base.GenderDto; import de.eshg.base.address.AddressDto; import de.eshg.base.address.DomesticAddressDto; import de.eshg.base.address.PostboxAddressDto; +import de.eshg.base.centralfile.api.person.PersonKeyAttributes; import de.eshg.lib.procedure.domain.model.ProcedureType; import de.eshg.schoolentry.SchoolEntryService; import de.eshg.schoolentry.api.ImportStatisticsDto; import de.eshg.schoolentry.business.model.*; import de.eshg.schoolentry.config.SchoolEntryFeature; import de.eshg.schoolentry.config.SchoolEntryFeatureToggle; +import de.eshg.schoolentry.config.SchoolEntryProperties; import de.eshg.schoolentry.domain.model.SchoolEntryProcedure; +import de.eshg.schoolentry.util.ProcedureTypeAssignmentHelper; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.Year; import java.util.*; +import java.util.Map.Entry; import java.util.function.Consumer; import java.util.stream.Stream; import org.apache.poi.ss.usermodel.*; @@ -35,6 +39,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ByteArrayResource; import org.springframework.stereotype.Service; +import org.springframework.util.Assert; @Service public class ImportService { @@ -46,11 +51,18 @@ public class ImportService { private final SchoolEntryService schoolEntryService; private final SchoolEntryFeatureToggle schoolEntryFeatureToggle; + private final SchoolEntryProperties schoolEntryProperties; + private final ProcedureTypeAssignmentHelper procedureTypeAssignmentHelper; public ImportService( - SchoolEntryService schoolEntryService, SchoolEntryFeatureToggle schoolEntryFeatureToggle) { + SchoolEntryService schoolEntryService, + SchoolEntryFeatureToggle schoolEntryFeatureToggle, + SchoolEntryProperties schoolEntryProperties, + ProcedureTypeAssignmentHelper procedureTypeAssignmentHelper) { this.schoolEntryService = schoolEntryService; this.schoolEntryFeatureToggle = schoolEntryFeatureToggle; + this.schoolEntryProperties = schoolEntryProperties; + this.procedureTypeAssignmentHelper = procedureTypeAssignmentHelper; } public ImportResult processSheetAndPersistProcedures( @@ -62,7 +74,9 @@ public class ImportService { RowProcessor<? extends RowValues> rowProcessor = switch (importType) { case CITIZEN_LIST -> new CitizenListRowProcessor(normalizedSheet); - case SCHOOL_LIST -> new SchoolListRowProcessor(normalizedSheet); + case SCHOOL_LIST -> + new SchoolListRowProcessor( + normalizedSheet, schoolYear, procedureTypeAssignmentHelper); }; return new Importer<>( normalizedSheet, importType, rowProcessor, schoolId, locationId, schoolYear) @@ -211,39 +225,59 @@ public class ImportService { .filter(Objects::nonNull) .toList(); List<UUID> existingProcedureIds = schoolEntryService.collectExistingProcedures(procedureIds); + Map<PersonKeyAttributes, List<ProcedureWithChildData>> mergeCandidates; + if (schoolEntryFeatureToggle.isNewFeatureEnabled( + SchoolEntryFeature.MERGE_PROCEDURES_ON_IMPORT)) { + Set<PersonKeyAttributes> rowsToSearchFor = + rowValues.values().stream() + .filter(row -> row.getProcedureId() == null) + .filter(RowValues::isValid) + .map(Importer::getChildKeyAttributes) + .collect(StreamUtil.toLinkedHashSet()); + mergeCandidates = schoolEntryService.searchForMergeCandidates(rowsToSearchFor); + } else { + mergeCandidates = Map.of(); + } + + for (Entry<Row, T> entry : rowValues.entrySet()) { + Row row = entry.getKey(); + T value = entry.getValue(); + if (value.getProcedureId() != null) { + if (existingProcedureIds.contains(value.getProcedureId())) { + writeStatus(row, IMPORTED_PREVIOUSLY); + stats.countPreviouslyImported(); + } else { + writeStatus(row, INVALID_PROCEDURE_ID); + stats.countFailed(); + } + } else if (value.getStatus() == DUPLICATE_WITHIN_LIST + || containsMatchingRow(validRows, value)) { + writeStatus(row, DUPLICATE_WITHIN_LIST); + stats.countDuplicated(); + } else if (value.isValid()) { + if (schoolEntryFeatureToggle.isNewFeatureEnabled( + SchoolEntryFeature.MERGE_PROCEDURES_ON_IMPORT)) { + evaluateActionsWhenMergeIsEnabled(row, value, mergeCandidates); + } else { + validRows.importableRows().add(value); + stats.countCreated(); + } + } else { + writeStatus(row, ERROR_INPUT_DATA); + stats.countFailed(); + } + } + } + + private static PersonKeyAttributes getChildKeyAttributes(RowValues rowValues) { + ImportChildData child = rowValues.getChild(); + return new PersonKeyAttributes(child.firstName(), child.lastName(), child.dateOfBirth()); + } - rowValues.forEach( - (row, value) -> { - if (value.getProcedureId() != null) { - if (existingProcedureIds.contains(value.getProcedureId())) { - writeStatus(row, IMPORTED_PREVIOUSLY); - stats.countPreviouslyImported(); - } else { - writeStatus(row, INVALID_PROCEDURE_ID); - stats.countFailed(); - } - } else if (value.getStatus() == DUPLICATE_WITHIN_LIST - || containsMatchingRow(validRows, value)) { - writeStatus(row, DUPLICATE_WITHIN_LIST); - stats.countDuplicated(); - } else if (value.isValid()) { - if (schoolEntryFeatureToggle.isNewFeatureEnabled( - SchoolEntryFeature.MERGE_PROCEDURES_ON_IMPORT)) { - merge(row, value); - } else { - validRows.importableRows().add(value); - stats.countCreated(); - } - } else { - writeStatus(row, ERROR_INPUT_DATA); - stats.countFailed(); - } - }); - } - - private void merge(Row row, T value) { + private void evaluateActionsWhenMergeIsEnabled( + Row row, T value, Map<PersonKeyAttributes, List<ProcedureWithChildData>> mergeCandidates) { List<ProcedureWithChildData> procedures = - schoolEntryService.searchOpenProceduresByChildWithExactMatching(value.getChild()); + mergeCandidates.getOrDefault(getChildKeyAttributes(value), List.of()); if (procedures.isEmpty()) { validRows.importableRows().add(value); stats.countCreated(); @@ -253,6 +287,9 @@ public class ImportService { row, DUPLICATE_IN_ASSET, procedures.getFirst().procedure().getExternalId()); stats.countMergeFailed(); } else { + Assert.isTrue( + !schoolEntryProperties.isDirectProcedureTypeAssignmentOnImport(), + "Procedures of a draft type should not exist when direct procedure type assignment is enabled."); ProcedureWithChildData procedure = procedures.getFirst(); if (procedureMatchesImportValues(procedure, value)) { value.setProcedureId(procedure.procedure().getExternalId()); diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/SchoolListRowProcessor.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/SchoolListRowProcessor.java index a7c5cab9b32582b96a543c386e010af1c7c07507..6686702eaa69d30e09ceb7f582838a4e1dc05d9a 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/SchoolListRowProcessor.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/importer/SchoolListRowProcessor.java @@ -8,12 +8,14 @@ package de.eshg.schoolentry.importer; import static de.eshg.schoolentry.importer.SchoolListRowProcessor.SchoolListFields.*; import de.eshg.base.CountryCodeDto; -import de.eshg.schoolentry.api.ProcedureTypeDto; +import de.eshg.lib.procedure.domain.model.ProcedureType; import de.eshg.schoolentry.business.model.AddressData; import de.eshg.schoolentry.business.model.ImportChildData; import de.eshg.schoolentry.business.model.ImportProcedureData; import de.eshg.schoolentry.business.model.MergeProcedureData; import de.eshg.schoolentry.mapper.PersonMapper; +import de.eshg.schoolentry.util.ProcedureTypeAssignmentHelper; +import java.time.Year; import java.util.Objects; import java.util.function.BiConsumer; import org.apache.poi.ss.usermodel.Cell; @@ -44,8 +46,14 @@ public class SchoolListRowProcessor extends RowProcessor<SchoolListRowValues> { } } - public SchoolListRowProcessor(Sheet sheet) { + private final Year schoolYear; + private final ProcedureTypeAssignmentHelper procedureTypeAssignmentHelper; + + public SchoolListRowProcessor( + Sheet sheet, Year schoolYear, ProcedureTypeAssignmentHelper procedureTypeAssignmentHelper) { super(sheet); + this.schoolYear = schoolYear; + this.procedureTypeAssignmentHelper = procedureTypeAssignmentHelper; } @Override @@ -69,9 +77,12 @@ public class SchoolListRowProcessor extends RowProcessor<SchoolListRowValues> { @Override public ImportProcedureData mapValuesToImportData(SchoolListRowValues values) { + ProcedureType procedureType = + procedureTypeAssignmentHelper.getProcedureTypeForSchoolListImport( + values.isEntryLevel(), values.getChild().dateOfBirth(), schoolYear); return new ImportProcedureData( PersonMapper.mapImportChildDataToCreatePersonDto(values.getChild()), - ProcedureTypeDto.DRAFT_SCHOOL_IMPORT, + procedureType, values.isEntryLevel(), values.isEarlyExamination()); } diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/mapper/AnamnesisMapper.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/mapper/AnamnesisMapper.java index 55b88c0313153676aa5380c3d8567fbeee8479e6..1bbbccd89872162092025fa51f476f31c0e58c79 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/mapper/AnamnesisMapper.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/mapper/AnamnesisMapper.java @@ -222,11 +222,12 @@ public class AnamnesisMapper { anamnesis.setResponsiblePhysician( citizenAnamnesisDto.additionalChildInfo().responsiblePhysician()); anamnesis.setNumberOfSiblings( - citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears().isEmpty() + citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears() == null + || citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears().isEmpty() ? null : citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears().size()); anamnesis.setSiblingsBirthYears( - citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears().isEmpty() + citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears() == null ? null : citizenAnamnesisDto.additionalChildInfo().siblingsBirthYears()); diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/population/CreateLabelsTask.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/population/CreateLabelsTask.java index dcdfb9344693fd1776dbd4b538370e30e5996f80..a8d177a879c94a6da41375dae072461b05b9e1f2 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/population/CreateLabelsTask.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/population/CreateLabelsTask.java @@ -16,13 +16,18 @@ import org.springframework.stereotype.Component; public class CreateLabelsTask { public static final String SPECIAL_NEEDS_LABEL_NAME = "Besonderer Förderbedarf"; + public static final String INFORMATION_BLOCK_LABEL_NAME = "Auskunftssperre"; private static final List<LabelData> SYSTEM_LABELS = List.of( new LabelData( SPECIAL_NEEDS_LABEL_NAME, "Vorgänge mit dieser Kennung erhalten Termine aus den geplanten Terminblöcken der Art \"Besonderer Förderbedarf\"", - "#008000")); + "#008000"), + new LabelData( + INFORMATION_BLOCK_LABEL_NAME, + "Für den Vorgang liegt eine Auskunftssperre vor.", + "#800080")); private final LabelRepository labelRepository; private final TransactionHelper transactionHelper; @@ -37,7 +42,7 @@ public class CreateLabelsTask { transactionHelper.executeInTransaction( () -> SYSTEM_LABELS.forEach( - (labelData) -> { + labelData -> { if (!labelRepository.existsByName(labelData.name())) { Label label = new Label(); label.setName(labelData.name()); diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperController.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperController.java index d6bcdb129e8040639460d780b25008791d85338a..6bddc6498e135f89dc227e31f7f2361a1a12a4c4 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperController.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperController.java @@ -16,6 +16,7 @@ import de.eshg.schoolentry.api.SchoolEntryAppointmentBlockPopulationResult; import de.eshg.schoolentry.api.SchoolEntryProcedurePopulationResult; import de.eshg.schoolentry.config.SchoolEntryFeature; import de.eshg.schoolentry.config.SchoolEntryFeatureToggle; +import de.eshg.schoolentry.config.SchoolEntryProperties; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import de.eshg.testhelper.TestHelperController; import de.eshg.testhelper.api.PopulationRequest; @@ -38,6 +39,7 @@ public class SchoolEntryTestHelperController extends TestHelperController private final SchoolEntryTestHelperService schoolEntryTestHelperService; private final SchoolEntryFeatureToggle schoolEntryFeatureToggle; + private final SchoolEntryProperties schoolEntryProperties; private final SchoolEntryProceduresPopulator schoolEntryProceduresPopulator; private final AppointmentBlockGroupsPopulator schoolEntryAppointmentBlockGroupsPopulator; private final AuditLogTestHelperService auditLogTestHelperService; @@ -46,6 +48,7 @@ public class SchoolEntryTestHelperController extends TestHelperController public SchoolEntryTestHelperController( SchoolEntryTestHelperService schoolEntryTestHelperService, SchoolEntryFeatureToggle schoolEntryFeatureToggle, + SchoolEntryProperties schoolEntryProperties, SchoolEntryProceduresPopulator schoolEntryProceduresPopulator, AppointmentBlockGroupsPopulator schoolEntryAppointmentBlockGroupsPopulator, AuditLogTestHelperService auditLogTestHelperService, @@ -53,6 +56,7 @@ public class SchoolEntryTestHelperController extends TestHelperController super(schoolEntryTestHelperService); this.schoolEntryTestHelperService = schoolEntryTestHelperService; this.schoolEntryFeatureToggle = schoolEntryFeatureToggle; + this.schoolEntryProperties = schoolEntryProperties; this.schoolEntryProceduresPopulator = schoolEntryProceduresPopulator; this.schoolEntryAppointmentBlockGroupsPopulator = schoolEntryAppointmentBlockGroupsPopulator; this.auditLogTestHelperService = auditLogTestHelperService; @@ -89,6 +93,11 @@ public class SchoolEntryTestHelperController extends TestHelperController appointmentBlockProperties.setLocationSelectionMode(newLocationSelectionMode); } + @PostExchange("/direct-procedure-type-assignment-on-import") + public void enableDirectProcedureTypeAssignmentOnImport() { + schoolEntryProperties.setDirectProcedureTypeAssignmentOnImport(true); + } + @PostExchange("/population/procedures") public SchoolEntryProcedurePopulationResult populateProcedures( @Valid @RequestBody PopulationRequest request) { diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProcedureTypeAssignmentHelper.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProcedureTypeAssignmentHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..a11b1d9a23913a31ee8d730c34fdb62b0f5b305d --- /dev/null +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProcedureTypeAssignmentHelper.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.schoolentry.util; + +import de.eshg.lib.procedure.domain.model.ProcedureType; +import de.eshg.schoolentry.config.SchoolEntryFeature; +import de.eshg.schoolentry.config.SchoolEntryFeatureToggle; +import de.eshg.schoolentry.config.SchoolEntryProperties; +import java.time.LocalDate; +import java.time.MonthDay; +import java.time.Year; +import org.jetbrains.annotations.VisibleForTesting; +import org.springframework.stereotype.Component; + +@Component +public class ProcedureTypeAssignmentHelper { + + private final SchoolEntryProperties schoolEntryProperties; + private final SchoolEntryFeatureToggle schoolEntryFeatureToggle; + + public ProcedureTypeAssignmentHelper( + SchoolEntryProperties schoolEntryProperties, + SchoolEntryFeatureToggle schoolEntryFeatureToggle) { + this.schoolEntryProperties = schoolEntryProperties; + this.schoolEntryFeatureToggle = schoolEntryFeatureToggle; + } + + public ProcedureType getProcedureTypeForSchoolListImport( + boolean isEntryLevel, LocalDate dateOfBirth, Year schoolYear) { + if (schoolEntryProperties.isDirectProcedureTypeAssignmentOnImport()) { + return suggestProcedureType(isEntryLevel, dateOfBirth, schoolYear); + } + return ProcedureType.DRAFT_SCHOOL_IMPORT; + } + + public ProcedureType suggestProcedureType( + boolean isEntryLevel, LocalDate dateOfBirth, Year schoolYear) { + if (isEntryLevel) { + return ProcedureType.ENTRY_LEVEL; + } else { + if (isRegularSchoolEntry(dateOfBirth, schoolYear)) { + return ProcedureType.REGULAR_EXAMINATION; + } else { + return ProcedureType.CAN_CHILD; + } + } + } + + @VisibleForTesting + boolean isRegularSchoolEntry(LocalDate dateOfBirth, Year schoolYear) { + MonthDay maxDateOfBirthForRegularSchoolEntry = + schoolEntryProperties.getMaxDateOfBirthForRegularSchoolEntry(); + if (schoolEntryFeatureToggle.isNewFeatureEnabled(SchoolEntryFeature.SCHOOL_YEAR)) { + LocalDate maxDateOfBirthForRegularSchoolEntryWithYear = + schoolYear.minusYears(6).atMonthDay(maxDateOfBirthForRegularSchoolEntry); + if (schoolEntryProperties.isMaxDateOfBirthForRegularSchoolEntryIsInclusive()) { + return !dateOfBirth.isAfter(maxDateOfBirthForRegularSchoolEntryWithYear); + } else { + return dateOfBirth.isBefore(maxDateOfBirthForRegularSchoolEntryWithYear); + } + } else { + if (schoolEntryProperties.isMaxDateOfBirthForRegularSchoolEntryIsInclusive()) { + return MonthDay.from(dateOfBirth).compareTo(maxDateOfBirthForRegularSchoolEntry) <= 0; + } else { + return MonthDay.from(dateOfBirth).compareTo(maxDateOfBirthForRegularSchoolEntry) < 0; + } + } + } +} diff --git a/backend/service-commons/src/main/resources/logback-spring.xml b/backend/service-commons/src/main/resources/logback-spring.xml index bb00d79cf3c2cc0a17ffa9828bd6aab7dca81770..31893156440a41e7104ea125296477bbcd83b2de 100644 --- a/backend/service-commons/src/main/resources/logback-spring.xml +++ b/backend/service-commons/src/main/resources/logback-spring.xml @@ -7,7 +7,7 @@ <include resource="org/springframework/boot/logging/logback/defaults.xml"/> <include resource="org/springframework/boot/logging/logback/console-appender.xml" /> - <springProfile name="!(dev | production)"> + <springProfile name="local"> <root level="info"> <appender-ref ref="CONSOLE" /> </root> @@ -24,8 +24,6 @@ </springProfile> <springProfile name="production"> -<!-- <property name="DEBUG_LOG_DIRECTORY" value="${DEBUG_LOG_DIRECTORY:-/logs}"/>--> - <appender name="logstash-info" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"/> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> @@ -33,24 +31,25 @@ </filter> </appender> - <!-- TODO: clarify the volume path --> -<!-- <appender name="debug-log" class="ch.qos.logback.core.rolling.RollingFileAppender">--> -<!-- <file>${DEBUG_LOG_DIRECTORY}/debug.log</file>--> + <property name="DEBUG_LOG_DIRECTORY" value="/var/log/eshg-debug"/> + + <appender name="debug-log" class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>${DEBUG_LOG_DIRECTORY}/debug.log</file> -<!-- <encoder>--> -<!-- <pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%-5p] [%t] %-40.40logger{39} : %m%n%wEx</pattern>--> -<!-- <charset>UTF-8</charset>--> -<!-- </encoder>--> + <encoder> + <pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%-5p] [%t] [%X] %-40.40logger{39} : %m%n%wEx</pattern> + <charset>UTF-8</charset> + </encoder> -<!-- <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">--> -<!-- <fileNamePattern>${DEBUG_LOG_DIRECTORY}/debug.log.%d{yyyy-MM-dd}</fileNamePattern>--> -<!-- <maxHistory>14</maxHistory>--> -<!-- </rollingPolicy>--> -<!-- </appender>--> + <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> + <fileNamePattern>${DEBUG_LOG_DIRECTORY}/debug.log.%d{yyyy-MM-dd}.gz</fileNamePattern> + <maxHistory>14</maxHistory> + </rollingPolicy> + </appender> <root level="info"> <appender-ref ref="logstash-info" /> -<!-- <appender-ref ref="debug-log" />--> + <appender-ref ref="debug-log" /> </root> </springProfile> diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServicedirectoryApplication.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryApplication.java similarity index 75% rename from backend/service-directory/src/main/java/de/eshg/servicedirectory/ServicedirectoryApplication.java rename to backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryApplication.java index 666e481410199b659c388c6cf92730bddfb2ab2f..ccd78264fda13d3678ada37e61e15d0cfc347a19 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServicedirectoryApplication.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryApplication.java @@ -9,9 +9,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class ServicedirectoryApplication { +public class ServiceDirectoryApplication { public static void main(String[] args) { - SpringApplication.run(ServicedirectoryApplication.class, args); + SpringApplication.run(ServiceDirectoryApplication.class, args); } } diff --git a/backend/settings.gradle b/backend/settings.gradle index bd66948600411fc15eec2c127edbbf65c9a24284..a05e9f7f4183309201bc368e5c8418ae34ae9205 100644 --- a/backend/settings.gradle +++ b/backend/settings.gradle @@ -82,6 +82,7 @@ include 'lib-service-directory-api' include 'lib-statistics' include 'lib-statistics-api' include 'local-service-directory' +include 'logging-commons' include 'measles-protection' include 'opendata' include 'relay-server' diff --git a/backend/spatz/src/main/java/de/eshg/spatz/client/HttpProxyClient.java b/backend/spatz/src/main/java/de/eshg/spatz/client/HttpProxyClient.java index ccd22878d90f370626d66e2980045be6bddc5a09..6db974aafa345a2eb42bbcaa31ce638ef9fca68a 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/client/HttpProxyClient.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/client/HttpProxyClient.java @@ -70,7 +70,7 @@ public class HttpProxyClient { HttpMethod method, Consumer<? super HttpHeaders> headersConsumer, HttpClient httpClient) { - log.info("Starting proxy client"); + log.debug("Starting proxy client"); logConnecting(uri, httpClient); diff --git a/backend/spatz/src/main/java/de/eshg/spatz/common/ServiceDirectoryTopologyService.java b/backend/spatz/src/main/java/de/eshg/spatz/common/ServiceDirectoryTopologyService.java index 15fcd5e06c5d53068fe13bbd9ab4f9b70b468220..90a4659b5bc6d00449de8d8c63fef10a8631b221 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/common/ServiceDirectoryTopologyService.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/common/ServiceDirectoryTopologyService.java @@ -29,6 +29,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -88,15 +89,16 @@ public class ServiceDirectoryTopologyService { if (eTag.equals(lastEtag)) { logger.debug("serviceDirectory topology has not changed"); + logTrustedActors(Level.TRACE); } else { trustedActors = getValidTrustedActors(trustedActorsCacheEntry); logTopologyChanged(trustedActorsCacheEntry, trustedActors); + logTrustedActors(Level.INFO); notifyListeners(trustedActors); lastEtag = eTag; } - logTrustedActors(); lastSuccessfulPollTime = Instant.now(); } catch (RuntimeException e) { handlePollingFailure(e); @@ -155,15 +157,17 @@ public class ServiceDirectoryTopologyService { trustedActorsCacheEntry.eTag()); } - private void logTrustedActors() { - if (logger.isTraceEnabled()) { - logger.trace( - "valid active inbound actors: {}", - trustedActors.inbound.stream().map(ActorResponseDto::commonName).sorted().toList()); - logger.trace( - "valid active outbound actors: {}", - trustedActors.outbound.stream().map(ActorResponseDto::commonName).sorted().toList()); - } + private void logTrustedActors(Level level) { + logger + .atLevel(level) + .log( + "valid active inbound actors: {}", + trustedActors.inbound.stream().map(ActorResponseDto::commonName).sorted().toList()); + logger + .atLevel(level) + .log( + "valid active outbound actors: {}", + trustedActors.outbound.stream().map(ActorResponseDto::commonName).sorted().toList()); } public interface TopologyChangedListener { diff --git a/backend/spatz/src/main/java/de/eshg/spatz/config/SpatzConfigurationProperties.java b/backend/spatz/src/main/java/de/eshg/spatz/config/SpatzConfigurationProperties.java index 93a4bbe1d57aabae7962050bd515756d2abb0883..ebf1f1441926c72671c274fc25e102e4f486843b 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/config/SpatzConfigurationProperties.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/config/SpatzConfigurationProperties.java @@ -55,13 +55,16 @@ public record SpatzConfigurationProperties( * connection and all content then will be forwarded to the application container. * @param targetPort Port where mTLS-terminated data ist forwarded to unencrypted * @param forceClientAuth can be set to {@code false} to disable mTLS in favor of TLS + * @param clientCnAllowList List of CNs that are allowed to call this service in addition to CNs + * configured in the service-directory */ public record InboundConfiguration( String listeningHost, int handlerPort, String targetHost, int targetPort, - boolean forceClientAuth) {} + boolean forceClientAuth, + List<String> clientCnAllowList) {} /** * @param handlerPort Port for outgoing unencrypted traffic. diff --git a/backend/spatz/src/main/java/de/eshg/spatz/dns/DnsResolver.java b/backend/spatz/src/main/java/de/eshg/spatz/dns/DnsResolver.java index 4a00c83e846dea5c1236d3d5fa96e9d781a14141..54437497944ed8926f2d64d9c259564264d05260 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/dns/DnsResolver.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/dns/DnsResolver.java @@ -191,7 +191,7 @@ public class DnsResolver { Message request, Resolver resolver, List<String> forwardRequestAllowList) throws IOException { String name = request.getQuestion().getName().toString(true).toLowerCase(); if (!forwardRequestAllowList.contains(name)) { - logger.warn("Refusing to forward query not in allow-list"); + logger.warn("Refusing to forward query to {} not in allow-list", name); return error(request, Rcode.REFUSED); } logger.trace("Forwarding request to {}", resolver); diff --git a/backend/spatz/src/main/java/de/eshg/spatz/relay/RelayConnector.java b/backend/spatz/src/main/java/de/eshg/spatz/relay/RelayConnector.java index 7989f8116e210564f05ee51504aaa1c92148e526..f02e1a6a7ffc871f865907b620b87127b746bfe5 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/relay/RelayConnector.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/relay/RelayConnector.java @@ -220,7 +220,7 @@ public class RelayConnector extends WebSocketClient { return; } String id = UUID.randomUUID().toString(); - logger.info("Sending ping {}", id); + logger.debug("Sending ping {}", id); try { PingFrame ping = new PingFrame(); ping.setPayload(ByteBuffer.wrap(id.getBytes(StandardCharsets.UTF_8))); @@ -322,7 +322,7 @@ public class RelayConnector extends WebSocketClient { @Override public void onMessage(String message) { - logger.info("Received String message: {}", message); + logger.debug("Received String message: {}", message); onMessage(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8))); } @@ -425,7 +425,7 @@ public class RelayConnector extends WebSocketClient { @Override public void onWebsocketPong(WebSocket conn, Framedata f) { - logger.info("Received pong {}", f); + logger.debug("Received pong {}", f); String pongPayload = StandardCharsets.UTF_8.decode(f.getPayloadData()).toString(); if (!pongPayload.equals(outstandingPingPayload.get())) { logger.warn("Payload mismatch: expected {}. Ignoring pong", outstandingPingPayload.get()); diff --git a/backend/spatz/src/main/java/de/eshg/spatz/security/CertificateValidationService.java b/backend/spatz/src/main/java/de/eshg/spatz/security/CertificateValidationService.java index 51030e9d28ab60fe5fe4a4886821a5a70f783979..55d4f16900463c27a0f3384f78b0102a369a140a 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/security/CertificateValidationService.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/security/CertificateValidationService.java @@ -98,6 +98,8 @@ public class CertificateValidationService { for (ActorResponseDto actor : allActors) { if (ActorTypeDto.LSD == actor.type() || isValidActor(actor)) { result.add(actor); + } else { + log.info("Ignoring invalid actor {}", actor.commonName()); } } diff --git a/backend/spatz/src/main/java/de/eshg/spatz/server/ProxyServer.java b/backend/spatz/src/main/java/de/eshg/spatz/server/ProxyServer.java index 8c84ba073928adee0c4dce218db8d3270bba9539..096f6b30d205057a3c07e6b9f0e63a09cf477207 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/server/ProxyServer.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/server/ProxyServer.java @@ -10,6 +10,7 @@ import static de.eshg.servicedirectory.util.X509Utils.ESHGACTOR_BUNDLE_NAME; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.ssl.SslContext; import jakarta.annotation.PreDestroy; +import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLException; @@ -49,6 +50,7 @@ public abstract class ProxyServer implements HealthIndicator { private DisposableChannel server; private final boolean clientAuth; + private final List<String> clientCnAllowList; private final SslBundles sslBundles; private final SSLFactory dynamicSSLFactory; @@ -62,11 +64,13 @@ public abstract class ProxyServer implements HealthIndicator { String listeningHost, Integer listeningPort, boolean clientAuth, + List<String> clientCnAllowList, SslBundles sslBundles) { this.baseServer = baseServer; this.listeningHost = Objects.requireNonNull(listeningHost); this.listeningPort = Objects.requireNonNull(listeningPort); this.clientAuth = clientAuth; + this.clientCnAllowList = clientCnAllowList; this.sslBundles = sslBundles; dynamicSSLFactory = SSLFactory.builder() @@ -93,6 +97,10 @@ public abstract class ProxyServer implements HealthIndicator { return clientAuth; } + public List<String> getClientCnAllowList() { + return clientCnAllowList; + } + void onSslBundleUpdate(SslBundle sslBundle) { logger.info("ssl configuration changed, applying for mTLS connections"); diff --git a/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/InboundServer.java b/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/InboundServer.java index 93f94df479d512f8e1b1abe94d250cfda0995526..cdeb082810f96a3a287373434b939e06e2027b71 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/InboundServer.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/InboundServer.java @@ -23,7 +23,9 @@ import io.netty.handler.ssl.SslHandler; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.reactivestreams.Publisher; @@ -60,6 +62,7 @@ public class InboundServer extends ProxyServer implements TopologyChangedListene inboundConfiguration.listeningHost(), inboundConfiguration.handlerPort(), inboundConfiguration.forceClientAuth(), + Optional.ofNullable(inboundConfiguration.clientCnAllowList()).orElse(List.of()), sslBundles); this.inboundTargetPort = inboundConfiguration.targetPort(); this.inboundTargetHost = @@ -110,6 +113,11 @@ public class InboundServer extends ProxyServer implements TopologyChangedListene EshgHttpHeaders.X_ESHG_SENDER_ORGUNIT.headerName, senderActor.orgUnitId().toString() }); + } else if (!getClientCnAllowList().contains(peerCommonName)) { + throw new SslCertificateException( + "Rejecting certificate with CN " + + peerCommonName + + " not in valid active inbound actors or client CN allow list"); } ActorResponseDto currentSelf = self; @@ -133,6 +141,7 @@ public class InboundServer extends ProxyServer implements TopologyChangedListene headersHolder.set(headers.toArray(String[][]::new)); } catch (Exception e) { + if (e instanceof SslCertificateException) throw (SslCertificateException) e; throw new SslCertificateException("could not parse client certificate: " + e, e); } }); diff --git a/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/SslCertificateException.java b/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/SslCertificateException.java index 492ad7de2373057045bd07e6843699f23ca7f4b0..77827749d3fa179d91a702165d4b728912b02e12 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/SslCertificateException.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/server/inbound/SslCertificateException.java @@ -13,4 +13,8 @@ public class SslCertificateException extends RuntimeException { public SslCertificateException(String message, Throwable cause) { super(message, cause); } + + public SslCertificateException(String message) { + super(message); + } } diff --git a/backend/spatz/src/main/java/de/eshg/spatz/server/outbound/OutboundServer.java b/backend/spatz/src/main/java/de/eshg/spatz/server/outbound/OutboundServer.java index 2937d8ec87aff43a6ef543e007d4cced3747464d..4cfa1f57c902e1b757eae3e279504529188b118f 100644 --- a/backend/spatz/src/main/java/de/eshg/spatz/server/outbound/OutboundServer.java +++ b/backend/spatz/src/main/java/de/eshg/spatz/server/outbound/OutboundServer.java @@ -28,6 +28,7 @@ import java.net.SocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.function.Consumer; import javax.net.ssl.SSLHandshakeException; @@ -67,6 +68,7 @@ public final class OutboundServer extends ProxyServer { outboundConfiguration.listeningHost(), outboundConfiguration.handlerPort(), true, + List.of(), sslBundles); this.outboundTargetPort = outboundConfiguration.targetPort(); this.addressMapper = addressMapper; diff --git a/backend/statistics/openApi.yaml b/backend/statistics/openApi.yaml index cb4b6c1b2b0081ff65f6b0f091869c638dd590ea..b02368506f866299539d14d78b223dee0be8542e 100644 --- a/backend/statistics/openApi.yaml +++ b/backend/statistics/openApi.yaml @@ -649,7 +649,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/UpdateReportSeriesRequest" + oneOf: + - $ref: "#/components/schemas/ActivateAutoReportSeriesRequest" + - $ref: "#/components/schemas/DeactivateAutoReportSeriesRequest" + - $ref: "#/components/schemas/UpdateNameAndDescriptionReportSeriesRequest" required: true responses: "200": @@ -658,10 +661,25 @@ paths: schema: $ref: "#/components/schemas/ReportSeries" description: The patched report series - summary: Change title and description of a report series + summary: Change title and description of a report series or change activation tags: - ReportSeries /statistic/report/{reportId}: + delete: + operationId: deleteReport + parameters: + - in: path + name: reportId + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Returned when the report is deleted + summary: Delete a report + tags: + - Report get: operationId: getReportDetailPage parameters: @@ -978,6 +996,34 @@ components: type: string required: - '@type' + AbstractUpdateReportSeriesRequest: + type: object + discriminator: + propertyName: '@type' + properties: + '@type': + type: string + required: + - '@type' + ActivateAutoReportSeriesRequest: + type: object + allOf: + - $ref: "#/components/schemas/AbstractUpdateReportSeriesRequest" + - type: object + properties: + frequency: + $ref: "#/components/schemas/Frequency" + reportingPeriod: + $ref: "#/components/schemas/ReportingPeriod" + startMonth: + type: integer + format: int32 + maximum: 12 + minimum: 1 + required: + - frequency + - reportingPeriod + - startMonth AddAutoReportSeriesRequest: type: object allOf: @@ -1603,6 +1649,10 @@ components: required: - code - name + DeactivateAutoReportSeriesRequest: + type: object + allOf: + - $ref: "#/components/schemas/AbstractUpdateReportSeriesRequest" DecimalAttribute: type: object allOf: @@ -1966,7 +2016,7 @@ components: type: integer format: int64 minimum: 0 - userDto: + user: $ref: "#/components/schemas/User" required: - evaluations @@ -2034,7 +2084,9 @@ components: type: integer format: int64 minimum: 0 - userDto: + userReport: + $ref: "#/components/schemas/User" + userReportSeries: $ref: "#/components/schemas/User" required: - createdAt @@ -2893,13 +2945,16 @@ components: type: string required: - name - UpdateReportSeriesRequest: + UpdateNameAndDescriptionReportSeriesRequest: type: object - properties: - description: - type: string - name: - type: string + allOf: + - $ref: "#/components/schemas/AbstractUpdateReportSeriesRequest" + - type: object + properties: + description: + type: string + name: + type: string required: - name User: diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportController.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportController.java index 045a373f522cc41cba821bc838b777185a044449..066a83590fe2feea9537cdd34d4c7ed48ffc1fff 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportController.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportController.java @@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.util.UUID; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.service.annotation.DeleteExchange; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.HttpExchange; @@ -42,4 +43,12 @@ public class ReportController { return reportService.getReportDetailPage(reportId); } + + @DeleteExchange(value = "/{reportId}", accept = APPLICATION_JSON_VALUE) + @ApiResponse(responseCode = "200", description = "Returned when the report is deleted") + @Operation(summary = "Delete a report") + public void deleteReport(@PathVariable(name = "reportId") UUID reportId) { + statisticsFeatureToggle.assertNewFeatureIsEnabled(StatisticsFeature.REPORTS); + reportService.deleteReport(reportId); + } } diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesController.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesController.java index bdfa6b8920f95e279a9387a946600940226ec5d4..bb485f562679b062a72575599f76b94a3855c546 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesController.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesController.java @@ -9,11 +9,11 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import de.eshg.rest.service.security.config.BaseUrls; import de.eshg.statistics.api.report.AbstractAddReportSeriesRequest; +import de.eshg.statistics.api.report.AbstractUpdateReportSeriesRequest; import de.eshg.statistics.api.report.AddManualReportSeriesRequest; import de.eshg.statistics.api.report.GetReportsRequest; import de.eshg.statistics.api.report.GetReportsResponse; import de.eshg.statistics.api.report.ReportSeriesDto; -import de.eshg.statistics.api.report.UpdateReportSeriesRequest; import de.eshg.statistics.config.StatisticsFeature; import de.eshg.statistics.config.StatisticsFeatureToggle; import io.swagger.v3.oas.annotations.Operation; @@ -64,10 +64,10 @@ public class ReportSeriesController { @PatchExchange(value = "/{reportSeriesId}", accept = APPLICATION_JSON_VALUE) @ApiResponse(responseCode = "200", description = "The patched report series") - @Operation(summary = "Change title and description of a report series") + @Operation(summary = "Change title and description of a report series or change activation") public ReportSeriesDto updateReportSeries( @PathVariable(name = "reportSeriesId") UUID reportSeriesId, - @RequestBody @Valid UpdateReportSeriesRequest updateReportSeriesRequest) { + @RequestBody @Valid AbstractUpdateReportSeriesRequest updateReportSeriesRequest) { statisticsFeatureToggle.assertNewFeatureIsEnabled(StatisticsFeature.REPORTS); return reportSeriesService.updateReportSeries(reportSeriesId, updateReportSeriesRequest); } diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesService.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesService.java index f0f6d7c4c9594a8ecc70dc7b255a2121afc41db2..5370940f471f87e75101b003247722c7ccb0302d 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesService.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportSeriesService.java @@ -12,12 +12,15 @@ import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.NotFoundException; import de.eshg.rest.service.security.CurrentUserHelper; import de.eshg.statistics.api.report.AbstractAddReportSeriesRequest; +import de.eshg.statistics.api.report.AbstractUpdateReportSeriesRequest; +import de.eshg.statistics.api.report.ActivateAutoReportSeriesRequest; import de.eshg.statistics.api.report.AddAutoReportSeriesRequest; import de.eshg.statistics.api.report.AddManualReportSeriesRequest; +import de.eshg.statistics.api.report.DeactivateAutoReportSeriesRequest; import de.eshg.statistics.api.report.GetReportsRequest; import de.eshg.statistics.api.report.GetReportsResponse; import de.eshg.statistics.api.report.ReportSeriesDto; -import de.eshg.statistics.api.report.UpdateReportSeriesRequest; +import de.eshg.statistics.api.report.UpdateNameAndDescriptionReportSeriesRequest; import de.eshg.statistics.mapper.ReportMapper; import de.eshg.statistics.mapper.StatisticMapper; import de.eshg.statistics.persistence.entity.AggregationResultState; @@ -43,14 +46,17 @@ import org.springframework.transaction.annotation.Transactional; public class ReportSeriesService { private final ReportSeriesRepository reportSeriesRepository; private final StatisticService statisticService; + private final ReportService reportService; private final Clock clock; public ReportSeriesService( ReportSeriesRepository reportSeriesRepository, StatisticService statisticService, + ReportService reportService, Clock clock) { this.reportSeriesRepository = reportSeriesRepository; this.statisticService = statisticService; + this.reportService = reportService; this.clock = clock; } @@ -111,20 +117,26 @@ public class ReportSeriesService { reportSeries.setPeriod( ReportMapper.mapToReportingPeriod(addAutoReportSeriesRequest.reportingPeriod())); - LocalDate executionAndEndDate = calculateExecutionDate(addAutoReportSeriesRequest.startMonth()); + addNewPlannedReportToSeries( + reportSeries, "1", addAutoReportSeriesRequest.startMonth(), statistic); + + return reportSeries; + } + + private void addNewPlannedReportToSeries( + ReportSeries reportSeries, String name, int startMonth, Statistic statistic) { + LocalDate executionAndEndDate = calculateExecutionDate(startMonth); LocalDate dateStart = ReportService.calculateStartDate(reportSeries.getPeriod(), executionAndEndDate); reportSeries.addReport( ReportService.createReport( - "1", + name, dateStart.atStartOfDay(clock.getZone()).toInstant(), executionAndEndDate.atStartOfDay(clock.getZone()).toInstant(), AggregationResultState.PLANNED, executionAndEndDate, statistic)); - - return reportSeries; } private LocalDate calculateExecutionDate(int startMonth) { @@ -150,19 +162,91 @@ public class ReportSeriesService { @Transactional public ReportSeriesDto updateReportSeries( - UUID reportSeriesId, UpdateReportSeriesRequest updateReportSeriesRequest) { + UUID reportSeriesId, AbstractUpdateReportSeriesRequest updateReportSeriesRequest) { ReportSeries reportSeries = getReportSeriesInternal(reportSeriesId); - validateNotPendingManualReport(reportSeries); - reportSeries.setName(updateReportSeriesRequest.name()); - reportSeries.setDescription(updateReportSeriesRequest.description()); - if (reportSeries.getReportType().equals(ReportType.MANUAL)) { - reportSeries.getReports().getFirst().setName(updateReportSeriesRequest.name()); + + switch (updateReportSeriesRequest) { + case ActivateAutoReportSeriesRequest activateAutoReportSeriesRequest -> + activateReportSeries(activateAutoReportSeriesRequest, reportSeries); + case DeactivateAutoReportSeriesRequest ignored -> deactivateReportSeries(reportSeries); + case UpdateNameAndDescriptionReportSeriesRequest + updateNameAndDescriptionReportSeriesRequest -> + updateNameAndDescription(updateNameAndDescriptionReportSeriesRequest, reportSeries); } return ReportMapper.mapToApi(reportSeries); } - private void validateNotPendingManualReport(ReportSeries reportSeries) { + private void activateReportSeries( + ActivateAutoReportSeriesRequest activateAutoReportSeriesRequest, ReportSeries reportSeries) { + validateIsAutoReportSeries(reportSeries); + reportSeries.setActive(true); + reportSeries.setStartMonth(activateAutoReportSeriesRequest.startMonth()); + reportSeries.setFrequency( + ReportMapper.mapToFrequency(activateAutoReportSeriesRequest.frequency())); + reportSeries.setPeriod( + ReportMapper.mapToReportingPeriod(activateAutoReportSeriesRequest.reportingPeriod())); + + Report plannedReport = getPlannedReport(reportSeries); + if (plannedReport == null) { + int nextNumber = reportService.findNextNumberInReports(reportSeries.getReports()); + addNewPlannedReportToSeries( + reportSeries, + String.valueOf(nextNumber), + reportSeries.getStartMonth(), + reportSeries.getStatistic()); + } else { + LocalDate executionAndEndDate = calculateExecutionDate(reportSeries.getStartMonth()); + LocalDate dateStart = + ReportService.calculateStartDate(reportSeries.getPeriod(), executionAndEndDate); + + plannedReport.setTimeRangeStart(dateStart.atStartOfDay(clock.getZone()).toInstant()); + plannedReport.setTimeRangeEnd(executionAndEndDate.atStartOfDay(clock.getZone()).toInstant()); + plannedReport.setExecutionDate(executionAndEndDate); + } + } + + private void deactivateReportSeries(ReportSeries reportSeries) { + validateIsAutoReportSeries(reportSeries); + if (reportSeries.isActive()) { + reportSeries.setActive(false); + Report plannedReport = getPlannedReport(reportSeries); + if (plannedReport != null) { + reportSeries.removeReport(plannedReport); + } + } + } + + private static void validateIsAutoReportSeries(ReportSeries reportSeries) { + if (!reportSeries.getReportType().equals(ReportType.AUTO)) { + throw new BadRequestException( + "Report series %s is not of type 'AUTO'".formatted(reportSeries.getExternalId())); + } + } + + private static Report getPlannedReport(ReportSeries reportSeries) { + return reportSeries.getReports().stream() + .filter(report -> report.getState().equals(AggregationResultState.PLANNED)) + .findFirst() + .orElse(null); + } + + private static void updateNameAndDescription( + UpdateNameAndDescriptionReportSeriesRequest updateNameAndDescriptionReportSeriesRequest, + ReportSeries reportSeries) { + validateNotPendingManualReport(reportSeries); + + reportSeries.setName(updateNameAndDescriptionReportSeriesRequest.name()); + reportSeries.setDescription(updateNameAndDescriptionReportSeriesRequest.description()); + if (reportSeries.getReportType().equals(ReportType.MANUAL)) { + reportSeries + .getReports() + .getFirst() + .setName(updateNameAndDescriptionReportSeriesRequest.name()); + } + } + + private static void validateNotPendingManualReport(ReportSeries reportSeries) { if (reportSeries.getReportType().equals(ReportType.MANUAL) && reportSeries.getReports().getFirst().getState().equals(AggregationResultState.PENDING)) { throw new BadRequestException( @@ -177,7 +261,7 @@ public class ReportSeriesService { reportSeriesRepository.delete(reportSeries); } - private void validateBelongsToCurrentUserOrIsAdmin(ReportSeries reportSeries) { + static void validateBelongsToCurrentUserOrIsAdmin(ReportSeries reportSeries) { UUID userId = CurrentUserHelper.getCurrentUserId(); if (!userId.equals(reportSeries.getCreatedByUserId()) && CurrentUserHelper.currentUserHasNoRole( diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportService.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportService.java index 77149475021847c68a4d68bb8804417c92e818e6..a18a11549dd71a504e72d4677dfdcf015aa97a6b 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportService.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportService.java @@ -37,6 +37,7 @@ import de.eshg.statistics.persistence.entity.report.ReportSeries; import de.eshg.statistics.persistence.entity.report.ReportType; import de.eshg.statistics.persistence.entity.report.ReportingPeriod; import de.eshg.statistics.persistence.repository.ReportRepository; +import de.eshg.statistics.persistence.repository.ReportSeriesRepository; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -57,6 +58,7 @@ import org.springframework.transaction.annotation.Transactional; @Service public class ReportService { private final ReportRepository reportRepository; + private final ReportSeriesRepository reportSeriesRepository; private final StatisticService statisticService; private final Clock clock; private final DataAggregationService dataAggregationService; @@ -66,11 +68,13 @@ public class ReportService { public ReportService( ReportRepository reportRepository, + ReportSeriesRepository reportSeriesRepository, StatisticService statisticService, Clock clock, DataAggregationService dataAggregationService, EvaluationService evaluationService) { this.reportRepository = reportRepository; + this.reportSeriesRepository = reportSeriesRepository; this.statisticService = statisticService; this.clock = clock; this.dataAggregationService = dataAggregationService; @@ -107,8 +111,11 @@ public class ReportService { public GetReportDetailPageResponse getReportDetailPage(UUID reportId) { Report report = getReportInternal(reportId); validateReportCompleted(report); - Map<UUID, UserDto> resolvedUsers = - statisticService.getResolvedUsers(Set.of(report.getCreatedByUserId())); + UUID reportSeriesUserId = report.getReportSeries().getCreatedByUserId(); + Set<UUID> userIds = new HashSet<>(); + userIds.add(reportSeriesUserId); + userIds.add(report.getCreatedByUserId()); + Map<UUID, UserDto> resolvedUsers = statisticService.getResolvedUsers(userIds); List<EvaluationDto> evaluations = EvaluationMapper.getEvaluations(report.getEvaluations()); return new GetReportDetailPageResponse( @@ -122,6 +129,7 @@ public class ReportService { report.getCreatedAt(), StatisticMapper.mapToApi(report.getTableColumns()), report.getNumberOfTableRows(), + resolvedUsers.get(reportSeriesUserId), resolvedUsers.get(report.getCreatedByUserId()), evaluations); } @@ -161,6 +169,9 @@ public class ReportService { public void createNewPlannedReportInSeries(UUID reportId) { Report report = getReportInternal(reportId); ReportSeries reportSeries = report.getReportSeries(); + if (!reportSeries.isActive()) { + return; + } int nextNumber = getNextNumber(report); LocalDate executionAndEndDate = @@ -199,14 +210,31 @@ public class ReportService { } private int getNextNumber(Report report) { + return getNumberOfReport(report).orElse(report.getReportSeries().getReports().size()) + 1; + } + + int findNextNumberInReports(List<Report> reports) { + int currentHighest = 0; + for (Report report : reports) { + Optional<Integer> numberOfReport = getNumberOfReport(report); + if (numberOfReport.isEmpty()) { + return reports.size() + 1; + } else if (numberOfReport.get() > currentHighest) { + currentHighest = numberOfReport.get(); + } + } + return currentHighest + 1; + } + + private Optional<Integer> getNumberOfReport(Report report) { try { - return Integer.parseInt(report.getName()) + 1; + return Optional.of(Integer.parseInt(report.getName())); } catch (NumberFormatException e) { log.error( "Report {} has name '{}' which is not a number", report.getExternalId(), report.getName()); - return report.getReportSeries().getReports().size() + 1; + return Optional.empty(); } } @@ -488,4 +516,20 @@ public class ReportService { report.setState(AggregationResultState.FAILED); } } + + @Transactional + public void deleteReport(UUID reportId) { + Report report = getReportInternal(reportId); + ReportSeries reportSeries = report.getReportSeries(); + ReportSeriesService.validateBelongsToCurrentUserOrIsAdmin(reportSeries); + if (report.getState().equals(AggregationResultState.PLANNED)) { + throw new BadRequestException( + "Report is in state 'PLANNED', deactivate report series to remove this report"); + } + if (reportSeries.getReportType().equals(ReportType.MANUAL)) { + reportSeriesRepository.delete(reportSeries); + } else { + reportRepository.delete(report); + } + } } diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/GetDetailPageInformationResponse.java b/backend/statistics/src/main/java/de/eshg/statistics/api/GetDetailPageInformationResponse.java index e4f3f62407bb6fc997f6eab2fddd34c57fed10e8..ef7046cd01f824ccbca95d82a102ebe213ccc7d7 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/api/GetDetailPageInformationResponse.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/api/GetDetailPageInformationResponse.java @@ -15,5 +15,5 @@ public record GetDetailPageInformationResponse( @NotNull @Valid StatisticInfo statisticInfo, @NotNull @Valid List<TableColumnHeader> tableColumnHeaders, @NotNull @Min(0) long totalNumberOfElements, - @Valid UserDto userDto, + @Valid UserDto user, @NotNull @Valid List<EvaluationDto> evaluations) {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/report/AbstractUpdateReportSeriesRequest.java b/backend/statistics/src/main/java/de/eshg/statistics/api/report/AbstractUpdateReportSeriesRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..e912018ffcc1e1e55f84824008e2b2456caf0551 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/api/report/AbstractUpdateReportSeriesRequest.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.api.report; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "AbstractUpdateReportSeriesRequest") +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "@type", + include = JsonTypeInfo.As.EXISTING_PROPERTY) +@JsonSubTypes({ + @JsonSubTypes.Type( + value = ActivateAutoReportSeriesRequest.class, + name = ActivateAutoReportSeriesRequest.SCHEMA_NAME), + @JsonSubTypes.Type( + value = DeactivateAutoReportSeriesRequest.class, + name = DeactivateAutoReportSeriesRequest.SCHEMA_NAME), + @JsonSubTypes.Type( + value = UpdateNameAndDescriptionReportSeriesRequest.class, + name = UpdateNameAndDescriptionReportSeriesRequest.SCHEMA_NAME), +}) +public sealed interface AbstractUpdateReportSeriesRequest + permits ActivateAutoReportSeriesRequest, + DeactivateAutoReportSeriesRequest, + UpdateNameAndDescriptionReportSeriesRequest { + @Hidden + @NotNull + @JsonProperty("@type") + String type(); +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/report/ActivateAutoReportSeriesRequest.java b/backend/statistics/src/main/java/de/eshg/statistics/api/report/ActivateAutoReportSeriesRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..8c48b81979df03fff9dffa338c9cfc3ebbb1f0a0 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/api/report/ActivateAutoReportSeriesRequest.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.api.report; + +import static de.eshg.statistics.api.report.ActivateAutoReportSeriesRequest.SCHEMA_NAME; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@Schema(name = SCHEMA_NAME) +public record ActivateAutoReportSeriesRequest( + @Min(1) @Max(12) @NotNull Integer startMonth, + @NotNull FrequencyDto frequency, + @NotNull ReportingPeriodDto reportingPeriod) + implements AbstractUpdateReportSeriesRequest { + public static final String SCHEMA_NAME = "ActivateAutoReportSeriesRequest"; + + @Override + public String type() { + return SCHEMA_NAME; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/report/DeactivateAutoReportSeriesRequest.java b/backend/statistics/src/main/java/de/eshg/statistics/api/report/DeactivateAutoReportSeriesRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..57e0e7ec9d39b8d08e7e03ecff7ed4f2f138d41a --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/api/report/DeactivateAutoReportSeriesRequest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.api.report; + +import static de.eshg.statistics.api.report.DeactivateAutoReportSeriesRequest.SCHEMA_NAME; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = SCHEMA_NAME) +public record DeactivateAutoReportSeriesRequest() implements AbstractUpdateReportSeriesRequest { + public static final String SCHEMA_NAME = "DeactivateAutoReportSeriesRequest"; + + @Override + public String type() { + return SCHEMA_NAME; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/report/GetReportDetailPageResponse.java b/backend/statistics/src/main/java/de/eshg/statistics/api/report/GetReportDetailPageResponse.java index 4b873c19e520924d7cfb19d5d433c42d3ad08915..b7907c12ec1c3f2552f8d7dbd6defacf47d5c70b 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/api/report/GetReportDetailPageResponse.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/api/report/GetReportDetailPageResponse.java @@ -27,5 +27,6 @@ public record GetReportDetailPageResponse( @NotNull Instant createdAt, @NotNull @Valid List<TableColumnHeader> tableColumnHeaders, @NotNull @Min(0) long totalNumberOfElements, - @Valid UserDto userDto, + @Valid UserDto userReportSeries, + @Valid UserDto userReport, @NotNull @Valid List<EvaluationDto> evaluation) {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/report/UpdateNameAndDescriptionReportSeriesRequest.java b/backend/statistics/src/main/java/de/eshg/statistics/api/report/UpdateNameAndDescriptionReportSeriesRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..673396837d75c1a3207d886d1ad7fbafd06ba65f --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/api/report/UpdateNameAndDescriptionReportSeriesRequest.java @@ -0,0 +1,22 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.api.report; + +import static de.eshg.statistics.api.report.UpdateNameAndDescriptionReportSeriesRequest.SCHEMA_NAME; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(name = SCHEMA_NAME) +public record UpdateNameAndDescriptionReportSeriesRequest(@NotBlank String name, String description) + implements AbstractUpdateReportSeriesRequest { + public static final String SCHEMA_NAME = "UpdateNameAndDescriptionReportSeriesRequest"; + + @Override + public String type() { + return SCHEMA_NAME; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/api/report/UpdateReportSeriesRequest.java b/backend/statistics/src/main/java/de/eshg/statistics/api/report/UpdateReportSeriesRequest.java deleted file mode 100644 index f40b7ee98e266e2ab55d3f3abc6bacd345f77741..0000000000000000000000000000000000000000 --- a/backend/statistics/src/main/java/de/eshg/statistics/api/report/UpdateReportSeriesRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2024 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.statistics.api.report; - -import jakarta.validation.constraints.NotBlank; - -public record UpdateReportSeriesRequest(@NotBlank String name, String description) {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/CellEntry.java b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/CellEntry.java index 277c271a7f37700d8346531396b3bfce1d71a167..9a7f74c1d97b860f38b9cc7a82decabc52d9eec6 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/CellEntry.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/CellEntry.java @@ -7,7 +7,7 @@ package de.eshg.statistics.persistence.entity; import static de.eshg.lib.common.SensitivityLevel.PUBLIC; -import de.eshg.domain.model.BaseEntity; +import de.eshg.domain.model.SequencedBaseEntity; import de.eshg.lib.common.DataSensitivity; import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; @@ -26,7 +26,7 @@ import jakarta.persistence.UniqueConstraint; @Table( uniqueConstraints = @UniqueConstraint(columnNames = {"table_column_id", "table_row_id"}), indexes = @Index(columnList = "table_row_id")) -public abstract class CellEntry extends BaseEntity { +public abstract class CellEntry extends SequencedBaseEntity { @DataSensitivity(PUBLIC) @ManyToOne(fetch = FetchType.LAZY, optional = false) diff --git a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableRow.java b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableRow.java index d7323d9c16ae2ee2e1d8f8d85b9322a790c5b473..14154404062294cfc6bddc0fe86ff7d4f3876938 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableRow.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableRow.java @@ -7,7 +7,7 @@ package de.eshg.statistics.persistence.entity; import static de.eshg.lib.common.SensitivityLevel.PUBLIC; -import de.eshg.domain.model.BaseEntity; +import de.eshg.domain.model.SequencedBaseEntity; import de.eshg.lib.common.DataSensitivity; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; @@ -24,7 +24,7 @@ import java.util.List; @Entity @DataSensitivity(PUBLIC) @Table(indexes = @Index(columnList = "aggregation_result_id")) -public class TableRow extends BaseEntity { +public class TableRow extends SequencedBaseEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "aggregation_result_id") private AbstractAggregationResult aggregationResult; diff --git a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/report/ReportSeries.java b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/report/ReportSeries.java index 482e389fd5dad570c67423c5e566e8505d66d91e..4aaf9c63f830fef77745648763928bb8643f6fa0 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/report/ReportSeries.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/report/ReportSeries.java @@ -193,6 +193,11 @@ public class ReportSeries extends BaseEntityWithExternalId { reports.add(report); } + public void removeReport(Report report) { + report.setReportSeries(null); + this.reports.remove(report); + } + public List<Report> getReports() { return reports; } diff --git a/backend/statistics/src/main/resources/application.properties b/backend/statistics/src/main/resources/application.properties index 086c9691cbd7e0c2026a3507208841b57f6cd31c..c0e60cd36730c4746c9b873084671d739fc6d4db 100644 --- a/backend/statistics/src/main/resources/application.properties +++ b/backend/statistics/src/main/resources/application.properties @@ -3,6 +3,8 @@ spring.datasource.url=jdbc:postgresql://localhost:5440/statistics spring.datasource.username=testuser spring.datasource.password=testpassword spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.jdbc.batch_size=250 +spring.jpa.properties.hibernate.order_inserts=true # Enable explicitly since we disable it by default via common-persistence.properties spring.liquibase.enabled=true diff --git a/backend/statistics/src/main/resources/migrations/0023_tablerow_cellentry_sequences.xml b/backend/statistics/src/main/resources/migrations/0023_tablerow_cellentry_sequences.xml new file mode 100644 index 0000000000000000000000000000000000000000..c023d17346a618cea08774483a6a901f659bbbb4 --- /dev/null +++ b/backend/statistics/src/main/resources/migrations/0023_tablerow_cellentry_sequences.xml @@ -0,0 +1,15 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2024 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="migrate_tablerow_cellentry_to_sequence_based_ids"> + <ext:migrateAutoIncrementToSequence tableName="table_row"/> + <ext:migrateAutoIncrementToSequence tableName="cell_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/statistics/src/main/resources/migrations/changelog.xml b/backend/statistics/src/main/resources/migrations/changelog.xml index f81dd3b83e2b5ba5c2d7769c906303edc3980e8d..46616a1dc0704259451c3bb126e64200069bced2 100644 --- a/backend/statistics/src/main/resources/migrations/changelog.xml +++ b/backend/statistics/src/main/resources/migrations/changelog.xml @@ -30,5 +30,6 @@ <include file="migrations/0020_introduce_reports.xml"/> <include file="migrations/0021_cell_entry_indexes.xml"/> <include file="migrations/0022_auto_report_series.xml"/> + <include file="migrations/0023_tablerow_cellentry_sequences.xml"/> </databaseChangeLog> diff --git a/backend/test-commons/build.gradle b/backend/test-commons/build.gradle index 3ea67cf1b4dd097afc2a9802c3ff9e245025bccb..82eb76c1a21807a5c35d7095d87b4254d2b1d2c9 100644 --- a/backend/test-commons/build.gradle +++ b/backend/test-commons/build.gradle @@ -9,6 +9,7 @@ dependencies { api project(':util-commons') api project(':lib-keycloak') api project(':test-helper-commons-api') + implementation project(':test-helper-commons-spring') implementation 'de.cronn:postgres-snapshot-util:latest.release' implementation 'org.springframework:spring-webmvc' diff --git a/backend/test-helper-commons-spring/src/main/java/de/eshg/testhelper/ConditionalOnLocalEnvironment.java b/backend/test-helper-commons-spring/src/main/java/de/eshg/testhelper/ConditionalOnLocalEnvironment.java new file mode 100644 index 0000000000000000000000000000000000000000..2a4a407215ad5f32c0fc2e65f4c80f79df3565f1 --- /dev/null +++ b/backend/test-helper-commons-spring/src/main/java/de/eshg/testhelper/ConditionalOnLocalEnvironment.java @@ -0,0 +1,19 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.testhelper; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Profile; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Profile(ConditionalOnLocalEnvironment.LOCAL_PROFILE_NAME) +public @interface ConditionalOnLocalEnvironment { + String LOCAL_PROFILE_NAME = "local"; +} diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/LocalProfileAutoConfiguration.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/LocalProfileAutoConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..e97809579bd3702b18cdf14b3c07f3f409a60c67 --- /dev/null +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/LocalProfileAutoConfiguration.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.testhelper; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.PropertySource; + +@AutoConfiguration +@ConditionalOnLocalEnvironment +@PropertySource("classpath:/common-local.properties") +public class LocalProfileAutoConfiguration { + + private static final Logger log = LoggerFactory.getLogger(LocalProfileAutoConfiguration.class); + + LocalProfileAutoConfiguration() { + log.info("{} is active", this); + } +} diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperAutoConfiguration.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperAutoConfiguration.java index a91548b87cd0012097f47817487c9976b93a47dd..0a86f0866dc159e7405ee6d9b47d5def1ff63afc 100644 --- a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperAutoConfiguration.java +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperAutoConfiguration.java @@ -10,7 +10,6 @@ import de.eshg.testhelper.population.PopulateWithAccessTokenHelper; import java.time.Clock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration; @@ -38,19 +37,15 @@ public class TestHelperAutoConfiguration { } @Bean - @ConditionalOnProperty( - value = "eshg.testclock.enabled", - havingValue = "true", - matchIfMissing = true) - public TestHelperClock testHelperClock() { + @ConditionalOnProperty(value = "eshg.testclock.enabled", havingValue = "true") + TestHelperClock testHelperClock() { TestHelperClock testHelperClock = TestHelperClock.defaultBerlin(); log.warn("Using {}", testHelperClock.getClass().getSimpleName()); return testHelperClock; } @Bean - public ValidationConfigurationCustomizer clockProviderConfigurationCustomizer( - @Autowired Clock clock) { + ValidationConfigurationCustomizer clockProviderConfigurationCustomizer(Clock clock) { return configuration -> configuration.clockProvider(() -> clock); } } diff --git a/backend/test-helper-commons/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/backend/test-helper-commons/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index af7c1e09cf87fed1914119120735e2f203d9a291..f612d88f395c0c56662fe642907fc46fd50a85f0 100644 --- a/backend/test-helper-commons/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/backend/test-helper-commons/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ de.eshg.testhelper.TestHelperAutoConfiguration +de.eshg.testhelper.LocalProfileAutoConfiguration diff --git a/backend/test-helper-commons/src/main/resources/common-local.properties b/backend/test-helper-commons/src/main/resources/common-local.properties new file mode 100644 index 0000000000000000000000000000000000000000..369d1e119a76d520c5ca4f585dc0d78f7f325c0f --- /dev/null +++ b/backend/test-helper-commons/src/main/resources/common-local.properties @@ -0,0 +1 @@ +eshg.testclock.enabled=true diff --git a/backend/travel-medicine/build.gradle b/backend/travel-medicine/build.gradle index 6c66d08e18fa63da109aec76355d3aacc350475a..fae4daa5d6031843a25614cf13571a3646becf66 100644 --- a/backend/travel-medicine/build.gradle +++ b/backend/travel-medicine/build.gradle @@ -16,6 +16,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' testImplementation testFixtures(project(':business-module-persistence-commons')) testImplementation testFixtures(project(':lib-document-generator')) + testImplementation testFixtures(project(':base-api')) } dockerCompose { @@ -33,4 +34,4 @@ tasks.named("test").configure { dependencyTrack { projectId = project.findProperty('dependency-track-project-id-travel-medicine') ?: "unspecified" -} \ No newline at end of file +} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/TravelMedicineApplication.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/TravelMedicineApplication.java index 40876be6925c73bd0745b945b584bd2b62e9e1a8..7139420a7d74d92e6198e0f8706f77b52a4e83a1 100644 --- a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/TravelMedicineApplication.java +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/TravelMedicineApplication.java @@ -9,6 +9,7 @@ import de.eshg.lib.common.BusinessModule; import de.eshg.rest.service.security.config.TravelMedicinePublicSecurityConfig; import de.eshg.travelmedicine.citizenpublic.DepartmentInfoProperties; import de.eshg.travelmedicine.featuretoggle.TravelMedicineFeatureToggle; +import de.eshg.travelmedicine.notification.NotificationProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -17,7 +18,11 @@ import org.springframework.context.annotation.Import; @SpringBootApplication @Import(TravelMedicinePublicSecurityConfig.class) -@EnableConfigurationProperties({TravelMedicineFeatureToggle.class, DepartmentInfoProperties.class}) +@EnableConfigurationProperties({ + TravelMedicineFeatureToggle.class, + DepartmentInfoProperties.class, + NotificationProperties.class +}) public class TravelMedicineApplication { @Bean diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/MailClient.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/MailClient.java new file mode 100644 index 0000000000000000000000000000000000000000..e94848e8252a31b35e27165949ba72df90582a0c --- /dev/null +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/MailClient.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.travelmedicine.notification; + +import de.eshg.base.mail.MailApi; +import de.eshg.base.mail.SendEmailRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class MailClient { + private static final Logger log = LoggerFactory.getLogger(MailClient.class); + + private final MailApi mailApi; + + public MailClient(MailApi mailApi) { + this.mailApi = mailApi; + } + + void sendMail(String to, String from, String subject, String text) { + log.info("Sending E-Mail notification"); + + SendEmailRequest sendEmailRequest = new SendEmailRequest(to, from, subject, text); + mailApi.sendEmail(sendEmailRequest); + + log.info("E-Mail notification send"); + } +} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationProperties.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..b24922b5e0122c7eb5c330123f9e201fba27e39f --- /dev/null +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationProperties.java @@ -0,0 +1,14 @@ +/* + * Copyright 2024 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.travelmedicine.notification; + +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "de.eshg.travel-medicine.notification") +public record NotificationProperties(@NotNull String fromAddress, @NotNull String greeting) {} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationService.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationService.java new file mode 100644 index 0000000000000000000000000000000000000000..b0d52a9cabf869e001b9f9d9c5a626e6d1f2e705 --- /dev/null +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationService.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.travelmedicine.notification; + +import de.eshg.lib.rest.oauth.client.commons.ModuleClientAuthenticator; +import de.eshg.travelmedicine.vaccinationconsultation.api.PatientDto; +import de.eshg.travelmedicine.vaccinationconsultation.persistence.entity.ProcedureStep; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class NotificationService { + + private static final String CITIZEN_PORTAL_RMBI_LOGIN_PATH = "impfberatung/meine-termine"; + + private final MailClient mailClient; + private final ModuleClientAuthenticator moduleClientAuthenticator; + private final NotificationProperties notificationProperties; + private final String citizenPortalUrl; + + public NotificationService( + MailClient mailClient, + ModuleClientAuthenticator moduleClientAuthenticator, + NotificationProperties notificationProperties, + @Value("${eshg.citizen-portal.reverse-proxy.url}") String citizenPortalUrl) { + this.mailClient = mailClient; + this.moduleClientAuthenticator = moduleClientAuthenticator; + this.notificationProperties = notificationProperties; + this.citizenPortalUrl = citizenPortalUrl; + ; + } + + public void onNewCitizenProcedure( + String accessCode, PatientDto patientDto, ProcedureStep procedureStep) { + String text = + NotificationText.getNewCitizenProcedureBody( + patientDto.firstName(), + patientDto.lastName(), + procedureStep.getAppointment().getAppointmentStart(), + buildLoginUrl(accessCode), + accessCode, + notificationProperties.greeting()); + + moduleClientAuthenticator.doWithModuleClientAuthentication( + () -> + mailClient.sendMail( + patientDto.emailAddresses().getFirst(), + notificationProperties.fromAddress(), + NotificationText.getNewCitizenProcedureSubject(), + text)); + } + + private String buildLoginUrl(String accessCode) { + return UriComponentsBuilder.fromUriString(citizenPortalUrl) + .pathSegment(CITIZEN_PORTAL_RMBI_LOGIN_PATH) + .queryParam("access_code", accessCode) + .build() + .toUriString(); + } +} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationText.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationText.java new file mode 100644 index 0000000000000000000000000000000000000000..291b97bb00d4a95b839672fba1ee2724735a7a0d --- /dev/null +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/notification/NotificationText.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.travelmedicine.notification; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +public class NotificationText { + private static final DateTimeFormatter APPOINTMENT_START_FORMAT = + DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm", Locale.GERMAN); + + private static final String NEW_CITIZEN_PROCEDURE_SUBJECT = "Deine Terminbuchung bei uns!"; + private static final String NEW_CITIZEN_PROCEDURE_BODY = + """ + Sehr geehrte(r) %s %s, + + wir möchten Ihnen mitteilen, dass Ihre Terminbuchung für den %s Uhr erfolgreich eingegangen ist. Bitte bewahren Sie diese Email als Bestätigung Ihrer Buchung auf. + Für den Fall, dass Sie Ihren Termin ändern oder stornieren möchten, bitten wir Sie dies über unseren Online-Service vorzunehmen. Nutzen Sie hierfür bitte den folgenden Link: + %s + + Anmeldecode: %s + Zur Verifikation wird Ihr Geburtsdatum benötigt. + + Beachten Sie bitte, dass es nicht möglich ist auf diese Email zu antworten. Für Änderungen und Stornierungen verwenden Sie bitte den angegebenen Link. + + Vielen Dank, dass Sie unseren Service nutzen. Wir freuen uns darauf Sie bald bei uns begrüßen zu dürfen. + + Mit freundlichen Grüßen, + + %s"""; + + public static String getNewCitizenProcedureSubject() { + return NEW_CITIZEN_PROCEDURE_SUBJECT; + } + + public static String getNewCitizenProcedureBody( + String firstName, + String lastName, + Instant appointmentStart, + String loginUrl, + String accessCode, + String greeting) { + return String.format( + NEW_CITIZEN_PROCEDURE_BODY, + firstName, + lastName, + APPOINTMENT_START_FORMAT.format(appointmentStart.atZone(ZoneId.of("Europe/Berlin"))), + loginUrl, + accessCode, + greeting); + } +} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationService.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationService.java index caf8f86483f0f9384dc219f1504c91d54dbbe64b..dd3e566d11f45e2140c95287d37b2154647fcad3 100644 --- a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationService.java +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationService.java @@ -21,6 +21,7 @@ import de.eshg.travelmedicine.informationstatementtemplate.persistence.entity.In import de.eshg.travelmedicine.informationstatementtemplate.persistence.entity.InformationStatementTemplateRepository; import de.eshg.travelmedicine.informationstatementtemplate.persistence.entity.InformationStatementTemplateState; import de.eshg.travelmedicine.medicalhistory.MedicalHistoryService; +import de.eshg.travelmedicine.notification.NotificationService; import de.eshg.travelmedicine.vaccinationconsultation.api.AppliedServiceDto; import de.eshg.travelmedicine.vaccinationconsultation.api.AppointmentBookingTypeDto; import de.eshg.travelmedicine.vaccinationconsultation.api.AppointmentOverviewEntryDto; @@ -106,6 +107,7 @@ public class VaccinationConsultationService { "The list of travel destinations must not contain null elements."; private final ProcedureAccessor procedureAccessor; private final InformationStatementTemplateRepository informationStatementTemplateRepository; + private final NotificationService notificationService; public VaccinationConsultationService( VaccinationConsultationRepository vaccinationConsultationRepository, @@ -124,7 +126,8 @@ public class VaccinationConsultationService { Clock clock, AuditLogger auditLogger, ProcedureAccessor procedureAccessor, - InformationStatementTemplateRepository informationStatementTemplateRepository) { + InformationStatementTemplateRepository informationStatementTemplateRepository, + NotificationService notificationService) { this.vaccinationConsultationRepository = vaccinationConsultationRepository; this.procedureStepRepository = procedureStepRepository; this.procedureStepService = procedureStepService; @@ -142,6 +145,7 @@ public class VaccinationConsultationService { this.auditLogger = auditLogger; this.procedureAccessor = procedureAccessor; this.informationStatementTemplateRepository = informationStatementTemplateRepository; + this.notificationService = notificationService; } public UUID createProcedure(PostVaccinationConsultationRequest request) { @@ -196,6 +200,9 @@ public class VaccinationConsultationService { vaccinationConsultationRepository.save(vaccinationConsultation); procedureStepRepository.save(initialProcedureStep); + notificationService.onNewCitizenProcedure( + citizenAccessCodeUser.accessCode(), request.patient(), initialProcedureStep); + return vaccinationConsultation.getExternalId(); } diff --git a/backend/travel-medicine/src/main/resources/application.properties b/backend/travel-medicine/src/main/resources/application.properties index 5e761c9868896cf9beffc8a95a1530e658f2e49b..c50541ac746fe781d8929723d83e568bd7eb525c 100644 --- a/backend/travel-medicine/src/main/resources/application.properties +++ b/backend/travel-medicine/src/main/resources/application.properties @@ -21,6 +21,11 @@ de.eshg.lib.appointmentblock.defaultAppointmentTypeConfiguration[VACCINATION]=15 de.eshg.lib.appointmentblock.createAppointmentBlockForCurrentUser=false +eshg.citizen-portal.reverse-proxy.url=http://localhost:4001 + +de.eshg.travel-medicine.notification.fromAddress=info.reisemedizin@stadt-frankfurt.de +de.eshg.travel-medicine.notification.greeting=Ihr Impfberatungsteam der Stadt Frankfurt + # can be set individually to overwrite base department infos # de.eshg.travel-medicine.department-info.name= # de.eshg.travel-medicine.department-info.abbreviation= diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/locales/de/anamnesis.json b/citizen-portal/src/lib/businessModules/schoolEntry/locales/de/anamnesis.json index 7d157b55f9e6f5bdaf7a55ec70880010f79b24b4..a3bc9dbbfe87a33b32989f0a25eb7ad83c478d16 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/locales/de/anamnesis.json +++ b/citizen-portal/src/lib/businessModules/schoolEntry/locales/de/anamnesis.json @@ -41,6 +41,9 @@ "preliminaryCourse": "Vorlaufkurs", "schoolName": "Name der zuständigen Schule", "interests": "Interessen und besondere Fähigkeiten", + "clubSportAndOther": "Was macht Ihr Kind besonders gerne?", + "clubSport": "Sport im Verein", + "otherInterests": "Sonstiges", "canSwim": "Kann Ihr Kind schwimmen?", "seahorseBadge": "Hat Ihr Kind das Seepferdchenabzeichen?", "personalCharacteristics": "Persönliche Besonderheiten", diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/locales/en/anamnesis.json b/citizen-portal/src/lib/businessModules/schoolEntry/locales/en/anamnesis.json index 622c57a115bda6bbd1a1710fcb7722c00ba09261..735127c7acf4c0d3aa82ac60a822ec5c5452180e 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/locales/en/anamnesis.json +++ b/citizen-portal/src/lib/businessModules/schoolEntry/locales/en/anamnesis.json @@ -41,6 +41,9 @@ "preliminaryCourse": "Preliminary Course", "schoolName": "Name of the Responsible School", "interests": "Interests and Special Skills", + "clubSportAndOther": "What does your child particularly enjoy doing?", + "clubSport": "Club Sport", + "otherInterests": "Other", "canSwim": "Can your child swim?", "seahorseBadge": "Does your child have the Seahorse Badge?", "personalCharacteristics": "Personal Characteristics", diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/CitizenAnamnesisForm.tsx b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/CitizenAnamnesisForm.tsx index 10e46d90ce80cf4a17f611e9502e7f1a2978854e..fdb8f2cf69d4cd35176086270352c8ee326a1aeb 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/CitizenAnamnesisForm.tsx +++ b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/CitizenAnamnesisForm.tsx @@ -75,9 +75,9 @@ interface MigrationBackgroundValues { interface PromotionBeforeSchoolEntryValues { earlySupport: boolean | null; integrationPlace: boolean | null; - ergotherapy: OptionalFieldValue<boolean>; - speechTherapy: OptionalFieldValue<boolean>; - physiotherapy: OptionalFieldValue<boolean>; + ergotherapy: boolean | null; + speechTherapy: boolean | null; + physiotherapy: boolean | null; } interface AdditionalChildInfoValues { @@ -96,6 +96,8 @@ interface DaycareAndSchoolInfoValues { interface InterestAndSportsInfoValues { canSwim: boolean | null; hasSeahorseBadge: boolean | null; + clubSport: OptionalFieldValue<string>; + otherInterests: OptionalFieldValue<string>; } interface DevelopmentInfoValues { @@ -133,15 +135,15 @@ interface PromotionTherapyAndAidInfoValues { hearingAid: ToggleableSectionFormValue & { which: OptionalFieldValue<string>; }; - speechTherapy: ToggleableSectionFormValue & { + speechTherapy: { start: OptionalFieldValue<string>; end: OptionalFieldValue<string>; }; - ergoTherapy: ToggleableSectionFormValue & { + ergoTherapy: { start: OptionalFieldValue<string>; end: OptionalFieldValue<string>; }; - physioTherapy: ToggleableSectionFormValue & { + physioTherapy: { start: OptionalFieldValue<string>; end: OptionalFieldValue<string>; }; @@ -151,7 +153,7 @@ interface PromotionTherapyAndAidInfoValues { } interface ToggleableSectionFormValue { - show: boolean; + show: boolean | null; } const INITIAL_VALUES: CitizenAnamnesisFormValues = { @@ -168,7 +170,7 @@ const INITIAL_VALUES: CitizenAnamnesisFormValues = { nationality: "", }, secondParent: { - show: false, + show: null, countryOfBirth: "", nationality: "", }, @@ -176,14 +178,14 @@ const INITIAL_VALUES: CitizenAnamnesisFormValues = { promotionBeforeSchoolEntry: { earlySupport: null, integrationPlace: null, - ergotherapy: "", - speechTherapy: "", - physiotherapy: "", + ergotherapy: null, + speechTherapy: null, + physiotherapy: null, }, additionalChildInfo: { responsiblePhysician: "", siblings: { - show: false, + show: null, birthYears: [""], }, }, @@ -195,6 +197,8 @@ const INITIAL_VALUES: CitizenAnamnesisFormValues = { interestsAndSportsInfo: { canSwim: null, hasSeahorseBadge: null, + clubSport: "", + otherInterests: "", }, personalConspicuities: null, developmentInfo: { @@ -205,7 +209,7 @@ const INITIAL_VALUES: CitizenAnamnesisFormValues = { }, illnessAndAccidentInfo: { allergies: { - show: false, + show: null, values: [""], }, severeIllnesses: null, @@ -216,7 +220,7 @@ const INITIAL_VALUES: CitizenAnamnesisFormValues = { familyHistoryInfo: { spectaclesInFamily: null, chronicIllnessOrDisabilityInFamily: { - show: false, + show: null, value: "", }, }, @@ -226,34 +230,31 @@ const INITIAL_VALUES: CitizenAnamnesisFormValues = { speechImpairment: null, spectacles: { since: "", - show: false, + show: null, }, visionSchool: { since: "", - show: false, + show: null, }, hearingAid: { which: "", - show: false, + show: null, }, speechTherapy: { start: "", end: "", - show: false, }, ergoTherapy: { start: "", end: "", - show: false, }, physioTherapy: { start: "", end: "", - show: false, }, additionalTherapies: { which: "", - show: false, + show: null, }, }, }; @@ -284,6 +285,7 @@ export function CitizenAnamnesisForm(props: CitizenAnamnesisFormProps) { }) .catch(); } + return ( <MultiStepForm<CitizenAnamnesisFormValues> steps={STEPS}> {({ Outlet, currentStep, totalSteps }) => ( @@ -359,31 +361,33 @@ function mapToRequest( nationalityFirstParent: mapOptionalValue( values.migrationBackground.firstParent.nationality, ), - countryOfBirthSecondParent: onlyIfShown( + countryOfBirthSecondParent: fallbackIfExplicitlyHidden( values.migrationBackground.secondParent, mapOptionalValue( values.migrationBackground.secondParent.countryOfBirth, ), + ApiSchoolEntryCountryCode.Uuu, ), - nationalitySecondParent: onlyIfShown( + nationalitySecondParent: fallbackIfExplicitlyHidden( values.migrationBackground.secondParent, mapOptionalValue(values.migrationBackground.secondParent.nationality), + ApiSchoolEntryCountryCode.Uuu, ), }, promotionBeforeSchoolEntry: { earlySupport: mapNullableValue( values.promotionBeforeSchoolEntry.earlySupport, ), - ergotherapy: mapOptionalValue( + ergotherapy: mapNullableValue( values.promotionBeforeSchoolEntry.ergotherapy, ), integrationPlace: mapNullableValue( values.promotionBeforeSchoolEntry.integrationPlace, ), - physiotherapy: mapOptionalValue( + physiotherapy: mapNullableValue( values.promotionBeforeSchoolEntry.physiotherapy, ), - speechTherapy: mapOptionalValue( + speechTherapy: mapNullableValue( values.promotionBeforeSchoolEntry.speechTherapy, ), }, @@ -394,8 +398,8 @@ function mapToRequest( siblingsBirthYears: values.additionalChildInfo.siblings.show ? dropBlankStrings( values.additionalChildInfo.siblings.birthYears, - ).map(parseInt) - : [], + ).map((it) => parseInt(it)) + : undefined, }, daycareAndSchoolInfo: { inDaycareSince: mapMonthAndYear( @@ -447,6 +451,10 @@ function mapToRequest( hasSeahorseBadge: mapNullableValue( values.interestsAndSportsInfo.hasSeahorseBadge, ), + clubSport: mapOptionalValue(values.interestsAndSportsInfo.clubSport), + otherInterests: mapOptionalValue( + values.interestsAndSportsInfo.otherInterests, + ), }, promotionTherapyAndAidInfo: { visionImpairment: mapNullableValue( @@ -471,31 +479,31 @@ function mapToRequest( mapOptionalValue(values.promotionTherapyAndAidInfo.hearingAid.which), ), speechTherapyStart: onlyIfShown( - values.promotionTherapyAndAidInfo.speechTherapy, + { show: values.promotionBeforeSchoolEntry.speechTherapy }, mapOptionalDate( values.promotionTherapyAndAidInfo.speechTherapy.start, ), ), speechTherapyEnd: onlyIfShown( - values.promotionTherapyAndAidInfo.speechTherapy, + { show: values.promotionBeforeSchoolEntry.speechTherapy }, mapOptionalDate(values.promotionTherapyAndAidInfo.speechTherapy.end), ), ergoTherapyStart: onlyIfShown( - values.promotionTherapyAndAidInfo.ergoTherapy, + { show: values.promotionBeforeSchoolEntry.ergotherapy }, mapOptionalDate(values.promotionTherapyAndAidInfo.ergoTherapy.start), ), ergoTherapyEnd: onlyIfShown( - values.promotionTherapyAndAidInfo.ergoTherapy, + { show: values.promotionBeforeSchoolEntry.ergotherapy }, mapOptionalDate(values.promotionTherapyAndAidInfo.ergoTherapy.end), ), physioTherapyStart: onlyIfShown( - values.promotionTherapyAndAidInfo.physioTherapy, + { show: values.promotionBeforeSchoolEntry.physiotherapy }, mapOptionalDate( values.promotionTherapyAndAidInfo.physioTherapy.start, ), ), physioTherapyEnd: onlyIfShown( - values.promotionTherapyAndAidInfo.physioTherapy, + { show: values.promotionBeforeSchoolEntry.physiotherapy }, mapOptionalDate(values.promotionTherapyAndAidInfo.physioTherapy.end), ), additionalTherapies: onlyIfShown( @@ -516,3 +524,10 @@ function onlyIfShown<TValue>( ): TValue | undefined { return section.show ? value : undefined; } +function fallbackIfExplicitlyHidden<TValue>( + section: ToggleableSectionFormValue, + value: TValue, + fallback: TValue, +): TValue { + return section.show === false ? fallback : value; +} diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepFour.tsx b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepFour.tsx index aec7462c44af73206425ed8f75102773f4a9032b..2e35ad9fff80fc0851bbaba3006786359af01a99 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepFour.tsx +++ b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepFour.tsx @@ -20,6 +20,10 @@ export function CitizenAnamnesisStepFour() { "promotionTherapyAndAidInfo", ); + const promotionBeforeSchoolEntry = createFieldNameMapper( + "promotionBeforeSchoolEntry", + ); + return ( <ContentSheet> <Typography level="h3">{t("support.title")}</Typography> @@ -102,7 +106,7 @@ export function CitizenAnamnesisStepFour() { </ToggleableSection> <Typography level="h4">{t("support.therapy.title")}</Typography> <ToggleableSection - name={promotionTherapyAndAidInfo("speechTherapy.show")} + name={promotionBeforeSchoolEntry("speechTherapy")} title={t("support.therapy.speechTherapy")} > <Typography level="body-sm">{t("support.therapy.date")}</Typography> @@ -124,7 +128,7 @@ export function CitizenAnamnesisStepFour() { </Grid> </ToggleableSection> <ToggleableSection - name={promotionTherapyAndAidInfo("ergoTherapy.show")} + name={promotionBeforeSchoolEntry("ergotherapy")} title={t("support.therapy.ergoTherapy")} > <Typography level="body-sm">{t("support.therapy.date")}</Typography> @@ -146,7 +150,7 @@ export function CitizenAnamnesisStepFour() { </Grid> </ToggleableSection> <ToggleableSection - name={promotionTherapyAndAidInfo("physioTherapy.show")} + name={promotionBeforeSchoolEntry("physiotherapy")} title={t("support.therapy.physioTherapy")} > <Typography level="body-sm">{t("support.therapy.date")}</Typography> diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepOne.tsx b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepOne.tsx index f80d5be0fa9251e3f9f04e081241eef66b252e77..a68ba0d9d3bc24de0df0c7f64ab8ebcd2cc70127 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepOne.tsx +++ b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepOne.tsx @@ -97,7 +97,7 @@ function ContactForm(props: ContactFormProps) { {t("migration.inGermanySince")} </FormLabel> <LocalMonthAndYearFields - fieldName={migrationBackground("migration.inGermanySince")} + fieldName={migrationBackground("child.inGermanySince")} date={props.values.migrationBackground.child.inGermanySince} /> </Grid> diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepTwo.tsx b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepTwo.tsx index 7a4aaee1225fbb3d35d6bb5ef131419357e17a70..e911d0f435399ca691b46eae8478e6c4b91074f6 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepTwo.tsx +++ b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/CitizenAnamnesisStepTwo.tsx @@ -168,6 +168,23 @@ export function CitizenAnamnesisStepTwo({ <Typography level="h4" component="h3"> {t("additionalInfo.interests")} </Typography> + <Typography level="title-md"> + {t("additionalInfo.clubSportAndOther")} + </Typography> + <Grid container sx={{ flexGrow: 1 }} spacing={2}> + <Grid xxs={12} lg={6}> + <InputField + name={interestsAndSportsInfo("clubSport")} + label={t("additionalInfo.clubSport")} + /> + </Grid> + <Grid xxs={12} lg={6}> + <InputField + name={interestsAndSportsInfo("otherInterests")} + label={t("additionalInfo.otherInterests")} + /> + </Grid> + </Grid> <LocalBooleanRadioField name={interestsAndSportsInfo("canSwim")} label={ diff --git a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/components/ToggleableSection.tsx b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/components/ToggleableSection.tsx index e059f85b2dcbff0077736dc3aeebd942ecd1ea93..01639f51567c745f19f11de8b91d1a4a0067d8cf 100644 --- a/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/components/ToggleableSection.tsx +++ b/citizen-portal/src/lib/businessModules/schoolEntry/pages/citizenAnamnesis/steps/components/ToggleableSection.tsx @@ -33,6 +33,7 @@ export function ToggleableSection({ } trueLabel={t("yes")} falseLabel={t("no")} + allowDeselection /> {field.value && children} </Stack> diff --git a/docs/flaky-tests.adoc b/docs/flaky-tests.adoc new file mode 100644 index 0000000000000000000000000000000000000000..90ccd7875a8f120a821d153e8a2e052b0ce9005e --- /dev/null +++ b/docs/flaky-tests.adoc @@ -0,0 +1,44 @@ += Flaky Tests +:sectnums: + +Due to their nature, flaky tests can be hard to reproduce locally. Sometimes, the reason for a flakiness is not obvious. Then it's essential to reproduce the test failure locally in order to identify the cause and come up with a stable fix. + +== Reproducing flaky tests + +=== Stressing the CPU + +When flakiness comes from a high workload on the machine running the test (e.g. a Gitlab Runner), the `stress` command is a reliable utility to reproduce flakiness. It can be used to put high load on your CPUs before executing the test to be analyzed. + +[source, shell] +---- +stress -c 16 +---- + +`-c` denotes the number of CPUs to put under stress. This value is depending on your system. In most cases, the number of CPUs available on your system is a good choice. + +`stress` can also be used to stress your local memory and hard drive. However, stressing the CPU is usually sufficient to reproduce flakiness. + +Note: `stress` is only available for UNIX systems. Windows users will need another tool. + +=== Retry until failure + +Flakiness can also be reproduced by retrying a test continuously. While this can be done manually, it's time-consuming and cumbersome. The `retry` command helps to automate this process. It can be configured to retry a command until it fails: + +[source, shell] +---- +retry --until=fail [--times=50] -- command-to-retry +---- + +Configuring `--times` is optional, but is useful to make the command terminate in case it hits no failure. +Consider setting `--delay=0` to disable the back-off time after each attempt. +After `--` follows the command to execute the test to analyze. + +.Playwright Example +[source, shell] +---- +retry --until=fail -- pnpm playwright test path/to/test.spec.ts:123 +---- + +`retry` is also a useful utility to verify whether a potential fix actually stabilizes a flaky test. + +Note: `retry` is only available for UNIX systems. Windows users will need another tool. diff --git a/employee-portal/src/app/(baseModule)/metrics/[businessModuleName]/[procedureType]/page.tsx b/employee-portal/src/app/(baseModule)/metrics/[businessModuleName]/[procedureType]/page.tsx index dc99827b43aa0c676dcfb2b218747f7cf9ef9be9..dd944828a6dfc170e2261413b6d3ea9d8270386d 100644 --- a/employee-portal/src/app/(baseModule)/metrics/[businessModuleName]/[procedureType]/page.tsx +++ b/employee-portal/src/app/(baseModule)/metrics/[businessModuleName]/[procedureType]/page.tsx @@ -6,48 +6,33 @@ "use client"; import { ApiProcedureType } from "@eshg/employee-portal-api/base"; -import { endOfToday } from "date-fns"; -import { useState } from "react"; -import { useTaskMetricsQuery } from "@/lib/baseModule/api/queries/taskMetrics"; -import { lastXMonthsInDate } from "@/lib/baseModule/components/procedureMetrics/rangeSelectHelper"; +import { TaskMetricsDisplay } from "@/lib/baseModule/components/procedureMetrics/taskMetrics/TaskMetricsDisplay"; import { routes } from "@/lib/baseModule/shared/routes"; import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; +import { procedureTypeNames } from "@/lib/shared/components/procedures/constants"; export default function TaskMetricsPage( props: Readonly<{ - params: { - businessModuleName: string; - procedureType: ApiProcedureType; - }; + params: { businessModuleName: string; procedureType: ApiProcedureType }; }>, ) { - const timeRangeEnd = endOfToday(); - - const [selectedTimeRange, _setSelectedTimeRange] = useState(12); - - const timeRangeStart = lastXMonthsInDate(timeRangeEnd, selectedTimeRange); - - const procedureMetrics = useTaskMetricsQuery({ - businessModuleName: props.params.businessModuleName, - procedureType: props.params.procedureType, - timeRangeStart, - timeRangeEnd, - }); - return ( <StickyToolbarLayout toolbar={ <Toolbar - title={`Aufgabenkennzahlen: ${props.params.procedureType}`} + title={`Aufgabenkennzahlen: ${procedureTypeNames[props.params.procedureType]}`} backHref={routes.metrics.index} /> } > <MainContentLayout> - {procedureMetrics.taskMetrics.join()} + <TaskMetricsDisplay + businessModuleName={props.params.businessModuleName} + procedureType={props.params.procedureType} + /> </MainContentLayout> </StickyToolbarLayout> ); diff --git a/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/anamnesis/page.tsx b/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/anamnesis/page.tsx index f835fcf3db2bde002eb5e0eeb4dd0c03b5768a39..2a86516df395985382066c4f762df25c6b5df09a 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/anamnesis/page.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/anamnesis/page.tsx @@ -352,6 +352,7 @@ function mapToRequest( ), promotionTherapyAndAidInfo: mapPromotionTherapyAndAidInfo( formValues.promotionTherapyAndAidInfo, + formValues.promotionBeforeSchoolEntry, ), personalConspicuities: mapOptionalValue(formValues.personalConspicuities), }, @@ -446,6 +447,7 @@ function mapPromotionBeforeSchoolEntry( function mapPromotionTherapyAndAidInfo( values: PromotionTherapyAndAidInfoValues, + promotionBeforeSchoolEntryValues: PromotionBeforeSchoolEntryValues, ) { return { visionImpairment: mapOptionalValue(values.visionImpairment), @@ -454,12 +456,24 @@ function mapPromotionTherapyAndAidInfo( spectaclesSince: mapOptionalDate(values.spectaclesSince), visionSchoolSince: mapOptionalDate(values.visionSchoolSince), hearingAid: mapOptionalValue(values.hearingAid), - speechTherapyStart: mapOptionalDate(values.speechTherapyStart), - speechTherapyEnd: mapOptionalDate(values.speechTherapyEnd), - ergoTherapyStart: mapOptionalDate(values.ergoTherapyStart), - ergoTherapyEnd: mapOptionalDate(values.ergoTherapyEnd), - physioTherapyStart: mapOptionalDate(values.physioTherapyStart), - physioTherapyEnd: mapOptionalDate(values.physioTherapyEnd), + speechTherapyStart: promotionBeforeSchoolEntryValues.speechTherapy + ? mapOptionalDate(values.speechTherapyStart) + : undefined, + speechTherapyEnd: promotionBeforeSchoolEntryValues.speechTherapy + ? mapOptionalDate(values.speechTherapyEnd) + : undefined, + ergoTherapyStart: promotionBeforeSchoolEntryValues.ergotherapy + ? mapOptionalDate(values.ergoTherapyStart) + : undefined, + ergoTherapyEnd: promotionBeforeSchoolEntryValues.ergotherapy + ? mapOptionalDate(values.ergoTherapyEnd) + : undefined, + physioTherapyStart: promotionBeforeSchoolEntryValues.physiotherapy + ? mapOptionalDate(values.physioTherapyStart) + : undefined, + physioTherapyEnd: promotionBeforeSchoolEntryValues.physiotherapy + ? mapOptionalDate(values.physioTherapyEnd) + : undefined, additionalTherapies: mapOptionalValue(values.additionalTherapies), }; } diff --git a/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/examinations/layout.tsx b/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/examinations/layout.tsx index f2a78d214b336d8e849c3bd87f377ac84dfad5b0..d100f5e33319ffd0a182bb5fb05ce68e8b7f2bcc 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/examinations/layout.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/procedures/[id]/examinations/layout.tsx @@ -15,6 +15,7 @@ import { PropsWithChildren, useState } from "react"; import { SchoolEntryProcedurePageProps } from "@/app/(businessModules)/school-entry/procedures/[id]/layout"; import { useSchoolEntryApi } from "@/lib/businessModules/schoolEntry/api/clients"; import { useIsNewFeatureEnabled } from "@/lib/businessModules/schoolEntry/api/queries/featureTogglesApi"; +import { useGetProcedure } from "@/lib/businessModules/schoolEntry/api/queries/schoolEntryApi"; import { RequiredProcedureDataDialog } from "@/lib/businessModules/schoolEntry/features/procedures/examinations/RequiredProcedureDataModal"; import { MedicalReportSidebar } from "@/lib/businessModules/schoolEntry/features/procedures/reports/MedicalReportSidebar"; import { SchoolInfoLetterSidebar } from "@/lib/businessModules/schoolEntry/features/procedures/reports/SchoolInfoLetterSidebar"; @@ -64,6 +65,7 @@ export default function SchoolEntryExaminationLayout( ); const procedureId = props.params.id; const navItems = buildNavItems(procedureId); + const procedureDetails = useGetProcedure(procedureId).data; return ( <PageGrid> @@ -80,13 +82,14 @@ export default function SchoolEntryExaminationLayout( ))} </SidePanelNav> </SidePanel> - {(medicalReportEnabled || schoolInfoLetterEnabled) && ( - <CreateReportsPanel - procedureId={procedureId} - showMedicalReportButton={medicalReportEnabled} - showSchoolInfoLetterButton={schoolInfoLetterEnabled} - /> - )} + {(medicalReportEnabled || schoolInfoLetterEnabled) && + !procedureDetails.isClosed && ( + <CreateReportsPanel + procedureId={procedureId} + showMedicalReportButton={medicalReportEnabled} + showSchoolInfoLetterButton={schoolInfoLetterEnabled} + /> + )} </Stack> </Grid> </PageGrid> diff --git a/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessageNotification.tsx b/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessageNotification.tsx index 9bce0ece5ee2933651478307373e7eea5354d186..1a8f471697b22c9f250802cab06e765c51393379 100644 --- a/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessageNotification.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessageNotification.tsx @@ -6,13 +6,21 @@ import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; import { isNonEmptyString } from "@eshg/lib-portal/helpers/guards"; -import SendIcon from "@mui/icons-material/Send"; -import { Avatar, Card, IconButton, Stack, Typography } from "@mui/joy"; +import CloseIcon from "@mui/icons-material/Close"; +import SendOutlinedIcon from "@mui/icons-material/SendOutlined"; +import { Box, Card, IconButton, Stack, Typography } from "@mui/joy"; +import { de } from "date-fns/locale"; import { Formik } from "formik"; import { User } from "matrix-js-sdk/lib/matrix"; +import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; +import { useGetUsersPresence } from "@/lib/businessModules/chat/shared/hooks/useGetUsersPresence"; import { useSendMessage } from "@/lib/businessModules/chat/shared/hooks/useSendMessage"; -import { Message } from "@/lib/businessModules/chat/shared/types"; +import { Message, Presence } from "@/lib/businessModules/chat/shared/types"; +import { + getStatusColor, + markAllMessagesAsRead, +} from "@/lib/businessModules/chat/shared/utils"; import { formatDateTimeRangeToNow } from "@/lib/shared/helpers/dateTime"; export function MessageNotification({ @@ -23,30 +31,99 @@ export function MessageNotification({ sender: User | null; }) { const { sendMessage } = useSendMessage(); + const usersPresence = useGetUsersPresence(); + const { matrixClient } = useChatClientContext(); + const isPrivateChat = + matrixClient + .getRoom(message.roomId) + ?.getMembers() + .filter((member) => member.userId !== matrixClient.getUserId()).length == + 1; return ( - <Card variant="soft" data-testid="notification" size="sm"> - <Stack direction="row" spacing={1}> - <Stack direction="row" alignItems="start"> - <Avatar src={sender?.avatarUrl} /> - </Stack> - <Stack width="100%"> - <Stack - direction="row" - justifyContent="space-between" - alignItems="center" - spacing={1} - > - <Typography level="body-md" fontWeight={600}> - {sender?.displayName} - </Typography> - <Typography level="body-xs" color="neutral" flexShrink={0}> - {message.timestamp - ? formatDateTimeRangeToNow(message.timestamp) - : ""} - </Typography> + <Card + variant="plain" + data-testid="notification" + size="sm" + slotProps={{ + root: { + sx: { + backgroundColor: "common.white", + marginInline: 0, + p: 0, + }, + }, + }} + > + <Stack direction="row"> + <Stack width="100%" justifyContent="space-between"> + <Stack direction="row" alignItems="center"> + <Box + display="flex" + flexDirection="row" + sx={{ + width: "100%", + boxSizing: "content-box", + alignItems: "center", + }} + > + {usersPresence && isPrivateChat && ( + <Box + sx={{ + width: "0.625rem", + height: "0.625rem", + borderRadius: "100%", + backgroundColor: getStatusColor( + sender?.presence as Presence, + ), + marginRight: 0.8, + }} + ></Box> + )} + <Typography + level="title-md" + sx={{ + fontWeight: "bold", + height: "1.5rem", + maxWidth: "15rem", + textOverflow: "ellipsis", + }} + > + {isPrivateChat + ? sender?.displayName + : matrixClient?.getRoom(message.roomId)?.name} + </Typography> + </Box> + <IconButton + aria-label="Schließen" + onClick={() => + markAllMessagesAsRead({ + matrixClient: matrixClient, + roomId: message.roomId, + }) + } + color="primary" + > + <CloseIcon /> + </IconButton> </Stack> - <Typography mb={0.5}>{message.content}</Typography> + <Box display="flex" flexDirection="row"> + <Typography mb={0.5}> + {isPrivateChat + ? message.content + : `${sender?.displayName}: ${message.content}`} + <Typography + component="span" + flexShrink={0} + paddingLeft="4px" + sx={{ color: "text.tertiary" }} + > + {message.timestamp + ? formatDateTimeRangeToNow(message.timestamp, { locale: de }) + : ""} + </Typography> + </Typography> + </Box> <Formik initialValues={{ messageValue: "" }} onSubmit={({ messageValue }) => { @@ -58,16 +135,24 @@ export function MessageNotification({ <FormPlus> <InputField label="" + placeholder="Antworten" type="text" name="messageValue" endDecorator={ - <IconButton aria-label="Schaltfläche Senden" type="submit"> - <SendIcon /> + <IconButton + aria-label="Schaltfläche Senden" + type="submit" + color="primary" + > + <SendOutlinedIcon /> </IconButton> } sx={{ - "& input": { - width: "100%", + ".MuiInput-endDecorator": { + paddingRight: "8px", + }, + ".MuiInput-root": { + height: "2.75rem", }, }} /> diff --git a/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebar.tsx b/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebar.tsx index dcd90aad6288eab5a9781a0d311f907618132a0d..f9f9a3e89d5ca290838f6d25a6fd7904d838305a 100644 --- a/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebar.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebar.tsx @@ -4,12 +4,12 @@ */ import { useNavigation } from "@eshg/lib-portal/components/navigation/NavigationContext"; -import { Button } from "@mui/joy"; +import OpenInNew from "@mui/icons-material/OpenInNew"; +import { Button, Divider, Stack } from "@mui/joy"; import { routes } from "@/lib/baseModule/shared/routes"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; -import { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; import { MessagesSidebarContent } from "./MessagesSidebarContent"; @@ -17,26 +17,29 @@ import { MessagesSidebarContent } from "./MessagesSidebarContent"; export function MessagesSidebar() { const { chatSidebar } = useChat(); const { tryNavigate } = useNavigation(); + return ( <Sidebar open={chatSidebar.isOpen} onClose={chatSidebar.close} zIndex={"headerSidebar"} > - <SidebarContent title="Nachrichten"> + <SidebarContent title="Ungelesene Chats"> <MessagesSidebarContent /> </SidebarContent> - <SidebarActions> + <Stack sx={{ paddingTop: 3 }} data-testid="sidebarActions"> + <Divider sx={{ marginBottom: 3, marginInline: -3, marginTop: -3 }} /> <Button + sx={{ alignSelf: "end" }} onClick={() => { chatSidebar.close(); tryNavigate(routes.chat as string); }} - sx={{ alignSelf: "end" }} + endDecorator={<OpenInNew />} > - Zum Chat + Chatbereich </Button> - </SidebarActions> + </Stack> </Sidebar> ); } diff --git a/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebarContent.tsx b/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebarContent.tsx index 9f3f6c653b5ba0d21011e7c501f5d58d6aa573f7..2367f02347cff3151ba1e2900b94f8b81d57d18b 100644 --- a/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebarContent.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/messagesSidebar/MessagesSidebarContent.tsx @@ -3,26 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import DraftsIcon from "@mui/icons-material/Drafts"; -import { Stack, Typography } from "@mui/joy"; +import { Divider, Stack, Typography } from "@mui/joy"; import { MessageNotification } from "@/lib/baseModule/components/layout/messagesSidebar/MessageNotification"; +import { NoMessagesIllustration } from "@/lib/businessModules/chat/assets/NoMessagesIllustration"; import { ChatNoAccessAlert } from "@/lib/businessModules/chat/components/ChatNoAccessAlert"; +import { GhostButton } from "@/lib/businessModules/chat/components/GhostButton"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; +import { useMatrixClient } from "@/lib/businessModules/chat/shared/hooks/useMatrixClient"; import { useNewMessages } from "@/lib/businessModules/chat/shared/hooks/useNewMessages"; +import { allMessagesRead } from "@/lib/businessModules/chat/shared/utils"; function NoMessagesInfo() { return ( - <Stack gap={3} alignItems={"center"}> - <DraftsIcon - sx={{ - marginTop: { xxs: 5, sm: 10 }, - fontSize: { xxs: 80, sm: 128 }, - }} - /> - <Typography level="h4" component="h2"> - Aktuell nichts Neues - </Typography> + <Stack alignItems="center" justifyContent="center" sx={{ height: "100%" }}> + <NoMessagesIllustration sx={{ width: "400px", height: "auto" }} /> + <Typography>Keine neuen Nachrichten.</Typography> </Stack> ); } @@ -30,6 +26,7 @@ function NoMessagesInfo() { export function MessagesSidebarContent() { const { userSettings } = useChat(); const { newMessages } = useNewMessages(); + const matrixClient = useMatrixClient(); if (!userSettings.chatUsageEnabled) { return <ChatNoAccessAlert />; @@ -39,14 +36,26 @@ export function MessagesSidebarContent() { return <NoMessagesInfo />; } return ( - <Stack sx={{ marginTop: 3 }} gap={2}> - {newMessages.map((notification) => ( - <MessageNotification - key={notification.id} - message={notification} - sender={notification.sender} - /> - ))} + <Stack> + <GhostButton + onClick={() => { + if (matrixClient) { + allMessagesRead(matrixClient, newMessages); + } + }} + > + Alle als gelesen markieren + </GhostButton> + <Divider sx={{ marginBottom: 3, marginInline: -3, marginTop: 4 }} /> + <Stack gap={5} sx={{ paddingBottom: -3 }}> + {newMessages.map((notification) => ( + <MessageNotification + key={notification.id} + message={notification} + sender={notification.sender} + /> + ))} + </Stack> </Stack> ); } diff --git a/employee-portal/src/lib/baseModule/components/procedureMetrics/ProcedureMetricsDisplay.tsx b/employee-portal/src/lib/baseModule/components/procedureMetrics/ProcedureMetricsDisplay.tsx index 128eb37cf2d744e869904ba03062990674623ac5..13d359b564c10e66f3bf3f8b69bcd104cdea40ce 100644 --- a/employee-portal/src/lib/baseModule/components/procedureMetrics/ProcedureMetricsDisplay.tsx +++ b/employee-portal/src/lib/baseModule/components/procedureMetrics/ProcedureMetricsDisplay.tsx @@ -16,7 +16,7 @@ import { } from "@mui/icons-material"; import { Stack, Typography } from "@mui/joy"; import { endOfToday } from "date-fns"; -import { useState } from "react"; +import { startTransition, useState } from "react"; import { unique } from "remeda"; import { useIsNewFeatureEnabled } from "@/lib/baseModule/api/queries/feature"; @@ -68,7 +68,11 @@ export function ProcedureMetricsDisplay() { <TimeRangeSelect optionsInMonths={[1, 3, 6, 12]} selectedTimeRange={selectedTimeRange} - setSelectedTimeRange={setSelectedTimeRange} + setSelectedTimeRange={(value) => + startTransition(() => { + setSelectedTimeRange(value); + }) + } /> <Stack role="list" direction="row" flexWrap="wrap" gap={2}> <FlashCard @@ -105,13 +109,13 @@ export function ProcedureMetricsDisplay() { <Stack gap={5}> {uniqueBusinessModules.map((businessModule) => ( <Stack key={businessModuleNames[businessModule]} gap={2}> - <Typography - level={"body-lg"} - key={businessModuleNames[businessModule]} + <TableSheet + title={ + <Typography level="h3" component="h2"> + {businessModuleNames[businessModule]} + </Typography> + } > - {businessModuleNames[businessModule]} - </Typography> - <TableSheet> <DataTable data={procedureMetrics.filter( (procedure) => procedure.businessModule === businessModule, diff --git a/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/TaskMetricsDisplay.tsx b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/TaskMetricsDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8fae0bda7ed1ba7e07d99c3a317058568f7ada7d --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/TaskMetricsDisplay.tsx @@ -0,0 +1,137 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +"use client"; + +import { ApiProcedureType } from "@eshg/employee-portal-api/base"; +import { + CheckOutlined, + HourglassEmptyOutlined, + RocketLaunchOutlined, +} from "@mui/icons-material"; +import { Stack, Typography } from "@mui/joy"; +import { endOfToday } from "date-fns"; +import { startTransition, useState } from "react"; + +import { useTaskMetricsQuery } from "@/lib/baseModule/api/queries/taskMetrics"; +import { TimeRangeSelect } from "@/lib/baseModule/components/procedureMetrics/TimeRangeSelect"; +import { lastXMonthsInDate } from "@/lib/baseModule/components/procedureMetrics/rangeSelectHelper"; +import { FlashCard } from "@/lib/shared/components/cards/FlashCard"; +import { DataTable } from "@/lib/shared/components/table/DataTable"; +import { TableSheet } from "@/lib/shared/components/table/TableSheet"; + +import { formatOptionalDuration } from "./formatOptionalDuration"; +import { slowestAndFastestTasksColumns } from "./slowestAndFastestColumns"; +import { tasksColumns } from "./taskColumns"; + +export function TaskMetricsDisplay(props: { + businessModuleName: string; + procedureType: ApiProcedureType; +}) { + const timeRangeEnd = endOfToday(); + + const [selectedTimeRange, setSelectedTimeRange] = useState(12); + + const timeRangeStart = lastXMonthsInDate(timeRangeEnd, selectedTimeRange); + + const taskMetrics = useTaskMetricsQuery({ + businessModuleName: props.businessModuleName, + procedureType: props.procedureType, + timeRangeStart, + timeRangeEnd, + }); + + return ( + <Stack gap={3}> + <TimeRangeSelect + optionsInMonths={[1, 3, 6, 12]} + selectedTimeRange={selectedTimeRange} + setSelectedTimeRange={(value) => + startTransition(() => { + setSelectedTimeRange(value); + }) + } + /> + <Stack role="list" direction="row" flexWrap="wrap" gap={2}> + <FlashCard + color={"primary"} + title="Geschlossene Vorgänge" + figure={`${taskMetrics.closedProcedureCount}`} + icon={<CheckOutlined fontSize="xl4" />} + /> + <FlashCard + color={"danger"} + title="Langsamster Vorgang" + figure={`${formatOptionalDuration(taskMetrics.slowestProcedures[0]?.duration)}`} + icon={<HourglassEmptyOutlined fontSize="xl4" />} + /> + <FlashCard + color={"success"} + title="Schnellster Vorgang" + figure={`${formatOptionalDuration(taskMetrics.fastestProcedures[0]?.duration)}`} + icon={<RocketLaunchOutlined fontSize="xl4" />} + /> + </Stack> + <Stack gap={3}> + <TableSheet + title={ + <Stack gap={3} marginBottom={1}> + <Typography level="h3" component="h2"> + Aufgaben + </Typography> + <Typography> + Auftreten der Aufgaben in abgeschlossenen Vorgängen. + </Typography> + </Stack> + } + > + <DataTable + data={taskMetrics.taskMetrics} + columns={tasksColumns} + sorting={{ + manualSorting: false, + }} + /> + </TableSheet> + + <TableSheet + title={ + <Stack marginBottom={1}> + <Typography level="h3" component="h2"> + Schnellste Vorgänge + </Typography> + </Stack> + } + > + <DataTable + data={taskMetrics.fastestProcedures} + columns={slowestAndFastestTasksColumns} + sorting={{ + manualSorting: false, + }} + /> + </TableSheet> + + <TableSheet + title={ + <Stack marginBottom={1}> + <Typography level="h3" component="h2"> + Langsamste Vorgänge + </Typography> + </Stack> + } + > + <DataTable + data={taskMetrics.slowestProcedures} + columns={slowestAndFastestTasksColumns} + sorting={{ + manualSorting: false, + }} + /> + </TableSheet> + </Stack> + </Stack> + ); +} diff --git a/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/formatOptionalDuration.ts b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/formatOptionalDuration.ts new file mode 100644 index 0000000000000000000000000000000000000000..62056427697613a7ae6b660a0795c2211f14e2e4 --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/formatOptionalDuration.ts @@ -0,0 +1,12 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isDefined } from "remeda"; + +import { formatDurationRounded } from "@/lib/shared/helpers/dateTime"; + +export function formatOptionalDuration(value: string | undefined) { + return isDefined(value) ? formatDurationRounded(value) : "-"; +} diff --git a/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/slowestAndFastestColumns.tsx b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/slowestAndFastestColumns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..719e2b7554f92852a2511d810053b51e989ff377 --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/slowestAndFastestColumns.tsx @@ -0,0 +1,31 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiProcedureWithDuration } from "@eshg/employee-portal-api/base"; +import { formatDate } from "@eshg/lib-portal/formatters/dateTime"; +import { createColumnHelper } from "@tanstack/react-table"; + +import { formatOptionalDuration } from "./formatOptionalDuration"; + +const columnHelper = createColumnHelper<ApiProcedureWithDuration>(); + +const meta = { + width: "6rem", +}; + +export const slowestAndFastestTasksColumns = [ + columnHelper.accessor("createdAt", { + header: "Erstellt am", + cell: (props) => formatDate(props.getValue()), + meta, + }), + columnHelper.accessor("duration", { + header: "Durchschnittliche Dauer", + cell: (props) => { + return formatOptionalDuration(props.getValue()); + }, + meta, + }), +]; diff --git a/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/taskColumns.tsx b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/taskColumns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a68f9d3df3a1601e435658f7d287fcf6e055ff34 --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/procedureMetrics/taskMetrics/taskColumns.tsx @@ -0,0 +1,50 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiTaskMetric } from "@eshg/employee-portal-api/base"; +import { createColumnHelper } from "@tanstack/react-table"; + +import { taskTypeNames } from "@/lib/shared/components/procedures/constants"; + +import { formatOptionalDuration } from "./formatOptionalDuration"; + +const columnHelper = createColumnHelper<ApiTaskMetric>(); + +const meta = { + width: "6rem", +}; + +export const tasksColumns = [ + columnHelper.accessor("taskType", { + header: "Bezeichnung", + cell: (props) => taskTypeNames[props.getValue()], + meta: { + width: "10rem", + }, + }), + columnHelper.accessor("averageDuration", { + header: "Durchschnittliche Dauer", + cell: (props) => { + return formatOptionalDuration(props.getValue()); + }, + meta, + }), + columnHelper.accessor("noOccurrencesCount", { + header: "Kein Auftreten", + meta, + }), + columnHelper.accessor("oneOccurrenceCount", { + header: "Auftreten: 1x", + meta, + }), + columnHelper.accessor("twoOccurrencesCount", { + header: "Auftreten: 2x", + meta, + }), + columnHelper.accessor("moreThanTwoOccurrencesCount", { + header: "Auftreten: >2", + meta, + }), +]; diff --git a/employee-portal/src/lib/businessModules/chat/assets/NoMessagesIllustration.tsx b/employee-portal/src/lib/businessModules/chat/assets/NoMessagesIllustration.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ff1b72a600f70e668d8e0a82e8f8ab73caf07ee --- /dev/null +++ b/employee-portal/src/lib/businessModules/chat/assets/NoMessagesIllustration.tsx @@ -0,0 +1,57 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { SvgIcon, SvgIconProps } from "@mui/joy"; + +export function NoMessagesIllustration(props: SvgIconProps) { + return ( + <SvgIcon {...props}> + <svg + width="441" + height="441" + viewBox="0 0 441 441" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + width="440" + height="440" + transform="translate(0.744141 0.5)" + fill="white" + /> + <rect + x="39.1875" + y="31.9434" + width="363.113" + height="363.113" + rx="181.557" + fill="#F0F4F8" + /> + <path + d="M151.897 125.881H303.614C314.045 125.881 322.579 134.415 322.579 144.846V315.527L284.65 277.598H151.897C141.467 277.598 132.933 269.064 132.933 258.633V144.846C132.933 134.415 141.467 125.881 151.897 125.881Z" + fill="#CDD7E1" + /> + <path + d="M106.911 171.215L115.642 204.77L149.196 213.5L115.642 222.231L106.911 255.786L98.1797 222.231L64.625 213.5L98.1797 204.77L106.911 171.215Z" + fill="#DDE7EE" + /> + <path + d="M261.416 315.527L268.005 340.85L293.328 347.439L268.005 354.029L261.416 379.352L254.827 354.029L229.504 347.439L254.827 340.85L261.416 315.527Z" + fill="white" + /> + <path + d="M258.235 58.957L262.774 76.3989L280.215 80.9373L262.774 85.4757L258.235 102.918L253.697 85.4757L236.255 80.9373L253.697 76.3989L258.235 58.957Z" + fill="white" + /> + <path + d="M183.867 204.446L219.711 236.309L275.142 168.195" + stroke="white" + strokeWidth="9" + strokeLinecap="square" + /> + </svg> + </SvgIcon> + ); +} diff --git a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatBubble.tsx b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatBubble.tsx index 7542ade9f5205127c6abda22fd1fda9635b97f41..44f12d7ac76607a05476f3ff1161a395a7d0a558 100644 --- a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatBubble.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatBubble.tsx @@ -7,29 +7,30 @@ import Box from "@mui/joy/Box"; import Sheet from "@mui/joy/Sheet"; import Stack from "@mui/joy/Stack"; import Typography from "@mui/joy/Typography"; -import { format, isToday } from "date-fns"; -import { User } from "matrix-js-sdk/lib/matrix"; import { ReactNode } from "react"; +import { isEmpty } from "remeda"; import { ChatAvatar } from "@/lib/businessModules/chat/components/ChatAvatar"; import { ReadingReceipt } from "@/lib/businessModules/chat/components/chatPanel/ReadingReceipt"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { Message } from "@/lib/businessModules/chat/shared/types"; -import { formatDateForChat } from "@/lib/businessModules/chat/shared/utils"; +import { formatChatDate } from "@/lib/businessModules/chat/shared/utils"; interface ChatBubbleProps { message: Message; variant: "sent" | "received"; loggedInUserId: string; - receiptUsers: (User | null)[]; + lastReadMessageIndexes: number[]; + index: number; } export function ChatBubble({ variant, message, loggedInUserId, - receiptUsers, + lastReadMessageIndexes = [], + index, }: Readonly<ChatBubbleProps>) { const { matrixClient } = useChatClientContext(); const { userSettings } = useChat(); @@ -40,6 +41,14 @@ export function ChatBubble({ return user?.displayName; }) .filter((item) => !!item) as string[]; + const hasNoReceipts = isEmpty(lastReadMessageIndexes); + + // Messages are sorted from newest to oldest. + // Here, we compare the index to check if it is greater than the last read index. + // This means that the message is older than the read ones, so it must have been read. + const isMessageRead = lastReadMessageIndexes.some( + (readIndex) => index >= readIndex, + ); return ( <Stack direction="column" alignItems="flex-start"> @@ -59,9 +68,7 @@ export function ChatBubble({ </Typography> {message.timestamp && ( <Typography textColor="text.secondary" sx={{ fontSize: "0.875rem" }}> - {isToday(message.timestamp) - ? `${format(message.timestamp, "HH:MM")} Uhr` - : formatDateForChat(message.timestamp)} + {formatChatDate(message.timestamp)} </Typography> )} </Stack> @@ -100,7 +107,7 @@ export function ChatBubble({ {isSent && ( <ReadingReceipt isReadReceiptEnabled={userSettings.showReadConfirmation} - isRead={receiptUsers?.length > 0} + isRead={hasNoReceipts ? false : isMessageRead} /> )} </Box> diff --git a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatMessages.tsx b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatMessages.tsx index 5c6a19431571d3e9fc63b7faa82ea7916b15987e..e8dd50d414965c9fc966d5394b69ccf4eaf34fa0 100644 --- a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatMessages.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatMessages.tsx @@ -6,44 +6,55 @@ import { Box, Divider, List, ListItem, Typography, useTheme } from "@mui/joy"; import { isSameDay, startOfDay } from "date-fns"; import { Fragment, useEffect, useRef } from "react"; +import { isEmpty, isNonNullish } from "remeda"; import { ChatIllustrationBackground } from "@/lib/businessModules/chat/components/ChatIllustrationBackground"; import { ChatBubble } from "@/lib/businessModules/chat/components/chatPanel/ChatBubble"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { useReadConfirmation } from "@/lib/businessModules/chat/shared/hooks/useReadConfirmation"; -import { useRoomMessages } from "@/lib/businessModules/chat/shared/hooks/useRoomMessages"; import { useTyping } from "@/lib/businessModules/chat/shared/hooks/useTyping"; import { Message, RoomWithCommunicationType, } from "@/lib/businessModules/chat/shared/types"; -import { - formatUserReceipts, - getDayLabel, -} from "@/lib/businessModules/chat/shared/utils"; +import { getDayLabel } from "@/lib/businessModules/chat/shared/utils"; interface ChatMessagesProps { room: RoomWithCommunicationType; + messages: Message[]; } -export function ChatMessages({ room }: Readonly<ChatMessagesProps>) { +export function ChatMessages({ room, messages }: Readonly<ChatMessagesProps>) { const { userSettings: { showReadConfirmation }, } = useChat(); const { matrixClient } = useChatClientContext(); - const loggedInUserId = matrixClient.getUserId(); + const loggedInUserId = matrixClient.getUserId() ?? ""; const { readConfirmationsPerRoom } = useReadConfirmation(showReadConfirmation); - const confirmationsArr = formatUserReceipts( - readConfirmationsPerRoom[room.room.roomId], + const roomReceipts = readConfirmationsPerRoom[room.room.roomId]; + // Here we filter out the logged-in user ID. + const { [loggedInUserId]: _, ...readConfirmationFromOtherUsers } = + roomReceipts ?? {}; + const lastReadMessageIds = Object.values(readConfirmationFromOtherUsers)?.map( + ({ eventId }) => eventId, ); + const lastReadIndexes = messages + .map(({ id }, index) => + lastReadMessageIds.includes(id) ? index : undefined, + ) + .filter((item) => isNonNullish(item)); + const initialReadIndexes = messages + .map(({ readReceipts }, index) => + readReceipts && !isEmpty(readReceipts) ? index : undefined, + ) + .filter((item) => isNonNullish(item)); const { userSettings: { showTypingNotification }, } = useChat(); const { typingUsersList } = useTyping(showTypingNotification); const typingUsers = typingUsersList[room.room.roomId]; - const { messages } = useRoomMessages(); const theme = useTheme(); const messagesWrapperRef = useRef<HTMLUListElement>(null); const wrapperRef = useRef<HTMLDivElement>(null); @@ -77,12 +88,6 @@ export function ChatMessages({ room }: Readonly<ChatMessagesProps>) { {messages?.map((message: Message, index: number) => { const isYou = message.sender?.userId === loggedInUserId; - const confirmationIds = confirmationsArr?.[message.id]; - - const receiptUsers = - showReadConfirmation && Array.isArray(confirmationIds) - ? confirmationIds.map((userId) => matrixClient.getUser(userId)) - : []; const nextMessage = messages[index + 1]; const shouldShowDivider = index !== messages.length - 1 && @@ -108,9 +113,10 @@ export function ChatMessages({ room }: Readonly<ChatMessagesProps>) { variant={isYou ? "sent" : "received"} loggedInUserId={loggedInUserId} message={message} - receiptUsers={receiptUsers.filter( - (user) => user?.userId !== loggedInUserId, - )} + lastReadMessageIndexes={ + [...initialReadIndexes, ...lastReadIndexes] as number[] + } + index={index} /> </ListItem> {shouldShowDivider && message.timestamp && ( diff --git a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatPanel.tsx b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatPanel.tsx index acecc0bc70aa9cac54950602de0ab5910a7837e0..564cd474927df09fea328cb662c39883f3d03a69 100644 --- a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatPanel.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatPanel.tsx @@ -47,7 +47,7 @@ export function ChatPanel({ userSettings: { showTypingNotification }, } = useChat(); const { handleUserTyping } = useTyping(showTypingNotification); - const { handleSendMessage } = useRoomMessages(); + const { handleSendMessage, messages } = useRoomMessages(); const [userList, setUserList] = useState< (ApiUser & { department?: string })[] | undefined >(); @@ -143,7 +143,7 @@ export function ChatPanel({ flexDirection: "column", }} > - <ChatMessages room={roomWithCommunicationType} /> + <ChatMessages room={roomWithCommunicationType} messages={messages} /> <MessageInput handleUserTyping={handleUserTyping} selectedRoomId={roomId} diff --git a/employee-portal/src/lib/businessModules/chat/components/chatPanel/NewDirectChat.tsx b/employee-portal/src/lib/businessModules/chat/components/chatPanel/NewDirectChat.tsx index f6c299bfde59e4bd7abf6774d1ff7cea965d6bb7..ed04aa8038ae4e6fcc39c499ab5dbd11c687b393 100644 --- a/employee-portal/src/lib/businessModules/chat/components/chatPanel/NewDirectChat.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/chatPanel/NewDirectChat.tsx @@ -16,6 +16,7 @@ import { InputComponent } from "@/lib/businessModules/chat/components/chatPanel/ import { ChatPanelView } from "@/lib/businessModules/chat/shared/enums"; import { useChatSearchParams } from "@/lib/businessModules/chat/shared/hooks/useChatSearchParams"; import { useCreateNewChat } from "@/lib/businessModules/chat/shared/hooks/useCreateNewChat"; +import { useRoomMessages } from "@/lib/businessModules/chat/shared/hooks/useRoomMessages"; import { useSendMessage } from "@/lib/businessModules/chat/shared/hooks/useSendMessage"; import { ApiUser } from "@/lib/businessModules/chat/shared/types"; @@ -36,6 +37,7 @@ export function NewDirectChat({ }: Readonly<NewDirectChatProps>) { const { createNewDirectMessage, findExisingRoom } = useCreateNewChat(); const { sendMessage } = useSendMessage(); + const { messages } = useRoomMessages(); const theme = useTheme(); const { setRoomIdParam } = useChatSearchParams(); @@ -127,7 +129,7 @@ export function NewDirectChat({ </Box> {isObjectType(existingChat) && existingChat?.room.roomId ? ( - <ChatMessages room={existingChat} /> + <ChatMessages room={existingChat} messages={messages} /> ) : ( <ChatIllustrationBackground /> )} diff --git a/employee-portal/src/lib/businessModules/chat/components/roomList/RoomListItem.tsx b/employee-portal/src/lib/businessModules/chat/components/roomList/RoomListItem.tsx index 9b263a958e4f67819b7009815bec0e50c7232040..21a6bca67f7ff7037a5ece6dda43f8059ce0c792 100644 --- a/employee-portal/src/lib/businessModules/chat/components/roomList/RoomListItem.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/roomList/RoomListItem.tsx @@ -14,7 +14,7 @@ import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClie import { CommunicationType } from "@/lib/businessModules/chat/shared/enums"; import { RoomLastMessage } from "@/lib/businessModules/chat/shared/types"; import { - convertMessageTimestamp, + formatChatDate, getMemberAvatarUrl, getRoomAvatarUrl, getRoomLastMessage, @@ -34,7 +34,7 @@ export function RoomListItem({ const { matrixClient, unreadNotificationsPerRoom } = useChatClientContext(); const [lastMessage, setLastMessage] = useState<RoomLastMessage>(); - const parsedDate = convertMessageTimestamp(lastMessage?.timestamp); + const parsedDate = formatChatDate(lastMessage?.timestamp); const unreadNotifications = unreadNotificationsPerRoom[room.roomId]; // TO DO - finish notification feature diff --git a/employee-portal/src/lib/businessModules/chat/components/secureBackup/BackupSetupView.tsx b/employee-portal/src/lib/businessModules/chat/components/secureBackup/BackupSetupView.tsx index 77bd92cd38bf118f027815f6e44c4d68bf148012..bfed28a47e435c90c0551442914fc00894a0afb6 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/BackupSetupView.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/BackupSetupView.tsx @@ -21,18 +21,19 @@ export interface SecureBackupContent { const content = { [ClientState.CreateBackupKey]: { - header: "Set up Secure Backup", - subheader: "Set up a secure backup to be able to use chat.", + header: "Richten Sie ein Sicherheitsbackup ein", + subheader: + "Richten Sie ein Sicherheitsbackup ein um die Chatfunktion zu nutzen", description: [ - "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.", - "Enter a Security Phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.", + "Schützen Sie sich vor dem Verlust des Zugriffs auf verschlüsselte Nachrichten und Daten, indem Sie die Sicherheitsschlüssel auf Ihrem Server sichern.", + "Geben Sie ein Passwort ein, das nur Sie kennen, da es zum Schutz Ihrer Daten verwendet wird. Aus Sicherheitsgründen sollten Sie Ihr GA-Lotse Benutzerpasswort nicht wieder verwenden.", ], }, [ClientState.RestoreBackupKey]: { - header: "Verify this device", - subheader: "Verify this device to be able to use chat.", + header: "Bestätigen sie dieses Endgerät", + subheader: "Bestätigen sie dieses Endgerät um die Chatfunktion zu nutzen", description: [ - "Verify your identity to access encrypted messages and prove your identity to others.", + "Bestätigen Sie Ihre Identität, um auf verschlüsselte Nachrichten zuzugreifen und Ihre Identität gegenüber anderen zu bestätigen.", ], }, }; diff --git a/employee-portal/src/lib/businessModules/chat/components/secureBackup/CreateBackupSidebar.tsx b/employee-portal/src/lib/businessModules/chat/components/secureBackup/CreateBackupSidebar.tsx index 6892ecd6217e3cb33442e8c6dbd6dbb43fa74aba..620a318da4764f1fc3028bc7debf97771c4e528e 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/CreateBackupSidebar.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/CreateBackupSidebar.tsx @@ -5,7 +5,6 @@ import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; import { createFieldNameMapper } from "@eshg/lib-portal/helpers/form"; -import { validateLength } from "@eshg/lib-portal/helpers/validators"; import { CheckCircleOutline, RadioButtonUnchecked } from "@mui/icons-material"; import { Stack, Typography } from "@mui/joy"; import { Formik } from "formik"; @@ -29,9 +28,9 @@ import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; import { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; import { - getPassphraseValidityInfo, - validatePassphrase, -} from "@/lib/shared/helpers/validatePassphrase"; + getPasswordValidityInfo, + validatePassword, +} from "@/lib/shared/helpers/validatePassword"; const initialValues = { validForm: "", @@ -144,7 +143,7 @@ export function CreateBackupSidebar({ }} validate={(values) => { if ( - !validatePassphrase(values.passphrase, values.repeatedPassphrase) + !validatePassword(values.passphrase, values.repeatedPassphrase) ) { return { validForm: "false" }; } @@ -163,17 +162,15 @@ export function CreateBackupSidebar({ ))} <PasswordField data-testid={"passphrase"} - label={"Enter a Security Phrase"} + label={"Sicherheitsphrase vergeben"} name={fieldName("passphrase")} visibilityLabel={"visiblePassphrase"} - validate={validateLength(1, 10)} /> <PasswordField data-testid={"repeatedPassphrase"} - label={"Confirm your Security Phrase"} + label={"Sicherheitsphrase wiederholen"} name={fieldName("repeatedPassphrase")} visibilityLabel={"visibleRepeatedPassphrase"} - validate={validateLength(1, 10)} /> </Stack> <PasswortRequirementHints @@ -225,7 +222,7 @@ function PasswortRequirementHints({ Anforderungen an Sicherheitsphrasen: </Typography> - {getPassphraseValidityInfo(password, repeatedPassword).map( + {getPasswordValidityInfo(password, repeatedPassword).map( ({ message, valid }) => ( <Typography fontWeight={"lighter"} diff --git a/employee-portal/src/lib/businessModules/chat/components/secureBackup/ResetBackupModal.tsx b/employee-portal/src/lib/businessModules/chat/components/secureBackup/ResetBackupModal.tsx index c7ffd916faa2ad3da9a7bbe35ad7f8c509832cc7..db80bc8dcf35407c5fd32771959c13c9916de93c 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/ResetBackupModal.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/ResetBackupModal.tsx @@ -29,18 +29,18 @@ export function ResetBackupModal(props: Omit<BaseModalProps, "children">) { return ( <BaseModal - modalTitle="Reset everything" + modalTitle="Alles zurücksetzen" key="reset-backup-modal" {...props} > <> <Typography> - Only do this if you have no other device to complete verification - with. + Tun Sie dies nur, wenn Sie kein anderes Gerät haben, mit dem Sie die + Verifizierung abschließen können. </Typography> <Typography textColor="text.secondary"> - If you reset everything, you will restart with no trusted sessions, no - trusted users, and might not be able to see past messages. + Wenn Sie alles zurücksetzen, können Sie möglicherweise frühere + Nachrichten nicht mehr lesen. </Typography> <Stack direction="row" @@ -54,7 +54,7 @@ export function ResetBackupModal(props: Omit<BaseModalProps, "children">) { onClick={props.onClose} data-testid="confirmationDialogCancel" > - Cancel + Abbrechen </Button> <Button size="sm" @@ -63,7 +63,7 @@ export function ResetBackupModal(props: Omit<BaseModalProps, "children">) { onClick={handleResetAllClick} data-testid="confirmationDialogConfirm" > - Reset everything + Alles zurücksetzen </Button> </Stack> </> diff --git a/employee-portal/src/lib/businessModules/chat/components/secureBackup/RestoreBackupSidebar.tsx b/employee-portal/src/lib/businessModules/chat/components/secureBackup/RestoreBackupSidebar.tsx index 535ed7f05e998599d0f9d70023969166b8f214ed..5cc59af8cf7140f28c41428dba8949aaa0d75731 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/RestoreBackupSidebar.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/RestoreBackupSidebar.tsx @@ -65,7 +65,8 @@ export function RestoreBackupSidebar({ return undefined; } catch { return { - passphrase: "Error while verifying the device. Is the phrase correct?", + passphrase: + "Fehler bei der Verifizierung des Geräts. Ist die Phrase korrekt?", }; } } @@ -86,10 +87,10 @@ export function RestoreBackupSidebar({ values.passphrase, ); setClientState(ClientState.Prepared); - snackbar.confirmation("Your device is now verified"); + snackbar.confirmation("Ihr Gerät wurde nun verifiziert"); } catch (e) { handleClose(); - snackbar.error("Cannot access chat"); + snackbar.error("Kein Zugriff auf den Chat"); setClientState(ClientState.Error); logger.error(e); } @@ -118,23 +119,23 @@ export function RestoreBackupSidebar({ ))} <PasswordField data-testid={"passphrase"} - label={"Enter a Security Phrase"} + label={"Sicherheitsphrase vergeben"} name={fieldName("passphrase")} visibilityLabel={"visiblePassphrase"} /> <Stack direction="row" spacing={0.5}> <Typography level="body-sm" color="neutral"> - Forgotten or lost recovery phrase? + Wiederherstellungsphrase vergessen oder verloren?{` `} + <Link + component="button" + type="button" + level="body-sm" + color="danger" + onClick={() => setModalOpen(true)} + > + Alles zurücksetzen + </Link> </Typography> - <Link - component="button" - type="button" - level="body-sm" - color="danger" - onClick={() => setModalOpen(true)} - > - Reset all - </Link> </Stack> </Stack> </SidebarContent> diff --git a/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx b/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx index 33332e5ba2667e5346a48cf17dd5e0d19f658123..4e6600ba47112c3d13d0d9a4a0e0550caef804b6 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx @@ -152,7 +152,6 @@ export function ChatClientProvider({ children }: Readonly<RequiresChildren>) { ) { showMessageTeaser({ username: room.name, - avatar: undefined, text: guestCount > 1 ? `${sender.displayName}: ${messageContent.body}` diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useMatrixClient.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useMatrixClient.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51dc0a95ec723a65c3465524316820e01ec3c08b --- /dev/null +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useMatrixClient.tsx @@ -0,0 +1,19 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useContext } from "react"; + +import { ChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; +import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; + +export function useMatrixClient() { + const { canAccessChat, userSettings } = useChat(); + const chatContext = useContext(ChatClientContext); + + const isChatEnabled = + canAccessChat && userSettings.chatUsageEnabled && chatContext?.matrixClient; + + return isChatEnabled ? chatContext.matrixClient : null; +} diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomMessages.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomMessages.tsx index 207169aece0c567dca0488729ba97f92748b4ad9..ba6639c1c6aad40d4b8b090122efd286c3755d86 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomMessages.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomMessages.tsx @@ -18,6 +18,7 @@ import { ClientState } from "@/lib/businessModules/chat/shared/enums"; import { useChatSearchParams } from "@/lib/businessModules/chat/shared/hooks/useChatSearchParams"; import { Message, + ReadConfirmationsPerUser, RoomEventDetails, isChatMessageType, isMessageTypeWithBody, @@ -30,6 +31,7 @@ export function useRoomMessages() { const { matrixClient, clientState } = useChatClientContext(); const messagesLimit = 10; const [isLoading, setIsLoading] = useState(false); + const loggedInUserId = matrixClient.getUserId(); async function handleSendMessage(text: string, mentionedUsers?: string[]) { try { @@ -169,7 +171,23 @@ export function useRoomMessages() { ) { return; } - return await onMessage({ event, room, removed: false }); + const readReceipts = room.getReceiptsForEvent(event); + const message = await onMessage({ event, room, removed: false }); + const readReceiptsObj = + readReceipts?.reduce<ReadConfirmationsPerUser>( + (acc, { userId, data }) => { + if (userId === loggedInUserId) return acc; + return { + ...acc, + [userId]: { + timestamp: data.ts, + eventId: event.event.event_id ?? "", + }, + }; + }, + {}, + ); + return { ...message, readReceipts: readReceiptsObj }; }), ); @@ -203,7 +221,7 @@ export function useRoomMessages() { setIsLoading(false); } }, - [matrixClient, onMessage], + [loggedInUserId, matrixClient, onMessage], ); async function paginateMessages() { diff --git a/employee-portal/src/lib/businessModules/chat/shared/types.ts b/employee-portal/src/lib/businessModules/chat/shared/types.ts index a378b3b4208400239594bde9bf27e5530bcec789..cdf1c37f2aef4826bb644e5c0a533cb853fc092c 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/types.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/types.ts @@ -29,6 +29,7 @@ export interface Message { sender: User | null; roomId: string; mentions?: string[]; + readReceipts?: ReadConfirmationsPerUser; } export function isChatMessageType(data: unknown): data is Message { diff --git a/employee-portal/src/lib/businessModules/chat/shared/utils.ts b/employee-portal/src/lib/businessModules/chat/shared/utils.ts index 4fc7dc5d3ae62ce01c2cd59907539cc5ad2f08ea..7207406c5a54d36e58758abd98160f3fd695d474 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/utils.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/utils.ts @@ -29,6 +29,7 @@ import { isEmpty, isStrictEqual, isString, last } from "remeda"; import { CommunicationType } from "@/lib/businessModules/chat/shared/enums"; import { + Message, Presence, ReadConfirmationsPerUser, RoomLastMessage, @@ -98,16 +99,6 @@ export function getDirectMessageMember(room: RoomWithCommunicationType) { return room.room.getAvatarFallbackMember(); } -export function getDMMemberInfo( - room: Room, - communicationType: CommunicationType, -) { - if (communicationType === CommunicationType.PublicRoom) { - return; - } - return room.getAvatarFallbackMember(); -} - export function formatUserReceipts( userReceipts: ReadConfirmationsPerUser | undefined, ): Record<string, string[]> | undefined { @@ -315,14 +306,14 @@ export async function getRoomLastMessage( } } -export function convertMessageTimestamp(timestamp?: Date | null) { +export function formatChatDate(timestamp?: Date | null) { if (!timestamp) return ""; if (isToday(timestamp)) { - return format(timestamp, "hh:mm"); + return format(timestamp, "HH:mm"); } - return format(timestamp, "MM/dd"); + return formatDateForChat(timestamp); } export function getDayLabel(date: Date): string { @@ -438,3 +429,15 @@ export async function leaveRoom(matrixClient: MatrixClient, roomId?: string) { await matrixClient.leave(roomId); } catch {} } + +export function allMessagesRead( + matrixClient: MatrixClient, + newMessages: Message[], +) { + newMessages.forEach((message) => { + void markAllMessagesAsRead({ + roomId: message.roomId, + matrixClient: matrixClient, + }); + }); +} diff --git a/employee-portal/src/lib/businessModules/inspection/api/mutations/checklistDefinition.ts b/employee-portal/src/lib/businessModules/inspection/api/mutations/checklistDefinition.ts index 75e5e44c2c23abb0ec2c9646671e17aa0a20a683..30e3cd4ba41327f4e9b4767105a5995d5b3c85ec 100644 --- a/employee-portal/src/lib/businessModules/inspection/api/mutations/checklistDefinition.ts +++ b/employee-portal/src/lib/businessModules/inspection/api/mutations/checklistDefinition.ts @@ -35,6 +35,7 @@ export interface FormChecklistDefinitionVersion { export function useCreateChecklistDefinition() { const checklistDefinitionApi = useChecklistDefinitionApi(); + const snackbar = useSnackbar(); return useHandledMutation({ mutationFn: async (cldVersion: FormChecklistDefinitionVersion) => { const createdChecklist: ApiCreateNewChecklistDefinitionRequest = { @@ -52,11 +53,15 @@ export function useCreateChecklistDefinition() { createdChecklist, ); }, + onSuccess: () => { + snackbar.confirmation("Checkliste erfolgreich erstellt."); + }, }); } export function useAddChecklistDefinitionVersion() { const checklistDefinitionApi = useChecklistDefinitionApi(); + const snackbar = useSnackbar(); return useHandledMutation({ mutationFn: async ({ defId, @@ -81,11 +86,15 @@ export function useAddChecklistDefinitionVersion() { createdChecklist, ); }, + onSuccess: () => { + snackbar.confirmation("Checkliste erfolgreich aktualisiert"); + }, }); } export function useEditDraftChecklistDefinitionVersion() { const checklistDefinitionApi = useChecklistDefinitionApi(); + const snackbar = useSnackbar(); return useHandledMutation({ mutationFn: async ({ versionId, @@ -110,6 +119,9 @@ export function useEditDraftChecklistDefinitionVersion() { request, ); }, + onSuccess: () => { + snackbar.confirmation("Checkliste erfolgreich aktualisiert"); + }, }); } diff --git a/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/EditChecklistDefinition.tsx b/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/EditChecklistDefinition.tsx index d065af99b16fda0de8372866d246d8d8a2f2c50c..c1405be42f1c83e5c9d72111012a452f022644a1 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/EditChecklistDefinition.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/EditChecklistDefinition.tsx @@ -5,6 +5,7 @@ "use client"; +import { ApiUserRole } from "@eshg/employee-portal-api/base"; import { ApiChecklistDefinitionVersion } from "@eshg/employee-portal-api/inspection"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { Stack } from "@mui/joy"; @@ -20,8 +21,13 @@ import { useEditDraftChecklistDefinitionVersion, } from "@/lib/businessModules/inspection/api/mutations/checklistDefinition"; import { ChecklistDefinitionHeaderCard } from "@/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderCard"; -import { ChecklistDefinitionHeaderRow } from "@/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderRow"; +import { + ChecklistDefinitionHeaderRow, + ChecklistDefinitionSubmitButtons, +} from "@/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderRow"; import { routes } from "@/lib/businessModules/inspection/shared/routes"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; +import { useHasUserRolesCheck } from "@/lib/shared/hooks/useAccessControl"; import { ChecklistDefinitionSectionsList } from "./sections/ChecklistDefinitionSectionsList"; @@ -42,6 +48,10 @@ export function EditChecklistDefinition({ const { mutateAsync: addCldVersion } = useAddChecklistDefinitionVersion(); const { mutateAsync: editDraftCldVersion } = useEditDraftChecklistDefinitionVersion(); + const [canEditChecklists, canEditCoreChecklists] = useHasUserRolesCheck([ + ApiUserRole.InspectionChecklistdefinitionsWrite, + ApiUserRole.InspectionCorechecklistdefinitionsEdit, + ]); const hasDraft = cldVersion?.hasDraft ?? false; const isNewestVersion = @@ -49,6 +59,9 @@ export function EditChecklistDefinition({ (cldVersion?.context.validTo === undefined && cldVersion?.context.published === true); const readOnlyMode = readonly ?? !isNewestVersion; + const canSeeSaveActions = + canEditChecklists && + (canEditCoreChecklists || !cldVersion?.isCoreChecklist); const formData: FormChecklistDefinitionVersion = useMemo( () => @@ -136,6 +149,16 @@ export function EditChecklistDefinition({ version={cldVersion?.context.version} /> <ChecklistDefinitionSectionsList readOnlyMode={readOnlyMode} /> + {canSeeSaveActions && !readOnlyMode && ( + <ButtonBar + right={ + <ChecklistDefinitionSubmitButtons + isSubmitting={isSubmitting} + onPublish={(shouldPublish) => (publish = shouldPublish)} + /> + } + /> + )} </Stack> </FormPlus> )} diff --git a/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderRow.tsx b/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderRow.tsx index f0f7bb112b725788088f41118b1396dcf1276622..5e4ba235dccb23cb15e6e784f9cb385d49f98697 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderRow.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/header/ChecklistDefinitionHeaderRow.tsx @@ -88,23 +88,39 @@ export function ChecklistDefinitionHeaderRow({ alignItems="center" justifyContent={"space-between"} > - <SubmitButton - key="draft" - variant="outlined" - submitting={isSubmitting} - onClick={() => onPublish(false)} - > - Als Entwurf speichern - </SubmitButton> - <SubmitButton - key="publish" - submitting={isSubmitting} - onClick={() => onPublish(true)} - > - Checkliste veröffentlichen - </SubmitButton> + <ChecklistDefinitionSubmitButtons + isSubmitting={isSubmitting} + onPublish={onPublish} + /> </Stack> ))} </Stack> ); } + +export function ChecklistDefinitionSubmitButtons({ + isSubmitting = false, + onPublish, +}: Readonly< + Pick<ChecklistDefinitionHeaderRowProps, "isSubmitting" | "onPublish"> +>) { + return ( + <> + <SubmitButton + key="draft" + variant="outlined" + submitting={isSubmitting} + onClick={() => onPublish(false)} + > + Als Entwurf speichern + </SubmitButton> + <SubmitButton + key="publish" + submitting={isSubmitting} + onClick={() => onPublish(true)} + > + Checkliste veröffentlichen + </SubmitButton> + </> + ); +} diff --git a/employee-portal/src/lib/businessModules/schoolEntry/api/models/Anamnesis.ts b/employee-portal/src/lib/businessModules/schoolEntry/api/models/Anamnesis.ts index bf08c6f239033eb151836bc61868ebb45e90be35..ab9b0cc9aaa03e66e7dfce9e9462e7a60078cf66 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/api/models/Anamnesis.ts +++ b/employee-portal/src/lib/businessModules/schoolEntry/api/models/Anamnesis.ts @@ -53,5 +53,6 @@ export function mapAnamnesis(response: ApiAnamnesis): Anamnesis { promotionTherapyAndAidInfo: response.promotionTherapyAndAidInfo, interestsAndSportsInfo: response.interestsAndSportsInfo, migrationBackground: response.migrationBackground, + personalConspicuities: response.personalConspicuities, }; } diff --git a/employee-portal/src/lib/businessModules/schoolEntry/api/queries/configApi.ts b/employee-portal/src/lib/businessModules/schoolEntry/api/queries/configApi.ts index e8a9bd01f19a18f12c5d764f65875acc2d185f42..4b529e0ae8974898a1d8300bc3e86a3a9a4619a7 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/api/queries/configApi.ts +++ b/employee-portal/src/lib/businessModules/schoolEntry/api/queries/configApi.ts @@ -19,6 +19,26 @@ export function useGetLocationSelectionMode() { return locationSelectionMode; } +export function useIsDirectProcedureTypeAssignmentOnImport() { + const configApi = useConfigApi(); + const { data: isDirectProcedureTypeAssignmentOnImport } = useSuspenseQuery( + getIsDirectProcedureTypeAssignmentOnImportQuery(configApi), + ); + return isDirectProcedureTypeAssignmentOnImport; +} + +export function getIsDirectProcedureTypeAssignmentOnImportQuery( + configApi: SchoolEntryConfigApi, +) { + return queryOptions({ + queryKey: configApiQueryKey(["getConfig"]), + queryFn: () => configApi.getConfig(), + select: (response) => response.isDirectProcedureTypeAssignmentOnImport, + staleTime: CACHE_DURATION_1DAY, + gcTime: CACHE_DURATION_1DAY, + }); +} + export function getLocationSelectionModeQuery(configApi: SchoolEntryConfigApi) { return queryOptions({ queryKey: configApiQueryKey(["getConfig"]), diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/labels/LabelChip.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/labels/LabelChip.tsx index 379aa63591afdaf5f59a41f83d08458d55a10e88..718ff4b0c8d3661d73033a29234cb5a835da03aa 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/labels/LabelChip.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/labels/LabelChip.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Chip } from "@mui/joy"; +import { Chip, Tooltip } from "@mui/joy"; import { Label } from "@/lib/businessModules/schoolEntry/api/models/Label"; @@ -36,14 +36,17 @@ function c(color: number) { export function LabelChip(props: Props) { return ( - <Chip - variant="solid" - sx={{ - backgroundColor: props.label.hexColor, - color: contrastColor(props.label.hexColor), - }} - > - {props.label.name} - </Chip> + <Tooltip title={props.label.name} size="sm" placement="right"> + <Chip + variant="solid" + sx={{ + backgroundColor: props.label.hexColor, + color: contrastColor(props.label.hexColor), + maxWidth: "100%", + }} + > + {props.label.name} + </Chip> + </Tooltip> ); } diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/anamnesis/InterestsAndSportInfoForm.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/anamnesis/InterestsAndSportInfoForm.tsx index 5ab62f85fdae8446ceb0f0ba1d9938df870ed9bf..1b200ad9905cac40199ca68a20877233a0eb48a2 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/anamnesis/InterestsAndSportInfoForm.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/anamnesis/InterestsAndSportInfoForm.tsx @@ -36,12 +36,11 @@ export function InterestAndSportsInfoForm() { sx={BOOLEAN_SELECT_STYLE} allowDeselection /> - <BooleanSelectField + <InputField name={interestAndSportsInfo("clubSport")} label="Sport im Verein" + type="text" component={HorizontalField} - sx={BOOLEAN_SELECT_STYLE} - allowDeselection /> <InputField name={interestAndSportsInfo("otherInterests")} diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataFields.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataFields.tsx index 8a18aafb39c23cf3cdf082125636dc65de9d447b..41a24d463d2281763795be144fd248dc429a5619 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataFields.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataFields.tsx @@ -27,6 +27,7 @@ interface ImportDataFieldsProps { listType: ImportListType; requireSchoolYear: boolean; locationSelectionMode: ApiLocationSelectionMode; + isDirectProcedureTypeAssignmentOnImport: boolean; setFieldValue: SetFieldValueHelper; } @@ -67,14 +68,16 @@ export function ImportDataFields(props: ImportDataFieldsProps) { return ( <Stack gap={4}> - <RadioGroupField - name="listType" - orientation="horizontal" - onChange={handleChangeListType} - > - <Radio value={ImportListType.SchoolList} label="Schulliste" /> - <Radio value={ImportListType.CitizenList} label="Bürgeramtsliste" /> - </RadioGroupField> + {!props.isDirectProcedureTypeAssignmentOnImport && ( + <RadioGroupField + name="listType" + orientation="horizontal" + onChange={handleChangeListType} + > + <Radio value={ImportListType.SchoolList} label="Schulliste" /> + <Radio value={ImportListType.CitizenList} label="Bürgeramtsliste" /> + </RadioGroupField> + )} {props.listType === ImportListType.SchoolList && ( <Stack gap={1}> <SearchContactField diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataSidebar.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataSidebar.tsx index 6d4c7f70f62380f63624433b73fd59f0f149d60f..0897b3b6b4e5f23a11a000d4284c6c8da7b7e78f 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataSidebar.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportDataSidebar.tsx @@ -11,7 +11,10 @@ import { Formik } from "formik"; import { useRouter } from "next/navigation"; import { useImportData } from "@/lib/businessModules/schoolEntry/api/mutations/schoolEntryApi"; -import { useGetLocationSelectionMode } from "@/lib/businessModules/schoolEntry/api/queries/configApi"; +import { + useGetLocationSelectionMode, + useIsDirectProcedureTypeAssignmentOnImport, +} from "@/lib/businessModules/schoolEntry/api/queries/configApi"; import { useIsNewFeatureEnabled } from "@/lib/businessModules/schoolEntry/api/queries/featureTogglesApi"; import { ImportListType } from "@/lib/businessModules/schoolEntry/features/procedures/importData/importTypes"; import { routes } from "@/lib/businessModules/schoolEntry/shared/routes"; @@ -46,6 +49,8 @@ export function ImportDataSidebar() { ApiSchoolEntryFeature.SchoolYear, ); const locationSelectionMode = useGetLocationSelectionMode(); + const isDirectProcedureTypeAssignmentOnImport = + useIsDirectProcedureTypeAssignmentOnImport(); const importData = useImportData(isSchoolYearEnabled); async function handleSubmit(values: ImportDataValues) { @@ -69,12 +74,18 @@ export function ImportDataSidebar() { <ImportResult file={importData.data.file} statistics={importData.data.statistics} + isDirectProcedureTypeAssignmentOnImport={ + isDirectProcedureTypeAssignmentOnImport + } /> ) : ( <ImportDataFields listType={values.listType} requireSchoolYear={isSchoolYearEnabled} locationSelectionMode={locationSelectionMode} + isDirectProcedureTypeAssignmentOnImport={ + isDirectProcedureTypeAssignmentOnImport + } setFieldValue={setFieldValue} /> )} diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResult.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResult.tsx index 806920cef6c58052cbe4333892d49698fbe71538..a884d404f002d036c7f1d7c61fb4f4bcb9e74562 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResult.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResult.tsx @@ -67,6 +67,7 @@ function getStatusHeading( interface ImportResultProps { file: File; statistics: ApiImportStatistics; + isDirectProcedureTypeAssignmentOnImport: boolean; } export function ImportResult(props: ImportResultProps) { @@ -83,7 +84,12 @@ export function ImportResult(props: ImportResultProps) { {getStatusHeading(props.statistics, isMergeEnabled)} </Typography> {isMergeEnabled && ( - <ImportResultProceduresSummary result={props.statistics} /> + <ImportResultProceduresSummary + result={props.statistics} + isDirectProcedureTypeAssignmentOnImport={ + props.isDirectProcedureTypeAssignmentOnImport + } + /> )} </Stack> {props.statistics.total > 0 && ( diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResultProceduresSummary.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResultProceduresSummary.tsx index 6a04e6a6665ec8218e3f725e7bf73c58979346a6..c612452dd8c873b49a6a2f01eacb1e2e712e6d76 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResultProceduresSummary.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/importData/ImportResultProceduresSummary.tsx @@ -13,6 +13,7 @@ interface ImportResultProcedures { interface ImportResultProceduresSummaryProps { result: ImportResultProcedures; + isDirectProcedureTypeAssignmentOnImport: boolean; } interface SummaryItemProps { @@ -43,6 +44,13 @@ function SummaryItem(props: SummaryItemProps) { export function ImportResultProceduresSummary( props: ImportResultProceduresSummaryProps, ) { + const createdOrMerged = props.isDirectProcedureTypeAssignmentOnImport + ? "angelegt" + : "angelegt oder zusammengeführt"; + const mergeFailedMessage = props.isDirectProcedureTypeAssignmentOnImport + ? "nicht angelegt wegen Duplikaten im Bestand" + : "konnten nicht zusammengeführt werden"; + return ( <Stack gap={1}> {props.result.created > 0 && ( @@ -58,14 +66,11 @@ export function ImportResultProceduresSummary( /> )} {props.result.created == 0 && props.result.merged == 0 && ( - <SummaryItem - content="0 angelegt oder zusammengeführt" - color="primary" - /> + <SummaryItem content={`0 ${createdOrMerged}`} color="primary" /> )} {props.result.mergeFailed > 0 && ( <SummaryItem - content={`${props.result.mergeFailed} konnten nicht zusammengeführt werden`} + content={`${props.result.mergeFailed} ${mergeFailedMessage}`} color="danger" /> )} diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelAutocomplete.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelAutocomplete.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a246e519474ae727f9a8309ab90403e1dea603f --- /dev/null +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelAutocomplete.tsx @@ -0,0 +1,45 @@ +/** + * Copyright 2024 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Autocomplete, AutocompleteOption } from "@mui/joy"; + +import { Label } from "@/lib/businessModules/schoolEntry/api/models/Label"; +import { useGetLabels } from "@/lib/businessModules/schoolEntry/api/queries/labelApi"; +import { LabelChip } from "@/lib/businessModules/schoolEntry/features/labels/LabelChip"; + +interface LabelAutocompleteProps { + name: string; + value: Label[]; + onChange: (newValue: Label[]) => void; +} + +export function LabelAutocomplete(props: LabelAutocompleteProps) { + const labelsQuery = useGetLabels(); + + return ( + <Autocomplete + name={props.name} + multiple + placeholder="Kennung" + options={labelsQuery.data} + getOptionLabel={(option) => option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={props.value} + onChange={(_, newValue) => { + props.onChange(newValue); + }} + renderOption={(props, label) => ( + <AutocompleteOption {...props} key={label.id}> + <LabelChip label={label} /> + </AutocompleteOption> + )} + renderTags={(tags, props) => + tags.map((label, index) => ( + <LabelChip {...props({ index })} key={label.id} label={label} /> + )) + } + /> + ); +} diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelSelection.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelSelection.tsx index f2fcf07ce71219e85c6771751bbb265dcd122303..3b616a00441a39df795716e584dc29d81a73f306 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelSelection.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelSelection.tsx @@ -7,44 +7,26 @@ import { BaseField, useBaseField, } from "@eshg/lib-portal/components/formFields/BaseField"; -import { Autocomplete, AutocompleteOption } from "@mui/joy"; import { Label } from "@/lib/businessModules/schoolEntry/api/models/Label"; -import { useGetLabels } from "@/lib/businessModules/schoolEntry/api/queries/labelApi"; -import { LabelChip } from "@/lib/businessModules/schoolEntry/features/labels/LabelChip"; +import { LabelAutocomplete } from "@/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelAutocomplete"; interface LabelSelectionProps { onChange: (newValue: Label[]) => void; } export function LabelSelection(props: LabelSelectionProps) { - const labelsQuery = useGetLabels(); const field = useBaseField<Label[]>({ name: "labels" }); return ( <BaseField label="Kennungen"> - <Autocomplete + <LabelAutocomplete name={field.input.name} - multiple - placeholder="Kennung" - options={labelsQuery.data} - getOptionLabel={(option) => option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} value={field.input.value} - onChange={(_, newValue) => { + onChange={(newValue) => { void field.helpers.setValue(newValue); props.onChange(newValue); }} - renderOption={(props, label) => ( - <AutocompleteOption {...props} key={label.id}> - <LabelChip label={label} /> - </AutocompleteOption> - )} - renderTags={(tags, props) => - tags.map((label, index) => ( - <LabelChip {...props({ index })} key={label.id} label={label} /> - )) - } /> </BaseField> ); diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProcedureFilterSettings.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProcedureFilterSettings.tsx index b9a9b924b1e4f35feecdc58d5207a424bf55d320..8b7348bb9c34c47ccc56ad5a9342d93aaccfcfec 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProcedureFilterSettings.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProcedureFilterSettings.tsx @@ -14,10 +14,12 @@ import { toUtcDate, } from "@eshg/lib-portal/helpers/dateTime"; import { FormControl, FormLabel, Input, Select } from "@mui/joy"; -import { isDefined } from "remeda"; +import { isDefined, isEmpty } from "remeda"; +import { Label } from "@/lib/businessModules/schoolEntry/api/models/Label"; import { useIsNewFeatureEnabled } from "@/lib/businessModules/schoolEntry/api/queries/featureTogglesApi"; import { PROCEDURE_TYPE_OPTIONS } from "@/lib/businessModules/schoolEntry/features/procedures/options"; +import { LabelAutocomplete } from "@/lib/businessModules/schoolEntry/features/procedures/procedureDetails/LabelAutocomplete"; import { SearchSchoolFilter } from "@/lib/businessModules/schoolEntry/features/procedures/proceduresTable/SearchSchoolFilter"; import { SchoolYearAutocomplete } from "@/lib/businessModules/schoolEntry/features/procedures/shared/schoolYear"; import { ResetButton } from "@/lib/shared/components/ResetButton"; @@ -36,8 +38,7 @@ export type ProcedureFilters = Pick< | "dayOfAppointmentFilter" | "hasAppointmentFilter" | "schoolYearFilter" - | "labelsFilter" ->; +> & { labelsFilter?: Label[] }; const FILTER_NAMES: Record<keyof ProcedureFilters, string> = { procedureTypeFilter: "Art", @@ -189,6 +190,19 @@ export function ProcedureFilterSettings(props: ProcedureFilterSettingsProps) { <SelectOptions options={PROCEDURE_TYPE_OPTIONS} /> </Select> </FormControl> + <FormControl> + <FormLabel>Kennungen</FormLabel> + <LabelAutocomplete + name="labels" + value={props.filterFormValues.labelsFilter ?? []} + onChange={(newValue) => { + props.setFilterFormValue( + "labelsFilter", + isEmpty(newValue) ? undefined : newValue, + ); + }} + /> + </FormControl> </FilterSettingsContent> </FilterSettingsSheet> ); diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProceduresTable.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProceduresTable.tsx index 6f5f848f65f57be89a3d05ab3605b63321c998d3..7b6fa26486695a2ea5eccdf046c9339e8f50899a 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProceduresTable.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProceduresTable.tsx @@ -106,6 +106,7 @@ export function ProceduresTable(props: ProceduresTableProps) { pageNumber: tableControl.paginationProps.pageNumber, pageSize: tableControl.paginationProps.pageSize, ...filterValues, + labelsFilter: filterValues.labelsFilter?.map((label) => label.id), ...personSearch.searchParams, sortKey: getSortKey(tableControl.tableSorting), sortDirection: getSortDirection(tableControl.tableSorting), diff --git a/employee-portal/src/lib/businessModules/statistics/api/mutations/useUpdateReport.ts b/employee-portal/src/lib/businessModules/statistics/api/mutations/useUpdateReport.ts index 7743b5df98d866729fb3e953c877aabc57bc1188..730d52caf8b1a263288b1977968a52ab6779b015 100644 --- a/employee-portal/src/lib/businessModules/statistics/api/mutations/useUpdateReport.ts +++ b/employee-portal/src/lib/businessModules/statistics/api/mutations/useUpdateReport.ts @@ -20,6 +20,7 @@ export function useUpdateReport(onSuccess: () => void) { props.model.description.trim().length > 0 ? props.model.description.trim() : undefined, + type: "UpdateNameAndDescriptionReportSeriesRequest", }), onSuccess: () => { snackbar.confirmation("Report bearbeitet"); diff --git a/employee-portal/src/lib/businessModules/statistics/api/queries/useGetDetailPageInformation.ts b/employee-portal/src/lib/businessModules/statistics/api/queries/useGetDetailPageInformation.ts index 8b2ab1e19ac8cfcb3ac6ed872ae52a871c3684ce..2bf909ca9794b491b3ef118a1b2f5c6dc23b1cab 100644 --- a/employee-portal/src/lib/businessModules/statistics/api/queries/useGetDetailPageInformation.ts +++ b/employee-portal/src/lib/businessModules/statistics/api/queries/useGetDetailPageInformation.ts @@ -139,7 +139,7 @@ export function mapToStatisticDetailsView( start: result.statisticInfo.timeRangeStart, end: mapTimeRangeEndApiToFrontend(result.statisticInfo.timeRangeEnd), createdAt: result.statisticInfo.createdAt, - createdBy: fullName(result.userDto), + createdBy: fullName(result.user), dataSource: { // We only have one datasource currently. If this changes the data structure changes and thus // this aggregation method has to become more sophisticated. diff --git a/employee-portal/src/lib/businessModules/statistics/api/queries/useGetReportDetails.ts b/employee-portal/src/lib/businessModules/statistics/api/queries/useGetReportDetails.ts index 23b44912e05b236ff412f460a6fc4af363055381..929cddef01f959100a9a16662088028d9c8dd8d9 100644 --- a/employee-portal/src/lib/businessModules/statistics/api/queries/useGetReportDetails.ts +++ b/employee-portal/src/lib/businessModules/statistics/api/queries/useGetReportDetails.ts @@ -21,7 +21,7 @@ import { mapEvaluations } from "./useGetDetailPageInformation"; export function mapToReportDetailsView( response: ApiGetReportDetailPageResponse, ): ReportDetailsView { - const user = response.userDto; + const user = response.userReportSeries; const attributes: FlatAttribute[] = mapTableColumnHeadersToFlatAttributes( response.tableColumnHeaders, ); diff --git a/employee-portal/src/lib/businessModules/statistics/components/reports/ReportDetailsTile.tsx b/employee-portal/src/lib/businessModules/statistics/components/reports/ReportDetailsTile.tsx index 2a18ef3e938368372c134fc157de985e5eb04e01..ca120773b881b473a1c16b251b6d0feeffa7158f 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/reports/ReportDetailsTile.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/reports/ReportDetailsTile.tsx @@ -49,7 +49,7 @@ function getActionItems( seriesId: string, description: string | undefined, updateReport: (report: UpdateReportSidebarReportInfo) => void, - deleteReportWithConfirmation: (reportId: string, name: string) => void, + deleteReportWithConfirmation: (reportId: string) => void, ) { // Uncomment in https://cronn-gmbh.atlassian.net/browse/ISSUE-5001 // const rememberReport = { @@ -81,7 +81,7 @@ function getActionItems( }, { label: "Report löschen", - onClick: () => deleteReportWithConfirmation(seriesId, name), + onClick: () => deleteReportWithConfirmation(seriesId), startDecorator: <Delete />, color: "danger", }, diff --git a/employee-portal/src/lib/businessModules/statistics/components/reports/ReportsOverview.tsx b/employee-portal/src/lib/businessModules/statistics/components/reports/ReportsOverview.tsx index c94093b0250159a4d92513bfac25b6204492200a..ed5c891f140b6adfde16a208d649e878aaf103cc 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/reports/ReportsOverview.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/reports/ReportsOverview.tsx @@ -7,18 +7,17 @@ import { ApiReportType } from "@eshg/employee-portal-api/statistics"; import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; -import { Box, List, ListItem } from "@mui/joy"; +import { Box } from "@mui/joy"; import { startTransition, useState } from "react"; import { translateReportType } from "@/lib/businessModules/statistics/api/mapper/translateReportType"; import { ReportDataType } from "@/lib/businessModules/statistics/api/models/statisticReports"; -import { useDeleteReport } from "@/lib/businessModules/statistics/api/mutations/useDeleteReport"; import { useGetReportsOverview } from "@/lib/businessModules/statistics/api/queries/useGetReportsOverview"; +import { useDeleteReportWithConfirmation } from "@/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation"; import { routes } from "@/lib/businessModules/statistics/shared/routes"; import { NoSearchResults } from "@/lib/shared/components/NoSearchResult"; import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { FilterButton } from "@/lib/shared/components/buttons/FilterButton"; -import { useConfirmationDialog } from "@/lib/shared/components/confirmationDialog/ConfirmationDialogProvider"; import { FilterSettings } from "@/lib/shared/components/filterSettings/FilterSettings"; import { FilterSettingsSheet } from "@/lib/shared/components/filterSettings/FilterSettingsSheet"; import { FilterDefinition } from "@/lib/shared/components/filterSettings/models/FilterDefinition"; @@ -64,8 +63,7 @@ const filterDefinitions: FilterDefinition[] = [ export function ReportsOverview() { const snackbar = useSnackbar(); - const { openConfirmationDialog } = useConfirmationDialog(); - const deleteReport = useDeleteReport(); + const deleteReportWithConfirmation = useDeleteReportWithConfirmation(); const { resetPageNumber, page, pageSize, getPaginationProps } = usePagination(); @@ -105,27 +103,6 @@ export function ReportsOverview() { snackbar.notification("Link in die Zwischenablage kopiert"); } - function handleDeleteReport(id: string) { - openConfirmationDialog({ - color: "danger", - title: "Report löschen?", - description: "Wenn Sie mit dem Löschen fortfahren, wird ...", - children: ( - <List marker="disc"> - <ListItem>der Report unwiderruflich gelöscht,</ListItem> - <ListItem>der Report aus allen Merklisten entfernt,</ListItem> - <ListItem> - eine Nachricht an die Nutzer:innen gesendet, die den Report in ihrer - Merkliste haben. - </ListItem> - </List> - ), - cancelLabel: "Abbrechen", - confirmLabel: "Ja, löschen", - onConfirm: () => deleteReport(id), - }); - } - return ( <TablePage data-testid="statistics-reports-overview-table" @@ -149,7 +126,7 @@ export function ReportsOverview() { wrapHeader columns={getReportsOverviewColumns( handleClickCopyAddress, - handleDeleteReport, + deleteReportWithConfirmation, )} data={reportsOverview.reports} noDataComponent={() => ( diff --git a/employee-portal/src/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation.ts b/employee-portal/src/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation.tsx similarity index 60% rename from employee-portal/src/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation.ts rename to employee-portal/src/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation.tsx index 0485d4998799d8b64315a389c7ccc69382baf8c3..b8c27571b8f9c0745a4d5b144a25ef50d9c5bc14 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation.ts +++ b/employee-portal/src/lib/businessModules/statistics/components/reports/useDeleteReportWithConfirmation.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { List, ListItem } from "@mui/joy"; import { useRouter } from "next/navigation"; import { isDefined } from "remeda"; @@ -24,12 +25,23 @@ export function useDeleteReportWithConfirmation({ }, }); - function deleteReportWithConfirmation(seriesId: string, reportName: string) { + function deleteReportWithConfirmation(seriesId: string) { openConfirmationDialog({ - title: "Report löschen?", - description: `Der Report "${reportName}" wird dann unwiderruflich gelöscht.`, - confirmLabel: "Report löschen", color: "danger", + title: "Report löschen?", + description: "Wenn Sie mit dem Löschen fortfahren, wird ...", + children: ( + <List marker="disc"> + <ListItem>der Report unwiderruflich gelöscht,</ListItem> + <ListItem>der Report aus allen Merklisten entfernt,</ListItem> + <ListItem> + eine Nachricht an die Nutzer:innen gesendet, die den Report in ihrer + Merkliste haben. + </ListItem> + </List> + ), + cancelLabel: "Abbrechen", + confirmLabel: "Ja, löschen", onConfirm: () => { deleteReport(seriesId); }, diff --git a/employee-portal/src/lib/businessModules/statistics/components/shared/EvaluationAccordion/EvaluationSortOrderSelect.tsx b/employee-portal/src/lib/businessModules/statistics/components/shared/EvaluationAccordion/EvaluationSortOrderSelect.tsx index db492ecab0ac92a12b422e3be65fc53a9c111067..dc8ca535e79a327a32894c5b8bbe58523f828bc3 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/shared/EvaluationAccordion/EvaluationSortOrderSelect.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/shared/EvaluationAccordion/EvaluationSortOrderSelect.tsx @@ -62,7 +62,12 @@ export function EvaluationSortOrderSelect( }} color="primary" value={props.sortOrder} - onChange={(_, value) => props.onSortOrderChange(value!)} + onChange={(_, value) => { + if (value === null) { + return; + } + props.onSortOrderChange(value); + }} > <SelectOptions options={buildEnumOptions(evaluationSortOrderOptions)} /> </Select> diff --git a/employee-portal/src/lib/businessModules/statistics/components/statistics/details/reports/StatisticReports.tsx b/employee-portal/src/lib/businessModules/statistics/components/statistics/details/reports/StatisticReports.tsx index e9eace62baf13b26b0edf797c2cd43762f655567..98125424e8928542d718ee3ca799cb3a690fbf40 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/statistics/details/reports/StatisticReports.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/statistics/details/reports/StatisticReports.tsx @@ -49,7 +49,7 @@ const meta = { }; function columns( - deleteReportWithConfirmation: (reportId: string, name: string) => void, + deleteReportWithConfirmation: (reportId: string) => void, editReport: (report: SingleReport) => void, ) { return [ @@ -104,10 +104,7 @@ function columns( { label: "Löschen", onClick: () => - deleteReportWithConfirmation( - props.row.original.seriesId, - props.row.original.name, - ), + deleteReportWithConfirmation(props.row.original.seriesId), startDecorator: <Delete />, color: "danger", }, diff --git a/employee-portal/src/lib/shared/components/chat/MessageTeaserProvider.tsx b/employee-portal/src/lib/shared/components/chat/MessageTeaserProvider.tsx index e47ff6f7d3954ed1685a4f6e4fddcfe2ebf9fb80..9ba7533036a022da55beb721366e63ae01e002a2 100644 --- a/employee-portal/src/lib/shared/components/chat/MessageTeaserProvider.tsx +++ b/employee-portal/src/lib/shared/components/chat/MessageTeaserProvider.tsx @@ -29,7 +29,6 @@ import { getStatusColor } from "@/lib/businessModules/chat/shared/utils"; interface SnackbarValues { username: string; - avatar?: string | null; text: string; link: string; userPresence: string; @@ -46,13 +45,13 @@ type SnackbarValuesWithoutKey = Omit<SnackbarValues, "key">; function BaseSnackbar({ snackbar, onClose }: Readonly<BaseSnackbarProps>) { const pathname = usePathname(); const { tryNavigate } = useNavigation(); - const { chatSidebar } = useChat(); + const { chatSidebar, userSettings } = useChat(); useEffect(() => { - if (pathname === routes.index) { + if (pathname === routes.index || chatSidebar.isOpen) { onClose(); } - }, [onClose, pathname]); + }, [onClose, pathname, chatSidebar.isOpen]); return ( <Snackbar @@ -60,7 +59,7 @@ function BaseSnackbar({ snackbar, onClose }: Readonly<BaseSnackbarProps>) { variant="soft" size="md" anchorOrigin={{ vertical: "top", horizontal: "right" }} - autoHideDuration={4000} + autoHideDuration={5000} key={snackbar?.key} onClose={(_event, reason) => { if (reason !== "clickaway") { @@ -96,7 +95,7 @@ function BaseSnackbar({ snackbar, onClose }: Readonly<BaseSnackbarProps>) { alignItems: "center", }} > - {snackbar.userPresence && ( + {userSettings.sharePresence && snackbar.userPresence && ( <Box sx={{ width: "0.625rem", @@ -111,7 +110,6 @@ function BaseSnackbar({ snackbar, onClose }: Readonly<BaseSnackbarProps>) { )} <Typography level="title-md" - fontStyle="Poppins" fontWeight={500} sx={{ fontWeight: "bold", diff --git a/employee-portal/src/lib/shared/helpers/validatePassphrase.tsx b/employee-portal/src/lib/shared/helpers/validatePassphrase.tsx deleted file mode 100644 index 061bc46d056c3f1897578616c80ffa85cfe7f5c6..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/shared/helpers/validatePassphrase.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright 2024 cronn GmbH - * SPDX-License-Identifier: Apache-2.0 - */ - -const minimalPassphraseLength = 6; - -export function validatePassphrase( - passphrase: string, - repeatedPassphrase: string, -): boolean { - return ( - validatePassphraseLength(passphrase) && - validatePassphraseUpperCase(passphrase) && - validatePassphraseLowerCase(passphrase) && - validateSameRepeatedPassphrase(passphrase, repeatedPassphrase) - ); -} - -export interface PassphraseValidityInfo { - message: string; - valid: boolean; -} - -export function getPassphraseValidityInfo( - passphrase: string, - repeatedPassphrase: string, -): PassphraseValidityInfo[] { - const isPasswortLengthValid = validatePassphraseLength(passphrase); - const hasPassphraseUpperCaseLetter = validatePassphraseUpperCase(passphrase); - const hasPassphraseLowerCaseLetter = validatePassphraseLowerCase(passphrase); - const isSameRepeatedPassphrase = validateSameRepeatedPassphrase( - passphrase, - repeatedPassphrase, - ); - - const result = [ - { - message: `Mindestens ${minimalPassphraseLength} Zeichen lang`, - valid: isPasswortLengthValid, - }, - ]; - result.push({ - message: "Mindestens ein Großbuchstabe", - valid: hasPassphraseUpperCaseLetter, - }); - result.push({ - message: "Mindestens ein Kleinbuchstabe", - valid: hasPassphraseLowerCaseLetter, - }); - result.push({ - message: "Muss mit der Wiederholung übereinstimmen", - valid: isSameRepeatedPassphrase, - }); - return result; -} - -function validatePassphraseLength(passphrase: string): boolean { - return passphrase.length >= minimalPassphraseLength; -} - -function validatePassphraseUpperCase(passphrase: string): boolean { - return passphrase.toLowerCase() !== passphrase; -} - -function validatePassphraseLowerCase(passphrase: string): boolean { - return passphrase.toUpperCase() !== passphrase; -} - -function validateSameRepeatedPassphrase( - passphrase: string, - repeatedPassphrase: string, -): boolean { - return passphrase === repeatedPassphrase && passphrase.length !== 0; -} diff --git a/reverse-proxy/auth_api_request.conf b/reverse-proxy/auth_api_request.conf index 314eb661bfa570e8dd8a4f942f224dd6d41c0f17..d8438c6d9d0aae316c21f36b7c2f7ac6d4d38464 100644 --- a/reverse-proxy/auth_api_request.conf +++ b/reverse-proxy/auth_api_request.conf @@ -2,4 +2,5 @@ include auth_request.conf; auth_request_set $resolved_authorization $upstream_http_authorization; -proxy_set_header Authorization $resolved_authorization; +proxy_set_header Authorization $resolved_authorization; +proxy_set_header X-Correlation-ID $request_id; diff --git a/reverse-proxy/citizen-portal.conf b/reverse-proxy/citizen-portal.conf index fb70cebd76a43ee0ffba374ba5659e14e1bdf8a2..97fb38583be6c82b1be208ea0737626316b53cd3 100644 --- a/reverse-proxy/citizen-portal.conf +++ b/reverse-proxy/citizen-portal.conf @@ -128,6 +128,7 @@ server { proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Correlation-ID $request_id; proxy_intercept_errors on; error_page 302 = @rewrite_302_to_401; diff --git a/reverse-proxy/employee-portal.conf b/reverse-proxy/employee-portal.conf index 154852e98a74f93ff951ccda1eb2896fe91d3bbc..96b4f9c11f2e46e3418e4fc0d0fc7d49979ea153 100644 --- a/reverse-proxy/employee-portal.conf +++ b/reverse-proxy/employee-portal.conf @@ -137,6 +137,7 @@ server { proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Correlation-ID $request_id; proxy_intercept_errors on; error_page 302 = @rewrite_302_to_401;